stagent 0.1.10 → 0.1.12

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 (112) hide show
  1. package/README.md +58 -27
  2. package/package.json +3 -3
  3. package/src/__tests__/e2e/blueprint.test.ts +63 -0
  4. package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
  5. package/src/__tests__/e2e/helpers.ts +286 -0
  6. package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
  7. package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
  8. package/src/__tests__/e2e/setup.ts +156 -0
  9. package/src/__tests__/e2e/single-task.test.ts +170 -0
  10. package/src/app/api/command-palette/recent/route.ts +41 -18
  11. package/src/app/api/context/batch/route.ts +44 -0
  12. package/src/app/api/permissions/presets/route.ts +80 -0
  13. package/src/app/api/playbook/status/route.ts +15 -0
  14. package/src/app/api/profiles/route.ts +23 -21
  15. package/src/app/api/settings/pricing/route.ts +15 -0
  16. package/src/app/costs/page.tsx +53 -43
  17. package/src/app/globals.css +0 -5
  18. package/src/app/playbook/[slug]/page.tsx +76 -0
  19. package/src/app/playbook/page.tsx +54 -0
  20. package/src/app/profiles/page.tsx +7 -4
  21. package/src/app/settings/page.tsx +2 -2
  22. package/src/app/tasks/page.tsx +5 -0
  23. package/src/components/costs/cost-dashboard.tsx +226 -320
  24. package/src/components/dashboard/activity-feed.tsx +6 -2
  25. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  26. package/src/components/notifications/notification-item.tsx +6 -3
  27. package/src/components/notifications/pending-approval-host.tsx +57 -11
  28. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  29. package/src/components/playbook/journey-card.tsx +110 -0
  30. package/src/components/playbook/playbook-action-button.tsx +22 -0
  31. package/src/components/playbook/playbook-browser.tsx +143 -0
  32. package/src/components/playbook/playbook-card.tsx +102 -0
  33. package/src/components/playbook/playbook-detail-view.tsx +223 -0
  34. package/src/components/playbook/playbook-homepage.tsx +142 -0
  35. package/src/components/playbook/playbook-toc.tsx +90 -0
  36. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  37. package/src/components/playbook/related-docs.tsx +30 -0
  38. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  39. package/src/components/profiles/context-proposal-review.tsx +7 -3
  40. package/src/components/profiles/learned-context-panel.tsx +116 -8
  41. package/src/components/profiles/profile-detail-view.tsx +7 -19
  42. package/src/components/profiles/profile-form-view.tsx +0 -22
  43. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  44. package/src/components/settings/api-key-form.tsx +5 -43
  45. package/src/components/settings/auth-config-section.tsx +10 -6
  46. package/src/components/settings/auth-status-badge.tsx +8 -0
  47. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  48. package/src/components/settings/connection-test-control.tsx +63 -0
  49. package/src/components/settings/permissions-section.tsx +85 -75
  50. package/src/components/settings/permissions-sections.tsx +24 -0
  51. package/src/components/settings/presets-section.tsx +159 -0
  52. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  53. package/src/components/shared/app-sidebar.tsx +2 -0
  54. package/src/components/shared/command-palette.tsx +30 -0
  55. package/src/components/shared/light-markdown.tsx +134 -0
  56. package/src/components/workflows/loop-status-view.tsx +8 -4
  57. package/src/components/workflows/workflow-status-view.tsx +16 -9
  58. package/src/lib/agents/__tests__/claude-agent.test.ts +7 -2
  59. package/src/lib/agents/__tests__/learned-context.test.ts +500 -0
  60. package/src/lib/agents/__tests__/pattern-extractor.test.ts +243 -0
  61. package/src/lib/agents/__tests__/sweep.test.ts +202 -0
  62. package/src/lib/agents/claude-agent.ts +104 -78
  63. package/src/lib/agents/learned-context.ts +32 -28
  64. package/src/lib/agents/learning-session.ts +234 -0
  65. package/src/lib/agents/pattern-extractor.ts +34 -64
  66. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  67. package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +0 -1
  68. package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +0 -1
  69. package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +0 -1
  70. package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +0 -1
  71. package/src/lib/agents/profiles/builtins/general/profile.yaml +0 -1
  72. package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +0 -1
  73. package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +0 -1
  74. package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +0 -1
  75. package/src/lib/agents/profiles/builtins/researcher/profile.yaml +0 -1
  76. package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +0 -1
  77. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +0 -1
  78. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +0 -1
  79. package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +0 -1
  80. package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +0 -1
  81. package/src/lib/agents/profiles/registry.ts +0 -1
  82. package/src/lib/agents/profiles/sort.ts +7 -0
  83. package/src/lib/agents/profiles/types.ts +0 -1
  84. package/src/lib/agents/runtime/catalog.ts +1 -1
  85. package/src/lib/agents/runtime/claude.ts +66 -0
  86. package/src/lib/constants/settings.ts +1 -0
  87. package/src/lib/constants/task-status.ts +6 -0
  88. package/src/lib/data/seed-data/profiles.ts +0 -3
  89. package/src/lib/db/schema.ts +3 -0
  90. package/src/lib/docs/adoption.ts +105 -0
  91. package/src/lib/docs/journey-tracker.ts +21 -0
  92. package/src/lib/docs/reader.ts +102 -0
  93. package/src/lib/docs/types.ts +54 -0
  94. package/src/lib/docs/usage-stage.ts +60 -0
  95. package/src/lib/notifications/actionable.ts +18 -10
  96. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  97. package/src/lib/settings/budget-guardrails.ts +213 -85
  98. package/src/lib/settings/permission-presets.ts +150 -0
  99. package/src/lib/settings/runtime-setup.ts +71 -0
  100. package/src/lib/usage/__tests__/ledger.test.ts +29 -5
  101. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  102. package/src/lib/usage/ledger.ts +4 -2
  103. package/src/lib/usage/pricing-registry.ts +570 -0
  104. package/src/lib/usage/pricing.ts +15 -41
  105. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  106. package/src/lib/utils/learned-context-history.ts +150 -0
  107. package/src/lib/validators/__tests__/profile.test.ts +0 -15
  108. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  109. package/src/lib/validators/profile.ts +0 -1
  110. package/src/lib/validators/settings.ts +3 -9
  111. package/src/lib/workflows/__tests__/engine.test.ts +2 -0
  112. package/src/lib/workflows/engine.ts +20 -1
