diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js index 376d5f90..1861e995 100644 --- a/open-sse/handlers/chatCore/requestDetail.js +++ b/open-sse/handlers/chatCore/requestDetail.js @@ -25,10 +25,16 @@ export function extractUsageFromResponse(responseBody) { if (!responseBody || typeof responseBody !== "object") return null; // Claude format + // Note: OpenAI Responses usage ({input_tokens, input_tokens_details:{cached_tokens}}) + // also matches this branch. Its prompt is cache-INCLUSIVE and its cache rides in + // input_tokens_details, so emit it as cached_tokens — the convention + // canonicalizeUsage() passes through without folding. Reading it here keeps + // cache accounting correct for /v1/responses and codex traffic. if (responseBody.usage?.input_tokens !== undefined) { return { prompt_tokens: responseBody.usage.input_tokens || 0, completion_tokens: responseBody.usage.output_tokens || 0, + cached_tokens: responseBody.usage.cached_tokens ?? responseBody.usage.input_tokens_details?.cached_tokens, cache_read_input_tokens: responseBody.usage.cache_read_input_tokens, cache_creation_input_tokens: responseBody.usage.cache_creation_input_tokens }; @@ -39,7 +45,7 @@ export function extractUsageFromResponse(responseBody) { return { prompt_tokens: responseBody.usage.prompt_tokens || 0, completion_tokens: responseBody.usage.completion_tokens || 0, - cached_tokens: responseBody.usage.prompt_tokens_details?.cached_tokens, + cached_tokens: responseBody.usage.cached_tokens ?? responseBody.usage.prompt_tokens_details?.cached_tokens, reasoning_tokens: responseBody.usage.completion_tokens_details?.reasoning_tokens }; } diff --git a/tests/unit/extract-usage-cache-shapes.test.js b/tests/unit/extract-usage-cache-shapes.test.js new file mode 100644 index 00000000..271d7ccd --- /dev/null +++ b/tests/unit/extract-usage-cache-shapes.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from "vitest"; + +// sever the DB import chain (usageDb -> @/lib/db/*) — not under test +vi.mock("@/lib/usageDb.js", () => ({ + saveRequestUsage: vi.fn(), + appendRequestLog: vi.fn(), + saveRequestDetail: vi.fn(), +})); +// and the stream/console-coloring utils that drag in the translator graph +vi.mock("../../open-sse/utils/stream.js", () => ({ + COLORS: {}, + formatSSE: vi.fn(), +})); + +import { extractUsageFromResponse } from "../../open-sse/handlers/chatCore/requestDetail.js"; +import { canonicalizeUsage } from "../../open-sse/utils/usageTracking.js"; + +// The three real-world usage shapes and how extractUsageFromResponse() must +// surface their cache-read count so canonicalizeUsage() produces a correct +// cached_tokens. Regression for non-streaming codex/Responses traffic, where +// cache reads were silently dropped and usage recorded cached_tokens: 0. +describe("extractUsageFromResponse cache surfaces", () => { + it("surfaces OpenAI Responses input_tokens_details.cached_tokens", () => { + // codex / /v1/responses shape: prompt is cache-INCLUSIVE + const out = extractUsageFromResponse({ + usage: { input_tokens: 25421, output_tokens: 5, total_tokens: 25426, + input_tokens_details: { cached_tokens: 24320 } }, + }); + expect(out.cached_tokens).toBe(24320); + expect(out.prompt_tokens).toBe(25421); + expect(out.cache_read_input_tokens).toBeUndefined(); + }); + + it("canonicalizes Responses usage without double-counting the prompt", () => { + const extracted = extractUsageFromResponse({ + usage: { input_tokens: 25421, output_tokens: 5, + input_tokens_details: { cached_tokens: 24320 } }, + }); + const out = canonicalizeUsage(extracted); + // inclusive prompt passes through unchanged; cache reported as subset + expect(out.prompt_tokens).toBe(25421); + expect(out.cached_tokens).toBe(24320); + expect(out.total_tokens).toBe(25426); + expect(out.cache_creation_input_tokens).toBe(0); + }); + + it("still folds genuine Claude exclusive cache (regression)", () => { + const extracted = extractUsageFromResponse({ + usage: { input_tokens: 100, output_tokens: 50, + cache_read_input_tokens: 200, cache_creation_input_tokens: 30 }, + }); + expect(extracted.cached_tokens).toBeUndefined(); + const out = canonicalizeUsage(extracted); + expect(out.prompt_tokens).toBe(330); // 100 + 200 + 30 + expect(out.cached_tokens).toBe(200); + expect(out.cache_creation_input_tokens).toBe(30); + }); + + it("surfaces flat cached_tokens on the OpenAI branch (SSE-to-JSON shape)", () => { + const out = extractUsageFromResponse({ + usage: { prompt_tokens: 300, completion_tokens: 10, cached_tokens: 240 }, + }); + expect(out.cached_tokens).toBe(240); + }); + + it("keeps nested prompt_tokens_details.cached_tokens working (regression)", () => { + const out = extractUsageFromResponse({ + usage: { prompt_tokens: 300, completion_tokens: 10, + prompt_tokens_details: { cached_tokens: 240 } }, + }); + expect(out.cached_tokens).toBe(240); + expect(canonicalizeUsage(out).cached_tokens).toBe(240); + }); +});