Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions open-sse/handlers/chatCore/requestDetail.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand All @@ -44,13 +45,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
};
}

Expand Down
9 changes: 7 additions & 2 deletions open-sse/providers/pricing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion open-sse/translator/concerns/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 20 additions & 1 deletion open-sse/translator/response/claude-to-openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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");
}

Expand Down
14 changes: 10 additions & 4 deletions open-sse/utils/usageTracking.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
],
Expand Down Expand Up @@ -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
});
}

Expand Down Expand Up @@ -288,9 +290,13 @@ 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.
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
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/cached-token-usage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,78 @@ 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/thinking
// reasoning_tokens = reasoning portion (subset of completion_tokens)
// 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("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("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("extractUsage reads thinking tokens off message_delta", () => {
const out = extractUsage({
type: "message_delta",
usage: { input_tokens: 22, output_tokens: 267, output_tokens_details: { thinking_tokens: 85 } },
});
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);
});
});

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 withThinking = calculateCostFromTokens(
{ prompt_tokens: 22, completion_tokens: 267, reasoning_tokens: 85 }, pricing);
const withoutThinking = calculateCostFromTokens(
{ prompt_tokens: 22, completion_tokens: 267 }, pricing);
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});
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", () => {
// 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 output rate + thoughts at reasoning rate
expect(folded).toBeCloseTo((100 * 1 + 40 * 4 + 10 * 10) / 1e6, 12);
});
});
78 changes: 78 additions & 0 deletions tests/unit/claude-thinking-token-passthrough.test.js
Original file line number Diff line number Diff line change
@@ -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 });
});
});