@animalabs/connectome-host 0.3.5 → 0.3.7
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/.env.example +6 -0
- package/README.md +5 -0
- package/bun.lock +8 -4
- package/docs/AGENT-ONBOARDING.md +6 -1
- package/package.json +3 -3
- package/scripts/import-codex-rollout.ts +288 -0
- package/src/call-ledger.ts +371 -0
- package/src/call-pricing.ts +119 -0
- package/src/index.ts +78 -27
- package/src/logging-adapter.ts +72 -9
- package/src/modules/fleet-module.ts +17 -7
- package/src/modules/mcpl-admin-module.ts +6 -0
- package/src/modules/time-module.ts +15 -9
- package/src/modules/web-ui-module.ts +25 -0
- package/src/modules/web-ui-observers.ts +16 -3
- package/src/recipe.ts +48 -3
- package/src/strategies/frontdesk-strategy.ts +10 -4
- package/src/web/protocol.ts +89 -0
- package/test/call-ledger.test.ts +91 -0
- package/test/call-pricing.test.ts +69 -0
- package/test/frontdesk-strategy.test.ts +10 -0
- package/test/import-codex-rollout.test.ts +36 -0
- package/test/logging-adapter.test.ts +58 -1
- package/test/recipe-cache-ttl.test.ts +7 -3
- package/test/recipe-provider.test.ts +36 -0
- package/test/recipe-timezone.test.ts +20 -0
- package/test/time-module.test.ts +13 -0
- package/test/web-ui-observers.test.ts +43 -0
- package/web/src/App.tsx +9 -0
- package/web/src/ObserverGate.tsx +21 -11
- package/web/src/Usage.tsx +116 -2
- package/web/src/wire.ts +5 -2
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recent provider-call ledger for the WebUI.
|
|
3
|
+
*
|
|
4
|
+
* The provider adapter feeds this tracker compact, content-free call records.
|
|
5
|
+
* It reconstructs the cache verdict operators actually need (hit, first
|
|
6
|
+
* write, expired rewrite, uncached auxiliary call) and retains a bounded
|
|
7
|
+
* window. Older process logs are replayed on startup so a host restart does
|
|
8
|
+
* not leave the dashboard blank.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { closeSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import type {
|
|
14
|
+
CallLedgerRow,
|
|
15
|
+
CallLedgerSnapshot,
|
|
16
|
+
CallLedgerVerdict,
|
|
17
|
+
} from './web/protocol.js';
|
|
18
|
+
import { ANTHROPIC_PRICING_VERSION, priceAnthropicCall } from './call-pricing.js';
|
|
19
|
+
|
|
20
|
+
export interface ProviderCallRecord {
|
|
21
|
+
timestamp: string;
|
|
22
|
+
kind: 'complete' | 'stream';
|
|
23
|
+
durationMs: number;
|
|
24
|
+
model: string;
|
|
25
|
+
messages: number;
|
|
26
|
+
inputTokens: number;
|
|
27
|
+
outputTokens: number;
|
|
28
|
+
cacheReadTokens: number;
|
|
29
|
+
cacheWriteTokens: number;
|
|
30
|
+
/** Provider-reported cache creation buckets. These remain separate because
|
|
31
|
+
* Anthropic charges different multipliers for 5m and 1h writes. */
|
|
32
|
+
cacheWrite5mTokens?: number;
|
|
33
|
+
cacheWrite1hTokens?: number;
|
|
34
|
+
/** True only when the split came from the response usage object. */
|
|
35
|
+
cacheWriteBucketsAuthoritative?: boolean;
|
|
36
|
+
inferenceGeo?: string;
|
|
37
|
+
serviceTier?: string;
|
|
38
|
+
/** Undefined for historical compact logs written before cache summaries. */
|
|
39
|
+
cacheBreakpoints?: number;
|
|
40
|
+
/** TTLs found on cache_control blocks. `default` means provider default. */
|
|
41
|
+
cacheTtls?: string[];
|
|
42
|
+
stopReason?: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CallLedgerOptions {
|
|
47
|
+
dataDir?: string;
|
|
48
|
+
defaultTtl?: '5m' | '1h';
|
|
49
|
+
maxRows?: number;
|
|
50
|
+
hydrate?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Listener = (snapshot: CallLedgerSnapshot) => void;
|
|
54
|
+
|
|
55
|
+
export class CallLedger {
|
|
56
|
+
private readonly rows: CallLedgerRow[] = [];
|
|
57
|
+
private readonly maxRows: number;
|
|
58
|
+
private readonly defaultTtl: '5m' | '1h';
|
|
59
|
+
private readonly listeners = new Set<Listener>();
|
|
60
|
+
private lastCacheActivityAt: number | null = null;
|
|
61
|
+
private sequence = 0;
|
|
62
|
+
|
|
63
|
+
constructor(opts: CallLedgerOptions = {}) {
|
|
64
|
+
this.maxRows = opts.maxRows ?? 200;
|
|
65
|
+
this.defaultTtl = opts.defaultTtl ?? '5m';
|
|
66
|
+
if (opts.hydrate !== false && opts.dataDir) this.hydrate(opts.dataDir);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
record(call: ProviderCallRecord): void {
|
|
70
|
+
this.append(call, true);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
onUpdate(listener: Listener): () => void {
|
|
74
|
+
this.listeners.add(listener);
|
|
75
|
+
return () => this.listeners.delete(listener);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
snapshot(): CallLedgerSnapshot {
|
|
79
|
+
const rows = this.rows.map((r) => ({
|
|
80
|
+
...r,
|
|
81
|
+
tokens: { ...r.tokens },
|
|
82
|
+
cache: { ...r.cache, ttls: [...r.cache.ttls] },
|
|
83
|
+
...(r.cost ? { cost: { ...r.cost, rates: { ...r.cost.rates } } } : {}),
|
|
84
|
+
}));
|
|
85
|
+
const byVerdict: Partial<Record<CallLedgerVerdict, number>> = {};
|
|
86
|
+
let input = 0;
|
|
87
|
+
let output = 0;
|
|
88
|
+
let cacheRead = 0;
|
|
89
|
+
let cacheWrite = 0;
|
|
90
|
+
let totalCost = 0;
|
|
91
|
+
let pricedCalls = 0;
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
byVerdict[row.verdict] = (byVerdict[row.verdict] ?? 0) + 1;
|
|
94
|
+
input += row.tokens.input;
|
|
95
|
+
output += row.tokens.output;
|
|
96
|
+
cacheRead += row.tokens.cacheRead;
|
|
97
|
+
cacheWrite += row.tokens.cacheWrite;
|
|
98
|
+
if (row.cost) {
|
|
99
|
+
totalCost += row.cost.total;
|
|
100
|
+
pricedCalls++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const cacheDenominator = input + cacheRead + cacheWrite;
|
|
104
|
+
return {
|
|
105
|
+
rows,
|
|
106
|
+
summary: {
|
|
107
|
+
calls: rows.length,
|
|
108
|
+
input,
|
|
109
|
+
output,
|
|
110
|
+
cacheRead,
|
|
111
|
+
cacheWrite,
|
|
112
|
+
cacheHitRatio: cacheDenominator > 0 ? cacheRead / cacheDenominator : 0,
|
|
113
|
+
cost: {
|
|
114
|
+
total: totalCost,
|
|
115
|
+
currency: 'USD',
|
|
116
|
+
pricedCalls,
|
|
117
|
+
unpricedCalls: rows.length - pricedCalls,
|
|
118
|
+
pricingVersion: ANTHROPIC_PRICING_VERSION,
|
|
119
|
+
},
|
|
120
|
+
byVerdict,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private append(call: ProviderCallRecord, notify: boolean): void {
|
|
126
|
+
const at = Date.parse(call.timestamp);
|
|
127
|
+
const ttl = effectiveTtl(call.cacheTtls, this.defaultTtl);
|
|
128
|
+
const ttlSeconds = ttl === '1h' ? 3600 : 300;
|
|
129
|
+
const hasKnownFlags = call.cacheBreakpoints !== undefined;
|
|
130
|
+
const hasCacheControls = (call.cacheBreakpoints ?? 0) > 0;
|
|
131
|
+
const cacheActivity = call.cacheReadTokens > 0 || call.cacheWriteTokens > 0;
|
|
132
|
+
const priorCacheAt = this.lastCacheActivityAt;
|
|
133
|
+
const gapSeconds = priorCacheAt !== null && Number.isFinite(at)
|
|
134
|
+
? Math.max(0, Math.round((at - priorCacheAt) / 1000))
|
|
135
|
+
: undefined;
|
|
136
|
+
|
|
137
|
+
const { verdict, cause } = classifyCall({
|
|
138
|
+
call,
|
|
139
|
+
hasKnownFlags,
|
|
140
|
+
hasCacheControls,
|
|
141
|
+
ttlSeconds,
|
|
142
|
+
gapSeconds,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const write5m = call.cacheWrite5mTokens ?? 0;
|
|
146
|
+
const write1h = call.cacheWrite1hTokens ?? 0;
|
|
147
|
+
const reportedSplit = write5m + write1h;
|
|
148
|
+
const splitMismatch = call.cacheWriteTokens > 0 && (
|
|
149
|
+
!call.cacheWriteBucketsAuthoritative || reportedSplit !== call.cacheWriteTokens
|
|
150
|
+
);
|
|
151
|
+
const cost = call.error ? undefined : priceAnthropicCall(call.model, call.timestamp, {
|
|
152
|
+
inputTokens: call.inputTokens,
|
|
153
|
+
outputTokens: call.outputTokens,
|
|
154
|
+
cacheReadTokens: call.cacheReadTokens,
|
|
155
|
+
cacheWrite5mTokens: write5m,
|
|
156
|
+
cacheWrite1hTokens: write1h,
|
|
157
|
+
unclassifiedCacheWriteTokens: splitMismatch
|
|
158
|
+
? Math.max(1, Math.abs(call.cacheWriteTokens - reportedSplit))
|
|
159
|
+
: 0,
|
|
160
|
+
inferenceGeo: call.inferenceGeo,
|
|
161
|
+
serviceTier: call.serviceTier,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const row: CallLedgerRow = {
|
|
165
|
+
id: `${call.timestamp}:${++this.sequence}`,
|
|
166
|
+
timestamp: call.timestamp,
|
|
167
|
+
kind: call.kind,
|
|
168
|
+
originEstimate: call.kind === 'stream' ? 'turn~' : 'aux~',
|
|
169
|
+
model: call.model,
|
|
170
|
+
messages: call.messages,
|
|
171
|
+
durationMs: call.durationMs,
|
|
172
|
+
tokens: {
|
|
173
|
+
input: call.inputTokens,
|
|
174
|
+
output: call.outputTokens,
|
|
175
|
+
cacheRead: call.cacheReadTokens,
|
|
176
|
+
cacheWrite: call.cacheWriteTokens,
|
|
177
|
+
},
|
|
178
|
+
...(cost ? { cost } : {}),
|
|
179
|
+
cache: {
|
|
180
|
+
breakpoints: call.cacheBreakpoints,
|
|
181
|
+
ttls: call.cacheTtls?.length ? [...new Set(call.cacheTtls)] : [],
|
|
182
|
+
effectiveTtl: ttl,
|
|
183
|
+
},
|
|
184
|
+
verdict,
|
|
185
|
+
cause,
|
|
186
|
+
...(call.stopReason ? { stopReason: call.stopReason } : {}),
|
|
187
|
+
...(call.error ? { error: call.error } : {}),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
this.rows.push(row);
|
|
191
|
+
if (this.rows.length > this.maxRows) this.rows.splice(0, this.rows.length - this.maxRows);
|
|
192
|
+
if (cacheActivity && Number.isFinite(at)) this.lastCacheActivityAt = at;
|
|
193
|
+
|
|
194
|
+
if (notify) {
|
|
195
|
+
const snapshot = this.snapshot();
|
|
196
|
+
for (const listener of this.listeners) {
|
|
197
|
+
try { listener(snapshot); } catch { /* observability must not break inference */ }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private hydrate(dataDir: string): void {
|
|
203
|
+
let files: string[];
|
|
204
|
+
try {
|
|
205
|
+
files = readdirSync(dataDir)
|
|
206
|
+
.filter((name) => /^llm-calls\..*\.jsonl$/.test(name))
|
|
207
|
+
.map((name) => join(dataDir, name))
|
|
208
|
+
.sort((a, b) => statSync(a).mtimeMs - statSync(b).mtimeMs)
|
|
209
|
+
.slice(-4);
|
|
210
|
+
} catch {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const calls: ProviderCallRecord[] = [];
|
|
215
|
+
for (const file of files) {
|
|
216
|
+
for (const line of readTailLines(file, 16 * 1024 * 1024)) {
|
|
217
|
+
try {
|
|
218
|
+
const parsed = parseLoggedCall(JSON.parse(line) as Record<string, unknown>);
|
|
219
|
+
if (parsed) calls.push(parsed);
|
|
220
|
+
} catch {
|
|
221
|
+
// Partial/legacy/oversized forensic lines are skipped individually.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
calls.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
226
|
+
for (const call of calls.slice(-this.maxRows)) this.append(call, false);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function classifyCall(opts: {
|
|
231
|
+
call: ProviderCallRecord;
|
|
232
|
+
hasKnownFlags: boolean;
|
|
233
|
+
hasCacheControls: boolean;
|
|
234
|
+
ttlSeconds: number;
|
|
235
|
+
gapSeconds?: number;
|
|
236
|
+
}): { verdict: CallLedgerVerdict; cause: string } {
|
|
237
|
+
const { call, hasKnownFlags, hasCacheControls, ttlSeconds, gapSeconds } = opts;
|
|
238
|
+
if (call.error) return { verdict: 'ERROR', cause: call.error };
|
|
239
|
+
if (hasKnownFlags && !hasCacheControls) {
|
|
240
|
+
return { verdict: 'uncached', cause: 'no cache_control flags in request' };
|
|
241
|
+
}
|
|
242
|
+
if (call.cacheReadTokens > 0 && call.cacheWriteTokens > 0) {
|
|
243
|
+
const pct = cachePercent(call);
|
|
244
|
+
return { verdict: 'hit+extend', cause: `${pct}% from cache; wrote ${formatTokens(call.cacheWriteTokens)} more` };
|
|
245
|
+
}
|
|
246
|
+
if (call.cacheReadTokens > 0) {
|
|
247
|
+
return { verdict: 'HIT', cause: `${cachePercent(call)}% from cache` };
|
|
248
|
+
}
|
|
249
|
+
if (call.cacheWriteTokens > 0) {
|
|
250
|
+
if (gapSeconds === undefined) {
|
|
251
|
+
return { verdict: 'first-write', cause: `created ${formatTokens(call.cacheWriteTokens)} cache tokens` };
|
|
252
|
+
}
|
|
253
|
+
if (gapSeconds > ttlSeconds) {
|
|
254
|
+
return { verdict: 'rewrite:expired', cause: `gap ${gapSeconds}s > ttl ${ttlSeconds}s` };
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
verdict: 'rewrite:unexplained',
|
|
258
|
+
cause: `cache miss inside ttl (${gapSeconds}s <= ${ttlSeconds}s); prefix changed or was truncated`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (!hasKnownFlags) {
|
|
262
|
+
return { verdict: 'unknown', cause: 'older log has no cache_control summary' };
|
|
263
|
+
}
|
|
264
|
+
return { verdict: 'empty', cause: 'cache flags present; provider reported no cache activity' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function cachePercent(call: ProviderCallRecord): number {
|
|
268
|
+
const total = call.inputTokens + call.cacheReadTokens + call.cacheWriteTokens;
|
|
269
|
+
return total > 0 ? Math.round(call.cacheReadTokens / total * 100) : 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function effectiveTtl(ttls: string[] | undefined, fallback: '5m' | '1h'): '5m' | '1h' {
|
|
273
|
+
if (ttls?.includes('1h')) return '1h';
|
|
274
|
+
if (ttls?.includes('5m') || ttls?.includes('default')) return '5m';
|
|
275
|
+
return fallback;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function formatTokens(n: number): string {
|
|
279
|
+
if (n < 1000) return String(n);
|
|
280
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
281
|
+
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseLoggedCall(record: Record<string, unknown>): ProviderCallRecord | null {
|
|
285
|
+
const kind = record.kind;
|
|
286
|
+
const timestamp = record.timestamp;
|
|
287
|
+
const summary = record.requestSummary as Record<string, unknown> | undefined;
|
|
288
|
+
if ((kind !== 'complete' && kind !== 'stream') || typeof timestamp !== 'string' || !summary) return null;
|
|
289
|
+
const rawResponse = record.rawResponse as Record<string, unknown> | null | undefined;
|
|
290
|
+
const usage = rawResponse?.usage as Record<string, unknown> | undefined;
|
|
291
|
+
const cacheCreation = usage?.cache_creation as Record<string, unknown> | undefined;
|
|
292
|
+
const rawRequest = record.rawRequest;
|
|
293
|
+
const summarized = rawRequest ? summarizeCacheControls(rawRequest) : undefined;
|
|
294
|
+
const error = record.error as Record<string, unknown> | string | undefined;
|
|
295
|
+
return {
|
|
296
|
+
timestamp,
|
|
297
|
+
kind,
|
|
298
|
+
durationMs: number(record.durationMs),
|
|
299
|
+
model: string(summary.model),
|
|
300
|
+
messages: number(summary.messages),
|
|
301
|
+
inputTokens: number(usage?.input_tokens),
|
|
302
|
+
outputTokens: number(usage?.output_tokens),
|
|
303
|
+
cacheReadTokens: number(usage?.cache_read_input_tokens),
|
|
304
|
+
cacheWriteTokens: number(usage?.cache_creation_input_tokens),
|
|
305
|
+
cacheWrite5mTokens: optionalNumber(cacheCreation?.ephemeral_5m_input_tokens),
|
|
306
|
+
cacheWrite1hTokens: optionalNumber(cacheCreation?.ephemeral_1h_input_tokens),
|
|
307
|
+
cacheWriteBucketsAuthoritative: cacheCreation !== undefined,
|
|
308
|
+
inferenceGeo: optionalString(usage?.inference_geo) ?? optionalString(rawResponse?.inference_geo),
|
|
309
|
+
serviceTier: optionalString(usage?.service_tier) ?? optionalString(rawResponse?.service_tier),
|
|
310
|
+
cacheBreakpoints: optionalNumber(summary.cacheBreakpoints) ?? summarized?.count,
|
|
311
|
+
cacheTtls: stringArray(summary.cacheTtls) ?? summarized?.ttls,
|
|
312
|
+
stopReason: typeof rawResponse?.stop_reason === 'string' ? rawResponse.stop_reason : undefined,
|
|
313
|
+
error: typeof error === 'string'
|
|
314
|
+
? error
|
|
315
|
+
: typeof error?.message === 'string' ? error.message : undefined,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Content-free cache_control inventory from an actual provider payload. */
|
|
320
|
+
export function summarizeCacheControls(raw: unknown): { count: number; ttls: string[] } {
|
|
321
|
+
let count = 0;
|
|
322
|
+
const ttls: string[] = [];
|
|
323
|
+
const visit = (value: unknown): void => {
|
|
324
|
+
if (!value || typeof value !== 'object') return;
|
|
325
|
+
if (Array.isArray(value)) {
|
|
326
|
+
for (const item of value) visit(item);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const obj = value as Record<string, unknown>;
|
|
330
|
+
if (obj.cache_control && typeof obj.cache_control === 'object') {
|
|
331
|
+
count++;
|
|
332
|
+
const ttl = (obj.cache_control as Record<string, unknown>).ttl;
|
|
333
|
+
ttls.push(typeof ttl === 'string' ? ttl : 'default');
|
|
334
|
+
}
|
|
335
|
+
for (const [key, child] of Object.entries(obj)) {
|
|
336
|
+
if (key !== 'cache_control') visit(child);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
visit(raw);
|
|
340
|
+
return { count, ttls };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function readTailLines(path: string, maxBytes: number): string[] {
|
|
344
|
+
let fd: number | undefined;
|
|
345
|
+
try {
|
|
346
|
+
const size = statSync(path).size;
|
|
347
|
+
const start = Math.max(0, size - maxBytes);
|
|
348
|
+
const length = size - start;
|
|
349
|
+
if (length <= 0) return [];
|
|
350
|
+
fd = openSync(path, 'r');
|
|
351
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
352
|
+
readSync(fd, buffer, 0, length, start);
|
|
353
|
+
let text = buffer.toString('utf8');
|
|
354
|
+
if (start > 0) {
|
|
355
|
+
const firstNewline = text.indexOf('\n');
|
|
356
|
+
text = firstNewline >= 0 ? text.slice(firstNewline + 1) : '';
|
|
357
|
+
}
|
|
358
|
+
return text.split('\n').filter(Boolean);
|
|
359
|
+
} catch {
|
|
360
|
+
return [];
|
|
361
|
+
} finally {
|
|
362
|
+
if (fd !== undefined) closeSync(fd);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const number = (v: unknown): number => typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
367
|
+
const string = (v: unknown): string => typeof v === 'string' ? v : '';
|
|
368
|
+
const optionalNumber = (v: unknown): number | undefined => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
369
|
+
const optionalString = (v: unknown): string | undefined => typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
370
|
+
const stringArray = (v: unknown): string[] | undefined =>
|
|
371
|
+
Array.isArray(v) && v.every((x) => typeof x === 'string') ? v as string[] : undefined;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-call pricing used by the operator ledger.
|
|
3
|
+
*
|
|
4
|
+
* Rates are USD per million tokens and are applied to the provider's
|
|
5
|
+
* authoritative usage buckets. Cache creation MUST remain split by TTL:
|
|
6
|
+
* Anthropic bills 5m writes at 1.25x input and 1h writes at 2x input.
|
|
7
|
+
*
|
|
8
|
+
* Source: https://platform.claude.com/docs/en/about-claude/pricing
|
|
9
|
+
* Snapshot: 2026-07-13. Keep the version string/date auditable; silently
|
|
10
|
+
* changing historical prices would make old JSONL replay disagree with bills.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CallCostBreakdown } from './web/protocol.js';
|
|
14
|
+
|
|
15
|
+
export const ANTHROPIC_PRICING_VERSION = 'anthropic-public-2026-07-13';
|
|
16
|
+
|
|
17
|
+
interface BaseRate {
|
|
18
|
+
inputPerMillion: number;
|
|
19
|
+
outputPerMillion: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PriceableCallUsage {
|
|
23
|
+
inputTokens: number;
|
|
24
|
+
outputTokens: number;
|
|
25
|
+
cacheReadTokens: number;
|
|
26
|
+
cacheWrite5mTokens: number;
|
|
27
|
+
cacheWrite1hTokens: number;
|
|
28
|
+
/** Non-zero means the provider reported creation tokens we could not place
|
|
29
|
+
* in an authoritative TTL bucket. Such a call is deliberately unpriced. */
|
|
30
|
+
unclassifiedCacheWriteTokens: number;
|
|
31
|
+
inferenceGeo?: string;
|
|
32
|
+
serviceTier?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function priceAnthropicCall(
|
|
36
|
+
model: string,
|
|
37
|
+
timestamp: string,
|
|
38
|
+
usage: PriceableCallUsage,
|
|
39
|
+
): CallCostBreakdown | undefined {
|
|
40
|
+
const rate = anthropicBaseRate(model, timestamp);
|
|
41
|
+
if (!rate || usage.unclassifiedCacheWriteTokens > 0) return undefined;
|
|
42
|
+
|
|
43
|
+
// Public list pricing covers standard service. Priority Tier is contract
|
|
44
|
+
// priced; returning no figure is safer than presenting a plausible lie.
|
|
45
|
+
const tier = usage.serviceTier?.toLowerCase();
|
|
46
|
+
if (tier && tier !== 'standard') return undefined;
|
|
47
|
+
|
|
48
|
+
const geo = usage.inferenceGeo?.toLowerCase();
|
|
49
|
+
const geoMultiplier = !geo || geo === 'global'
|
|
50
|
+
? 1
|
|
51
|
+
: (geo === 'us' || geo === 'us-only' || geo === 'us_only') ? 1.1 : undefined;
|
|
52
|
+
if (geoMultiplier === undefined) return undefined;
|
|
53
|
+
|
|
54
|
+
const input = usage.inputTokens * rate.inputPerMillion / 1_000_000 * geoMultiplier;
|
|
55
|
+
const cacheWrite5m = usage.cacheWrite5mTokens * rate.inputPerMillion * 1.25 / 1_000_000 * geoMultiplier;
|
|
56
|
+
const cacheWrite1h = usage.cacheWrite1hTokens * rate.inputPerMillion * 2 / 1_000_000 * geoMultiplier;
|
|
57
|
+
const cacheRead = usage.cacheReadTokens * rate.inputPerMillion * 0.1 / 1_000_000 * geoMultiplier;
|
|
58
|
+
const output = usage.outputTokens * rate.outputPerMillion / 1_000_000 * geoMultiplier;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
input,
|
|
62
|
+
cacheWrite5m,
|
|
63
|
+
cacheWrite1h,
|
|
64
|
+
cacheRead,
|
|
65
|
+
output,
|
|
66
|
+
total: input + cacheWrite5m + cacheWrite1h + cacheRead + output,
|
|
67
|
+
currency: 'USD',
|
|
68
|
+
grade: 'billing',
|
|
69
|
+
pricingVersion: ANTHROPIC_PRICING_VERSION,
|
|
70
|
+
rates: {
|
|
71
|
+
inputPerMillion: rate.inputPerMillion * geoMultiplier,
|
|
72
|
+
outputPerMillion: rate.outputPerMillion * geoMultiplier,
|
|
73
|
+
cacheWrite5mPerMillion: rate.inputPerMillion * 1.25 * geoMultiplier,
|
|
74
|
+
cacheWrite1hPerMillion: rate.inputPerMillion * 2 * geoMultiplier,
|
|
75
|
+
cacheReadPerMillion: rate.inputPerMillion * 0.1 * geoMultiplier,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function anthropicBaseRate(model: string, timestamp: string): BaseRate | undefined {
|
|
81
|
+
// Fable 5 and Mythos 5 share pricing.
|
|
82
|
+
if (starts(model, 'claude-fable-5', 'claude-mythos-5')) return rate(10, 50);
|
|
83
|
+
|
|
84
|
+
if (starts(model,
|
|
85
|
+
'claude-opus-4-8',
|
|
86
|
+
'claude-opus-4-7',
|
|
87
|
+
'claude-opus-4-6',
|
|
88
|
+
'claude-opus-4-5',
|
|
89
|
+
)) return rate(5, 25);
|
|
90
|
+
|
|
91
|
+
// Sonnet 5 launch pricing is promotional through 2026-08-31 inclusive.
|
|
92
|
+
if (starts(model, 'claude-sonnet-5')) {
|
|
93
|
+
const at = Date.parse(timestamp);
|
|
94
|
+
const promoEnd = Date.parse('2026-09-01T00:00:00Z');
|
|
95
|
+
return Number.isFinite(at) && at < promoEnd ? rate(2, 10) : rate(3, 15);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (starts(model,
|
|
99
|
+
'claude-sonnet-4-6',
|
|
100
|
+
'claude-sonnet-4-5',
|
|
101
|
+
'claude-sonnet-4-',
|
|
102
|
+
'claude-3-7-sonnet',
|
|
103
|
+
'claude-3-5-sonnet',
|
|
104
|
+
)) return rate(3, 15);
|
|
105
|
+
|
|
106
|
+
if (starts(model, 'claude-haiku-4-5')) return rate(1, 5);
|
|
107
|
+
if (starts(model, 'claude-3-5-haiku')) return rate(0.8, 4);
|
|
108
|
+
|
|
109
|
+
if (starts(model, 'claude-opus-4-1', 'claude-opus-4-')) return rate(15, 75);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function starts(model: string, ...prefixes: string[]): boolean {
|
|
114
|
+
return prefixes.some((prefix) => model.startsWith(prefix));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rate(inputPerMillion: number, outputPerMillion: number): BaseRate {
|
|
118
|
+
return { inputPerMillion, outputPerMillion };
|
|
119
|
+
}
|