diff --git a/README.md b/README.md index a793e52..d29646f 100644 --- a/README.md +++ b/README.md @@ -458,7 +458,7 @@ Sessions without a registry entry fall back to `claude.billing.defaultMode`. ## Pricing -Pricing data for 60+ models is included in `pricing/models.json`. Models are matched by substring, so variants like `claude-3-5-sonnet-20241022` match `claude-3-5-sonnet`. +Pricing data for 70+ models is included in `pricing/models.json` (last updated 2026-07-02). Models are matched exactly first, then by substring preferring the most specific entry, so variants like `claude-haiku-4-5-20251001` match `claude-haiku-4-5` rather than a shorter prefix relative. To update pricing: 1. Edit `pricing/models.json` diff --git a/pricing/models.json b/pricing/models.json index 188ac9e..71dbaa2 100644 --- a/pricing/models.json +++ b/pricing/models.json @@ -1,8 +1,29 @@ { - "updated": "2026-02-05", + "updated": "2026-07-02", "currency": "USD", "per": "1M", "models": [ + { + "provider": "openai", + "model": "gpt-5.5", + "input_per_million": 5, + "output_per_million": 30, + "cache_read_per_million": 0.5 + }, + { + "provider": "openai", + "model": "gpt-5.4", + "input_per_million": 2.5, + "output_per_million": 15, + "cache_read_per_million": 0.25 + }, + { + "provider": "openai", + "model": "gpt-5.3-codex", + "input_per_million": 1.75, + "output_per_million": 14, + "cache_read_per_million": 0.175 + }, { "provider": "openai", "model": "gpt-5.2", @@ -327,6 +348,55 @@ "output_per_million": 3, "cache_read_per_million": 0.1 }, + { + "provider": "anthropic", + "model": "claude-fable-5", + "input_per_million": 10, + "output_per_million": 50, + "cache_write_per_million": 12.5, + "cache_read_per_million": 1 + }, + { + "provider": "anthropic", + "model": "claude-mythos-5", + "input_per_million": 10, + "output_per_million": 50, + "cache_write_per_million": 12.5, + "cache_read_per_million": 1 + }, + { + "provider": "anthropic", + "model": "claude-opus-4-8", + "input_per_million": 5, + "output_per_million": 25, + "cache_write_per_million": 6.25, + "cache_read_per_million": 0.5 + }, + { + "provider": "anthropic", + "model": "claude-opus-4-7", + "input_per_million": 5, + "output_per_million": 25, + "cache_write_per_million": 6.25, + "cache_read_per_million": 0.5 + }, + { + "provider": "anthropic", + "model": "claude-sonnet-5", + "input_per_million": 3, + "output_per_million": 15, + "cache_write_per_million": 3.75, + "cache_read_per_million": 0.3, + "notes": "Standard rates; intro pricing of $2 in / $10 out per MTok applies through 2026-08-31." + }, + { + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "input_per_million": 3, + "output_per_million": 15, + "cache_write_per_million": 3.75, + "cache_read_per_million": 0.3 + }, { "provider": "anthropic", "model": "claude-opus-4-6", diff --git a/src/collectors/codex.ts b/src/collectors/codex.ts index e936365..127917e 100644 --- a/src/collectors/codex.ts +++ b/src/collectors/codex.ts @@ -25,7 +25,22 @@ interface TokenUsage { reasoning_output_tokens?: number; } -function normalizeUsage(entry: any): { +// Codex TokenUsage semantics: cached_input_tokens is a SUBSET of +// input_tokens, and reasoning_output_tokens is a SUBSET of output_tokens +// (total_tokens = input_tokens + output_tokens). Split cached out of input +// so it's billed at the cache-read rate instead of the full input rate, and +// never add reasoning on top of output. +function splitTokenUsage(usage: TokenUsage | undefined): UsageSnapshot { + const cached = usage?.cached_input_tokens ?? 0; + return { + input: Math.max((usage?.input_tokens ?? 0) - cached, 0), + output: usage?.output_tokens ?? 0, + cacheWrite: 0, + cacheRead: cached, + }; +} + +export function normalizeUsage(entry: any): { delta: UsageSnapshot; total?: UsageSnapshot; } | null { @@ -35,23 +50,8 @@ function normalizeUsage(entry: any): { const last = info.last_token_usage as TokenUsage | undefined; const total = info.total_token_usage as TokenUsage | undefined; - const delta: UsageSnapshot = { - input: last?.input_tokens ?? 0, - output: - (last?.output_tokens ?? 0) + (last?.reasoning_output_tokens ?? 0), - cacheWrite: 0, - cacheRead: last?.cached_input_tokens ?? 0, - }; - - const totalSnapshot: UsageSnapshot | undefined = total - ? { - input: total.input_tokens ?? 0, - output: - (total.output_tokens ?? 0) + (total.reasoning_output_tokens ?? 0), - cacheWrite: 0, - cacheRead: total.cached_input_tokens ?? 0, - } - : undefined; + const delta = splitTokenUsage(last); + const totalSnapshot = total ? splitTokenUsage(total) : undefined; if ( delta.input === 0 && diff --git a/src/core/pricing.ts b/src/core/pricing.ts index 2397f8c..37d33c0 100644 --- a/src/core/pricing.ts +++ b/src/core/pricing.ts @@ -36,11 +36,20 @@ export function findPricing( ); if (direct) return direct; - const fuzzy = table.models.find( - (entry) => entry.provider === provider && model.includes(entry.model) - ); + // Prefer the longest (most specific) substring match. Model families share + // prefixes ("claude-opus-4" vs "claude-opus-4-8"), and first-match-wins + // would price a new model at whichever related entry happens to come first + // in the table. + let fuzzy: PricingModel | null = null; + for (const entry of table.models) { + if (entry.provider !== provider) continue; + if (!model.includes(entry.model)) continue; + if (!fuzzy || entry.model.length > fuzzy.model.length) { + fuzzy = entry; + } + } - return fuzzy ?? null; + return fuzzy; } export function estimateCostUsd( diff --git a/tests/codex.test.ts b/tests/codex.test.ts new file mode 100644 index 0000000..3a6e794 --- /dev/null +++ b/tests/codex.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { normalizeUsage } from "../src/collectors/codex.js"; + +// Shape taken from a real Codex CLI session log. The invariant that matters: +// total_tokens = input_tokens + output_tokens, i.e. cached_input_tokens is a +// subset of input_tokens and reasoning_output_tokens a subset of +// output_tokens. Neither may be double-counted. +const tokenCountEntry = { + timestamp: "2026-05-01T21:29:03.000Z", + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 783392, + cached_input_tokens: 722688, + output_tokens: 8155, + reasoning_output_tokens: 2360, + total_tokens: 791547, + }, + last_token_usage: { + input_tokens: 69565, + cached_input_tokens: 66944, + output_tokens: 250, + reasoning_output_tokens: 17, + total_tokens: 69815, + }, + model_context_window: 258400, + }, + }, +}; + +describe("codex normalizeUsage", () => { + it("splits cached tokens out of input instead of double-counting", () => { + const result = normalizeUsage(tokenCountEntry); + + expect(result).not.toBeNull(); + expect(result?.delta.input).toBe(69565 - 66944); + expect(result?.delta.cacheRead).toBe(66944); + expect(result?.total?.input).toBe(783392 - 722688); + expect(result?.total?.cacheRead).toBe(722688); + }); + + it("does not add reasoning tokens on top of output tokens", () => { + const result = normalizeUsage(tokenCountEntry); + + expect(result?.delta.output).toBe(250); + expect(result?.total?.output).toBe(8155); + }); + + it("accounts for every token exactly once", () => { + const result = normalizeUsage(tokenCountEntry); + const t = result!.total!; + + expect(t.input + t.cacheRead + t.output).toBe(791547); + }); + + it("clamps input to zero if cached exceeds input", () => { + const result = normalizeUsage({ + payload: { + type: "token_count", + info: { + last_token_usage: { + input_tokens: 10, + cached_input_tokens: 20, + output_tokens: 5, + }, + }, + }, + }); + + expect(result?.delta.input).toBe(0); + expect(result?.delta.cacheRead).toBe(20); + }); + + it("returns null for token_count events with no usage", () => { + const result = normalizeUsage({ + payload: { type: "token_count", info: null }, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/tests/pricing.test.ts b/tests/pricing.test.ts index f0a6be0..b794ef2 100644 --- a/tests/pricing.test.ts +++ b/tests/pricing.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; import { findPricing, estimateCostUsd, PricingTable, PricingModel } from "../src/core/pricing.js"; const mockPricing: PricingTable = { @@ -26,6 +28,20 @@ const mockPricing: PricingTable = { input_per_million: 15.0, output_per_million: 60.0, }, + // Shared-prefix pair, least specific first, to prove fuzzy matching + // prefers the longest entry rather than table order. + { + provider: "anthropic", + model: "claude-opus-4", + input_per_million: 15.0, + output_per_million: 75.0, + }, + { + provider: "anthropic", + model: "claude-opus-4-8", + input_per_million: 5.0, + output_per_million: 25.0, + }, ], }; @@ -57,6 +73,16 @@ describe("findPricing", () => { expect(result).toBeNull(); }); + it("prefers the most specific fuzzy match over table order", () => { + // "claude-opus-4-8-fast" has no exact entry; both "claude-opus-4" and + // "claude-opus-4-8" are substrings, and the longer one must win even + // though the shorter one comes first in the table. + const result = findPricing(mockPricing, "anthropic", "claude-opus-4-8-fast"); + + expect(result?.model).toBe("claude-opus-4-8"); + expect(result?.input_per_million).toBe(5.0); + }); + it("respects provider when matching", () => { // gpt-4o exists for openai but not anthropic const resultOpenai = findPricing(mockPricing, "openai", "gpt-4o"); @@ -127,3 +153,42 @@ describe("estimateCostUsd", () => { expect(cost).toBeCloseTo(0.00525); }); }); + +describe("bundled pricing table", () => { + const bundled = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "pricing", "models.json"), "utf8") + ) as PricingTable; + + // Models that appear in real Claude Code / Codex CLI session logs as of + // mid-2026. Each must resolve to an entry with its own (correct) rates — + // not fall through to a cheaper/pricier prefix relative. + const expectations: Array<[string, string, number, number]> = [ + ["anthropic", "claude-fable-5", 10, 50], + ["anthropic", "claude-opus-4-8", 5, 25], + ["anthropic", "claude-opus-4-7", 5, 25], + ["anthropic", "claude-sonnet-5", 3, 15], + ["anthropic", "claude-sonnet-4-6", 3, 15], + ["anthropic", "claude-haiku-4-5-20251001", 1, 5], + ["openai", "gpt-5.5", 5, 30], + ["openai", "gpt-5.4", 2.5, 15], + ["openai", "gpt-5.3-codex", 1.75, 14], + ]; + + it.each(expectations)( + "prices %s %s at $%d/$%d per MTok", + (provider, model, input, output) => { + const result = findPricing(bundled, provider as PricingModel["provider"], model); + + expect(result).not.toBeNull(); + expect(result?.input_per_million).toBe(input); + expect(result?.output_per_million).toBe(output); + } + ); + + it("does not price claude-opus-4-8 at legacy claude-opus-4 rates", () => { + const result = findPricing(bundled, "anthropic", "claude-opus-4-8"); + + expect(result?.model).toBe("claude-opus-4-8"); + expect(result?.input_per_million).not.toBe(15); + }); +});