diff --git a/.changeset/bright-sols-write.md b/.changeset/bright-sols-write.md new file mode 100644 index 00000000000..b008ad24778 --- /dev/null +++ b/.changeset/bright-sols-write.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/ai": patch +--- + +Report OpenAI prompt cache write tokens in normalized usage. diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 4cd54573196..249f6a93b43 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -167,7 +167,12 @@ export type OpenResponsesBody = Schema.Schema.Type const OpenResponsesUsage = Schema.Struct({ input_tokens: Schema.optional(Schema.Number), - input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })), + input_tokens_details: optionalNull( + Schema.Struct({ + cached_tokens: Schema.optional(Schema.Number), + cache_write_tokens: Schema.optional(Schema.Number), + }), + ), output_tokens: Schema.optional(Schema.Number), output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })), total_tokens: Schema.optional(Schema.Number), @@ -540,19 +545,21 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* ( // Stream Parsing // ============================================================================= // Responses APIs report `input_tokens` (inclusive total) with a -// `cached_tokens` subset, and `output_tokens` (inclusive total) with a -// `reasoning_tokens` subset. Pass the totals through and derive the +// cached-read and cache-write subsets, and `output_tokens` (inclusive total) +// with a `reasoning_tokens` subset. Pass the totals through and derive the // non-cached breakdown. const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => { if (!usage) return undefined const cached = usage.input_tokens_details?.cached_tokens + const cacheWrite = usage.input_tokens_details?.cache_write_tokens const reasoning = usage.output_tokens_details?.reasoning_tokens - const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached) + const nonCached = ProviderShared.subtractTokens(usage.input_tokens, ProviderShared.sumTokens(cached, cacheWrite)) return new Usage({ inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, nonCachedInputTokens: nonCached, cacheReadInputTokens: cached, + cacheWriteInputTokens: cacheWrite, reasoningTokens: reasoning, totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens), providerMetadata: { [providerMetadataKey]: usage }, diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 0e327f2abf2..e130f0bdfe4 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -131,6 +131,7 @@ const OpenAIChatUsage = Schema.Struct({ prompt_tokens_details: optionalNull( Schema.Struct({ cached_tokens: Schema.optional(Schema.Number), + cache_write_tokens: Schema.optional(Schema.Number), }), ), completion_tokens_details: optionalNull( @@ -453,20 +454,22 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => { } // OpenAI Chat reports `prompt_tokens` (inclusive total) with a -// `cached_tokens` subset, and `completion_tokens` (inclusive total) with -// a `reasoning_tokens` subset. We pass the inclusive totals through and -// derive the non-cached breakdown so the `LLM.Usage` contract is +// cached-read and cache-write subsets, and `completion_tokens` (inclusive +// total) with a `reasoning_tokens` subset. We pass the inclusive totals +// through and derive the non-cached breakdown so the `LLM.Usage` contract is // satisfied on both sides. const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { if (!usage) return undefined const cached = usage.prompt_tokens_details?.cached_tokens + const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens const reasoning = usage.completion_tokens_details?.reasoning_tokens - const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached) + const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite)) return new Usage({ inputTokens: usage.prompt_tokens, outputTokens: usage.completion_tokens, nonCachedInputTokens: nonCached, cacheReadInputTokens: cached, + cacheWriteInputTokens: cacheWrite, reasoningTokens: reasoning, totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens), providerMetadata: { openai: usage }, diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 926ea30dba0..57ae8e86530 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -550,7 +550,7 @@ describe("OpenAI Chat route", () => { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7, - prompt_tokens_details: { cached_tokens: 1 }, + prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, completion_tokens_details: { reasoning_tokens: 0 }, }), ) @@ -558,8 +558,9 @@ describe("OpenAI Chat route", () => { const usage = new Usage({ inputTokens: 5, outputTokens: 2, - nonCachedInputTokens: 4, + nonCachedInputTokens: 2, cacheReadInputTokens: 1, + cacheWriteInputTokens: 2, reasoningTokens: 0, totalTokens: 7, providerMetadata: { @@ -567,7 +568,7 @@ describe("OpenAI Chat route", () => { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7, - prompt_tokens_details: { cached_tokens: 1 }, + prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, completion_tokens_details: { reasoning_tokens: 0 }, }, }, diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index cf66e5cbd58..43d19ed42ec 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -832,7 +832,7 @@ describe("OpenAI Responses route", () => { input_tokens: 5, output_tokens: 2, total_tokens: 7, - input_tokens_details: { cached_tokens: 1 }, + input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, output_tokens_details: { reasoning_tokens: 0 }, }, }, @@ -842,8 +842,9 @@ describe("OpenAI Responses route", () => { const usage = new Usage({ inputTokens: 5, outputTokens: 2, - nonCachedInputTokens: 4, + nonCachedInputTokens: 2, cacheReadInputTokens: 1, + cacheWriteInputTokens: 2, reasoningTokens: 0, totalTokens: 7, providerMetadata: { @@ -851,7 +852,7 @@ describe("OpenAI Responses route", () => { input_tokens: 5, output_tokens: 2, total_tokens: 7, - input_tokens_details: { cached_tokens: 1 }, + input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, output_tokens_details: { reasoning_tokens: 0 }, }, },