feat(eve): account for AI Gateway costs

Signed-off-by: Chad Hietala <chad.hietala@vercel.com>
This commit is contained in:
Chad Hietala
2026-09-17 17:24:12 -04:00
parent e7594441bd
commit 7ed46b52b6
17 changed files with 542 additions and 128 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"eve": patch
---
Report BYOK and compaction model spend in workflow tags and OpenTelemetry. Successful BYOK calls now use AI Gateway's market-price estimate as effective cost, compaction calls receive their own step span, and their usage is folded into running token and cost totals.
+7 -4
View File
@@ -201,10 +201,13 @@ could only grant another zero-value window.
When `maxInputTokensPerSession` is omitted, root sessions apply a default
input budget of `40_000_000` provider-reported input tokens.
`maxOutputTokensPerSession` and `maxTokenCostUsdPerSession` are unset by
default. `maxTokenCostUsdPerSession` is a US-dollar limit on model token cost,
not tool or infrastructure spend. It uses the cost reported with each model
step; AI Gateway supplies this value, while model steps without reported cost
do not add to the limit. Set any usage limit to `false` to uncap that axis.
default. `maxTokenCostUsdPerSession` is a US-dollar limit on model-call spend
reported by AI Gateway, including compaction calls; it does not cover every
tool or infrastructure cost. For system credentials it uses the
Gateway-reported call cost excluding surcharges, and for successful BYOK calls
it uses the Gateway's market-price estimate of upstream provider spend. Calls
without reported cost do not add to the limit. Set any usage limit to `false`
to uncap that axis.
Delegated subagent sessions have no fixed default. Each child receives a
share of the delegating parent's remaining quota at dispatch time — the
+15 -7
View File
@@ -148,14 +148,14 @@ Local traces retain model, tool, and memory-record content by default. Set
## Workflow run tags
Separately from OpenTelemetry, eve tags every Workflow run with reserved
`$eve.*` attributes. These framework-owned attributes are queryable in the
Workflow dashboard, not on OTel spans. eve emits them for every session, turn,
and subagent run, whether or not `agent/instrumentation.ts` exists.
Separately from OpenTelemetry, eve tags Workflow runs with reserved `$eve.*`
attributes. These framework-owned attributes are queryable in the Workflow
dashboard, not on OTel spans. eve emits them for session and subagent runs,
whether or not `agent/instrumentation.ts` exists.
Structural tags describe a run's place in its tree:
- `$eve.type`: `"session"`, `"turn"`, or `"subagent"`.
- `$eve.type`: `"session"` or `"subagent"`.
- `$eve.parent`: the immediate parent session ID.
- `$eve.root`: the root session ID for the tree.
- `$eve.subagent`: the compiled graph node ID for a subagent run.
@@ -166,11 +166,19 @@ Structural tags describe a run's place in its tree:
conversation-wide identity.
Each turn also accumulates `$eve.model`, `$eve.input_tokens`,
`$eve.output_tokens`, `$eve.cache_read_tokens`, and `$eve.tool_count`. These
tags power the **Agent Runs** tab in Vercel's **Observability** view. See
`$eve.output_tokens`, `$eve.cache_read_tokens`, `$eve.cache_write_tokens`,
`$eve.cost_usd`, and `$eve.tool_count`. Automatic and standalone compaction
model calls are included in those running totals. These tags power the **Agent
Runs** tab in Vercel's **Observability** view. See
[Deploy to Vercel](../guides/deployment/vercel#inspect-agent-runs) for
enablement.
For calls served with AI Gateway system credentials, `$eve.cost_usd` is the
Gateway-reported call cost, excluding surcharges. For successful BYOK calls,
it uses the Gateway's market-price estimate of upstream provider spend; the
Gateway's own BYOK inference debit is zero. Direct provider calls, including
the local ChatGPT subscription path, report token usage but no dollar cost.
## Debug discovery
Run `eve info` to see the instrumentation eve discovered and any diagnostics.
+1 -1
View File
@@ -319,7 +319,7 @@ Every span carries a real duration. A turn's root `invoke_agent` span is written
Model, `execute_tool`, and memory spans retain their content by default. Set `EVE_TRACES_CONTENT=off` to omit system prompts, prompt messages, and response text for models; call arguments and results for tools; and recalled memory records. Each captured value is capped at 32 KB.
Step spans carry token counts under `agent.usage.*`, and cost when Vercel AI Gateway served the call. Model spans also expose `gen_ai.usage.*` token counters. The CLI sums step-level counters only, so model and delegated-call totals are not counted twice.
Step spans carry token counts under `agent.usage.*`, and cost when Vercel AI Gateway served the call. For successful BYOK calls, `gen_ai.usage.cost` uses the Gateway's market-price estimate of upstream provider spend and `gen_ai.usage.upstream_cost` preserves that estimate; `gen_ai.usage.gateway_cost` preserves the Gateway debit. Compaction model calls get their own step span. Model spans also expose `gen_ai.usage.*` token counters. The CLI sums step-level counters only, so model and delegated-call totals are not counted twice.
### Retention
+3
View File
@@ -193,6 +193,9 @@ Pass another bare OpenAI model slug to override the default. `experimental_chatg
`chatgpt()` uses stateless requests (`store: false`). eve retains reasoning summaries and encrypted reasoning in session history and replays them after tool calls and on later turns. You do not need to configure `reasoning.encrypted_content` explicitly.
The ChatGPT subscription path reports token usage, but it does not report a
dollar cost. Usage limits that depend on cost do not increase on these calls.
eve uses one local authentication path with two credential owners:
1. Run `eve dev`, open `/login`, and select **ChatGPT subscription**.
+44 -3
View File
@@ -362,12 +362,13 @@ async function compact(
recentWindowSize: overrides.recentWindowSize ?? 4,
threshold: overrides.threshold ?? ROOMY,
};
const result = await compactMessages(
const compaction = await compactMessages(
messages,
{} as Parameters<typeof compactMessages>[1],
compactionConfig,
);
const result = compaction.messages;
expectWellFormedCompaction(result, compactionConfig.threshold);
return { result, summarizer: summarizer as ReturnType<typeof vi.mocked<never>> };
}
@@ -699,7 +700,7 @@ describe("compactMessages: forced summary", () => {
} as Awaited<ReturnType<typeof generateText>>);
const messages = [user("old message"), assistant("old reply")];
const result = await compactMessages(
const compaction = await compactMessages(
messages,
{} as Parameters<typeof compactMessages>[1],
{ recentWindowSize: 10, threshold: ROOMY },
@@ -711,7 +712,47 @@ describe("compactMessages: forced summary", () => {
);
expect(generateText).toHaveBeenCalledOnce();
expect(result).toContainEqual({ content: "forced checkpoint", role: "assistant" });
expect(compaction.messages).toContainEqual({ content: "forced checkpoint", role: "assistant" });
});
it("returns provider-reported usage from the summarization call", async () => {
const { generateText } = await import("ai");
vi.mocked(generateText).mockResolvedValue({
providerMetadata: {
gateway: {
cost: "0",
marketCost: "0.0123",
routing: {
modelAttempts: [{ providerAttempts: [{ credentialType: "byok", success: true }] }],
},
},
},
text: "checkpoint text",
usage: {
inputTokenDetails: { cacheReadTokens: 10, cacheWriteTokens: 5 },
inputTokens: 100,
outputTokens: 20,
},
} as never);
const compaction = await compactMessages(
[user("old message"), assistant("old reply")],
{} as Parameters<typeof compactMessages>[1],
{ recentWindowSize: 10, threshold: ROOMY },
undefined,
undefined,
undefined,
undefined,
true,
);
expect(compaction.usage).toEqual({
cacheReadTokens: 10,
cacheWriteTokens: 5,
costUsd: 0.0123,
inputTokens: 100,
outputTokens: 20,
});
});
});
+60 -5
View File
@@ -11,11 +11,25 @@ import {
} from "#harness/compaction-prompt.js";
import { createFrameworkUserMessage, isFrameworkUserMessage } from "#harness/messages.js";
import { estimateTokens } from "#harness/token-estimate.js";
import { readGatewayEffectiveCostUsd } from "#shared/gateway-cost.js";
import type { RuntimeModelReference } from "#runtime/agent/bootstrap.js";
import type { CompactionConfig, ToolLoopHarnessConfig } from "#harness/types.js";
const COMPACTION_SUMMARY_RESERVE_TOKENS = 2_048;
export interface CompactionUsage {
readonly cacheReadTokens?: number;
readonly cacheWriteTokens?: number;
readonly costUsd?: number;
readonly inputTokens?: number;
readonly outputTokens?: number;
}
export interface CompactionResult {
readonly messages: ModelMessage[];
readonly usage?: CompactionUsage;
}
/**
* Element type of a non-string `ModelMessage.content` array.
*/
@@ -194,15 +208,16 @@ export async function compactMessages(
headers?: Record<string, string>,
abortSignal?: AbortSignal,
forceSummary = false,
): Promise<ModelMessage[]> {
): Promise<CompactionResult> {
const { conversation, previousCheckpoint } = extractPreviousCheckpoint(messages);
const recentConfig = forceSummary ? { ...config, recentWindowSize: 1 } : config;
let keep = selectRecentWindowSize(conversation, recentConfig);
let usage: CompactionUsage | undefined;
if (!forceSummary) {
const { older, recent } = splitMessagesForCompaction(conversation, keep);
if (older.length === 0 && previousCheckpoint === undefined) {
return keepNonToolResultMessages(recent);
return { messages: keepNonToolResultMessages(recent) };
}
// Capping preserves most of the measured prompt. Retain any known
@@ -222,7 +237,7 @@ export async function compactMessages(
tokenEstimateAdjustment,
});
if (outcome.type === "within-limit") {
return outcome.messages;
return { messages: outcome.messages };
}
}
}
@@ -246,6 +261,13 @@ export async function compactMessages(
telemetry: telemetry ? { ...telemetry, functionId: "eve.compaction" } : undefined,
temperature: 0,
});
usage = addCompactionUsage(usage, {
cacheReadTokens: result.usage?.inputTokenDetails?.cacheReadTokens,
cacheWriteTokens: result.usage?.inputTokenDetails?.cacheWriteTokens,
costUsd: readGatewayEffectiveCostUsd(result.providerMetadata),
inputTokens: result.usage?.inputTokens,
outputTokens: result.usage?.outputTokens,
});
if (result.text.trim().length === 0) {
throw new Error(
@@ -267,7 +289,7 @@ export async function compactMessages(
config.threshold,
);
if (evaluateThreshold(verbatim, config, "estimate").type === "within-limit") {
return verbatim;
return { messages: verbatim, usage };
}
const stripped = withResumptionGuard(
@@ -276,13 +298,46 @@ export async function compactMessages(
config.threshold,
);
if (evaluateThreshold(stripped, config, "estimate").type === "within-limit" || keep === 0) {
return stripped;
return { messages: stripped, usage };
}
keep -= 1;
}
}
function addCompactionUsage(
current: CompactionUsage | undefined,
delta: CompactionUsage,
): CompactionUsage | undefined {
if (!hasUsage(delta)) return current;
if (current === undefined) return delta;
return {
cacheReadTokens: sumOptional(current.cacheReadTokens, delta.cacheReadTokens),
cacheWriteTokens: sumOptional(current.cacheWriteTokens, delta.cacheWriteTokens),
costUsd:
current.costUsd === undefined && delta.costUsd === undefined
? undefined
: (current.costUsd ?? 0) + (delta.costUsd ?? 0),
inputTokens: sumOptional(current.inputTokens, delta.inputTokens),
outputTokens: sumOptional(current.outputTokens, delta.outputTokens),
};
}
function hasUsage(usage: CompactionUsage): boolean {
return (
usage.cacheReadTokens !== undefined ||
usage.cacheWriteTokens !== undefined ||
usage.costUsd !== undefined ||
usage.inputTokens !== undefined ||
usage.outputTokens !== undefined
);
}
function sumOptional(current: number | undefined, delta: number | undefined): number | undefined {
if (current === undefined && delta === undefined) return undefined;
return (current ?? 0) + (delta ?? 0);
}
const CAPPED_RESULT_ANNOTATION =
"[Truncated by eve: tool result reduced during context compaction. Re-run the tool if you need the full output.]";
+2 -14
View File
@@ -40,6 +40,7 @@ import {
} from "#harness/action-presentation.js";
import { isInvalidToolCall } from "#harness/tool-call-input-errors.js";
import type { RuntimeToolResultActionResult } from "#shared/action-types.js";
import { readGatewayEffectiveCostUsd } from "#shared/gateway-cost.js";
import {
type HarnessEmitFn,
type HarnessSession,
@@ -350,7 +351,7 @@ export async function emitStepActions(
stepIndex: state.stepIndex,
turnId: state.turnId,
usage: extractStepUsage({
costUsd: extractGatewayCostUsd(step.providerMetadata),
costUsd: readGatewayEffectiveCostUsd(step.providerMetadata),
usage: step.usage,
}),
}),
@@ -483,19 +484,6 @@ function extractStepProviderMetadata(
return generationId === undefined ? undefined : { gateway: { generationId } };
}
function extractGatewayCostUsd(providerMetadata: ProviderMetadata | undefined): number | undefined {
const gateway = readGatewayMetadata(providerMetadata);
const cost = gateway?.cost;
if (typeof cost === "number" && Number.isFinite(cost)) {
return cost;
}
if (typeof cost === "string") {
const parsed = Number(cost);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
export function readGatewayGenerationId(
providerMetadata: ProviderMetadata | undefined,
): string | undefined {
+150 -45
View File
@@ -60,7 +60,7 @@ import {
type ConversationContext,
} from "#shared/conversation-context.js";
import type { InstrumentationDecision } from "#shared/instrumentation-decision.js";
import { compactMessages, shouldCompact } from "#harness/compaction.js";
import { compactMessages, shouldCompact, type CompactionResult } from "#harness/compaction.js";
import {
createFrameworkUserMessage,
createUserMessage,
@@ -92,6 +92,7 @@ import { PendingSkillAnnouncementKey } from "#context/dynamic-skill-lifecycle.js
import { deserializeContext, serializeContext } from "#context/serialize.js";
import { stashToolInterrupt } from "#harness/tool-interrupts.js";
import { appendMissingToolResultMessages, createToolLoopHarness } from "#harness/tool-loop.js";
import { setEveAttributes } from "#runtime/attributes/emit.js";
import { isSessionLimitDecline, TurnCancelledError } from "#harness/turn-cancellation.js";
import {
getSessionUsageLimitViolation,
@@ -275,15 +276,27 @@ vi.mock("./compaction.js", () => ({
shouldCompact: vi.fn().mockReturnValue(false),
}));
vi.mock("#runtime/attributes/emit.js", () => ({
setEveAttributes: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
vi.mocked(shouldCompact).mockReset().mockReturnValue(false);
vi.mocked(compactMessages).mockReset();
vi.mocked(setEveAttributes).mockClear();
vi.unstubAllEnvs();
declareTelemetry(undefined);
mockGetRegisteredTelemetryIntegrations.mockReset().mockReturnValue([]);
});
function compactionResult(
messages: ModelMessage[],
usage?: CompactionResult["usage"],
): CompactionResult {
return { messages, usage };
}
function createTestSession(overrides?: Partial<HarnessSession>): HarnessSession {
return {
agent: {
@@ -2086,7 +2099,15 @@ describe("createToolLoopHarness", () => {
it("accumulates provider-reported token usage and cost across the session", async () => {
setupMockAgent({
finishReason: "stop",
providerMetadata: { gateway: { cost: "0.0123" } },
providerMetadata: {
gateway: {
cost: "0",
marketCost: "0.0123",
routing: {
modelAttempts: [{ providerAttempts: [{ credentialType: "byok", success: true }] }],
},
},
},
response: { messages: [{ content: "Hello!", role: "assistant" }] },
text: "Hello!",
toolCalls: [],
@@ -9862,11 +9883,13 @@ describe("createToolLoopHarness", () => {
it("emits compaction.requested and compaction.completed when compaction triggers", async () => {
vi.mocked(shouldCompact).mockReturnValue(true);
vi.mocked(compactMessages).mockResolvedValue([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
createUserMessage("user", "recent message"),
]);
vi.mocked(compactMessages).mockResolvedValue(
compactionResult([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
createUserMessage("user", "recent message"),
]),
);
setupMockAgent({
finishReason: "stop",
@@ -9951,7 +9974,7 @@ describe("createToolLoopHarness", () => {
{ content: "summary", role: "assistant" },
{ content: "current", kind: "user" as const, role: "user" },
];
vi.mocked(compactMessages).mockResolvedValue(compactedHistory);
vi.mocked(compactMessages).mockResolvedValue(compactionResult(compactedHistory));
setupMockAgent({
finishReason: "stop",
response: { messages: [{ content: "done", role: "assistant" }] },
@@ -10061,7 +10084,7 @@ describe("createToolLoopHarness", () => {
{ content: "Summary of our conversation so far:", kind: "context.compaction", role: "user" },
{ content: "summary", role: "assistant" },
];
vi.mocked(compactMessages).mockResolvedValue(compactedHistory);
vi.mocked(compactMessages).mockResolvedValue(compactionResult(compactedHistory));
const { emit, events } = createEventCollector();
const onCompaction = vi.fn(() => []);
@@ -10183,11 +10206,13 @@ describe("createToolLoopHarness", () => {
it("uses the authored compaction model when one is configured", async () => {
vi.mocked(shouldCompact).mockReturnValue(true);
vi.mocked(compactMessages).mockResolvedValue([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
createUserMessage("user", "recent message"),
]);
vi.mocked(compactMessages).mockResolvedValue(
compactionResult([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
createUserMessage("user", "recent message"),
]),
);
setupMockAgent({
finishReason: "stop",
@@ -10396,10 +10421,12 @@ describe("createToolLoopHarness", () => {
it("invokes onCompaction callback after compaction", async () => {
vi.mocked(shouldCompact).mockReturnValue(true);
vi.mocked(compactMessages).mockResolvedValue([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
]);
vi.mocked(compactMessages).mockResolvedValue(
compactionResult([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
]),
);
setupMockAgent({
finishReason: "stop",
@@ -10431,6 +10458,59 @@ describe("createToolLoopHarness", () => {
]);
});
it("folds compaction model usage into workflow run tags", async () => {
vi.mocked(shouldCompact).mockReturnValue(true);
vi.mocked(compactMessages).mockResolvedValue(
compactionResult(
[
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
],
{
cacheReadTokens: 10,
cacheWriteTokens: 5,
costUsd: 0.01,
inputTokens: 100,
outputTokens: 20,
},
),
);
setupMockAgent({
finishReason: "stop",
providerMetadata: { gateway: { cost: "0.0123" } },
response: { messages: [{ content: "Resuming.", role: "assistant" }] },
text: "Resuming.",
toolCalls: [],
toolResults: [],
usage: {
inputTokenDetails: { cacheReadTokens: 2, cacheWriteTokens: 1 },
inputTokens: 7,
outputTokens: 3,
},
});
const runStep = createToolLoopHarness(createTestConfig("conversation"));
const result = await runStep(createTestSession(), { message: "Continue" });
expect(getSessionTokenUsage(result.session)).toEqual({
cacheReadTokens: 12,
cacheWriteTokens: 6,
costUsd: 0.0223,
inputTokens: 107,
outputTokens: 23,
sawCost: true,
});
expect(setEveAttributes).toHaveBeenLastCalledWith(
expect.objectContaining({
"$eve.cache_read_tokens": 12,
"$eve.cache_write_tokens": 6,
"$eve.cost_usd": 0.0223,
"$eve.input_tokens": 107,
"$eve.output_tokens": 23,
}),
);
});
it("compaction appends a framework continuation when recent window trails with assistant", async () => {
// Step 1: tool call → harness continues (next === runStep).
setupMockAgent({
@@ -10480,12 +10560,14 @@ describe("createToolLoopHarness", () => {
// guarded output from the real compactMessages: trailing
// assistant gets a framework continuation appended.
vi.mocked(shouldCompact).mockReturnValue(true);
vi.mocked(compactMessages).mockResolvedValue([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
{ content: "The answer is 42.", role: "assistant" },
createFrameworkUserMessage("execution.continuation", "Continue."),
]);
vi.mocked(compactMessages).mockResolvedValue(
compactionResult([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
{ content: "The answer is 42.", role: "assistant" },
createFrameworkUserMessage("execution.continuation", "Continue."),
]),
);
setupMockAgent({
finishReason: "stop",
@@ -11172,8 +11254,12 @@ describe("createToolLoopHarness", () => {
finishReason: "stop",
providerMetadata: {
gateway: {
cost: 0.0042,
cost: "0",
generationId: "gen_cost_only",
marketCost: "0.0042",
routing: {
modelAttempts: [{ providerAttempts: [{ credentialType: "byok", success: true }] }],
},
},
},
response: { messages: [{ content: "done", role: "assistant" }] },
@@ -11430,10 +11516,12 @@ describe("createToolLoopHarness", () => {
it("derives compaction attribution from the compaction model", async () => {
vi.mocked(shouldCompact).mockReturnValueOnce(true);
vi.mocked(compactMessages).mockResolvedValueOnce([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
]);
vi.mocked(compactMessages).mockResolvedValueOnce(
compactionResult([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
]),
);
setupStopResultForAttribution();
const originalProductionUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL;
@@ -11494,10 +11582,12 @@ describe("createToolLoopHarness", () => {
it("marks Gateway compaction calls made by eval sessions", async () => {
vi.mocked(shouldCompact).mockReturnValueOnce(true);
vi.mocked(compactMessages).mockResolvedValueOnce([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
]);
vi.mocked(compactMessages).mockResolvedValueOnce(
compactionResult([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
]),
);
setupStopResultForAttribution();
const config: ToolLoopHarnessConfig = {
@@ -11727,10 +11817,12 @@ describe("createToolLoopHarness", () => {
it("keeps compaction telemetry metadata-only on a rejected trace", async () => {
vi.mocked(shouldCompact).mockReturnValueOnce(true);
vi.mocked(compactMessages).mockResolvedValueOnce([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
]);
vi.mocked(compactMessages).mockResolvedValueOnce(
compactionResult([
createFrameworkUserMessage("context.compaction", "Summary of our conversation so far:"),
{ content: "summary", role: "assistant" },
]),
);
setupMockAgent({
finishReason: "stop",
response: { messages: [{ content: "Hello!", role: "assistant" }] },
@@ -11755,6 +11847,11 @@ describe("createToolLoopHarness", () => {
recordInputs: false,
recordOutputs: false,
});
expect(
mockCreateAiSdkHookBridge.mock.calls.some(
([scope]) => (scope as { attemptKind?: string }).attemptKind === "compaction",
),
).toBe(true);
});
it("keeps the content-capable lifecycle bridge active on a rejected trace", async () => {
@@ -12802,7 +12899,9 @@ describe("createToolLoopHarness", () => {
const withClientContext = scenario === "client context";
if (scenario === "compaction") {
vi.mocked(shouldCompact).mockReturnValueOnce(true);
vi.mocked(compactMessages).mockImplementationOnce(async (messages) => messages.slice(2));
vi.mocked(compactMessages).mockImplementationOnce(async (messages) =>
compactionResult(messages.slice(2)),
);
}
const toolCall = {
type: "tool-call" as const,
@@ -12965,9 +13064,11 @@ describe("createToolLoopHarness", () => {
}),
);
vi.mocked(shouldCompact).mockReturnValueOnce(true);
vi.mocked(compactMessages).mockResolvedValueOnce([
createFrameworkUserMessage("context.compaction", "Conversation summary"),
]);
vi.mocked(compactMessages).mockResolvedValueOnce(
compactionResult([
createFrameworkUserMessage("context.compaction", "Conversation summary"),
]),
);
setupMockAgentError(new Error("Model unavailable"));
const failed = await contextStorage.run(ctx, () =>
@@ -13015,9 +13116,11 @@ describe("createToolLoopHarness", () => {
};
expect(ctx.get(HistoryStateKey)).toEqual(expectedState);
vi.mocked(compactMessages).mockResolvedValue([
createFrameworkUserMessage("context.compaction", "Conversation summary"),
]);
vi.mocked(compactMessages).mockResolvedValue(
compactionResult([
createFrameworkUserMessage("context.compaction", "Conversation summary"),
]),
);
let session = first.session;
if (replacement === "automatic compaction") {
vi.mocked(shouldCompact).mockReturnValueOnce(true);
@@ -13069,7 +13172,9 @@ describe("createToolLoopHarness", () => {
it("keeps ephemeral client context out of compaction and its token baseline", async () => {
vi.mocked(shouldCompact).mockReturnValueOnce(true);
vi.mocked(compactMessages).mockImplementationOnce(async (messages) => [...messages]);
vi.mocked(compactMessages).mockImplementationOnce(async (messages) =>
compactionResult([...messages]),
);
setupMockAgent({
...defaultModelResult(),
usage: { inputTokens: 321 },
+76 -45
View File
@@ -7,7 +7,6 @@ import {
type LanguageModelCallEndEvent,
type LanguageModel,
type ModelMessage,
type ProviderMetadata,
type SystemModelMessage,
type TelemetryOptions,
ToolLoopAgent,
@@ -51,6 +50,7 @@ import {
import { buildDynamicSubagentTools } from "#context/dynamic-subagent-lifecycle.js";
import { PendingSkillAnnouncementKey } from "#context/dynamic-skill-lifecycle.js";
import { toErrorMessage } from "#shared/errors.js";
import { readGatewayEffectiveCostUsd } from "#shared/gateway-cost.js";
import {
createActionResultEvent,
createApprovalCandidateEvent,
@@ -603,8 +603,10 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn {
onCompaction: config.onCompaction,
resolveModel: config.resolveModel,
gatewayAttribution: config.gatewayAttribution,
prepareAttempt: stepInstrumentation?.prepareAttempt,
session,
telemetry: stepInstrumentation?.telemetry(),
toolCount: config.tools.size,
});
session = compacted.session;
@@ -1195,8 +1197,10 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn {
promptMessages: createModelMessages(messages),
resolveModel: config.resolveModel,
gatewayAttribution: config.gatewayAttribution,
prepareAttempt: stepInstrumentation?.prepareAttempt,
session,
telemetry: stepInstrumentation?.telemetry(),
toolCount: config.tools.size,
});
session = compaction.session;
if (compaction.compacted) {
@@ -1476,7 +1480,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn {
if (generation.interrupted) return;
interruptedUsage = extractTokenUsageDelta({
usage: event.usage,
costUsd: extractGatewayCostUsd(event.providerMetadata),
costUsd: readGatewayEffectiveCostUsd(event.providerMetadata),
});
for (const part of event.content) {
if (
@@ -1874,13 +1878,10 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn {
// --- Step-side observability tags ---------------------------------------
//
// Tag the **turn workflow run** (the current `"use step"` is hosted by
// that workflow, so `setAttributes` writes to its
// attributes table) with the model id and per-turn cumulative token
// counts. Per-turn totals are accumulated on `session.state` because
// each tool-loop iteration is a fresh `"use step"` and the workflow
// runtime's last-write-wins per-key semantics mean only the running
// total — not the per-step delta — should reach the dashboard.
// Tag the owner workflow run with the model id and per-turn cumulative
// token counts. Totals include any compaction model call and are stored on
// `session.state` because each tool-loop iteration is a fresh `"use step"`
// and Workflow attributes use last-write-wins semantics.
//
// Best-effort: `setEveAttributes` swallows runtime failures so a
// broken tag emit can never break the agent loop.
@@ -1888,7 +1889,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn {
previous: getTurnUsageState(session.state),
turnId: emissionState.turnId,
usage: extractTokenUsageDelta({
costUsd: extractGatewayCostUsd(result.providerMetadata),
costUsd: readGatewayEffectiveCostUsd(result.providerMetadata),
usage: result.usage,
}),
});
@@ -1898,12 +1899,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn {
// mock models in tests omit it, so guard the lookup so a missing field
// becomes `undefined` and is dropped by `setEveAttributes` instead of
// throwing into the tool loop.
let modelTag: string | undefined;
try {
modelTag = formatLanguageModelGatewayId(model);
} catch {
modelTag = undefined;
}
const modelTag = formatModelTag(model);
await setEveAttributes({
"$eve.model": modelTag,
"$eve.input_tokens": nextTurnUsage.inputTokens,
@@ -1966,24 +1962,12 @@ function extractTokenUsageDelta(input: {
};
}
function extractGatewayCostUsd(providerMetadata: ProviderMetadata | undefined): number | undefined {
const gateway = readGatewayMetadata(providerMetadata);
const cost = gateway?.cost;
if (typeof cost === "number" && Number.isFinite(cost)) {
return cost;
function formatModelTag(model: LanguageModel): string | undefined {
try {
return formatLanguageModelGatewayId(model);
} catch {
return undefined;
}
if (typeof cost === "string") {
const parsed = Number(cost);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function readGatewayMetadata(
providerMetadata: ProviderMetadata | undefined,
): ProviderMetadata[string] | undefined {
const gateway = providerMetadata?.gateway;
return gateway && typeof gateway === "object" && !Array.isArray(gateway) ? gateway : undefined;
}
// ---------------------------------------------------------------------------
@@ -3093,10 +3077,12 @@ async function maybeCompact(input: {
readonly onCompaction?: ToolLoopHarnessConfig["onCompaction"];
/** Model-visible prompt used only to decide whether durable history needs compaction. */
readonly promptMessages?: readonly HarnessModelMessage[];
readonly prepareAttempt?: InstrumentationStepScope<HarnessSession>["prepareAttempt"];
readonly resolveModel: ToolLoopHarnessConfig["resolveModel"];
readonly gatewayAttribution?: ToolLoopHarnessConfig["gatewayAttribution"];
readonly session: HarnessSession;
readonly telemetry?: TelemetryOptions;
readonly toolCount: number;
}): Promise<{
readonly compacted: boolean;
readonly messages: HarnessModelMessage[];
@@ -3117,6 +3103,17 @@ async function maybeCompact(input: {
return { compacted: false, messages, session };
}
const compactionAttempt = needsSummary
? input.prepareAttempt?.({
attemptIndex: 0,
attemptKind: "compaction",
functionId: "eve.compaction",
runtimeContext: { "eve.compaction": true },
stepIndex: emissionState.stepIndex,
turnId: emissionState.turnId,
})
: undefined;
const compaction = await resolveCompactionModel({
compactionModelReference: session.agent.compactionModelReference,
model: input.model,
@@ -3159,18 +3156,9 @@ async function maybeCompact(input: {
canonical.ordinary,
);
const compactedOrdinary = needsSummary
? await compactMessages(
[...ordinary],
compaction.model,
session.compaction,
providerOptions,
input.telemetry,
resolveGatewayRequestHeaders(compaction.model, input.gatewayAttribution),
input.abortSignal,
input.force === true,
)
: [...ordinary];
messages = validateHarnessModelMessages([...canonical.memory, ...compactedOrdinary]);
? await runCompactionAttempt()
: { messages: [...ordinary] };
messages = validateHarnessModelMessages([...canonical.memory, ...compactedOrdinary.messages]);
if (input.onCompaction) {
for (const msg of input.onCompaction()) {
@@ -3178,6 +3166,26 @@ async function maybeCompact(input: {
}
}
let compactionUsageState: ReturnType<typeof accumulateTurnUsage> | undefined;
if (compactedOrdinary.usage !== undefined) {
const nextUsage = accumulateTurnUsage({
previous: getTurnUsageState(session.state),
turnId: emissionState.turnId,
usage: compactedOrdinary.usage,
});
session = setTurnUsageState(session, nextUsage);
compactionUsageState = nextUsage;
await setEveAttributes({
"$eve.model": formatModelTag(compaction.model),
"$eve.input_tokens": nextUsage.inputTokens,
"$eve.output_tokens": nextUsage.outputTokens,
"$eve.cache_read_tokens": nextUsage.cacheReadTokens,
"$eve.cache_write_tokens": nextUsage.cacheWriteTokens,
"$eve.cost_usd": nextUsage.sawCost ? nextUsage.costUsd : undefined,
"$eve.tool_count": input.toolCount,
});
}
if (emit) {
const ctx = contextStorage.getStore();
if (ctx !== undefined) {
@@ -3197,11 +3205,34 @@ async function maybeCompact(input: {
if (commit !== undefined) {
messages = validateHarnessModelMessages(commit.history);
session = { ...session, state: commit.state };
if (compactionUsageState !== undefined) {
session = setTurnUsageState(session, compactionUsageState);
}
}
}
}
return { compacted: true, messages, session: replaceSessionHistory(session, messages) };
async function runCompactionAttempt() {
try {
const result = await compactMessages(
[...ordinary],
compaction.model,
session.compaction,
providerOptions,
compactionAttempt?.telemetry ?? input.telemetry,
resolveGatewayRequestHeaders(compaction.model, input.gatewayAttribution),
input.abortSignal,
input.force === true,
);
await compactionAttempt?.complete();
return result;
} catch (error) {
await compactionAttempt?.fail(error);
throw error;
}
}
}
/**
@@ -12,13 +12,15 @@ import type {
/**
* Stable eve identity for one model attempt. Retries share `stepIndex` and
* differ by `attemptIndex`, so `step.attempt.*` fires once per attempt while
* differ by `attemptIndex`; compaction attempts share the step but use a
* distinct `attemptKind`, so `step.attempt.*` fires once per attempt while
* protocol `step.*` events and the resolver hook fire once per step.
*/
export interface InstrumentationAttemptScope {
readonly channelAudience?: ChannelAudience;
readonly attemptId: string;
readonly attemptIndex: number;
readonly attemptKind?: "compaction" | "model";
readonly functionId?: string;
readonly rootSessionId?: string;
readonly sessionId: string;
+10 -2
View File
@@ -99,6 +99,8 @@ export interface InstrumentationStepScope<TSession> {
) => HandleEventFn | undefined;
readonly prepareAttempt: (input: {
readonly attemptIndex: number;
readonly attemptKind?: InstrumentationAttemptKind;
readonly functionId?: string;
readonly runtimeContext?: Readonly<Record<string, unknown>>;
readonly stepIndex: number;
readonly turnId: string;
@@ -130,6 +132,8 @@ export interface PreparedInstrumentationAttempt {
readonly telemetry: TelemetryOptions | undefined;
}
export type InstrumentationAttemptKind = "compaction" | "model";
export type InstrumentationAttempt = InstrumentationAttemptScope;
export interface BoundInstrumentationSession {
@@ -409,11 +413,15 @@ export function bindInstrumentationRuntime(
title: sessionContext.title,
}),
prepareAttempt: (attemptInput) => {
const attemptKind = attemptInput.attemptKind ?? "model";
const attemptKindSuffix = attemptKind === "model" ? "" : `:${attemptKind}`;
const scope: InstrumentationAttemptScope = {
attemptId: `${boundSession.sessionId}:${attemptInput.turnId}:${attemptInput.stepIndex}:${attemptInput.attemptIndex}`,
attemptId: `${boundSession.sessionId}:${attemptInput.turnId}:${attemptInput.stepIndex}:${attemptInput.attemptIndex}${attemptKindSuffix}`,
attemptIndex: attemptInput.attemptIndex,
attemptKind,
channelAudience: audience,
functionId: settings?.functionId ?? boundSession.agentName,
functionId:
attemptInput.functionId ?? settings?.functionId ?? boundSession.agentName,
rootSessionId: sessionContext.parent?.rootSessionId ?? boundSession.sessionId,
sessionId: boundSession.sessionId,
stepIndex: attemptInput.stepIndex,
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
readGatewayCostUsd,
readGatewayEffectiveCostUsd,
readGatewayUpstreamCostUsd,
} from "#shared/gateway-cost.js";
const byokMetadata = {
gateway: {
cost: "0",
marketCost: "0.0123",
routing: {
modelAttempts: [
{
providerAttempts: [{ credentialType: "byok", success: true }],
},
],
},
},
};
describe("gateway cost metadata", () => {
it("reads the gateway debit for system-credential calls", () => {
const providerMetadata = { gateway: { cost: "0.0042" } };
expect(readGatewayCostUsd(providerMetadata)).toBe(0.0042);
expect(readGatewayUpstreamCostUsd(providerMetadata)).toBeUndefined();
expect(readGatewayEffectiveCostUsd(providerMetadata)).toBe(0.0042);
});
it("uses market cost as effective spend for successful BYOK calls", () => {
expect(readGatewayCostUsd(byokMetadata)).toBe(0);
expect(readGatewayUpstreamCostUsd(byokMetadata)).toBe(0.0123);
expect(readGatewayEffectiveCostUsd(byokMetadata)).toBe(0.0123);
});
it("does not treat a BYOK fallback attempt as successful BYOK spend", () => {
const providerMetadata = {
gateway: {
cost: "0.0042",
marketCost: "0.0123",
routing: {
modelAttempts: [
{
providerAttempts: [
{ credentialType: "byok", success: false },
{ credentialType: "system", success: true },
],
},
],
},
},
};
expect(readGatewayUpstreamCostUsd(providerMetadata)).toBeUndefined();
expect(readGatewayEffectiveCostUsd(providerMetadata)).toBe(0.0042);
});
});
+67
View File
@@ -0,0 +1,67 @@
type GatewayMetadata = Record<string, unknown>;
type ProviderMetadataLike = Readonly<Record<string, unknown>>;
/** Reads AI Gateway's reported call cost, excluding surcharges. */
export function readGatewayCostUsd(
providerMetadata: ProviderMetadataLike | undefined,
): number | undefined {
return readUsd(readGatewayMetadata(providerMetadata)?.cost);
}
/**
* Reads the market-price cost reported for successful BYOK calls.
*
* AI Gateway reports its own BYOK debit as zero for inference. `marketCost` is
* the Gateway's estimate of what the caller paid the upstream provider.
*/
export function readGatewayUpstreamCostUsd(
providerMetadata: ProviderMetadataLike | undefined,
): number | undefined {
const gateway = readGatewayMetadata(providerMetadata);
if (gateway === undefined || !usedSuccessfulByokCredential(gateway)) return undefined;
return readUsd(gateway.marketCost);
}
/** Reads the cost that most directly represents the caller's model spend. */
export function readGatewayEffectiveCostUsd(
providerMetadata: ProviderMetadataLike | undefined,
): number | undefined {
return readGatewayUpstreamCostUsd(providerMetadata) ?? readGatewayCostUsd(providerMetadata);
}
function readGatewayMetadata(
providerMetadata: ProviderMetadataLike | undefined,
): GatewayMetadata | undefined {
const gateway = providerMetadata?.gateway;
return isRecord(gateway) ? gateway : undefined;
}
function usedSuccessfulByokCredential(gateway: GatewayMetadata): boolean {
const routing = gateway.routing;
if (!isRecord(routing) || !Array.isArray(routing.modelAttempts)) return false;
for (const modelAttempt of routing.modelAttempts) {
if (!isRecord(modelAttempt) || !Array.isArray(modelAttempt.providerAttempts)) continue;
for (const providerAttempt of modelAttempt.providerAttempts) {
if (
isRecord(providerAttempt) &&
providerAttempt.success === true &&
providerAttempt.credentialType === "byok"
) {
return true;
}
}
}
return false;
}
function readUsd(value: unknown): number | undefined {
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
if (typeof value !== "string") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -2950,6 +2950,41 @@ describe("createAgentOtelInstrumentation", () => {
});
});
it("writes BYOK market cost as effective spend on the step span", async () => {
const runtime = createRuntime();
await emitAttempt({
hooks: runtime.hooks,
providerMetadata: {
gateway: {
cost: "0",
gatewayCost: "0",
generationId: "gen_01KYR80F7ZV4RM3PJ635KMXB5V",
marketCost: "0.000182",
routing: {
modelAttempts: [
{
providerAttempts: [{ credentialType: "byok", success: true }],
},
],
},
},
},
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.000182,
"gen_ai.usage.gateway_cost": 0,
"gen_ai.usage.upstream_cost": 0.000182,
});
});
it("emits no cost attributes when the provider is not the gateway", async () => {
const runtime = createRuntime();
await emitAttempt({
@@ -233,6 +233,7 @@ export function createAgentOtelInstrumentation(
"agent.framework.name": "eve",
"agent.framework.version": input.frameworkVersion,
"agent.step.attempt": event.scope.attemptIndex,
"agent.step.kind": event.scope.attemptKind ?? "model",
"agent.step.index": event.scope.stepIndex,
"agent.turn.id": event.scope.turnId,
"agent.name": event.scope.functionId,
+4 -1
View File
@@ -3,6 +3,7 @@ import type { Span } from "#compiled/@opentelemetry/api/index.js";
import type { InstrumentationUsage } from "#instrumentation/lifecycle.js";
import type { AgentTurnTraceState } from "#tracing/agent-trace-state.js";
import { AGENT_USAGE_ATTRIBUTES } from "#tracing/agent-span-contract.js";
import { readGatewayEffectiveCostUsd, readGatewayUpstreamCostUsd } from "#shared/gateway-cost.js";
/** Applies eve's structural token usage attributes to an agent span. */
export function setAgentUsage(span: Span, usage: InstrumentationUsage): void {
@@ -54,8 +55,10 @@ export function readGatewayCost(
const gateway = providerMetadata.gateway;
if (!isRecord(gateway)) return undefined;
const attributes: Record<string, string | number> = {};
const cost = readUsd(gateway.cost);
const cost = readGatewayEffectiveCostUsd(providerMetadata);
if (cost !== undefined) attributes["gen_ai.usage.cost"] = cost;
const upstreamCost = readGatewayUpstreamCostUsd(providerMetadata);
if (upstreamCost !== undefined) attributes["gen_ai.usage.upstream_cost"] = upstreamCost;
const gatewayCost = readUsd(gateway.gatewayCost);
if (gatewayCost !== undefined) attributes["gen_ai.usage.gateway_cost"] = gatewayCost;
const inputCost = readUsd(gateway.inputInferenceCost);