diff --git a/.changeset/gateway-eve-version-ua.md b/.changeset/gateway-eve-version-ua.md new file mode 100644 index 000000000..83a936bb3 --- /dev/null +++ b/.changeset/gateway-eve-version-ua.md @@ -0,0 +1,9 @@ +--- +"eve": patch +--- + +Send a versioned `User-Agent` on AI Gateway model calls. eve now sets a leading +`eve/ ()` product token, and the AI SDK appends its own +`ai/ ai-sdk/provider-utils/ runtime/node.js/` tokens +after it, so gateway traffic is attributable to both the eve and SDK versions +alongside the existing `x-title` and `http-referer` attribution headers. diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index 9396dbb2f..b725c99f2 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -8019,6 +8019,7 @@ describe("createToolLoopHarness", () => { const agentCall = vi.mocked(ToolLoopAgent).mock.calls[0]?.[0]; expect(agentCall?.headers).toEqual({ + "user-agent": "eve/0.0.0 (Weather Agent)", "x-title": "Weather Agent", "http-referer": "https://my-agent.vercel.app", }); @@ -8031,6 +8032,39 @@ describe("createToolLoopHarness", () => { } }); + it("sets a versioned user-agent suffixed with the agent name", async () => { + setupStopResultForAttribution(); + const originalProductionUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL; + delete process.env.VERCEL_PROJECT_PRODUCTION_URL; + const originalUrl = process.env.VERCEL_URL; + delete process.env.VERCEL_URL; + try { + const config: ToolLoopHarnessConfig = { + mode: "conversation", + resolveModel: vi.fn().mockResolvedValue("anthropic/claude-sonnet-4-5"), + runtimeIdentity: { + agentId: "weather-agent", + agentName: "Weather Agent", + eveVersion: "9.9.9", + modelId: "anthropic/claude-sonnet-4-5", + }, + tools: new Map(), + }; + const runStep = createToolLoopHarness(config); + await runStep(createTestSession(), { message: "hi" }); + + const agentCall = vi.mocked(ToolLoopAgent).mock.calls[0]?.[0]; + expect(agentCall?.headers?.["user-agent"]).toBe("eve/9.9.9 (Weather Agent)"); + } finally { + if (originalProductionUrl !== undefined) { + process.env.VERCEL_PROJECT_PRODUCTION_URL = originalProductionUrl; + } + if (originalUrl !== undefined) { + process.env.VERCEL_URL = originalUrl; + } + } + }); + it("falls back to agentId when agentName is not set", async () => { setupStopResultForAttribution(); const originalProductionUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL; @@ -8053,6 +8087,7 @@ describe("createToolLoopHarness", () => { const agentCall = vi.mocked(ToolLoopAgent).mock.calls[0]?.[0]; expect(agentCall?.headers).toEqual({ + "user-agent": "eve/0.0.0 (weather-agent)", "x-title": "weather-agent", }); } finally { @@ -8088,6 +8123,7 @@ describe("createToolLoopHarness", () => { const agentCall = vi.mocked(ToolLoopAgent).mock.calls[0]?.[0]; expect(agentCall?.headers).toEqual({ + "user-agent": "eve/0.0.0 (My Agent)", "x-title": "My Agent", "http-referer": "https://preview-123.vercel.app", }); @@ -8183,6 +8219,7 @@ describe("createToolLoopHarness", () => { const compactCall = vi.mocked(compactMessages).mock.calls[0]; expect(compactCall?.[5]).toEqual({ + "user-agent": "eve/0.0.0 (Weather Agent)", "x-title": "Weather Agent", "http-referer": "https://my-agent.vercel.app", }); diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index 0b4e30606..17ab1cf9f 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -19,6 +19,7 @@ import { } from "ai"; import { isScheduleAppAuth } from "#channel/schedule-auth.js"; import { resolveInstalledPackageInfo } from "#internal/application/package.js"; +import { EVE_PACKAGE_NAME } from "#internal/package-name.js"; import { createErrorId, createLogger, @@ -263,8 +264,15 @@ function enrichTelemetry( * Builds AI Gateway app attribution headers when the model is gateway-routed. * * Gateway routing is detected by `typeof model === "string"` — the same - * condition used for the `gateway-auto` cache path. Returns `undefined` - * for non-gateway models or when no meaningful attribution is available. + * condition used for the `gateway-auto` cache path. Sets a leading + * `user-agent` product token (`eve/ ()`); the AI SDK then + * appends its own `ai/ ai-sdk/provider-utils/ runtime/node.js/` + * tokens after it (via `withUserAgentSuffix`), so the gateway sees both the + * eve and SDK versions without eve reconstructing the SDK's. The agent name + * rides in a UA comment so a spaced display name stays a valid User-Agent. + * Also sets the OpenRouter-style `x-title` (agent name) and `http-referer` + * (deployment host). Returns `undefined` for non-gateway models or when no + * meaningful attribution is available. */ function buildGatewayAttributionHeaders( model: LanguageModel, @@ -274,18 +282,20 @@ function buildGatewayAttributionHeaders( return undefined; } + const version = runtimeIdentity?.eveVersion; const title = runtimeIdentity?.agentName ?? runtimeIdentity?.agentId; const deploymentHost = process.env.VERCEL_PROJECT_PRODUCTION_URL || process.env.VERCEL_URL; const referer = deploymentHost ? `https://${deploymentHost}` : undefined; - if (!title && !referer) { - return undefined; - } - const headers: Record = {}; + if (version) { + headers["user-agent"] = title + ? `${EVE_PACKAGE_NAME}/${version} (${title})` + : `${EVE_PACKAGE_NAME}/${version}`; + } if (title) headers["x-title"] = title; if (referer) headers["http-referer"] = referer; - return headers; + return Object.keys(headers).length > 0 ? headers : undefined; } async function resolveActiveRuntimeModel(input: {