Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eve-vercel-service-user-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Identify Eve and its version on AI Gateway, Sandbox, and Workflow service requests.
11 changes: 11 additions & 0 deletions packages/eve/src/compiler/model-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 2 additions & 3 deletions packages/eve/src/compiler/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -213,7 +212,7 @@ type CompiledRuntimeModelCatalogCache = z.infer<typeof compiledRuntimeModelCatal
async function fetchAndPersistModelCatalog(
appRoot: string,
): Promise<CompiledRuntimeModelCatalogCache> {
const response = await fetch(AI_GATEWAY_MODELS_CATALOG_URL);
const response = await vercelGatewayFetch(AI_GATEWAY_MODELS_CATALOG_URL);

if (!response.ok) {
throw new Error(
Expand Down
27 changes: 27 additions & 0 deletions packages/eve/src/evals/autoevals-client.test.ts
Original file line number Diff line number Diff line change
@@ -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\/.+/) },
}),
);
});
});
2 changes: 2 additions & 0 deletions packages/eve/src/evals/autoevals-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ async function getVercelSandboxGetOptions(input: {
readonly sandboxName: string;
}): Promise<VercelGetOptions> {
const baseOptions = {
fetch: getVercelSandboxFetch(input.createOptions),
name: input.sandboxName,
resume: false,
};
Expand All @@ -44,7 +45,6 @@ async function getVercelSandboxGetOptions(input: {
return {
...baseOptions,
...credentials,
fetch: getVercelSandboxFetch(input.createOptions),
signal: input.createOptions.signal,
} as VercelGetOptions;
} catch {
Expand Down
28 changes: 0 additions & 28 deletions packages/eve/src/execution/sandbox/bindings/vercel-user-agent.ts

This file was deleted.

8 changes: 7 additions & 1 deletion packages/eve/src/execution/sandbox/bindings/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -1005,6 +1010,7 @@ describe("createVercelSandbox", () => {
});

expect(sandboxModule.Sandbox.get).toHaveBeenCalledWith({
fetch: expect.any(Function),
name: "deleted-sandbox",
resume: false,
});
Expand Down
56 changes: 51 additions & 5 deletions packages/eve/src/harness/tool-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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" },
Expand All @@ -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",
Expand All @@ -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) {
Expand Down
23 changes: 9 additions & 14 deletions packages/eve/src/harness/tool-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> | 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<string, string> = {};
const headers: Record<string, string> = { ...providerHeaders };
if (title) headers["x-title"] = title;
if (referer) headers["http-referer"] = referer;
return headers;
Expand Down Expand Up @@ -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,
}));
Expand Down Expand Up @@ -2403,11 +2398,11 @@ async function maybeCompact(input: {
readonly abortSignal?: AbortSignal;
readonly emit?: ToolLoopHarnessConfig["handleEvent"];
readonly emissionState: ReturnType<typeof getHarnessEmissionState>;
readonly headers?: Record<string, string>;
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 }> {
Expand Down Expand Up @@ -2444,7 +2439,7 @@ async function maybeCompact(input: {
session.compaction,
compaction.providerOptions,
input.telemetry,
input.headers,
buildGatewayAttributionHeaders(compaction.model, input.runtimeIdentity),
input.abortSignal,
);

Expand Down
15 changes: 8 additions & 7 deletions packages/eve/src/internal/application/compiled-artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\/.+" \}/);
});
});

Expand Down
Loading
Loading