@hraness/dawg 0.2.0 → 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.
- package/CHANGELOG.md +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +143 -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
|
+
}
|
package/src/agent/ops.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { pitchToMidi } from "../../core/pitch.ts";
|
|
1
2
|
import { SCORE_LIMITS } from "../../core/score.ts";
|
|
2
3
|
|
|
4
|
+
export { pitchToMidi };
|
|
5
|
+
|
|
3
6
|
export type AgentOperation =
|
|
4
7
|
| { type: "add-track"; trackId: string }
|
|
5
8
|
| {
|
|
@@ -152,21 +155,3 @@ export function parsePrompt(prompt: string): AgentOperation | undefined {
|
|
|
152
155
|
return undefined;
|
|
153
156
|
return { type: "add-note", pitch, start, duration, velocity: 0.8 };
|
|
154
157
|
}
|
|
155
|
-
|
|
156
|
-
export function pitchToMidi(value: string): number {
|
|
157
|
-
const match = value.match(/^([a-g])([#b]?)(-?\d+)$/);
|
|
158
|
-
if (!match) return Number.NaN;
|
|
159
|
-
const semitones: Record<string, number> = {
|
|
160
|
-
c: 0,
|
|
161
|
-
d: 2,
|
|
162
|
-
e: 4,
|
|
163
|
-
f: 5,
|
|
164
|
-
g: 7,
|
|
165
|
-
a: 9,
|
|
166
|
-
b: 11,
|
|
167
|
-
};
|
|
168
|
-
const accidental = match[2] === "#" ? 1 : match[2] === "b" ? -1 : 0;
|
|
169
|
-
return (
|
|
170
|
-
(Number(match[3]) + 1) * 12 + (semitones[match[1] ?? "c"] ?? 0) + accidental
|
|
171
|
-
);
|
|
172
|
-
}
|