From 49e31cf90cc2fc00330d5fd0d5e3b7407b23de5c Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:11:56 -0700 Subject: [PATCH] fix: normalize cached and reasoning usage details --- plugins/tracing/dist/index.mjs | 10 ++++++---- plugins/tracing/src/trace.ts | 23 ++++++++++++++++------- plugins/tracing/test/trace.test.ts | 21 ++++++++++++++++----- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/plugins/tracing/dist/index.mjs b/plugins/tracing/dist/index.mjs index cdc80c5..dbd553b 100644 --- a/plugins/tracing/dist/index.mjs +++ b/plugins/tracing/dist/index.mjs @@ -46984,11 +46984,13 @@ async function seededTraceParent(config$1, sessionMeta, turnNumber) { function toUsageDetails(usage) { if (!usage) return void 0; const details = {}; - if (typeof usage.input_tokens === "number") details.input = usage.input_tokens; - if (typeof usage.output_tokens === "number") details.output = usage.output_tokens; + const cachedInputTokens = usage.cached_input_tokens ?? 0; + const reasoningOutputTokens = usage.reasoning_output_tokens ?? 0; + if (typeof usage.input_tokens === "number") details.input = Math.max(usage.input_tokens - cachedInputTokens, 0); + if (cachedInputTokens > 0) details.cache_read_input_tokens = cachedInputTokens; + if (typeof usage.output_tokens === "number") details.output = Math.max(usage.output_tokens - reasoningOutputTokens, 0); + if (reasoningOutputTokens > 0) details.output_reasoning_tokens = reasoningOutputTokens; if (typeof usage.total_tokens === "number") details.total = usage.total_tokens; - if (typeof usage.cached_input_tokens === "number") details.cache_read_input_tokens = usage.cached_input_tokens; - if (typeof usage.reasoning_output_tokens === "number") details.reasoning_tokens = usage.reasoning_output_tokens; return Object.keys(details).length > 0 ? details : void 0; } /** Build a clip() that truncates long strings to `maxChars`. */ diff --git a/plugins/tracing/src/trace.ts b/plugins/tracing/src/trace.ts index 3f5e2d0..ef55868 100644 --- a/plugins/tracing/src/trace.ts +++ b/plugins/tracing/src/trace.ts @@ -112,15 +112,24 @@ async function seededTraceParent( function toUsageDetails(usage: TokenUsage | undefined): Record | undefined { if (!usage) return undefined; const details: Record = {}; - if (typeof usage.input_tokens === "number") details.input = usage.input_tokens; - if (typeof usage.output_tokens === "number") details.output = usage.output_tokens; - if (typeof usage.total_tokens === "number") details.total = usage.total_tokens; - if (typeof usage.cached_input_tokens === "number") { - details.cache_read_input_tokens = usage.cached_input_tokens; + const cachedInputTokens = usage.cached_input_tokens ?? 0; + const reasoningOutputTokens = usage.reasoning_output_tokens ?? 0; + + // Codex reports cached and reasoning tokens as subsets of input/output. Langfuse + // pricing treats usage-detail keys as additive buckets, so make them exclusive. + if (typeof usage.input_tokens === "number") { + details.input = Math.max(usage.input_tokens - cachedInputTokens, 0); + } + if (cachedInputTokens > 0) { + details.cache_read_input_tokens = cachedInputTokens; } - if (typeof usage.reasoning_output_tokens === "number") { - details.reasoning_tokens = usage.reasoning_output_tokens; + if (typeof usage.output_tokens === "number") { + details.output = Math.max(usage.output_tokens - reasoningOutputTokens, 0); } + if (reasoningOutputTokens > 0) { + details.output_reasoning_tokens = reasoningOutputTokens; + } + if (typeof usage.total_tokens === "number") details.total = usage.total_tokens; return Object.keys(details).length > 0 ? details : undefined; } diff --git a/plugins/tracing/test/trace.test.ts b/plugins/tracing/test/trace.test.ts index e3c0c95..473d9c2 100644 --- a/plugins/tracing/test/trace.test.ts +++ b/plugins/tracing/test/trace.test.ts @@ -92,11 +92,22 @@ describe("convertRollout", () => { expect(parentId(gen)).toBe(root!.spanContext().spanId); expect(attr(gen, "langfuse.observation.model.name")).toBe("gpt-5.4"); } - // First generation carries token usage. - const usage = generations - .map((g) => attr(g, "langfuse.observation.usage_details")) - .find((u) => u.includes("120")); - expect(usage, "expected usage details with 120 total tokens").toBeTruthy(); + // Usage buckets must be mutually exclusive for Langfuse cost inference. + const usageDetails = generations.map( + (g) => JSON.parse(attr(g, "langfuse.observation.usage_details")) as Record, + ); + expect(usageDetails).toContainEqual({ + input: 100, + output: 15, + output_reasoning_tokens: 5, + total: 120, + }); + expect(usageDetails).toContainEqual({ + input: 100, + cache_read_input_tokens: 50, + output: 30, + total: 180, + }); // One tool span, nested under a generation, with the captured command output. const tools = spans.filter((s) => obsType(s) === "tool");