burner-cc 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,202 @@
1
1
  {
2
- "_comment": "Last-resort snapshot only, used when there's no network AND no prior cache at ~/.burner-cc-cache/litellm-pricing.json. Normal operation fetches live from LiteLLM's Anthropic-sourced pricing JSON — see src/pricing.js. Values below are USD per million tokens, captured 2026-09-27.",
3
- "models": {
4
- "claude-opus-5": { "displayName": "Claude Opus 5", "input": 5, "output": 25, "cacheWrite5m": 6.25, "cacheWrite1h": 10, "cacheRead": 0.5 },
5
- "claude-sonnet-5": { "displayName": "Claude Sonnet 5", "input": 2, "output": 10, "cacheWrite5m": 2.5, "cacheWrite1h": 4, "cacheRead": 0.2 },
6
- "claude-haiku-4-5": { "displayName": "Claude Haiku 4.5", "input": 1, "output": 5, "cacheWrite5m": 1.25, "cacheWrite1h": 2, "cacheRead": 0.1 },
7
- "claude-fable-5": { "displayName": "Claude Fable 5", "input": 10, "output": 50, "cacheWrite5m": 12.5, "cacheWrite1h": 20, "cacheRead": 1 }
2
+ "_comment": "Last-resort snapshot used only when offline with no cache. Live prices come from LiteLLM (src/pricing.js). USD per million tokens.",
3
+ "capturedAt": 1792919926365,
4
+ "models": {
5
+ "claude-3-5-haiku": {
6
+ "input": 0.8,
7
+ "output": 4,
8
+ "cacheWrite5m": 1,
9
+ "cacheWrite1h": 1.6,
10
+ "cacheRead": 0.08
11
+ },
12
+ "claude-3-5-sonnet": {
13
+ "input": 3,
14
+ "output": 15,
15
+ "cacheWrite5m": 3.75,
16
+ "cacheWrite1h": 6,
17
+ "cacheRead": 0.3
18
+ },
19
+ "claude-3-7-sonnet": {
20
+ "input": 3.6,
21
+ "output": 18,
22
+ "cacheWrite5m": 4.5,
23
+ "cacheWrite1h": 7.2,
24
+ "cacheRead": 0.36
25
+ },
26
+ "claude-3-haiku": {
27
+ "input": 0.25,
28
+ "output": 1.25,
29
+ "cacheWrite5m": 0.3125,
30
+ "cacheWrite1h": 0.5,
31
+ "cacheRead": 0.025
32
+ },
33
+ "claude-3-opus": {
34
+ "input": 15,
35
+ "output": 75,
36
+ "cacheWrite5m": 18.75,
37
+ "cacheWrite1h": 30,
38
+ "cacheRead": 1.5
39
+ },
40
+ "claude-3-sonnet": {
41
+ "input": 3,
42
+ "output": 15,
43
+ "cacheWrite5m": 3.75,
44
+ "cacheWrite1h": 6,
45
+ "cacheRead": 0.3
46
+ },
47
+ "claude-fable-5": {
48
+ "input": 10,
49
+ "output": 50,
50
+ "cacheWrite5m": 12.5,
51
+ "cacheWrite1h": 20,
52
+ "cacheRead": 1
53
+ },
54
+ "claude-fable-5-1": {
55
+ "input": 10,
56
+ "output": 50,
57
+ "cacheWrite5m": 12.5,
58
+ "cacheWrite1h": 20,
59
+ "cacheRead": 0.25
60
+ },
61
+ "claude-haiku-4-5": {
62
+ "input": 1,
63
+ "output": 5,
64
+ "cacheWrite5m": 1.25,
65
+ "cacheWrite1h": 2,
66
+ "cacheRead": 0.1
67
+ },
68
+ "claude-haiku-5-5": {
69
+ "input": 0.1,
70
+ "output": 0.5,
71
+ "cacheWrite5m": 0.125,
72
+ "cacheWrite1h": 0.2,
73
+ "cacheRead": 0.01
74
+ },
75
+ "claude-instant": {
76
+ "input": 0.8,
77
+ "output": 2.4,
78
+ "cacheWrite5m": 1,
79
+ "cacheWrite1h": 1.6,
80
+ "cacheRead": 0.08
81
+ },
82
+ "claude-mythos-5": {
83
+ "input": 10,
84
+ "output": 50,
85
+ "cacheWrite5m": 12.5,
86
+ "cacheWrite1h": 20,
87
+ "cacheRead": 1
88
+ },
89
+ "claude-mythos-5-1": {
90
+ "input": 10,
91
+ "output": 50,
92
+ "cacheWrite5m": 12.5,
93
+ "cacheWrite1h": 20,
94
+ "cacheRead": 0.25
95
+ },
96
+ "claude-mythos-preview": {
97
+ "input": 10,
98
+ "output": 50,
99
+ "cacheWrite5m": 12.5,
100
+ "cacheWrite1h": 20,
101
+ "cacheRead": 1
102
+ },
103
+ "claude-opus-4": {
104
+ "input": 15,
105
+ "output": 75,
106
+ "cacheWrite5m": 18.75,
107
+ "cacheWrite1h": 30,
108
+ "cacheRead": 1.5
109
+ },
110
+ "claude-opus-4-1": {
111
+ "input": 15,
112
+ "output": 75,
113
+ "cacheWrite5m": 18.75,
114
+ "cacheWrite1h": 30,
115
+ "cacheRead": 1.5
116
+ },
117
+ "claude-opus-4-5": {
118
+ "input": 5,
119
+ "output": 25,
120
+ "cacheWrite5m": 6.25,
121
+ "cacheWrite1h": 10,
122
+ "cacheRead": 0.5
123
+ },
124
+ "claude-opus-4-6": {
125
+ "input": 5,
126
+ "output": 25,
127
+ "cacheWrite5m": 6.25,
128
+ "cacheWrite1h": 10,
129
+ "cacheRead": 0.5
130
+ },
131
+ "claude-opus-4-7": {
132
+ "input": 5,
133
+ "output": 25,
134
+ "cacheWrite5m": 6.25,
135
+ "cacheWrite1h": 10,
136
+ "cacheRead": 0.5
137
+ },
138
+ "claude-opus-4-8": {
139
+ "input": 5,
140
+ "output": 25,
141
+ "cacheWrite5m": 6.25,
142
+ "cacheWrite1h": 10,
143
+ "cacheRead": 0.5
144
+ },
145
+ "claude-opus-5": {
146
+ "input": 5,
147
+ "output": 25,
148
+ "cacheWrite5m": 6.25,
149
+ "cacheWrite1h": 10,
150
+ "cacheRead": 0.5
151
+ },
152
+ "claude-opus-5-5": {
153
+ "input": 4,
154
+ "output": 20,
155
+ "cacheWrite5m": 5,
156
+ "cacheWrite1h": 8,
157
+ "cacheRead": 0.2
158
+ },
159
+ "claude-opus-5-5-fast": {
160
+ "input": 8,
161
+ "output": 40,
162
+ "cacheWrite5m": 10,
163
+ "cacheWrite1h": 16,
164
+ "cacheRead": 0.4
165
+ },
166
+ "claude-sonnet-4": {
167
+ "input": 3,
168
+ "output": 15,
169
+ "cacheWrite5m": 3.75,
170
+ "cacheWrite1h": 6,
171
+ "cacheRead": 0.3
172
+ },
173
+ "claude-sonnet-4-5": {
174
+ "input": 3,
175
+ "output": 15,
176
+ "cacheWrite5m": 3.75,
177
+ "cacheWrite1h": 6,
178
+ "cacheRead": 0.3
179
+ },
180
+ "claude-sonnet-4-6": {
181
+ "input": 3,
182
+ "output": 15,
183
+ "cacheWrite5m": 3.75,
184
+ "cacheWrite1h": 6,
185
+ "cacheRead": 0.3
186
+ },
187
+ "claude-sonnet-5": {
188
+ "input": 2,
189
+ "output": 10,
190
+ "cacheWrite5m": 2.5,
191
+ "cacheWrite1h": 4,
192
+ "cacheRead": 0.2
193
+ },
194
+ "claude-sonnet-5-5": {
195
+ "input": 2,
196
+ "output": 10,
197
+ "cacheWrite5m": 2.5,
198
+ "cacheWrite1h": 4,
199
+ "cacheRead": 0.1
8
200
  }
201
+ }
9
202
  }
package/src/pricing.js CHANGED
@@ -1,143 +1,221 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
- import os from 'node:os';
4
+ import { execFile } from 'node:child_process';
5
+ import { FILES, readJson, writeJson, loadPricingOverrides } from './config.js';
5
6
 
6
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
- const cacheDir = path.join(os.homedir(), '.burner-cc-cache');
8
- const cacheFile = path.join(cacheDir, 'litellm-pricing.json');
9
8
  const fallbackFile = path.join(__dirname, 'pricing-fallback.json');
10
9
 
11
- const SOURCE_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
12
- const TTL_MS = 6 * 60 * 60 * 1000; // 6h — pricing changes rarely, this just avoids hammering GitHub.
10
+ export const SOURCE_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
11
+ const TTL_MS = 24 * 60 * 60 * 1000;
12
+ const RATE_KEYS = ['input', 'output', 'cacheWrite5m', 'cacheWrite1h', 'cacheRead'];
13
13
 
14
- // Anthropic bare-alias shorthands Claude Code sometimes writes instead of a
15
- // dated/versioned model id (e.g. subagent transcripts saying "sonnet").
16
- const ALIAS_FAMILIES = ['opus', 'sonnet', 'haiku', 'fable', 'mythos'];
14
+ async function fetchText(url, timeoutMs = 15000) {
15
+ try {
16
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
17
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
18
+ return await res.text();
19
+ } catch (err) {
20
+ // Node's fetch ignores HTTP(S)_PROXY; curl honours it.
21
+ const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
22
+ const args = ['--silent', '--show-error', '--fail', '--location', '--max-time', String(Math.ceil(timeoutMs / 1000)), '--compressed'];
23
+ if (proxy) args.push('--proxy', proxy);
24
+ args.push(url);
25
+ return new Promise((resolve, reject) => {
26
+ execFile('curl', args, { maxBuffer: 64 * 1024 * 1024 }, (cerr, stdout, stderr) => {
27
+ if (cerr) reject(new Error(`${err.message}; curl: ${(stderr || cerr.message).trim()}`));
28
+ else resolve(stdout);
29
+ });
30
+ });
31
+ }
32
+ }
17
33
 
18
- function toRates(entry, displayName) {
19
- const mtok = 1_000_000;
34
+ const PROVIDER_PREFIX = /^(anthropic|bedrock|bedrock_converse|vertex_ai|vertex_ai_beta|openrouter|azure|azure_ai|openai|google|gemini|deepseek|mistral|groq|together_ai|fireworks_ai|xai|ollama|ollama_chat|zai|moonshot|dashscope|databricks|litellm_proxy)\//;
35
+
36
+ // Collapses the many spellings of one model to a lookup key:
37
+ // "us.anthropic.claude-sonnet-4-5-20250929-v1:0" → "claude-sonnet-4-5",
38
+ // "claude-opus-4-1@20250805" → "claude-opus-4-1", "claude-sonnet-4[1m]" → "claude-sonnet-4".
39
+ export function normalizeModelId(raw) {
40
+ let s = String(raw ?? '').trim().toLowerCase();
41
+ for (let i = 0; i < 3 && PROVIDER_PREFIX.test(s); i++) s = s.replace(PROVIDER_PREFIX, '');
42
+ s = s.replace(/\[[^\]]*\]$/, '');
43
+ s = s.replace(/^(us|eu|apac|global|au|jp)\./, '').replace(/^anthropic\./, '');
44
+ s = s.replace(/-v\d+(:\d+)?$/, '').replace(/:\d+$/, '');
45
+ s = s.replace(/@[a-z0-9-]+$/, '').replace(/-\d{8}$/, '').replace(/-latest$/, '');
46
+ return s;
47
+ }
48
+
49
+ export function isClaudeModel(raw) {
50
+ const n = normalizeModelId(raw);
51
+ return n.startsWith('claude-') || /^(opus|sonnet|haiku|fable|mythos)(-|$)/.test(n);
52
+ }
53
+
54
+ function toRates(entry) {
55
+ const perM = (v) => Math.round(v * 1_000_000 * 1e6) / 1e6;
56
+ const inp = entry.input_cost_per_token;
20
57
  return {
21
- displayName,
22
- input: entry.input_cost_per_token * mtok,
23
- output: entry.output_cost_per_token * mtok,
24
- cacheWrite5m: (entry.cache_creation_input_token_cost ?? 0) * mtok,
25
- cacheWrite1h: (entry.cache_creation_input_token_cost_above_1hr ?? entry.cache_creation_input_token_cost ?? 0) * mtok,
26
- cacheRead: (entry.cache_read_input_token_cost ?? 0) * mtok,
58
+ input: perM(inp),
59
+ output: perM(entry.output_cost_per_token),
60
+ cacheWrite5m: perM(entry.cache_creation_input_token_cost ?? inp * 1.25),
61
+ cacheWrite1h: perM(entry.cache_creation_input_token_cost_above_1hr ?? inp * 2),
62
+ cacheRead: perM(entry.cache_read_input_token_cost ?? inp * 0.1),
27
63
  };
28
64
  }
29
65
 
30
- function buildTableFromLiteLLM(raw) {
66
+ const PROVIDER_RANK = { anthropic: 0, openai: 1, gemini: 2, vertex_ai: 3, deepseek: 4, mistral: 5, xai: 6, zai: 7, moonshot: 8, dashscope: 9 };
67
+
68
+ // Keyed by normalized id. For each id, the first-party provider's price wins
69
+ // over resellers (OpenRouter, Bedrock regional markups, …).
70
+ export function buildTable(raw) {
31
71
  const models = {};
72
+ const rank = {};
32
73
  for (const [key, entry] of Object.entries(raw)) {
33
- if (entry.litellm_provider !== 'anthropic' || entry.mode !== 'chat') continue;
74
+ if (!entry || typeof entry !== 'object') continue;
75
+ if (entry.mode && entry.mode !== 'chat' && entry.mode !== 'responses') continue;
34
76
  if (typeof entry.input_cost_per_token !== 'number' || typeof entry.output_cost_per_token !== 'number') continue;
35
- models[key] = toRates(entry, prettyName(key));
77
+ const id = normalizeModelId(key);
78
+ if (!id) continue;
79
+ const r = PROVIDER_RANK[entry.litellm_provider] ?? 50;
80
+ const hasPrefix = PROVIDER_PREFIX.test(key.toLowerCase()) || /^(us|eu|apac|global)\./.test(key);
81
+ const score = r * 2 + (hasPrefix ? 1 : 0);
82
+ if (rank[id] != null && rank[id] <= score) continue;
83
+ rank[id] = score;
84
+ models[id] = { ...toRates(entry), provider: entry.litellm_provider ?? null, sourceKey: key };
36
85
  }
37
86
  return models;
38
87
  }
39
88
 
40
- function prettyName(modelId) {
41
- const parts = modelId.split('-').filter((p) => p !== 'claude');
42
- if (!parts.length) return modelId;
43
- const family = parts[0][0].toUpperCase() + parts[0].slice(1);
44
- const rest = parts.slice(1).join('.');
45
- return rest ? `Claude ${family} ${rest}` : `Claude ${family}`;
89
+ export async function refreshPricing() {
90
+ const text = await fetchText(SOURCE_URL);
91
+ const models = buildTable(JSON.parse(text));
92
+ if (Object.keys(models).length < 50) throw new Error('pricing source looked truncated');
93
+ const fetchedAt = Date.now();
94
+ writeJson(FILES.pricingCache, { fetchedAt, url: SOURCE_URL, models });
95
+ return { models, source: 'live', fetchedAt };
46
96
  }
47
97
 
48
- async function fetchLiteLLMTable() {
49
- const res = await fetch(SOURCE_URL, { signal: AbortSignal.timeout(8000) });
50
- if (!res.ok) throw new Error(`pricing source returned ${res.status}`);
51
- const raw = await res.json();
52
- return buildTableFromLiteLLM(raw);
53
- }
54
-
55
- function readCache() {
56
- if (!existsSync(cacheFile)) return null;
57
- try {
58
- return JSON.parse(readFileSync(cacheFile, 'utf-8'));
59
- } catch {
60
- return null;
98
+ // Resolved once per run: fresh cache, else live fetch, else stale cache,
99
+ // else the bundled snapshot. User overrides from pricing.yaml are layered
100
+ // on top by lookupPricing.
101
+ export async function loadPricingTable({ force = false, offline = false } = {}) {
102
+ const overrides = loadPricingOverrides();
103
+ const cache = readJson(FILES.pricingCache, null);
104
+ const fresh = cache?.models && Date.now() - cache.fetchedAt < TTL_MS;
105
+ if (fresh && !force) return { models: cache.models, overrides, source: 'cache', fetchedAt: cache.fetchedAt };
106
+ if (!offline) {
107
+ try {
108
+ return { ...(await refreshPricing()), overrides };
109
+ } catch (err) {
110
+ if (cache?.models) return { models: cache.models, overrides, source: 'stale-cache', fetchedAt: cache.fetchedAt, error: err.message };
111
+ const fb = JSON.parse(readFileSync(fallbackFile, 'utf-8'));
112
+ return { models: fb.models, overrides, source: 'bundled-fallback', fetchedAt: fb.capturedAt ?? null, error: err.message };
113
+ }
61
114
  }
115
+ if (cache?.models) return { models: cache.models, overrides, source: 'cache', fetchedAt: cache.fetchedAt };
116
+ const fb = JSON.parse(readFileSync(fallbackFile, 'utf-8'));
117
+ return { models: fb.models, overrides, source: 'bundled-fallback', fetchedAt: fb.capturedAt ?? null };
62
118
  }
63
119
 
64
- function writeCache(models) {
65
- mkdirSync(cacheDir, { recursive: true });
66
- writeFileSync(cacheFile, JSON.stringify({ fetchedAt: Date.now(), models }, null, 2), 'utf-8');
67
- }
120
+ const ALIAS_FAMILIES = ['opus', 'sonnet', 'haiku', 'fable', 'mythos'];
68
121
 
69
- function cacheIsFresh(cache) {
70
- return cache && Date.now() - cache.fetchedAt < TTL_MS;
122
+ function versionOf(id) {
123
+ return id.replace(/^claude-[a-z]+-?/, '').split(/[-.]/).map(Number);
71
124
  }
72
125
 
73
- // Resolves the full pricing table once per CLI invocation: fresh cache wins,
74
- // otherwise re-fetch from LiteLLM's community-maintained (Anthropic-sourced)
75
- // pricing JSON, falling back to a stale cache or the bundled snapshot if
76
- // offline. This is the whole point: nobody has to hand-edit prices.
77
- export async function loadPricingTable() {
78
- const cache = readCache();
79
- if (cacheIsFresh(cache)) {
80
- return { models: cache.models, source: 'cache', fetchedAt: cache.fetchedAt };
81
- }
82
-
83
- try {
84
- const models = await fetchLiteLLMTable();
85
- writeCache(models);
86
- return { models, source: 'live', fetchedAt: Date.now() };
87
- } catch (err) {
88
- if (cache) {
89
- return { models: cache.models, source: 'stale-cache', fetchedAt: cache.fetchedAt, error: err.message };
126
+ function newestInFamily(family, models) {
127
+ const ids = Object.keys(models).filter((k) => new RegExp(`^claude-${family}(-[\\d.]+)*(-\\d)?$`).test(k));
128
+ ids.sort((a, b) => {
129
+ const va = versionOf(a);
130
+ const vb = versionOf(b);
131
+ for (let i = 0; i < Math.max(va.length, vb.length); i++) {
132
+ const d = (vb[i] ?? 0) - (va[i] ?? 0);
133
+ if (d) return d;
90
134
  }
91
- const fallback = JSON.parse(readFileSync(fallbackFile, 'utf-8'));
92
- return { models: fallback.models, source: 'bundled-fallback', fetchedAt: null, error: err.message };
93
- }
135
+ return 0;
136
+ });
137
+ return ids[0] ?? null;
94
138
  }
95
139
 
96
- function resolveAlias(rawModel, models) {
97
- const family = ALIAS_FAMILIES.find((f) => rawModel === f || rawModel.startsWith(f + '-'));
98
- if (!family) return null;
99
- // Pick the newest-looking concrete model in that family: prefer bare
100
- // "claude-<family>-<N>" ids (no date suffix) and take the highest one.
101
- const candidates = Object.keys(models).filter((k) => k.startsWith(`claude-${family}-`) && /^claude-[a-z]+-[\d.]+$/.test(k));
102
- candidates.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
103
- return candidates[0] ?? null;
140
+ function cleanRates(r) {
141
+ const out = {};
142
+ for (const k of RATE_KEYS) out[k] = Number(r?.[k]) || 0;
143
+ return out;
104
144
  }
105
145
 
106
- export function lookupPricing(rawModel, table) {
107
- if (!rawModel) return { fidelity: 'unknown', rates: null, displayName: 'unknown model' };
108
- const models = table.models;
146
+ function findOverride(rawModel, overrides) {
147
+ if (!overrides) return null;
148
+ if (overrides[rawModel]) return overrides[rawModel];
149
+ const n = normalizeModelId(rawModel);
150
+ for (const [k, v] of Object.entries(overrides)) if (normalizeModelId(k) === n) return v;
151
+ return null;
152
+ }
109
153
 
110
- if (models[rawModel]) {
111
- return { fidelity: 'measured', rates: models[rawModel], displayName: models[rawModel].displayName };
154
+ // Returns { rates, fidelity, suggested }
155
+ // fidelity: override | measured | approximate-alias | unpriced
156
+ // unpriced → rates are all zero; `suggested` carries the public list price
157
+ // when one exists so the Pricing page can offer it.
158
+ export function lookupPricing(rawModel, table) {
159
+ if (!rawModel) return { fidelity: 'unpriced', rates: cleanRates(null), suggested: null };
160
+ const models = table.models ?? {};
161
+ const override = findOverride(rawModel, table.overrides);
162
+ const n = normalizeModelId(rawModel);
163
+ const claude = isClaudeModel(rawModel);
164
+
165
+ let publicRates = models[n] ?? models[rawModel] ?? null;
166
+ let fidelity = 'measured';
167
+ if (!publicRates && claude) {
168
+ const family = ALIAS_FAMILIES.find((f) => n === f || n.startsWith(f + '-') || n === `claude-${f}`);
169
+ const target = family ? newestInFamily(family, models) : null;
170
+ if (target) {
171
+ publicRates = models[target];
172
+ fidelity = 'approximate-alias';
173
+ }
112
174
  }
113
175
 
114
- const aliasTarget = resolveAlias(rawModel, models);
115
- if (aliasTarget) {
116
- const rates = models[aliasTarget];
117
- return { fidelity: 'approximate-alias', rates, displayName: `${rates.displayName} (via alias "${rawModel}")` };
176
+ if (override) return { fidelity: 'override', rates: cleanRates(override), suggested: publicRates ? cleanRates(publicRates) : null };
177
+ if (publicRates && claude) {
178
+ return { fidelity, rates: cleanRates(publicRates), suggested: null };
118
179
  }
180
+ // Non-Claude model behind the Claude Code harness (gateway, on-prem, local):
181
+ // we can't know what it costs this user, so it's $0 until they say otherwise.
182
+ return { fidelity: 'unpriced', rates: cleanRates(null), suggested: publicRates ? cleanRates(publicRates) : null };
183
+ }
119
184
 
120
- // Longest-prefix match as a last resort (handles dated variants Claude
121
- // Code might report that aren't in the pricing source verbatim).
122
- const prefixMatch = Object.keys(models)
123
- .filter((k) => rawModel.startsWith(k) || k.startsWith(rawModel))
124
- .sort((a, b) => b.length - a.length)[0];
125
- if (prefixMatch) {
126
- const rates = models[prefixMatch];
127
- return { fidelity: 'approximate-alias', rates, displayName: `${rates.displayName} (matched from "${rawModel}")` };
185
+ // Looks a model id up in the public price list: exact (after normalizing
186
+ // provider prefixes / dates), else the newest Claude of the same family,
187
+ // else close matches the user can pick from.
188
+ export function lookupPublic(rawModel, table) {
189
+ const models = table.models ?? {};
190
+ const n = normalizeModelId(rawModel);
191
+ const exact = models[n] ?? models[String(rawModel).trim().toLowerCase()];
192
+ if (exact) {
193
+ const id = models[n] ? n : String(rawModel).trim().toLowerCase();
194
+ return { found: true, match: 'exact', id, rates: cleanRates(exact), provider: exact.provider ?? null, sourceKey: exact.sourceKey ?? id };
128
195
  }
129
-
130
- return { fidelity: 'unpriced', rates: null, displayName: rawModel };
196
+ const q = n.replace(/[^a-z0-9]/g, '');
197
+ const common = (k) => { let i = 0; while (i < q.length && i < k.length && q[i] === k[i]) i++; return i; };
198
+ const scored = [];
199
+ for (const id of Object.keys(models)) {
200
+ const k = id.replace(/[^a-z0-9]/g, '');
201
+ const score = q.length >= 3 && (k.includes(q) || q.includes(k)) ? 100 : common(k);
202
+ if (score >= 5) scored.push([id, score]);
203
+ }
204
+ scored.sort((x, y) => y[1] - x[1] || x[0].length - y[0].length);
205
+ const candidates = scored.slice(0, 8).map(([id]) => ({ id, rates: cleanRates(models[id]), provider: models[id].provider ?? null }));
206
+ return { found: false, match: 'none', id: n, candidates };
131
207
  }
132
208
 
133
209
  export function costFor(usage, rates) {
134
210
  if (!rates) return null;
135
211
  const mtok = 1_000_000;
136
- const cacheWrite5m = usage.cacheWrite5m ?? 0;
137
- const cacheWrite1h = usage.cacheWrite1h ?? 0;
138
- const input = (usage.input / mtok) * rates.input;
139
- const output = (usage.output / mtok) * rates.output;
140
- const cacheWrite = (cacheWrite5m / mtok) * rates.cacheWrite5m + (cacheWrite1h / mtok) * rates.cacheWrite1h;
141
- const cacheRead = (usage.cacheRead / mtok) * rates.cacheRead;
212
+ const input = ((usage.input ?? 0) / mtok) * rates.input;
213
+ const output = ((usage.output ?? 0) / mtok) * rates.output;
214
+ const cacheWrite = ((usage.cacheWrite5m ?? 0) / mtok) * rates.cacheWrite5m + ((usage.cacheWrite1h ?? 0) / mtok) * rates.cacheWrite1h;
215
+ const cacheRead = ((usage.cacheRead ?? 0) / mtok) * rates.cacheRead;
142
216
  return { input, output, cacheWrite, cacheRead, total: input + output + cacheWrite + cacheRead };
143
217
  }
218
+
219
+ export function pricingInfo(table) {
220
+ return { source: table.source, fetchedAt: table.fetchedAt ?? null, error: table.error ?? null, url: SOURCE_URL, modelCount: Object.keys(table.models ?? {}).length };
221
+ }