Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
72 changes: 71 additions & 1 deletion pricing/models.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
36 changes: 18 additions & 18 deletions src/collectors/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 &&
Expand Down
17 changes: 13 additions & 4 deletions src/core/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
83 changes: 83 additions & 0 deletions tests/codex.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
65 changes: 65 additions & 0 deletions tests/pricing.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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,
},
],
};

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
});
});