stagent 0.1.11 → 0.1.13

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 (145) hide show
  1. package/README.md +74 -49
  2. package/package.json +3 -2
  3. package/public/readme/cost-usage-list.png +0 -0
  4. package/public/readme/dashboard-bulk-select.png +0 -0
  5. package/public/readme/dashboard-card-edit.png +0 -0
  6. package/public/readme/dashboard-create-form-ai-applied.png +0 -0
  7. package/public/readme/dashboard-create-form-ai-assist.png +0 -0
  8. package/public/readme/dashboard-create-form-empty.png +0 -0
  9. package/public/readme/dashboard-create-form-filled.png +0 -0
  10. package/public/readme/dashboard-filtered.png +0 -0
  11. package/public/readme/dashboard-list.png +0 -0
  12. package/public/readme/dashboard-workflow-confirm.png +0 -0
  13. package/public/readme/home-below-fold.png +0 -0
  14. package/public/readme/home-list.png +0 -0
  15. package/public/readme/inbox-list.png +0 -0
  16. package/public/readme/playbook-list.png +0 -0
  17. package/public/readme/profiles-list.png +0 -0
  18. package/public/readme/settings-list.png +0 -0
  19. package/public/readme/workflows-list.png +0 -0
  20. package/src/__tests__/e2e/blueprint.test.ts +63 -0
  21. package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
  22. package/src/__tests__/e2e/helpers.ts +286 -0
  23. package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
  24. package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
  25. package/src/__tests__/e2e/setup.ts +156 -0
  26. package/src/__tests__/e2e/single-task.test.ts +170 -0
  27. package/src/app/api/command-palette/recent/route.ts +41 -18
  28. package/src/app/api/context/batch/route.ts +44 -0
  29. package/src/app/api/permissions/presets/route.ts +80 -0
  30. package/src/app/api/playbook/status/route.ts +15 -0
  31. package/src/app/api/profiles/route.ts +23 -20
  32. package/src/app/api/settings/pricing/route.ts +15 -0
  33. package/src/app/api/tasks/[id]/route.ts +54 -3
  34. package/src/app/api/workflows/[id]/route.ts +43 -4
  35. package/src/app/api/workflows/[id]/status/route.ts +70 -2
  36. package/src/app/api/workflows/from-assist/route.ts +6 -32
  37. package/src/app/costs/page.tsx +53 -43
  38. package/src/app/dashboard/page.tsx +59 -21
  39. package/src/app/documents/[id]/page.tsx +10 -8
  40. package/src/app/globals.css +11 -0
  41. package/src/app/page.tsx +60 -3
  42. package/src/app/playbook/[slug]/page.tsx +76 -0
  43. package/src/app/playbook/page.tsx +54 -0
  44. package/src/app/profiles/page.tsx +7 -4
  45. package/src/app/settings/page.tsx +2 -2
  46. package/src/app/tasks/[id]/page.tsx +22 -2
  47. package/src/components/costs/cost-dashboard.tsx +226 -320
  48. package/src/components/dashboard/activity-feed.tsx +6 -2
  49. package/src/components/dashboard/greeting.tsx +3 -1
  50. package/src/components/dashboard/priority-queue.tsx +58 -9
  51. package/src/components/dashboard/stats-cards.tsx +16 -2
  52. package/src/components/documents/document-chip-bar.tsx +183 -0
  53. package/src/components/documents/document-content-renderer.tsx +146 -0
  54. package/src/components/documents/document-detail-view.tsx +16 -239
  55. package/src/components/documents/image-zoom-view.tsx +60 -0
  56. package/src/components/documents/smart-extracted-text.tsx +47 -0
  57. package/src/components/documents/utils.ts +70 -0
  58. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  59. package/src/components/notifications/inbox-list.tsx +4 -5
  60. package/src/components/notifications/notification-item.tsx +73 -6
  61. package/src/components/notifications/pending-approval-host.tsx +63 -14
  62. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  63. package/src/components/playbook/journey-card.tsx +110 -0
  64. package/src/components/playbook/playbook-action-button.tsx +22 -0
  65. package/src/components/playbook/playbook-browser.tsx +143 -0
  66. package/src/components/playbook/playbook-card.tsx +102 -0
  67. package/src/components/playbook/playbook-detail-view.tsx +225 -0
  68. package/src/components/playbook/playbook-homepage.tsx +142 -0
  69. package/src/components/playbook/playbook-toc.tsx +90 -0
  70. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  71. package/src/components/playbook/related-docs.tsx +30 -0
  72. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  73. package/src/components/profiles/context-proposal-review.tsx +7 -3
  74. package/src/components/profiles/learned-context-panel.tsx +116 -8
  75. package/src/components/profiles/profile-browser.tsx +1 -0
  76. package/src/components/profiles/profile-card.tsx +16 -8
  77. package/src/components/profiles/profile-detail-view.tsx +12 -4
  78. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  79. package/src/components/settings/api-key-form.tsx +5 -43
  80. package/src/components/settings/auth-config-section.tsx +10 -6
  81. package/src/components/settings/auth-status-badge.tsx +8 -0
  82. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  83. package/src/components/settings/connection-test-control.tsx +63 -0
  84. package/src/components/settings/permissions-section.tsx +85 -75
  85. package/src/components/settings/permissions-sections.tsx +24 -0
  86. package/src/components/settings/presets-section.tsx +159 -0
  87. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  88. package/src/components/shared/app-sidebar.tsx +4 -2
  89. package/src/components/shared/command-palette.tsx +30 -0
  90. package/src/components/shared/light-markdown.tsx +134 -0
  91. package/src/components/tasks/__tests__/kanban-board-accessibility.test.tsx +1 -1
  92. package/src/components/tasks/ai-assist-panel.tsx +108 -78
  93. package/src/components/tasks/content-preview.tsx +2 -1
  94. package/src/components/tasks/kanban-board.tsx +57 -5
  95. package/src/components/tasks/kanban-column.tsx +34 -23
  96. package/src/components/tasks/task-bento-cell.tsx +50 -0
  97. package/src/components/tasks/task-bento-grid.tsx +155 -0
  98. package/src/components/tasks/task-card.tsx +14 -16
  99. package/src/components/tasks/task-chip-bar.tsx +207 -0
  100. package/src/components/tasks/task-detail-view.tsx +42 -190
  101. package/src/components/tasks/task-result-renderer.tsx +33 -0
  102. package/src/components/workflows/blueprint-gallery.tsx +19 -12
  103. package/src/components/workflows/blueprint-preview.tsx +8 -1
  104. package/src/components/workflows/loop-status-view.tsx +2 -4
  105. package/src/components/workflows/swarm-dashboard.tsx +2 -3
  106. package/src/components/workflows/workflow-confirmation-view.tsx +2 -7
  107. package/src/components/workflows/workflow-full-output.tsx +80 -0
  108. package/src/components/workflows/workflow-kanban-card.tsx +121 -0
  109. package/src/components/workflows/workflow-list.tsx +47 -42
  110. package/src/components/workflows/workflow-status-view.tsx +163 -16
  111. package/src/lib/agents/learned-context.ts +27 -15
  112. package/src/lib/agents/learning-session.ts +354 -0
  113. package/src/lib/agents/pattern-extractor.ts +19 -0
  114. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  115. package/src/lib/agents/profiles/sort.ts +7 -0
  116. package/src/lib/constants/card-icons.tsx +202 -0
  117. package/src/lib/constants/prose-styles.ts +7 -0
  118. package/src/lib/constants/settings.ts +1 -0
  119. package/src/lib/constants/task-status.ts +3 -0
  120. package/src/lib/db/schema.ts +3 -0
  121. package/src/lib/docs/adoption.ts +105 -0
  122. package/src/lib/docs/journey-tracker.ts +21 -0
  123. package/src/lib/docs/reader.ts +107 -0
  124. package/src/lib/docs/types.ts +54 -0
  125. package/src/lib/docs/usage-stage.ts +60 -0
  126. package/src/lib/documents/context-builder.ts +41 -0
  127. package/src/lib/notifications/actionable.ts +18 -10
  128. package/src/lib/queries/chart-data.ts +20 -1
  129. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  130. package/src/lib/settings/budget-guardrails.ts +213 -85
  131. package/src/lib/settings/permission-presets.ts +150 -0
  132. package/src/lib/settings/runtime-setup.ts +71 -0
  133. package/src/lib/usage/__tests__/ledger.test.ts +2 -2
  134. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  135. package/src/lib/usage/ledger.ts +1 -1
  136. package/src/lib/usage/pricing-registry.ts +570 -0
  137. package/src/lib/usage/pricing.ts +15 -95
  138. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  139. package/src/lib/utils/learned-context-history.ts +150 -0
  140. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  141. package/src/lib/validators/settings.ts +3 -9
  142. package/src/lib/workflows/engine.ts +75 -61
  143. package/src/lib/workflows/types.ts +2 -0
  144. package/tsconfig.json +2 -1
  145. package/src/components/documents/document-preview.tsx +0 -68
