@ycstudios/token-tab 0.1.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.
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/adapters/claude-live.mjs +90 -0
- package/adapters/install-live.sh +87 -0
- package/adapters/write-live.mjs +85 -0
- package/package.json +35 -0
- package/src/core.mjs +278 -0
- package/src/live-parse.mjs +74 -0
- package/src/pricing.mjs +104 -0
- package/src/token-tab.mjs +353 -0
- package/swiftbar/README.md +46 -0
- package/swiftbar/token-tab-live.2m.sh +55 -0
- package/swiftbar/token-tab.30s.sh +40 -0
package/src/core.mjs
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Token Tab — pure parsing core.
|
|
2
|
+
//
|
|
3
|
+
// No I/O, no dependencies. Takes already-parsed JSONL objects and returns an
|
|
4
|
+
// aggregate. Keeping this pure is what lets golden-fixture tests pin every edge
|
|
5
|
+
// case (see the test plan) without touching the filesystem or a sandbox.
|
|
6
|
+
//
|
|
7
|
+
// It never touches `message.content` — only the metadata fields needed to count
|
|
8
|
+
// tokens. That is the whole trust story: we read the numbers, never your text.
|
|
9
|
+
|
|
10
|
+
/** Sum of all four token classes — matches ccusage's default total. cache_read
|
|
11
|
+
* usually dominates, so leaving it out would diverge wildly. */
|
|
12
|
+
export function usageSum(u) {
|
|
13
|
+
if (!u) return 0;
|
|
14
|
+
return (
|
|
15
|
+
(u.input_tokens || 0) +
|
|
16
|
+
(u.cache_creation_input_tokens || 0) +
|
|
17
|
+
(u.cache_read_input_tokens || 0) +
|
|
18
|
+
(u.output_tokens || 0)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function usageByClass(u) {
|
|
23
|
+
return {
|
|
24
|
+
input: u?.input_tokens || 0,
|
|
25
|
+
cacheCreate: u?.cache_creation_input_tokens || 0,
|
|
26
|
+
cacheRead: u?.cache_read_input_tokens || 0,
|
|
27
|
+
output: u?.output_tokens || 0,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Strip the `[1m]` 1M-context suffix; report whether it was present (it's a
|
|
32
|
+
* distinct price tier for the dollars layer later). */
|
|
33
|
+
export function normalizeModel(model) {
|
|
34
|
+
if (typeof model !== "string") return { base: "<unknown>", oneM: false };
|
|
35
|
+
const oneM = model.endsWith("[1m]");
|
|
36
|
+
return { base: oneM ? model.slice(0, -4) : model, oneM };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Route a model id to a billing surface.
|
|
40
|
+
* bedrock: us.anthropic.* / anthropic.*:0
|
|
41
|
+
* subscription: claude-* and bare names (sonnet/opus/haiku)
|
|
42
|
+
* untracked: <synthetic> and anything unrecognized (still counted for tokens) */
|
|
43
|
+
export function classifySurface(model) {
|
|
44
|
+
const { base } = normalizeModel(model);
|
|
45
|
+
if (!base || base === "<synthetic>" || base === "<unknown>") return "untracked";
|
|
46
|
+
// Bedrock ids carry an optional region prefix (us./eu./apac./us-gov.) before
|
|
47
|
+
// `anthropic.`; strip it so every region routes to bedrock — matching the
|
|
48
|
+
// region set canonicalModelId strips in pricing.mjs (kept in lockstep).
|
|
49
|
+
const deregioned = base.replace(/^(us|eu|apac|us-gov)\./, "");
|
|
50
|
+
if (deregioned.startsWith("anthropic.")) return "bedrock";
|
|
51
|
+
if (base.startsWith("claude-") || /^(sonnet|opus|haiku)$/i.test(base)) return "subscription";
|
|
52
|
+
return "untracked";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
function localDayKey(d) {
|
|
58
|
+
// YYYY-MM-DD in LOCAL time (logs are UTC; "today" must mean the user's day).
|
|
59
|
+
const y = d.getFullYear();
|
|
60
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
61
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
62
|
+
return `${y}-${m}-${day}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function startOfLocalWeek(now, weekStartsOn /* 0=Sun,1=Mon */) {
|
|
66
|
+
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
67
|
+
const diff = (d.getDay() - weekStartsOn + 7) % 7;
|
|
68
|
+
d.setDate(d.getDate() - diff);
|
|
69
|
+
return d;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Aggregate a stream of assistant usage records.
|
|
75
|
+
*
|
|
76
|
+
* @param {Iterable<{messageId?:string,requestId?:string,model:string,usage:object,timestamp:string,isSidechain?:boolean}>} records
|
|
77
|
+
* @param {{now?:Date, weekStartsOn?:number, cost?:(usage:object,model:string)=>{usd:number,priced:boolean}}} opts
|
|
78
|
+
* opts.cost (optional): a pure pricing function (see src/pricing.mjs). When supplied,
|
|
79
|
+
* the result carries a `cost` block (dollars per window + per model). Omit it and the
|
|
80
|
+
* output is byte-for-byte unchanged — the dollars layer never alters the token numbers.
|
|
81
|
+
* @returns aggregate snapshot (plain value object — no content, no PII)
|
|
82
|
+
*/
|
|
83
|
+
export function aggregate(records, opts = {}) {
|
|
84
|
+
const now = opts.now ? new Date(opts.now) : new Date();
|
|
85
|
+
const weekStartsOn = opts.weekStartsOn ?? 1; // Monday
|
|
86
|
+
const costFn = typeof opts.cost === "function" ? opts.cost : null;
|
|
87
|
+
const todayKey = localDayKey(now);
|
|
88
|
+
const weekStart = startOfLocalWeek(now, weekStartsOn).getTime();
|
|
89
|
+
const rollingCutoff = now.getTime() - FIVE_HOURS_MS;
|
|
90
|
+
|
|
91
|
+
// Pass 1 — dedup with KEEP-LAST resolution.
|
|
92
|
+
// Key = `${messageId}:${requestId}` when BOTH exist; otherwise a unique key (so a
|
|
93
|
+
// line missing an id is always counted, never collapsed).
|
|
94
|
+
// Streaming emits several usage lines per message sharing one key; input/cache
|
|
95
|
+
// are constant across them but `output_tokens` GROWS, so the FINAL line is
|
|
96
|
+
// authoritative. Keeping last (verified against ccusage: output reconciles only
|
|
97
|
+
// with last-write-wins) — collisions with differing totals are still reported.
|
|
98
|
+
const kept = new Map(); // key -> record (last seen)
|
|
99
|
+
let uniqueCounter = 0;
|
|
100
|
+
let duplicatesDropped = 0;
|
|
101
|
+
let collisionsDifferingTotals = 0;
|
|
102
|
+
let approximate = false;
|
|
103
|
+
|
|
104
|
+
for (const r of records) {
|
|
105
|
+
const hasIds = !!(r.messageId && r.requestId);
|
|
106
|
+
const key = hasIds ? `${r.messageId}:${r.requestId}` : `__nokey__${uniqueCounter++}`;
|
|
107
|
+
if (!hasIds) approximate = true;
|
|
108
|
+
if (kept.has(key)) {
|
|
109
|
+
duplicatesDropped++;
|
|
110
|
+
if (usageSum(kept.get(key).usage) !== usageSum(r.usage)) collisionsDifferingTotals++;
|
|
111
|
+
}
|
|
112
|
+
kept.set(key, r); // last-write-wins (records arrive in deterministic file-mtime+line order)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Pass 2 — aggregate the deduped records.
|
|
116
|
+
const byClass = { input: 0, cacheCreate: 0, cacheRead: 0, output: 0 };
|
|
117
|
+
const bySurface = {}; // surface -> tokens
|
|
118
|
+
const byModel = {}; // base model -> tokens
|
|
119
|
+
let total = 0;
|
|
120
|
+
let today = 0;
|
|
121
|
+
let thisWeek = 0;
|
|
122
|
+
let rolling5h = 0;
|
|
123
|
+
let counted = 0;
|
|
124
|
+
let untrackedTokens = 0;
|
|
125
|
+
let untrackedRequests = 0;
|
|
126
|
+
// Dollars (only when a pricing fn is injected). Mirrors the token windows so the
|
|
127
|
+
// estimate can be shown per today/week/5h. "unpriced" = a model with no rate in the
|
|
128
|
+
// table: its tokens still count everywhere else, just not toward dollars.
|
|
129
|
+
let costTotal = 0;
|
|
130
|
+
let costToday = 0;
|
|
131
|
+
let costWeek = 0;
|
|
132
|
+
let costRolling = 0;
|
|
133
|
+
const costByModel = {};
|
|
134
|
+
let unpricedTokens = 0;
|
|
135
|
+
let unpricedRequests = 0;
|
|
136
|
+
const unpricedModels = new Set();
|
|
137
|
+
const stamps = []; // {t, sum} for the window pass (past-dated only)
|
|
138
|
+
|
|
139
|
+
for (const r of kept.values()) {
|
|
140
|
+
const sum = usageSum(r.usage);
|
|
141
|
+
counted++;
|
|
142
|
+
total += sum;
|
|
143
|
+
const c = usageByClass(r.usage);
|
|
144
|
+
byClass.input += c.input;
|
|
145
|
+
byClass.cacheCreate += c.cacheCreate;
|
|
146
|
+
byClass.cacheRead += c.cacheRead;
|
|
147
|
+
byClass.output += c.output;
|
|
148
|
+
|
|
149
|
+
const surface = classifySurface(r.model);
|
|
150
|
+
bySurface[surface] = (bySurface[surface] || 0) + sum;
|
|
151
|
+
const { base } = normalizeModel(r.model);
|
|
152
|
+
byModel[base] = (byModel[base] || 0) + sum;
|
|
153
|
+
if (surface === "untracked") {
|
|
154
|
+
untrackedTokens += sum;
|
|
155
|
+
untrackedRequests++;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let usd = 0;
|
|
159
|
+
let priced = false;
|
|
160
|
+
if (costFn) {
|
|
161
|
+
const c2 = costFn(r.usage, r.model);
|
|
162
|
+
usd = c2.usd || 0;
|
|
163
|
+
priced = !!c2.priced;
|
|
164
|
+
if (priced) {
|
|
165
|
+
costTotal += usd;
|
|
166
|
+
costByModel[base] = (costByModel[base] || 0) + usd;
|
|
167
|
+
} else {
|
|
168
|
+
unpricedTokens += sum;
|
|
169
|
+
unpricedRequests++;
|
|
170
|
+
unpricedModels.add(base);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const ts = new Date(r.timestamp);
|
|
175
|
+
if (!isNaN(ts)) {
|
|
176
|
+
// Upper-bound every window at `now` so a future-dated line (clock skew)
|
|
177
|
+
// can't inflate today/week/5h. Total still includes it — it's real spend,
|
|
178
|
+
// just mis-stamped. Makes rolling5h the documented half-open (now-5h, now].
|
|
179
|
+
const tms = ts.getTime();
|
|
180
|
+
if (tms <= now.getTime()) {
|
|
181
|
+
if (localDayKey(ts) === todayKey) today += sum;
|
|
182
|
+
if (tms >= weekStart) thisWeek += sum;
|
|
183
|
+
if (tms > rollingCutoff) rolling5h += sum;
|
|
184
|
+
if (priced) {
|
|
185
|
+
if (localDayKey(ts) === todayKey) costToday += usd;
|
|
186
|
+
if (tms >= weekStart) costWeek += usd;
|
|
187
|
+
if (tms > rollingCutoff) costRolling += usd;
|
|
188
|
+
}
|
|
189
|
+
stamps.push({ t: tms, sum });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Pass 3 — current usage window (Anthropic-style fixed 5h reset blocks).
|
|
195
|
+
// A block starts at the EXACT first message (verified: Claude anchors the window to
|
|
196
|
+
// your first message, not the top of the hour) and lasts blockHours; a gap >
|
|
197
|
+
// blockHours, or crossing the block end, starts a new block. The reset time is exact.
|
|
198
|
+
// The % needs the plan cap, which Anthropic does not publish — so pct is only given
|
|
199
|
+
// when opts.cap is supplied. calibratedCap (busiest completed block) is exposed as
|
|
200
|
+
// info but is NOT used as a denominator: you've usually never maxed a window, so it
|
|
201
|
+
// would over-report how close you are. Set the cap from Claude's /usage to get a %.
|
|
202
|
+
const blockMs = (opts.blockHours ?? 5) * 60 * 60 * 1000;
|
|
203
|
+
stamps.sort((a, b) => a.t - b.t);
|
|
204
|
+
const blocks = [];
|
|
205
|
+
for (const s of stamps) {
|
|
206
|
+
const last = blocks[blocks.length - 1];
|
|
207
|
+
if (last && s.t - last.lastT <= blockMs && s.t < last.start + blockMs) {
|
|
208
|
+
last.tokens += s.sum;
|
|
209
|
+
last.lastT = s.t;
|
|
210
|
+
} else {
|
|
211
|
+
blocks.push({ start: s.t, lastT: s.t, tokens: s.sum });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const nowMs = now.getTime();
|
|
215
|
+
const lastBlock = blocks[blocks.length - 1] || null;
|
|
216
|
+
const windowActive = !!(lastBlock && nowMs >= lastBlock.start && nowMs < lastBlock.start + blockMs);
|
|
217
|
+
const completed = windowActive ? blocks.slice(0, -1) : blocks;
|
|
218
|
+
const calibratedCap = completed.reduce((m, b) => Math.max(m, b.tokens), 0);
|
|
219
|
+
const cap = opts.cap != null && opts.cap > 0 ? opts.cap : 0;
|
|
220
|
+
const windowTokens = windowActive ? lastBlock.tokens : 0;
|
|
221
|
+
const resetAt = windowActive ? lastBlock.start + blockMs : null;
|
|
222
|
+
const windowStats = {
|
|
223
|
+
active: windowActive,
|
|
224
|
+
tokens: windowTokens,
|
|
225
|
+
resetAt, // ms epoch, or null when no active block
|
|
226
|
+
msToReset: resetAt != null ? resetAt - nowMs : null,
|
|
227
|
+
cap, // 0 when no configured cap
|
|
228
|
+
calibratedCap, // busiest completed block — informational only
|
|
229
|
+
capSource: cap > 0 ? "config" : "none",
|
|
230
|
+
pct: cap > 0 ? Math.round((windowTokens / cap) * 100) : null,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
total,
|
|
235
|
+
byClass,
|
|
236
|
+
bySurface,
|
|
237
|
+
byModel,
|
|
238
|
+
today,
|
|
239
|
+
thisWeek,
|
|
240
|
+
rolling5h,
|
|
241
|
+
window: windowStats,
|
|
242
|
+
dedup: { counted, duplicatesDropped, collisionsDifferingTotals },
|
|
243
|
+
approximate,
|
|
244
|
+
untracked: { tokens: untrackedTokens, requests: untrackedRequests },
|
|
245
|
+
// Present only when opts.cost was supplied. Dollars are an estimate; `unpriced`
|
|
246
|
+
// records the tokens we counted but had no rate for (honest gap, never guessed).
|
|
247
|
+
cost: costFn
|
|
248
|
+
? {
|
|
249
|
+
total: costTotal,
|
|
250
|
+
today: costToday,
|
|
251
|
+
thisWeek: costWeek,
|
|
252
|
+
rolling5h: costRolling,
|
|
253
|
+
byModel: costByModel,
|
|
254
|
+
unpriced: {
|
|
255
|
+
tokens: unpricedTokens,
|
|
256
|
+
requests: unpricedRequests,
|
|
257
|
+
models: [...unpricedModels].sort(),
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
: undefined,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Extract the fields we care about from a raw JSONL object. Returns null for
|
|
265
|
+
* any line that is not an assistant turn carrying usage. Never reads content. */
|
|
266
|
+
export function recordFromLine(obj) {
|
|
267
|
+
if (!obj || obj.type !== "assistant") return null;
|
|
268
|
+
const usage = obj.message?.usage;
|
|
269
|
+
if (!usage) return null;
|
|
270
|
+
return {
|
|
271
|
+
messageId: obj.message?.id,
|
|
272
|
+
requestId: obj.requestId,
|
|
273
|
+
model: obj.message?.model ?? "<unknown>",
|
|
274
|
+
usage,
|
|
275
|
+
timestamp: obj.timestamp,
|
|
276
|
+
isSidechain: !!obj.isSidechain, // counted, NOT filtered — sidechains are real spend
|
|
277
|
+
};
|
|
278
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Token Tab — pure parser for the opt-in live usage window.
|
|
2
|
+
//
|
|
3
|
+
// PURE: no I/O, no subprocess, no network. Takes the raw stdout of
|
|
4
|
+
// `claude -p "/usage" --output-format json` and returns the server-side
|
|
5
|
+
// session/weekly percentages, or null on anything unexpected (fail closed).
|
|
6
|
+
//
|
|
7
|
+
// This file deliberately lives in src/ (the audited core); the live adapter that
|
|
8
|
+
// produces its input lives under adapters/ (outside src/). Keeping the audited
|
|
9
|
+
// core free of subprocess and network calls is the whole point — the audit greps
|
|
10
|
+
// over src/ must keep printing nothing, so this file avoids those tokens even in
|
|
11
|
+
// comments (it uses String.match, not the dotted variant the audit looks for).
|
|
12
|
+
//
|
|
13
|
+
// Robustness notes (each pins a real failure mode, see test/live-parse.test.mjs):
|
|
14
|
+
// - The percentage is matched INDEPENDENTLY of the "· resets …" tail, so a
|
|
15
|
+
// future separator/encoding change (the `·` is U+00B7) costs only the reset
|
|
16
|
+
// text, never the number the feature exists to show.
|
|
17
|
+
// - We split on "\n" and match per line; a single anchored regex against the
|
|
18
|
+
// whole multi-line `result` would never match.
|
|
19
|
+
// - The whole body is guarded so parseUsageOutput(null) returns null, never
|
|
20
|
+
// throws (JSON.parse("null") yields null without throwing).
|
|
21
|
+
|
|
22
|
+
const PCT_RE = /^Current (session|week \(([^)]+)\)):\s*(\d+)%\s*used\b(.*)$/;
|
|
23
|
+
const RESET_RE = /resets\s+(.+?)\s*$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} stdout raw JSON string from `claude -p "/usage" --output-format json`
|
|
27
|
+
* @returns {null | {source:string, sessionPct?:number, sessionResetText?:string,
|
|
28
|
+
* weeklyPct?:number, weeklyResetText?:string, weeklyByModel:Object}}
|
|
29
|
+
*/
|
|
30
|
+
export function parseUsageOutput(stdout) {
|
|
31
|
+
let obj;
|
|
32
|
+
try {
|
|
33
|
+
obj = JSON.parse(stdout);
|
|
34
|
+
} catch {
|
|
35
|
+
return null; // non-JSON stdout (e.g. "command not found")
|
|
36
|
+
}
|
|
37
|
+
// JSON.parse("null") returns null WITHOUT throwing — guard before any access.
|
|
38
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
|
|
39
|
+
if (obj.is_error === true || typeof obj.result !== "string") return null;
|
|
40
|
+
|
|
41
|
+
let sessionPct, sessionResetText, weeklyPct, weeklyResetText;
|
|
42
|
+
const weeklyByModel = {};
|
|
43
|
+
let found = false;
|
|
44
|
+
|
|
45
|
+
for (const raw of obj.result.split("\n")) {
|
|
46
|
+
const line = raw.replace(/\r$/, ""); // tolerate CRLF
|
|
47
|
+
const m = line.match(PCT_RE);
|
|
48
|
+
if (!m) continue;
|
|
49
|
+
const kind = m[1]; // "session" | "week (...)"
|
|
50
|
+
const inner = m[2]; // undefined | "all models" | "Sonnet only"
|
|
51
|
+
const pct = Number(m[3]);
|
|
52
|
+
const rm = (m[4] || "").match(RESET_RE); // tail parsed independently of the separator
|
|
53
|
+
const resetText = rm ? rm[1] : undefined; // undefined on idle / drift
|
|
54
|
+
|
|
55
|
+
if (kind === "session") {
|
|
56
|
+
sessionPct = pct;
|
|
57
|
+
sessionResetText = resetText;
|
|
58
|
+
found = true;
|
|
59
|
+
} else {
|
|
60
|
+
const label = inner.trim().toLowerCase();
|
|
61
|
+
if (label === "all models") {
|
|
62
|
+
weeklyPct = pct;
|
|
63
|
+
weeklyResetText = resetText;
|
|
64
|
+
found = true;
|
|
65
|
+
} else {
|
|
66
|
+
weeklyByModel[label.replace(/\s+only$/, "")] = pct; // "Sonnet only" -> "sonnet"
|
|
67
|
+
found = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!found) return null; // no "Current …% used" line anywhere
|
|
73
|
+
return { source: "claude /usage", sessionPct, sessionResetText, weeklyPct, weeklyResetText, weeklyByModel };
|
|
74
|
+
}
|
package/src/pricing.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Token Tab — price table + cost math (pure, no I/O, no dependencies).
|
|
2
|
+
//
|
|
3
|
+
// Dollars are an ESTIMATE, not an invoice (this is a stated premise of the design:
|
|
4
|
+
// "good enough to know your tab, not good enough for accounting"). It is a bundled
|
|
5
|
+
// per-model rate table applied to the four token classes the logs already carry —
|
|
6
|
+
// no network call, no key, nothing retrieved. Just arithmetic on numbers already on disk.
|
|
7
|
+
//
|
|
8
|
+
// Rates are USD per MILLION tokens, from Anthropic's public list pricing. Input and
|
|
9
|
+
// output are listed per model; the two cache classes are derived from the input rate
|
|
10
|
+
// using Anthropic's published multipliers:
|
|
11
|
+
// cache WRITE (cache_creation_input_tokens) = 1.25x input — the 5-minute-TTL write
|
|
12
|
+
// rate. The logs don't record the TTL, so we assume 5m (what ccusage assumes too).
|
|
13
|
+
// cache READ (cache_read_input_tokens) = 0.10x input
|
|
14
|
+
//
|
|
15
|
+
// Unknown models are NEVER invented a price for. costOfUsage returns priced:false and
|
|
16
|
+
// the caller still counts the tokens — "tracked tokens, untracked price." A guessed
|
|
17
|
+
// dollar figure that disagrees with the real bill is worse than honestly saying "unknown."
|
|
18
|
+
|
|
19
|
+
import { usageByClass, normalizeModel } from "./core.mjs";
|
|
20
|
+
|
|
21
|
+
const CACHE_WRITE_MULT = 1.25; // 5-minute cache-write rate, relative to input
|
|
22
|
+
const CACHE_READ_MULT = 0.1; // cache-read rate, relative to input
|
|
23
|
+
|
|
24
|
+
// input / output USD per 1M tokens, from Anthropic's published list pricing. Covers every
|
|
25
|
+
// model Anthropic still publishes a standard rate for — current models plus older ones that
|
|
26
|
+
// remain billable (several only on Bedrock/Vertex now). Models with NO published rate
|
|
27
|
+
// (e.g. Haiku 3) and synthetic ids fall through to unpriced on purpose — a guessed figure
|
|
28
|
+
// is worse than an honest "no rate." The 1M-context tier ([1m] suffix) is standard-priced
|
|
29
|
+
// on current models (no long-context premium), so it shares the base rate — normalizeModel
|
|
30
|
+
// strips the suffix before lookup.
|
|
31
|
+
const RATES = {
|
|
32
|
+
// Current models.
|
|
33
|
+
"claude-fable-5": { input: 10, output: 50 },
|
|
34
|
+
"claude-opus-4-8": { input: 5, output: 25 },
|
|
35
|
+
"claude-opus-4-7": { input: 5, output: 25 },
|
|
36
|
+
"claude-opus-4-6": { input: 5, output: 25 },
|
|
37
|
+
// Sonnet 5 list price. Anthropic is running a $2/$10 introductory rate through
|
|
38
|
+
// 2026-08-31, but the table isn't date-aware and documents itself as list pricing —
|
|
39
|
+
// the intro discount would silently go stale on 2026-09-01. (Same as Sonnet 4.6.)
|
|
40
|
+
"claude-sonnet-5": { input: 3, output: 15 },
|
|
41
|
+
"claude-sonnet-4-6": { input: 3, output: 15 },
|
|
42
|
+
"claude-haiku-4-5": { input: 1, output: 5 },
|
|
43
|
+
// Older, still-billable models. canonicalModelId reduces dated/Bedrock ids to these keys
|
|
44
|
+
// (e.g. claude-sonnet-4-20250514 and anthropic.claude-sonnet-4-...-v1:0 -> claude-sonnet-4).
|
|
45
|
+
"claude-opus-4-5": { input: 5, output: 25 },
|
|
46
|
+
"claude-opus-4-1": { input: 15, output: 75 },
|
|
47
|
+
"claude-opus-4": { input: 15, output: 75 },
|
|
48
|
+
"claude-sonnet-4-5": { input: 3, output: 15 },
|
|
49
|
+
"claude-sonnet-4": { input: 3, output: 15 },
|
|
50
|
+
"claude-3-5-haiku": { input: 0.8, output: 4 },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Bare aliases Claude Code sometimes writes (e.g. "sonnet") resolve to the current
|
|
54
|
+
// model in that family. This is the same family→latest mapping the official tooling uses.
|
|
55
|
+
const ALIASES = {
|
|
56
|
+
opus: "claude-opus-4-8",
|
|
57
|
+
sonnet: "claude-sonnet-5",
|
|
58
|
+
haiku: "claude-haiku-4-5",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Reduce any model id to the rate-table key:
|
|
62
|
+
* - strip the [1m] suffix (same surface, same price on current models)
|
|
63
|
+
* - strip Bedrock region prefixes (us./eu./apac.) and the `anthropic.` vendor prefix
|
|
64
|
+
* - strip the Bedrock `-vN:M` version suffix and a trailing `-YYYYMMDD` snapshot date
|
|
65
|
+
* So `us.anthropic.claude-opus-4-8-20251101-v1:0` and `claude-opus-4-8[1m]` both → `claude-opus-4-8`.
|
|
66
|
+
* Bedrock thus reuses the list-price table (region surcharges are not modeled — part of the stated tolerance). */
|
|
67
|
+
export function canonicalModelId(model) {
|
|
68
|
+
let id = normalizeModel(model).base;
|
|
69
|
+
if (typeof id !== "string") return "";
|
|
70
|
+
id = id.toLowerCase();
|
|
71
|
+
id = id.replace(/^(us|eu|apac|us-gov)\./, ""); // Bedrock region prefix
|
|
72
|
+
id = id.replace(/^anthropic\./, ""); // Bedrock vendor prefix
|
|
73
|
+
id = id.replace(/-v\d+:\d+$/, ""); // Bedrock version suffix
|
|
74
|
+
id = id.replace(/-\d{8}$/, ""); // dated snapshot suffix (e.g. -20251001)
|
|
75
|
+
return id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Per-class USD-per-million rates for a model, or null when it isn't in the table. */
|
|
79
|
+
export function ratesFor(model) {
|
|
80
|
+
const id = canonicalModelId(model);
|
|
81
|
+
const base = RATES[id] || RATES[ALIASES[id]];
|
|
82
|
+
if (!base) return null;
|
|
83
|
+
return {
|
|
84
|
+
input: base.input,
|
|
85
|
+
cacheWrite: base.input * CACHE_WRITE_MULT,
|
|
86
|
+
cacheRead: base.input * CACHE_READ_MULT,
|
|
87
|
+
output: base.output,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Cost of one usage block under a model. Returns {usd, priced}: priced:false means
|
|
92
|
+
* the model isn't in the table — usd is 0 and the caller should track tokens, not dollars. */
|
|
93
|
+
export function costOfUsage(usage, model) {
|
|
94
|
+
const r = ratesFor(model);
|
|
95
|
+
if (!r) return { usd: 0, priced: false };
|
|
96
|
+
const c = usageByClass(usage);
|
|
97
|
+
const usd =
|
|
98
|
+
(c.input * r.input +
|
|
99
|
+
c.cacheCreate * r.cacheWrite +
|
|
100
|
+
c.cacheRead * r.cacheRead +
|
|
101
|
+
c.output * r.output) /
|
|
102
|
+
1e6;
|
|
103
|
+
return { usd, priced: true };
|
|
104
|
+
}
|