From c6773dfcc2e986832e0b4f178785248b458bafcc Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 00:16:10 +0900 Subject: [PATCH] fix(deepseek): read documented top-level prompt-cache counters What: - Add src/api/transform/chat-usage-normalization.ts, a shared normalizer that resolves OpenAI-shaped chat-completions usage into { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }. - DeepSeekHandler.processUsageMetrics now uses the normalizer instead of reading usage.prompt_tokens_details.cache_miss_tokens directly. - Rewrite the deepseek spec fixtures to DeepSeek's documented wire shape and add top-level-only, nested-only-legacy, and both-shapes-present cases. Why: DeepSeek's OpenAPI marks prompt_cache_hit_tokens and prompt_cache_miss_tokens as required top-level members and declares only cached_tokens inside prompt_tokens_details. The nested cache_miss_tokens field the provider read does not exist, so cacheWriteTokens was permanently undefined. DeepSeek catalog entries set inputPrice: 0 (inputs are priced as cache read/write only), so calculateApiCostOpenAI priced every cache-miss input token at zero: a permanent, near-total under-report of DeepSeek input cost. The old spec hid this by mocking the non-existent nested shape and asserting cacheWriteTokens from it, so the suite was green because it mocked the bug. Read priority is per-field with no summing, because the two shapes describe the same quantities: - cacheWriteTokens: prompt_cache_miss_tokens, then nested cache_miss_tokens (legacy fallback), then nested cache_write_tokens. - cacheReadTokens: nested cached_tokens, then prompt_cache_hit_tokens, then cache_read_input_tokens. Impact: DeepSeek input cost is now reported instead of collapsing to ~$0 for cache-miss tokens. The helper is generic over OpenAI-shaped usage so the family-wide provider rewiring can adopt it without a second implementation. No behavior change for providers that do not use the helper. Refs: https://api-docs.deepseek.com/api/create-chat-completion https://api-docs.deepseek.com/api/create-completion --- src/api/providers/__tests__/deepseek.spec.ts | 89 +++++++++---- src/api/providers/deepseek.ts | 16 ++- .../chat-usage-normalization.spec.ts | 124 ++++++++++++++++++ src/api/transform/chat-usage-normalization.ts | 121 +++++++++++++++++ 4 files changed, 323 insertions(+), 27 deletions(-) create mode 100644 src/api/transform/__tests__/chat-usage-normalization.spec.ts create mode 100644 src/api/transform/chat-usage-normalization.ts diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 7878eaa2e1..3b6c2496c9 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -27,9 +27,10 @@ vi.mock("openai", () => { completion_tokens: 5, total_tokens: 15, prompt_tokens_details: { - cache_miss_tokens: 8, cached_tokens: 2, }, + prompt_cache_hit_tokens: 2, + prompt_cache_miss_tokens: 8, }, } } @@ -111,9 +112,10 @@ vi.mock("openai", () => { completion_tokens: 5, total_tokens: 15, prompt_tokens_details: { - cache_miss_tokens: 8, cached_tokens: 2, }, + prompt_cache_hit_tokens: 2, + prompt_cache_miss_tokens: 8, }, }) @@ -517,17 +519,55 @@ describe("DeepSeekHandler", () => { }) describe("processUsageMetrics", () => { - it("should correctly process usage metrics including cache information", () => { - // We need to access the protected method, so we'll create a test subclass - class TestDeepSeekHandler extends DeepSeekHandler { - public testProcessUsageMetrics(usage: any) { - return this.processUsageMetrics(usage) - } + class TestDeepSeekHandler extends DeepSeekHandler { + public testProcessUsageMetrics(usage: any) { + return this.processUsageMetrics(usage) } + } - const testHandler = new TestDeepSeekHandler(mockOptions) + // mockOptions is assigned in beforeEach, so the handler must be built + // per test rather than at describe-collection time. + const processUsage = (usage: any) => new TestDeepSeekHandler(mockOptions).testProcessUsageMetrics(usage) + + // DeepSeek's OpenAPI marks prompt_cache_hit_tokens and + // prompt_cache_miss_tokens as required top-level members, and declares + // only cached_tokens inside prompt_tokens_details. This is the vendor's + // own documented example response. + // https://api-docs.deepseek.com/api/create-chat-completion + it("reads the documented top-level cache counters", () => { + const result = processUsage({ + prompt_tokens: 7, + completion_tokens: 11, + total_tokens: 18, + prompt_tokens_details: { cached_tokens: 0 }, + prompt_cache_hit_tokens: 0, + prompt_cache_miss_tokens: 7, + }) - const usage = { + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(7) + expect(result.outputTokens).toBe(11) + expect(result.cacheReadTokens).toBe(0) + expect(result.cacheWriteTokens).toBe(7) + }) + + it("reads top-level cache counters when prompt_tokens_details is absent", () => { + const result = processUsage({ + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + prompt_cache_hit_tokens: 20, + prompt_cache_miss_tokens: 80, + }) + + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBe(80) + expect(result.cacheReadTokens).toBe(20) + }) + + it("falls back to the nested cache counters for legacy shapes", () => { + const result = processUsage({ prompt_tokens: 100, completion_tokens: 50, total_tokens: 150, @@ -535,34 +575,37 @@ describe("DeepSeekHandler", () => { cache_miss_tokens: 80, cached_tokens: 20, }, - } - - const result = testHandler.testProcessUsageMetrics(usage) + }) - expect(result.type).toBe("usage") expect(result.inputTokens).toBe(100) expect(result.outputTokens).toBe(50) expect(result.cacheWriteTokens).toBe(80) expect(result.cacheReadTokens).toBe(20) }) - it("should handle missing cache metrics gracefully", () => { - class TestDeepSeekHandler extends DeepSeekHandler { - public testProcessUsageMetrics(usage: any) { - return this.processUsageMetrics(usage) - } - } + it("does not double-count when both shapes are present", () => { + const result = processUsage({ + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + prompt_tokens_details: { cached_tokens: 20, cache_miss_tokens: 80 }, + prompt_cache_hit_tokens: 20, + prompt_cache_miss_tokens: 80, + }) - const testHandler = new TestDeepSeekHandler(mockOptions) + expect(result.cacheWriteTokens).toBe(80) + expect(result.cacheReadTokens).toBe(20) + }) + it("should handle missing cache metrics gracefully", () => { const usage = { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150, - // No prompt_tokens_details + // No prompt_tokens_details and no top-level cache counters } - const result = testHandler.testProcessUsageMetrics(usage) + const result = processUsage(usage) expect(result.type).toBe("usage") expect(result.inputTokens).toBe(100) diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index b65973ac96..25e660abec 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -15,6 +15,7 @@ import type { ApiHandlerOptions, GetModelsOptions } from "../../shared/api" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { convertToR1Format } from "../transform/r1-format" +import { normalizeChatUsage } from "../transform/chat-usage-normalization" import { OpenAiHandler } from "./openai" import { NOT_PROVIDED } from "./constants" @@ -227,13 +228,20 @@ export class DeepSeekHandler extends OpenAiHandler { } // Override to handle DeepSeek's usage metrics, including caching. + // DeepSeek reports cache counters in two shapes that describe the same + // quantities: top-level `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` + // and nested `prompt_tokens_details.cached_tokens`. The normalizer resolves + // one value per field and never sums the shapes. + // https://api-docs.deepseek.com/api/create-chat-completion protected override processUsageMetrics(usage: any, _modelInfo?: any): ApiStreamUsageChunk { + const normalized = normalizeChatUsage(usage) + return { type: "usage", - inputTokens: usage?.prompt_tokens || 0, - outputTokens: usage?.completion_tokens || 0, - cacheWriteTokens: usage?.prompt_tokens_details?.cache_miss_tokens, - cacheReadTokens: usage?.prompt_tokens_details?.cached_tokens, + inputTokens: normalized?.inputTokens ?? 0, + outputTokens: normalized?.outputTokens ?? 0, + cacheWriteTokens: normalized?.cacheWriteTokens, + cacheReadTokens: normalized?.cacheReadTokens, } } } diff --git a/src/api/transform/__tests__/chat-usage-normalization.spec.ts b/src/api/transform/__tests__/chat-usage-normalization.spec.ts new file mode 100644 index 0000000000..581e995923 --- /dev/null +++ b/src/api/transform/__tests__/chat-usage-normalization.spec.ts @@ -0,0 +1,124 @@ +// npx vitest run api/transform/__tests__/chat-usage-normalization.spec.ts + +import { normalizeChatUsage } from "../chat-usage-normalization" + +// Documented DeepSeek usage block, copied from the vendor's OpenAPI example. +// https://api-docs.deepseek.com/api/create-chat-completion +const documentedDeepSeekUsage = { + prompt_tokens: 7, + completion_tokens: 11, + total_tokens: 18, + prompt_tokens_details: { cached_tokens: 0 }, + prompt_cache_hit_tokens: 0, + prompt_cache_miss_tokens: 7, +} + +describe("normalizeChatUsage", () => { + it("returns undefined when the provider sent no usage object", () => { + expect(normalizeChatUsage(undefined)).toBeUndefined() + expect(normalizeChatUsage(null)).toBeUndefined() + }) + + it("returns undefined for a truthy non-object usage value", () => { + // A malformed proxy can send a scalar where the usage object belongs. + expect(normalizeChatUsage("usage" as unknown as Record)).toBeUndefined() + expect(normalizeChatUsage(0 as unknown as Record)).toBeUndefined() + }) + + it("reads the documented DeepSeek top-level shape", () => { + expect(normalizeChatUsage(documentedDeepSeekUsage)).toEqual({ + inputTokens: 7, + outputTokens: 11, + cacheReadTokens: 0, + cacheWriteTokens: 7, + }) + }) + + it("reads a top-level-only usage object with no nested details", () => { + expect( + normalizeChatUsage({ + prompt_tokens: 120, + completion_tokens: 30, + prompt_cache_hit_tokens: 100, + prompt_cache_miss_tokens: 20, + }), + ).toEqual({ inputTokens: 120, outputTokens: 30, cacheReadTokens: 100, cacheWriteTokens: 20 }) + }) + + it("reads the nested-only shape used by OpenAI and by legacy DeepSeek fixtures", () => { + expect( + normalizeChatUsage({ + prompt_tokens: 100, + completion_tokens: 50, + prompt_tokens_details: { cached_tokens: 20, cache_miss_tokens: 80 }, + }), + ).toEqual({ inputTokens: 100, outputTokens: 50, cacheReadTokens: 20, cacheWriteTokens: 80 }) + }) + + it("does not add the nested and top-level shapes together when both are present", () => { + // Both shapes describe the same 100 reads / 20 writes. Summing would + // report 200 / 40 and double the billed cache tokens. + const normalized = normalizeChatUsage({ + prompt_tokens: 120, + completion_tokens: 30, + prompt_tokens_details: { cached_tokens: 100, cache_miss_tokens: 20 }, + prompt_cache_hit_tokens: 100, + prompt_cache_miss_tokens: 20, + }) + + expect(normalized?.cacheReadTokens).toBe(100) + expect(normalized?.cacheWriteTokens).toBe(20) + }) + + it("prefers the nested cached_tokens over the top-level hit counter for reads", () => { + expect( + normalizeChatUsage({ + prompt_tokens: 120, + prompt_tokens_details: { cached_tokens: 100 }, + prompt_cache_hit_tokens: 90, + })?.cacheReadTokens, + ).toBe(100) + }) + + it("prefers the top-level miss counter over the nested one for writes", () => { + expect( + normalizeChatUsage({ + prompt_tokens: 120, + prompt_tokens_details: { cache_miss_tokens: 20 }, + prompt_cache_miss_tokens: 30, + })?.cacheWriteTokens, + ).toBe(30) + }) + + it("falls through a malformed alias to the next one", () => { + expect( + normalizeChatUsage({ + prompt_tokens: 120, + prompt_cache_miss_tokens: "30", + prompt_tokens_details: { cache_miss_tokens: 20 }, + })?.cacheWriteTokens, + ).toBe(20) + }) + + it("leaves cache counters undefined when the provider reports none", () => { + const normalized = normalizeChatUsage({ prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }) + + expect(normalized).toEqual({ inputTokens: 100, outputTokens: 50 }) + expect(normalized).not.toHaveProperty("cacheReadTokens") + expect(normalized).not.toHaveProperty("cacheWriteTokens") + }) + + it("rejects negative and fractional token counts", () => { + expect( + normalizeChatUsage({ + prompt_tokens: 120, + prompt_cache_hit_tokens: -1, + prompt_cache_miss_tokens: 1.5, + }), + ).toEqual({ inputTokens: 120, outputTokens: 0 }) + }) + + it("defaults missing prompt and completion tokens to zero", () => { + expect(normalizeChatUsage({})).toEqual({ inputTokens: 0, outputTokens: 0 }) + }) +}) diff --git a/src/api/transform/chat-usage-normalization.ts b/src/api/transform/chat-usage-normalization.ts new file mode 100644 index 0000000000..ef08b9508b --- /dev/null +++ b/src/api/transform/chat-usage-normalization.ts @@ -0,0 +1,121 @@ +/** + * Normalization of OpenAI-shaped chat-completions usage objects into the + * cache token fields Zoo Code bills on. + * + * Providers that speak the OpenAI chat-completions wire format disagree on + * where they put cache counters. Some publish them top level, some nest them + * under `prompt_tokens_details`, and some publish both shapes describing the + * same quantities. Every reader here therefore resolves a single field through + * an ordered list of aliases and stops at the first numeric value. The aliases + * are never summed: a response that carries both shapes reports the same + * tokens twice, so adding them would double-count. + */ + +/** + * A usage object as it arrives off the wire, before any provider-specific + * typing. Fields are deliberately `unknown`: providers type their own usage + * loosely and the normalizer is the single place that validates them. + */ +export interface ChatUsageLike { + prompt_tokens?: unknown + completion_tokens?: unknown + prompt_tokens_details?: unknown + [key: string]: unknown +} + +export interface NormalizedChatUsage { + /** Total prompt tokens, including cache reads and cache writes. */ + inputTokens: number + outputTokens: number + /** + * Tokens written into the provider's prompt cache on this request. + * `undefined` when the provider reported no such counter at all, which is + * distinct from a reported zero. + */ + cacheReadTokens?: number + cacheWriteTokens?: number +} + +/** + * Aliases for cache-write (cache-miss) tokens, highest priority first. + * + * DeepSeek documents `prompt_cache_miss_tokens` at the top level of the usage + * object and marks it required; its `prompt_tokens_details` schema declares + * only `cached_tokens`. `prompt_tokens_details.cache_miss_tokens` is not part + * of that schema and is kept last as a legacy fallback for proxies that + * forwarded a rewritten shape. + * + * https://api-docs.deepseek.com/api/create-chat-completion + */ +const CACHE_WRITE_ALIASES = [ + "prompt_cache_miss_tokens", + "prompt_tokens_details.cache_miss_tokens", + "prompt_tokens_details.cache_write_tokens", +] as const + +/** + * Aliases for cache-read (cache-hit) tokens, highest priority first. + * + * `prompt_tokens_details.cached_tokens` is the shape DeepSeek documents and + * also the shape OpenAI documents, so it wins. `prompt_cache_hit_tokens` is + * DeepSeek's required top-level counterpart and covers responses that omit the + * nested object. + * + * https://api-docs.deepseek.com/api/create-chat-completion + */ +const CACHE_READ_ALIASES = [ + "prompt_tokens_details.cached_tokens", + "prompt_cache_hit_tokens", + "cache_read_input_tokens", +] as const + +const readPath = (usage: ChatUsageLike, path: string): unknown => { + let current: unknown = usage + + for (const segment of path.split(".")) { + if (typeof current !== "object" || current === null) return undefined + current = (current as Record)[segment] + } + + return current +} + +/** + * Counters are token counts: only non-negative integers are accepted. Anything + * else (missing, null, a string, a float, a negative sentinel) is treated as + * "not reported" so a malformed field falls through to the next alias instead + * of poisoning the total. + */ +const asTokenCount = (value: unknown): number | undefined => + typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined + +const resolveTokenCount = (usage: ChatUsageLike, aliases: readonly string[]): number | undefined => { + for (const alias of aliases) { + const resolved = asTokenCount(readPath(usage, alias)) + if (resolved !== undefined) return resolved + } + + return undefined +} + +/** + * Normalizes an OpenAI-shaped chat-completions usage object. + * + * Returns `undefined` for a missing usage object so callers can skip emitting a + * usage chunk entirely. Token counts that the provider did not report stay + * `undefined` rather than defaulting to 0, which keeps "no cache counter" and + * "zero cache hits" distinguishable for cost reporting. + */ +export function normalizeChatUsage(usage: ChatUsageLike | null | undefined): NormalizedChatUsage | undefined { + if (!usage || typeof usage !== "object") return undefined + + const cacheReadTokens = resolveTokenCount(usage, CACHE_READ_ALIASES) + const cacheWriteTokens = resolveTokenCount(usage, CACHE_WRITE_ALIASES) + + return { + inputTokens: asTokenCount(usage.prompt_tokens) ?? 0, + outputTokens: asTokenCount(usage.completion_tokens) ?? 0, + ...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}), + ...(cacheWriteTokens !== undefined ? { cacheWriteTokens } : {}), + } +}