diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index de8c3dc4cbd1..383d778d2d70 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -335,16 +335,44 @@ export function plan(input: { slug: string; time: { created: number } }, instanc return path.join(base, [input.time.created, input.slug].join("-") + ".md") } +function metadataNumber(value: unknown): number | undefined { + if (typeof value !== "number") return undefined + return Number.isFinite(value) && value >= 0 ? value : undefined +} + +function metadataRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + return value as Record +} + +function metadataUsage(value: ProviderMetadata | undefined) { + if (!value) return + const roots = Object.values(value) + .map(metadataRecord) + .filter((item): item is Record => !!item) + if (roots.length === 0) return + const usage = roots.map((item) => metadataRecord(item.usage)).find((item): item is Record => !!item) + return usage ?? roots[0] +} + export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?: ProviderMetadata }) => { const safe = (value: number) => { if (!Number.isFinite(value)) return 0 return Math.max(0, value) } + const metadata = metadataUsage(input.metadata) const inputTokens = safe(input.usage.inputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0) const reasoningTokens = safe(input.usage.reasoningTokens ?? 0) - const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0) + const cacheReadInputTokens = safe( + Number( + input.usage.cacheReadInputTokens ?? + metadataNumber(metadataRecord(metadata?.prompt_tokens_details)?.cached_tokens) ?? + metadataNumber(metadata?.cached_tokens) ?? + 0, + ), + ) const cacheWriteInputTokens = safe( Number( input.usage.cacheWriteInputTokens ?? @@ -356,6 +384,8 @@ export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata? input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? // @ts-expect-error input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? + metadataNumber(metadata?.cache_creation_input_tokens) ?? + metadataNumber(metadata?.cacheWriteInputTokens) ?? 0, ), ) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 61aac13ac395..0e92da20c573 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -503,6 +503,106 @@ describe("session.llm.ai-sdk adapter", () => { expect(result.tokens.cache.read).toBe(200) }) + test("extracts cache read tokens from openai-compatible metadata usage fallback", async () => { + const result = SessionNs.getUsage({ + model: { + id: "custom-gpt", + providerID: "custom-provider", + name: "Custom GPT", + 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: { inputTokens: 1000, outputTokens: 200, totalTokens: 1200 } as never, + metadata: { + custom: { + usage: { + prompt_tokens_details: { + cached_tokens: 450, + }, + }, + }, + }, + }) + + expect(result.tokens.cache.read).toBe(450) + }) + + test("extracts cache write tokens from openai-compatible metadata usage", async () => { + const result = SessionNs.getUsage({ + model: { + id: "custom-gpt", + providerID: "custom-provider", + name: "Custom GPT", + 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: { inputTokens: 1000, outputTokens: 200, totalTokens: 1200 } as never, + metadata: { + custom: { + usage: { + cache_creation_input_tokens: 200, + }, + }, + }, + }) + + expect(result.tokens.cache.write).toBe(200) + }) + + test("respects usage.cacheReadInputTokens over metadata", async () => { + const result = SessionNs.getUsage({ + model: { + id: "custom-gpt", + providerID: "custom-provider", + name: "Custom GPT", + 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: { inputTokens: 1000, outputTokens: 200, totalTokens: 1200, cacheReadInputTokens: 100 } as never, + metadata: { + custom: { + usage: { + prompt_tokens_details: { + cached_tokens: 450, + }, + }, + }, + }, + }) + + expect(result.tokens.cache.read).toBe(100) + }) + test("captures Copilot billed usage from raw Anthropic message deltas per step", async () => { const events = await adapt([ uncheckedAdapterEvent({