@hraness/dawg 0.0.0-stage → 0.3.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +327 -0
  3. package/LICENSE +21 -0
  4. package/README.md +213 -2
  5. package/core/diff.ts +249 -0
  6. package/core/drums.ts +102 -0
  7. package/core/key.ts +43 -0
  8. package/core/loop.ts +78 -0
  9. package/core/pitch.ts +60 -0
  10. package/core/score.ts +1388 -0
  11. package/core/sdk/eval-child.ts +113 -0
  12. package/core/sdk/eval.ts +257 -0
  13. package/core/sdk/print.ts +393 -0
  14. package/core/sdk/v1.ts +954 -0
  15. package/core/slug.ts +19 -0
  16. package/package.json +45 -4
  17. package/src/agent/agent.ts +853 -0
  18. package/src/agent/brief.ts +160 -0
  19. package/src/agent/gateway.ts +441 -0
  20. package/src/agent/models.ts +633 -0
  21. package/src/agent/ops.ts +157 -0
  22. package/src/agent/planner.ts +259 -0
  23. package/src/agent/provider.ts +454 -0
  24. package/src/agent/sse.ts +114 -0
  25. package/src/agent/tools.ts +1373 -0
  26. package/src/agent/usage.ts +296 -0
  27. package/src/agent/workspace.ts +683 -0
  28. package/src/agent/xcb-agent.ts +262 -0
  29. package/src/agent/xcb.ts +579 -0
  30. package/src/audio/click.ts +125 -0
  31. package/src/audio/clock.ts +68 -0
  32. package/src/audio/engine.ts +841 -0
  33. package/src/audio/live.ts +152 -0
  34. package/src/audio/lock.ts +57 -0
  35. package/src/audio/player.ts +134 -0
  36. package/src/audio/render-worker.ts +68 -0
  37. package/src/audio/renderer.ts +174 -0
  38. package/src/audio/sampler.ts +292 -0
  39. package/src/audio/samples.ts +683 -0
  40. package/src/audio/wav.ts +861 -0
  41. package/src/auth/cli.ts +231 -0
  42. package/src/auth/credentials.ts +411 -0
  43. package/src/auth/discover.ts +481 -0
  44. package/src/auth/login.ts +1191 -0
  45. package/src/auth/openrouter.ts +206 -0
  46. package/src/auth/picker.ts +282 -0
  47. package/src/auth/runner.ts +207 -0
  48. package/src/auth/tui.ts +107 -0
  49. package/src/commands/edit.ts +170 -0
  50. package/src/commands/help.ts +247 -0
  51. package/src/commands/history.ts +69 -0
  52. package/src/commands/music.ts +461 -0
  53. package/src/commands/sample.ts +302 -0
  54. package/src/daemon.ts +31 -0
  55. package/src/main.ts +2209 -0
  56. package/src/media/analyze.ts +364 -0
  57. package/src/media/backend.ts +253 -0
  58. package/src/media/cli.ts +173 -0
  59. package/src/media/download.ts +281 -0
  60. package/src/media/dsp.ts +281 -0
  61. package/src/media/import.ts +130 -0
  62. package/src/media/lyrics.ts +201 -0
  63. package/src/media/notes.ts +363 -0
  64. package/src/media/paths.ts +168 -0
  65. package/src/media/process.ts +226 -0
  66. package/src/media/registry.ts +9 -0
  67. package/src/media/sidecar.ts +72 -0
  68. package/src/media/stemdeck.ts +254 -0
  69. package/src/media/stems.ts +173 -0
  70. package/src/media/tools.ts +292 -0
  71. package/src/media/types.ts +92 -0
  72. package/src/media/vendor/basic-pitch.ts +261 -0
  73. package/src/media/vendor/drums.ts +817 -0
  74. package/src/media/vendor/grid.ts +203 -0
  75. package/src/media/vendor/util.ts +139 -0
  76. package/src/media/vendor/wav.ts +233 -0
  77. package/src/project/check.ts +80 -0
  78. package/src/project/init.ts +253 -0
  79. package/src/project/sync.ts +432 -0
  80. package/src/project/typecheck.ts +149 -0
  81. package/src/render.ts +121 -0
  82. package/src/session/attach.ts +181 -0
  83. package/src/session/client.ts +498 -0
  84. package/src/session/daemon.ts +740 -0
  85. package/src/session/delta.ts +249 -0
  86. package/src/session/list.ts +180 -0
  87. package/src/session/lock.ts +92 -0
  88. package/src/session/meta.ts +253 -0
  89. package/src/session/naming.ts +430 -0
  90. package/src/session/port.ts +481 -0
  91. package/src/session/presence.ts +159 -0
  92. package/src/session/protocol.ts +618 -0
  93. package/src/session/rebase.ts +168 -0
  94. package/src/session/store.ts +581 -0
  95. package/src/tui/menu.ts +1083 -0
  96. package/src/tui/play-mode.ts +442 -0
  97. package/src/tui/play-session.ts +636 -0
  98. package/src/web/fetch.ts +340 -0
  99. package/src/web/http.ts +137 -0
  100. package/src/web/search.ts +681 -0
  101. package/tui/activity.ts +364 -0
  102. package/tui/app.ts +1372 -0
  103. package/tui/drums.ts +65 -0
  104. package/tui/highway.ts +921 -0
  105. package/tui/input.ts +63 -0
  106. package/tui/keys.ts +102 -0
  107. package/tui/layers.ts +80 -0
  108. package/tui/play-strip.ts +143 -0
  109. package/tui/prompt.ts +609 -0
  110. package/tui/render.ts +124 -0
  111. package/tui/screen.ts +247 -0
  112. package/tui/text.ts +72 -0
  113. package/tui/theme.ts +451 -0
