Files
oh-my-pi/packages/ai/test/usage-attribution.test.ts
T
can1357 fbe051bcbd fix(ai): fixed OpenRouter cache write attribution in usage parsing
- Updated parseChunkUsage to subtract prompt_tokens_details.cache_write_tokens from prompt token input so OpenRouter write tokens are not misclassified as billable input.
- Set cacheWrite and total token counts to include cache-write usage, while preserving cache-read behavior from existing cached_tokens handling.
- Added OpenRouter attribution tests verifying cacheWrite and cacheRead totals for write-heavy and cache-warm prompts.
2026-04-30 02:24:57 +02:00

165 lines
5.1 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { applyAnthropicUsageExtras } from "@oh-my-pi/pi-ai/providers/anthropic";
import { parseChunkUsage } from "@oh-my-pi/pi-ai/providers/openai-completions";
import type { Model, Usage } from "@oh-my-pi/pi-ai/types";
const OPENAI_MODEL: Model<"openai-completions"> = {
id: "gpt-5",
name: "GPT-5",
api: "openai-completions",
provider: "openai",
baseUrl: "https://api.openai.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
};
function blankUsage(): Usage {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
describe("openai-completions parseChunkUsage", () => {
it("does not double-count reasoning_tokens (subset of completion_tokens)", () => {
// OpenAI spec: reasoning_tokens is a subset of completion_tokens (the total billed output).
// A 100-token completion that included 40 reasoning tokens should report output=100, not 140.
const usage = parseChunkUsage(
{
prompt_tokens: 1_000,
completion_tokens: 100,
prompt_tokens_details: { cached_tokens: 200 },
completion_tokens_details: { reasoning_tokens: 40 },
},
OPENAI_MODEL,
undefined,
);
expect(usage.output).toBe(100);
expect(usage.input).toBe(800);
expect(usage.cacheRead).toBe(200);
expect(usage.totalTokens).toBe(1_100);
expect(usage.reasoningTokens).toBe(40);
});
it("omits reasoningTokens when no reasoning_tokens are reported", () => {
const usage = parseChunkUsage({ prompt_tokens: 50, completion_tokens: 25 }, OPENAI_MODEL, undefined);
expect(usage.reasoningTokens).toBeUndefined();
expect(usage.output).toBe(25);
});
it("attributes OpenRouter cache_write_tokens to cacheWrite, not input", () => {
// OpenRouter (https://openrouter.ai/docs/guides/best-practices/prompt-caching)
// reports cache writes via prompt_tokens_details.cache_write_tokens and
// INCLUDES them in prompt_tokens. Naively subtracting only cached_tokens
// leaves cache-write tokens stuck in `input`.
const usage = parseChunkUsage(
{
prompt_tokens: 6_000,
completion_tokens: 250,
prompt_tokens_details: { cached_tokens: 0, cache_write_tokens: 5_500 },
},
OPENAI_MODEL,
undefined,
);
expect(usage.input).toBe(500);
expect(usage.cacheWrite).toBe(5_500);
expect(usage.cacheRead).toBe(0);
expect(usage.totalTokens).toBe(6_250);
});
it("attributes OpenRouter cache_read_tokens correctly when cache is warm", () => {
const usage = parseChunkUsage(
{
prompt_tokens: 6_000,
completion_tokens: 250,
prompt_tokens_details: { cached_tokens: 5_800, cache_write_tokens: 0 },
},
OPENAI_MODEL,
undefined,
);
expect(usage.input).toBe(200);
expect(usage.cacheRead).toBe(5_800);
expect(usage.cacheWrite).toBe(0);
expect(usage.totalTokens).toBe(6_250);
});
});
describe("anthropic applyAnthropicUsageExtras", () => {
it("captures cache TTL breakdown when both buckets are non-zero", () => {
const usage = blankUsage();
applyAnthropicUsageExtras(usage, {
cache_creation: {
ephemeral_5m_input_tokens: 1_200,
ephemeral_1h_input_tokens: 800,
},
});
expect(usage.cttl).toEqual({ ephemeral5m: 1_200, ephemeral1h: 800 });
});
it("only sets the bucket the provider populated", () => {
const usage = blankUsage();
applyAnthropicUsageExtras(usage, {
cache_creation: { ephemeral_5m_input_tokens: 50, ephemeral_1h_input_tokens: 0 },
});
expect(usage.cttl).toEqual({ ephemeral5m: 50 });
expect(usage.cttl?.ephemeral1h).toBeUndefined();
});
it("captures server tool requests", () => {
const usage = blankUsage();
applyAnthropicUsageExtras(usage, {
server_tool_use: { web_search_requests: 3, web_fetch_requests: 1 },
});
expect(usage.server).toEqual({ webSearch: 3, webFetch: 1 });
});
it("leaves serverToolUse undefined when both counters are zero", () => {
const usage = blankUsage();
applyAnthropicUsageExtras(usage, {
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
});
expect(usage.server).toBeUndefined();
});
it("does not clobber a previously-populated breakdown when called with no cache_creation field (message_delta case)", () => {
// message_start populated the 5m/1h split; message_delta lacks cache_creation
// but reports cumulative server_tool_use. The helper must not erase the breakdown
// already on the usage object.
const usage = blankUsage();
usage.cttl = { ephemeral5m: 100, ephemeral1h: 200 };
applyAnthropicUsageExtras(usage, {
server_tool_use: { web_search_requests: 2, web_fetch_requests: 0 },
});
expect(usage.cttl).toEqual({ ephemeral5m: 100, ephemeral1h: 200 });
expect(usage.server).toEqual({ webSearch: 2 });
});
it("treats null SDK fields as absent (cache_creation: null skips breakdown)", () => {
const usage = blankUsage();
applyAnthropicUsageExtras(usage, {
cache_creation: null,
server_tool_use: null,
});
expect(usage.cttl).toBeUndefined();
expect(usage.server).toBeUndefined();
});
});