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 <NahuSaruf@users.noreply.github.com>
This commit is contained in:
Nahuel Saruf
2026-08-20 22:33:39 -03:00
committed by GitHub
parent 769ab62fa3
commit 00aba8ef16
2 changed files with 46 additions and 0 deletions

View File

@@ -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 }
: {}),
},
}
: {}),
},
}
: {};

View File

@@ -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);
});