@@ -0,0 +1,633 @@
1
+ import { mkdir, readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { writePrivateJson } from "../auth/credentials.ts";
4
+ import {
5
+ DEFAULT_MODEL_IDS,
6
+ GATEWAY_BASE_URL,
7
+ isGatewayModel,
8
+ OPENROUTER_BASE_URL,
9
+ type ApiProvider,
10
+ } from "./gateway.ts";
11
+
12
+ /**
13
+ * The model catalog behind `/model` and `dawg model`: a curated list of
14
+ * frontier, fast and open-weight models per key-based provider, filtered by
15
+ * what the provider actually serves with tool calling, plus per-prompt cost
16
+ * estimates from models.dev (or OpenRouter's own pricing on OpenRouter).
17
+ */
18
+ export type ModelClass = "frontier" | "fast" | "open";
19
+ export const CLASS_TITLES: Readonly<Record<ModelClass, string>> = Object.freeze(
20
+ { frontier: "frontier", fast: "fast", open: "open weights" },
21
+ );
22
+
23
+ export type CatalogEntry = Readonly<{
24
+ /** Short name used in the UI and accepted by `/model <alias>`. */
25
+ alias: string;
26
+ label: string;
27
+ class: ModelClass;
28
+ gateway?: string;
29
+ openrouter?: string;
30
+ }>;
31
+
32
+ /**
33
+ * Slugs checked against the live catalogs on 2026-10-06
34
+ * (`GET ai-gateway.vercel.sh/v1/models`, tag `tool-use`; `GET
35
+ * openrouter.ai/api/v1/models`, `supported_parameters` has `tools`). The live
36
+ * filter drops anything a provider stops serving.
37
+ */
38
+ export const MODEL_CATALOG: readonly CatalogEntry[] = Object.freeze([
39
+ {
40
+ alias: "opus-5.5",
41
+ label: "Claude Opus 5.5",
42
+ class: "frontier",
43
+ gateway: "anthropic/claude-opus-5.5",
44
+ openrouter: "anthropic/claude-opus-5.5",
45
+ },
46
+ {
47
+ alias: "fable-5.1",
48
+ label: "Claude Fable 5.1",
49
+ class: "frontier",
50
+ gateway: "anthropic/claude-fable-5.1",
51
+ openrouter: "anthropic/claude-fable-5.1",
52
+ },
53
+ {
54
+ alias: "sol-6.1",
55
+ label: "GPT-6.1 Sol",
56
+ class: "frontier",
57
+ gateway: "openai/gpt-6.1-sol",
58
+ openrouter: "openai/gpt-6.1-sol",
59
+ },
60
+ {
61
+ alias: "gemini-3.1-pro",
62
+ label: "Gemini 3.1 Pro",
63
+ class: "frontier",
64
+ gateway: "google/gemini-3.1-pro-preview",
65
+ openrouter: "google/gemini-3.1-pro-preview",
66
+ },
67
+ {
68
+ alias: "sonnet-5.5",
69
+ label: "Claude Sonnet 5.5",
70
+ class: "fast",
71
+ gateway: "anthropic/claude-sonnet-5.5",
72
+ openrouter: "anthropic/claude-sonnet-5.5",
73
+ },
74
+ {
75
+ alias: "haiku-4.5",
76
+ label: "Claude Haiku 4.5",
77
+ class: "fast",
78
+ gateway: "anthropic/claude-haiku-4.5",
79
+ openrouter: "anthropic/claude-haiku-4.5",
80
+ },
81
+ {
82
+ alias: "gpt-5.4-mini",
83
+ label: "GPT-5.4 mini",
84
+ class: "fast",
85
+ gateway: "openai/gpt-5.4-mini",
86
+ openrouter: "openai/gpt-5.4-mini",
87
+ },
88
+ {
89
+ alias: "gemini-3.8-flash",
90
+ label: "Gemini 3.8 Flash",
91
+ class: "fast",
92
+ gateway: "google/gemini-3.8-flash",
93
+ openrouter: "google/gemini-3.8-flash",
94
+ },
95
+ {
96
+ alias: "deepseek-v4-pro",
97
+ label: "DeepSeek V4 Pro",
98
+ class: "open",
99
+ gateway: "deepseek/deepseek-v4-pro",
100
+ openrouter: "deepseek/deepseek-v4-pro",
101
+ },
102
+ {
103
+ alias: "kimi-k3",
104
+ label: "Kimi K3",
105
+ class: "open",
106
+ gateway: "moonshotai/kimi-k3",
107
+ openrouter: "moonshotai/kimi-k3",
108
+ },
109
+ {
110
+ alias: "qwen3.8-27b",
111
+ label: "Qwen3.8 27B",
112
+ class: "open",
113
+ gateway: "alibaba/qwen3.8-27b",
114
+ openrouter: "qwen/qwen3.8-27b",
115
+ },
116
+ {
117
+ alias: "glm-5.3",
118
+ label: "GLM-5.3",
119
+ class: "open",
120
+ gateway: "zai/glm-5.3",
121
+ openrouter: "z-ai/glm-5.3",
122
+ },
123
+ {
124
+ alias: "llama-4-maverick",
125
+ label: "Llama 4 Maverick",
126
+ class: "open",
127
+ gateway: "meta/llama-4-maverick",
128
+ openrouter: "meta-llama/llama-4-maverick",
129
+ },
130
+ ]);
131
+
132
+ const MODEL_ID_PATTERN =
133
+ /^[a-z0-9][a-z0-9-]{0,63}\/[a-z0-9][a-z0-9._-]{0,127}$/i;
134
+
135
+ export function defaultModelId(provider: ApiProvider): string {
136
+ return provider === "openrouter"
137
+ ? MODEL_CATALOG[0]!.openrouter!
138
+ : DEFAULT_MODEL_IDS["opus-5.5"];
139
+ }
140
+
141
+ /** An alias, a catalog label, or an exact `vendor/model` ID → the provider's ID. */
142
+ export function resolveModelChoice(
143
+ provider: ApiProvider,
144
+ input: string,
145
+ ): string | undefined {
146
+ const value = input.trim();
147
+ const lower = value.toLowerCase();
148
+ const entry = MODEL_CATALOG.find(
149
+ (row) => row.alias === lower || row.label.toLowerCase() === lower,
150
+ );
151
+ if (entry) return entry[provider];
152
+ if (isGatewayModel(lower)) return DEFAULT_MODEL_IDS[lower];
153
+ return MODEL_ID_PATTERN.test(value) ? value : undefined;
154
+ }
155
+
156
+ /** `anthropic/claude-opus-5.5` → `opus-5.5`; unknown IDs show as themselves. */
157
+ export function modelAlias(
158
+ provider: ApiProvider | undefined,
159
+ id: string,
160
+ ): string {
161
+ const entry = MODEL_CATALOG.find(
162
+ (row) =>
163
+ (provider ? row[provider] === id : false) ||
164
+ row.gateway === id ||
165
+ row.openrouter === id,
166
+ );
167
+ return entry?.alias ?? id;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Pricing
172
+
173
+ /** USD per token. */
174
+ export type Price = Readonly<{
175
+ input: number;
176
+ output: number;
177
+ cacheRead?: number;
178
+ }>;
179
+
180
+ export type PriceTable = Readonly<{
181
+ fetchedAt: number;
182
+ /** models.dev provider id (`vercel`, `openrouter`, …) → model id → price. */
183
+ providers: Readonly<Record<string, Readonly<Record<string, Price>>>>;
184
+ }>;
185
+
186
+ export const MODELS_DEV_URL = "https://models.dev/api.json";
187
+ export const PRICE_TTL_MS = 24 * 60 * 60_000;
188
+ /** models.dev is ~5 MB today; refuse anything far larger. */
189
+ const MAX_MODELS_DEV_BYTES = 24 * 1024 * 1024;
190
+ const MAX_CATALOG_BYTES = 8 * 1024 * 1024;
191
+ const MODELS_DEV_PROVIDERS = new Set([
192
+ "vercel",
193
+ "openrouter",
194
+ "anthropic",
195
+ "openai",
196
+ "google",
197
+ "deepseek",
198
+ "moonshotai",
199
+ "zai",
200
+ "alibaba",
201
+ "llama",
202
+ ]);
203
+ const PRICE_TIMEOUT_MS = 8_000;
204
+
205
+ type Fetcher = (
206
+ input: RequestInfo | URL,
207
+ init?: RequestInit,
208
+ ) => Promise<Response>;
209
+
210
+ function perToken(value: unknown, scale: number): number | undefined {
211
+ const number = typeof value === "string" ? Number(value) : value;
212
+ return typeof number === "number" && Number.isFinite(number) && number >= 0
213
+ ? number / scale
214
+ : undefined;
215
+ }
216
+
217
+ function isRecord(value: unknown): value is Record<string, unknown> {
218
+ return typeof value === "object" && value !== null && !Array.isArray(value);
219
+ }
220
+
221
+ /**
222
+ * Keep only prices from models.dev's `{provider: {models: {id: {cost}}}}`
223
+ * (USD per million tokens). A 0/0 price is treated as unknown: models.dev
224
+ * uses it for entries it has not priced.
225
+ */
226
+ export function parseModelsDev(value: unknown, now = Date.now()): PriceTable {
227
+ const providers: Record<string, Record<string, Price>> = {};
228
+ if (!isRecord(value)) return { fetchedAt: now, providers };
229
+ for (const [providerId, provider] of Object.entries(value)) {
230
+ if (!MODELS_DEV_PROVIDERS.has(providerId) || !isRecord(provider)) continue;
231
+ const models = isRecord(provider.models) ? provider.models : {};
232
+ const table: Record<string, Price> = {};
233
+ for (const [id, model] of Object.entries(models)) {
234
+ if (!isRecord(model) || !isRecord(model.cost) || id.length > 200)
235
+ continue;
236
+ const input = perToken(model.cost.input, 1e6);
237
+ const output = perToken(model.cost.output, 1e6);
238
+ if (input === undefined || output === undefined) continue;
239
+ if (input === 0 && output === 0 && !id.endsWith(":free")) continue;
240
+ const cacheRead = perToken(model.cost.cache_read, 1e6);
241
+ table[id] =
242
+ cacheRead === undefined
243
+ ? { input, output }
244
+ : { input, output, cacheRead };
245
+ }
246
+ providers[providerId] = table;
247
+ }
248
+ return { fetchedAt: now, providers };
249
+ }
250
+
251
+ async function readBounded(response: Response, max: number): Promise<string> {
252
+ const length = Number(response.headers.get("content-length") ?? 0);
253
+ if (length > max) {
254
+ await response.body?.cancel().catch(() => undefined);
255
+ throw new Error("response too large");
256
+ }
257
+ const reader = response.body?.getReader();
258
+ if (!reader) return "";
259
+ const chunks: Uint8Array[] = [];
260
+ let total = 0;
261
+ for (;;) {
262
+ const { done, value } = await reader.read();
263
+ if (done) break;
264
+ total += value.byteLength;
265
+ if (total > max) {
266
+ await reader.cancel().catch(() => undefined);
267
+ throw new Error("response too large");
268
+ }
269
+ chunks.push(value);
270
+ }
271
+ return Buffer.concat(chunks).toString("utf8");
272
+ }
273
+
274
+ export function cacheDir(configDirectory: string): string {
275
+ return join(configDirectory, "cache");
276
+ }
277
+
278
+ async function readCache<T>(
279
+ path: string,
280
+ parse: (value: unknown) => T | undefined,
281
+ ): Promise<T | undefined> {
282
+ try {
283
+ const info = await stat(path);
284
+ if (!info.isFile() || info.size > MAX_CATALOG_BYTES) return undefined;
285
+ return parse(JSON.parse(await readFile(path, "utf8")));
286
+ } catch {
287
+ return undefined;
288
+ }
289
+ }
290
+
291
+ function parsePriceCache(value: unknown): PriceTable | undefined {
292
+ if (
293
+ !isRecord(value) ||
294
+ typeof value.fetchedAt !== "number" ||
295
+ !isRecord(value.providers)
296
+ )
297
+ return undefined;
298
+ return value as PriceTable;
299
+ }
300
+
301
+ export type PriceOptions = Readonly<{
302
+ configDir: string;
303
+ fetcher?: Fetcher;
304
+ now?: () => number;
305
+ url?: string;
306
+ }>;
307
+
308
+ /**
309
+ * Prices from the ~24 h cache, refreshed from models.dev when stale. Offline
310
+ * or on any fetch error, a stale cache wins; with no cache, an empty table.
311
+ */
312
+ export async function loadPrices(options: PriceOptions): Promise<PriceTable> {
313
+ const now = options.now ?? Date.now;
314
+ const path = join(cacheDir(options.configDir), "models-dev.json");
315
+ const cached = await readCache(path, parsePriceCache);
316
+ if (cached && now() - cached.fetchedAt < PRICE_TTL_MS) return cached;
317
+ try {
318
+ const response = await (options.fetcher ?? fetch)(
319
+ options.url ?? MODELS_DEV_URL,
320
+ {
321
+ signal: AbortSignal.timeout(PRICE_TIMEOUT_MS),
322
+ headers: { accept: "application/json" },
323
+ },
324
+ );
325
+ if (!response.ok) throw new Error(`models.dev ${response.status}`);
326
+ const table = parseModelsDev(
327
+ JSON.parse(await readBounded(response, MAX_MODELS_DEV_BYTES)),
328
+ now(),
329
+ );
330
+ await mkdir(cacheDir(options.configDir), {
331
+ recursive: true,
332
+ mode: 0o700,
333
+ }).catch(() => undefined);
334
+ await writePrivateJson(cacheDir(options.configDir), path, table).catch(
335
+ () => undefined,
336
+ );
337
+ return table;
338
+ } catch {
339
+ return cached ?? { fetchedAt: 0, providers: {} };
340
+ }
341
+ }
342
+
343
+ /** The models.dev entry for a provider model ID. */
344
+ export function priceFor(
345
+ table: PriceTable,
346
+ provider: ApiProvider,
347
+ id: string,
348
+ ): Price | undefined {
349
+ const own =
350
+ table.providers[provider === "gateway" ? "vercel" : "openrouter"]?.[id];
351
+ if (own) return own;
352
+ // Fall back to the vendor's direct listing (`anthropic/claude-x` → anthropic: `claude-x`).
353
+ const [vendor, model] = id.split("/");
354
+ if (!vendor || !model) return undefined;
355
+ const direct =
356
+ table.providers[
357
+ vendor === "meta-llama" || vendor === "meta"
358
+ ? "llama"
359
+ : vendor === "z-ai"
360
+ ? "zai"
361
+ : vendor === "qwen"
362
+ ? "alibaba"
363
+ : vendor
364
+ ];
365
+ return direct?.[model];
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Live provider catalogs
370
+
371
+ export type LiveModel = Readonly<{
372
+ id: string;
373
+ name: string;
374
+ tools: boolean;
375
+ /** The provider's own price, when its catalog lists one. */
376
+ price?: Price;
377
+ }>;
378
+
379
+ export type LiveCatalog = Readonly<{
380
+ fetchedAt: number;
381
+ provider: ApiProvider;
382
+ models: readonly LiveModel[];
383
+ }>;
384
+
385
+ /** Gateway `/v1/models` (tag `tool-use`) or OpenRouter `/api/v1/models` (`tools`). */
386
+ export function parseLiveCatalog(
387
+ provider: ApiProvider,
388
+ value: unknown,
389
+ now = Date.now(),
390
+ ): LiveCatalog {
391
+ const rows =
392
+ isRecord(value) && Array.isArray(value.data)
393
+ ? value.data.slice(0, 4000)
394
+ : [];
395
+ const models: LiveModel[] = [];
396
+ for (const row of rows) {
397
+ if (
398
+ !isRecord(row) ||
399
+ typeof row.id !== "string" ||
400
+ !MODEL_ID_PATTERN.test(row.id)
401
+ )
402
+ continue;
403
+ const pricing = isRecord(row.pricing) ? row.pricing : {};
404
+ const tools =
405
+ provider === "openrouter"
406
+ ? Array.isArray(row.supported_parameters) &&
407
+ row.supported_parameters.includes("tools")
408
+ : Array.isArray(row.tags) &&
409
+ row.tags.includes("tool-use") &&
410
+ (row.type ?? "language") === "language";
411
+ const input = perToken(
412
+ provider === "openrouter" ? pricing.prompt : pricing.input,
413
+ 1,
414
+ );
415
+ const output = perToken(
416
+ provider === "openrouter" ? pricing.completion : pricing.output,
417
+ 1,
418
+ );
419
+ models.push({
420
+ id: row.id,
421
+ name: typeof row.name === "string" ? row.name.slice(0, 80) : row.id,
422
+ tools,
423
+ ...(input !== undefined && output !== undefined
424
+ ? { price: { input, output } }
425
+ : {}),
426
+ });
427
+ }
428
+ return { fetchedAt: now, provider, models };
429
+ }
430
+
431
+ function parseCatalogCache(value: unknown): LiveCatalog | undefined {
432
+ if (
433
+ !isRecord(value) ||
434
+ typeof value.fetchedAt !== "number" ||
435
+ !Array.isArray(value.models)
436
+ )
437
+ return undefined;
438
+ return value as LiveCatalog;
439
+ }
440
+
441
+ export async function loadLiveCatalog(
442
+ provider: ApiProvider,
443
+ options: PriceOptions & { apiKey?: string; baseUrl?: string },
444
+ ): Promise<LiveCatalog | undefined> {
445
+ const now = options.now ?? Date.now;
446
+ const path = join(cacheDir(options.configDir), `models-${provider}.json`);
447
+ const cached = await readCache(path, parseCatalogCache);
448
+ if (cached && now() - cached.fetchedAt < PRICE_TTL_MS) return cached;
449
+ const base = (
450
+ options.baseUrl ??
451
+ (provider === "openrouter" ? OPENROUTER_BASE_URL : GATEWAY_BASE_URL)
452
+ ).replace(/\/$/, "");
453
+ try {
454
+ const response = await (options.fetcher ?? fetch)(`${base}/models`, {
455
+ signal: AbortSignal.timeout(PRICE_TIMEOUT_MS),
456
+ headers: options.apiKey
457
+ ? { authorization: `Bearer ${options.apiKey}` }
458
+ : {},
459
+ });
460
+ if (!response.ok) throw new Error(`models ${response.status}`);
461
+ const catalog = parseLiveCatalog(
462
+ provider,
463
+ JSON.parse(await readBounded(response, MAX_CATALOG_BYTES)),
464
+ now(),
465
+ );
466
+ if (catalog.models.length === 0) throw new Error("empty catalog");
467
+ // Cache only what the picker needs: tool-capable rows.
468
+ const slim: LiveCatalog = {
469
+ ...catalog,
470
+ models: catalog.models.filter((model) => model.tools),
471
+ };
472
+ await writePrivateJson(cacheDir(options.configDir), path, slim).catch(
473
+ () => undefined,
474
+ );
475
+ return slim;
476
+ } catch {
477
+ return cached;
478
+ }
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // Cost per prompt
483
+
484
+ /**
485
+ * A typical agent prompt, measured from the real loop (`AGENT_SYSTEM_PROMPT`
486
+ * and `chatTools()` serialized, plus `compositionBrief` of a 3-track, 48-note
487
+ * loop; see models.test.ts, which keeps these in step with the code):
488
+ *
489
+ * - system prompt ≈ 1.9 KB, tool schemas ≈ 15.2 KB, brief ≈ 1.2 KB typical
490
+ * (capped at 12 KiB), user request ≈ 0.1 KB → ≈ 18.4 KB ≈ 4,600 tokens
491
+ * per request at ~4 bytes per token;
492
+ * - about 2 requests per prompt (tool calls, then a short reply), the second
493
+ * also carrying the first step's tool calls and results (≈ 0.6 KB);
494
+ * - output ≈ 450 tokens of tool-call arguments and ≈ 150 of reply.
495
+ *
496
+ * So ≈ 9,400 input and ≈ 600 output tokens per prompt.
497
+ */
498
+ export const TYPICAL_PROMPT = Object.freeze({
499
+ inputTokens: 9_400,
500
+ outputTokens: 600,
501
+ });
502
+
503
+ export function estimatePromptCost(
504
+ price: Price,
505
+ typical: { inputTokens: number; outputTokens: number } = TYPICAL_PROMPT,
506
+ ): number {
507
+ return (
508
+ typical.inputTokens * price.input + typical.outputTokens * price.output
509
+ );
510
+ }
511
+
512
+ /** One response's cost from its token counts; cached input at the cache rate. */
513
+ export function estimateRequestCost(
514
+ price: Price,
515
+ usage: {
516
+ inputTokens: number;
517
+ outputTokens: number;
518
+ cachedInputTokens?: number;
519
+ },
520
+ ): number {
521
+ const cached = Math.min(usage.inputTokens, usage.cachedInputTokens ?? 0);
522
+ const cacheRate = price.cacheRead ?? price.input;
523
+ return (
524
+ (usage.inputTokens - cached) * price.input +
525
+ cached * cacheRate +
526
+ usage.outputTokens * price.output
527
+ );
528
+ }
529
+
530
+ /** `~$0.034/prompt`, `~$0.0004/prompt`, `free`. */
531
+ export function formatPromptCost(usd: number | undefined): string {
532
+ if (usd === undefined) return "—";
533
+ if (usd === 0) return "free";
534
+ return `~${formatUsd(usd)}/prompt`;
535
+ }
536
+
537
+ /** Two significant digits under a cent, cents above. */
538
+ export function formatUsd(usd: number): string {
539
+ if (usd >= 10) return `$${usd.toFixed(0)}`;
540
+ if (usd >= 0.995) return `$${usd.toFixed(2)}`;
541
+ if (usd >= 0.01) return `$${usd.toFixed(2)}`;
542
+ if (usd <= 0) return "$0";
543
+ const digits = Math.min(6, Math.max(3, 1 - Math.floor(Math.log10(usd))));
544
+ return `$${usd.toFixed(digits)}`;
545
+ }
546
+
547
+ // ---------------------------------------------------------------------------
548
+ // Picker rows
549
+
550
+ export type ModelRow = Readonly<{
551
+ id: string;
552
+ alias: string;
553
+ label: string;
554
+ class: ModelClass;
555
+ /** Estimated USD per prompt, undefined when the price is unknown. */
556
+ costUsd?: number;
557
+ current: boolean;
558
+ }>;
559
+
560
+ /**
561
+ * The picker list for a key-based provider: curated entries the live
562
+ * catalog serves with tools (all curated entries when the catalog is
563
+ * unavailable), grouped frontier → fast → open, plus the current model if
564
+ * it is outside the list.
565
+ */
566
+ export function modelRows(
567
+ provider: ApiProvider,
568
+ options: {
569
+ current?: string;
570
+ catalog?: LiveCatalog;
571
+ prices?: PriceTable;
572
+ },
573
+ ): ModelRow[] {
574
+ const live = new Map(
575
+ options.catalog?.models.map((model) => [model.id, model]),
576
+ );
577
+ const price = (id: string): number | undefined => {
578
+ // OpenRouter's own catalog price is authoritative there.
579
+ const own = provider === "openrouter" ? live.get(id)?.price : undefined;
580
+ const found =
581
+ own ??
582
+ (options.prices ? priceFor(options.prices, provider, id) : undefined) ??
583
+ live.get(id)?.price;
584
+ return found ? estimatePromptCost(found) : undefined;
585
+ };
586
+ const rows: ModelRow[] = [];
587
+ for (const entry of MODEL_CATALOG) {
588
+ const id = entry[provider];
589
+ if (!id) continue;
590
+ if (options.catalog && !live.get(id)?.tools) continue;
591
+ const cost = price(id);
592
+ rows.push({
593
+ id,
594
+ alias: entry.alias,
595
+ label: entry.label,
596
+ class: entry.class,
597
+ ...(cost !== undefined ? { costUsd: cost } : {}),
598
+ current: id === options.current,
599
+ });
600
+ }
601
+ const order: ModelClass[] = ["frontier", "fast", "open"];
602
+ rows.sort((a, b) => order.indexOf(a.class) - order.indexOf(b.class));
603
+ if (options.current && !rows.some((row) => row.current)) {
604
+ const cost = price(options.current);
605
+ rows.unshift({
606
+ id: options.current,
607
+ alias: modelAlias(provider, options.current),
608
+ label: live.get(options.current)?.name ?? options.current,
609
+ class: "frontier",
610
+ ...(cost !== undefined ? { costUsd: cost } : {}),
611
+ current: true,
612
+ });
613
+ }
614
+ return rows;
615
+ }
616
+
617
+ /**
618
+ * The price used to cost a response when the provider sent no cost: on
619
+ * OpenRouter its own catalog price (authoritative there), else models.dev.
620
+ * Both come from the ~24 h caches, so this rarely touches the network.
621
+ */
622
+ export async function priceForModel(
623
+ provider: ApiProvider,
624
+ id: string,
625
+ options: PriceOptions & { apiKey?: string; baseUrl?: string },
626
+ ): Promise<Price | undefined> {
627
+ if (provider === "openrouter") {
628
+ const live = await loadLiveCatalog(provider, options);
629
+ const own = live?.models.find((model) => model.id === id)?.price;
630
+ if (own) return own;
631
+ }
632
+ return priceFor(await loadPrices(options), provider, id);
633
+ }