mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
feat(eve): account for AI Gateway costs
Signed-off-by: Chad Hietala <chad.hietala@vercel.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.]";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user