diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 80620d3463..42e149f03a 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -370,10 +370,8 @@ const mapFinishReason = (reason: string): FinishReason => { // reasoning tokens for any current model. const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => { if (!usage) return undefined - const inputTokens = ProviderShared.subtractTokens( - ProviderShared.subtractTokens(usage.inputTokens, usage.cacheReadInputTokens), - usage.cacheWriteInputTokens, - ) + const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0) + const inputTokens = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal) return new Usage({ inputTokens, outputTokens: usage.outputTokens, diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 09165d502d..e7613903aa 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -292,9 +292,8 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => { // OpenAI Chat reports `prompt_tokens` as the total prompt (cached tokens // included) and `completion_tokens` as the total output (reasoning tokens -// included). The additive `LLM.Usage` contract pulls each subtotal out at -// the boundary so consumers never subtract — eliminating the underflow -// class addressed by opencode#26620. +// included). Pull each subtotal out at the boundary so the additive +// `LLM.Usage` contract holds and consumers never subtract. const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { if (!usage) return undefined const cached = usage.prompt_tokens_details?.cached_tokens diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 6c7d91fe43..e8a2a18892 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -46,21 +46,12 @@ export class Usage extends Schema.Class("LLM.Usage")({ }) {} export namespace Usage { - type InputFields = Pick - type OutputFields = Pick - - /** - * Sum of every input-side category: non-cached input + cache reads + - * cache writes. Monotonic; cannot underflow under the additive contract. - */ - export const totalInput = (usage: InputFields) => + /** Sum of every input-side category. Monotonic under the additive contract. */ + export const totalInput = (usage: Usage) => (usage.inputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0) - /** - * Sum of every output-side category: visible output + reasoning. - * Monotonic; cannot underflow under the additive contract. - */ - export const totalOutput = (usage: OutputFields) => (usage.outputTokens ?? 0) + (usage.reasoningTokens ?? 0) + /** Sum of every output-side category. Monotonic under the additive contract. */ + export const totalOutput = (usage: Usage) => (usage.outputTokens ?? 0) + (usage.reasoningTokens ?? 0) } export const RequestStart = Schema.Struct({ diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index 55d77a4e85..7143798e0f 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -197,9 +197,6 @@ describe("Gemini route", () => { expect(response.text).toBe("Hello!") expect(response.reasoning).toBe("thinking") expect(response.usage).toMatchObject({ - // Additive contract: promptTokenCount=5 includes 1 cached, so - // inputTokens=4 + cacheReadInputTokens=1. Gemini already splits - // candidates from thoughts, so outputTokens=2 + reasoningTokens=1. inputTokens: 4, outputTokens: 2, reasoningTokens: 1, diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 1938580f3b..2c9cf7d3bc 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -231,9 +231,6 @@ describe("OpenAI Chat route", () => { type: "request-finish", reason: "stop", usage: { - // Additive contract: prompt_tokens=5 includes 1 cached, so - // inputTokens=4 (non-cached) + cacheReadInputTokens=1. - // completion_tokens=2 includes 0 reasoning, so outputTokens=2. inputTokens: 4, outputTokens: 2, reasoningTokens: 0, diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 8f232854a9..787ef9ba90 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -343,9 +343,6 @@ describe("OpenAI Responses route", () => { reason: "stop", providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, usage: { - // Additive contract: input_tokens=5 includes 1 cached, so - // inputTokens=4 + cacheReadInputTokens=1. - // output_tokens=2 includes 0 reasoning, so outputTokens=2. inputTokens: 4, outputTokens: 2, reasoningTokens: 0,