From 44cdc4d78c43192b6c9ed784413673b2b1a47bdc Mon Sep 17 00:00:00 2001 From: luoyide Date: Wed, 22 Jul 2026 12:20:31 +0800 Subject: [PATCH 1/2] fix(pricing): stop billing reasoning tokens twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calculateCostFromTokens charged reasoning_tokens at the full reasoning rate ON TOP of completion_tokens: cost += completion_tokens * output_rate cost += reasoning_tokens * (reasoning_rate || output_rate) But reasoning tokens are a subset of completion tokens everywhere we consume them: OpenAI counts reasoning_tokens inside completion_tokens, and toOpenAIUsage's gemini extractor folds thoughtsTokenCount into completionTokens. Every reasoning request was therefore billed for its thinking twice — and since MODEL_PRICING sets reasoning == output for most entries, that is a straight 2x on the thinking portion. This is the same contract the function already applies one block up, where cached/cache_creation are subtracted because prompt_tokens is cache-inclusive. Reasoning now bills only the differential, and only when a model prices it apart from output. Two call sites reported gemini usage with thoughts OUTSIDE the completion count, which would have turned the fix into an under-charge for gemini. Both now fold thoughts in, matching what toOpenAIUsage already did. Total gemini cost is unchanged by the fold — candidates*output + thoughts*reasoning either way — only the field split changes; there is a test pinning that equivalence. Reported cost for reasoning models goes DOWN after this change. That is the point: the previous figures over-counted. 由 Claude Code 辅助生成 --- open-sse/handlers/chatCore/requestDetail.js | 8 ++- open-sse/providers/pricing.js | 9 ++- open-sse/utils/usageTracking.js | 7 ++- tests/unit/cached-token-usage.test.js | 66 +++++++++++++++++++++ 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js index b3d24115f33..4dc18470490 100644 --- a/open-sse/handlers/chatCore/requestDetail.js +++ b/open-sse/handlers/chatCore/requestDetail.js @@ -44,13 +44,15 @@ export function extractUsageFromResponse(responseBody) { }; } - // Gemini format + // Gemini format — thoughts sit outside candidates upstream; fold them in so + // completion_tokens stays reasoning-inclusive (see extractUsage in usageTracking.js) if (responseBody.usageMetadata) { + const thoughts = responseBody.usageMetadata.thoughtsTokenCount || 0; return { prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0, - completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0, + completion_tokens: (responseBody.usageMetadata.candidatesTokenCount || 0) + thoughts, cached_tokens: responseBody.usageMetadata.cachedContentTokenCount || 0, - reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount || 0 + reasoning_tokens: thoughts }; } diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index c2831fdb366..e4e61f36f57 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -304,9 +304,14 @@ export function calculateCostFromTokens(tokens, pricing) { const outputTokens = tokens.completion_tokens || tokens.output_tokens || 0; cost += outputTokens * (pricing.output / 1000000); + // completion_tokens is reasoning-inclusive (same contract as the cache-inclusive + // prompt_tokens above): OpenAI counts reasoning_tokens inside completion_tokens, and + // our gemini normalization folds thoughtsTokenCount in. They are therefore already + // billed at the output rate — charge only the difference when a model prices + // reasoning apart from output. const reasoningTokens = tokens.reasoning_tokens || 0; - if (reasoningTokens > 0) { - cost += reasoningTokens * ((pricing.reasoning || pricing.output) / 1000000); + if (reasoningTokens > 0 && pricing.reasoning) { + cost += reasoningTokens * ((pricing.reasoning - pricing.output) / 1000000); } if (cacheCreationTokens > 0) { diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.js index 24518ef37cb..39f55722730 100644 --- a/open-sse/utils/usageTracking.js +++ b/open-sse/utils/usageTracking.js @@ -288,9 +288,14 @@ export function extractUsage(chunk) { // Antigravity wraps usageMetadata inside response: { response: { usageMetadata: {...} } } const usageMeta = chunk.usageMetadata || chunk.response?.usageMetadata; if (usageMeta && typeof usageMeta === "object") { + // Gemini keeps thoughtsTokenCount OUTSIDE candidatesTokenCount. Fold it in so + // completion_tokens stays reasoning-inclusive like every other provider — that + // invariant is what lets calculateCostFromTokens avoid double-charging. This is + // what toOpenAIUsage's gemini extractor already does. + const thoughts = usageMeta.thoughtsTokenCount || 0; return normalizeUsage({ prompt_tokens: usageMeta.promptTokenCount || 0, - completion_tokens: usageMeta.candidatesTokenCount || 0, + completion_tokens: (usageMeta.candidatesTokenCount || 0) + thoughts, total_tokens: usageMeta.totalTokenCount, cached_tokens: usageMeta.cachedContentTokenCount, reasoning_tokens: usageMeta.thoughtsTokenCount diff --git a/tests/unit/cached-token-usage.test.js b/tests/unit/cached-token-usage.test.js index 878110d03c9..7371c80620c 100644 --- a/tests/unit/cached-token-usage.test.js +++ b/tests/unit/cached-token-usage.test.js @@ -186,3 +186,69 @@ describe("Kiro usage pass-through", () => { expect(out.prompt_tokens_details.cache_creation_tokens).toBe(50); }); }); + +// Second canonical convention, parallel to the cache-inclusive prompt above: +// completion_tokens = output INCLUDING reasoning +// reasoning_tokens = reasoning portion (subset of completion_tokens) +// Discriminator: OpenAI already folds reasoning into completion_tokens; Gemini reports +// thoughtsTokenCount OUTSIDE candidatesTokenCount, so we fold it in — matching what +// toOpenAIUsage's gemini extractor has always done. +describe("reasoning-inclusive completion convention", () => { + it("folds Gemini thoughts into completion_tokens", () => { + const out = extractUsage({ + usageMetadata: { promptTokenCount: 100, candidatesTokenCount: 40, thoughtsTokenCount: 10, totalTokenCount: 150 }, + }); + expect(out.completion_tokens).toBe(50); // 40 candidates + 10 thoughts + expect(out.reasoning_tokens).toBe(10); + }); + + it("agrees with the toOpenAIUsage gemini extractor", () => { + const raw = { promptTokenCount: 100, candidatesTokenCount: 40, thoughtsTokenCount: 10, totalTokenCount: 150 }; + expect(extractUsage({ usageMetadata: raw }).completion_tokens) + .toBe(toOpenAIUsage(raw, "gemini").completion_tokens); + }); + + it("leaves OpenAI usage alone (reasoning already inside completion_tokens)", () => { + const out = extractUsage({ + usage: { prompt_tokens: 100, completion_tokens: 50, completion_tokens_details: { reasoning_tokens: 10 } }, + }); + expect(out.completion_tokens).toBe(50); + expect(out.reasoning_tokens).toBe(10); + }); +}); + +describe("calculateCostFromTokens (reasoning is a subset of completion)", () => { + const pricing = { input: 3, output: 15, cached: 0.3, reasoning: 15, cache_creation: 3.75 }; + + it("does not bill reasoning twice when it is priced at the output rate", () => { + const withReasoning = calculateCostFromTokens( + { prompt_tokens: 22, completion_tokens: 267, reasoning_tokens: 85 }, pricing); + const withoutReasoning = calculateCostFromTokens( + { prompt_tokens: 22, completion_tokens: 267 }, pricing); + expect(withReasoning).toBeCloseTo(withoutReasoning, 12); + expect(withReasoning).toBeCloseTo((22 * 3 + 267 * 15) / 1e6, 12); + }); + + it("bills only the difference when reasoning is priced above output", () => { + const cost = calculateCostFromTokens( + { prompt_tokens: 22, completion_tokens: 267, reasoning_tokens: 85 }, + { ...pricing, reasoning: 22.5 }); + expect(cost).toBeCloseTo((22 * 3 + 267 * 15 + 85 * 7.5) / 1e6, 12); + }); + + it("ignores reasoning tokens when the model declares no reasoning price", () => { + const cost = calculateCostFromTokens( + { prompt_tokens: 22, completion_tokens: 267, reasoning_tokens: 85 }, + { input: 3, output: 15 }); + expect(cost).toBeCloseTo((22 * 3 + 267 * 15) / 1e6, 12); + }); + + it("keeps Gemini total cost unchanged under the fold", () => { + // folded: completion 40+10=50, reasoning 10, reasoning priced above output + const folded = calculateCostFromTokens( + { prompt_tokens: 100, completion_tokens: 50, reasoning_tokens: 10 }, + { input: 1, output: 4, reasoning: 10 }); + // legacy math: candidates at the output rate + thoughts at the reasoning rate + expect(folded).toBeCloseTo((100 * 1 + 40 * 4 + 10 * 10) / 1e6, 12); + }); +}); From ea18e5653e619bbeeee190e510d1605e2d091bb8 Mon Sep 17 00:00:00 2001 From: luoyide Date: Wed, 22 Jul 2026 15:13:01 +0800 Subject: [PATCH 2/2] feat(usage): surface Claude thinking token counts to clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on the reasoning-token double-billing fix — that one has to land first, otherwise adding reasoning_tokens for Claude makes Claude join the double-billing it removes. Anthropic reports thinking tokens on message_delta as usage.output_tokens_details.thinking_tokens. Nothing consumed it: the Claude branches of extractUsage / extractUsageFromResponse and toOpenAIUsage's claude extractor all stopped at input/output/cache, so the count never reached cost tracking, the request-detail record, or the client. Wiring it up needed one non-obvious step. claude-to-openai builds its own usage object and assigns it to the shared stream state, mixing Claude field names in: state.usage = { prompt_tokens, completion_tokens, total_tokens, input_tokens, output_tokens } state.usage.output_tokens_details = { thinking_tokens } stream.js then passes that object through filterUsageForFormat before emitting the final chunk, and the OpenAI allow-list has no `output_tokens_details` — so a naive fix extracts the count correctly and still drops it on the way out. The count is therefore mirrored under both OpenAI spellings (`reasoning_tokens` and `completion_tokens_details.reasoning_tokens`), and `output_tokens_details` is added to the Claude allow-list so Claude-format clients keep it too. Note that thinking tokens are already inside output_tokens, so completion_tokens is left alone — the reasoning-inclusive convention the pricing fix relies on still holds. This matters most where the thinking TEXT is unavailable: GitHub Copilot returns a signed but EMPTY thinking block for its 4.7+ Claude shims (sonnet-5, opus-4.7, opus-4.8, fable-5). Verified end-to-end against a live gateway: sonnet-4.6 reasoning_content=1012ch reasoning_tokens=426 sonnet-5 reasoning_content= 0ch reasoning_tokens=886 Without the count, that second row is indistinguishable from a broken pipeline. 由 Claude Code 辅助生成 --- open-sse/handlers/chatCore/requestDetail.js | 5 +- open-sse/translator/concerns/usage.js | 7 +- .../translator/response/claude-to-openai.js | 21 ++++- open-sse/utils/usageTracking.js | 11 +-- tests/unit/cached-token-usage.test.js | 57 ++++++++------ .../claude-thinking-token-passthrough.test.js | 78 +++++++++++++++++++ 6 files changed, 146 insertions(+), 33 deletions(-) create mode 100644 tests/unit/claude-thinking-token-passthrough.test.js diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js index 4dc18470490..e0cce148537 100644 --- a/open-sse/handlers/chatCore/requestDetail.js +++ b/open-sse/handlers/chatCore/requestDetail.js @@ -24,13 +24,14 @@ export function extractRequestConfig(body, stream) { export function extractUsageFromResponse(responseBody) { if (!responseBody || typeof responseBody !== "object") return null; - // Claude format + // Claude format — thinking tokens are already inside output_tokens if (responseBody.usage?.input_tokens !== undefined) { return { prompt_tokens: responseBody.usage.input_tokens || 0, completion_tokens: responseBody.usage.output_tokens || 0, cache_read_input_tokens: responseBody.usage.cache_read_input_tokens, - cache_creation_input_tokens: responseBody.usage.cache_creation_input_tokens + cache_creation_input_tokens: responseBody.usage.cache_creation_input_tokens, + reasoning_tokens: responseBody.usage.output_tokens_details?.thinking_tokens }; } diff --git a/open-sse/translator/concerns/usage.js b/open-sse/translator/concerns/usage.js index 3ace3062437..d07ff788d86 100644 --- a/open-sse/translator/concerns/usage.js +++ b/open-sse/translator/concerns/usage.js @@ -22,7 +22,12 @@ const USAGE_EXTRACTORS = { const input = n(raw.input_tokens), output = n(raw.output_tokens); const cacheRead = n(raw.cache_read_input_tokens), cacheCreate = n(raw.cache_creation_input_tokens); const prompt = input + cacheRead + cacheCreate; - return { promptTokens: prompt, completionTokens: output, totalTokens: prompt + output, cachedTokens: cacheRead, cacheCreationTokens: cacheCreate }; + // Anthropic reports thinking tokens on message_delta as + // output_tokens_details.thinking_tokens and counts them INSIDE output_tokens + // (unlike gemini's thoughtsTokenCount, which sits outside candidatesTokenCount). + // Surface them as reasoning_tokens without re-adding them to completionTokens. + const thinking = n(raw.output_tokens_details?.thinking_tokens); + return { promptTokens: prompt, completionTokens: output, totalTokens: prompt + output, cachedTokens: cacheRead, cacheCreationTokens: cacheCreate, reasoningTokens: thinking }; }, gemini(raw) { const cached = n(raw.cachedContentTokenCount); diff --git a/open-sse/translator/response/claude-to-openai.js b/open-sse/translator/response/claude-to-openai.js index 4651d3cf1b7..c940c92ccf5 100644 --- a/open-sse/translator/response/claude-to-openai.js +++ b/open-sse/translator/response/claude-to-openai.js @@ -145,6 +145,24 @@ export function claudeToOpenAIResponse(chunk, state) { if (cacheReadTokens > 0) state.usage.cache_read_input_tokens = cacheReadTokens; if (cacheCreationTokens > 0) state.usage.cache_creation_input_tokens = cacheCreationTokens; + + // Thinking tokens ride on message_delta only. Anthropic reports them as + // usage.output_tokens_details.thinking_tokens; carry the count through so the + // OpenAI usage can expose it. For models whose thinking TEXT is withheld + // upstream (Copilot's 4.7+ Claude shims) this is the only signal that + // reasoning happened at all. + const thinkingTokens = typeof chunk.usage.output_tokens_details?.thinking_tokens === "number" + ? chunk.usage.output_tokens_details.thinking_tokens + : prev.output_tokens_details?.thinking_tokens; + if (typeof thinkingTokens === "number") { + state.usage.output_tokens_details = { thinking_tokens: thinkingTokens }; + // stream.js hands state.usage to filterUsageForFormat before the client sees + // it, and that filter only passes OpenAI field names — output_tokens_details + // is a Claude name and gets dropped. Mirror the count under both OpenAI + // spellings so it actually reaches the client. + state.usage.reasoning_tokens = thinkingTokens; + state.usage.completion_tokens_details = { reasoning_tokens: thinkingTokens }; + } } if (chunk.delta?.stop_reason) { @@ -158,7 +176,8 @@ export function claudeToOpenAIResponse(chunk, state) { input_tokens: state.usage.input_tokens || 0, output_tokens: state.usage.output_tokens || 0, cache_read_input_tokens: state.usage.cache_read_input_tokens, - cache_creation_input_tokens: state.usage.cache_creation_input_tokens + cache_creation_input_tokens: state.usage.cache_creation_input_tokens, + output_tokens_details: state.usage.output_tokens_details }, "claude"); } diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.js index 39f55722730..058c761aa54 100644 --- a/open-sse/utils/usageTracking.js +++ b/open-sse/utils/usageTracking.js @@ -73,7 +73,7 @@ export function filterUsageForFormat(usage, targetFormat) { // Define allowed fields for each format const formatFields = { [FORMATS.CLAUDE]: [ - 'input_tokens', 'output_tokens', + 'input_tokens', 'output_tokens', 'output_tokens_details', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'estimated' ], @@ -249,13 +249,15 @@ export function extractUsage(chunk) { }); } - // Claude format (message_delta event) + // Claude format (message_delta event) — the only event carrying + // output_tokens_details.thinking_tokens (message_start has no details block). if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") { return normalizeUsage({ prompt_tokens: chunk.usage.input_tokens || 0, completion_tokens: chunk.usage.output_tokens || 0, cache_read_input_tokens: chunk.usage.cache_read_input_tokens, - cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens + cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens, + reasoning_tokens: chunk.usage.output_tokens_details?.thinking_tokens }); } @@ -290,8 +292,7 @@ export function extractUsage(chunk) { if (usageMeta && typeof usageMeta === "object") { // Gemini keeps thoughtsTokenCount OUTSIDE candidatesTokenCount. Fold it in so // completion_tokens stays reasoning-inclusive like every other provider — that - // invariant is what lets calculateCostFromTokens avoid double-charging. This is - // what toOpenAIUsage's gemini extractor already does. + // invariant is what lets calculateCostFromTokens avoid double-charging. const thoughts = usageMeta.thoughtsTokenCount || 0; return normalizeUsage({ prompt_tokens: usageMeta.promptTokenCount || 0, diff --git a/tests/unit/cached-token-usage.test.js b/tests/unit/cached-token-usage.test.js index 7371c80620c..865899556a7 100644 --- a/tests/unit/cached-token-usage.test.js +++ b/tests/unit/cached-token-usage.test.js @@ -188,31 +188,40 @@ describe("Kiro usage pass-through", () => { }); // Second canonical convention, parallel to the cache-inclusive prompt above: -// completion_tokens = output INCLUDING reasoning +// completion_tokens = output INCLUDING reasoning/thinking // reasoning_tokens = reasoning portion (subset of completion_tokens) -// Discriminator: OpenAI already folds reasoning into completion_tokens; Gemini reports -// thoughtsTokenCount OUTSIDE candidatesTokenCount, so we fold it in — matching what -// toOpenAIUsage's gemini extractor has always done. +// Discriminator: OpenAI and Anthropic already fold reasoning into their output count; +// Gemini reports thoughtsTokenCount OUTSIDE candidatesTokenCount, so we fold it in. describe("reasoning-inclusive completion convention", () => { - it("folds Gemini thoughts into completion_tokens", () => { - const out = extractUsage({ - usageMetadata: { promptTokenCount: 100, candidatesTokenCount: 40, thoughtsTokenCount: 10, totalTokenCount: 150 }, - }); - expect(out.completion_tokens).toBe(50); // 40 candidates + 10 thoughts - expect(out.reasoning_tokens).toBe(10); + it("surfaces Anthropic thinking tokens without inflating completion_tokens", () => { + const out = toOpenAIUsage( + { input_tokens: 22, output_tokens: 267, output_tokens_details: { thinking_tokens: 85 } }, + "claude" + ); + expect(out.completion_tokens).toBe(267); // thinking already inside output_tokens + expect(out.completion_tokens_details.reasoning_tokens).toBe(85); }); - it("agrees with the toOpenAIUsage gemini extractor", () => { - const raw = { promptTokenCount: 100, candidatesTokenCount: 40, thoughtsTokenCount: 10, totalTokenCount: 150 }; - expect(extractUsage({ usageMetadata: raw }).completion_tokens) - .toBe(toOpenAIUsage(raw, "gemini").completion_tokens); + it("omits reasoning details when the model reported no thinking", () => { + const out = toOpenAIUsage({ input_tokens: 22, output_tokens: 41 }, "claude"); + expect(out.completion_tokens).toBe(41); + expect(out.completion_tokens_details).toBeUndefined(); }); - it("leaves OpenAI usage alone (reasoning already inside completion_tokens)", () => { + it("extractUsage reads thinking tokens off message_delta", () => { const out = extractUsage({ - usage: { prompt_tokens: 100, completion_tokens: 50, completion_tokens_details: { reasoning_tokens: 10 } }, + type: "message_delta", + usage: { input_tokens: 22, output_tokens: 267, output_tokens_details: { thinking_tokens: 85 } }, }); - expect(out.completion_tokens).toBe(50); + expect(out.completion_tokens).toBe(267); + expect(out.reasoning_tokens).toBe(85); + }); + + it("folds Gemini thoughts into completion_tokens so the convention holds", () => { + const out = extractUsage({ + usageMetadata: { promptTokenCount: 100, candidatesTokenCount: 40, thoughtsTokenCount: 10, totalTokenCount: 150 }, + }); + expect(out.completion_tokens).toBe(50); // 40 candidates + 10 thoughts expect(out.reasoning_tokens).toBe(10); }); }); @@ -221,18 +230,18 @@ describe("calculateCostFromTokens (reasoning is a subset of completion)", () => const pricing = { input: 3, output: 15, cached: 0.3, reasoning: 15, cache_creation: 3.75 }; it("does not bill reasoning twice when it is priced at the output rate", () => { - const withReasoning = calculateCostFromTokens( + const withThinking = calculateCostFromTokens( { prompt_tokens: 22, completion_tokens: 267, reasoning_tokens: 85 }, pricing); - const withoutReasoning = calculateCostFromTokens( + const withoutThinking = calculateCostFromTokens( { prompt_tokens: 22, completion_tokens: 267 }, pricing); - expect(withReasoning).toBeCloseTo(withoutReasoning, 12); - expect(withReasoning).toBeCloseTo((22 * 3 + 267 * 15) / 1e6, 12); + expect(withThinking).toBeCloseTo(withoutThinking, 12); + expect(withThinking).toBeCloseTo((22 * 3 + 267 * 15) / 1e6, 12); }); it("bills only the difference when reasoning is priced above output", () => { const cost = calculateCostFromTokens( { prompt_tokens: 22, completion_tokens: 267, reasoning_tokens: 85 }, - { ...pricing, reasoning: 22.5 }); + { ...pricing, reasoning: 22.5}); expect(cost).toBeCloseTo((22 * 3 + 267 * 15 + 85 * 7.5) / 1e6, 12); }); @@ -244,11 +253,11 @@ describe("calculateCostFromTokens (reasoning is a subset of completion)", () => }); it("keeps Gemini total cost unchanged under the fold", () => { - // folded: completion 40+10=50, reasoning 10, reasoning priced above output + // fold: completion 40+10=50, reasoning 10, reasoning priced above output const folded = calculateCostFromTokens( { prompt_tokens: 100, completion_tokens: 50, reasoning_tokens: 10 }, { input: 1, output: 4, reasoning: 10 }); - // legacy math: candidates at the output rate + thoughts at the reasoning rate + // legacy math: candidates at output rate + thoughts at reasoning rate expect(folded).toBeCloseTo((100 * 1 + 40 * 4 + 10 * 10) / 1e6, 12); }); }); diff --git a/tests/unit/claude-thinking-token-passthrough.test.js b/tests/unit/claude-thinking-token-passthrough.test.js new file mode 100644 index 00000000000..55fa9d3979e --- /dev/null +++ b/tests/unit/claude-thinking-token-passthrough.test.js @@ -0,0 +1,78 @@ +/** + * Anthropic reports thinking tokens as usage.output_tokens_details.thinking_tokens on + * message_delta. For Copilot's 4.7+ Claude shims — which return a signed but EMPTY + * thinking block — that count is the only signal that reasoning happened at all, so it + * has to survive all the way to the client. + * + * It nearly didn't: claude-to-openai writes its own usage object into the shared stream + * state, and stream.js filters that object with OpenAI field names before emitting it. + * output_tokens_details is a Claude name, so the count was dropped on the way out. + */ +import { describe, it, expect } from "vitest"; +import "../translator/registerAll.js"; +import { createSSETransformStreamWithLogger } from "../../open-sse/utils/stream.js"; +import { filterUsageForFormat } from "../../open-sse/utils/usageTracking.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +const EVENTS = [ + { type: "message_start", message: { id: "msg_1", model: "claude-sonnet-4.6", usage: { input_tokens: 22, output_tokens: 1 } } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "reasoning…" } }, + { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "Esig" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "answer" } }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { input_tokens: 22, output_tokens: 267, output_tokens_details: { thinking_tokens: 85 } } }, + { type: "message_stop" }, +]; + +async function runStream(events) { + const sse = events.map((e) => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join(""); + const ts = createSSETransformStreamWithLogger( + FORMATS.CLAUDE, FORMATS.OPENAI, "github", null, null, "claude-sonnet-4.6", null, { messages: [] }); + const rs = new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode(sse)); c.close(); } }); + const reader = rs.pipeThrough(ts).getReader(); + const parts = []; + for (;;) { const { done, value } = await reader.read(); if (done) break; parts.push(new TextDecoder().decode(value)); } + return parts.join("").split("\n") + .filter((l) => l.startsWith("data:")) + .map((l) => l.slice(5).trim()) + .filter((p) => p && p !== "[DONE]") + .map((p) => JSON.parse(p)); +} + +describe("claude -> openai streaming exposes thinking tokens", () => { + it("puts the count in the client-facing usage", async () => { + const chunks = await runStream(EVENTS); + const usage = chunks.filter((c) => c.usage).at(-1)?.usage; + expect(usage).toBeDefined(); + expect(usage.reasoning_tokens).toBe(85); + expect(usage.completion_tokens_details?.reasoning_tokens).toBe(85); + }); + + it("still streams the thinking text as reasoning_content", async () => { + const chunks = await runStream(EVENTS); + const reasoning = chunks.map((c) => c.choices?.[0]?.delta?.reasoning_content || "").join(""); + expect(reasoning).toBe("reasoning…"); + }); + + // The 4.7+ Copilot case: signed thinking block, no text — the count is all we get. + it("reports the count even when upstream withholds the thinking text", async () => { + const withheld = EVENTS.map((e) => + e.type === "content_block_delta" && e.delta?.type === "thinking_delta" + ? { ...e, delta: { ...e.delta, thinking: "" } } + : e); + const chunks = await runStream(withheld); + const reasoning = chunks.map((c) => c.choices?.[0]?.delta?.reasoning_content || "").join(""); + expect(reasoning).toBe(""); + expect(chunks.filter((c) => c.usage).at(-1).usage.reasoning_tokens).toBe(85); + }); + + it("keeps output_tokens_details for Claude-format clients", () => { + const out = filterUsageForFormat( + { input_tokens: 22, output_tokens: 267, output_tokens_details: { thinking_tokens: 85 } }, + FORMATS.CLAUDE); + expect(out.output_tokens_details).toEqual({ thinking_tokens: 85 }); + }); +});