@m8i-51/shoal 0.1.0

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.
@@ -0,0 +1,414 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import type { Page, BrowserContext } from "playwright";
4
+ import type { LLMClient } from "./llm-client";
5
+ import { createMessageWithRetry } from "./agent-loop";
6
+ import { saveFinding } from "./findings";
7
+ import {
8
+ setupObservation,
9
+ getRecentConsoleLogs,
10
+ getRecentNetworkErrors,
11
+ readPageText,
12
+ readAccessibilityTree,
13
+ saveSnapshotBeforeAction,
14
+ getDiffFromSnapshot,
15
+ } from "./observation";
16
+ import type { ProductSpec } from "./product-discovery";
17
+ import type { Credentials } from "../targets/types";
18
+ import Anthropic from "@anthropic-ai/sdk";
19
+
20
+ export interface TestAccount {
21
+ email: string;
22
+ password: string;
23
+ role: string;
24
+ storageStatePath: string;
25
+ }
26
+
27
+ const ACCOUNTS_DIR = path.join(process.cwd(), "test-accounts");
28
+ const ACCOUNTS_PATH = path.join(ACCOUNTS_DIR, "accounts.json");
29
+
30
+ export function loadTestAccounts(): TestAccount[] {
31
+ try {
32
+ if (fs.existsSync(ACCOUNTS_PATH)) {
33
+ return JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8")) as TestAccount[];
34
+ }
35
+ } catch { /* ignore */ }
36
+ return [];
37
+ }
38
+
39
+ function saveTestAccounts(accounts: TestAccount[]): void {
40
+ fs.mkdirSync(ACCOUNTS_DIR, { recursive: true });
41
+ fs.writeFileSync(ACCOUNTS_PATH, JSON.stringify(accounts, null, 2), "utf-8");
42
+ }
43
+
44
+ // ================================================================
45
+ // Playwright helpers (shared with browser agent but local here)
46
+ // ================================================================
47
+
48
+ async function takeScreenshot(page: Page, label: string): Promise<string> {
49
+ const buffer = await page.screenshot({ type: "png", fullPage: false });
50
+ return buffer.toString("base64");
51
+ }
52
+
53
+ async function performLogin(
54
+ page: Page,
55
+ baseUrl: string,
56
+ credentials: Credentials,
57
+ ): Promise<boolean> {
58
+ try {
59
+ await page.goto(baseUrl, { waitUntil: "networkidle" });
60
+ await page.waitForTimeout(1000);
61
+
62
+ // email / username フィールドを探す
63
+ const emailSelectors = [
64
+ 'input[type="email"]',
65
+ 'input[name="email"]',
66
+ 'input[name="username"]',
67
+ 'input[placeholder*="mail" i]',
68
+ 'input[placeholder*="user" i]',
69
+ ];
70
+ let filled = false;
71
+ for (const sel of emailSelectors) {
72
+ const el = page.locator(sel).first();
73
+ if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
74
+ await el.fill(credentials.email);
75
+ filled = true;
76
+ break;
77
+ }
78
+ }
79
+ if (!filled) return false;
80
+
81
+ // password フィールド
82
+ const passEl = page.locator('input[type="password"]').first();
83
+ if (!await passEl.isVisible({ timeout: 2000 }).catch(() => false)) return false;
84
+ await passEl.fill(credentials.password);
85
+
86
+ // submit
87
+ const submitSelectors = [
88
+ 'button[type="submit"]',
89
+ 'input[type="submit"]',
90
+ 'button:has-text("Login")',
91
+ 'button:has-text("Sign in")',
92
+ 'button:has-text("ログイン")',
93
+ 'button:has-text("サインイン")',
94
+ ];
95
+ for (const sel of submitSelectors) {
96
+ const el = page.locator(sel).first();
97
+ if (await el.isVisible({ timeout: 1000 }).catch(() => false)) {
98
+ await el.click();
99
+ await page.waitForTimeout(2000);
100
+ return true;
101
+ }
102
+ }
103
+ return false;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ // ================================================================
110
+ // Account Manager tools
111
+ // ================================================================
112
+
113
+ const ACCOUNT_MANAGER_TOOLS: Anthropic.Tool[] = [
114
+ {
115
+ name: "view_screen",
116
+ description: "Capture the current screen.",
117
+ input_schema: { type: "object", properties: {}, required: [] },
118
+ },
119
+ {
120
+ name: "navigate",
121
+ description: "Navigate to a path.",
122
+ input_schema: {
123
+ type: "object",
124
+ properties: { path: { type: "string" } },
125
+ required: ["path"],
126
+ },
127
+ },
128
+ {
129
+ name: "click",
130
+ description: "Click a button, link, or element on screen.",
131
+ input_schema: {
132
+ type: "object",
133
+ properties: { description: { type: "string" } },
134
+ required: ["description"],
135
+ },
136
+ },
137
+ {
138
+ name: "fill",
139
+ description: "Type text into an input field.",
140
+ input_schema: {
141
+ type: "object",
142
+ properties: {
143
+ label: { type: "string" },
144
+ value: { type: "string" },
145
+ },
146
+ required: ["label", "value"],
147
+ },
148
+ },
149
+ {
150
+ name: "read_page_text",
151
+ description: "Get all visible text on the page.",
152
+ input_schema: { type: "object", properties: {}, required: [] },
153
+ },
154
+ {
155
+ name: "read_accessibility_tree",
156
+ description: "Get the page accessibility tree.",
157
+ input_schema: { type: "object", properties: {}, required: [] },
158
+ },
159
+ {
160
+ name: "save_account",
161
+ description: "Save a test account you successfully created. Call this once per account.",
162
+ input_schema: {
163
+ type: "object",
164
+ properties: {
165
+ email: { type: "string", description: "Email address of the new account" },
166
+ password: { type: "string", description: "Password of the new account" },
167
+ role: { type: "string", description: "Role or permission level (e.g. admin, member, viewer)" },
168
+ },
169
+ required: ["email", "password", "role"],
170
+ },
171
+ },
172
+ {
173
+ name: "post_finding",
174
+ description: "Record a UX issue you encountered while navigating user management.",
175
+ input_schema: {
176
+ type: "object",
177
+ properties: {
178
+ title: { type: "string" },
179
+ body: { type: "string" },
180
+ },
181
+ required: ["title", "body"],
182
+ },
183
+ },
184
+ {
185
+ name: "done",
186
+ description: "Signal that account setup is complete.",
187
+ input_schema: { type: "object", properties: {}, required: [] },
188
+ },
189
+ ];
190
+
191
+ // ================================================================
192
+ // Main
193
+ // ================================================================
194
+
195
+ export async function runAccountManager(
196
+ baseUrl: string,
197
+ credentials: Credentials,
198
+ productSpec: ProductSpec,
199
+ context: BrowserContext,
200
+ client: LLMClient,
201
+ model: string,
202
+ runId: string,
203
+ ): Promise<TestAccount[]> {
204
+ console.log("\n[account-manager] starting...");
205
+
206
+ const page = await context.newPage();
207
+ const observation = setupObservation(page);
208
+
209
+ // まず seed アカウントでログイン
210
+ console.log(`[account-manager] logging in as ${credentials.email}...`);
211
+ const loggedIn = await performLogin(page, baseUrl, credentials);
212
+ if (!loggedIn) {
213
+ console.warn("[account-manager] login failed — skipping account setup");
214
+ await page.close();
215
+ return [];
216
+ }
217
+ console.log("[account-manager] login succeeded");
218
+
219
+ const initialScreenshot = await takeScreenshot(page, "initial");
220
+ const savedAccounts: Omit<TestAccount, "storageStatePath">[] = [];
221
+
222
+ const systemPrompt = `You are the Account Manager for "${productSpec.appName}".
223
+ You are already logged in as the seed account (${credentials.email}).
224
+
225
+ Your job:
226
+ 1. Explore the app to find user management features (settings, admin panel, user list, invite, etc.)
227
+ 2. Identify what roles or permission levels exist (e.g. admin, member, viewer, manager)
228
+ 3. Create one test account per role you find — use realistic-looking test emails like test-admin@example.com
229
+ 4. Use save_account to record each account you successfully create
230
+ 5. If you encounter confusing, broken, or hard-to-find UI during this process, use post_finding to record it as a UX issue
231
+ 6. When done (or after 10 actions), call done
232
+
233
+ [App Overview]
234
+ ${productSpec.appDescription}
235
+
236
+ [Known Features]
237
+ ${productSpec.features}
238
+
239
+ If user management is not accessible from this account, or the app has no role system, just call done immediately.`;
240
+
241
+ const messages: Anthropic.MessageParam[] = [
242
+ {
243
+ role: "user",
244
+ content: [
245
+ { type: "image", source: { type: "base64", media_type: "image/png", data: initialScreenshot } },
246
+ { type: "text", text: "You are logged in. Start exploring user management." },
247
+ ],
248
+ },
249
+ ];
250
+
251
+ let iterations = 0;
252
+ outer: while (iterations < 12) {
253
+ iterations++;
254
+
255
+ const response = await createMessageWithRetry(client, {
256
+ model,
257
+ max_tokens: 1024,
258
+ system: systemPrompt,
259
+ tools: ACCOUNT_MANAGER_TOOLS,
260
+ messages,
261
+ });
262
+
263
+ messages.push({ role: "assistant", content: response.content });
264
+
265
+ const toolUses = response.content.filter(
266
+ (b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
267
+ );
268
+ if (toolUses.length === 0 || response.stop_reason === "end_turn") break;
269
+
270
+ const toolResults: Anthropic.ToolResultBlockParam[] = [];
271
+
272
+ for (const toolUse of toolUses) {
273
+ let resultText = "";
274
+ let screenshot: string | null = null;
275
+
276
+ try {
277
+ switch (toolUse.name) {
278
+ case "done": {
279
+ toolResults.push({ type: "tool_result", tool_use_id: toolUse.id, content: "Done." });
280
+ break outer;
281
+ }
282
+
283
+ case "view_screen": {
284
+ screenshot = await takeScreenshot(page, "view");
285
+ resultText = "Current screen.";
286
+ break;
287
+ }
288
+
289
+ case "navigate": {
290
+ const { path: navPath } = toolUse.input as { path: string };
291
+ await saveSnapshotBeforeAction(page, observation);
292
+ await page.goto(`${baseUrl}${navPath}`, { waitUntil: "networkidle" });
293
+ await page.waitForTimeout(500);
294
+ screenshot = await takeScreenshot(page, `nav_${navPath}`);
295
+ resultText = `Navigated to ${navPath}`;
296
+ break;
297
+ }
298
+
299
+ case "click": {
300
+ const { description } = toolUse.input as { description: string };
301
+ await saveSnapshotBeforeAction(page, observation);
302
+ const escaped = description.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
303
+ let clicked = false;
304
+ for (const loc of [
305
+ page.getByRole("button", { name: new RegExp(escaped, "i") }),
306
+ page.getByRole("link", { name: new RegExp(escaped, "i") }),
307
+ page.getByText(description, { exact: false }),
308
+ ]) {
309
+ try { await loc.first().click({ timeout: 4000 }); clicked = true; break; } catch { /* next */ }
310
+ }
311
+ if (!clicked) throw new Error(`No element matching: ${description}`);
312
+ await page.waitForTimeout(500);
313
+ screenshot = await takeScreenshot(page, `click`);
314
+ resultText = `Clicked: ${description}`;
315
+ break;
316
+ }
317
+
318
+ case "fill": {
319
+ const { label, value } = toolUse.input as { label: string; value: string };
320
+ await saveSnapshotBeforeAction(page, observation);
321
+ const byLabel = page.getByLabel(new RegExp(label, "i"));
322
+ const byPlaceholder = page.getByPlaceholder(new RegExp(label, "i"));
323
+ let filled = false;
324
+ for (const loc of [byLabel, byPlaceholder]) {
325
+ try { await loc.first().fill(value, { timeout: 3000 }); filled = true; break; } catch { /* next */ }
326
+ }
327
+ if (!filled) throw new Error(`No input matching: ${label}`);
328
+ resultText = `Filled "${label}" with "${value}"`;
329
+ break;
330
+ }
331
+
332
+ case "read_page_text": {
333
+ resultText = await readPageText(page);
334
+ break;
335
+ }
336
+
337
+ case "read_accessibility_tree": {
338
+ resultText = await readAccessibilityTree(page);
339
+ break;
340
+ }
341
+
342
+ case "save_account": {
343
+ const { email, password, role } = toolUse.input as { email: string; password: string; role: string };
344
+ savedAccounts.push({ email, password, role });
345
+ console.log(` [account-manager] saved account: ${email} (role: ${role})`);
346
+ resultText = `Account saved: ${email} (${role})`;
347
+ break;
348
+ }
349
+
350
+ case "post_finding": {
351
+ const { title, body } = toolUse.input as { title: string; body: string };
352
+ saveFinding({
353
+ id: `acct_${Date.now()}`,
354
+ runId,
355
+ agentId: "account-manager",
356
+ agentName: "Account Manager",
357
+ role: "setup",
358
+ title,
359
+ body,
360
+ category: "ux",
361
+ timestamp: new Date().toISOString(),
362
+ });
363
+ console.log(` [account-manager] finding: ${title}`);
364
+ resultText = "Finding recorded.";
365
+ break;
366
+ }
367
+ }
368
+ } catch (e) {
369
+ resultText = `error: ${String(e)}`;
370
+ }
371
+
372
+ const content: Anthropic.ToolResultBlockParam["content"] = screenshot
373
+ ? [
374
+ { type: "image", source: { type: "base64", media_type: "image/png", data: screenshot } },
375
+ { type: "text", text: resultText },
376
+ ]
377
+ : resultText;
378
+
379
+ toolResults.push({ type: "tool_result", tool_use_id: toolUse.id, content });
380
+ }
381
+
382
+ messages.push({ role: "user", content: toolResults });
383
+ }
384
+
385
+ await page.close();
386
+ console.log(`[account-manager] found ${savedAccounts.length} account(s)`);
387
+
388
+ if (savedAccounts.length === 0) return [];
389
+
390
+ // 各アカウントにログインして storageState を保存
391
+ const testAccounts: TestAccount[] = [];
392
+ for (const account of savedAccounts) {
393
+ const stateDir = path.join(ACCOUNTS_DIR, "states");
394
+ fs.mkdirSync(stateDir, { recursive: true });
395
+ const statePath = path.join(stateDir, `${account.role.replace(/[^a-zA-Z0-9]/g, "_")}.json`);
396
+
397
+ console.log(` [account-manager] saving session for role: ${account.role}`);
398
+ const loginPage = await context.newPage();
399
+ const ok = await performLogin(loginPage, baseUrl, account);
400
+ if (ok) {
401
+ await context.storageState({ path: statePath });
402
+ console.log(` saved: ${statePath}`);
403
+ } else {
404
+ console.warn(` login failed for ${account.email} — storageState not saved`);
405
+ }
406
+ await loginPage.close();
407
+
408
+ testAccounts.push({ ...account, storageStatePath: ok ? statePath : "" });
409
+ }
410
+
411
+ saveTestAccounts(testAccounts);
412
+ console.log(`[account-manager] done (${testAccounts.length} account(s) ready)`);
413
+ return testAccounts;
414
+ }
@@ -0,0 +1,103 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import type { LLMClient, CreateMessageParams, Tool } from "./llm-client";
3
+ import type { AgentLog } from "./types";
4
+ import { runLog } from "./findings";
5
+
6
+ export let rateLimitRetries = 0;
7
+
8
+ export async function sleep(ms: number): Promise<void> {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+
12
+ export async function createMessageWithRetry(
13
+ client: LLMClient,
14
+ params: CreateMessageParams,
15
+ retries = 5
16
+ ): Promise<Anthropic.Message> {
17
+ for (let i = 0; i < retries; i++) {
18
+ try {
19
+ const response = await client.createMessage(params);
20
+ if (runLog?.summary?.cost) {
21
+ runLog.summary.cost.inputTokens += response.usage?.input_tokens ?? 0;
22
+ runLog.summary.cost.outputTokens += response.usage?.output_tokens ?? 0;
23
+ }
24
+ return response;
25
+ } catch (e: unknown) {
26
+ const err = e as { status?: number; headers?: { get?: (key: string) => string | null } };
27
+ if (err?.status === 429 && i < retries - 1) {
28
+ const retryAfter = err?.headers?.get?.("retry-after");
29
+ const waitMs = retryAfter ? parseInt(retryAfter) * 1000 : (i + 1) * 10000;
30
+ console.log(` [rate-limit] waiting ${waitMs / 1000}s (attempt ${i + 1}/${retries})`);
31
+ rateLimitRetries++;
32
+ await sleep(waitMs);
33
+ continue;
34
+ }
35
+ throw e;
36
+ }
37
+ }
38
+ throw new Error("max retries exceeded");
39
+ }
40
+
41
+ export async function runAgentLoop(
42
+ agentLog: AgentLog,
43
+ systemPrompt: string,
44
+ tools: Tool[],
45
+ client: LLMClient,
46
+ model: string,
47
+ executeToolFn: (toolName: string, input: Record<string, unknown>) => Promise<string>
48
+ ): Promise<void> {
49
+ const messages: Anthropic.MessageParam[] = [
50
+ { role: "user", content: "Use the app." },
51
+ ];
52
+
53
+ try {
54
+ while (agentLog.iterations < 10) {
55
+ agentLog.iterations++;
56
+
57
+ const response = await createMessageWithRetry(client, {
58
+ model,
59
+ max_tokens: 1024,
60
+ system: systemPrompt,
61
+ tools,
62
+ messages,
63
+ });
64
+
65
+ const assistantContent: Anthropic.ContentBlock[] = response.content;
66
+ messages.push({ role: "assistant", content: assistantContent });
67
+
68
+ const toolUses = assistantContent.filter(
69
+ (b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
70
+ );
71
+
72
+ if (toolUses.length === 0 || response.stop_reason === "end_turn") {
73
+ agentLog.status = "completed";
74
+ break;
75
+ }
76
+
77
+ if (agentLog.iterations >= 10) {
78
+ agentLog.status = "iteration_limit";
79
+ runLog.summary.iterationLimitReached++;
80
+ }
81
+
82
+ const toolResults: Anthropic.ToolResultBlockParam[] = [];
83
+ for (const toolUse of toolUses) {
84
+ console.log(` → ${toolUse.name}(${JSON.stringify(toolUse.input).slice(0, 80)})`);
85
+ const result = await executeToolFn(
86
+ toolUse.name,
87
+ toolUse.input as Record<string, unknown>
88
+ );
89
+ toolResults.push({ type: "tool_result", tool_use_id: toolUse.id, content: result });
90
+ }
91
+
92
+ messages.push({ role: "user", content: toolResults });
93
+ }
94
+ runLog.summary.completed++;
95
+ } catch (e) {
96
+ agentLog.status = "error";
97
+ agentLog.error = String(e);
98
+ runLog.summary.errors++;
99
+ console.error(`[${agentLog.agentName}] error:`, e);
100
+ } finally {
101
+ agentLog.completedAt = new Date().toISOString();
102
+ }
103
+ }
@@ -0,0 +1,47 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+
4
+ export interface Agent {
5
+ id: string;
6
+ name: string;
7
+ role: string;
8
+ persona: string;
9
+ createdAt: string;
10
+ }
11
+
12
+ const STORE_PATH = path.join(process.cwd(), "agents.json");
13
+
14
+ export function loadAgents(): Agent[] {
15
+ if (!fs.existsSync(STORE_PATH)) return [];
16
+ try {
17
+ return JSON.parse(fs.readFileSync(STORE_PATH, "utf-8")) as Agent[];
18
+ } catch {
19
+ return [];
20
+ }
21
+ }
22
+
23
+ function saveAgents(agents: Agent[]): void {
24
+ fs.writeFileSync(STORE_PATH, JSON.stringify(agents, null, 2), "utf-8");
25
+ }
26
+
27
+ export function addAgent(input: { name: string; role: string; persona: string }): Agent {
28
+ const agents = loadAgents();
29
+ const agent: Agent = {
30
+ id: `agent_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
31
+ name: input.name,
32
+ role: input.role,
33
+ persona: input.persona,
34
+ createdAt: new Date().toISOString(),
35
+ };
36
+ agents.push(agent);
37
+ saveAgents(agents);
38
+ return agent;
39
+ }
40
+
41
+ export function retireAgent(id: string): boolean {
42
+ const agents = loadAgents();
43
+ const filtered = agents.filter((a) => a.id !== id);
44
+ if (filtered.length === agents.length) return false;
45
+ saveAgents(filtered);
46
+ return true;
47
+ }
@@ -0,0 +1,91 @@
1
+ // Per-token USD prices (as of 2026-04)
2
+ const ANTHROPIC_PRICING: Record<string, { input: number; output: number }> = {
3
+ "claude-opus-4-7": { input: 15 / 1e6, output: 75 / 1e6 },
4
+ "claude-sonnet-4-6": { input: 3 / 1e6, output: 15 / 1e6 },
5
+ "claude-haiku-4-5-20251001": { input: 0.8 / 1e6, output: 4 / 1e6 },
6
+ "claude-haiku-4-5": { input: 0.8 / 1e6, output: 4 / 1e6 },
7
+ "claude-3-5-sonnet-20241022": { input: 3 / 1e6, output: 15 / 1e6 },
8
+ "claude-3-5-haiku-20241022": { input: 0.8 / 1e6, output: 4 / 1e6 },
9
+ "claude-3-opus-20240229": { input: 15 / 1e6, output: 75 / 1e6 },
10
+ };
11
+
12
+ const OPENAI_PRICING: Record<string, { input: number; output: number }> = {
13
+ "gpt-4o": { input: 5 / 1e6, output: 15 / 1e6 },
14
+ "gpt-4o-mini": { input: 0.15 / 1e6, output: 0.6 / 1e6 },
15
+ "gpt-4-turbo": { input: 10 / 1e6, output: 30 / 1e6 },
16
+ "o1": { input: 15 / 1e6, output: 60 / 1e6 },
17
+ "o1-mini": { input: 3 / 1e6, output: 12 / 1e6 },
18
+ "o3-mini": { input: 1.1 / 1e6, output: 4.4 / 1e6 },
19
+ "o3": { input: 10 / 1e6, output: 40 / 1e6 },
20
+ };
21
+
22
+ // Local / subscription providers — cost tracking not applicable
23
+ const FREE_PROVIDERS = new Set(["ollama", "lm-studio", "codex", "local"]);
24
+
25
+ let openrouterCache: Map<string, { input: number; output: number }> | null = null;
26
+ let openrouterCachedAt = 0;
27
+ const CACHE_TTL_MS = 60 * 60 * 1000;
28
+
29
+ async function fetchOpenRouterPricing(): Promise<Map<string, { input: number; output: number }>> {
30
+ if (openrouterCache && Date.now() - openrouterCachedAt < CACHE_TTL_MS) {
31
+ return openrouterCache;
32
+ }
33
+ try {
34
+ const res = await fetch("https://openrouter.ai/api/v1/models", {
35
+ signal: AbortSignal.timeout(8000),
36
+ });
37
+ if (!res.ok) throw new Error(`status ${res.status}`);
38
+ const data = await res.json() as {
39
+ data: Array<{ id: string; pricing?: { prompt?: string; completion?: string } }>;
40
+ };
41
+ const map = new Map<string, { input: number; output: number }>();
42
+ for (const m of data.data) {
43
+ const inp = parseFloat(m.pricing?.prompt ?? "0");
44
+ const out = parseFloat(m.pricing?.completion ?? "0");
45
+ if (inp >= 0 && out >= 0) map.set(m.id, { input: inp, output: out });
46
+ }
47
+ openrouterCache = map;
48
+ openrouterCachedAt = Date.now();
49
+ console.log(`[cost] OpenRouter pricing loaded (${map.size} models)`);
50
+ return map;
51
+ } catch (e) {
52
+ console.warn("[cost] OpenRouter pricing fetch failed:", String(e));
53
+ return openrouterCache ?? new Map();
54
+ }
55
+ }
56
+
57
+ export async function estimateCost(
58
+ model: string,
59
+ provider: string,
60
+ inputTokens: number,
61
+ outputTokens: number,
62
+ ): Promise<number | null> {
63
+ if (FREE_PROVIDERS.has(provider)) return null;
64
+
65
+ let pricing: { input: number; output: number } | undefined;
66
+
67
+ if (provider === "anthropic") {
68
+ pricing = ANTHROPIC_PRICING[model];
69
+ if (!pricing) {
70
+ // prefix match (e.g. "claude-haiku-4-5-20251001" → matches "claude-haiku-4-5")
71
+ const key = Object.keys(ANTHROPIC_PRICING).find((k) => model.startsWith(k));
72
+ if (key) pricing = ANTHROPIC_PRICING[key];
73
+ }
74
+ } else if (provider === "openai") {
75
+ pricing = OPENAI_PRICING[model];
76
+ } else if (provider === "openrouter") {
77
+ const map = await fetchOpenRouterPricing();
78
+ pricing = map.get(model);
79
+ }
80
+
81
+ if (!pricing) return null;
82
+ return pricing.input * inputTokens + pricing.output * outputTokens;
83
+ }
84
+
85
+ export function formatCostUSD(usd: number | null | undefined): string {
86
+ if (usd == null) return "—";
87
+ if (usd < 0.0001) return "< $0.0001";
88
+ if (usd < 0.01) return `$${usd.toFixed(4)}`;
89
+ if (usd < 1) return `$${usd.toFixed(3)}`;
90
+ return `$${usd.toFixed(2)}`;
91
+ }