@mbeato/contextscope 0.1.3 → 0.1.5

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.
Files changed (55) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  4. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  19. package/.next/standalone/.next/server/app/context/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/items/page.js +3 -2
  21. package/.next/standalone/.next/server/app/items/page.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/items/page_client-reference-manifest.js +1 -1
  23. package/.next/standalone/.next/server/app/page.js +3 -2
  24. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  26. package/.next/standalone/.next/server/app/sessions/page.js +3 -2
  27. package/.next/standalone/.next/server/app/sessions/page.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  29. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08l_yt3._.js +3 -0
  30. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0j40w4k._.js +3 -0
  31. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lbywin._.js +3 -0
  32. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p2sxww._.js +3 -0
  33. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0wj0exw._.js +3 -0
  34. package/.next/standalone/.next/server/chunks/ssr/_0j0avc7._.js +1 -1
  35. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  36. package/.next/standalone/.next/server/pages/404.html +1 -1
  37. package/.next/standalone/.next/server/pages/500.html +1 -1
  38. package/.next/standalone/app/page.tsx +8 -2
  39. package/.next/standalone/app/sessions/page.tsx +10 -2
  40. package/.next/standalone/lib/model-prices.json +147 -0
  41. package/.next/standalone/lib/pricing.ts +80 -0
  42. package/.next/standalone/lib/sessions.ts +33 -6
  43. package/.next/standalone/lib/transcripts.ts +218 -54
  44. package/.next/standalone/package.json +2 -1
  45. package/.next/standalone/scripts/refresh-prices.mjs +58 -0
  46. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  47. package/.next/static/chunks/118uk9v3812u1.css +1 -0
  48. package/package.json +2 -1
  49. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08jw6yr._.js +0 -3
  50. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0duau-w._.js +0 -3
  51. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ubqc9u._.js +0 -3
  52. package/.next/static/chunks/13525lf.7uo9s.css +0 -1
  53. /package/.next/static/{BT6H4g8OlEYi9snYU0PI- → I-W1iV4XdkTRyjis8Ros1}/_buildManifest.js +0 -0
  54. /package/.next/static/{BT6H4g8OlEYi9snYU0PI- → I-W1iV4XdkTRyjis8Ros1}/_clientMiddlewareManifest.js +0 -0
  55. /package/.next/static/{BT6H4g8OlEYi9snYU0PI- → I-W1iV4XdkTRyjis8Ros1}/_ssgManifest.js +0 -0
@@ -6,7 +6,10 @@
6
6
  * - invocations (Skill / Agent tool_use events) -> consumed by lib/usage.ts
7
7
  * - session stats (turn count, token usage) -> consumed by lib/sessions.ts
8
8
  *
9
- * Subsequent page loads reuse cached results when mtime is unchanged.
9
+ * CC creates a new JSONL when you `--continue` a session, which replays the
10
+ * prior turns. To match ccusage's totals we dedup messages by `msg.id:requestId`
11
+ * across all files at aggregate time. Per-file records are cached unchanged
12
+ * (preserves mtime cache); dedupAndSum runs on every getAllTranscripts call.
10
13
  */
11
14
  import { readdir, stat } from "node:fs/promises";
12
15
  import { createReadStream } from "node:fs";
@@ -18,6 +21,28 @@ const PROJECTS_DIR = join(homedir(), ".claude", "projects");
18
21
 
19
22
  export type Invocation = { kind: "skill" | "agent"; name: string; ts: number };
20
23
 