@@ -0,0 +1,570 @@
1
+ import { createHash } from "node:crypto";
2
+ import { SETTINGS_KEYS } from "@/lib/constants/settings";
3
+ import { getSetting, setSetting } from "@/lib/settings/helpers";
4
+ import type { ClaudeOAuthPlan } from "@/lib/validators/settings";
5
+
6
+ export type PricingProviderId = "anthropic" | "openai";
7
+ export type PricingRowKind = "api_model" | "subscription_plan";
8
+ export type PricingSourceType = "bundled_default" | "official_pricing_page";
9
+
10
+ export interface PricingRow {
11
+ key: string;
12
+ providerId: PricingProviderId;
13
+ kind: PricingRowKind;
14
+ label: string;
15
+ visible: boolean;
16
+ matchPrefixes: string[];
17
+ inputCostPerMillionMicros: number | null;
18
+ outputCostPerMillionMicros: number | null;
19
+ monthlyPriceUsd: number | null;
20
+ }
21
+
22
+ export interface ProviderPricingSnapshot {
23
+ providerId: PricingProviderId;
24
+ sourceType: PricingSourceType;
25
+ sourceLabel: string;
26
+ fetchedAtIso: string | null;
27
+ version: string;
28
+ refreshError: string | null;
29
+ rows: PricingRow[];
30
+ }
31
+
32
+ export interface PricingRegistry {
33
+ providers: Record<PricingProviderId, ProviderPricingSnapshot>;
34
+ }
35
+
36
+ export interface PricingRegistrySnapshot extends PricingRegistry {
37
+ lastUpdatedIso: string | null;
38
+ stale: boolean;
39
+ }
40
+
41
+ const STALE_AFTER_MS = 1000 * 60 * 60 * 24 * 7;
42
+
43
+ const DEFAULT_ROWS: PricingRow[] = [
44
+ {
45
+ key: "anthropic-claude-opus",
46
+ providerId: "anthropic",
47
+ kind: "api_model",
48
+ label: "Claude Opus",
49
+ visible: true,
50
+ matchPrefixes: ["claude-opus"],
51
+ inputCostPerMillionMicros: 5_000_000,
52
+ outputCostPerMillionMicros: 25_000_000,
53
+ monthlyPriceUsd: null,
54
+ },
55
+ {
56
+ key: "anthropic-claude-sonnet",
57
+ providerId: "anthropic",
58
+ kind: "api_model",
59
+ label: "Claude Sonnet",
60
+ visible: true,
61
+ matchPrefixes: ["claude-sonnet"],
62
+ inputCostPerMillionMicros: 3_000_000,
63
+ outputCostPerMillionMicros: 15_000_000,
64
+ monthlyPriceUsd: null,
65
+ },
66
+ {
67
+ key: "anthropic-claude-haiku",
68
+ providerId: "anthropic",
69
+ kind: "api_model",
70
+ label: "Claude Haiku",
71
+ visible: true,
72
+ matchPrefixes: ["claude-haiku"],
73
+ inputCostPerMillionMicros: 1_000_000,
74
+ outputCostPerMillionMicros: 5_000_000,
75
+ monthlyPriceUsd: null,
76
+ },
77
+ {
78
+ key: "anthropic-claude-fallback",
79
+ providerId: "anthropic",
80
+ kind: "api_model",
81
+ label: "Anthropic Fallback",
82
+ visible: false,
83
+ matchPrefixes: [],
84
+ inputCostPerMillionMicros: 5_000_000,
85
+ outputCostPerMillionMicros: 25_000_000,
86
+ monthlyPriceUsd: null,
87
+ },
88
+ {
89
+ key: "anthropic-plan-pro",
90
+ providerId: "anthropic",
91
+ kind: "subscription_plan",
92
+ label: "Claude Pro",
93
+ visible: true,
94
+ matchPrefixes: [],
95
+ inputCostPerMillionMicros: null,
96
+ outputCostPerMillionMicros: null,
97
+ monthlyPriceUsd: 20,
98
+ },
99
+ {
100
+ key: "anthropic-plan-max-5x",
101
+ providerId: "anthropic",
102
+ kind: "subscription_plan",
103
+ label: "Claude Max 5x",
104
+ visible: true,
105
+ matchPrefixes: [],
106
+ inputCostPerMillionMicros: null,
107
+ outputCostPerMillionMicros: null,
108
+ monthlyPriceUsd: 100,
109
+ },
110
+ {
111
+ key: "anthropic-plan-max-20x",
112
+ providerId: "anthropic",
113
+ kind: "subscription_plan",
114
+ label: "Claude Max 20x",
115
+ visible: true,
116
+ matchPrefixes: [],
117
+ inputCostPerMillionMicros: null,
118
+ outputCostPerMillionMicros: null,
119
+ monthlyPriceUsd: 200,
120
+ },
121
+ {
122
+ key: "openai-codex-mini",
123
+ providerId: "openai",
124
+ kind: "api_model",
125
+ label: "Codex Mini",
126
+ visible: true,
127
+ matchPrefixes: ["codex-mini"],
128
+ inputCostPerMillionMicros: 1_500_000,
129
+ outputCostPerMillionMicros: 6_000_000,
130
+ monthlyPriceUsd: null,
131
+ },
132
+ {
133
+ key: "openai-gpt-5",
134
+ providerId: "openai",
135
+ kind: "api_model",
136
+ label: "GPT-5",
137
+ visible: true,
138
+ matchPrefixes: ["gpt-5", "o3", "o4"],
139
+ inputCostPerMillionMicros: 10_000_000,
140
+ outputCostPerMillionMicros: 30_000_000,
141
+ monthlyPriceUsd: null,
142
+ },
143
+ {
144
+ key: "openai-gpt-4o",
145
+ providerId: "openai",
146
+ kind: "api_model",
147
+ label: "GPT-4o",
148
+ visible: true,
149
+ matchPrefixes: ["gpt-4o"],
150
+ inputCostPerMillionMicros: 2_500_000,
151
+ outputCostPerMillionMicros: 10_000_000,
152
+ monthlyPriceUsd: null,
153
+ },
154
+ {
155
+ key: "openai-fallback",
156
+ providerId: "openai",
157
+ kind: "api_model",
158
+ label: "OpenAI Fallback",
159
+ visible: false,
160
+ matchPrefixes: [],
161
+ inputCostPerMillionMicros: 10_000_000,
162
+ outputCostPerMillionMicros: 30_000_000,
163
+ monthlyPriceUsd: null,
164
+ },
165
+ ];
166
+
167
+ type ProviderDefaults = Record<PricingProviderId, ProviderPricingSnapshot>;
168
+
169
+ function buildDefaultProviders(): ProviderDefaults {
170
+ const nowIso = "2026-03-17T00:00:00.000Z";
171
+
172
+ return {
173
+ anthropic: {
174
+ providerId: "anthropic",
175
+ sourceType: "bundled_default",
176
+ sourceLabel: "Bundled Anthropic pricing defaults",
177
+ fetchedAtIso: nowIso,
178
+ version: "bundled-2026-03-17",
179
+ refreshError: null,
180
+ rows: DEFAULT_ROWS.filter((row) => row.providerId === "anthropic"),
181
+ },
182
+ openai: {
183
+ providerId: "openai",
184
+ sourceType: "bundled_default",
185
+ sourceLabel: "Bundled OpenAI pricing defaults",
186
+ fetchedAtIso: nowIso,
187
+ version: "bundled-2026-03-17",
188
+ refreshError: null,
189
+ rows: DEFAULT_ROWS.filter((row) => row.providerId === "openai"),
190
+ },
191
+ };
192
+ }
193
+
194
+ function cloneRegistry(registry: PricingRegistry): PricingRegistry {
195
+ return {
196
+ providers: {
197
+ anthropic: {
198
+ ...registry.providers.anthropic,
199
+ rows: registry.providers.anthropic.rows.map((row) => ({ ...row })),
200
+ },
201
+ openai: {
202
+ ...registry.providers.openai,
203
+ rows: registry.providers.openai.rows.map((row) => ({ ...row })),
204
+ },
205
+ },
206
+ };
207
+ }
208
+
209
+ function buildDefaultRegistry(): PricingRegistry {
210
+ return { providers: buildDefaultProviders() };
211
+ }
212
+
213
+ function isPricingProviderId(value: string): value is PricingProviderId {
214
+ return value === "anthropic" || value === "openai";
215
+ }
216
+
217
+ function isPricingRowKind(value: string): value is PricingRowKind {
218
+ return value === "api_model" || value === "subscription_plan";
219
+ }
220
+
221
+ function parsePricingRegistry(value: string | null): PricingRegistry | null {
222
+ if (!value) {
223
+ return null;
224
+ }
225
+
226
+ try {
227
+ const parsed = JSON.parse(value) as PricingRegistry;
228
+ if (!parsed || typeof parsed !== "object" || !parsed.providers) {
229
+ return null;
230
+ }
231
+
232
+ const next = buildDefaultRegistry();
233
+
234
+ for (const providerId of ["anthropic", "openai"] as const) {
235
+ const provider = parsed.providers[providerId];
236
+ if (!provider) {
237
+ continue;
238
+ }
239
+
240
+ const sourceType = provider.sourceType;
241
+ next.providers[providerId] = {
242
+ providerId,
243
+ sourceType:
244
+ sourceType === "official_pricing_page" ? sourceType : "bundled_default",
245
+ sourceLabel:
246
+ typeof provider.sourceLabel === "string"
247
+ ? provider.sourceLabel
248
+ : next.providers[providerId].sourceLabel,
249
+ fetchedAtIso:
250
+ typeof provider.fetchedAtIso === "string" ? provider.fetchedAtIso : null,
251
+ version:
252
+ typeof provider.version === "string"
253
+ ? provider.version
254
+ : next.providers[providerId].version,
255
+ refreshError:
256
+ typeof provider.refreshError === "string" ? provider.refreshError : null,
257
+ rows: Array.isArray(provider.rows)
258
+ ? provider.rows
259
+ .filter(
260
+ (row): row is PricingRow =>
261
+ Boolean(row) &&
262
+ typeof row === "object" &&
263
+ typeof row.key === "string" &&
264
+ isPricingProviderId(row.providerId) &&
265
+ isPricingRowKind(row.kind) &&
266
+ typeof row.label === "string" &&
267
+ typeof row.visible === "boolean" &&
268
+ Array.isArray(row.matchPrefixes)
269
+ )
270
+ .map((row) => ({
271
+ ...row,
272
+ matchPrefixes: row.matchPrefixes.filter(
273
+ (prefix): prefix is string => typeof prefix === "string"
274
+ ),
275
+ }))
276
+ : next.providers[providerId].rows,
277
+ };
278
+ }
279
+
280
+ return next;
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
286
+ function normalizeText(html: string) {
287
+ return html
288
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
289
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
290
+ .replace(/<[^>]+>/g, " ")
291
+ .replace(/&nbsp;/gi, " ")
292
+ .replace(/&#x27;/gi, "'")
293
+ .replace(/&amp;/gi, "&")
294
+ .replace(/\s+/g, " ")
295
+ .trim();
296
+ }
297
+
298
+ function escapePattern(value: string) {
299
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
300
+ }
301
+
302
+ function priceToMicros(value: number) {
303
+ return Math.round(value * 1_000_000);
304
+ }
305
+
306
+ function extractModelPricing(
307
+ text: string,
308
+ labels: string[]
309
+ ): { inputMicros: number; outputMicros: number } | null {
310
+ for (const label of labels) {
311
+ const pattern = new RegExp(
312
+ `${escapePattern(label)}[\\s\\S]{0,260}?\\$(\\d+(?:\\.\\d+)?)\\s*\\/\\s*1M[\\s\\S]{0,160}?\\$(\\d+(?:\\.\\d+)?)\\s*\\/\\s*1M`,
313
+ "i"
314
+ );
315
+ const match = text.match(pattern);
316
+ if (match?.[1] && match?.[2]) {
317
+ return {
318
+ inputMicros: priceToMicros(Number(match[1])),
319
+ outputMicros: priceToMicros(Number(match[2])),
320
+ };
321
+ }
322
+ }
323
+
324
+ return null;
325
+ }
326
+
327
+ function extractPlanPrice(text: string, labels: string[]): number | null {
328
+ for (const label of labels) {
329
+ const pattern = new RegExp(
330
+ `${escapePattern(label)}[\\s\\S]{0,120}?\\$(\\d+(?:\\.\\d+)?)`,
331
+ "i"
332
+ );
333
+ const match = text.match(pattern);
334
+ if (match?.[1]) {
335
+ return Number(match[1]);
336
+ }
337
+ }
338
+
339
+ return null;
340
+ }
341
+
342
+ function buildVersion(providerId: PricingProviderId, rows: PricingRow[]) {
343
+ const payload = rows
344
+ .filter((row) => row.visible)
345
+ .map((row) => ({
346
+ key: row.key,
347
+ inputCostPerMillionMicros: row.inputCostPerMillionMicros,
348
+ outputCostPerMillionMicros: row.outputCostPerMillionMicros,
349
+ monthlyPriceUsd: row.monthlyPriceUsd,
350
+ }));
351
+ const hash = createHash("sha256")
352
+ .update(JSON.stringify(payload))
353
+ .digest("hex")
354
+ .slice(0, 10);
355
+ return `${providerId}-registry-${hash}`;
356
+ }
357
+
358
+ function updateRow(
359
+ rows: PricingRow[],
360
+ key: string,
361
+ values: Partial<Pick<PricingRow, "inputCostPerMillionMicros" | "outputCostPerMillionMicros" | "monthlyPriceUsd">>
362
+ ) {
363
+ const row = rows.find((entry) => entry.key === key);
364
+ if (!row) {
365
+ return;
366
+ }
367
+
368
+ if (values.inputCostPerMillionMicros != null) {
369
+ row.inputCostPerMillionMicros = values.inputCostPerMillionMicros;
370
+ }
371
+ if (values.outputCostPerMillionMicros != null) {
372
+ row.outputCostPerMillionMicros = values.outputCostPerMillionMicros;
373
+ }
374
+ if (values.monthlyPriceUsd != null) {
375
+ row.monthlyPriceUsd = values.monthlyPriceUsd;
376
+ }
377
+ }
378
+
379
+ async function refreshAnthropicPricing(
380
+ current: ProviderPricingSnapshot
381
+ ): Promise<ProviderPricingSnapshot> {
382
+ const response = await fetch("https://www.anthropic.com/pricing", {
383
+ cache: "no-store",
384
+ });
385
+ if (!response.ok) {
386
+ throw new Error(`Anthropic pricing fetch failed (${response.status})`);
387
+ }
388
+
389
+ const text = normalizeText(await response.text());
390
+ const rows = current.rows.map((row) => ({ ...row }));
391
+
392
+ const opus = extractModelPricing(text, ["Claude Opus 4.6", "Claude Opus 4.5", "Claude Opus 4"]);
393
+ if (opus) {
394
+ updateRow(rows, "anthropic-claude-opus", {
395
+ inputCostPerMillionMicros: opus.inputMicros,
396
+ outputCostPerMillionMicros: opus.outputMicros,
397
+ });
398
+ }
399
+
400
+ const sonnet = extractModelPricing(text, ["Claude Sonnet 4.6", "Claude Sonnet 4.5", "Claude Sonnet 4"]);
401
+ if (sonnet) {
402
+ updateRow(rows, "anthropic-claude-sonnet", {
403
+ inputCostPerMillionMicros: sonnet.inputMicros,
404
+ outputCostPerMillionMicros: sonnet.outputMicros,
405
+ });
406
+ }
407
+
408
+ const haiku = extractModelPricing(text, ["Claude Haiku 4.5", "Claude Haiku 3.5", "Claude Haiku"]);
409
+ if (haiku) {
410
+ updateRow(rows, "anthropic-claude-haiku", {
411
+ inputCostPerMillionMicros: haiku.inputMicros,
412
+ outputCostPerMillionMicros: haiku.outputMicros,
413
+ });
414
+ }
415
+
416
+ const pro = extractPlanPrice(text, ["Claude Pro", "Pro"]);
417
+ if (pro != null) {
418
+ updateRow(rows, "anthropic-plan-pro", { monthlyPriceUsd: pro });
419
+ }
420
+
421
+ const max5x = extractPlanPrice(text, ["Max 5x", "Claude Max 5x"]);
422
+ if (max5x != null) {
423
+ updateRow(rows, "anthropic-plan-max-5x", { monthlyPriceUsd: max5x });
424
+ }
425
+
426
+ const max20x = extractPlanPrice(text, ["Max 20x", "Claude Max 20x"]);
427
+ if (max20x != null) {
428
+ updateRow(rows, "anthropic-plan-max-20x", { monthlyPriceUsd: max20x });
429
+ }
430
+
431
+ const fetchedAtIso = new Date().toISOString();
432
+
433
+ return {
434
+ providerId: "anthropic",
435
+ sourceType: "official_pricing_page",
436
+ sourceLabel: "Anthropic pricing page",
437
+ fetchedAtIso,
438
+ version: buildVersion("anthropic", rows),
439
+ refreshError: null,
440
+ rows,
441
+ };
442
+ }
443
+
444
+ async function refreshOpenAIPricing(
445
+ current: ProviderPricingSnapshot
446
+ ): Promise<ProviderPricingSnapshot> {
447
+ const response = await fetch("https://openai.com/api/pricing/", {
448
+ cache: "no-store",
449
+ });
450
+ if (!response.ok) {
451
+ throw new Error(`OpenAI pricing fetch failed (${response.status})`);
452
+ }
453
+
454
+ const text = normalizeText(await response.text());
455
+ const rows = current.rows.map((row) => ({ ...row }));
456
+
457
+ const gpt5 = extractModelPricing(text, ["GPT-5.4", "GPT-5.1", "GPT-5"]);
458
+ if (gpt5) {
459
+ updateRow(rows, "openai-gpt-5", {
460
+ inputCostPerMillionMicros: gpt5.inputMicros,
461
+ outputCostPerMillionMicros: gpt5.outputMicros,
462
+ });
463
+ }
464
+
465
+ const gpt4o = extractModelPricing(text, ["GPT-4o"]);
466
+ if (gpt4o) {
467
+ updateRow(rows, "openai-gpt-4o", {
468
+ inputCostPerMillionMicros: gpt4o.inputMicros,
469
+ outputCostPerMillionMicros: gpt4o.outputMicros,
470
+ });
471
+ }
472
+
473
+ const fetchedAtIso = new Date().toISOString();
474
+
475
+ return {
476
+ providerId: "openai",
477
+ sourceType: "official_pricing_page",
478
+ sourceLabel: "OpenAI API pricing page",
479
+ fetchedAtIso,
480
+ version: buildVersion("openai", rows),
481
+ refreshError: null,
482
+ rows,
483
+ };
484
+ }
485
+
486
+ export async function getPricingRegistry(): Promise<PricingRegistry> {
487
+ const stored = parsePricingRegistry(await getSetting(SETTINGS_KEYS.PRICING_REGISTRY));
488
+ return stored ?? buildDefaultRegistry();
489
+ }
490
+
491
+ export async function setPricingRegistry(registry: PricingRegistry) {
492
+ await setSetting(SETTINGS_KEYS.PRICING_REGISTRY, JSON.stringify(registry));
493
+ }
494
+
495
+ export async function getPricingRegistrySnapshot(): Promise<PricingRegistrySnapshot> {
496
+ const registry = await getPricingRegistry();
497
+ const fetchedAtValues = Object.values(registry.providers)
498
+ .map((provider) => provider.fetchedAtIso)
499
+ .filter((value): value is string => Boolean(value));
500
+ const lastUpdatedIso =
501
+ fetchedAtValues.length > 0
502
+ ? fetchedAtValues.sort((left, right) => right.localeCompare(left))[0]
503
+ : null;
504
+ const stale =
505
+ lastUpdatedIso == null
506
+ ? true
507
+ : Date.now() - new Date(lastUpdatedIso).getTime() > STALE_AFTER_MS;
508
+
509
+ return {
510
+ ...cloneRegistry(registry),
511
+ lastUpdatedIso,
512
+ stale,
513
+ };
514
+ }
515
+
516
+ export async function refreshPricingRegistry(): Promise<PricingRegistrySnapshot> {
517
+ const current = await getPricingRegistry();
518
+ const next = cloneRegistry(current);
519
+
520
+ try {
521
+ next.providers.anthropic = await refreshAnthropicPricing(next.providers.anthropic);
522
+ } catch (error) {
523
+ next.providers.anthropic.refreshError =
524
+ error instanceof Error ? error.message : "Failed to refresh Anthropic pricing";
525
+ }
526
+
527
+ try {
528
+ next.providers.openai = await refreshOpenAIPricing(next.providers.openai);
529
+ } catch (error) {
530
+ next.providers.openai.refreshError =
531
+ error instanceof Error ? error.message : "Failed to refresh OpenAI pricing";
532
+ }
533
+
534
+ await setPricingRegistry(next);
535
+ return getPricingRegistrySnapshot();
536
+ }
537
+
538
+ export async function findPricingRowForModel(input: {
539
+ providerId: PricingProviderId;
540
+ modelId: string;
541
+ }) {
542
+ const registry = await getPricingRegistry();
543
+ const rows = registry.providers[input.providerId].rows.filter(
544
+ (row) => row.kind === "api_model"
545
+ );
546
+
547
+ const matched = rows.find((row) =>
548
+ row.matchPrefixes.some((prefix) => input.modelId.startsWith(prefix))
549
+ );
550
+ if (matched) {
551
+ return matched;
552
+ }
553
+
554
+ return rows.find((row) => row.key === `${input.providerId}-fallback`) ?? null;
555
+ }
556
+
557
+ export async function getClaudeOAuthPlanPrice(plan: ClaudeOAuthPlan | undefined) {
558
+ const registry = await getPricingRegistry();
559
+ const key =
560
+ plan === "max_5x"
561
+ ? "anthropic-plan-max-5x"
562
+ : plan === "max_20x"
563
+ ? "anthropic-plan-max-20x"
564
+ : "anthropic-plan-pro";
565
+
566
+ return (
567
+ registry.providers.anthropic.rows.find((row) => row.key === key)?.monthlyPriceUsd ??
568
+ 20
569
+ );
570
+ }
@@ -1,68 +1,42 @@
1
- export interface PricingRule {
2
- providerId: "anthropic" | "openai";
3
- pricingVersion: string;
4
- inputCostPerMillionMicros: number;
5
- outputCostPerMillionMicros: number;
6
- matchesModel(modelId: string): boolean;
7
- }
8
-
9
- const PRICING_RULES: PricingRule[] = [
10
- {
11
- providerId: "anthropic",
12
- pricingVersion: "registry-2026-03-12",
13
- inputCostPerMillionMicros: 3_000_000,
14
- outputCostPerMillionMicros: 15_000_000,
15
- matchesModel(modelId) {
16
- return (
17
- modelId === "claude-sonnet-4-20250514" ||
18
- modelId.startsWith("claude-sonnet-4")
19
- );
20
- },
21
- },
22
- {
23
- providerId: "openai",
24
- pricingVersion: "registry-2026-03-12",
25
- inputCostPerMillionMicros: 1_500_000,
26
- outputCostPerMillionMicros: 6_000_000,
27
- matchesModel(modelId) {
28
- return modelId === "codex-mini-latest" || modelId.startsWith("codex-mini");
29
- },
30
- },
31
- ];
1
+ import { findPricingRowForModel } from "./pricing-registry";
32
2
 
33
3
  export interface DerivedCost {
34
4
  costMicros: number | null;
35
5
  pricingVersion: string | null;
36
6
  }
37
7
 
38
- export function deriveUsageCostMicros(input: {
8
+ export async function deriveUsageCostMicros(input: {
39
9
  providerId: string;
40
10
  modelId?: string | null;
41
11
  inputTokens?: number | null;
42
12
  outputTokens?: number | null;
43
- }): DerivedCost {
13
+ }): Promise<DerivedCost> {
44
14
  if (!input.modelId) {
45
15
  return { costMicros: null, pricingVersion: null };
46
16
  }
47
17
 
48
- const rule = PRICING_RULES.find(
49
- (entry) =>
50
- entry.providerId === input.providerId && entry.matchesModel(input.modelId!)
51
- );
18
+ if (input.providerId !== "anthropic" && input.providerId !== "openai") {
19
+ return { costMicros: null, pricingVersion: null };
20
+ }
21
+
22
+ const row = await findPricingRowForModel({
23
+ providerId: input.providerId,
24
+ modelId: input.modelId,
25
+ });
52
26
 
53
- if (!rule) {
27
+ if (!row) {
54
28
  return { costMicros: null, pricingVersion: null };
55
29
  }
56
30
 
57
31
  const inputTokens = input.inputTokens ?? 0;
58
32
  const outputTokens = input.outputTokens ?? 0;
59
33
  const inputCost =
60
- (inputTokens * rule.inputCostPerMillionMicros) / 1_000_000;
34
+ (inputTokens * (row.inputCostPerMillionMicros ?? 0)) / 1_000_000;
61
35
  const outputCost =
62
- (outputTokens * rule.outputCostPerMillionMicros) / 1_000_000;
36
+ (outputTokens * (row.outputCostPerMillionMicros ?? 0)) / 1_000_000;
63
37
 
64
38
  return {
65
39
  costMicros: Math.round(inputCost + outputCost),
66
- pricingVersion: rule.pricingVersion,
40
+ pricingVersion: row.key,
67
41
  };
68
42
  }