diff --git a/packages/opencode/src/session/llm/ai-sdk.ts b/packages/opencode/src/session/llm/ai-sdk.ts index 8db8985d7b09..d13669b681a8 100644 --- a/packages/opencode/src/session/llm/ai-sdk.ts +++ b/packages/opencode/src/session/llm/ai-sdk.ts @@ -41,6 +41,17 @@ function copilotTotalNanoAiu(value: unknown) { return total } +// The OpenAI usage shape has no cache-write field, so @ai-sdk/openai-compatible always leaves +// cacheWriteTokens undefined. Gateways fronting Anthropic still report the count as an extra key, +// which survives untouched in `raw`. +function rawCacheWriteTokens(value: unknown) { + if (!value || typeof value !== "object") return + const raw = value as Record + const total = raw.cache_creation_input_tokens ?? raw.cache_write_tokens + if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return + return total +} + function usage(value: unknown) { if (!value || typeof value !== "object") return undefined const item = value as { @@ -51,6 +62,7 @@ function usage(value: unknown) { cachedInputTokens?: number inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } outputTokenDetails?: { reasoningTokens?: number } + raw?: unknown } const entries = Object.entries({ inputTokens: item.inputTokens, @@ -58,7 +70,7 @@ function usage(value: unknown) { totalTokens: item.totalTokens, reasoningTokens: item.outputTokenDetails?.reasoningTokens ?? item.reasoningTokens, cacheReadInputTokens: item.inputTokenDetails?.cacheReadTokens ?? item.cachedInputTokens, - cacheWriteInputTokens: item.inputTokenDetails?.cacheWriteTokens, + cacheWriteInputTokens: item.inputTokenDetails?.cacheWriteTokens ?? rawCacheWriteTokens(item.raw), }).filter((entry) => entry[1] !== undefined) return entries.length === 0 ? undefined : Object.fromEntries(entries) } diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 61aac13ac395..3c1e1454a252 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -503,6 +503,60 @@ describe("session.llm.ai-sdk adapter", () => { expect(result.tokens.cache.read).toBe(200) }) + // The OpenAI usage shape has no cache-write field, so @ai-sdk/openai-compatible leaves + // cacheWriteTokens undefined and passes the gateway payload through as usage.raw. A gateway + // fronting Anthropic still reports the count there, and providerMetadata carries nothing to + // fall back on, so raw is the only place the number survives. + test("reads cache write tokens from raw usage for an openai-compatible gateway", async () => { + const events = await adapt([ + { + type: "finish-step", + response: { id: "msg_test", timestamp: new Date(0), modelId: "claude-sonnet-4" }, + finishReason: "stop", + rawFinishReason: "stop", + usage: { + inputTokens: 19428, + outputTokens: 4, + totalTokens: 19432, + inputTokenDetails: { noCacheTokens: 19428, cacheReadTokens: 0, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: 4, reasoningTokens: undefined }, + raw: { prompt_tokens: 19428, completion_tokens: 4, cache_creation_input_tokens: 19426 }, + }, + providerMetadata: undefined, + }, + ]) + + expect(events).toHaveLength(1) + const stepFinish = events[0] + if (stepFinish.type !== "step-finish") throw new Error("expected step-finish") + expect(stepFinish.usage?.cacheWriteInputTokens).toBe(19426) + + const result = SessionNs.getUsage({ + model: { + id: "claude-sonnet-4", + providerID: "gateway", + name: "Claude via gateway", + limit: { context: 200_000, output: 8_000 }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + capabilities: { + toolcall: true, + attachment: false, + reasoning: false, + temperature: true, + input: { text: true, image: false, audio: false, video: false }, + output: { text: true, image: false, audio: false, video: false }, + }, + api: { npm: "@ai-sdk/openai-compatible" }, + options: {}, + } as never, + usage: stepFinish.usage!, + metadata: stepFinish.providerMetadata, + }) + expect(result.tokens.cache.write).toBe(19426) + expect(result.tokens.input).toBe(2) + expect(result.tokens.output).toBe(4) + }) + test("captures Copilot billed usage from raw Anthropic message deltas per step", async () => { const events = await adapt([ uncheckedAdapterEvent({