From b9451175a6b456444e9e5a679ae3d315d36bf3b8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 13:07:58 -0400 Subject: [PATCH 1/8] refactor(llm): make LLM.Usage a fully-additive contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines a single invariant for `LLM.Usage`: every field is non-negative and every meaningful aggregate is a *sum*, never a difference. Total billable input = inputTokens + cacheReadInputTokens + cacheWriteInputTokens. Total billable output = outputTokens + reasoningTokens. Adding two non-negatives cannot underflow, so consumers can no longer reproduce the underflow-then-clamp bug class fixed by #26620. Each protocol mapper now enforces the contract at the provider boundary via `ProviderShared.subtractTokens`, which clamps with `Math.max(0, …)` for defense against provider bugs: - OpenAI Chat / Responses: pull `cached_tokens` out of `prompt_tokens` / `input_tokens`; pull `reasoning_tokens` out of `completion_tokens` / `output_tokens`. The provider's `total_tokens` is preserved verbatim. - Gemini: pull `cachedContentTokenCount` out of `promptTokenCount`. Gemini already split visible candidates from thoughts. - Bedrock: pull `cacheReadInputTokens` and `cacheWriteInputTokens` out of `inputTokens`, matching AWS prompt-caching docs. - Anthropic: already non-overlapping per the Messages API; pass through. Adds `Usage.totalInput` / `Usage.totalOutput` helpers for callers that want the merged view, and a regression test covering the clamp behavior. The reasoning underflow fixed in #26620 was the most visible symptom of a broader semantic inconsistency in this package: providers also disagreed on whether `inputTokens` includes cache reads (Anthropic excluded; OpenAI/Gemini/Bedrock included), which would silently double-subtract the moment v2 wired LLM.Usage into Session.getUsage. Normalizing now, pre-integration, closes both holes in one move. --- .../llm/src/protocols/anthropic-messages.ts | 8 +++ .../llm/src/protocols/bedrock-converse.ts | 13 ++++- packages/llm/src/protocols/gemini.ts | 13 +++-- packages/llm/src/protocols/openai-chat.ts | 19 +++++-- .../llm/src/protocols/openai-responses.ts | 18 +++++-- packages/llm/src/protocols/shared.ts | 29 +++++++++++ packages/llm/src/schema/events.ts | 50 +++++++++++++++++++ packages/llm/test/provider/gemini.test.ts | 7 ++- .../llm/test/provider/openai-chat.test.ts | 5 +- .../test/provider/openai-responses.test.ts | 5 +- packages/llm/test/schema.test.ts | 28 ++++++++++- 11 files changed, 175 insertions(+), 20 deletions(-) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index fba785373d..afef20f1fb 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -364,6 +364,14 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => { return "unknown" } +// Anthropic already reports input/cache-read/cache-write as separate +// non-overlapping categories per the Messages API docs, so the additive +// `LLM.Usage` contract is satisfied by direct pass-through. Extended +// thinking tokens are *not* broken out by Anthropic — they're billed as +// part of `output_tokens`, so `outputTokens` here may include reasoning +// the same way OpenAI's `output_tokens` does pre-normalization. This is +// a documented limitation of the Anthropic API surface, not a contract +// violation. const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => { if (!usage) return undefined return new Usage({ diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 260ee612cd..80620d3463 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -363,12 +363,21 @@ const mapFinishReason = (reason: string): FinishReason => { return "unknown" } +// AWS Bedrock Converse reports `inputTokens` as the total prompt with +// cached and cache-write tokens included (per the Bedrock prompt-caching +// docs). Pull each subtotal out at the boundary so the additive +// `LLM.Usage` contract holds. Bedrock does not separately report +// 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, + ) return new Usage({ - inputTokens: usage.inputTokens, + inputTokens, outputTokens: usage.outputTokens, - totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens), + totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens), cacheReadInputTokens: usage.cacheReadInputTokens, cacheWriteInputTokens: usage.cacheWriteInputTokens, native: usage, diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 140da521a5..fbb03d1fd8 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -281,14 +281,21 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque // ============================================================================= // Stream Parsing // ============================================================================= +// Gemini reports `promptTokenCount` as the total prompt with cached +// content included, but `candidatesTokenCount` already excludes +// `thoughtsTokenCount` (visible vs reasoning are separate). Pull the +// cached portion out at the boundary so the additive `LLM.Usage` contract +// holds across providers. const mapUsage = (usage: GeminiUsage | undefined) => { if (!usage) return undefined + const cached = usage.cachedContentTokenCount + const inputTokens = ProviderShared.subtractTokens(usage.promptTokenCount, cached) return new Usage({ - inputTokens: usage.promptTokenCount, + inputTokens, outputTokens: usage.candidatesTokenCount, reasoningTokens: usage.thoughtsTokenCount, - cacheReadInputTokens: usage.cachedContentTokenCount, - totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, usage.candidatesTokenCount, usage.totalTokenCount), + cacheReadInputTokens: cached, + totalTokens: ProviderShared.totalTokens(inputTokens, usage.candidatesTokenCount, usage.totalTokenCount), native: usage, }) } diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 5d42c0a4e9..09165d502d 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -290,14 +290,23 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => { return "unknown" } +// 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. const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { if (!usage) return undefined + const cached = usage.prompt_tokens_details?.cached_tokens + const reasoning = usage.completion_tokens_details?.reasoning_tokens + const inputTokens = ProviderShared.subtractTokens(usage.prompt_tokens, cached) + const outputTokens = ProviderShared.subtractTokens(usage.completion_tokens, reasoning) return new Usage({ - inputTokens: usage.prompt_tokens, - outputTokens: usage.completion_tokens, - reasoningTokens: usage.completion_tokens_details?.reasoning_tokens, - cacheReadInputTokens: usage.prompt_tokens_details?.cached_tokens, - totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens), + inputTokens, + outputTokens, + reasoningTokens: reasoning, + cacheReadInputTokens: cached, + totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, usage.total_tokens), native: usage, }) } diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 14dc32130c..6a0918efb8 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -276,14 +276,22 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: // ============================================================================= // Stream Parsing // ============================================================================= +// OpenAI Responses reports `input_tokens` as the total prompt (cached +// included) and `output_tokens` as the total output (reasoning included). +// The additive `LLM.Usage` contract pulls each subtotal out at the boundary +// so consumers never subtract. const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => { if (!usage) return undefined + const cached = usage.input_tokens_details?.cached_tokens + const reasoning = usage.output_tokens_details?.reasoning_tokens + const inputTokens = ProviderShared.subtractTokens(usage.input_tokens, cached) + const outputTokens = ProviderShared.subtractTokens(usage.output_tokens, reasoning) return new Usage({ - inputTokens: usage.input_tokens, - outputTokens: usage.output_tokens, - reasoningTokens: usage.output_tokens_details?.reasoning_tokens, - cacheReadInputTokens: usage.input_tokens_details?.cached_tokens, - totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens), + inputTokens, + outputTokens, + reasoningTokens: reasoning, + cacheReadInputTokens: cached, + totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, usage.total_tokens), native: usage, }) } diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index c931353998..79e019097e 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -42,6 +42,13 @@ export interface ToolAccumulator { * supplied total; otherwise falls back to `inputTokens + outputTokens` only * when at least one is defined. Returns `undefined` when neither input nor * output is known so routes don't publish a misleading `0`. + * + * Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens` + * are the non-cached input and visible output only. The provider-supplied + * `total` is the source of truth when present; the computed fallback + * under-counts cache and reasoning by design and exists mainly so + * Anthropic-style providers (which don't surface a total) still get a + * sensible aggregate on the input + output axes. */ export const totalTokens = ( inputTokens: number | undefined, @@ -53,6 +60,28 @@ export const totalTokens = ( return (inputTokens ?? 0) + (outputTokens ?? 0) } +/** + * Subtract `subtrahend` from `total`, clamping to zero if the provider + * reports a non-sensical breakdown (e.g. `cached_tokens > prompt_tokens`). + * Used by protocol mappers to enforce the additive `LLM.Usage` contract: + * each provider's "inclusive" subtotals (cached, reasoning) are pulled out + * of the parent count at the boundary so downstream consumers never have to + * subtract — eliminating the underflow class of bug where a clamped + * difference would silently store the wrong value. + * + * If `total` is `undefined`, returns `undefined` (we don't fabricate + * counts). If `subtrahend` is `undefined`, returns `total` unchanged. The + * provider-native breakdown stays available on `Usage.native` for debugging. + */ +export const subtractTokens = ( + total: number | undefined, + subtrahend: number | undefined, +): number | undefined => { + if (total === undefined) return undefined + if (subtrahend === undefined) return total + return Math.max(0, total - subtrahend) +} + export const eventError = (route: string, message: string, raw?: string) => new LLMError({ module: "ProviderShared", diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index d0befe246e..6c7d91fe43 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -3,6 +3,38 @@ import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, import { ModelRef } from "./options" import { ToolResultValue } from "./messages" +/** + * Token usage reported by an LLM provider, normalized to a fully-additive + * contract so consumers never have to subtract. + * + * **Field semantics** (each non-negative; missing means "not reported"): + * + * - `inputTokens` — non-cached input tokens (the "fresh" prompt portion). + * - `cacheReadInputTokens` — input tokens served from cache. + * - `cacheWriteInputTokens` — input tokens written to cache. + * - `outputTokens` — visible output tokens (text + tool calls). + * - `reasoningTokens` — hidden reasoning / thinking tokens. + * - `totalTokens` — provider-supplied total, or sum of input + output as a + * fallback (see `ProviderShared.totalTokens`). + * - `native` — the provider's raw usage payload, preserved for debugging. + * + * **Invariant**: every aggregate of interest is a *sum*, never a difference. + * Total billable input = `inputTokens + cacheReadInputTokens + + * cacheWriteInputTokens`. Total billable output = `outputTokens + + * reasoningTokens`. Adding two non-negatives cannot underflow, so consumers + * cannot reproduce the underflow-then-clamp bug class where a stored + * negative gets rejected by a strict schema later. + * + * Each protocol mapper enforces this contract at the provider boundary. + * Providers that report cache or reasoning as subsets of input/output + * (OpenAI Chat/Responses, Gemini, Bedrock) have those subsets pulled out + * once via `ProviderShared.subtractTokens`, with `Math.max(0, …)` clamping + * for defense against provider bugs. Providers that already report + * separately (Anthropic) pass through. Where a provider doesn't surface a + * category at all (e.g. Anthropic does not break out extended-thinking + * tokens), the corresponding field is `undefined` and the parent count + * carries the combined total — a documented limitation of that API. + */ export class Usage extends Schema.Class("LLM.Usage")({ inputTokens: Schema.optional(Schema.Number), outputTokens: Schema.optional(Schema.Number), @@ -13,6 +45,24 @@ export class Usage extends Schema.Class("LLM.Usage")({ native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} +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) => + (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) +} + export const RequestStart = Schema.Struct({ type: Schema.tag("request-start"), id: ResponseID, diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index 9de4e0dc25..55d77a4e85 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -197,7 +197,10 @@ describe("Gemini route", () => { expect(response.text).toBe("Hello!") expect(response.reasoning).toBe("thinking") expect(response.usage).toMatchObject({ - inputTokens: 5, + // 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, cacheReadInputTokens: 1, @@ -211,7 +214,7 @@ describe("Gemini route", () => { type: "request-finish", reason: "stop", usage: { - inputTokens: 5, + inputTokens: 4, outputTokens: 2, reasoningTokens: 1, cacheReadInputTokens: 1, diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 8b0dfc2894..1938580f3b 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -231,7 +231,10 @@ describe("OpenAI Chat route", () => { type: "request-finish", reason: "stop", usage: { - inputTokens: 5, + // 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, cacheReadInputTokens: 1, diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 5141b44cc2..8f232854a9 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -343,7 +343,10 @@ describe("OpenAI Responses route", () => { reason: "stop", providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, usage: { - inputTokens: 5, + // 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, cacheReadInputTokens: 1, diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index 46eb85b075..7ef3247f8b 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" -import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID } from "../src/schema" +import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID, Usage } from "../src/schema" +import { ProviderShared } from "../src/protocols/shared" const model = new ModelRef({ id: ModelID.make("fake-model"), @@ -48,3 +49,28 @@ describe("llm schema", () => { expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false) }) }) + +describe("LLM.Usage additive contract", () => { + test("subtractTokens clamps non-sensical breakdowns to zero", () => { + // Defense against a provider reporting cached_tokens > prompt_tokens or + // reasoning_tokens > completion_tokens. The clamp prevents the negative + // values that triggered opencode#26620 from ever entering the pipeline. + expect(ProviderShared.subtractTokens(5, 3)).toBe(2) + expect(ProviderShared.subtractTokens(5, 10)).toBe(0) + expect(ProviderShared.subtractTokens(5, undefined)).toBe(5) + expect(ProviderShared.subtractTokens(undefined, 3)).toBeUndefined() + expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined() + }) + + test("totalInput sums every input-side category", () => { + expect(Usage.totalInput(new Usage({ inputTokens: 10, cacheReadInputTokens: 3, cacheWriteInputTokens: 2 }))).toBe(15) + expect(Usage.totalInput(new Usage({ inputTokens: 10 }))).toBe(10) + expect(Usage.totalInput(new Usage({}))).toBe(0) + }) + + test("totalOutput sums every output-side category", () => { + expect(Usage.totalOutput(new Usage({ outputTokens: 7, reasoningTokens: 4 }))).toBe(11) + expect(Usage.totalOutput(new Usage({ outputTokens: 7 }))).toBe(7) + expect(Usage.totalOutput(new Usage({}))).toBe(0) + }) +}) From 478f3ae50c7cad04a7a77da513f0623581577f9f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 13:22:49 -0400 Subject: [PATCH 2/8] refactor(llm): trim Usage helpers + Bedrock subtraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass: - Drop `Pick<>` type aliases on `Usage.totalInput` / `Usage.totalOutput` — the helpers can take `Usage` directly since every field is optional. - Collapse Bedrock's nested `subtractTokens(subtractTokens(...))` into a single subtraction against the summed cache subtotals. - Drop arithmetic-walkthrough comments in test fixtures (the raw fixture values are right next to the expected outputs). - Generalize the comment on `mapUsage` in `openai-chat.ts` so the rationale outlives the PR reference. --- packages/llm/src/protocols/bedrock-converse.ts | 6 ++---- packages/llm/src/protocols/openai-chat.ts | 5 ++--- packages/llm/src/schema/events.ts | 17 ++++------------- packages/llm/test/provider/gemini.test.ts | 3 --- packages/llm/test/provider/openai-chat.test.ts | 3 --- .../llm/test/provider/openai-responses.test.ts | 3 --- 6 files changed, 8 insertions(+), 29 deletions(-) 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, From 0d4f8d126f3cb2dedde4f7d904243b0ba42ce42a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 19:15:46 -0400 Subject: [PATCH 3/8] refactor(llm): drop Usage.totalInput / totalOutput helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The additive contract delivers value at the mapper boundary — every field is non-overlapping and non-negative, so any caller summing arbitrary subsets is correct by construction. Two-line helpers that just sum three or two known fields add API surface without paying for themselves, and there are no in-tree consumers today. If v2 wants them at integration time, the right place is a getter on the `Schema.Class` (matching the `LLMResponse.text` / `reasoning` / `toolCalls` pattern in the same file), not a static namespace helper. --- packages/llm/src/schema/events.ts | 9 --------- packages/llm/test/schema.test.ts | 18 +++--------------- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index e8a2a18892..187291e685 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -45,15 +45,6 @@ export class Usage extends Schema.Class("LLM.Usage")({ native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} -export namespace Usage { - /** 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. Monotonic under the additive contract. */ - export const totalOutput = (usage: Usage) => (usage.outputTokens ?? 0) + (usage.reasoningTokens ?? 0) -} - export const RequestStart = Schema.Struct({ type: Schema.tag("request-start"), id: ResponseID, diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index 7ef3247f8b..a64b0ff71c 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" -import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID, Usage } from "../src/schema" +import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID } from "../src/schema" import { ProviderShared } from "../src/protocols/shared" const model = new ModelRef({ @@ -53,24 +53,12 @@ describe("llm schema", () => { describe("LLM.Usage additive contract", () => { test("subtractTokens clamps non-sensical breakdowns to zero", () => { // Defense against a provider reporting cached_tokens > prompt_tokens or - // reasoning_tokens > completion_tokens. The clamp prevents the negative - // values that triggered opencode#26620 from ever entering the pipeline. + // reasoning_tokens > completion_tokens — the negative would otherwise + // round-trip through the pipeline and crash strict downstream schemas. expect(ProviderShared.subtractTokens(5, 3)).toBe(2) expect(ProviderShared.subtractTokens(5, 10)).toBe(0) expect(ProviderShared.subtractTokens(5, undefined)).toBe(5) expect(ProviderShared.subtractTokens(undefined, 3)).toBeUndefined() expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined() }) - - test("totalInput sums every input-side category", () => { - expect(Usage.totalInput(new Usage({ inputTokens: 10, cacheReadInputTokens: 3, cacheWriteInputTokens: 2 }))).toBe(15) - expect(Usage.totalInput(new Usage({ inputTokens: 10 }))).toBe(10) - expect(Usage.totalInput(new Usage({}))).toBe(0) - }) - - test("totalOutput sums every output-side category", () => { - expect(Usage.totalOutput(new Usage({ outputTokens: 7, reasoningTokens: 4 }))).toBe(11) - expect(Usage.totalOutput(new Usage({ outputTokens: 7 }))).toBe(7) - expect(Usage.totalOutput(new Usage({}))).toBe(0) - }) }) From f5d199db624fac3c7434464c226dbe27c5d415b3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 19:29:41 -0400 Subject: [PATCH 4/8] feat(llm): add Usage.totalInputTokens / totalOutputTokens getters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the `LLMResponse.text` / `reasoning` / `toolCalls` getter pattern in the same file — `usage.totalInputTokens` reads naturally and lives where the Usage data does. Both sums are monotonic under the additive contract, so callers no longer need to remember which fields are non-overlapping. Test fixtures that previously asserted with `usage: { ... }` plain literals are now wrapped with `new Usage({...})` to match the runtime shape the mappers actually produce (an instance, not a struct). --- packages/llm/src/schema/events.ts | 12 +++++++++++- .../llm/test/provider/anthropic-messages.test.ts | 4 ++-- packages/llm/test/provider/gemini.test.ts | 10 +++++----- packages/llm/test/provider/openai-chat.test.ts | 6 +++--- .../llm/test/provider/openai-responses.test.ts | 8 ++++---- packages/llm/test/schema.test.ts | 14 +++++++++++++- 6 files changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 187291e685..ee755e93e3 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -43,7 +43,17 @@ export class Usage extends Schema.Class("LLM.Usage")({ cacheWriteInputTokens: Schema.optional(Schema.Number), totalTokens: Schema.optional(Schema.Number), native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), -}) {} +}) { + /** Sum of every input-side category. Monotonic under the additive contract. */ + get totalInputTokens() { + return (this.inputTokens ?? 0) + (this.cacheReadInputTokens ?? 0) + (this.cacheWriteInputTokens ?? 0) + } + + /** Sum of every output-side category. Monotonic under the additive contract. */ + get totalOutputTokens() { + return (this.outputTokens ?? 0) + (this.reasoningTokens ?? 0) + } +} export const RequestStart = Schema.Struct({ type: Schema.tag("request-start"), diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 85900a1143..6541454cb5 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { CacheHint, LLM, LLMError } from "../../src" +import { CacheHint, LLM, LLMError, Usage } from "../../src" import { LLMClient } from "../../src/route" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import { it } from "../lib/effect" @@ -152,7 +152,7 @@ describe("Anthropic Messages route", () => { { type: "request-finish", reason: "tool-calls", - usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }, + usage: new Usage({ inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }), }, ]) }), diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index 7143798e0f..cd34360cce 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMError } from "../../src" +import { LLM, LLMError, Usage } from "../../src" import { LLMClient } from "../../src/route" import * as Gemini from "../../src/protocols/gemini" import { it } from "../lib/effect" @@ -210,7 +210,7 @@ describe("Gemini route", () => { { type: "request-finish", reason: "stop", - usage: { + usage: new Usage({ inputTokens: 4, outputTokens: 2, reasoningTokens: 1, @@ -223,7 +223,7 @@ describe("Gemini route", () => { thoughtsTokenCount: 1, cachedContentTokenCount: 1, }, - }, + }), }, ]) }), @@ -257,12 +257,12 @@ describe("Gemini route", () => { { type: "request-finish", reason: "tool-calls", - usage: { + usage: new Usage({ inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { promptTokenCount: 5, candidatesTokenCount: 1 }, - }, + }), }, ]) }), diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 2c9cf7d3bc..ecb1a81141 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError } from "../../src" +import { LLM, LLMError, Usage } from "../../src" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" import * as OpenAIChat from "../../src/protocols/openai-chat" @@ -230,7 +230,7 @@ describe("OpenAI Chat route", () => { { type: "request-finish", reason: "stop", - usage: { + usage: new Usage({ inputTokens: 4, outputTokens: 2, reasoningTokens: 0, @@ -243,7 +243,7 @@ describe("OpenAI Chat route", () => { prompt_tokens_details: { cached_tokens: 1 }, completion_tokens_details: { reasoning_tokens: 0 }, }, - }, + }), }, ]) }), diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 787ef9ba90..0723ddf816 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { ConfigProvider, Effect, Layer, Stream } from "effect" import { Headers, HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError } from "../../src" +import { LLM, LLMError, Usage } from "../../src" import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" @@ -342,7 +342,7 @@ describe("OpenAI Responses route", () => { type: "request-finish", reason: "stop", providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, - usage: { + usage: new Usage({ inputTokens: 4, outputTokens: 2, reasoningTokens: 0, @@ -355,7 +355,7 @@ describe("OpenAI Responses route", () => { input_tokens_details: { cached_tokens: 1 }, output_tokens_details: { reasoning_tokens: 0 }, }, - }, + }), }, ]) }), @@ -411,7 +411,7 @@ describe("OpenAI Responses route", () => { { type: "request-finish", reason: "tool-calls", - usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }, + usage: new Usage({ inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }), }, ]) }), diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index a64b0ff71c..9ddfe9e597 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" -import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID } from "../src/schema" +import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID, Usage } from "../src/schema" import { ProviderShared } from "../src/protocols/shared" const model = new ModelRef({ @@ -61,4 +61,16 @@ describe("LLM.Usage additive contract", () => { expect(ProviderShared.subtractTokens(undefined, 3)).toBeUndefined() expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined() }) + + test("totalInputTokens sums every input-side category", () => { + expect(new Usage({ inputTokens: 10, cacheReadInputTokens: 3, cacheWriteInputTokens: 2 }).totalInputTokens).toBe(15) + expect(new Usage({ inputTokens: 10 }).totalInputTokens).toBe(10) + expect(new Usage({}).totalInputTokens).toBe(0) + }) + + test("totalOutputTokens sums every output-side category", () => { + expect(new Usage({ outputTokens: 7, reasoningTokens: 4 }).totalOutputTokens).toBe(11) + expect(new Usage({ outputTokens: 7 }).totalOutputTokens).toBe(7) + expect(new Usage({}).totalOutputTokens).toBe(0) + }) }) From d4ff331052544e23c5b485a501e6fed16ff2539a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 20:39:22 -0400 Subject: [PATCH 5/8] refactor(llm): inclusive total + non-overlapping breakdown for Usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final shape after considering ecosystem conventions: inputTokens — inclusive total (matches AI SDK / OpenAI / LangChain) outputTokens — inclusive total (includes reasoning) nonCachedInputTokens — breakdown: fresh prompt cacheReadInputTokens — breakdown: cache hit cacheWriteInputTokens — breakdown: cache write reasoningTokens — subset of outputTokens Invariant: nonCached + cacheRead + cacheWrite = inputTokens reasoningTokens <= outputTokens Why this shape: - `inputTokens` keeps its AI-SDK / OpenAI semantics, so a reader from any major ecosystem sees the number they expect. - The non-overlapping breakdown fields are populated alongside the inclusive totals — consumers read whichever they need without subtracting. This eliminates the underflow bug class (opencode#26620) structurally without diverging on naming. - Aligns with the AI SDK v3 spec proposal (vercel/ai#9921), which adds exactly this kind of non-overlapping breakdown to address the active ecosystem bugs around cache token double-counting and underflow (pydantic-ai#4364, langfuse#12306/#11979, vercel/ai#8349, langchain#32818, langchainjs#10249). Mappers: - OpenAI Chat / Responses / Bedrock: provider reports inclusive totals natively; mapper derives `nonCachedInputTokens` via `ProviderShared.subtractTokens`. - Gemini: `promptTokenCount` is inclusive; `candidatesTokenCount` is *exclusive* of `thoughtsTokenCount`, so mapper sums those to produce the inclusive `outputTokens`. Only computes the total when the visible component is reported (avoids fabricating an inclusive number from a partial breakdown). - Anthropic: `input_tokens` is *non-cached* natively; mapper sums it with cache reads/writes to produce the inclusive `inputTokens`. `output_tokens` is inclusive (Anthropic doesn't break thinking out, so `reasoningTokens` stays undefined). Added a `visibleOutputTokens` getter (clamped `outputTokens - reasoningTokens`) as the one safe escape hatch for consumers wanting the non-reasoning view. Added `ProviderShared.sumTokens` to derive an inclusive total from a non-overlapping breakdown, returning `undefined` when every input is undefined (so we don't fabricate a 0). --- .../llm/src/protocols/anthropic-messages.ts | 41 ++++++---- .../llm/src/protocols/bedrock-converse.ts | 16 ++-- packages/llm/src/protocols/gemini.ts | 28 ++++--- packages/llm/src/protocols/openai-chat.ts | 21 ++--- .../llm/src/protocols/openai-responses.ts | 20 ++--- packages/llm/src/protocols/shared.ts | 20 +++-- packages/llm/src/schema/events.ts | 76 +++++++++++-------- .../test/provider/anthropic-messages.test.ts | 13 +++- packages/llm/test/provider/gemini.test.ts | 15 ++-- .../llm/test/provider/openai-chat.test.ts | 5 +- .../test/provider/openai-responses.test.ts | 13 +++- packages/llm/test/schema.test.ts | 20 ++--- 12 files changed, 173 insertions(+), 115 deletions(-) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index afef20f1fb..f9b7ef523a 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -364,40 +364,49 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => { return "unknown" } -// Anthropic already reports input/cache-read/cache-write as separate -// non-overlapping categories per the Messages API docs, so the additive -// `LLM.Usage` contract is satisfied by direct pass-through. Extended +// Anthropic reports the non-overlapping breakdown natively — its +// `input_tokens` is the *non-cached* count per the Messages API docs, with +// cache reads and writes as separate fields. We sum them to derive the +// inclusive `inputTokens` the rest of the contract expects. Extended // thinking tokens are *not* broken out by Anthropic — they're billed as -// part of `output_tokens`, so `outputTokens` here may include reasoning -// the same way OpenAI's `output_tokens` does pre-normalization. This is -// a documented limitation of the Anthropic API surface, not a contract -// violation. +// part of `output_tokens`, so `reasoningTokens` stays `undefined` and +// `outputTokens` carries the combined total. const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => { if (!usage) return undefined + const nonCached = usage.input_tokens + const cacheRead = usage.cache_read_input_tokens ?? undefined + const cacheWrite = usage.cache_creation_input_tokens ?? undefined + const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite) return new Usage({ - inputTokens: usage.input_tokens, + inputTokens, outputTokens: usage.output_tokens, - cacheReadInputTokens: usage.cache_read_input_tokens ?? undefined, - cacheWriteInputTokens: usage.cache_creation_input_tokens ?? undefined, - totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, undefined), + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cacheRead, + cacheWriteInputTokens: cacheWrite, + totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined), native: usage, }) } // Anthropic emits usage on `message_start` and again on `message_delta` — the // final delta carries the authoritative totals. Right-biased merge: each -// field prefers `right` when defined, falls back to `left`. `totalTokens` is -// recomputed from the merged input/output to stay consistent. +// field prefers `right` when defined, falls back to `left`. `inputTokens` is +// recomputed from the merged breakdown so the inclusive total stays +// consistent with `nonCached + cacheRead + cacheWrite`. const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => { if (!left) return right if (!right) return left - const inputTokens = right.inputTokens ?? left.inputTokens + const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens + const cacheReadInputTokens = right.cacheReadInputTokens ?? left.cacheReadInputTokens + const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens + const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens) const outputTokens = right.outputTokens ?? left.outputTokens return new Usage({ inputTokens, outputTokens, - cacheReadInputTokens: right.cacheReadInputTokens ?? left.cacheReadInputTokens, - cacheWriteInputTokens: right.cacheWriteInputTokens ?? left.cacheWriteInputTokens, + nonCachedInputTokens, + cacheReadInputTokens, + cacheWriteInputTokens, totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), native: { ...left.native, ...right.native }, }) diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 42e149f03a..8385c7fe51 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -363,21 +363,21 @@ const mapFinishReason = (reason: string): FinishReason => { return "unknown" } -// AWS Bedrock Converse reports `inputTokens` as the total prompt with -// cached and cache-write tokens included (per the Bedrock prompt-caching -// docs). Pull each subtotal out at the boundary so the additive -// `LLM.Usage` contract holds. Bedrock does not separately report -// reasoning tokens for any current model. +// AWS Bedrock Converse reports `inputTokens` (inclusive total) with +// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass +// the total through and derive the non-cached breakdown. Bedrock does +// not break reasoning out of `outputTokens` for any current model. const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => { if (!usage) return undefined const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0) - const inputTokens = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal) + const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal) return new Usage({ - inputTokens, + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, - totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens), + nonCachedInputTokens: nonCached, cacheReadInputTokens: usage.cacheReadInputTokens, cacheWriteInputTokens: usage.cacheWriteInputTokens, + totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens), native: usage, }) } diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index fbb03d1fd8..f78a6c9e87 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -281,21 +281,29 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque // ============================================================================= // Stream Parsing // ============================================================================= -// Gemini reports `promptTokenCount` as the total prompt with cached -// content included, but `candidatesTokenCount` already excludes -// `thoughtsTokenCount` (visible vs reasoning are separate). Pull the -// cached portion out at the boundary so the additive `LLM.Usage` contract -// holds across providers. +// Gemini reports `promptTokenCount` (inclusive total) with a +// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive* +// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two +// to produce the inclusive `outputTokens` the rest of the contract expects. const mapUsage = (usage: GeminiUsage | undefined) => { if (!usage) return undefined const cached = usage.cachedContentTokenCount - const inputTokens = ProviderShared.subtractTokens(usage.promptTokenCount, cached) + const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached) + // `candidatesTokenCount` is visible-only; sum with thoughts to produce the + // inclusive `outputTokens` the contract expects. Only compute the total + // when the visible component is reported — otherwise we'd fabricate an + // inclusive number from a partial breakdown. + const outputTokens = + usage.candidatesTokenCount !== undefined + ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) + : undefined return new Usage({ - inputTokens, - outputTokens: usage.candidatesTokenCount, - reasoningTokens: usage.thoughtsTokenCount, + inputTokens: usage.promptTokenCount, + outputTokens, + nonCachedInputTokens: nonCached, cacheReadInputTokens: cached, - totalTokens: ProviderShared.totalTokens(inputTokens, usage.candidatesTokenCount, usage.totalTokenCount), + reasoningTokens: usage.thoughtsTokenCount, + totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount), native: usage, }) } diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index e7613903aa..6633f1bfed 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -290,22 +290,23 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => { return "unknown" } -// OpenAI Chat reports `prompt_tokens` as the total prompt (cached tokens -// included) and `completion_tokens` as the total output (reasoning tokens -// included). Pull each subtotal out at the boundary so the additive -// `LLM.Usage` contract holds and consumers never subtract. +// 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 +// satisfied on both sides. const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { if (!usage) return undefined const cached = usage.prompt_tokens_details?.cached_tokens const reasoning = usage.completion_tokens_details?.reasoning_tokens - const inputTokens = ProviderShared.subtractTokens(usage.prompt_tokens, cached) - const outputTokens = ProviderShared.subtractTokens(usage.completion_tokens, reasoning) + const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached) return new Usage({ - inputTokens, - outputTokens, - reasoningTokens: reasoning, + inputTokens: usage.prompt_tokens, + outputTokens: usage.completion_tokens, + nonCachedInputTokens: nonCached, cacheReadInputTokens: cached, - totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, usage.total_tokens), + reasoningTokens: reasoning, + totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens), native: usage, }) } diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 6a0918efb8..a90a5d32c7 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -276,22 +276,22 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: // ============================================================================= // Stream Parsing // ============================================================================= -// OpenAI Responses reports `input_tokens` as the total prompt (cached -// included) and `output_tokens` as the total output (reasoning included). -// The additive `LLM.Usage` contract pulls each subtotal out at the boundary -// so consumers never subtract. +// OpenAI Responses reports `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 +// non-cached breakdown. const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => { if (!usage) return undefined const cached = usage.input_tokens_details?.cached_tokens const reasoning = usage.output_tokens_details?.reasoning_tokens - const inputTokens = ProviderShared.subtractTokens(usage.input_tokens, cached) - const outputTokens = ProviderShared.subtractTokens(usage.output_tokens, reasoning) + const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached) return new Usage({ - inputTokens, - outputTokens, - reasoningTokens: reasoning, + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + nonCachedInputTokens: nonCached, cacheReadInputTokens: cached, - totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, usage.total_tokens), + reasoningTokens: reasoning, + totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens), native: usage, }) } diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 79e019097e..3b9886553a 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -63,11 +63,9 @@ export const totalTokens = ( /** * Subtract `subtrahend` from `total`, clamping to zero if the provider * reports a non-sensical breakdown (e.g. `cached_tokens > prompt_tokens`). - * Used by protocol mappers to enforce the additive `LLM.Usage` contract: - * each provider's "inclusive" subtotals (cached, reasoning) are pulled out - * of the parent count at the boundary so downstream consumers never have to - * subtract — eliminating the underflow class of bug where a clamped - * difference would silently store the wrong value. + * Used by protocol mappers when deriving a non-overlapping breakdown field + * from a provider's inclusive total — `nonCachedInputTokens` from + * `inputTokens - cacheReadInputTokens - cacheWriteInputTokens`. * * If `total` is `undefined`, returns `undefined` (we don't fabricate * counts). If `subtrahend` is `undefined`, returns `total` unchanged. The @@ -82,6 +80,18 @@ export const subtractTokens = ( return Math.max(0, total - subtrahend) } +/** + * Sum a list of optional token counts, returning `undefined` only when + * every value is `undefined` (so we don't fabricate a `0`). Used by + * protocol mappers to derive the inclusive `inputTokens` total from a + * provider that natively reports a non-overlapping breakdown + * (e.g. Anthropic, whose `input_tokens` is already non-cached only). + */ +export const sumTokens = (...values: ReadonlyArray): number | undefined => { + if (values.every((value) => value === undefined)) return undefined + return values.reduce((acc, value) => acc + (value ?? 0), 0) +} + export const eventError = (route: string, message: string, raw?: string) => new LLMError({ module: "ProviderShared", diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index ee755e93e3..5c34a01b5c 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -4,54 +4,64 @@ import { ModelRef } from "./options" import { ToolResultValue } from "./messages" /** - * Token usage reported by an LLM provider, normalized to a fully-additive - * contract so consumers never have to subtract. + * Token usage reported by an LLM provider. * - * **Field semantics** (each non-negative; missing means "not reported"): + * **Inclusive totals** (match AI SDK / OpenAI / LangChain convention — a + * reader from any of those ecosystems sees the number they expect): * - * - `inputTokens` — non-cached input tokens (the "fresh" prompt portion). + * - `inputTokens` — total prompt tokens, *including* cached reads/writes. + * - `outputTokens` — total output tokens, *including* reasoning. + * - `totalTokens` — provider-supplied total, or `inputTokens + outputTokens`. + * + * **Non-overlapping breakdown** (every field is independently meaningful; + * consumers never have to subtract): + * + * - `nonCachedInputTokens` — the "fresh" portion of the prompt. * - `cacheReadInputTokens` — input tokens served from cache. * - `cacheWriteInputTokens` — input tokens written to cache. - * - `outputTokens` — visible output tokens (text + tool calls). - * - `reasoningTokens` — hidden reasoning / thinking tokens. - * - `totalTokens` — provider-supplied total, or sum of input + output as a - * fallback (see `ProviderShared.totalTokens`). - * - `native` — the provider's raw usage payload, preserved for debugging. + * - `reasoningTokens` — subset of `outputTokens` spent on hidden reasoning. * - * **Invariant**: every aggregate of interest is a *sum*, never a difference. - * Total billable input = `inputTokens + cacheReadInputTokens + - * cacheWriteInputTokens`. Total billable output = `outputTokens + - * reasoningTokens`. Adding two non-negatives cannot underflow, so consumers - * cannot reproduce the underflow-then-clamp bug class where a stored - * negative gets rejected by a strict schema later. + * **Invariant**: `nonCachedInputTokens + cacheReadInputTokens + + * cacheWriteInputTokens = inputTokens`, and `reasoningTokens ≤ outputTokens`. + * Each protocol mapper computes whichever side it doesn't get natively, + * with `Math.max(0, …)` clamping for defense against provider bugs. Because + * every breakdown field is stored independently, downstream consumers can + * read whatever they need (cost-by-category, context-pressure, AI-SDK-style + * inclusive total) without ever subtracting — eliminating the underflow + * class of bug where a clamped difference would silently store the wrong + * value. * - * Each protocol mapper enforces this contract at the provider boundary. - * Providers that report cache or reasoning as subsets of input/output - * (OpenAI Chat/Responses, Gemini, Bedrock) have those subsets pulled out - * once via `ProviderShared.subtractTokens`, with `Math.max(0, …)` clamping - * for defense against provider bugs. Providers that already report - * separately (Anthropic) pass through. Where a provider doesn't surface a - * category at all (e.g. Anthropic does not break out extended-thinking - * tokens), the corresponding field is `undefined` and the parent count - * carries the combined total — a documented limitation of that API. + * **Semantics by provider**: + * + * - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive + * `inputTokens` and an inclusive `outputTokens`; mapper subtracts to + * derive the breakdown. + * - Anthropic: provider reports the breakdown natively (`input_tokens` is + * non-cached only); mapper sums to derive the inclusive `inputTokens`. + * Anthropic does *not* break extended-thinking out of `output_tokens`, so + * `reasoningTokens` is `undefined` and `outputTokens` carries the + * combined total — a documented limitation of the Anthropic API. + * + * `native` always carries the provider's raw usage payload for debugging. */ export class Usage extends Schema.Class("LLM.Usage")({ inputTokens: Schema.optional(Schema.Number), outputTokens: Schema.optional(Schema.Number), - reasoningTokens: Schema.optional(Schema.Number), + nonCachedInputTokens: Schema.optional(Schema.Number), cacheReadInputTokens: Schema.optional(Schema.Number), cacheWriteInputTokens: Schema.optional(Schema.Number), + reasoningTokens: Schema.optional(Schema.Number), totalTokens: Schema.optional(Schema.Number), native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) { - /** Sum of every input-side category. Monotonic under the additive contract. */ - get totalInputTokens() { - return (this.inputTokens ?? 0) + (this.cacheReadInputTokens ?? 0) + (this.cacheWriteInputTokens ?? 0) - } - - /** Sum of every output-side category. Monotonic under the additive contract. */ - get totalOutputTokens() { - return (this.outputTokens ?? 0) + (this.reasoningTokens ?? 0) + /** + * Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped + * to zero. The one place subtraction happens in this contract; the clamp + * means a provider reporting `reasoningTokens > outputTokens` produces a + * harmless zero rather than a negative that crashes downstream schemas. + */ + get visibleOutputTokens() { + return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0)) } } diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 6541454cb5..eb867530c3 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -110,10 +110,11 @@ describe("Anthropic Messages route", () => { expect(response.text).toBe("Hello!") expect(response.reasoning).toBe("thinking") expect(response.usage).toMatchObject({ - inputTokens: 5, + inputTokens: 6, outputTokens: 2, + nonCachedInputTokens: 5, cacheReadInputTokens: 1, - totalTokens: 7, + totalTokens: 8, }) expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ providerMetadata: { anthropic: { signature: "sig_1" } }, @@ -152,7 +153,13 @@ describe("Anthropic Messages route", () => { { type: "request-finish", reason: "tool-calls", - usage: new Usage({ inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }), + usage: new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + totalTokens: 6, + native: { input_tokens: 5, output_tokens: 1 }, + }), }, ]) }), diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index cd34360cce..50f597b429 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -197,10 +197,11 @@ describe("Gemini route", () => { expect(response.text).toBe("Hello!") expect(response.reasoning).toBe("thinking") expect(response.usage).toMatchObject({ - inputTokens: 4, - outputTokens: 2, - reasoningTokens: 1, + inputTokens: 5, + outputTokens: 3, + nonCachedInputTokens: 4, cacheReadInputTokens: 1, + reasoningTokens: 1, totalTokens: 7, }) expect(response.events).toEqual([ @@ -211,10 +212,11 @@ describe("Gemini route", () => { type: "request-finish", reason: "stop", usage: new Usage({ - inputTokens: 4, - outputTokens: 2, - reasoningTokens: 1, + inputTokens: 5, + outputTokens: 3, + nonCachedInputTokens: 4, cacheReadInputTokens: 1, + reasoningTokens: 1, totalTokens: 7, native: { promptTokenCount: 5, @@ -260,6 +262,7 @@ describe("Gemini route", () => { usage: new Usage({ inputTokens: 5, outputTokens: 1, + nonCachedInputTokens: 5, totalTokens: 6, native: { promptTokenCount: 5, candidatesTokenCount: 1 }, }), diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index ecb1a81141..1bac72ba64 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -231,10 +231,11 @@ describe("OpenAI Chat route", () => { type: "request-finish", reason: "stop", usage: new Usage({ - inputTokens: 4, + inputTokens: 5, outputTokens: 2, - reasoningTokens: 0, + nonCachedInputTokens: 4, cacheReadInputTokens: 1, + reasoningTokens: 0, totalTokens: 7, native: { prompt_tokens: 5, diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 0723ddf816..3cdb3e070b 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -343,10 +343,11 @@ describe("OpenAI Responses route", () => { reason: "stop", providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, usage: new Usage({ - inputTokens: 4, + inputTokens: 5, outputTokens: 2, - reasoningTokens: 0, + nonCachedInputTokens: 4, cacheReadInputTokens: 1, + reasoningTokens: 0, totalTokens: 7, native: { input_tokens: 5, @@ -411,7 +412,13 @@ describe("OpenAI Responses route", () => { { type: "request-finish", reason: "tool-calls", - usage: new Usage({ inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }), + usage: new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + totalTokens: 6, + native: { input_tokens: 5, output_tokens: 1 }, + }), }, ]) }), diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index 9ddfe9e597..23bd9fd9bb 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -50,7 +50,7 @@ describe("llm schema", () => { }) }) -describe("LLM.Usage additive contract", () => { +describe("LLM.Usage", () => { test("subtractTokens clamps non-sensical breakdowns to zero", () => { // Defense against a provider reporting cached_tokens > prompt_tokens or // reasoning_tokens > completion_tokens — the negative would otherwise @@ -62,15 +62,17 @@ describe("LLM.Usage additive contract", () => { expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined() }) - test("totalInputTokens sums every input-side category", () => { - expect(new Usage({ inputTokens: 10, cacheReadInputTokens: 3, cacheWriteInputTokens: 2 }).totalInputTokens).toBe(15) - expect(new Usage({ inputTokens: 10 }).totalInputTokens).toBe(10) - expect(new Usage({}).totalInputTokens).toBe(0) + test("sumTokens returns undefined only when every input is undefined", () => { + expect(ProviderShared.sumTokens(1, 2, 3)).toBe(6) + expect(ProviderShared.sumTokens(1, undefined, 3)).toBe(4) + expect(ProviderShared.sumTokens(undefined, undefined, undefined)).toBeUndefined() + expect(ProviderShared.sumTokens()).toBeUndefined() }) - test("totalOutputTokens sums every output-side category", () => { - expect(new Usage({ outputTokens: 7, reasoningTokens: 4 }).totalOutputTokens).toBe(11) - expect(new Usage({ outputTokens: 7 }).totalOutputTokens).toBe(7) - expect(new Usage({}).totalOutputTokens).toBe(0) + test("visibleOutputTokens clamps reasoning > output to zero", () => { + expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6) + expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10) + expect(new Usage({ outputTokens: 4, reasoningTokens: 10 }).visibleOutputTokens).toBe(0) + expect(new Usage({}).visibleOutputTokens).toBe(0) }) }) From ab9b79ef88864c42f9c3b88e7314562ff6c35f85 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 21:42:09 -0400 Subject: [PATCH 6/8] refactor(llm): rename Usage.native to providerMetadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the escape-hatch field name with `LLMEvent.providerMetadata` used elsewhere in this package (and with AI SDK / pydantic-ai / LangChain conventions for the same idea). Two parallel escape hatches having different names was a wart. The raw payload is now wrapped under the provider key — `{ openai: ... }`, `{ anthropic: ... }`, `{ google: ... }`, `{ bedrock: ... }` — using the existing `ProviderMetadata = Record>` schema rather than a flat record. Same shape as `LLMEvent.providerMetadata`, so consumers downstream can read both with the same code. Anthropic's `mergeUsage` merges the per-provider sub-record across `message_start` and `message_delta` instead of spreading at the top level. --- packages/llm/src/protocols/anthropic-messages.ts | 9 +++++++-- packages/llm/src/protocols/bedrock-converse.ts | 2 +- packages/llm/src/protocols/gemini.ts | 2 +- packages/llm/src/protocols/openai-chat.ts | 2 +- packages/llm/src/protocols/openai-responses.ts | 2 +- packages/llm/src/schema/events.ts | 7 +++++-- .../llm/test/provider/anthropic-messages.test.ts | 2 +- packages/llm/test/provider/gemini.test.ts | 16 +++++++++------- packages/llm/test/provider/openai-chat.test.ts | 14 ++++++++------ .../llm/test/provider/openai-responses.test.ts | 16 +++++++++------- 10 files changed, 43 insertions(+), 29 deletions(-) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index f9b7ef523a..4f02248a9c 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -384,7 +384,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => { cacheReadInputTokens: cacheRead, cacheWriteInputTokens: cacheWrite, totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined), - native: usage, + providerMetadata: { anthropic: usage }, }) } @@ -408,7 +408,12 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => { cacheReadInputTokens, cacheWriteInputTokens, totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), - native: { ...left.native, ...right.native }, + providerMetadata: { + anthropic: { + ...(left.providerMetadata?.["anthropic"] ?? {}), + ...(right.providerMetadata?.["anthropic"] ?? {}), + }, + }, }) } diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 8385c7fe51..eadfda3a0b 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -378,7 +378,7 @@ const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => { cacheReadInputTokens: usage.cacheReadInputTokens, cacheWriteInputTokens: usage.cacheWriteInputTokens, totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens), - native: usage, + providerMetadata: { bedrock: usage }, }) } diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index f78a6c9e87..ff6f3f83ec 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -304,7 +304,7 @@ const mapUsage = (usage: GeminiUsage | undefined) => { cacheReadInputTokens: cached, reasoningTokens: usage.thoughtsTokenCount, totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount), - native: usage, + providerMetadata: { google: usage }, }) } diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 6633f1bfed..133adb503b 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -307,7 +307,7 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { cacheReadInputTokens: cached, reasoningTokens: reasoning, totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens), - native: usage, + providerMetadata: { openai: usage }, }) } diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index a90a5d32c7..035cc07713 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -292,7 +292,7 @@ const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => { cacheReadInputTokens: cached, reasoningTokens: reasoning, totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens), - native: usage, + providerMetadata: { openai: usage }, }) } diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 5c34a01b5c..6e6bb1541b 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -42,7 +42,10 @@ import { ToolResultValue } from "./messages" * `reasoningTokens` is `undefined` and `outputTokens` carries the * combined total — a documented limitation of the Anthropic API. * - * `native` always carries the provider's raw usage payload for debugging. + * `providerMetadata` always carries the provider's raw usage payload — + * keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.) + * — for fields we don't normalize and for billing-level audit trails. + * Matches the same escape-hatch field on `LLMEvent`. */ export class Usage extends Schema.Class("LLM.Usage")({ inputTokens: Schema.optional(Schema.Number), @@ -52,7 +55,7 @@ export class Usage extends Schema.Class("LLM.Usage")({ cacheWriteInputTokens: Schema.optional(Schema.Number), reasoningTokens: Schema.optional(Schema.Number), totalTokens: Schema.optional(Schema.Number), - native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), }) { /** * Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index eb867530c3..0005ae7dfe 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -158,7 +158,7 @@ describe("Anthropic Messages route", () => { outputTokens: 1, nonCachedInputTokens: 5, totalTokens: 6, - native: { input_tokens: 5, output_tokens: 1 }, + providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } }, }), }, ]) diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index 50f597b429..e0b3864a26 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -218,12 +218,14 @@ describe("Gemini route", () => { cacheReadInputTokens: 1, reasoningTokens: 1, totalTokens: 7, - native: { - promptTokenCount: 5, - candidatesTokenCount: 2, - totalTokenCount: 7, - thoughtsTokenCount: 1, - cachedContentTokenCount: 1, + providerMetadata: { + google: { + promptTokenCount: 5, + candidatesTokenCount: 2, + totalTokenCount: 7, + thoughtsTokenCount: 1, + cachedContentTokenCount: 1, + }, }, }), }, @@ -264,7 +266,7 @@ describe("Gemini route", () => { outputTokens: 1, nonCachedInputTokens: 5, totalTokens: 6, - native: { promptTokenCount: 5, candidatesTokenCount: 1 }, + providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } }, }), }, ]) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 1bac72ba64..2c692dcd7d 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -237,12 +237,14 @@ describe("OpenAI Chat route", () => { cacheReadInputTokens: 1, reasoningTokens: 0, totalTokens: 7, - native: { - prompt_tokens: 5, - completion_tokens: 2, - total_tokens: 7, - prompt_tokens_details: { cached_tokens: 1 }, - completion_tokens_details: { reasoning_tokens: 0 }, + providerMetadata: { + openai: { + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + prompt_tokens_details: { cached_tokens: 1 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, }, }), }, diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 3cdb3e070b..2319857ed1 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -349,12 +349,14 @@ describe("OpenAI Responses route", () => { cacheReadInputTokens: 1, reasoningTokens: 0, totalTokens: 7, - native: { - input_tokens: 5, - output_tokens: 2, - total_tokens: 7, - input_tokens_details: { cached_tokens: 1 }, - output_tokens_details: { reasoning_tokens: 0 }, + providerMetadata: { + openai: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + input_tokens_details: { cached_tokens: 1 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, }, }), }, @@ -417,7 +419,7 @@ describe("OpenAI Responses route", () => { outputTokens: 1, nonCachedInputTokens: 5, totalTokens: 6, - native: { input_tokens: 5, output_tokens: 1 }, + providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } }, }), }, ]) From d048bd6f4b7cfde6ced9edea174fc2cae99a9a18 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 22:03:02 -0400 Subject: [PATCH 7/8] test(llm): re-record golden scenarios against live providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies the new Usage mapper code against live provider responses for OpenAI Chat, OpenAI Responses, Anthropic, Gemini, DeepSeek, and TogetherAI — 16 fresh recordings, all assertions pass. No existing cassettes were modified; these populate test slots that were previously skipped in replay mode. Recorded via: set -a; source .env.recorded.local; set +a RECORD=true bun test test/provider/*.recorded.test.ts Redactor stripped all auth headers; no secrets in the cassettes. --- .../anthropic-haiku-4-5-text.json | 38 +++++ .../anthropic-haiku-4-5-tool-call.json | 39 +++++ .../anthropic-opus-4-7-tool-loop.json | 59 ++++++++ ...ejects-malformed-assistant-tool-order.json | 35 +++++ .../gemini/gemini-2-5-flash-text.json | 37 +++++ .../gemini/gemini-2-5-flash-tool-call.json | 38 +++++ .../openai-chat-gpt-4o-mini-text.json | 37 +++++ .../openai-chat-gpt-4o-mini-tool-call.json | 38 +++++ .../openai-chat-gpt-4o-mini-tool-loop.json | 56 +++++++ .../deepseek-chat-text.json | 37 +++++ .../togetherai-llama-3-3-70b-text.json | 37 +++++ .../togetherai-llama-3-3-70b-tool-call.json | 38 +++++ ...nses-websocket-gpt-4-1-mini-tool-loop.json | 143 ++++++++++++++++++ .../openai-responses-gpt-5-5-text.json | 38 +++++ .../openai-responses-gpt-5-5-tool-call.json | 39 +++++ .../openai-responses-gpt-5-5-tool-loop.json | 57 +++++++ 16 files changed, 766 insertions(+) create mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-text.json create mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-tool-loop.json create mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order.json create mode 100644 packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-text.json create mode 100644 packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-text.json create mode 100644 packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-tool-loop.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-chat-text.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-llama-3-3-70b-text.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-llama-3-3-70b-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/openai-responses-websocket/openai-responses-websocket-gpt-4-1-mini-tool-loop.json create mode 100644 packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-text.json create mode 100644 packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-tool-loop.json diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-text.json b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-text.json new file mode 100644 index 0000000000..dbb29a09fe --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-text.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/anthropic-haiku-4-5-text", + "recordedAt": "2026-05-11T02:02:03.804Z", + "provider": "anthropic", + "route": "anthropic-messages", + "transport": "http", + "model": "claude-haiku-4-5-20251001", + "tags": [ + "prefix:anthropic-messages", + "provider:anthropic", + "text", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly with: Hello!\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01SvRWwb75gDuhBpVMHjnFaf\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-tool-call.json b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-tool-call.json new file mode 100644 index 0000000000..1b18b62cfc --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-tool-call.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/anthropic-haiku-4-5-tool-call", + "recordedAt": "2026-05-11T02:02:04.363Z", + "provider": "anthropic", + "route": "anthropic-messages", + "transport": "http", + "model": "claude-haiku-4-5-20251001", + "tags": [ + "prefix:anthropic-messages", + "provider:anthropic", + "tool", + "tool-call", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Lu38yDM3WD8QBQTcg3dBaF\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_017Dqk9SAAsHHfiLKsUyitaQ\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"cit\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"y\\\": \\\"Paris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":33} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-tool-loop.json b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-tool-loop.json new file mode 100644 index 0000000000..6b1cd5082b --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-tool-loop.json @@ -0,0 +1,59 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/anthropic-opus-4-7-tool-loop", + "recordedAt": "2026-05-11T02:02:07.788Z", + "provider": "anthropic", + "route": "anthropic-messages", + "transport": "http", + "model": "claude-opus-4-7", + "tags": [ + "prefix:anthropic-messages", + "provider:anthropic", + "flagship", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_01GK5kgi8AuVfRCnQFcXEfV8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":812,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":0,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01BnVDAp13NU8ZdJ9JeJ7byF\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}}}\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"c\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ity\\\": \\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"Paris\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":812,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":66} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01BnVDAp13NU8ZdJ9JeJ7byF\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_01BnVDAp13NU8ZdJ9JeJ7byF\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_01237VTnjPeYSRh31UjXWaEa\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":909,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":8,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris is sunny.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":909,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":12} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order.json b/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order.json new file mode 100644 index 0000000000..a391c4ce83 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/rejects-malformed-assistant-tool-order", + "recordedAt": "2026-05-11T02:01:44.544Z", + "tags": [ + "prefix:anthropic-messages", + "provider:anthropic", + "protocol:anthropic-messages", + "tool", + "sad-path" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}},{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}" + }, + "response": { + "status": 400, + "headers": { + "content-type": "application/json" + }, + "body": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1: `tool_use` ids were found without `tool_result` blocks immediately after: call_1. Each `tool_use` block must have a corresponding `tool_result` block in the next message.\"},\"request_id\":\"req_011CauxVdQf3N2PPFJ5aH8Bh\"}" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-text.json b/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-text.json new file mode 100644 index 0000000000..6e0588cca5 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-text.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "metadata": { + "name": "gemini/gemini-2-5-flash-text", + "recordedAt": "2026-05-11T02:02:08.410Z", + "provider": "google", + "route": "gemini", + "transport": "http", + "model": "gemini-2.5-flash", + "tags": [ + "prefix:gemini", + "provider:google", + "text", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply exactly with: Hello!\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are concise.\"}]},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"Hello!\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 11,\"candidatesTokenCount\": 2,\"totalTokenCount\": 13,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 11}],\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"nzgBatP3OZmW-8YP567bqQs\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-tool-call.json b/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-tool-call.json new file mode 100644 index 0000000000..a5902e50d6 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-tool-call.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "metadata": { + "name": "gemini/gemini-2-5-flash-tool-call", + "recordedAt": "2026-05-11T02:02:09.308Z", + "provider": "google", + "route": "gemini", + "transport": "http", + "model": "gemini-2.5-flash", + "tags": [ + "prefix:gemini", + "provider:google", + "tool", + "tool-call", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call tools exactly as requested.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}],\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"ANY\",\"allowedFunctionNames\":[\"get_weather\"]}},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"get_weather\",\"args\": {\"city\": \"Paris\"}},\"thoughtSignature\": \"CiQBDDnWx/X6sWeX2joSugyWO3L/lt0AgIPCvhpqf3845fj+H70KXwEMOdbHB/cnaqYCro0pU+yLWoA55jhuwoLmTcnYm4Qzcm5DuW/v0NUyz8RDx6DFh61juENveUztly6yc6/XiWJHtsgncd9YgcZhuQKqtp5KZTkGYpT3g6v3yP9GK4AoCoUBAQw51sePh3WuWovHnwIotKLVZiU9pwh34k4FY7ugPOxyDAG9j69cy7BYYzSchI10LEjLoLlCMZuNIPootBgI02QWY/4h2PIv33BAADrFPM2T3aE4cAuMoa3GCu2nztJ/95junDhIhuXZSQ/Mh9EVxpx7ml99Z7Hxb7OtDsSZZLeCBuGmSw==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0,\"finishMessage\": \"Model generated function call(s).\"}],\"usageMetadata\": {\"promptTokenCount\": 55,\"candidatesTokenCount\": 15,\"totalTokenCount\": 115,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 55}],\"thoughtsTokenCount\": 45,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"oDgBauj6HZ3B-8YPpaOc6QU\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-text.json b/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-text.json new file mode 100644 index 0000000000..b908a120e8 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-text.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/openai-chat-gpt-4o-mini-text", + "recordedAt": "2026-05-11T02:01:46.536Z", + "provider": "openai", + "route": "openai-chat", + "transport": "http", + "model": "gpt-4o-mini", + "tags": [ + "prefix:openai-chat", + "provider:openai", + "text", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DeAFmC9UiBnVu6dAjBvWVBVypm2gc\",\"object\":\"chat.completion.chunk\",\"created\":1778464906,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_3ef558a83f\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"wHajUz1js\"}\n\ndata: {\"id\":\"chatcmpl-DeAFmC9UiBnVu6dAjBvWVBVypm2gc\",\"object\":\"chat.completion.chunk\",\"created\":1778464906,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_3ef558a83f\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"lVHzGq\"}\n\ndata: {\"id\":\"chatcmpl-DeAFmC9UiBnVu6dAjBvWVBVypm2gc\",\"object\":\"chat.completion.chunk\",\"created\":1778464906,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_3ef558a83f\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3gaQRWEjE7\"}\n\ndata: {\"id\":\"chatcmpl-DeAFmC9UiBnVu6dAjBvWVBVypm2gc\",\"object\":\"chat.completion.chunk\",\"created\":1778464906,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_3ef558a83f\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"vuKPZ\"}\n\ndata: {\"id\":\"chatcmpl-DeAFmC9UiBnVu6dAjBvWVBVypm2gc\",\"object\":\"chat.completion.chunk\",\"created\":1778464906,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_3ef558a83f\",\"choices\":[],\"usage\":{\"prompt_tokens\":21,\"completion_tokens\":2,\"total_tokens\":23,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"kfFsssdujmM\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-tool-call.json b/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-tool-call.json new file mode 100644 index 0000000000..1ed411fb96 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-tool-call.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/openai-chat-gpt-4o-mini-tool-call", + "recordedAt": "2026-05-11T02:01:47.484Z", + "provider": "openai", + "route": "openai-chat", + "transport": "http", + "model": "gpt-4o-mini", + "tags": [ + "prefix:openai-chat", + "provider:openai", + "tool", + "tool-call", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_sH8T7MPdJXS5KJginKrzexL5\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"d0rJ\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"hL82erd6bBoy91\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"e3aoKH4tvJlXW\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"T8EzFVNaUCLE\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"lelRBxF08Zes\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"F1xO8sVMyZt4BU\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"YL3pl\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[],\"usage\":{\"prompt_tokens\":67,\"completion_tokens\":5,\"total_tokens\":72,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"7cov9qkofwo\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-tool-loop.json new file mode 100644 index 0000000000..1cdc46f30c --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-tool-loop.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/openai-chat-gpt-4o-mini-tool-loop", + "recordedAt": "2026-05-11T02:01:50.433Z", + "provider": "openai", + "route": "openai-chat", + "transport": "http", + "model": "gpt-4o-mini", + "tags": [ + "prefix:openai-chat", + "provider:openai", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_HrDZhrMUauvVddKWzVQFJ69Q\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"aAqC\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"haZ3pKk14oDay0\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ZUJHQytFyDezp\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3buqnStPteyG\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"7epPqHEU3OeA\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"gSU5gyl9K7sUCv\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"usage\":null,\"obfuscation\":\"P3oSvByfy60JCsz\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[],\"usage\":{\"prompt_tokens\":71,\"completion_tokens\":14,\"total_tokens\":85,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"5AhQ5ToYra\"}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_HrDZhrMUauvVddKWzVQFJ69Q\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_HrDZhrMUauvVddKWzVQFJ69Q\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"2QWjdWPSe\"}\n\ndata: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Paris\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"sm5IpQ\"}\n\ndata: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"cWsGxqHw\"}\n\ndata: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"8hNCi\"}\n\ndata: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"4yKCNDRcwq\"}\n\ndata: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"NvRgg\"}\n\ndata: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[],\"usage\":{\"prompt_tokens\":103,\"completion_tokens\":5,\"total_tokens\":108,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"4s7mLtNpZ\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-chat-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-chat-text.json new file mode 100644 index 0000000000..bc91ce6342 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-chat-text.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/deepseek-chat-text", + "recordedAt": "2026-05-11T02:02:10.220Z", + "provider": "deepseek", + "route": "openai-compatible-chat", + "transport": "http", + "model": "deepseek-chat", + "tags": [ + "prefix:openai-compatible-chat", + "provider:deepseek", + "text", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.deepseek.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"159019a5-f981-4103-8b3c-2be37cefc505\",\"object\":\"chat.completion.chunk\",\"created\":1778464929,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"159019a5-f981-4103-8b3c-2be37cefc505\",\"object\":\"chat.completion.chunk\",\"created\":1778464929,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"159019a5-f981-4103-8b3c-2be37cefc505\",\"object\":\"chat.completion.chunk\",\"created\":1778464929,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"159019a5-f981-4103-8b3c-2be37cefc505\",\"object\":\"chat.completion.chunk\",\"created\":1778464929,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":14,\"completion_tokens\":2,\"total_tokens\":16,\"prompt_tokens_details\":{\"cached_tokens\":0},\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":14}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-llama-3-3-70b-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-llama-3-3-70b-text.json new file mode 100644 index 0000000000..585ae1bb19 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-llama-3-3-70b-text.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/togetherai-llama-3-3-70b-text", + "recordedAt": "2026-05-11T02:02:13.341Z", + "provider": "togetherai", + "route": "openai-compatible-chat", + "transport": "http", + "model": "meta-llama/Llama-3.3-70B-Instruct-Turbo", + "tags": [ + "prefix:openai-compatible-chat", + "provider:togetherai", + "text", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.together.xyz/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream;charset=utf-8" + }, + "body": "data: {\"id\":\"oibreET-3pDw3Z-9f9d9996ce37066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464931,\"choices\":[{\"index\":0,\"text\":\"Hello\",\"logprobs\":null,\"finish_reason\":null,\"seed\":null,\"delta\":{\"token_id\":9906,\"role\":\"assistant\",\"content\":\"Hello\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":null}\n\ndata: {\"id\":\"oibreET-3pDw3Z-9f9d9996ce37066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464931,\"choices\":[{\"index\":0,\"text\":\"!\",\"logprobs\":null,\"finish_reason\":null,\"seed\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"!\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":null}\n\ndata: {\"id\":\"oibreET-3pDw3Z-9f9d9996ce37066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464931,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"stop\",\"seed\":12144769634208630000,\"delta\":{\"token_id\":128009,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":3,\"total_tokens\":48,\"cached_tokens\":0}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-llama-3-3-70b-tool-call.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-llama-3-3-70b-tool-call.json new file mode 100644 index 0000000000..8a19ef5fdc --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-llama-3-3-70b-tool-call.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/togetherai-llama-3-3-70b-tool-call", + "recordedAt": "2026-05-11T02:02:14.453Z", + "provider": "togetherai", + "route": "openai-compatible-chat", + "transport": "http", + "model": "meta-llama/Llama-3.3-70B-Instruct-Turbo", + "tags": [ + "prefix:openai-compatible-chat", + "provider:togetherai", + "tool", + "tool-call", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.together.xyz/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream;charset=utf-8" + }, + "body": "data: {\"id\":\"oibreu3-6Ng1vN-9f9d99a9ba73066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464933,\"choices\":[{\"index\":0,\"role\":\"assistant\",\"text\":\"\",\"logprobs\":null,\"finish_reason\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"oibreu3-6Ng1vN-9f9d99a9ba73066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464933,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"index\":0,\"id\":\"call_jue52dtu6iozr0ny9rq357u5\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"oibreu3-6Ng1vN-9f9d99a9ba73066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464933,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"oibreu3-6Ng1vN-9f9d99a9ba73066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464933,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"seed\":17440360718047570000,\"delta\":{\"token_id\":128009,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":{\"prompt_tokens\":194,\"completion_tokens\":19,\"total_tokens\":213,\"cached_tokens\":0}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses-websocket/openai-responses-websocket-gpt-4-1-mini-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-responses-websocket/openai-responses-websocket-gpt-4-1-mini-tool-loop.json new file mode 100644 index 0000000000..88c7e6aa69 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses-websocket/openai-responses-websocket-gpt-4-1-mini-tool-loop.json @@ -0,0 +1,143 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses-websocket/openai-responses-websocket-gpt-4-1-mini-tool-loop", + "recordedAt": "2026-05-11T02:02:03.284Z", + "provider": "openai", + "route": "openai-responses-websocket", + "transport": "websocket", + "model": "gpt-4.1-mini", + "tags": [ + "prefix:openai-responses-websocket", + "provider:openai", + "transport:websocket", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "websocket", + "open": { + "url": "wss://api.openai.com/v1/responses", + "headers": {} + }, + "client": [ + { + "kind": "text", + "body": "{\"type\":\"response.create\",\"model\":\"gpt-4.1-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"max_output_tokens\":80,\"temperature\":0}" + } + ], + "server": [ + { + "kind": "text", + "body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_0f8d81b5d3287513016a013897415481a28585571d6710366f\",\"object\":\"response\",\"created_at\":1778464919,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0f8d81b5d3287513016a013897415481a28585571d6710366f\",\"object\":\"response\",\"created_at\":1778464919,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_wRJoUdcb6w5SIpieBFOKxF6z\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":2}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"obfuscation\":\"EAdvUfYaysnNd3\",\"output_index\":0,\"sequence_number\":3}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"obfuscation\":\"b0QWJJlscLyl\",\"output_index\":0,\"sequence_number\":4}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"obfuscation\":\"m5EV1wzceCkjb\",\"output_index\":0,\"sequence_number\":5}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"obfuscation\":\"QM3FjPoN9Gi\",\"output_index\":0,\"sequence_number\":6}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"obfuscation\":\"lug74orAYXtg0e\",\"output_index\":0,\"sequence_number\":7}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"output_index\":0,\"sequence_number\":8}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_wRJoUdcb6w5SIpieBFOKxF6z\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":9}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0f8d81b5d3287513016a013897415481a28585571d6710366f\",\"object\":\"response\",\"created_at\":1778464919,\"status\":\"completed\",\"background\":false,\"completed_at\":1778464920,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[{\"id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_wRJoUdcb6w5SIpieBFOKxF6z\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":69,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":15,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":84},\"user\":null,\"metadata\":{}},\"sequence_number\":10}" + } + ] + }, + { + "transport": "websocket", + "open": { + "url": "wss://api.openai.com/v1/responses", + "headers": {} + }, + "client": [ + { + "kind": "text", + "body": "{\"type\":\"response.create\",\"model\":\"gpt-4.1-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_wRJoUdcb6w5SIpieBFOKxF6z\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_wRJoUdcb6w5SIpieBFOKxF6z\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"max_output_tokens\":80,\"temperature\":0}" + } + ], + "server": [ + { + "kind": "text", + "body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_0767cfe3f5d98b2a016a0138994258819485d082e5c78849a4\",\"object\":\"response\",\"created_at\":1778464921,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0767cfe3f5d98b2a016a0138994258819485d082e5c78849a4\",\"object\":\"response\",\"created_at\":1778464921,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Paris\",\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"logprobs\":[],\"obfuscation\":\"fWbkBKTZ5oG\",\"output_index\":0,\"sequence_number\":4}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" is\",\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"logprobs\":[],\"obfuscation\":\"BGuPlDrPchXwG\",\"output_index\":0,\"sequence_number\":5}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" sunny\",\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"logprobs\":[],\"obfuscation\":\"7THzde2pni\",\"output_index\":0,\"sequence_number\":6}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"logprobs\":[],\"obfuscation\":\"Eo38bSElfyNmljj\",\"output_index\":0,\"sequence_number\":7}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":8,\"text\":\"Paris is sunny.\"}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"},\"sequence_number\":9}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":10}" + }, + { + "kind": "text", + "body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0767cfe3f5d98b2a016a0138994258819485d082e5c78849a4\",\"object\":\"response\",\"created_at\":1778464921,\"status\":\"completed\",\"background\":false,\"completed_at\":1778464923,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[{\"id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":99,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":6,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":105},\"user\":null,\"metadata\":{}},\"sequence_number\":11}" + } + ] + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-text.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-text.json new file mode 100644 index 0000000000..befd07c850 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-text.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/openai-responses-gpt-5-5-text", + "recordedAt": "2026-05-11T02:01:52.043Z", + "provider": "openai", + "route": "openai-responses", + "transport": "http", + "model": "gpt-5.5", + "tags": [ + "prefix:openai-responses", + "provider:openai", + "flagship", + "text", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly with: Hello!\"}]}],\"max_output_tokens\":40,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_03db31943129228b006a01388ea6a481a081e6e6b17947a5ee\",\"object\":\"response\",\"created_at\":1778464910,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_03db31943129228b006a01388ea6a481a081e6e6b17947a5ee\",\"object\":\"response\",\"created_at\":1778464910,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_03db31943129228b006a01388fb4a881a0aa678375e614d2c4\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_03db31943129228b006a01388fb4a881a0aa678375e614d2c4\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_03db31943129228b006a01388fb4a881a0aa678375e614d2c4\",\"logprobs\":[],\"obfuscation\":\"gpNAzOcV2UP\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_03db31943129228b006a01388fb4a881a0aa678375e614d2c4\",\"logprobs\":[],\"obfuscation\":\"r4ktr666Pt0BiSJ\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_03db31943129228b006a01388fb4a881a0aa678375e614d2c4\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":6,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_03db31943129228b006a01388fb4a881a0aa678375e614d2c4\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":7}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_03db31943129228b006a01388fb4a881a0aa678375e614d2c4\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":8}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_03db31943129228b006a01388ea6a481a081e6e6b17947a5ee\",\"object\":\"response\",\"created_at\":1778464910,\"status\":\"completed\",\"background\":false,\"completed_at\":1778464911,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"msg_03db31943129228b006a01388fb4a881a0aa678375e614d2c4\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":20,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":6,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":26},\"user\":null,\"metadata\":{}},\"sequence_number\":9}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-tool-call.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-tool-call.json new file mode 100644 index 0000000000..544c0ba53b --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-tool-call.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/openai-responses-gpt-5-5-tool-call", + "recordedAt": "2026-05-11T02:01:54.829Z", + "provider": "openai", + "route": "openai-responses", + "transport": "http", + "model": "gpt-5.5", + "tags": [ + "prefix:openai-responses", + "provider:openai", + "flagship", + "tool", + "tool-call", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"max_output_tokens\":80,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_03cd5bf3fbd90b99006a01389022a48193b770ab6d062b3ff5\",\"object\":\"response\",\"created_at\":1778464912,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_03cd5bf3fbd90b99006a01389022a48193b770ab6d062b3ff5\",\"object\":\"response\",\"created_at\":1778464912,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_03cd5bf3fbd90b99006a0138923f8c8193bca949d9ecdfcc03\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_eEA5zbL9Seb7SbNCXifopygi\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_03cd5bf3fbd90b99006a0138923f8c8193bca949d9ecdfcc03\",\"obfuscation\":\"3jhfcjwHwEFUsm\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_03cd5bf3fbd90b99006a0138923f8c8193bca949d9ecdfcc03\",\"obfuscation\":\"xcS1ivWglNNX\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_03cd5bf3fbd90b99006a0138923f8c8193bca949d9ecdfcc03\",\"obfuscation\":\"bM7tal2PhDGQ3\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_03cd5bf3fbd90b99006a0138923f8c8193bca949d9ecdfcc03\",\"obfuscation\":\"4cQtJsBXtgn\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_03cd5bf3fbd90b99006a0138923f8c8193bca949d9ecdfcc03\",\"obfuscation\":\"7ng8mgc0SqelhX\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_03cd5bf3fbd90b99006a0138923f8c8193bca949d9ecdfcc03\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_03cd5bf3fbd90b99006a0138923f8c8193bca949d9ecdfcc03\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_eEA5zbL9Seb7SbNCXifopygi\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":9}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_03cd5bf3fbd90b99006a01389022a48193b770ab6d062b3ff5\",\"object\":\"response\",\"created_at\":1778464912,\"status\":\"completed\",\"background\":false,\"completed_at\":1778464914,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"fc_03cd5bf3fbd90b99006a0138923f8c8193bca949d9ecdfcc03\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_eEA5zbL9Seb7SbNCXifopygi\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":61,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":18,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":79},\"user\":null,\"metadata\":{}},\"sequence_number\":10}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-tool-loop.json new file mode 100644 index 0000000000..f0c519d09d --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-tool-loop.json @@ -0,0 +1,57 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/openai-responses-gpt-5-5-tool-loop", + "recordedAt": "2026-05-11T02:01:58.688Z", + "provider": "openai", + "route": "openai-responses", + "transport": "http", + "model": "gpt-5.5", + "tags": [ + "prefix:openai-responses", + "provider:openai", + "flagship", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"max_output_tokens\":80,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_002938b5a91b8a90006a013892e648819f8a1c887d6a80ae92\",\"object\":\"response\",\"created_at\":1778464914,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_002938b5a91b8a90006a013892e648819f8a1c887d6a80ae92\",\"object\":\"response\",\"created_at\":1778464914,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_002938b5a91b8a90006a013894af54819fa387f5a066f14730\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_002938b5a91b8a90006a013894af54819fa387f5a066f14730\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_002938b5a91b8a90006a0138951da4819fa867ae1cf299de51\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_Zz0OL9xt7R1z0eTMx3p36BmX\",\"name\":\"get_weather\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_002938b5a91b8a90006a0138951da4819fa867ae1cf299de51\",\"obfuscation\":\"WooL7EJol03OR0\",\"output_index\":1,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_002938b5a91b8a90006a0138951da4819fa867ae1cf299de51\",\"obfuscation\":\"K35a7Ps4q34B\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_002938b5a91b8a90006a0138951da4819fa867ae1cf299de51\",\"obfuscation\":\"U0lxUOBbnEWi6\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_002938b5a91b8a90006a0138951da4819fa867ae1cf299de51\",\"obfuscation\":\"eYJ8S5XzMMk\",\"output_index\":1,\"sequence_number\":8}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_002938b5a91b8a90006a0138951da4819fa867ae1cf299de51\",\"obfuscation\":\"3tPQtIQZLbEtvL\",\"output_index\":1,\"sequence_number\":9}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_002938b5a91b8a90006a0138951da4819fa867ae1cf299de51\",\"output_index\":1,\"sequence_number\":10}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_002938b5a91b8a90006a0138951da4819fa867ae1cf299de51\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_Zz0OL9xt7R1z0eTMx3p36BmX\",\"name\":\"get_weather\"},\"output_index\":1,\"sequence_number\":11}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_002938b5a91b8a90006a013892e648819f8a1c887d6a80ae92\",\"object\":\"response\",\"created_at\":1778464914,\"status\":\"completed\",\"background\":false,\"completed_at\":1778464917,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_002938b5a91b8a90006a013894af54819fa387f5a066f14730\",\"type\":\"reasoning\",\"summary\":[]},{\"id\":\"fc_002938b5a91b8a90006a0138951da4819fa867ae1cf299de51\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_Zz0OL9xt7R1z0eTMx3p36BmX\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":74,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":44,\"output_tokens_details\":{\"reasoning_tokens\":24},\"total_tokens\":118},\"user\":null,\"metadata\":{}},\"sequence_number\":12}\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_Zz0OL9xt7R1z0eTMx3p36BmX\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Zz0OL9xt7R1z0eTMx3p36BmX\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"max_output_tokens\":80,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0851ba0c41fc4434006a013895976081a38f006157c1fd43db\",\"object\":\"response\",\"created_at\":1778464917,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0851ba0c41fc4434006a013895976081a38f006157c1fd43db\",\"object\":\"response\",\"created_at\":1778464917,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0851ba0c41fc4434006a0138964d2481a3997fbea69c3b7378\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0851ba0c41fc4434006a0138964d2481a3997fbea69c3b7378\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Paris\",\"item_id\":\"msg_0851ba0c41fc4434006a0138964d2481a3997fbea69c3b7378\",\"logprobs\":[],\"obfuscation\":\"S6CIU3RQIod\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" is\",\"item_id\":\"msg_0851ba0c41fc4434006a0138964d2481a3997fbea69c3b7378\",\"logprobs\":[],\"obfuscation\":\"Eht80PaSjVwPy\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" sunny\",\"item_id\":\"msg_0851ba0c41fc4434006a0138964d2481a3997fbea69c3b7378\",\"logprobs\":[],\"obfuscation\":\"qOBv4MMHpy\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_0851ba0c41fc4434006a0138964d2481a3997fbea69c3b7378\",\"logprobs\":[],\"obfuscation\":\"CU7ay4cq29qD1OT\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0851ba0c41fc4434006a0138964d2481a3997fbea69c3b7378\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":8,\"text\":\"Paris is sunny.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0851ba0c41fc4434006a0138964d2481a3997fbea69c3b7378\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0851ba0c41fc4434006a0138964d2481a3997fbea69c3b7378\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0851ba0c41fc4434006a013895976081a38f006157c1fd43db\",\"object\":\"response\",\"created_at\":1778464917,\"status\":\"completed\",\"background\":false,\"completed_at\":1778464918,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"msg_0851ba0c41fc4434006a0138964d2481a3997fbea69c3b7378\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":113,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":8,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":121},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + } + } + ] +} From c20d070b9aaffdb8c3a016a57228e64909789123 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 22:08:12 -0400 Subject: [PATCH 8/8] docs(llm): fix stale references in protocols/shared.ts and gemini.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subtractTokens JSDoc said the raw payload lives on Usage.native, but that field was renamed to providerMetadata earlier in this PR. - totalTokens JSDoc still described the abandoned "additive" first-pass contract where inputTokens/outputTokens were non-cached / visible only. We landed on inclusive totals; the fallback already covers cache and reasoning. - Removed a duplicate inline comment in Gemini's mapUsage — the function-level comment already explains the visible/reasoning sum and the undefined-when-incomplete rule. --- packages/llm/src/protocols/gemini.ts | 6 ++---- packages/llm/src/protocols/shared.ts | 13 ++++++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index ff6f3f83ec..a59b0e5017 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -285,14 +285,12 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque // `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive* // of `thoughtsTokenCount` — visible-only, not a total — so we sum the two // to produce the inclusive `outputTokens` the rest of the contract expects. +// Output is left undefined when the visible component is missing, so we +// don't fabricate an inclusive number from a partial breakdown. const mapUsage = (usage: GeminiUsage | undefined) => { if (!usage) return undefined const cached = usage.cachedContentTokenCount const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached) - // `candidatesTokenCount` is visible-only; sum with thoughts to produce the - // inclusive `outputTokens` the contract expects. Only compute the total - // when the visible component is reported — otherwise we'd fabricate an - // inclusive number from a partial breakdown. const outputTokens = usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 3b9886553a..a07d38bd19 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -43,12 +43,10 @@ export interface ToolAccumulator { * when at least one is defined. Returns `undefined` when neither input nor * output is known so routes don't publish a misleading `0`. * - * Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens` - * are the non-cached input and visible output only. The provider-supplied - * `total` is the source of truth when present; the computed fallback - * under-counts cache and reasoning by design and exists mainly so - * Anthropic-style providers (which don't surface a total) still get a - * sensible aggregate on the input + output axes. + * Under the `LLM.Usage` contract, `inputTokens` and `outputTokens` are + * inclusive totals, so the computed fallback already covers cache reads / + * writes and reasoning — used mainly for Anthropic-style providers that + * don't surface a top-level total. */ export const totalTokens = ( inputTokens: number | undefined, @@ -69,7 +67,8 @@ export const totalTokens = ( * * If `total` is `undefined`, returns `undefined` (we don't fabricate * counts). If `subtrahend` is `undefined`, returns `total` unchanged. The - * provider-native breakdown stays available on `Usage.native` for debugging. + * provider-native breakdown stays available on `Usage.providerMetadata` + * for debugging. */ export const subtractTokens = ( total: number | undefined,