diff --git a/.changeset/eve-vercel-service-user-agent.md b/.changeset/eve-vercel-service-user-agent.md new file mode 100644 index 000000000..6c5dbfbbb --- /dev/null +++ b/.changeset/eve-vercel-service-user-agent.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Identify Eve and its version on AI Gateway, Sandbox, and Workflow service requests. diff --git a/packages/eve/src/compiler/model-catalog.test.ts b/packages/eve/src/compiler/model-catalog.test.ts index b71aa2ec1..3ddd6f1fc 100644 --- a/packages/eve/src/compiler/model-catalog.test.ts +++ b/packages/eve/src/compiler/model-catalog.test.ts @@ -165,6 +165,17 @@ describe("createCompiledRuntimeModelCatalogLoader", () => { expect(limits).toEqual({ contextWindowTokens: 200_000, maxOutputTokens: 32_000 }); }); + it("identifies eve when fetching the AI Gateway model catalog", async () => { + mockCatalogFetch(); + const loader = createCompiledRuntimeModelCatalogLoader("/tmp/test-app"); + + await loader.getModelLimits("anthropic/claude-opus-4.7"); + + const [url, init] = vi.mocked(fetch).mock.calls[0] ?? []; + expect(url).toBe("https://ai-gateway.vercel.sh/v1/models/catalog"); + expect(new Headers(init?.headers).get("user-agent")).toMatch(/^eve\/.+/); + }); + it("returns null for unknown slug", async () => { mockCatalogFetch(); const loader = createCompiledRuntimeModelCatalogLoader("/tmp/test-app"); diff --git a/packages/eve/src/compiler/model-catalog.ts b/packages/eve/src/compiler/model-catalog.ts index 8960d595c..cccc4f87f 100644 --- a/packages/eve/src/compiler/model-catalog.ts +++ b/packages/eve/src/compiler/model-catalog.ts @@ -2,8 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { z } from "#compiled/zod/index.js"; - -const AI_GATEWAY_MODELS_CATALOG_URL = "https://ai-gateway.vercel.sh/v1/models/catalog"; +import { AI_GATEWAY_MODELS_CATALOG_URL, vercelGatewayFetch } from "#internal/gateway.js"; const COMPILED_RUNTIME_MODEL_CATALOG_CACHE_KIND = "eve-model-catalog-cache"; const COMPILED_RUNTIME_MODEL_CATALOG_CACHE_VERSION = 2; const COMPILED_RUNTIME_MODEL_CATALOG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -213,7 +212,7 @@ type CompiledRuntimeModelCatalogCache = z.infer { - const response = await fetch(AI_GATEWAY_MODELS_CATALOG_URL); + const response = await vercelGatewayFetch(AI_GATEWAY_MODELS_CATALOG_URL); if (!response.ok) { throw new Error( diff --git a/packages/eve/src/evals/autoevals-client.test.ts b/packages/eve/src/evals/autoevals-client.test.ts new file mode 100644 index 000000000..01477c5e5 --- /dev/null +++ b/packages/eve/src/evals/autoevals-client.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ generateText: vi.fn() })); + +vi.mock("ai", () => ({ + generateText: mocks.generateText, + jsonSchema: vi.fn(), +})); + +vi.mock("autoevals", () => ({ Factuality: vi.fn() })); + +import { createAutoevalsClient } from "#evals/autoevals-client.js"; + +describe("createAutoevalsClient", () => { + it("identifies eve on Gateway-routed judge calls", async () => { + mocks.generateText.mockResolvedValueOnce({ text: "ok", toolCalls: [] }); + const client = createAutoevalsClient({ languageModel: "openai/gpt-5.5" }); + + await client.chat.completions.create({ messages: [], model: "openai/gpt-5.5" }); + + expect(mocks.generateText).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { "user-agent": expect.stringMatching(/^eve\/.+/) }, + }), + ); + }); +}); diff --git a/packages/eve/src/evals/autoevals-client.ts b/packages/eve/src/evals/autoevals-client.ts index 7920f0846..19bbe2107 100644 --- a/packages/eve/src/evals/autoevals-client.ts +++ b/packages/eve/src/evals/autoevals-client.ts @@ -6,6 +6,7 @@ import { type ToolSet, } from "ai"; import { Factuality } from "autoevals"; +import { resolveProviderHeaders } from "#internal/gateway.js"; import { toInputSchema } from "#shared/tool-schema.js"; @@ -69,6 +70,7 @@ async function createChatCompletion( ): Promise<{ readonly choices: readonly unknown[] }> { const tools = convertTools(params.tools); const result = await generateText({ + headers: resolveProviderHeaders(config.languageModel), model: config.languageModel, messages: convertMessages(params.messages ?? []), tools: Object.keys(tools).length > 0 ? tools : undefined, diff --git a/packages/eve/src/execution/sandbox/bindings/vercel-credentials.ts b/packages/eve/src/execution/sandbox/bindings/vercel-credentials.ts index dadbd679a..cc6fd5c40 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel-credentials.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel-credentials.ts @@ -1,11 +1,11 @@ import { getVercelOidcToken } from "#compiled/@vercel/oidc/index.js"; import { decodeVercelOidcTokenClaims } from "#shared/vercel-project.js"; -import { withEveSandboxUserAgent } from "#execution/sandbox/bindings/vercel-user-agent.js"; +import { withPackageUserAgent } from "#internal/user-agent.js"; import type { VercelCreateOptions } from "#execution/sandbox/bindings/vercel-sdk-types.js"; export function getVercelSandboxFetch(createOptions: VercelCreateOptions): typeof globalThis.fetch { const fetchOverride = (createOptions as { readonly fetch?: typeof globalThis.fetch }).fetch; - return withEveSandboxUserAgent(fetchOverride ?? globalThis.fetch); + return withPackageUserAgent(fetchOverride); } export async function getVercelSandboxCredentials( diff --git a/packages/eve/src/execution/sandbox/bindings/vercel-lookup.ts b/packages/eve/src/execution/sandbox/bindings/vercel-lookup.ts index b1a58365b..98fbf8a09 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel-lookup.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel-lookup.ts @@ -35,6 +35,7 @@ async function getVercelSandboxGetOptions(input: { readonly sandboxName: string; }): Promise { const baseOptions = { + fetch: getVercelSandboxFetch(input.createOptions), name: input.sandboxName, resume: false, }; @@ -44,7 +45,6 @@ async function getVercelSandboxGetOptions(input: { return { ...baseOptions, ...credentials, - fetch: getVercelSandboxFetch(input.createOptions), signal: input.createOptions.signal, } as VercelGetOptions; } catch { diff --git a/packages/eve/src/execution/sandbox/bindings/vercel-user-agent.ts b/packages/eve/src/execution/sandbox/bindings/vercel-user-agent.ts deleted file mode 100644 index cbf623022..000000000 --- a/packages/eve/src/execution/sandbox/bindings/vercel-user-agent.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { resolveInstalledPackageInfo } from "#internal/application/package.js"; - -export function eveSandboxUserAgentToken(): string { - const { name, version } = resolveInstalledPackageInfo(); - return `${name}/${version}`; -} - -/** - * Wraps a `fetch` implementation so every request's `user-agent` ends with the - * {@link eveSandboxUserAgentToken} (e.g.: eve/0.18.1). - */ -export function withEveSandboxUserAgent( - inner: typeof globalThis.fetch = globalThis.fetch, -): typeof globalThis.fetch { - const token = eveSandboxUserAgentToken(); - - return (input, init) => { - const headers = new Headers( - init?.headers ?? - (typeof input === "object" && input !== null && "headers" in input - ? (input as Request).headers - : undefined), - ); - const existing = headers.get("user-agent"); - headers.set("user-agent", existing ? `${existing} ${token}` : token); - return inner(input, { ...init, headers }); - }; -} diff --git a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts index fc177ca1b..44ab4b693 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel.test.ts +++ b/packages/eve/src/execution/sandbox/bindings/vercel.test.ts @@ -559,7 +559,11 @@ describe("createVercelSandbox", () => { }); expect(get).toHaveBeenCalledTimes(1); - expect(get).toHaveBeenCalledWith({ name: "session-key", resume: false }); + expect(get).toHaveBeenCalledWith({ + fetch: expect.any(Function), + name: "session-key", + resume: false, + }); expect(create).toHaveBeenCalledTimes(1); expect(create.mock.calls[0]?.[0]).toMatchObject({ name: "session-key", @@ -921,6 +925,7 @@ describe("createVercelSandbox", () => { expect(sandboxModule.Sandbox.create).not.toHaveBeenCalled(); expect(sandboxModule.Sandbox.get).toHaveBeenCalledWith({ + fetch: expect.any(Function), name: "persisted-sandbox-name", resume: false, }); @@ -1005,6 +1010,7 @@ describe("createVercelSandbox", () => { }); expect(sandboxModule.Sandbox.get).toHaveBeenCalledWith({ + fetch: expect.any(Function), name: "deleted-sandbox", resume: false, }); diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index b7b7d2ce9..e7d63a217 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -8436,6 +8436,7 @@ describe("createToolLoopHarness", () => { expect(agentCall?.headers).toEqual({ "x-title": "Weather Agent", "http-referer": "https://my-agent.vercel.app", + "user-agent": expect.stringMatching(/^eve\/.+/), }); } finally { if (originalProductionUrl === undefined) { @@ -8469,6 +8470,7 @@ describe("createToolLoopHarness", () => { const agentCall = vi.mocked(ToolLoopAgent).mock.calls[0]?.[0]; expect(agentCall?.headers).toEqual({ "x-title": "weather-agent", + "user-agent": expect.stringMatching(/^eve\/.+/), }); } finally { if (originalProductionUrl !== undefined) { @@ -8505,6 +8507,7 @@ describe("createToolLoopHarness", () => { expect(agentCall?.headers).toEqual({ "x-title": "My Agent", "http-referer": "https://preview-123.vercel.app", + "user-agent": expect.stringMatching(/^eve\/.+/), }); } finally { if (originalProductionUrl !== undefined) { @@ -8542,7 +8545,28 @@ describe("createToolLoopHarness", () => { expect(agentCall?.headers).toBeUndefined(); }); - it("does not set headers when no runtimeIdentity and no deployment URL", async () => { + it("sets the eve user-agent for explicit Gateway model objects", async () => { + setupStopResultForAttribution(); + const config: ToolLoopHarnessConfig = { + mode: "conversation", + resolveModel: vi.fn().mockResolvedValue( + new MockLanguageModelV3({ + provider: "gateway.language-model", + 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).toEqual({ + "user-agent": expect.stringMatching(/^eve\/.+/), + }); + }); + + it("sets the eve user-agent when no app attribution is available", async () => { setupStopResultForAttribution(); const originalProductionUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL; const originalUrl = process.env.VERCEL_URL; @@ -8558,7 +8582,9 @@ describe("createToolLoopHarness", () => { await runStep(createTestSession(), { message: "hi" }); const agentCall = vi.mocked(ToolLoopAgent).mock.calls[0]?.[0]; - expect(agentCall?.headers).toBeUndefined(); + expect(agentCall?.headers).toEqual({ + "user-agent": expect.stringMatching(/^eve\/.+/), + }); } finally { if (originalProductionUrl !== undefined) { process.env.VERCEL_PROJECT_PRODUCTION_URL = originalProductionUrl; @@ -8569,7 +8595,7 @@ describe("createToolLoopHarness", () => { } }); - it("passes attribution headers to compaction generateText call", async () => { + it("derives compaction attribution from the compaction model", async () => { vi.mocked(shouldCompact).mockReturnValueOnce(true); vi.mocked(compactMessages).mockResolvedValueOnce([ { content: "Summary of our conversation so far:", role: "user" }, @@ -8584,7 +8610,14 @@ describe("createToolLoopHarness", () => { const config: ToolLoopHarnessConfig = { handleEvent: emit, mode: "conversation", - resolveModel: vi.fn().mockResolvedValue("anthropic/claude-sonnet-4-5"), + resolveModel: vi.fn().mockImplementation(async (reference) => + reference.id === "compaction-model" + ? "anthropic/claude-sonnet-4-5" + : new MockLanguageModelV3({ + provider: "anthropic.messages", + modelId: "claude-sonnet-4-5-20250514", + }), + ), runtimeIdentity: { agentId: "weather-agent", agentName: "Weather Agent", @@ -8594,12 +8627,25 @@ describe("createToolLoopHarness", () => { tools: new Map(), }; const runStep = createToolLoopHarness(config); - await runStep(createTestSession(), { message: "hi" }); + await runStep( + createTestSession({ + agent: { + compactionModelReference: { id: "compaction-model" }, + modelReference: { id: "main-model" }, + system: "You are a test assistant.", + tools: [], + }, + }), + { message: "hi" }, + ); + const agentCall = vi.mocked(ToolLoopAgent).mock.calls[0]?.[0]; + expect(agentCall?.headers).toBeUndefined(); const compactCall = vi.mocked(compactMessages).mock.calls[0]; expect(compactCall?.[5]).toEqual({ "x-title": "Weather Agent", "http-referer": "https://my-agent.vercel.app", + "user-agent": expect.stringMatching(/^eve\/.+/), }); } finally { if (originalProductionUrl === undefined) { diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index 0f97d67a0..b5e85c615 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 { resolveProviderHeaders } from "#internal/gateway.js"; import { createErrorId, createLogger, @@ -296,27 +297,21 @@ function mergeSystemInstructions( /** * 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. + * Bare model ids and `gateway.*` model instances route through AI Gateway. + * Direct-provider model instances receive no Gateway-specific headers. */ function buildGatewayAttributionHeaders( model: LanguageModel, runtimeIdentity: ToolLoopHarnessConfig["runtimeIdentity"], ): Record | undefined { - if (typeof model !== "string") { - return undefined; - } + const providerHeaders = resolveProviderHeaders(model); + if (providerHeaders === undefined) return undefined; 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 = {}; + const headers: Record = { ...providerHeaders }; if (title) headers["x-title"] = title; if (referer) headers["http-referer"] = referer; return headers; @@ -687,11 +682,11 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { abortSignal: config.abortSignal, emit, emissionState, - headers: attributionHeaders, messages, model, onCompaction: config.onCompaction, resolveModel: config.resolveModel, + runtimeIdentity: config.runtimeIdentity, session, telemetry: enrichTelemetry(telemetryConfig, agentName) ?? undefined, })); @@ -2403,11 +2398,11 @@ async function maybeCompact(input: { readonly abortSignal?: AbortSignal; readonly emit?: ToolLoopHarnessConfig["handleEvent"]; readonly emissionState: ReturnType; - readonly headers?: Record; readonly messages: ModelMessage[]; readonly model: LanguageModel; readonly onCompaction?: ToolLoopHarnessConfig["onCompaction"]; readonly resolveModel: ToolLoopHarnessConfig["resolveModel"]; + readonly runtimeIdentity?: ToolLoopHarnessConfig["runtimeIdentity"]; readonly session: HarnessSession; readonly telemetry?: TelemetryOptions; }): Promise<{ readonly messages: ModelMessage[]; readonly session: HarnessSession }> { @@ -2444,7 +2439,7 @@ async function maybeCompact(input: { session.compaction, compaction.providerOptions, input.telemetry, - input.headers, + buildGatewayAttributionHeaders(compaction.model, input.runtimeIdentity), input.abortSignal, ); diff --git a/packages/eve/src/internal/application/compiled-artifacts.test.ts b/packages/eve/src/internal/application/compiled-artifacts.test.ts index 071ee09ff..3dff1e8d6 100644 --- a/packages/eve/src/internal/application/compiled-artifacts.test.ts +++ b/packages/eve/src/internal/application/compiled-artifacts.test.ts @@ -41,13 +41,14 @@ describe("createWorkflowWorldPluginSource", () => { }); it("selects the vendored Vercel World with Workflow's selector", () => { - expect( - createWorkflowWorldPluginSource({ - compiledArtifactsBootstrapPath: "/app/.eve/compile/bootstrap.mjs", - configuredWorld: undefined, - defaultWorld: "vercel", - }), - ).toContain("/compiled/@workflow/world-vercel/index.js"); + const source = createWorkflowWorldPluginSource({ + compiledArtifactsBootstrapPath: "/app/.eve/compile/bootstrap.mjs", + configuredWorld: undefined, + defaultWorld: "vercel", + }); + + expect(source).toContain("/compiled/@workflow/world-vercel/index.js"); + expect(source).toMatch(/headers: \{ "User-Agent": "eve\/.+" \}/); }); }); diff --git a/packages/eve/src/internal/application/compiled-artifacts.ts b/packages/eve/src/internal/application/compiled-artifacts.ts index a70f30c4a..81114d440 100644 --- a/packages/eve/src/internal/application/compiled-artifacts.ts +++ b/packages/eve/src/internal/application/compiled-artifacts.ts @@ -11,6 +11,7 @@ import { resolvePackageCompiledFilePath, resolvePackageSourceFilePath, } from "#internal/application/package.js"; +import { buildPackageUserAgent } from "#internal/user-agent.js"; import type { AgentWorkflowWorldDefinition } from "#shared/agent-definition.js"; import { readMaterializedAuthoredModuleIndex } from "#internal/materialized-authored-modules.js"; import { usesParentDevelopmentWorkflowWorld } from "#internal/workflow/development-world-protocol.js"; @@ -243,6 +244,69 @@ export async function createCompiledArtifactsBootstrapSource(input: { ].join("\n"); } +interface WorkflowWorldWiring { + /** Import specifier for the `workflowWorldModule` namespace import. */ + readonly moduleImportSpecifier: string; + /** Extra import lines the construction source depends on. */ + readonly extraImportLines: readonly string[]; + /** Named imports pulled from the workflow core runtime. */ + readonly runtimeImports: string; + /** Statement that assigns `workflowWorld`. */ + readonly createWorldSource: string; +} + +/** + * Per-world wiring for the generated workflow-world plugin. The four pieces + * co-vary and must stay together: a world constructed through an explicit + * `createWorld(config)` call must not import `createWorldFromModule`, and + * only the vendored local World needs the data-directory resolver import. + */ +function resolveWorkflowWorldWiring(packageName: string): WorkflowWorldWiring { + if (packageName === "@workflow/world-local") { + const dataDirectoryImportSpecifier = stringifyEsmImportSpecifier( + resolvePackageSourceFilePath("src/internal/workflow/local-world-data-directory.ts"), + ); + const moduleImportSpecifier = resolvePackageCompiledFilePath( + `src/compiled/${packageName}/index.js`, + ); + const importLines = ` +import { resolveLocalWorkflowWorldDataDirectory } from ${dataDirectoryImportSpecifier};`.trimStart(); + const createWorldSource = ` +const workflowWorld = await workflowWorldModule.createWorld({ + dataDir: resolveLocalWorkflowWorldDataDirectory(process.cwd()), +});`.trimStart(); + return { + moduleImportSpecifier, + extraImportLines: importLines.split("\n"), + runtimeImports: "getWorld, setWorld", + createWorldSource, + }; + } + + if (packageName === "@workflow/world-vercel") { + const moduleImportSpecifier = resolvePackageCompiledFilePath( + `src/compiled/${packageName}/index.js`, + ); + const createWorldSource = ` +const workflowWorld = await workflowWorldModule.createWorld({ + headers: { "User-Agent": ${JSON.stringify(buildPackageUserAgent())} }, +});`.trimStart(); + return { + moduleImportSpecifier, + extraImportLines: [], + runtimeImports: "getWorld, setWorld", + createWorldSource, + }; + } + + return { + moduleImportSpecifier: packageName, + extraImportLines: [], + runtimeImports: "createWorldFromModule, getWorld, setWorld", + createWorldSource: "const workflowWorld = await createWorldFromModule(workflowWorldModule);", + }; +} + export function createWorkflowWorldPluginSource(input: { compiledArtifactsBootstrapPath: string; configuredWorld: AgentWorkflowWorldDefinition | undefined; @@ -250,46 +314,23 @@ export function createWorkflowWorldPluginSource(input: { }): string { const targetWorld = input.configuredWorld ?? input.defaultWorld; const packageName = getWorldImport({ WORKFLOW_TARGET_WORLD: targetWorld }); - const importSpecifier = - packageName === "@workflow/world-local" || packageName === "@workflow/world-vercel" - ? resolvePackageCompiledFilePath(`src/compiled/${packageName}/index.js`) - : packageName; + const wiring = resolveWorkflowWorldWiring(packageName); const workflowRuntimeImportSpecifier = resolvePackageCompiledFilePath( "src/compiled/@workflow/core/runtime.js", ); const workflowWorldValidationImportSpecifier = resolvePackageSourceFilePath( "src/internal/workflow/validate-world.ts", ); - const localWorldDataDirectoryResolverImportSpecifier = - packageName === "@workflow/world-local" - ? resolvePackageSourceFilePath("src/internal/workflow/local-world-data-directory.ts") - : undefined; - const createWorkflowWorldSource = - localWorldDataDirectoryResolverImportSpecifier === undefined - ? "const workflowWorld = await createWorldFromModule(workflowWorldModule);" - : [ - "const workflowWorld = await workflowWorldModule.createWorld({", - " dataDir: resolveLocalWorkflowWorldDataDirectory(process.cwd()),", - "});", - ].join("\n"); - const workflowRuntimeImports = - localWorldDataDirectoryResolverImportSpecifier === undefined - ? "createWorldFromModule, getWorld, setWorld" - : "getWorld, setWorld"; return [ "// Generated by eve. Do not edit by hand.", `import ${stringifyEsmImportSpecifier(input.compiledArtifactsBootstrapPath)};`, - `import * as workflowWorldModule from ${stringifyEsmImportSpecifier(importSpecifier)};`, - ...(localWorldDataDirectoryResolverImportSpecifier === undefined - ? [] - : [ - `import { resolveLocalWorkflowWorldDataDirectory } from ${stringifyEsmImportSpecifier(localWorldDataDirectoryResolverImportSpecifier)};`, - ]), - `import { ${workflowRuntimeImports} } from ${stringifyEsmImportSpecifier(workflowRuntimeImportSpecifier)};`, + `import * as workflowWorldModule from ${stringifyEsmImportSpecifier(wiring.moduleImportSpecifier)};`, + ...wiring.extraImportLines, + `import { ${wiring.runtimeImports} } from ${stringifyEsmImportSpecifier(workflowRuntimeImportSpecifier)};`, `import { validateWorkflowWorld } from ${stringifyEsmImportSpecifier(workflowWorldValidationImportSpecifier)};`, "", - createWorkflowWorldSource, + wiring.createWorldSource, `validateWorkflowWorld({ packageName: ${JSON.stringify(input.configuredWorld)}, world: workflowWorld });`, "setWorld(workflowWorld);", "await getWorld();", diff --git a/packages/eve/src/internal/gateway.test.ts b/packages/eve/src/internal/gateway.test.ts new file mode 100644 index 000000000..494168375 --- /dev/null +++ b/packages/eve/src/internal/gateway.test.ts @@ -0,0 +1,57 @@ +import { MockLanguageModelV3 } from "ai/test"; +import { describe, expect, it, vi } from "vitest"; + +import { + AI_GATEWAY_MODELS_CATALOG_URL, + AI_GATEWAY_MODELS_URL, + vercelGatewayFetch, + resolveProviderHeaders, +} from "#internal/gateway.js"; + +describe("Gateway endpoints", () => { + it("point at the Gateway origin", () => { + expect(AI_GATEWAY_MODELS_URL).toBe("https://ai-gateway.vercel.sh/v1/models"); + expect(AI_GATEWAY_MODELS_CATALOG_URL).toBe("https://ai-gateway.vercel.sh/v1/models/catalog"); + }); +}); + +describe("vercelGatewayFetch", () => { + it("sends the eve product token as the user-agent", async () => { + const inner = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal("fetch", inner); + try { + await vercelGatewayFetch(AI_GATEWAY_MODELS_URL); + } finally { + vi.unstubAllGlobals(); + } + + const [, init] = inner.mock.calls[0]!; + expect(new Headers(init?.headers).get("user-agent")).toMatch(/^eve\/.+/); + }); +}); + +describe("resolveProviderHeaders", () => { + it("returns the eve user-agent for bare model ids", () => { + expect(resolveProviderHeaders("anthropic/claude-sonnet-4-5")).toEqual({ + "user-agent": expect.stringMatching(/^eve\/.+/), + }); + }); + + it("returns the eve user-agent for gateway model instances", () => { + const model = new MockLanguageModelV3({ + provider: "gateway.language-model", + modelId: "anthropic/claude-sonnet-4-5", + }); + expect(resolveProviderHeaders(model)).toEqual({ + "user-agent": expect.stringMatching(/^eve\/.+/), + }); + }); + + it("returns undefined for direct-provider model instances", () => { + const model = new MockLanguageModelV3({ + provider: "anthropic.messages", + modelId: "claude-sonnet-4-5", + }); + expect(resolveProviderHeaders(model)).toBeUndefined(); + }); +}); diff --git a/packages/eve/src/internal/gateway.ts b/packages/eve/src/internal/gateway.ts new file mode 100644 index 000000000..bfc8d92ae --- /dev/null +++ b/packages/eve/src/internal/gateway.ts @@ -0,0 +1,34 @@ +import type { LanguageModel } from "ai"; + +import { appendPackageUserAgent, withPackageUserAgent } from "#internal/user-agent.js"; + +const GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh"; + +/** AI Gateway model listing endpoint (OpenAI-compatible `/v1/models`). */ +export const AI_GATEWAY_MODELS_URL = `${GATEWAY_BASE_URL}/v1/models`; + +/** AI Gateway model catalog endpoint, the richer variant of {@link AI_GATEWAY_MODELS_URL}. */ +export const AI_GATEWAY_MODELS_CATALOG_URL = `${GATEWAY_BASE_URL}/v1/models/catalog`; + +/** + * A `fetch` for direct AI Gateway requests that identifies eve via its + * User-Agent product token — call sites get a decorated transport, not header + * plumbing. Unlike the Sandbox binding's `getVercelSandboxFetch`, nothing is + * constructed per call site, so this is a value rather than a factory. + */ +export const vercelGatewayFetch: typeof globalThis.fetch = withPackageUserAgent(); + +/** + * Request headers eve attaches for a model's provider, or `undefined` when + * the provider needs none. Gateway-routed models (bare ids and `gateway.*` + * instances) get the eve User-Agent product token so AI Gateway can attribute + * the traffic; direct-provider models get no extra headers. + */ +export function resolveProviderHeaders(model: LanguageModel): Record | undefined { + if (!isGatewayModel(model)) return undefined; + return Object.fromEntries(appendPackageUserAgent(new Headers())); +} + +function isGatewayModel(model: LanguageModel): boolean { + return typeof model === "string" || model.provider?.split(".")[0] === "gateway"; +} diff --git a/packages/eve/src/execution/sandbox/bindings/vercel-user-agent.test.ts b/packages/eve/src/internal/user-agent.test.ts similarity index 62% rename from packages/eve/src/execution/sandbox/bindings/vercel-user-agent.test.ts rename to packages/eve/src/internal/user-agent.test.ts index a49fa6aa9..4c0eddf64 100644 --- a/packages/eve/src/execution/sandbox/bindings/vercel-user-agent.test.ts +++ b/packages/eve/src/internal/user-agent.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it, vi } from "vitest"; -import { withEveSandboxUserAgent } from "#execution/sandbox/bindings/vercel-user-agent.js"; +import { withPackageUserAgent } from "#internal/user-agent.js"; function userAgentOf(init: RequestInit | undefined): string | null { return new Headers(init?.headers).get("user-agent"); } -describe("withEveSandboxUserAgent", () => { +describe("withPackageUserAgent", () => { it("appends the eve token to an existing user-agent", async () => { const inner = vi.fn().mockResolvedValue(new Response()); - const wrapped = withEveSandboxUserAgent(inner); + const wrapped = withPackageUserAgent(inner); await wrapped("https://api.vercel.com/sandboxes", { headers: { "user-agent": "vercel/sandbox/2.2.0" }, @@ -21,7 +21,7 @@ describe("withEveSandboxUserAgent", () => { it("sets the eve token as the user-agent when none is present", async () => { const inner = vi.fn().mockResolvedValue(new Response()); - const wrapped = withEveSandboxUserAgent(inner); + const wrapped = withPackageUserAgent(inner); await wrapped("https://api.vercel.com/sandboxes"); @@ -29,8 +29,18 @@ describe("withEveSandboxUserAgent", () => { expect(userAgentOf(init)).toMatch(/^eve\/.+/); }); + it("does not append the token twice", async () => { + const inner = vi.fn().mockResolvedValue(new Response()); + const wrapped = withPackageUserAgent(withPackageUserAgent(inner)); + + await wrapped("https://ai-gateway.vercel.sh/v1/models"); + + const [, init] = inner.mock.calls[0]!; + expect(userAgentOf(init)).toMatch(/^eve\/[^ ]+$/); + }); + it("delegates to globalThis.fetch when no inner fetch is supplied", () => { - const wrapped = withEveSandboxUserAgent(); + const wrapped = withPackageUserAgent(); expect(typeof wrapped).toBe("function"); }); }); diff --git a/packages/eve/src/internal/user-agent.ts b/packages/eve/src/internal/user-agent.ts new file mode 100644 index 000000000..ff2568e9b --- /dev/null +++ b/packages/eve/src/internal/user-agent.ts @@ -0,0 +1,44 @@ +import { resolveInstalledPackageInfo } from "#internal/application/package.js"; + +/** Product token for the installed package, such as `package-name/1.2.3`. */ +export function buildPackageUserAgent(): string { + const { name, version } = resolveInstalledPackageInfo(); + return `${name}/${version}`; +} + +/** + * Joins User-Agent product tokens with spaces per the RFC 9110 `User-Agent` + * grammar (`product *( RWS ( product / comment ) )`), skipping empty parts. + * Never join UA products with `Headers.append()` — repeated header values + * combine with `", "`, and a comma glued to a product token breaks + * whitespace-delimited parsers on the receiving side. + */ +function joinUserAgentProducts(...products: (string | null | undefined)[]): string { + return products.filter(Boolean).join(" "); +} + +/** Appends the installed package product without discarding an existing User-Agent. */ +export function appendPackageUserAgent(headers: Headers): Headers { + const product = buildPackageUserAgent(); + const existing = headers.get("user-agent"); + if (existing?.split(/\s+/).includes(product)) return headers; + headers.set("user-agent", joinUserAgentProducts(existing, product)); + return headers; +} + +/** + * Wraps a `fetch` implementation so every request's `user-agent` carries the + * installed package product token (appended after existing products). Without + * an explicit `inner`, `globalThis.fetch` is resolved per request rather than + * captured at wrap time, so globally installed instrumentation still applies. + */ +export function withPackageUserAgent(inner?: typeof globalThis.fetch): typeof globalThis.fetch { + return (input, init) => { + const headers = new Headers( + init?.headers ?? + (typeof input === "object" && "headers" in input ? (input as Request).headers : undefined), + ); + appendPackageUserAgent(headers); + return (inner ?? globalThis.fetch)(input, { ...init, headers }); + }; +} diff --git a/packages/eve/src/setup/boxes/select-model.test.ts b/packages/eve/src/setup/boxes/select-model.test.ts index a19127ed6..145e30872 100644 --- a/packages/eve/src/setup/boxes/select-model.test.ts +++ b/packages/eve/src/setup/boxes/select-model.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import { DEFAULT_AGENT_MODEL_ID } from "#shared/default-agent-model.js"; @@ -9,6 +9,7 @@ import { createDefaultSetupState } from "../state.js"; import type { OutputSink } from "../step.js"; import { runHeadless, runInteractive } from "../runner.js"; import { + fetchGatewayCatalog, modelOptionsFromCatalog, parseGatewayCatalog, selectModel, @@ -18,6 +19,24 @@ import { const silentSink: OutputSink = { write: () => {} }; +afterEach(() => { + vi.unstubAllGlobals(); +}); + +it("identifies eve when fetching the AI Gateway catalog", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [] }), { + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchGatewayCatalog(); + + const [, init] = fetchMock.mock.calls[0]!; + expect(new Headers(init?.headers).get("user-agent")).toMatch(/^eve\/.+/); +}); + const CATALOG: GatewayCatalogModel[] = [ { id: "zai/glm-4.6", diff --git a/packages/eve/src/setup/boxes/select-model.ts b/packages/eve/src/setup/boxes/select-model.ts index e0ce99b71..5d5d37cee 100644 --- a/packages/eve/src/setup/boxes/select-model.ts +++ b/packages/eve/src/setup/boxes/select-model.ts @@ -1,11 +1,10 @@ import { z } from "#compiled/zod/index.js"; +import { AI_GATEWAY_MODELS_URL, vercelGatewayFetch } from "#internal/gateway.js"; import { DEFAULT_AGENT_MODEL_ID } from "#shared/default-agent-model.js"; import { select, type Asker, type SelectOption } from "../ask.js"; import type { SetupState } from "../state.js"; import type { SetupBox } from "../step.js"; - -const AI_GATEWAY_URL = "https://ai-gateway.vercel.sh/v1/models"; const FETCH_TIMEOUT_MS = 5000; const WEB_SEARCH_TAG = "web-search"; const MODEL_PROMPT_MESSAGE = "Which model should your agent use?"; @@ -83,7 +82,7 @@ export async function fetchGatewayCatalog(signal?: AbortSignal): Promise ({ captureVercel: vi.fn() })); + +vi.mock("#setup/primitives/index.js", () => ({ + captureVercel: mocks.captureVercel, +})); + +import { fetchGatewayModelIds } from "#setup/gateway-models.js"; + +describe("fetchGatewayModelIds", () => { + it("identifies eve on the Vercel-authenticated catalog request", async () => { + mocks.captureVercel.mockResolvedValueOnce({ ok: true, stdout: '{"data":[]}' }); + + await fetchGatewayModelIds("/tmp/app"); + + expect(mocks.captureVercel).toHaveBeenCalledWith( + expect.arrayContaining(["--header", expect.stringMatching(/^User-Agent: eve\/.+/)]), + { cwd: "/tmp/app" }, + ); + }); +}); diff --git a/packages/eve/src/setup/gateway-models.ts b/packages/eve/src/setup/gateway-models.ts index 3595f1ae2..e5c578c64 100644 --- a/packages/eve/src/setup/gateway-models.ts +++ b/packages/eve/src/setup/gateway-models.ts @@ -1,7 +1,7 @@ +import { AI_GATEWAY_MODELS_URL } from "#internal/gateway.js"; +import { buildPackageUserAgent } from "#internal/user-agent.js"; import { captureVercel } from "#setup/primitives/index.js"; -const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1/models"; - /** * Fetches the set of AI Gateway model ids, for validating a `--model` before * scaffolding. Routed through the (required) Vercel CLI — equivalent to: @@ -12,7 +12,17 @@ const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1/models"; * a malformed response) — callers must not block creation on it. */ export async function fetchGatewayModelIds(cwd: string): Promise | null> { - const result = await captureVercel(["curl", AI_GATEWAY_MODELS_URL, "--", "--silent"], { cwd }); + const result = await captureVercel( + [ + "curl", + AI_GATEWAY_MODELS_URL, + "--", + "--silent", + "--header", + `User-Agent: ${buildPackageUserAgent()}`, + ], + { cwd }, + ); if (!result.ok) return null; try { const json = JSON.parse(result.stdout) as { data?: { id?: unknown }[] }; diff --git a/packages/eve/src/setup/validate-gateway-key.test.ts b/packages/eve/src/setup/validate-gateway-key.test.ts index 59dc71fba..a974ebda9 100644 --- a/packages/eve/src/setup/validate-gateway-key.test.ts +++ b/packages/eve/src/setup/validate-gateway-key.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ getCredits: vi.fn() })); +const mocks = vi.hoisted(() => ({ createGateway: vi.fn(), getCredits: vi.fn() })); vi.mock("ai", () => ({ - createGateway: () => ({ getCredits: mocks.getCredits }), + createGateway: mocks.createGateway.mockReturnValue({ getCredits: mocks.getCredits }), })); import { validateGatewayApiKey } from "./validate-gateway-key.js"; @@ -12,6 +12,20 @@ describe("validateGatewayApiKey", () => { it("is valid when the gateway accepts the key", async () => { mocks.getCredits.mockResolvedValueOnce({ models: [] }); await expect(validateGatewayApiKey("sk-good")).resolves.toEqual({ kind: "valid" }); + + // The provider's fetch must send the eve product token on the wire. + const { fetch: providerFetch } = mocks.createGateway.mock.calls[0]![0] as { + fetch: typeof globalThis.fetch; + }; + const inner = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal("fetch", inner); + try { + await providerFetch("https://ai-gateway.vercel.sh/v1/credits", {}); + } finally { + vi.unstubAllGlobals(); + } + const [, init] = inner.mock.calls[0]!; + expect(new Headers(init?.headers).get("user-agent")).toMatch(/^eve\/.+/); }); it("is invalid on an authentication rejection (401 / authentication_error)", async () => { diff --git a/packages/eve/src/setup/validate-gateway-key.ts b/packages/eve/src/setup/validate-gateway-key.ts index a7c856ee3..8940636cd 100644 --- a/packages/eve/src/setup/validate-gateway-key.ts +++ b/packages/eve/src/setup/validate-gateway-key.ts @@ -1,5 +1,6 @@ import { createGateway } from "ai"; +import { vercelGatewayFetch } from "#internal/gateway.js"; import { toErrorMessage } from "#shared/errors.js"; /** @@ -55,7 +56,7 @@ export async function validateGatewayApiKey( const provider = createGateway({ apiKey, fetch: (url: Parameters[0], init?: Parameters[1]) => - globalThis.fetch(url, { ...init, signal: effectiveSignal }), + vercelGatewayFetch(url, { ...init, signal: effectiveSignal }), }); await provider.getCredits(); return { kind: "valid" };