24
+ export type ModelUsage = {
25
+ inputTokens: number;
26
+ cacheReadTokens: number;
27
+ cacheCreation5mTokens: number;
28
+ cacheCreation1hTokens: number;
29
+ outputTokens: number;
30
+ };
31
+
32
+ export type UsageRecord = {
33
+ // `${message.id}:${requestId}` — same key ccusage uses. Empty string if msg
34
+ // lacked an id (treat as un-dedupable; count it).
35
+ dedupKey: string;
36
+ model: string;
37
+ ts: number;
38
+ input: number;
39
+ cacheRead: number;
40
+ cacheCreation5m: number;
41
+ cacheCreation1h: number;
42
+ output: number;
43
+ isSidechain: boolean;
44
+ };
45
+
21
46
  export type TranscriptResult = {
22
47
  filePath: string;
23
48
  mtimeMs: number;
@@ -28,20 +53,22 @@ export type TranscriptResult = {
28
53
  endTime: number;
29
54
  turnCount: number;
30
55
  models: string[];
56
+ // Aggregate totals — sums after dedup (when produced via getAllTranscripts).
57
+ // On raw parseFile output these are pre-dedup; getAllTranscripts rebuilds them.
31
58
  inputTokens: number;
32
59
  cacheReadTokens: number;
33
- cacheCreationTokens: number;
60
+ cacheCreationTokens: number; // total: 5m + 1h
34
61
  outputTokens: number;
62
+ byModel: Record<string, ModelUsage>;
63
+ // Per-message records — kept on the cached result so dedup at aggregate time
64
+ // is exact across resumed sessions.
65
+ usageRecords: UsageRecord[];
35
66
  invocations: Invocation[];
36
- toolCalls: Record<string, number>; // tool name -> count
37
- toolErrors: number; // total tool_result with is_error: true
38
- sidechainTurns: number; // turns with isSidechain: true (subagent context)
67
+ toolCalls: Record<string, number>;
68
+ toolErrors: number;
69
+ sidechainTurns: number;
39
70
  };
40
71
 
41
- // Module-level cache: persists across server-component renders within the same
42
- // Next.js dev/prod process. Keyed by filePath; invalidated on mtime change.
43
- // Capped via simple FIFO eviction (Map preserves insertion order) so a
44
- // long-running process scanning thousands of transcripts can't grow unbounded.
45
72
  const cache = new Map<string, TranscriptResult>();
46
73
  const MAX_CACHE_ENTRIES = 5000;
47
74
 
@@ -51,8 +78,18 @@ function inferProjectPath(dirName: string): string {
51
78
 
52
79
  async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptResult> {
53
80
  const parts = filePath.split("/");
54
- const sessionId = parts[parts.length - 1].replace(/\.jsonl$/, "");
55
- const project = parts[parts.length - 2] ?? "";
81
+ // Two shapes:
82
+ // <project>/<session-uuid>.jsonl → main session file
83
+ // <project>/<session-uuid>/subagents/agent-<id>.jsonl → subagent file
84
+ // For subagents we want the parent session UUID + parent project, so the
85
+ // tokens roll up to the same Session as the main file.
86
+ const isSubagent = parts[parts.length - 2] === "subagents";
87
+ const sessionId = isSubagent
88
+ ? (parts[parts.length - 3] ?? "")
89
+ : parts[parts.length - 1].replace(/\.jsonl$/, "");
90
+ const project = isSubagent
91
+ ? (parts[parts.length - 4] ?? "")
92
+ : (parts[parts.length - 2] ?? "");
56
93
  const result: TranscriptResult = {
57
94
  filePath,
58
95
  mtimeMs,
@@ -67,12 +104,13 @@ async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptR
67
104
  cacheReadTokens: 0,
68
105
  cacheCreationTokens: 0,
69
106
  outputTokens: 0,
107
+ byModel: {},
108
+ usageRecords: [],
70
109
  invocations: [],
71
110
  toolCalls: {},
72
111
  toolErrors: 0,
73
112
  sidechainTurns: 0,
74
113
  };
75
- const modelSet = new Set<string>();
76
114
 
77
115
  return new Promise((resolve) => {
78
116
  const rl = createInterface({
@@ -81,7 +119,6 @@ async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptR
81
119
  });
82
120
  rl.on("line", (line) => {
83
121
  if (!line || line[0] !== "{") return;
84
- // Prefilter: usage events, tool_use events, and tool_result events (for errors).
85
122
  const hasUsage = line.includes('"usage"');
86
123
  const hasToolUse = line.includes('"tool_use"');
87
124
  const hasToolResult = line.includes('"tool_result"');
@@ -94,29 +131,49 @@ async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptR
94
131
  return;
95
132
  }
96
133
  const msg = rec.message as
97
- | { model?: string; usage?: Record<string, unknown>; content?: unknown }
134
+ | { id?: string; model?: string; usage?: Record<string, unknown>; content?: unknown }
98
135
  | undefined;
99
136
  const tsRaw = rec.timestamp;
100
137
  const tsMs = typeof tsRaw === "string" ? Date.parse(tsRaw) : NaN;
101
138
  const isSidechain = rec.isSidechain === true;
102
139
 
103
- // Usage aggregation (assistant messages)
104
140
  const usage = msg?.usage;
105
141
  if (usage) {
106
- result.inputTokens += Number(usage.input_tokens) || 0;
107
- result.cacheReadTokens += Number(usage.cache_read_input_tokens) || 0;
108
- result.cacheCreationTokens += Number(usage.cache_creation_input_tokens) || 0;
109
- result.outputTokens += Number(usage.output_tokens) || 0;
110
- result.turnCount += 1;
111
- if (isSidechain) result.sidechainTurns += 1;
112
- if (msg?.model) modelSet.add(msg.model);
113
- if (Number.isFinite(tsMs)) {
114
- if (!result.startTime || tsMs < result.startTime) result.startTime = tsMs;
115
- if (tsMs > result.endTime) result.endTime = tsMs;
142
+ const msgId = typeof msg?.id === "string" ? msg.id : "";
143
+ const requestId = typeof rec.requestId === "string" ? rec.requestId : "";
144
+ const dedupKey = msgId ? `${msgId}:${requestId}` : "";
145
+ // cache_creation may have an ephemeral_{5m,1h}_input_tokens breakdown
146
+ // (priced separately at $6.25/M vs $10/M for opus-4-7). Older transcripts
147
+ // lack the sub-object — fall back to treating the total as 5min.
148
+ const ccTotal = Number(usage.cache_creation_input_tokens) || 0;
149
+ const ccBreakdown = (usage.cache_creation ?? null) as
150
+ | { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
151
+ | null;
152
+ let cc5m = 0;
153
+ let cc1h = 0;
154
+ if (ccBreakdown && typeof ccBreakdown === "object") {
155
+ cc5m = Number(ccBreakdown.ephemeral_5m_input_tokens) || 0;
156
+ cc1h = Number(ccBreakdown.ephemeral_1h_input_tokens) || 0;
157
+ // If breakdown is present but sum doesn't match the parent total,
158
+ // trust the parent and attribute the diff to 5min (conservative).
159
+ const diff = ccTotal - (cc5m + cc1h);
160
+ if (diff > 0) cc5m += diff;
161
+ } else {
162
+ cc5m = ccTotal;
116
163
  }
164
+ result.usageRecords.push({
165
+ dedupKey,
166
+ model: msg?.model || "<synthetic>",
167
+ ts: Number.isFinite(tsMs) ? tsMs : 0,
168
+ input: Number(usage.input_tokens) || 0,
169
+ cacheRead: Number(usage.cache_read_input_tokens) || 0,
170
+ cacheCreation5m: cc5m,
171
+ cacheCreation1h: cc1h,
172
+ output: Number(usage.output_tokens) || 0,
173
+ isSidechain,
174
+ });
117
175
  }
118
176
 
119
- // Content scan: tool_use (counts + invocations) and tool_result (errors)
120
177
  if (Array.isArray(msg?.content)) {
121
178
  for (const c of msg.content) {
122
179
  if (!c || typeof c !== "object") continue;
@@ -146,14 +203,8 @@ async function parseFile(filePath: string, mtimeMs: number): Promise<TranscriptR
146
203
  }
147
204
  }
148
205
  });
149
- rl.on("close", () => {
150
- result.models = [...modelSet];
151
- resolve(result);
152
- });
153
- rl.on("error", () => {
154
- result.models = [...modelSet];
155
- resolve(result);
156
- });
206
+ rl.on("close", () => resolve(result));
207
+ rl.on("error", () => resolve(result));
157
208
  });
158
209
  }
159
210
 
@@ -161,7 +212,7 @@ async function getFileResult(filePath: string, mtimeMs: number): Promise<Transcr
161
212
  const cached = cache.get(filePath);
162
213
  if (cached && cached.mtimeMs === mtimeMs) return cached;
163
214
  const fresh = await parseFile(filePath, mtimeMs);
164
- if (cached) cache.delete(filePath); // re-insert at tail so FIFO eviction stays correct
215
+ if (cached) cache.delete(filePath);
165
216
  cache.set(filePath, fresh);
166
217
  while (cache.size > MAX_CACHE_ENTRIES) {
167
218
  const oldest = cache.keys().next().value;
@@ -185,7 +236,135 @@ async function pMapLimit<T, R>(items: T[], limit: number, fn: (x: T) => Promise<
185
236
  return out;
186
237
  }
187
238
 
188
- /** Scan all transcripts modified in the last N days. Cached per (filePath, mtime). */
239
+ /**
240
+ * Walk raw per-file results oldest first, dedup messages globally by
241
+ * (msg.id, requestId), and merge multiple files belonging to the same logical
242
+ * session (main + subagents/*) into one TranscriptResult per session.
243
+ *
244
+ * Important: do NOT mutate the cached `raws` objects — they're shared across
245
+ * calls.
246
+ */
247
+ function dedupAndSum(raws: TranscriptResult[]): TranscriptResult[] {
248
+ const sorted = [...raws].sort((a, b) => a.mtimeMs - b.mtimeMs);
249
+ const seen = new Set<string>();
250
+ const merged = new Map<string, TranscriptResult>();
251
+
252
+ for (const r of sorted) {
253
+ const key = `${r.project}\x00${r.sessionId}`;
254
+ let t = merged.get(key);
255
+ if (!t) {
256
+ t = {
257
+ filePath: r.filePath,
258
+ mtimeMs: r.mtimeMs,
259
+ sessionId: r.sessionId,
260
+ project: r.project,
261
+ projectPath: r.projectPath,
262
+ startTime: 0,
263
+ endTime: 0,
264
+ turnCount: 0,
265
+ models: [],
266
+ inputTokens: 0,
267
+ cacheReadTokens: 0,
268
+ cacheCreationTokens: 0,
269
+ outputTokens: 0,
270
+ byModel: {},
271
+ usageRecords: [],
272
+ invocations: [],
273
+ toolCalls: {},
274
+ toolErrors: 0,
275
+ sidechainTurns: 0,
276
+ };
277
+ merged.set(key, t);
278
+ } else {
279
+ // Prefer the main session file's path/mtime as the canonical reference;
280
+ // a subagent file landed first only if no main yet, which is rare.
281
+ const incomingIsMain = !r.filePath.includes("/subagents/");
282
+ if (incomingIsMain) {
283
+ t.filePath = r.filePath;
284
+ t.mtimeMs = Math.max(t.mtimeMs, r.mtimeMs);
285
+ }
286
+ }
287
+
288
+ // Accumulate ancillary fields
289
+ for (const inv of r.invocations) t.invocations.push(inv);
290
+ for (const [name, n] of Object.entries(r.toolCalls)) {
291
+ t.toolCalls[name] = (t.toolCalls[name] ?? 0) + n;
292
+ }
293
+ t.toolErrors += r.toolErrors;
294
+
295
+ const modelSet = new Set<string>(t.models);
296
+ for (const u of r.usageRecords) {
297
+ if (u.dedupKey) {
298
+ if (seen.has(u.dedupKey)) continue;
299
+ seen.add(u.dedupKey);
300
+ }
301
+ t.inputTokens += u.input;
302
+ t.cacheReadTokens += u.cacheRead;
303
+ t.cacheCreationTokens += u.cacheCreation5m + u.cacheCreation1h;
304
+ t.outputTokens += u.output;
305
+ t.turnCount += 1;
306
+ if (u.isSidechain) t.sidechainTurns += 1;
307
+ modelSet.add(u.model);
308
+ const bm = t.byModel[u.model] ?? {
309
+ inputTokens: 0,
310
+ cacheReadTokens: 0,
311
+ cacheCreation5mTokens: 0,
312
+ cacheCreation1hTokens: 0,
313
+ outputTokens: 0,
314
+ };
315
+ bm.inputTokens += u.input;
316
+ bm.cacheReadTokens += u.cacheRead;
317
+ bm.cacheCreation5mTokens += u.cacheCreation5m;
318
+ bm.cacheCreation1hTokens += u.cacheCreation1h;
319
+ bm.outputTokens += u.output;
320
+ t.byModel[u.model] = bm;
321
+ if (u.ts > 0) {
322
+ if (!t.startTime || u.ts < t.startTime) t.startTime = u.ts;
323
+ if (u.ts > t.endTime) t.endTime = u.ts;
324
+ }
325
+ }
326
+ t.models = [...modelSet];
327
+ }
328
+
329
+ return [...merged.values()];
330
+ }
331
+
332
+ async function collectJsonlFiles(
333
+ dir: string,
334
+ cutoff: number,
335
+ out: { filePath: string; mtimeMs: number }[],
336
+ depth: number = 0
337
+ ): Promise<void> {
338
+ if (depth > 3) return; // <project>/<session>/subagents/<file> is the deepest expected shape
339
+ let entries;
340
+ try {
341
+ entries = await readdir(dir, { withFileTypes: true });
342
+ } catch {
343
+ return;
344
+ }
345
+ for (const e of entries) {
346
+ const fp = join(dir, e.name);
347
+ if (e.isDirectory()) {
348
+ await collectJsonlFiles(fp, cutoff, out, depth + 1);
349
+ continue;
350
+ }
351
+ if (!e.isFile() || !e.name.endsWith(".jsonl")) continue;
352
+ try {
353
+ const st = await stat(fp);
354
+ if (st.mtimeMs >= cutoff) out.push({ filePath: fp, mtimeMs: st.mtimeMs });
355
+ } catch {
356
+ // skip
357
+ }
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Scan all transcripts modified in the last N days. Scans both main session
363
+ * files (<project>/<session>.jsonl) and subagent files
364
+ * (<project>/<session>/subagents/agent-*.jsonl), then attributes subagent
365
+ * tokens to their parent session via shared sessionId. Deduped globally by
366
+ * (msg.id, requestId).
367
+ */
189
368
  export async function getAllTranscripts(daysBack: number = 30): Promise<TranscriptResult[]> {
190
369
  const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1000;
191
370
  let projDirs: import("node:fs").Dirent[];
@@ -198,24 +377,9 @@ export async function getAllTranscripts(daysBack: number = 30): Promise<Transcri
198
377
  await Promise.all(
199
378
  projDirs.map(async (d) => {
200
379
  if (!d.isDirectory()) return;
201
- const dir = join(PROJECTS_DIR, String(d.name));
202
- let entries;
203
- try {
204
- entries = await readdir(dir, { withFileTypes: true });
205
- } catch {
206
- return;
207
- }
208
- for (const e of entries) {
209
- if (!e.isFile() || !e.name.endsWith(".jsonl")) continue;
210
- const fp = join(dir, e.name);
211
- try {
212
- const st = await stat(fp);
213
- if (st.mtimeMs >= cutoff) candidates.push({ filePath: fp, mtimeMs: st.mtimeMs });
214
- } catch {
215
- // skip
216
- }
217
- }
380
+ await collectJsonlFiles(join(PROJECTS_DIR, String(d.name)), cutoff, candidates);
218
381
  })
219
382
  );
220
- return pMapLimit(candidates, 16, (c) => getFileResult(c.filePath, c.mtimeMs));
383
+ const raws = await pMapLimit(candidates, 16, (c) => getFileResult(c.filePath, c.mtimeMs));
384
+ return dedupAndSum(raws);
221
385
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mbeato/contextscope",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Local dashboard auditing Claude Code's per-turn token context (skills, agents, commands, CLAUDE.md, MEMORY.md, hooks, MCP) with toggle-based disable and session analytics.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "start": "next start",
13
13
  "prod": "next build && next start",
14
14
  "lint": "eslint",
15
+ "refresh-prices": "node scripts/refresh-prices.mjs",
15
16
  "prepublishOnly": "next build",
16
17
  "release": "test -f .env.publish || (echo 'missing .env.publish — copy .env.publish.example and add your NPM_TOKEN' && exit 1) && set -a && . ./.env.publish && set +a && npm publish"
17
18
  },
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Refresh lib/model-prices.json from LiteLLM's authoritative price list.
4
+ *
5
+ * Pulls https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json,
6
+ * keeps only Anthropic models with the cost fields we use, and writes a pruned
7
+ * JSON to lib/model-prices.json. Run before each contextscope release so npm
8
+ * users get current prices.
9
+ *
10
+ * Usage: node scripts/refresh-prices.mjs
11
+ */
12
+ import { writeFile } from "node:fs/promises";
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname, join } from "node:path";
15
+
16
+ const SRC = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
17
+ const HERE = dirname(fileURLToPath(import.meta.url));
18
+ const DST = join(HERE, "..", "lib", "model-prices.json");
19
+
20
+ const res = await fetch(SRC);
21
+ if (!res.ok) {
22
+ console.error(`fetch failed: ${res.status} ${res.statusText}`);
23
+ process.exit(1);
24
+ }
25
+ const all = await res.json();
26
+
27
+ const pruned = {};
28
+ for (const [name, m] of Object.entries(all)) {
29
+ if (m?.litellm_provider !== "anthropic") continue;
30
+ if (typeof m.input_cost_per_token !== "number") continue;
31
+ const cc5m = m.cache_creation_input_token_cost ?? 0;
32
+ pruned[name] = {
33
+ input: m.input_cost_per_token,
34
+ output: m.output_cost_per_token ?? 0,
35
+ cache_read: m.cache_read_input_token_cost ?? 0,
36
+ cache_creation_5m: cc5m,
37
+ // Anthropic's documented 1hr cache rate is 2x the 5min rate.
38
+ // Fall back to 2x when LiteLLM doesn't list it explicitly.
39
+ cache_creation_1h: m.cache_creation_input_token_cost_above_1hr ?? cc5m * 2,
40
+ };
41
+ }
42
+
43
+ const sorted = Object.fromEntries(Object.entries(pruned).sort(([a], [b]) => a.localeCompare(b)));
44
+ await writeFile(
45
+ DST,
46
+ JSON.stringify(
47
+ {
48
+ _source: SRC,
49
+ _refreshedAt: new Date().toISOString(),
50
+ _description: "Anthropic model prices in USD per token (input, output, cache_read, cache_creation 5min default). Refresh with `npm run refresh-prices`.",
51
+ models: sorted,
52
+ },
53
+ null,
54
+ 2
55
+ ) + "\n",
56
+ "utf8"
57
+ );
58
+ console.log(`wrote ${Object.keys(sorted).length} anthropic models → ${DST}`);