From e0b8d665752bbd992c943055d7791d2825b18520 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Wed, 9 Sep 2026 13:50:10 +0800 Subject: [PATCH 1/2] fix: map Chat usage onto Responses usage counters The Responses adapter forwarded the upstream Chat usage object unchanged for non-streamed requests and emitted no usage at all for streamed ones. Clients therefore received the wrong field names for /v1/responses and Codex-style UIs displayed zeroed or missing token counts. Chat usage is now mapped onto the Responses usage shape for both paths, carrying over prompt and completion totals, cached prompt tokens and reasoning tokens, while falling back to zeroed counters when upstream omits usage. --- lib/server/proxy/responses.ts | 82 ++++++++++++++++++++++++++- tests/server/responses-memory.test.ts | 78 +++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index 07b5292..a866fee 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -1046,6 +1046,78 @@ const normalizeTranscriptMessageToolNames = ( }); }; +const toResponsesUsageNumber = (value: unknown): number => { + const numeric = + typeof value === 'number' ? value : Number.parseFloat(String(value ?? '')); + + if (!Number.isFinite(numeric) || numeric < 0) { + return 0; + } + + return numeric; +}; + +const mapChatUsageToResponses = (usage: unknown): Record => { + if (!usage || typeof usage !== 'object') { + return { + input_tokens: 0, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 0, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 0, + }; + } + + const value = usage as { + cache_creation_input_tokens?: unknown; + cache_read_input_tokens?: unknown; + completion_tokens?: unknown; + completion_tokens_details?: { reasoning_tokens?: unknown }; + completion_thinking_tokens?: unknown; + input_tokens_details?: { cached_tokens?: unknown }; + prompt_cache_hit_tokens?: unknown; + prompt_cache_miss_tokens?: unknown; + prompt_cache_write_tokens?: unknown; + prompt_tokens?: unknown; + prompt_tokens_details?: { + cache_creation_tokens?: unknown; + cached_tokens?: unknown; + }; + total_tokens?: unknown; + }; + // Chat usage is the single source of truth for both shapes. Keep the + // Responses counters faithful to it so clients never see zeroed metrics. + const inputTokens = toResponsesUsageNumber( + value.prompt_tokens ?? value.prompt_cache_miss_tokens, + ); + const outputTokens = toResponsesUsageNumber(value.completion_tokens); + const cachedTokens = toResponsesUsageNumber( + value.prompt_tokens_details?.cached_tokens ?? + value.input_tokens_details?.cached_tokens ?? + value.cache_read_input_tokens ?? + value.prompt_cache_hit_tokens, + ); + const cacheCreationTokens = toResponsesUsageNumber( + value.prompt_tokens_details?.cache_creation_tokens ?? + value.cache_creation_input_tokens ?? + value.prompt_cache_write_tokens, + ); + const reasoningTokens = toResponsesUsageNumber( + value.completion_tokens_details?.reasoning_tokens ?? + value.completion_thinking_tokens, + ); + + return { + input_tokens: inputTokens, + input_tokens_details: { cached_tokens: cachedTokens }, + output_tokens: outputTokens, + output_tokens_details: { reasoning_tokens: reasoningTokens }, + total_tokens: + toResponsesUsageNumber(value.total_tokens) || + inputTokens + outputTokens + cacheCreationTokens, + }; +}; + const mapChatResponseToResponsesPayload = async ( accessKeyId: string | null, credentialFilename: string | null, @@ -1127,7 +1199,7 @@ const mapChatResponseToResponsesPayload = async ( model, output, output_text: outputText, - usage: upstreamPayload.usage ?? null, + usage: mapChatUsageToResponses(upstreamPayload.usage), metadata: defaults.metadata ?? {}, previous_response_id: previousResponseId, }; @@ -1180,6 +1252,7 @@ const createResponsesEventStream = async ( const toolCallStates = new Map(); const toolCallStateKeys = new Map(); let nextToolCallOutputIndex = 1; + let latestUsage: unknown = null; let reader: ReadableStreamDefaultReader | null = null; let cancelled = false; const releaseReader = (): void => { @@ -1405,6 +1478,7 @@ const createResponsesEventStream = async ( status: 'completed', output_text: outputText, previous_response_id: previousResponseId, + usage: mapChatUsageToResponses(latestUsage), output: [ ...(outputText ? [buildStreamingMessageItem('completed')] @@ -1464,7 +1538,13 @@ const createResponsesEventStream = async ( tool_calls?: ChatResponseToolCall[]; }; }>; + usage?: unknown; }; + // The final upstream chunk carries the aggregated usage, so + // remember it for the downstream response.completed event. + if (payload.usage !== undefined) { + latestUsage = payload.usage; + } const delta = payload.choices?.[0]?.delta; if (delta?.content) { diff --git a/tests/server/responses-memory.test.ts b/tests/server/responses-memory.test.ts index 32b4487..216db0c 100644 --- a/tests/server/responses-memory.test.ts +++ b/tests/server/responses-memory.test.ts @@ -339,6 +339,84 @@ describe('Responses memory bounds', () => { expect(writePgSession).not.toHaveBeenCalled(); }); + it('maps Chat usage onto Responses usage for non-streamed requests', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + choices: [{ message: { content: 'usage answer' } }], + usage: { + completion_tokens: 506, + completion_tokens_details: { reasoning_tokens: 12 }, + prompt_tokens: 281734, + prompt_tokens_details: { cached_tokens: 281408 }, + total_tokens: 282240, + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await handleResponsesRequest(makeRequest(), { + input: 'usage please', + model: 'gpt-5.5', + }); + const payload = (await response.json()) as Record; + + expect(payload.usage).toEqual({ + input_tokens: 281734, + input_tokens_details: { cached_tokens: 281408 }, + output_tokens: 506, + output_tokens_details: { reasoning_tokens: 12 }, + total_tokens: 282240, + }); + }); + + it('emits mapped usage in streamed response.completed events', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + 'data: {"choices":[{"delta":{"content":"usage "}}]}\n\n' + + 'data: {"choices":[{"delta":{"content":"stream"}}]}\n\n' + + 'data: {"choices":[],"usage":{"prompt_tokens":281734,"completion_tokens":506,"total_tokens":282240,"prompt_tokens_details":{"cached_tokens":281408},"prompt_cache_hit_tokens":281408,"prompt_cache_miss_tokens":326,"completion_tokens_details":{"reasoning_tokens":12}}}\n\n' + + 'data: [DONE]\n\n', + { headers: { 'Content-Type': 'text/event-stream' } }, + ), + ); + + const response = await handleResponsesRequest(makeRequest(), { + input: 'stream usage please', + model: 'gpt-5.5', + stream: true, + }); + const body = await response.text(); + + expect(body).toContain('"output_text":"usage stream"'); + expect(body).toContain('"input_tokens":281734'); + expect(body).toContain('"cached_tokens":281408'); + expect(body).toContain('"output_tokens":506'); + expect(body).toContain('"reasoning_tokens":12'); + expect(body).toContain('"total_tokens":282240'); + }); + + it('reports zeroed Responses usage when upstream omits usage', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + makeChatResponse('no usage here'), + ); + + const response = await handleResponsesRequest(makeRequest(), { + input: 'missing usage', + model: 'gpt-5.5', + }); + const payload = (await response.json()) as Record; + + expect(payload.usage).toEqual({ + input_tokens: 0, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 0, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 0, + }); + }); + it('bounds incomplete SSE frames in every proxy stream', async () => { const oversizedFrame = 'x'.repeat(1_000_001); const fetchMock = vi From 8330d0af4b5bb44674f821d9bb97c985a33060d4 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Wed, 9 Sep 2026 14:00:45 +0800 Subject: [PATCH 2/2] fix: correct Responses usage fallbacks for split cache counters Two fallback paths in the Chat to Responses usage mapping produced incorrect counters: - When prompt_tokens was absent, only prompt_cache_miss_tokens was used as input_tokens, so cached tokens could exceed the reported input total. The split counters are now summed in that case. - The fallback total added cache creation tokens on top of prompt_tokens, double-counting them because prompt_tokens already includes its cached and created subsets. The fallback is now input plus output. --- lib/server/proxy/responses.ts | 19 +++++--- tests/server/responses-memory.test.ts | 64 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index a866fee..b82583e 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -1085,11 +1085,6 @@ const mapChatUsageToResponses = (usage: unknown): Record => { }; total_tokens?: unknown; }; - // Chat usage is the single source of truth for both shapes. Keep the - // Responses counters faithful to it so clients never see zeroed metrics. - const inputTokens = toResponsesUsageNumber( - value.prompt_tokens ?? value.prompt_cache_miss_tokens, - ); const outputTokens = toResponsesUsageNumber(value.completion_tokens); const cachedTokens = toResponsesUsageNumber( value.prompt_tokens_details?.cached_tokens ?? @@ -1106,6 +1101,17 @@ const mapChatUsageToResponses = (usage: unknown): Record => { value.completion_tokens_details?.reasoning_tokens ?? value.completion_thinking_tokens, ); + // Chat usage is the single source of truth for both shapes. Keep the + // Responses counters faithful to it so clients never see zeroed metrics. + // prompt_tokens already covers its cached and created subsets, so the + // split counters are only summed when prompt_tokens is missing. Otherwise + // cached tokens would exceed the reported input total. + const inputTokens = toResponsesUsageNumber( + value.prompt_tokens ?? + toResponsesUsageNumber(value.prompt_cache_miss_tokens) + + cachedTokens + + cacheCreationTokens, + ); return { input_tokens: inputTokens, @@ -1113,8 +1119,7 @@ const mapChatUsageToResponses = (usage: unknown): Record => { output_tokens: outputTokens, output_tokens_details: { reasoning_tokens: reasoningTokens }, total_tokens: - toResponsesUsageNumber(value.total_tokens) || - inputTokens + outputTokens + cacheCreationTokens, + toResponsesUsageNumber(value.total_tokens) || inputTokens + outputTokens, }; }; diff --git a/tests/server/responses-memory.test.ts b/tests/server/responses-memory.test.ts index 216db0c..851be01 100644 --- a/tests/server/responses-memory.test.ts +++ b/tests/server/responses-memory.test.ts @@ -417,6 +417,70 @@ describe('Responses memory bounds', () => { }); }); + it('does not double-count cache creation in the fallback total', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + choices: [{ message: { content: 'derived totals' } }], + usage: { + completion_tokens: 3, + prompt_tokens: 10, + prompt_tokens_details: { cache_creation_tokens: 2 }, + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await handleResponsesRequest(makeRequest(), { + input: 'derive totals', + model: 'gpt-5.5', + }); + const payload = (await response.json()) as Record; + + // prompt_tokens already includes cache creation, so it must not be + // added again when computing the fallback total. + expect(payload.usage).toEqual({ + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 3, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 13, + }); + }); + + it('sums split cache counters when upstream omits prompt_tokens', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + choices: [{ message: { content: 'split counters' } }], + usage: { + completion_tokens: 506, + prompt_cache_hit_tokens: 281408, + prompt_cache_miss_tokens: 326, + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await handleResponsesRequest(makeRequest(), { + input: 'split counters', + model: 'gpt-5.5', + }); + const payload = (await response.json()) as Record; + + // Without prompt_tokens, the split counters must be summed so cached + // tokens never exceed the reported input total. + expect(payload.usage).toEqual({ + input_tokens: 281734, + input_tokens_details: { cached_tokens: 281408 }, + output_tokens: 506, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 282240, + }); + }); + it('bounds incomplete SSE frames in every proxy stream', async () => { const oversizedFrame = 'x'.repeat(1_000_001); const fetchMock = vi