From b13c3cd20219fb298b068ff58537dda55a0aec2b Mon Sep 17 00:00:00 2001 From: Kittisak Tangsiri Date: Tue, 11 Aug 2026 14:35:30 +0700 Subject: [PATCH] fix(translator): normalize streamed optional tool arguments (#9423) * fix: preserve Codex cache usage for Claude suggestions Co-Authored-By: Claude * fix: normalize streamed optional tool arguments Co-Authored-By: Claude --------- Co-authored-by: Kittisak Tangsiri Co-authored-by: Claude --- open-sse/handlers/chatCore/upstreamBody.ts | 2 +- .../translator/response/openai-responses.ts | 77 ++++++- .../response/openai-responses/pureHelpers.ts | 15 +- .../response/openai-responses/toolSchemas.ts | 6 +- tests/unit/chatcore-upstream-body.test.ts | 23 +- tests/unit/repro-6951.test.ts | 26 ++- tests/unit/repro-7023.test.ts | 215 ++++++++++++++++-- .../translator-responses-cache-usage.test.ts | 86 +++++++ 8 files changed, 408 insertions(+), 42 deletions(-) create mode 100644 tests/unit/translator-responses-cache-usage.test.ts diff --git a/open-sse/handlers/chatCore/upstreamBody.ts b/open-sse/handlers/chatCore/upstreamBody.ts index 57e3a358b4..f2d8368c8c 100644 --- a/open-sse/handlers/chatCore/upstreamBody.ts +++ b/open-sse/handlers/chatCore/upstreamBody.ts @@ -99,7 +99,7 @@ async function injectPromptCacheKey( providerSupportsCaching(provider, undefined, connectionCacheOverride) && !bodyToSend.prompt_cache_key && Array.isArray(bodyToSend.messages) && - !["nvidia", "codex", "xai"].includes(provider) + !["nvidia", "xai"].includes(provider) ) { const { generatePromptCacheKey } = await import("@/lib/promptCache"); const cacheKey = generatePromptCacheKey(bodyToSend.messages); diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 1fc6153804..5aa3728f77 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -846,6 +846,48 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { function openaiResponsesToOpenAIResponseStream(chunk, state) { if (!chunk) { + if ( + state.currentToolCallNeedsNormalization && + state.currentToolCallArgsBuffer && + state.currentToolCallName + ) { + const toolSchema = state.toolSchemas?.get(state.currentToolCallName); + const argsToEmit = stripEmptyOptionalToolArgs( + state.currentToolCallArgsBuffer, + state.currentToolCallName, + toolSchema + ); + const argsStr = + typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {}); + state.currentToolCallArgsBuffer = ""; + state.currentToolCallNeedsNormalization = false; + state.finishReasonSent = true; + state.finishReason = "tool_calls"; + const common = { + id: state.chatId, + object: "chat.completion.chunk", + created: state.created, + model: state.model || "gpt-4", + }; + return [ + { + ...common, + choices: [ + { + index: 0, + delta: { + tool_calls: [{ index: state.toolCallIndex, function: { arguments: argsStr } }], + }, + finish_reason: null, + }, + ], + }, + { + ...common, + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + }, + ]; + } // Flush: send final chunk with finish_reason if (!state.finishReasonSent && state.started) { state.finishReasonSent = true; @@ -931,6 +973,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { const toolName = normalizeToolName(item.name); state.currentToolName = toolName; // track for schema lookup at done time + state.currentToolCallName = toolName; + state.currentToolCallNeedsNormalization = toolName === "Agent"; if (!toolName) { // Some Responses providers briefly emit placeholder/empty tool names. // Defer emission until output_item.done in case the final name is populated there. @@ -974,7 +1018,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { if (!argsDelta) return null; state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta; - if (state.currentToolCallDeferred) return null; + if (state.currentToolCallDeferred || state.currentToolCallNeedsNormalization) return null; // #9168: buffer arguments until output_item.done for schema-aware null normalization // Previously emitted raw null values for optional enum fields (e.g. isolation: null). @@ -991,6 +1035,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { const callId = item.call_id || state.currentToolCallId || fallbackToolCallId(); const toolName = normalizeToolName(item.name); const toolSchema = state.toolSchemas?.get(toolName); + const shouldNormalizeArguments = toolName === "Agent"; + state.currentToolCallNeedsNormalization = shouldNormalizeArguments; // Track this call_id so response.completed doesn't synthesize a duplicate if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set(); @@ -1007,7 +1053,13 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { state.toolCallIndex++; - const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName, toolSchema); + const terminalArguments = + typeof item.arguments === "string" + ? item.arguments.length > 0 + ? item.arguments + : buffered + : (item.arguments ?? buffered); + const argsToEmit = stripEmptyOptionalToolArgs(terminalArguments, toolName, toolSchema); const argsStr = argsToEmit != null @@ -1046,10 +1098,20 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { state.toolCallIndex++; state.currentToolCallArgsBuffer = ""; // reset for next tool call state.currentToolCallId = null; + const needsNormalization = state.currentToolCallNeedsNormalization === true; + state.currentToolCallNeedsNormalization = false; + state.currentToolCallName = ""; - // Only emit if arguments exist in the done event AND they weren't already streamed via deltas - if (item.arguments != null && !buffered) { - const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName, toolSchema); + // Nullable omission sentinels must be normalized before any argument bytes reach the client. + // Other tool calls retain immediate argument streaming. + if ((needsNormalization || !buffered) && (item.arguments != null || buffered)) { + const terminalArguments = + typeof item.arguments === "string" + ? item.arguments.length > 0 + ? item.arguments + : buffered + : (item.arguments ?? buffered); + const argsToEmit = stripEmptyOptionalToolArgs(terminalArguments, toolName, toolSchema); const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit); if (argsStr) { @@ -1127,8 +1189,9 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { responseUsage.reasoning_tokens || 0; - // prompt_tokens = input_tokens + cache_read + cache_creation (all prompt-side tokens) - const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens; + const promptTokens = + inputTokens + + ("cache_read_input_tokens" in responseUsage ? cacheReadTokens + cacheCreationTokens : 0); state.usage = { prompt_tokens: promptTokens, diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts index 01999f9ac9..7cea183884 100644 --- a/open-sse/translator/response/openai-responses/pureHelpers.ts +++ b/open-sse/translator/response/openai-responses/pureHelpers.ts @@ -60,8 +60,17 @@ function isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) { // no-default optional enum properties to accept `null`, meaning "omitted" (OpenAI's own // nullable-union idiom for Responses-API strict mode). Drop the key when the model // follows that idiom for a non-required, schema-declared property. -function isDroppableNullEntry(entry, propSchema, required, key) { - return entry === null && propSchema != null && !required.has(key); +function isDroppableNullEntry(entry, propSchema, required, key, toolName) { + if (entry !== null) return false; + if (toolName === "Agent") return true; + if (propSchema == null) return false; + const omissionSentinel = + typeof propSchema === "object" && + Array.isArray(propSchema.enum) && + propSchema.enum.includes(null) && + typeof propSchema.description === "string" && + propSchema.description.includes("null = omit this parameter"); + return !required.has(key) || omissionSentinel; } function stripEmptyOptionalToolArgsObject(value, toolName, schema) { @@ -75,7 +84,7 @@ function stripEmptyOptionalToolArgsObject(value, toolName, schema) { if ( matchesSchemaDefault(propSchema, entry) || isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) || - isDroppableNullEntry(entry, propSchema, required, key) + isDroppableNullEntry(entry, propSchema, required, key, toolName) ) { delete cleaned[key]; } diff --git a/open-sse/translator/response/openai-responses/toolSchemas.ts b/open-sse/translator/response/openai-responses/toolSchemas.ts index 8f5457ea72..692164897a 100644 --- a/open-sse/translator/response/openai-responses/toolSchemas.ts +++ b/open-sse/translator/response/openai-responses/toolSchemas.ts @@ -20,9 +20,11 @@ export function extractToolSchemaMap(body: unknown): Map | n const item = asRecord(tool); if (!item) continue; const fn = asRecord(item.function); - const name = (typeof fn?.name === "string" ? fn.name : typeof item.name === "string" ? item.name : "").trim(); + const name = ( + typeof fn?.name === "string" ? fn.name : typeof item.name === "string" ? item.name : "" + ).trim(); if (!name) continue; - const schema = asRecord(fn?.parameters ?? item.parameters); + const schema = asRecord(fn?.parameters ?? item.parameters ?? item.input_schema); if (schema) map.set(name, schema); } return map.size > 0 ? map : null; diff --git a/tests/unit/chatcore-upstream-body.test.ts b/tests/unit/chatcore-upstream-body.test.ts index 1348300cba..685a545fb2 100644 --- a/tests/unit/chatcore-upstream-body.test.ts +++ b/tests/unit/chatcore-upstream-body.test.ts @@ -229,18 +229,31 @@ test("preserves the full tool list when within the grok-cli limit", async () => targetFormat: "claude", credentials: null, }); + assert.ok(Array.isArray(out.tools)); assert.equal(out.tools.length, 150); }); -test("never injects prompt_cache_key for an excluded provider (codex)", async () => { - const out = await prepareUpstreamBody({ - translatedBody: { model: "gpt-5-codex", messages: [{ role: "user", content: "hi" }] }, +test("injects a stable prompt_cache_key for Codex automatic prefix caching", async () => { + const request = { + model: "gpt-5-codex", + messages: [ + { role: "system", content: "stable coding instructions" }, + { role: "user", content: "fix this" }, + ], + }; + const opts = { + translatedBody: request, modelToCall: "gpt-5-codex", provider: "codex", targetFormat: "openai", credentials: null, - }); - assert.equal(out.prompt_cache_key, undefined); + }; + + const first = await prepareUpstreamBody(opts); + const second = await prepareUpstreamBody(opts); + + assert.match(String(first.prompt_cache_key), /^omni-[0-9a-f]{32}$/); + assert.equal(second.prompt_cache_key, first.prompt_cache_key); }); test("never injects prompt_cache_key when the target format is not OpenAI", async () => { diff --git a/tests/unit/repro-6951.test.ts b/tests/unit/repro-6951.test.ts index 9de9bf3912..ef937d442d 100644 --- a/tests/unit/repro-6951.test.ts +++ b/tests/unit/repro-6951.test.ts @@ -11,15 +11,12 @@ import assert from "node:assert/strict"; // and the end-to-end schema threading from the request's `tools[]` into the streaming // call sites in `openai-responses.ts` (response.output_item.done handling). -const { stripEmptyOptionalToolArgs } = await import( - "../../open-sse/translator/response/openai-responses/pureHelpers.ts" -); -const { extractToolSchemaMap } = await import( - "../../open-sse/translator/response/openai-responses/toolSchemas.ts" -); -const { openaiResponsesToOpenAIResponse } = await import( - "../../open-sse/translator/response/openai-responses.ts" -); +const { stripEmptyOptionalToolArgs } = + await import("../../open-sse/translator/response/openai-responses/pureHelpers.ts"); +const { extractToolSchemaMap } = + await import("../../open-sse/translator/response/openai-responses/toolSchemas.ts"); +const { openaiResponsesToOpenAIResponse } = + await import("../../open-sse/translator/response/openai-responses.ts"); const AGENT_SCHEMA = { type: "object", @@ -101,13 +98,22 @@ test("6951: extractToolSchemaMap builds a name->schema map from Chat Completions assert.equal(extractToolSchemaMap({}), null); }); +test("6951: extractToolSchemaMap accepts Claude input_schema tools", () => { + const body = { tools: [{ name: "Agent", input_schema: AGENT_SCHEMA }] }; + const map = extractToolSchemaMap(body); + assert.equal(map?.get("Agent"), AGENT_SCHEMA); +}); + test("6951: RED->GREEN — schema threaded end-to-end strips the default-valued isolation arg", () => { // Simulates createSSEStream's TranslateState carrying `toolSchemas` extracted from the // request body, as wired in open-sse/utils/stream.ts. const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA]]) }; openaiResponsesToOpenAIResponse( - { type: "response.output_item.added", item: { type: "function_call", call_id: "call_1", name: "Agent" } }, + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_1", name: "Agent" }, + }, state ); const done = openaiResponsesToOpenAIResponse( diff --git a/tests/unit/repro-7023.test.ts b/tests/unit/repro-7023.test.ts index 94f8116633..fc34658be5 100644 --- a/tests/unit/repro-7023.test.ts +++ b/tests/unit/repro-7023.test.ts @@ -11,15 +11,12 @@ import assert from "node:assert/strict"; // own documented nullable-union idiom for this exact strict-mode limitation), and // response-side drops the key when the model emits `null` for a non-required property. -const { injectOptionalEnumOmissionSentinel, injectOptionalEnumOmissionForTools } = await import( - "../../open-sse/translator/helpers/schemaCoercion.ts" -); -const { stripEmptyOptionalToolArgs } = await import( - "../../open-sse/translator/response/openai-responses/pureHelpers.ts" -); -const { openaiResponsesToOpenAIResponse } = await import( - "../../open-sse/translator/response/openai-responses.ts" -); +const { injectOptionalEnumOmissionSentinel, injectOptionalEnumOmissionForTools } = + await import("../../open-sse/translator/helpers/schemaCoercion.ts"); +const { stripEmptyOptionalToolArgs } = + await import("../../open-sse/translator/response/openai-responses/pureHelpers.ts"); +const { openaiResponsesToOpenAIResponse } = + await import("../../open-sse/translator/response/openai-responses.ts"); const { translateRequest } = await import("../../open-sse/translator/index.ts"); const { FORMATS } = await import("../../open-sse/translator/formats.ts"); @@ -60,7 +57,9 @@ test("7023: injectOptionalEnumOmissionSentinel leaves a required enum property u test("7023: injectOptionalEnumOmissionSentinel leaves an enum property with a default untouched", () => { const schema = { type: "object", - properties: { isolation: { type: "string", enum: ["worktree", "remote"], default: "worktree" } }, + properties: { + isolation: { type: "string", enum: ["worktree", "remote"], default: "worktree" }, + }, required: [], }; const result = injectOptionalEnumOmissionSentinel(schema); @@ -124,7 +123,9 @@ test("7023: translateRequest applies the injection only for targetFormat OPENAI_ "gpt-5.1-codex", JSON.parse(JSON.stringify(body)) ); - const responsesTool = toResponses.tools.find((t) => t.name === "Agent" || t?.function?.name === "Agent"); + const responsesTool = toResponses.tools.find( + (t) => t.name === "Agent" || t?.function?.name === "Agent" + ); const responsesParams = responsesTool.parameters ?? responsesTool.function?.parameters; assert.ok(responsesParams.properties.isolation.enum.includes(null)); @@ -143,7 +144,16 @@ const AGENT_SCHEMA_OPTIONAL = { type: "object", properties: { description: { type: "string" }, - isolation: { type: ["string", "null"], enum: ["worktree", "remote", null] }, + model: { + type: ["string", "null"], + enum: ["sonnet", "opus", "haiku", "fable", null], + description: "Model override (null = omit this parameter)", + }, + isolation: { + type: ["string", "null"], + enum: ["worktree", "remote", null], + description: "Isolation mode (null = omit this parameter)", + }, }, required: ["description"], }; @@ -166,11 +176,38 @@ test("7023: stripEmptyOptionalToolArgs preserves null for a required property (n assert.equal(Object.prototype.hasOwnProperty.call(cleaned, "note"), true); }); +test("7023: stripEmptyOptionalToolArgs drops the omission sentinel after strict mode marks it required", () => { + const schema = { + type: "object", + properties: { + isolation: { + type: ["string", "null"], + enum: ["worktree", "remote", null], + description: "Isolation mode (null = omit this parameter)", + }, + }, + required: ["isolation"], + }; + const cleaned = JSON.parse( + stripEmptyOptionalToolArgs(JSON.stringify({ isolation: null }), "Agent", schema) + ); + assert.equal(Object.prototype.hasOwnProperty.call(cleaned, "isolation"), false); +}); + +test("7023: Agent null fields drop even when the strict schema snapshot is unavailable", () => { + const raw = JSON.stringify({ description: "schema unavailable", model: null, isolation: null }); + const cleaned = JSON.parse(stripEmptyOptionalToolArgs(raw, "Agent", null)); + assert.deepEqual(cleaned, { description: "schema unavailable" }); +}); + test("7023: acceptance — codex Agent call emits isolation:null (post-injection idiom) -> client-visible call has no isolation key", () => { const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) }; openaiResponsesToOpenAIResponse( - { type: "response.output_item.added", item: { type: "function_call", call_id: "call_1", name: "Agent" } }, + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_1", name: "Agent" }, + }, state ); const done = openaiResponsesToOpenAIResponse( @@ -191,11 +228,161 @@ test("7023: acceptance — codex Agent call emits isolation:null (post-injection assert.equal(args.description, "no isolation intended"); }); +test("7023: streamed Agent arguments are buffered until the null omission sentinel is stripped", () => { + const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) }; + + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_1", name: "Agent" }, + }, + state + ); + const raw = JSON.stringify({ + description: "no isolation intended", + model: null, + isolation: null, + }); + const firstDelta = openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", delta: raw.slice(0, 35) }, + state + ); + const secondDelta = openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", delta: raw.slice(35) }, + state + ); + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_1", name: "Agent", arguments: raw }, + }, + state + ); + + assert.equal(firstDelta, null); + assert.equal(secondDelta, null); + const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments); + assert.equal(Object.prototype.hasOwnProperty.call(args, "model"), false); + assert.equal(Object.prototype.hasOwnProperty.call(args, "isolation"), false); + assert.equal(args.description, "no isolation intended"); +}); + +test("7023: buffered fragments are normalized when done omits its argument snapshot", () => { + const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) }; + const raw = JSON.stringify({ description: "fragmented", model: null, isolation: null }); + + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_2", name: "Agent" }, + }, + state + ); + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", delta: raw.slice(0, 25) }, + state + ); + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", delta: raw.slice(25) }, + state + ); + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_2", name: "Agent" }, + }, + state + ); + + const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments); + assert.deepEqual(args, { description: "fragmented" }); +}); + +test("7023: stream flush emits normalized arguments and terminal finish reason", () => { + const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) }; + const raw = JSON.stringify({ description: "flush", model: null, isolation: null }); + + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_flush", name: "Agent" }, + }, + state + ); + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", delta: raw }, + state + ); + const flushed = openaiResponsesToOpenAIResponse(null, state); + + assert.equal(flushed.length, 2); + const args = JSON.parse(flushed[0].choices[0].delta.tool_calls[0].function.arguments); + assert.deepEqual(args, { description: "flush" }); + assert.equal(flushed[1].choices[0].finish_reason, "tool_calls"); +}); + +test("7023: object-valued terminal arguments retain explicit isolation", () => { + const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) }; + + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_object", name: "Agent" }, + }, + state + ); + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_object", + name: "Agent", + arguments: { description: "explicit", model: null, isolation: "worktree" }, + }, + }, + state + ); + + const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments); + assert.deepEqual(args, { description: "explicit", isolation: "worktree" }); +}); + +test("7023: deferred Agent name normalizes buffered null sentinels", () => { + const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) }; + const raw = JSON.stringify({ description: "deferred", model: null, isolation: null }); + + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_deferred", name: "" }, + }, + state + ); + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", delta: raw }, + state + ); + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_deferred", name: "Agent" }, + }, + state + ); + + const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments); + assert.deepEqual(args, { description: "deferred" }); +}); + test("7023: negative — a legitimate isolation:'worktree' value is preserved unchanged", () => { const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) }; openaiResponsesToOpenAIResponse( - { type: "response.output_item.added", item: { type: "function_call", call_id: "call_2", name: "Agent" } }, + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_2", name: "Agent" }, + }, state ); const done = openaiResponsesToOpenAIResponse( diff --git a/tests/unit/translator-responses-cache-usage.test.ts b/tests/unit/translator-responses-cache-usage.test.ts new file mode 100644 index 0000000000..e7a30b1bab --- /dev/null +++ b/tests/unit/translator-responses-cache-usage.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIResponse } = + await import("../../open-sse/translator/response/openai-responses.ts"); +const { openaiToClaudeResponse } = + await import("../../open-sse/translator/response/openai-to-claude.ts"); + +function createResponsesState() { + return { + started: false, + finishReasonSent: false, + completedOutputItems: [], + funcArgsBuf: {}, + funcNames: {}, + funcCallIds: {}, + funcArgsDone: {}, + funcItemAdded: {}, + funcItemDone: {}, + }; +} + +function createClaudeState() { + return { + toolCalls: new Map(), + _pendingXmlToolCalls: [], + _xmlInvokeBuffer: "", + }; +} + +test("Responses cache usage survives the OpenAI-to-Claude streaming chain", () => { + const openaiChunk = openaiResponsesToOpenAIResponse( + { + type: "response.completed", + response: { + id: "resp-cache-hit", + model: "gpt-5.6-sol", + output: [], + usage: { + input_tokens: 21_023, + input_tokens_details: { cached_tokens: 20_224 }, + output_tokens: 5, + }, + }, + }, + createResponsesState() + ); + + assert.equal(openaiChunk.usage.prompt_tokens, 21_023); + assert.equal(openaiChunk.usage.prompt_tokens_details.cached_tokens, 20_224); + assert.equal(openaiChunk.usage.total_tokens, 21_028); + + const events = openaiToClaudeResponse(openaiChunk, createClaudeState()); + const messageDelta = events.find((event) => event.type === "message_delta"); + + assert.deepEqual(messageDelta.usage, { + input_tokens: 799, + output_tokens: 5, + cache_read_input_tokens: 20_224, + }); +}); + +test("Anthropic-style Responses usage still adds cache tokens to total prompt tokens", () => { + const openaiChunk = openaiResponsesToOpenAIResponse( + { + type: "response.completed", + response: { + id: "resp-anthropic-usage", + model: "claude-test", + output: [], + usage: { + input_tokens: 799, + cache_read_input_tokens: 20_224, + cache_creation_input_tokens: 100, + output_tokens: 5, + }, + }, + }, + createResponsesState() + ); + + assert.equal(openaiChunk.usage.prompt_tokens, 21_123); + assert.equal(openaiChunk.usage.prompt_tokens_details.cached_tokens, 20_224); + assert.equal(openaiChunk.usage.prompt_tokens_details.cache_creation_tokens, 100); + assert.equal(openaiChunk.usage.total_tokens, 21_128); +});