@@ -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,122 +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
- // ── Anthropic ──────────────────────────────────────────────────────
11
- {
12
- providerId: "anthropic",
13
- pricingVersion: "registry-2026-03-15",
14
- inputCostPerMillionMicros: 15_000_000,
15
- outputCostPerMillionMicros: 75_000_000,
16
- matchesModel(modelId) {
17
- return modelId.startsWith("claude-opus");
18
- },
19
- },
20
- {
21
- providerId: "anthropic",
22
- pricingVersion: "registry-2026-03-15",
23
- inputCostPerMillionMicros: 3_000_000,
24
- outputCostPerMillionMicros: 15_000_000,
25
- matchesModel(modelId) {
26
- return modelId.startsWith("claude-sonnet");
27
- },
28
- },
29
- {
30
- providerId: "anthropic",
31
- pricingVersion: "registry-2026-03-15",
32
- inputCostPerMillionMicros: 800_000,
33
- outputCostPerMillionMicros: 4_000_000,
34
- matchesModel(modelId) {
35
- return modelId.startsWith("claude-haiku");
36
- },
37
- },
38
- // ── OpenAI ─────────────────────────────────────────────────────────
39
- {
40
- providerId: "openai",
41
- pricingVersion: "registry-2026-03-15",
42
- inputCostPerMillionMicros: 1_500_000,
43
- outputCostPerMillionMicros: 6_000_000,
44
- matchesModel(modelId) {
45
- return modelId.startsWith("codex-mini") || modelId === "codex-mini-latest";
46
- },
47
- },
48
- {
49
- providerId: "openai",
50
- pricingVersion: "registry-2026-03-15",
51
- inputCostPerMillionMicros: 2_500_000,
52
- outputCostPerMillionMicros: 10_000_000,
53
- matchesModel(modelId) {
54
- return modelId.startsWith("gpt-4o");
55
- },
56
- },
57
- {
58
- providerId: "openai",
59
- pricingVersion: "registry-2026-03-15",
60
- inputCostPerMillionMicros: 10_000_000,
61
- outputCostPerMillionMicros: 30_000_000,
62
- matchesModel(modelId) {
63
- return modelId.startsWith("gpt-5") || modelId.startsWith("o3") || modelId.startsWith("o4");
64
- },
65
- },
66
- // ── Catch-all (conservative estimate to prevent null costs) ────────
67
- {
68
- providerId: "anthropic",
69
- pricingVersion: "registry-2026-03-15-fallback",
70
- inputCostPerMillionMicros: 15_000_000,
71
- outputCostPerMillionMicros: 75_000_000,
72
- matchesModel() {
73
- return true;
74
- },
75
- },
76
- {
77
- providerId: "openai",
78
- pricingVersion: "registry-2026-03-15-fallback",
79
- inputCostPerMillionMicros: 10_000_000,
80
- outputCostPerMillionMicros: 30_000_000,
81
- matchesModel() {
82
- return true;
83
- },
84
- },
85
- ];
1
+ import { findPricingRowForModel } from "./pricing-registry";
86
2
 
