From 808c4d6b0c89d22be1451ba6b94cb4a8deb7f8a1 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 15:56:48 +0900 Subject: [PATCH] fix(ollama): report prompt_eval_cached_count as cache-read input (#8101) --- scripts/test-layout/layout.json | 2 +- src/adapters/ollama-native.ts | 12 ++++- tests/fixtures/test-layout-expected.json | 2 +- .../ollama/ollama-native-cached-usage.test.ts | 53 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 tests/providers/ollama/ollama-native-cached-usage.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1621c385242..9fffd663de5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1330,7 +1330,7 @@ "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", "ocx-run.test.ts": "cli", - "ollama-native-parser.test.ts": "providers/ollama", "ollama-native-reasoning-wire.test.ts": "providers/ollama", "ollama-native-structured-output.test.ts": "providers/ollama", "ollama-native-tool-continuations.test.ts": "providers/ollama", + "ollama-native-parser.test.ts": "providers/ollama", "ollama-native-cached-usage.test.ts": "providers/ollama", "ollama-native-reasoning-wire.test.ts": "providers/ollama", "ollama-native-structured-output.test.ts": "providers/ollama", "ollama-native-tool-continuations.test.ts": "providers/ollama", "ollama-native-v4.test.ts": "providers/ollama", "ollama-native-scaling.test.ts": "providers/ollama", "ollama-native.test.ts": "providers/ollama", "ollama-show-enrichment-v7.test.ts": "providers/ollama", "ollama-show-enrichment.test.ts": "providers/ollama", "ollama-show-ignore-abort.test.ts": "providers/ollama", "opaque-blob-wrapped-rejection.test.ts": "responses", "openai-api-virtual-models.test.ts": "adapters/openai", "openai-chat-bounded-tool-names.test.ts": "adapters/openai", "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts index e612011347d..64eea472fc6 100644 --- a/src/adapters/ollama-native.ts +++ b/src/adapters/ollama-native.ts @@ -641,7 +641,17 @@ function usageFromNative(value: JsonRecord | undefined): OcxUsage | undefined { const input = isFiniteNonNegativeInteger(value.prompt_eval_count) ? value.prompt_eval_count : undefined; const output = isFiniteNonNegativeInteger(value.eval_count) ? value.eval_count : undefined; if (input === undefined && output === undefined) return undefined; - return { inputTokens: input ?? 0, outputTokens: output ?? 0 }; + // Ollama reports prompt_eval_cached_count as the cached subset of prompt_eval_count (api/types.go; + // its CLI prints prompt_eval_count - cached as the uncached share), which is OCX's cache-read + // convention. Omitted means unknown, so no cache field is invented; a count that exceeds the + // prompt is not a subset and is ignored rather than clamped (#8101). + const cached = input !== undefined && isFiniteNonNegativeInteger(value.prompt_eval_cached_count) + && value.prompt_eval_cached_count <= input ? value.prompt_eval_cached_count : undefined; + return { + inputTokens: input ?? 0, + outputTokens: output ?? 0, + ...(cached !== undefined ? { cachedInputTokens: cached, cacheReadInputTokens: cached } : {}), + }; } function stopReasonFromNative(value: unknown): string | undefined { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c2f191980c6..dde70fe63d4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1092,7 +1092,7 @@ "oauth-tos-warning.test.ts": "gui", "oauth-upsert-preserves-api-key.test.ts": "oauth", "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-fixture-trust.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", - "ocx-run.test.ts": "cli", "ollama-native-parser.test.ts": "providers/ollama", + "ocx-run.test.ts": "cli", "ollama-native-parser.test.ts": "providers/ollama", "ollama-native-cached-usage.test.ts": "providers/ollama", "ollama-native-reasoning-wire.test.ts": "providers/ollama", "ollama-native-scaling.test.ts": "providers/ollama", "ollama-native-structured-output.test.ts": "providers/ollama", "ollama-native-v4.test.ts": "providers/ollama", diff --git a/tests/providers/ollama/ollama-native-cached-usage.test.ts b/tests/providers/ollama/ollama-native-cached-usage.test.ts new file mode 100644 index 00000000000..97053ce9b0b --- /dev/null +++ b/tests/providers/ollama/ollama-native-cached-usage.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { createOllamaNativeAdapter } from "../../../src/adapters/ollama-native"; +import type { AdapterEvent } from "../../../src/types"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +// #8101: the native usage parser dropped prompt_eval_cached_count, so cached input always read 0. +const adapter = () => createOllamaNativeAdapter({ adapter: "ollama-native", baseUrl: "http://127.0.0.1:11434", models: ["m"] }); +const ndjson = (frames: unknown[]) => new Response(frames.map(frame => JSON.stringify(frame)).join("\n") + "\n", + { headers: { "content-type": "application/x-ndjson" } }); +const final = (usage: Record) => ({ + model: "m", message: { role: "assistant", content: "hi" }, done: true, done_reason: "stop", ...usage, +}); + +async function streamedUsage(usage: Record) { + const events: AdapterEvent[] = []; + for await (const event of adapter().parseStream(ndjson([final(usage)]), createTestTranslatorBudget())) events.push(event); + const done = events.at(-1); + expect(done?.type).toBe("done"); + return (done as Extract).usage; +} + +describe("ollama-native cached prompt usage (#8101)", () => { + test("streamed final record maps the cached subset without changing the input total", async () => { + expect(await streamedUsage({ prompt_eval_count: 2000, prompt_eval_cached_count: 1500, eval_count: 100 })) + .toEqual({ inputTokens: 2000, outputTokens: 100, cachedInputTokens: 1500, cacheReadInputTokens: 1500 }); + }); + + test("buffered responses carry the same cached count", async () => { + const events = await adapter().parseResponse!(ndjson([final({ prompt_eval_count: 11, prompt_eval_cached_count: 8, eval_count: 18 })]), + createTestTranslatorBudget()); + expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 11, cachedInputTokens: 8, cacheReadInputTokens: 8 } }); + }); + + test("an explicit zero stays a known zero", async () => { + expect(await streamedUsage({ prompt_eval_count: 40, prompt_eval_cached_count: 0, eval_count: 2 })) + .toEqual({ inputTokens: 40, outputTokens: 2, cachedInputTokens: 0, cacheReadInputTokens: 0 }); + }); + + test.each([ + ["omitted", {}], + ["negative", { prompt_eval_cached_count: -1 }], + ["fractional", { prompt_eval_cached_count: 1.5 }], + ["non-numeric", { prompt_eval_cached_count: "8" }], + ["larger than the prompt", { prompt_eval_cached_count: 41 }], + ])("%s cached count reports no cache field", async (_label, extra) => { + expect(await streamedUsage({ prompt_eval_count: 40, eval_count: 2, ...extra })).toEqual({ inputTokens: 40, outputTokens: 2 }); + }); + + test("a cached count without a prompt count is ignored", async () => { + expect(await streamedUsage({ prompt_eval_cached_count: 5, eval_count: 2 })).toEqual({ inputTokens: 0, outputTokens: 2 }); + }); +}); +