From 29c5f96809e16445577464f04a5a6ef9960c1f66 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Wed, 29 Jul 2026 21:09:22 -0400 Subject: [PATCH 1/2] feat(eve): record Vercel AI Gateway cost on local trace spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent.step spans now carry the cost the gateway reports per model call: gen_ai.usage.cost (raw inference USD), gen_ai.usage.gateway_cost (with surcharge), gen_ai.usage.input_cost / output_cost, and gen_ai.generation.id for dashboard reconciliation. The bridge publishes step provider metadata from the AI SDK's onStepEnd hook, and attributes are written only when the gateway section exists — other providers emit nothing. Signed-off-by: Chad Hietala --- .changeset/great-moles-tap.md | 5 ++ docs/guides/instrumentation.md | 2 + .../src/harness/agent-otel-provider.test.ts | 58 +++++++++++++++++++ .../eve/src/harness/agent-otel-provider.ts | 51 ++++++++++++++++ .../src/harness/ai-sdk-hook-bridge.test.ts | 28 +++++++++ .../eve/src/harness/ai-sdk-hook-bridge.ts | 11 ++++ .../src/harness/instrumentation-lifecycle.ts | 15 +++++ 7 files changed, 170 insertions(+) create mode 100644 .changeset/great-moles-tap.md diff --git a/.changeset/great-moles-tap.md b/.changeset/great-moles-tap.md new file mode 100644 index 000000000..bc8642bee --- /dev/null +++ b/.changeset/great-moles-tap.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Records Vercel AI Gateway cost on local trace spans: `agent.step` spans now carry `gen_ai.usage.cost`, `gen_ai.usage.gateway_cost`, `gen_ai.usage.input_cost`/`output_cost`, and `gen_ai.generation.id` when the gateway reports them. The attributes only exist for gateway-served calls — other providers emit nothing. diff --git a/docs/guides/instrumentation.md b/docs/guides/instrumentation.md index b6ab7e1ab..6d8cf4956 100644 --- a/docs/guides/instrumentation.md +++ b/docs/guides/instrumentation.md @@ -15,6 +15,8 @@ The directory is an immutable OTLP/JSON spool and remains available after `eve d Use `eve trace ls` to list captured traces and `eve trace ` to inspect a session's span tree. +When a model call is served by Vercel AI Gateway, its `agent.step` span also carries the cost the gateway reported: `gen_ai.usage.cost` (raw inference, USD), `gen_ai.usage.gateway_cost` (with the gateway surcharge), `gen_ai.usage.input_cost` / `gen_ai.usage.output_cost` (the split), and `gen_ai.generation.id` for reconciliation with the gateway dashboard. These attributes only exist for gateway-served calls — other providers emit nothing. Cost per turn is the sum across the turn's step spans. + The local writer is an internal development default, not a second provider layered over authored instrumentation. When `instrumentation.ts` exists, its setup retains control and the zero-config writer is not installed. ### Local trace retention diff --git a/packages/eve/src/harness/agent-otel-provider.test.ts b/packages/eve/src/harness/agent-otel-provider.test.ts index b37786e9b..a77dfe1b1 100644 --- a/packages/eve/src/harness/agent-otel-provider.test.ts +++ b/packages/eve/src/harness/agent-otel-provider.test.ts @@ -44,6 +44,7 @@ async function emitAttempt(input: { readonly attemptIndex?: number; readonly hooks: InstrumentationHooks; readonly runInContext: InstrumentationContextRunner; + readonly providerMetadata?: Readonly>; readonly sessionId: string; readonly toolError?: Error; readonly turnAlreadyStarted?: boolean; @@ -112,6 +113,14 @@ async function emitAttempt(input: { }, ]); + if (input.providerMetadata !== undefined) { + await input.hooks.publish({ + providerMetadata: input.providerMetadata, + scope, + type: "attempt.metadata", + }); + } + await input.hooks.publish({ scope, type: "attempt.completed" }); await input.hooks.publish({ sessionId: input.sessionId, @@ -203,6 +212,55 @@ describe("createAgentOtelInstrumentation", () => { expect(JSON.stringify(spans.map((span) => span.attributes))).not.toContain("private"); }); + it("writes gateway cost attributes on the step span when the gateway reports them", async () => { + const runtime = createRuntime(); + await emitAttempt({ + hooks: runtime.hooks, + providerMetadata: { + gateway: { + cost: "0.000082", + gatewayCost: "0.000182", + generationId: "gen_01KYR80F7ZV4RM3PJ635KMXB5V", + inputInferenceCost: "0.000042", + outputInferenceCost: "0.00004", + }, + }, + runInContext: runtime.runInContext, + sessionId: "session-1", + turnId: "turn-1", + turnSequence: 0, + }); + await runtime.provider.forceFlush(); + + const step = byName(runtime.exporter.getFinishedSpans(), "agent.step")[0]!; + expect(step.attributes).toMatchObject({ + "gen_ai.generation.id": "gen_01KYR80F7ZV4RM3PJ635KMXB5V", + "gen_ai.usage.cost": 0.000082, + "gen_ai.usage.gateway_cost": 0.000182, + "gen_ai.usage.input_cost": 0.000042, + "gen_ai.usage.output_cost": 0.00004, + }); + }); + + it("emits no cost attributes when the provider is not the gateway", async () => { + const runtime = createRuntime(); + await emitAttempt({ + hooks: runtime.hooks, + providerMetadata: { anthropic: { cacheCreationInputTokens: 0 } }, + runInContext: runtime.runInContext, + sessionId: "session-1", + turnId: "turn-1", + turnSequence: 0, + }); + await runtime.provider.forceFlush(); + + const step = byName(runtime.exporter.getFinishedSpans(), "agent.step")[0]!; + const keys = Object.keys(step.attributes); + for (const key of keys) { + expect(key).not.toContain("cost"); + } + }); + it("reuses one trace id across turns", async () => { const stateStore = new InMemoryAgentTraceStateStore(); const firstRuntime = createRuntime(stateStore); diff --git a/packages/eve/src/harness/agent-otel-provider.ts b/packages/eve/src/harness/agent-otel-provider.ts index 8df93fc67..6b2a2305d 100644 --- a/packages/eve/src/harness/agent-otel-provider.ts +++ b/packages/eve/src/harness/agent-otel-provider.ts @@ -12,6 +12,7 @@ import { } from "#compiled/@opentelemetry/api/index.js"; import type { + InstrumentationAttemptMetadataEvent, InstrumentationAttemptScope, InstrumentationAttemptStartedEvent, InstrumentationAttemptTerminalEvent, @@ -387,11 +388,24 @@ export function createAgentOtelInstrumentation( return state; }; + const onAttemptMetadata = (event: InstrumentationAttemptMetadataEvent): void => { + const attempt = steps.get(event.scope); + if (attempt === undefined) return; + // Vercel AI Gateway reports per-call cost in providerMetadata.gateway; + // attributes exist only when it was actually the gateway serving the call. + const gateway = readGatewayCost(event.providerMetadata); + if (gateway === undefined) return; + for (const [key, value] of Object.entries(gateway)) { + attempt.step.span.setAttribute(key, value); + } + }; + return { hook: { events: { "attempt.completed": onAttemptTerminal, "attempt.failed": onAttemptTerminal, + "attempt.metadata": onAttemptMetadata, "attempt.started": onAttemptStarted, "model.call": { after: afterModelCall, before: beforeModelCall }, "session.completed": onSessionTransition, @@ -448,6 +462,39 @@ function contextFromSpanContext(spanContext: SpanContext): Context { return trace.setSpan(ROOT_CONTEXT, trace.wrapSpanContext(spanContext)); } +/** + * Extracts cost data from a step result's provider metadata. Only Vercel AI + * Gateway reports it (`providerMetadata.gateway`): raw inference cost, the + * gateway's surcharged total, the input/output split, and the generation id + * for dashboard reconciliation. Values arrive as USD strings; anything + * missing or non-numeric is skipped, so non-gateway providers get nothing. + */ +function readGatewayCost( + providerMetadata: Readonly>, +): Record | undefined { + const gateway = providerMetadata.gateway; + if (!isRecord(gateway)) return undefined; + const attributes: Record = {}; + const cost = readUsd(gateway.cost); + if (cost !== undefined) attributes["gen_ai.usage.cost"] = cost; + const gatewayCost = readUsd(gateway.gatewayCost); + if (gatewayCost !== undefined) attributes["gen_ai.usage.gateway_cost"] = gatewayCost; + const inputCost = readUsd(gateway.inputInferenceCost); + if (inputCost !== undefined) attributes["gen_ai.usage.input_cost"] = inputCost; + const outputCost = readUsd(gateway.outputInferenceCost); + if (outputCost !== undefined) attributes["gen_ai.usage.output_cost"] = outputCost; + if (typeof gateway.generationId === "string" && gateway.generationId.length > 0) { + attributes["gen_ai.generation.id"] = gateway.generationId; + } + return Object.keys(attributes).length === 0 ? undefined : attributes; +} + +function readUsd(value: unknown): number | undefined { + if (typeof value !== "string") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + function isSpanState(value: unknown): value is SpanState { return typeof value === "object" && value !== null && "context" in value && "span" in value; } @@ -456,6 +503,10 @@ function isToolSpanState(value: unknown): value is ToolSpanState { return isSpanState(value) && "toolSpan" in value; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function modelSpanName(operationName: string): string { return operationName === "ai.generateText" ? "ai.generateText.doGenerate" diff --git a/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts b/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts index ee6a98412..4093b51ca 100644 --- a/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts +++ b/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { createAiSdkHookBridge } from "#harness/ai-sdk-hook-bridge.js"; import { createInstrumentationHooks, + type InstrumentationAttemptMetadataEvent, type InstrumentationAttemptScope, type InstrumentationProviderDefinition, } from "#harness/instrumentation-lifecycle.js"; @@ -119,6 +120,33 @@ describe("createAiSdkHookBridge", () => { expect(adapterCalls).toBe(0); }); + it("publishes step provider metadata as attempt.metadata, skipping steps without any", async () => { + const events: InstrumentationAttemptMetadataEvent[] = []; + const hooks = createInstrumentationHooks([ + { + events: { + "attempt.metadata": (event) => { + events.push(event); + }, + }, + }, + ]); + const bridge = createAiSdkHookBridge(scope, hooks); + + await Reflect.apply(bridge.onStepEnd!, bridge, [ + { providerMetadata: { gateway: { cost: "0.000082" } } }, + ]); + await Reflect.apply(bridge.onStepEnd!, bridge, [{ providerMetadata: undefined }]); + + expect(events).toEqual([ + { + providerMetadata: { gateway: { cost: "0.000082" } }, + scope, + type: "attempt.metadata", + }, + ]); + }); + it("isolates a failing provider from the remaining providers", async () => { const after = vi.fn(); const hooks = createInstrumentationHooks([ diff --git a/packages/eve/src/harness/ai-sdk-hook-bridge.ts b/packages/eve/src/harness/ai-sdk-hook-bridge.ts index e2779999e..80945ae65 100644 --- a/packages/eve/src/harness/ai-sdk-hook-bridge.ts +++ b/packages/eve/src/harness/ai-sdk-hook-bridge.ts @@ -61,6 +61,17 @@ export function createAiSdkHookBridge( const completed = toModelCallCompleted(state, id, event); await hooks.after("model.call", completed); }, + async onStepEnd(event) { + // Step results carry provider metadata (e.g. Vercel AI Gateway cost) + // that the per-call telemetry events don't. Publish it for providers + // that know what to do with it; skip when there is none. + if (event.providerMetadata === undefined) return; + await hooks.publish({ + providerMetadata: event.providerMetadata, + scope: state.scope, + type: "attempt.metadata", + }); + }, async onToolExecutionStart(event) { const id = createToolCallIdentity(state, event.toolCall.toolCallId); state.toolIds.set(event.toolCall.toolCallId, id); diff --git a/packages/eve/src/harness/instrumentation-lifecycle.ts b/packages/eve/src/harness/instrumentation-lifecycle.ts index c0e71992b..828a30693 100644 --- a/packages/eve/src/harness/instrumentation-lifecycle.ts +++ b/packages/eve/src/harness/instrumentation-lifecycle.ts @@ -58,6 +58,17 @@ export interface InstrumentationAttemptTerminalEvent { readonly scope: InstrumentationAttemptScope; } +/** + * Provider metadata for one completed step, as reported by the AI SDK + * (`StepResult.providerMetadata`). Carries Vercel AI Gateway cost data when + * the request went through the gateway; absent for other providers. + */ +export interface InstrumentationAttemptMetadataEvent { + readonly type: "attempt.metadata"; + readonly scope: InstrumentationAttemptScope; + readonly providerMetadata: Readonly>; +} + export interface InstrumentationModelCallStartedEvent { readonly type: "model.call.started"; readonly id: string; @@ -129,6 +140,9 @@ export interface InstrumentationProviderDefinition { readonly "attempt.failed"?: ( event: InstrumentationAttemptTerminalEvent, ) => void | PromiseLike; + readonly "attempt.metadata"?: ( + event: InstrumentationAttemptMetadataEvent, + ) => void | PromiseLike; readonly "session.completed"?: ( event: InstrumentationSessionTransitionEvent, ) => void | PromiseLike; @@ -171,6 +185,7 @@ export type InstrumentationRelatedEventName = keyof InstrumentationRelatedEventM export type InstrumentationPointEvent = | InstrumentationAttemptStartedEvent + | InstrumentationAttemptMetadataEvent | InstrumentationAttemptTerminalEvent | InstrumentationSessionStartedEvent | InstrumentationSessionTransitionEvent From 73879486fed5244cd4829214b3b468b221dd4066 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Wed, 29 Jul 2026 21:20:19 -0400 Subject: [PATCH 2/2] refactor(eve): clarify gateway cost attribute naming Signed-off-by: Chad Hietala --- packages/eve/src/harness/agent-otel-provider.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/eve/src/harness/agent-otel-provider.ts b/packages/eve/src/harness/agent-otel-provider.ts index 6b2a2305d..c3770f7aa 100644 --- a/packages/eve/src/harness/agent-otel-provider.ts +++ b/packages/eve/src/harness/agent-otel-provider.ts @@ -393,9 +393,10 @@ export function createAgentOtelInstrumentation( if (attempt === undefined) return; // Vercel AI Gateway reports per-call cost in providerMetadata.gateway; // attributes exist only when it was actually the gateway serving the call. - const gateway = readGatewayCost(event.providerMetadata); - if (gateway === undefined) return; - for (const [key, value] of Object.entries(gateway)) { + const costAttributes = readGatewayCost(event.providerMetadata); + if (costAttributes === undefined) return; + // The vendored OTel Span surface only has singular setAttribute. + for (const [key, value] of Object.entries(costAttributes)) { attempt.step.span.setAttribute(key, value); } };