87
3
  export interface DerivedCost {
88
4
  costMicros: number | null;
89
5
  pricingVersion: string | null;
90
6
  }
91
7
 
92
- export function deriveUsageCostMicros(input: {
8
+ export async function deriveUsageCostMicros(input: {
93
9
  providerId: string;
94
10
  modelId?: string | null;
95
11
  inputTokens?: number | null;
96
12
  outputTokens?: number | null;
97
- }): DerivedCost {
13
+ }): Promise<DerivedCost> {
98
14
  if (!input.modelId) {
99
15
  return { costMicros: null, pricingVersion: null };
100
16
  }
101
17
 
102
- const rule = PRICING_RULES.find(
103
- (entry) =>
104
- entry.providerId === input.providerId && entry.matchesModel(input.modelId!)
105
- );
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
+ });
106
26
 
107
- if (!rule) {
27
+ if (!row) {
108
28
  return { costMicros: null, pricingVersion: null };
109
29
  }
110
30
 
111
31
  const inputTokens = input.inputTokens ?? 0;
112
32
  const outputTokens = input.outputTokens ?? 0;
113
33
  const inputCost =
114
- (inputTokens * rule.inputCostPerMillionMicros) / 1_000_000;
34
+ (inputTokens * (row.inputCostPerMillionMicros ?? 0)) / 1_000_000;
115
35
  const outputCost =
116
- (outputTokens * rule.outputCostPerMillionMicros) / 1_000_000;
36
+ (outputTokens * (row.outputCostPerMillionMicros ?? 0)) / 1_000_000;
117
37
 
118
38
  return {
119
39
  costMicros: Math.round(inputCost + outputCost),
120
- pricingVersion: rule.pricingVersion,
40
+ pricingVersion: row.key,
121
41
  };
122
42
  }