@animalabs/connectome-host 0.3.1 → 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/.github/workflows/publish.yml +9 -4
- package/README.md +5 -0
- package/bun.lock +8 -4
- package/docs/AGENT-ONBOARDING.md +6 -1
- package/package.json +5 -5
- package/scripts/import-codex-rollout.ts +288 -0
- package/src/call-ledger.ts +371 -0
- package/src/call-pricing.ts +119 -0
- package/src/commands.ts +57 -3
- package/src/index.ts +87 -27
- package/src/logging-adapter.ts +72 -9
- package/src/modules/fleet-module.ts +21 -9
- package/src/modules/mcpl-admin-module.ts +6 -0
- package/src/modules/observers-module.ts +180 -0
- package/src/modules/time-module.ts +15 -9
- package/src/modules/web-ui-module.ts +415 -16
- package/src/modules/web-ui-observers.ts +322 -0
- package/src/recipe.ts +48 -3
- package/src/strategies/frontdesk-strategy.ts +10 -4
- package/src/web/protocol.ts +141 -0
- package/test/call-ledger.test.ts +91 -0
- package/test/call-pricing.test.ts +69 -0
- package/test/fleet-subscribe-union-e2e.test.ts +5 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +2 -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-context-coverage.test.ts +61 -0
- package/test/web-ui-observers.test.ts +344 -0
- package/web/package-lock.json +2446 -0
- package/web/src/App.tsx +24 -0
- package/web/src/Context.tsx +207 -7
- package/web/src/ObserverGate.tsx +78 -0
- package/web/src/Usage.tsx +116 -2
- package/web/src/observer-identity.ts +110 -0
- package/web/src/wire.ts +60 -1
|
@@ -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
|
+
}
|
package/src/commands.ts
CHANGED
|
@@ -197,7 +197,13 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
|
197
197
|
return handleCheckout(framework, args[0]);
|
|
198
198
|
|
|
199
199
|
case 'history':
|
|
200
|
-
return handleHistory(framework);
|
|
200
|
+
return handleHistory(framework, args[0]);
|
|
201
|
+
|
|
202
|
+
case 'branchto':
|
|
203
|
+
return handleBranchTo(app, args[0]);
|
|
204
|
+
|
|
205
|
+
case 'find':
|
|
206
|
+
return handleFind(framework, args.join(' '));
|
|
201
207
|
|
|
202
208
|
case 'mcp':
|
|
203
209
|
return handleMcp(args);
|
|
@@ -793,7 +799,55 @@ function handleCheckout(framework: AgentFramework, name?: string): CommandResult
|
|
|
793
799
|
};
|
|
794
800
|
}
|
|
795
801
|
|
|
796
|
-
function
|
|
802
|
+
function handleFind(framework: AgentFramework, needle: string): CommandResult {
|
|
803
|
+
const cm = getAgentCM(framework);
|
|
804
|
+
if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
|
|
805
|
+
if (!needle) return { lines: [{ text: 'Usage: /find <text>', style: 'system' }] };
|
|
806
|
+
const { messages } = cm.queryMessages({});
|
|
807
|
+
const lines: Line[] = [{ text: `--- Find "${needle}" in ${messages.length} msgs ---`, style: 'system' }];
|
|
808
|
+
const needleLc = needle.toLowerCase();
|
|
809
|
+
for (const msg of messages) {
|
|
810
|
+
// Search text blocks plus stringified tool i/o (case-insensitive), so ids
|
|
811
|
+
// buried in tool_use inputs / tool_result payloads are findable too.
|
|
812
|
+
const full = msg.content
|
|
813
|
+
.map((b) => b.type === 'text' ? b.text : JSON.stringify(b))
|
|
814
|
+
.join(' ');
|
|
815
|
+
const at = full.toLowerCase().indexOf(needleLc);
|
|
816
|
+
if (at >= 0) {
|
|
817
|
+
const snip = full.slice(Math.max(0, at - 30), at + needle.length + 45).replace(/\s+/g, ' ');
|
|
818
|
+
lines.push({ text: ` [${msg.id}] ${msg.participant}: ...${snip}...`, style: 'system' });
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (lines.length === 1) lines.push({ text: ' (no matches)', style: 'system' });
|
|
822
|
+
return { lines };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function handleBranchTo(app: AppContext, messageId: string | undefined): CommandResult {
|
|
826
|
+
const cm = getAgentCM(app.framework);
|
|
827
|
+
if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
|
|
828
|
+
if (!messageId) return { lines: [{ text: 'Usage: /branchto <messageId>', style: 'system' }] };
|
|
829
|
+
const { messages } = cm.queryMessages({});
|
|
830
|
+
const target = messages.find(m => String(m.id) === String(messageId));
|
|
831
|
+
if (!target) return { lines: [{ text: `No message with id ${messageId} on current branch.`, style: 'system' }] };
|
|
832
|
+
const newBranchName = `rollback-at-${messageId}-${Date.now()}`;
|
|
833
|
+
let createdBranchName: string;
|
|
834
|
+
try {
|
|
835
|
+
createdBranchName = cm.branchAt(String(messageId), newBranchName);
|
|
836
|
+
} catch (err) {
|
|
837
|
+
return { lines: [{ text: `branchto failed (branchAt): ${err}`, style: 'system' }] };
|
|
838
|
+
}
|
|
839
|
+
const asyncWork = Promise.resolve(cm.switchBranch(createdBranchName))
|
|
840
|
+
.then(() => ({
|
|
841
|
+
lines: [{ text: `Branched at [${messageId}] -> "${createdBranchName}" and switched. Everything after [${messageId}] is dropped on this branch; old branch preserved.`, style: 'system' as const }],
|
|
842
|
+
branchChanged: true,
|
|
843
|
+
}))
|
|
844
|
+
.catch((err: unknown) => ({
|
|
845
|
+
lines: [{ text: `branchto failed (switchBranch): ${err}`, style: 'system' as const }],
|
|
846
|
+
}));
|
|
847
|
+
return { lines: [{ text: `Branching at [${messageId}] -> "${createdBranchName}"...`, style: 'system' }], asyncWork };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function handleHistory(framework: AgentFramework, countArg?: string): CommandResult {
|
|
797
851
|
const cm = getAgentCM(framework);
|
|
798
852
|
if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
|
|
799
853
|
|
|
@@ -801,7 +855,7 @@ function handleHistory(framework: AgentFramework): CommandResult {
|
|
|
801
855
|
const lines: Line[] = [{ text: `--- History (${messages.length} messages) ---`, style: 'system' }];
|
|
802
856
|
|
|
803
857
|
// Show the last 20 messages in summary form
|
|
804
|
-
const recent = messages.slice(-20);
|
|
858
|
+
const recent = messages.slice(-(countArg && Number(countArg) > 0 ? Number(countArg) : 20));
|
|
805
859
|
for (const msg of recent) {
|
|
806
860
|
const text = msg.content
|
|
807
861
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|