From 00aba8ef16e946451a10e2f01293f23e34dce5c0 Mon Sep 17 00:00:00 2001 From: Nahuel Saruf <86387896+NahuSaruf@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:33:39 -0300 Subject: [PATCH] fix(sse): include prompt cache usage fields on message_stop fallback path (#10545) Mirrors the existing prompt_tokens_details cache-field mapping from the message_delta finish path into the message_stop fallback, so OpenAI-compatible clients see cache_read/cache_creation counters when the finish signal arrives without usage on the same event. Fixes #10535. Validated in an isolated worktree boarded onto origin/release/v3.8.50 (0 conflicts, 2 files): - 16/16 focused tests pass (translator-resp-claude-to-openai.test.ts), including the new regression test for this exact fallback path. - check-file-size, check-changelog-integrity: OK. - typecheck:core: clean. - check-complexity / check-cognitive-complexity: OK, both under baseline. Co-authored-by: NahuSaruf --- .../translator/response/claude-to-openai.ts | 12 +++++++ .../translator-resp-claude-to-openai.test.ts | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/open-sse/translator/response/claude-to-openai.ts b/open-sse/translator/response/claude-to-openai.ts index 4905bd8e60..2d20661e7b 100644 --- a/open-sse/translator/response/claude-to-openai.ts +++ b/open-sse/translator/response/claude-to-openai.ts @@ -332,6 +332,8 @@ export function claudeToOpenAIResponse(chunk, state) { if (!state.finishReasonSent) { const finishReason = state.finishReason || (state.toolCalls?.size > 0 ? "tool_calls" : "stop"); + const cachedTokens = state.usage?.cache_read_input_tokens || 0; + const cacheCreationTokens = state.usage?.cache_creation_input_tokens || 0; const usageObj = state.usage && typeof state.usage === "object" ? { @@ -347,6 +349,16 @@ export function claudeToOpenAIResponse(chunk, state) { }, } : {}), + ...(cachedTokens > 0 || cacheCreationTokens > 0 + ? { + prompt_tokens_details: { + ...(cachedTokens > 0 ? { cached_tokens: cachedTokens } : {}), + ...(cacheCreationTokens > 0 + ? { cache_creation_tokens: cacheCreationTokens } + : {}), + }, + } + : {}), }, } : {}; diff --git a/tests/unit/translator-resp-claude-to-openai.test.ts b/tests/unit/translator-resp-claude-to-openai.test.ts index 591622febf..67a242d39f 100644 --- a/tests/unit/translator-resp-claude-to-openai.test.ts +++ b/tests/unit/translator-resp-claude-to-openai.test.ts @@ -403,6 +403,40 @@ test("Claude stream: message_stop falls back to tool_calls when tool use already assert.equal(result[0].choices[0].finish_reason, "tool_calls"); }); +test("Claude stream: message_stop includes prompt_tokens_details when usage arrived on an earlier message_delta without stop_reason (#10535)", () => { + const state = createState(); + claudeToOpenAIResponse( + { type: "message_start", message: { id: "msg1", model: "claude-sonnet-4-6" } }, + state + ); + + // Usage lands on a message_delta that carries no stop_reason (e.g. an + // upstream that reports usage and the finish signal in separate events), + // so the finalChunk branch in the message_delta case never runs and + // finishReasonSent stays false. + const deltaResult = claudeToOpenAIResponse( + { + type: "message_delta", + delta: {}, + usage: { + input_tokens: 8, + output_tokens: 5, + cache_read_input_tokens: 2000, + cache_creation_input_tokens: 0, + }, + }, + state + ); + assert.equal(deltaResult, null); + + const result = claudeToOpenAIResponse({ type: "message_stop" }, state); + + assert.equal(result[0].usage.prompt_tokens, 2008); + assert.equal(result[0].usage.completion_tokens, 5); + assert.equal(result[0].usage.total_tokens, 2013); + assert.equal(result[0].usage.prompt_tokens_details.cached_tokens, 2000); +}); + test("Claude stream: unsupported events return null", () => { assert.equal(claudeToOpenAIResponse({ type: "error" }, createState()), null); });