faberwright 0.3.1 → 0.4.1

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,364 @@
1
+ import pc from "picocolors";
2
+ import { select, readSecret } from "./prompt.js";
3
+ import { vendors, modelsForRoute, baseUrlFor, getRoute, DEFAULT_REGION, } from "./routes.js";
4
+ import * as fs from "node:fs";
5
+ import { loadSettings, saveSettings, settingsPath } from "./settings.js";
6
+ import { credentialSource, saveCredential, maskCredential, credentialsPath, looksLikeKey, deleteCredential, } from "./credentials.js";
7
+ /** First run = no settings file yet. */
8
+ /** Where a credential comes from, so setup can point people at the right page. */
9
+ const KEY_SOURCE = {
10
+ ANTHROPIC_API_KEY: "https://console.anthropic.com/settings/keys",
11
+ OPENAI_API_KEY: "https://platform.openai.com/api-keys",
12
+ BEDROCK_API_KEY: "the AWS console (Bedrock → API keys)",
13
+ };
14
+ export function needsOnboarding() {
15
+ return !hasAnySettingsFile();
16
+ }
17
+ /**
18
+ * Is this project actually ready to run?
19
+ *
20
+ * "A settings file exists" is the wrong question — a profile can name a route
21
+ * whose credential was never supplied, which then fails as a 401 on the first
22
+ * task instead of at launch. What counts as complete depends on the route:
23
+ *
24
+ * Anthropic / OpenAI a key, from the environment or Faber's store
25
+ * Bedrock a key OR AWS credentials it can sign with
26
+ * Ollama / local nothing — but a model id, since there are no defaults
27
+ * custom endpoint a base URL, and a key unless it's localhost
28
+ */
29
+ export async function setupComplete(cfg) {
30
+ const route = getRoute(cfg.route);
31
+ if (!route)
32
+ return { complete: false, missing: `unknown route "${cfg.route}"`, fix: "/setup" };
33
+ if (!route.implemented) {
34
+ return { complete: false, missing: `${route.label} isn't wired up yet`, fix: "/route" };
35
+ }
36
+ if (!cfg.model)
37
+ return { complete: false, missing: "no model chosen", fix: "/model" };
38
+ if (route.needsBaseUrl && !cfg.baseUrl) {
39
+ return { complete: false, missing: "no endpoint URL for this route", fix: "/route" };
40
+ }
41
+ if (cfg.apiKey)
42
+ return { complete: true };
43
+ if (route.id === "bedrock") {
44
+ const { discoverAwsCredentials } = await import("./sigv4.js");
45
+ if (await discoverAwsCredentials())
46
+ return { complete: true }; // IAM role signs
47
+ return {
48
+ complete: false,
49
+ missing: "no AWS credentials and no BEDROCK_API_KEY",
50
+ fix: "/key set BEDROCK_API_KEY",
51
+ };
52
+ }
53
+ const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(cfg.baseUrl ?? "");
54
+ if (isLocal)
55
+ return { complete: true }; // local models need no key
56
+ if (!route.keyEnv)
57
+ return { complete: true };
58
+ return { complete: false, missing: `${route.keyEnv} is not set`, fix: `/key set ${route.keyEnv}` };
59
+ }
60
+ function hasAnySettingsFile() {
61
+ try {
62
+ return fs.existsSync(settingsPath());
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ /** True when we can prompt at all. */
69
+ export function interactive() {
70
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
71
+ }
72
+ /**
73
+ * Read a key and refuse to silently store something that isn't one. A typo
74
+ * saved here becomes the preferred credential and breaks every later run,
75
+ * which is a much worse outcome than one extra confirmation.
76
+ */
77
+ async function readKey(rl, envName, promptText) {
78
+ const key = await readSecret(rl, promptText);
79
+ if (!key)
80
+ return undefined;
81
+ const problem = looksLikeKey(envName, key);
82
+ if (problem) {
83
+ console.log(pc.yellow(` ${problem}.`));
84
+ const ok = await select(rl, "Save it anyway?", ["No, discard it", "Yes, save it"]);
85
+ if (ok === 0)
86
+ return undefined;
87
+ }
88
+ return key;
89
+ }
90
+ /**
91
+ * Walk vendor -> route -> region/endpoint -> model. Returns the profile it
92
+ * saved so the caller can report what still needs doing (e.g. an unset key).
93
+ */
94
+ export async function runOnboarding(rl, opts = {}) {
95
+ if (opts.first) {
96
+ console.log();
97
+ console.log(pc.cyan("Welcome to Faber."));
98
+ console.log(pc.dim("Let's pick where your model comes from. You can change this any time with /route or /model."));
99
+ console.log();
100
+ }
101
+ const groups = vendors();
102
+ const vi = await select(rl, "Who provides the model?", groups.map((g) => g.vendor));
103
+ const routes = groups[vi].routes;
104
+ const ri = await select(rl, "How should Faber reach it?", routes.map((r) => `${r.label.padEnd(30)} ${pc.dim(r.hint)}${r.implemented ? "" : pc.yellow(" (not yet wired)")}`));
105
+ const route = routes[ri];
106
+ if (!route.implemented) {
107
+ console.log(pc.yellow(`${route.label} isn't wired up yet — falling back to the Anthropic API.`));
108
+ const fallback = groups[0].routes[0];
109
+ return finish(rl, fallback, {});
110
+ }
111
+ return finish(rl, route, {});
112
+ }
113
+ /**
114
+ * Make sure the route has a usable credential, prompting if not.
115
+ * Exported so startup can run just this step: a configured profile with a
116
+ * missing key should ask at launch, not fail with a 401 on the first task.
117
+ *
118
+ * Returns what the user chose to do when they have no key — the ways forward
119
+ * are offered as choices, because the REPL that prose would point them at
120
+ * never opens when setup is incomplete.
121
+ */
122
+ export async function ensureCredential(rl, route, profile) {
123
+ if (!route.keyEnv)
124
+ return {};
125
+ const found = credentialSource(route.keyEnv);
126
+ if (found?.from === "store") {
127
+ // Running setup again usually means something needs changing — often the
128
+ // key itself, because it expired or was wrong. Silently reusing the saved
129
+ // one makes that impossible, so offer the same choice as for an
130
+ // environment key.
131
+ console.log();
132
+ console.log(`${route.keyEnv} is already saved on this computer (${maskCredential(found.value)}).`);
133
+ const choice = await select(rl, "Use it?", [
134
+ `Keep using it ${pc.dim("no change")}`,
135
+ `Replace it ${pc.dim("paste a new key")}`,
136
+ `Remove it ${pc.dim("delete the saved key and stop")}`,
137
+ ]);
138
+ if (choice === 1) {
139
+ const key = await readKey(rl, route.keyEnv, ` ${route.keyEnv} (hidden): `);
140
+ if (key) {
141
+ saveCredential(route.keyEnv, key);
142
+ console.log(pc.dim(` replaced (${maskCredential(key)})`));
143
+ }
144
+ else {
145
+ console.log(pc.dim(" nothing entered — keeping the saved key"));
146
+ }
147
+ }
148
+ else if (choice === 2) {
149
+ deleteCredential(route.keyEnv);
150
+ console.log(pc.dim(` removed. Run faber again to set one up.`));
151
+ return { switchedTo: "quit" };
152
+ }
153
+ return {};
154
+ }
155
+ if (found?.from === "environment") {
156
+ console.log();
157
+ console.log(`Found ${route.keyEnv} in your environment (${maskCredential(found.value)}).`);
158
+ const choice = await select(rl, "Use it?", [
159
+ `Yes, and remember it here ${pc.dim("works in any shell, even without the env var")}`,
160
+ `Yes, but don't save it ${pc.dim("keep reading it from the environment")}`,
161
+ `No, use a different key ${pc.dim("paste another one")}`,
162
+ ]);
163
+ if (choice === 0) {
164
+ saveCredential(route.keyEnv, found.value);
165
+ console.log(pc.dim(` kept on this computer only, at ${credentialsPath()}`));
166
+ }
167
+ else if (choice === 1) {
168
+ console.log(pc.dim(" not saved — Faber will read it from the environment each run"));
169
+ }
170
+ else {
171
+ const key = await readKey(rl, route.keyEnv, ` ${route.keyEnv} (hidden): `);
172
+ if (key) {
173
+ saveCredential(route.keyEnv, key);
174
+ profile.preferStoredKey = true; // an explicit choice beats the env var
175
+ console.log(pc.dim(` saved (${maskCredential(key)}) — Faber will use this one, not the environment`));
176
+ }
177
+ else {
178
+ console.log(pc.dim(" not saved — keeping the key from your environment"));
179
+ }
180
+ }
181
+ return {};
182
+ }
183
+ if (route.id === "bedrock") {
184
+ // Bedrock can sign with an IAM role, so a key may not be needed at all.
185
+ const { discoverAwsCredentials } = await import("./sigv4.js");
186
+ const aws = await discoverAwsCredentials();
187
+ console.log();
188
+ console.log(aws
189
+ ? pc.dim(` No key needed — signing with AWS credentials from ${aws.source}.`)
190
+ : pc.yellow(` No AWS credentials found. Set ${route.keyEnv}, or run where an IAM role is available.`));
191
+ return {};
192
+ }
193
+ // No key anywhere: say where to get one, read it hidden, and if they don't
194
+ // have one, offer real alternatives rather than a dead end.
195
+ console.log();
196
+ console.log(`Faber needs an API key for ${route.label}.`);
197
+ const where = KEY_SOURCE[route.keyEnv];
198
+ if (where)
199
+ console.log(pc.dim(` Get one at ${where}`));
200
+ const key = await readKey(rl, route.keyEnv, ` ${route.keyEnv} (hidden): `);
201
+ if (key) {
202
+ saveCredential(route.keyEnv, key);
203
+ console.log(pc.dim(` saved (${maskCredential(key)}) — kept on this computer only, at ${credentialsPath()}`));
204
+ return {};
205
+ }
206
+ // No key means setup didn't complete. Nothing is written, so the next run
207
+ // starts over from the beginning rather than resuming into a broken state.
208
+ return { switchedTo: "quit" };
209
+ }
210
+ /** Does the provider accept this credential? Never blocks on being offline. */
211
+ async function verifyCredential(route, profile) {
212
+ if (!route.keyEnv)
213
+ return "ok";
214
+ try {
215
+ const { LLMClient } = await import("./llm.js");
216
+ const { resolveCredential } = await import("./credentials.js");
217
+ const key = resolveCredential(route.keyEnv);
218
+ const baseUrl = profile.baseUrl ?? baseUrlFor(route, profile.region);
219
+ if (!key || !baseUrl)
220
+ return "unreachable";
221
+ process.stdout.write(pc.dim(" checking your key… "));
222
+ const llm = new LLMClient({
223
+ provider: route.wire, baseUrl, apiKey: key, model: "x", route: route.id,
224
+ });
225
+ const verdict = await llm.verifyKey();
226
+ process.stdout.write("\r\x1b[2K");
227
+ return verdict;
228
+ }
229
+ catch {
230
+ process.stdout.write("\r\x1b[2K");
231
+ return "unreachable";
232
+ }
233
+ }
234
+ /** Ask the provider which models this key can use. Empty on any failure. */
235
+ async function discoverModels(route, profile) {
236
+ try {
237
+ const { LLMClient } = await import("./llm.js");
238
+ const { resolveCredential } = await import("./credentials.js");
239
+ const key = route.keyEnv ? resolveCredential(route.keyEnv) : undefined;
240
+ const baseUrl = profile.baseUrl ?? baseUrlFor(route, profile.region);
241
+ if (!baseUrl)
242
+ return [];
243
+ process.stdout.write(pc.dim(" checking which models your key can use… "));
244
+ const llm = new LLMClient({
245
+ provider: route.wire, baseUrl, apiKey: key, model: "x", route: route.id,
246
+ });
247
+ const models = await llm.listModels();
248
+ process.stdout.write("\r\x1b[2K");
249
+ return models;
250
+ }
251
+ catch {
252
+ process.stdout.write("\r\x1b[2K");
253
+ return [];
254
+ }
255
+ }
256
+ async function finish(rl, route, base) {
257
+ const profile = { ...base, route: route.id };
258
+ const activeRoute = route;
259
+ if (route.needsRegion) {
260
+ const ans = (await rl.question(`AWS region [${DEFAULT_REGION}]: `)).trim();
261
+ profile.region = ans || DEFAULT_REGION;
262
+ }
263
+ if (route.needsBaseUrl) {
264
+ const ans = (await rl.question("Base URL of the OpenAI-compatible endpoint: ")).trim();
265
+ if (ans)
266
+ profile.baseUrl = ans;
267
+ }
268
+ if (route.keyEnv) {
269
+ profile.apiKeyEnv = route.keyEnv;
270
+ const outcome = await ensureCredential(rl, activeRoute, profile);
271
+ if (outcome.switchedTo === "quit") {
272
+ // Nothing is written: an incomplete profile would make the next run
273
+ // look configured when it isn't.
274
+ return { profile, route: activeRoute, aborted: true };
275
+ }
276
+ }
277
+ // Model choice, with real ids and real prices.
278
+ //
279
+ // Aliases like "gpt" or "sonnet" hide what you're actually paying for, and
280
+ // the difference is not small: gpt-4o costs about 17x gpt-4o-mini. Since the
281
+ // credential is saved by now, ask the provider what this key can really use
282
+ // and show each model's rate, so nothing about the bill is implicit.
283
+ const { refreshPrices, priceFor, readPriceCache } = await import("./pricing.js");
284
+ if (!readPriceCache())
285
+ process.stdout.write(pc.dim(" fetching prices… "));
286
+ await refreshPrices(undefined, { baseUrl: profile.baseUrl ?? activeRoute.baseUrl });
287
+ process.stdout.write("\r\x1b[2K");
288
+ // Verify the credential before going further. A rejected key means setup
289
+ // did not succeed, so nothing is saved and the next run starts over — the
290
+ // same rule as skipping the key entirely.
291
+ const verdict = await verifyCredential(activeRoute, profile);
292
+ if (verdict === "rejected") {
293
+ if (activeRoute.keyEnv)
294
+ deleteCredential(activeRoute.keyEnv);
295
+ console.log();
296
+ console.log(pc.yellow(`${activeRoute.label} rejected that key.`));
297
+ console.log(pc.dim(" Nothing was saved. Run faber again with a working key."));
298
+ return { profile, route: activeRoute, aborted: true };
299
+ }
300
+ const { isChatModel, sortModels, requiresUnsupportedApi } = await import("./models.js");
301
+ const discovered = sortModels((await discoverModels(activeRoute, profile))
302
+ .filter((m) => isChatModel(m.id) && !requiresUnsupportedApi(m.id)), activeRoute.wire);
303
+ const fallback = modelsForRoute(activeRoute).map((m) => ({ id: m.id, name: m.blurb }));
304
+ const options = discovered.length ? discovered : fallback;
305
+ if (options.length) {
306
+ // Say which list this is. A built-in fallback and a live list look
307
+ // identical otherwise, and the user can't tell whether the models shown
308
+ // are the ones their key can actually reach.
309
+ console.log();
310
+ if (discovered.length) {
311
+ console.log(pc.dim(` ${discovered.length} models available to this key`));
312
+ }
313
+ else {
314
+ const { lastModelListError } = await import("./llm.js");
315
+ const why = lastModelListError();
316
+ console.log(pc.yellow(" Couldn't list models — showing built-in defaults."));
317
+ console.log(pc.dim(why ? ` ${why}` : " No response from the provider."));
318
+ console.log(pc.dim(" /model re-checks later."));
319
+ }
320
+ const width = Math.min(34, Math.max(...options.map((m) => m.id.length)) + 2);
321
+ const labels = options.map((m) => {
322
+ const p = priceFor(m.id);
323
+ const cost = p ? `$${p.in}/$${p.out} per Mtok` : "price unknown";
324
+ return `${m.id.padEnd(width)}${pc.dim(cost)}${m.name ? pc.dim(" " + m.name) : ""}`;
325
+ });
326
+ console.log();
327
+ const mi = await select(rl, "Which model? (input/output cost per million tokens)", labels);
328
+ profile.model = options[mi].id; // a concrete id, never an alias
329
+ }
330
+ else {
331
+ const hint = activeRoute.id === "ollama" ? "qwen2.5-coder" : "";
332
+ const ans = (await rl.question(`Model id${hint ? ` [${hint}]` : ""}: `)).trim();
333
+ profile.model = ans || hint || undefined;
334
+ }
335
+ const settings = loadSettings();
336
+ settings.profiles[settings.activeProfile] = profile;
337
+ saveSettings(settings);
338
+ const missingKeyEnv = activeRoute.keyEnv && !credentialSource(activeRoute.keyEnv)
339
+ ? activeRoute.keyEnv : undefined;
340
+ return { profile, route: activeRoute, missingKeyEnv };
341
+ }
342
+ /** Summary printed after onboarding, including anything still to be done. */
343
+ export function reportSetup(r) {
344
+ if (r.aborted) {
345
+ console.log();
346
+ console.log(pc.yellow("Setup not completed — nothing was saved."));
347
+ console.log(pc.dim(" Run faber again when you have a key, or choose a local model."));
348
+ console.log();
349
+ return;
350
+ }
351
+ const url = r.profile.baseUrl ?? baseUrlFor(r.route, r.profile.region);
352
+ console.log();
353
+ console.log(`Route: ${r.route.label}`);
354
+ if (url)
355
+ console.log(pc.dim(`Endpoint: ${url}`));
356
+ if (r.profile.model)
357
+ console.log(pc.dim(`Model: ${r.profile.model}`));
358
+ if (r.missingKeyEnv) {
359
+ console.log();
360
+ console.log(pc.yellow(`One thing left — ${r.missingKeyEnv} isn't set:`));
361
+ console.log(pc.dim(` /key set ${r.missingKeyEnv}`));
362
+ }
363
+ console.log();
364
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Model pricing.
3
+ *
4
+ * Where prices come from, best source first:
5
+ * 1. What you configured (env vars or a profile) — always wins.
6
+ * 2. The provider itself, when it publishes prices. OpenRouter's
7
+ * /api/v1/models returns live per-token rates including cache read/write,
8
+ * and it needs no key. That is authoritative for that route.
9
+ * 3. A refreshable community dataset covering everyone else.
10
+ * 4. A small built-in table so /usage works with zero configuration.
11
+ *
12
+ * Anthropic's and OpenAI's own /v1/models return ids and capabilities but not
13
+ * dollars, which is why 3 and 4 exist at all.
14
+ *
15
+ * The built-in table is deliberately small and dated. It exists so `/usage`
16
+ * shows real numbers out of the box; it is not the source of truth. Anything
17
+ * you set explicitly always wins, and `/usage --refresh-prices` pulls current
18
+ * figures. Prices here are $ per MILLION tokens.
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as os from "node:os";
22
+ import * as path from "node:path";
23
+ /** Verified 2026-08. Check the vendor's pricing page before quoting these. */
24
+ export const PRICES_AS_OF = "2026-08";
25
+ export const BUILTIN_PRICES = {
26
+ "claude-fable-5": { in: 10, out: 50, cacheRead: 1, cacheWrite: 12.5 },
27
+ "claude-opus-5": { in: 5, out: 25, cacheRead: 0.5, cacheWrite: 6.25 },
28
+ "claude-sonnet-5": { in: 2, out: 10, cacheRead: 0.2, cacheWrite: 2.5 },
29
+ "claude-haiku-4-5-20251001": { in: 1, out: 5, cacheRead: 0.1, cacheWrite: 1.25 },
30
+ "claude-sonnet-4-5": { in: 3, out: 15, cacheRead: 0.3, cacheWrite: 3.75 },
31
+ "claude-opus-4-1": { in: 15, out: 75, cacheRead: 1.5, cacheWrite: 18.75 },
32
+ "gpt-4o": { in: 2.5, out: 10, cacheRead: 1.25 },
33
+ "gpt-4o-mini": { in: 0.15, out: 0.6, cacheRead: 0.075 },
34
+ };
35
+ /**
36
+ * Cloud routes prefix ids with a region scope and suffix a version, e.g.
37
+ * "us.anthropic.claude-sonnet-5" or "anthropic.claude-haiku-4-5-20251001-v1:0".
38
+ * Strip those so one table entry covers every deployment of a model.
39
+ * (Regional Bedrock endpoints do cost a little more than global — treat the
40
+ * result as an estimate, and pin exact prices in your profile if it matters.)
41
+ */
42
+ export function normalizeModelId(id) {
43
+ let s = id.trim();
44
+ s = s.replace(/^(global|us|eu|apac|au|jp)\./, ""); // region scope
45
+ s = s.replace(/^anthropic\./, ""); // provider scope
46
+ s = s.replace(/-v\d+:\d+$/, ""); // bedrock version suffix
47
+ s = s.replace(/@\d{8}$/, ""); // vertex date suffix
48
+ return s;
49
+ }
50
+ function cacheFile() {
51
+ return path.join(os.homedir(), ".faber", "cache", "prices.json");
52
+ }
53
+ /**
54
+ * Bumped whenever the cached shape gains a field Faber relies on. A cache
55
+ * written by an older version is discarded rather than trusted: after adding
56
+ * `mode` and `supported_endpoints`, a stale file looked complete but carried
57
+ * neither, so Responses-only models were routed to the wrong endpoint and
58
+ * failed with a 404 that looked like a Faber bug.
59
+ */
60
+ export const PRICE_CACHE_VERSION = 2;
61
+ /** Wait this long before retrying after a failed refresh. */
62
+ export const RETRY_AFTER_FAILURE_MS = 24 * 60 * 60 * 1000;
63
+ /** How old the cached prices are, in days. undefined = never fetched. */
64
+ export function priceAgeDays(now = Date.now()) {
65
+ const c = readPriceCache();
66
+ return c ? (now - c.fetchedAt) / 86_400_000 : undefined;
67
+ }
68
+ /** Prices this old are worth re-checking before they get baked into history. */
69
+ export const STALE_AFTER_DAYS = 14;
70
+ export function pricesAreStale(now = Date.now()) {
71
+ const age = priceAgeDays(now);
72
+ return age === undefined || age > STALE_AFTER_DAYS;
73
+ }
74
+ export function readPriceCache() {
75
+ try {
76
+ const raw = JSON.parse(fs.readFileSync(cacheFile(), "utf8"));
77
+ if (!raw.prices || typeof raw.prices !== "object")
78
+ return undefined;
79
+ if ((raw.version ?? 1) < PRICE_CACHE_VERSION)
80
+ return undefined; // stale shape
81
+ return raw;
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
87
+ export function writePriceCache(prices, source, etag) {
88
+ try {
89
+ const f = cacheFile();
90
+ fs.mkdirSync(path.dirname(f), { recursive: true });
91
+ const now = Date.now();
92
+ fs.writeFileSync(f, JSON.stringify({ version: PRICE_CACHE_VERSION, fetchedAt: now, lastAttempt: now, source, etag, prices }, null, 2));
93
+ }
94
+ catch { /* best effort */ }
95
+ }
96
+ /** Prices were re-confirmed unchanged (a 304) — still current, nothing to store. */
97
+ export function markPricesConfirmed(now = Date.now()) {
98
+ try {
99
+ const existing = readPriceCache();
100
+ if (!existing)
101
+ return;
102
+ const f = cacheFile();
103
+ fs.writeFileSync(f, JSON.stringify({ ...existing, fetchedAt: now, lastAttempt: now }, null, 2));
104
+ }
105
+ catch { /* best effort */ }
106
+ }
107
+ /**
108
+ * Remember that we tried, even when the fetch failed. Without this an offline
109
+ * machine would retry on every single launch, which is exactly the behaviour
110
+ * a background refresh must not have.
111
+ */
112
+ export function markRefreshAttempt(now = Date.now()) {
113
+ try {
114
+ const f = cacheFile();
115
+ fs.mkdirSync(path.dirname(f), { recursive: true });
116
+ const existing = readPriceCache();
117
+ fs.writeFileSync(f, JSON.stringify({
118
+ version: PRICE_CACHE_VERSION,
119
+ fetchedAt: existing?.fetchedAt ?? 0,
120
+ lastAttempt: now,
121
+ source: existing?.source ?? "none",
122
+ etag: existing?.etag, // must survive, or conditional GET breaks
123
+ prices: existing?.prices ?? {},
124
+ }, null, 2));
125
+ }
126
+ catch { /* best effort */ }
127
+ }
128
+ /**
129
+ * Should a background refresh run now?
130
+ *
131
+ * Yes on every launch — a conditional request is ~0 bytes when nothing has
132
+ * changed, so there's no reason to tolerate a stale price window. The only
133
+ * exception is backing off after a FAILED attempt, so an offline machine
134
+ * isn't making a doomed request every time you start.
135
+ */
136
+ export function shouldAutoRefresh(now = Date.now()) {
137
+ const c = readPriceCache();
138
+ if (!c)
139
+ return true; // never fetched
140
+ const failedRecently = c.lastAttempt !== undefined
141
+ && c.lastAttempt > c.fetchedAt // attempt newer than success
142
+ && now - c.lastAttempt < RETRY_AFTER_FAILURE_MS;
143
+ return !failedRecently;
144
+ }
145
+ const DATASET_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
146
+ const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
147
+ /** True when this endpoint is OpenRouter, which publishes live prices. */
148
+ export function isOpenRouter(baseUrl) {
149
+ return Boolean(baseUrl && /(^|\/\/)([^/]*\.)?openrouter\.ai/.test(baseUrl));
150
+ }
151
+ /**
152
+ * Live prices straight from OpenRouter. Values are USD per token as strings;
153
+ * we convert to per-Mtok. No API key needed for the catalogue.
154
+ */
155
+ export async function fetchOpenRouterPrices(url = OPENROUTER_MODELS_URL) {
156
+ try {
157
+ const ctl = new AbortController();
158
+ const timer = setTimeout(() => ctl.abort(), 15000);
159
+ const res = await fetch(url, { signal: ctl.signal });
160
+ clearTimeout(timer);
161
+ if (!res.ok)
162
+ return undefined;
163
+ const body = await res.json();
164
+ const out = {};
165
+ const perM = (s) => {
166
+ const n = Number(s);
167
+ return Number.isFinite(n) ? Math.round(n * 1e6 * 1e6) / 1e6 : undefined;
168
+ };
169
+ for (const m of body.data ?? []) {
170
+ if (!m.id || !m.pricing)
171
+ continue;
172
+ const inC = perM(m.pricing["prompt"]), outC = perM(m.pricing["completion"]);
173
+ if (inC === undefined || outC === undefined)
174
+ continue;
175
+ const price = { in: inC, out: outC };
176
+ const cr = perM(m.pricing["input_cache_read"]);
177
+ const cw = perM(m.pricing["input_cache_write"]);
178
+ if (cr !== undefined)
179
+ price.cacheRead = cr;
180
+ if (cw !== undefined)
181
+ price.cacheWrite = cw;
182
+ out[m.id] = price;
183
+ }
184
+ return Object.keys(out).length ? out : undefined;
185
+ }
186
+ catch {
187
+ return undefined;
188
+ }
189
+ }
190
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
191
+ export async function refreshPrices(url = DATASET_URL, opts = {}) {
192
+ markRefreshAttempt(); // recorded first, so a crash mid-fetch still backs off
193
+ // If the active endpoint publishes its own prices, that beats any dataset.
194
+ if (isOpenRouter(opts.baseUrl)) {
195
+ const live = await fetchOpenRouterPrices();
196
+ if (live) {
197
+ const merged = { ...(readPriceCache()?.prices ?? {}), ...live };
198
+ writePriceCache(merged, OPENROUTER_MODELS_URL);
199
+ return Object.keys(live).length;
200
+ }
201
+ }
202
+ try {
203
+ const ctl = new AbortController();
204
+ const timer = setTimeout(() => ctl.abort(), 15000);
205
+ // Conditional request: if the price list hasn't changed the server answers
206
+ // 304 with no body, so checking every launch costs ~0 bytes instead of 1.6MB.
207
+ const known = readPriceCache();
208
+ const headers = {};
209
+ if (known?.etag && !opts.force)
210
+ headers["if-none-match"] = known.etag;
211
+ const res = await fetch(url, { signal: ctl.signal, headers });
212
+ clearTimeout(timer);
213
+ if (res.status === 304) {
214
+ markPricesConfirmed();
215
+ return "unchanged";
216
+ }
217
+ if (!res.ok)
218
+ return undefined;
219
+ const etag = res.headers.get("etag") ?? undefined;
220
+ const raw = await res.json();
221
+ const out = {};
222
+ for (const [id, v] of Object.entries(raw)) {
223
+ const inC = v["input_cost_per_token"], outC = v["output_cost_per_token"];
224
+ if (typeof inC !== "number" || typeof outC !== "number")
225
+ continue;
226
+ // per-token floats round badly at 1e6 (2e-7 -> 0.19999999999999998),
227
+ // and these values get written to a file people read.
228
+ const perM = (n) => Math.round(n * 1e6 * 1e6) / 1e6;
229
+ const price = { in: perM(inC), out: perM(outC) };
230
+ if (typeof v["mode"] === "string")
231
+ price.mode = v["mode"];
232
+ const eps = v["supported_endpoints"];
233
+ if (Array.isArray(eps))
234
+ price.endpoints = eps.filter((e) => typeof e === "string");
235
+ const cr = v["cache_read_input_token_cost"], cw = v["cache_creation_input_token_cost"];
236
+ if (typeof cr === "number")
237
+ price.cacheRead = perM(cr);
238
+ if (typeof cw === "number")
239
+ price.cacheWrite = perM(cw);
240
+ out[id] = price;
241
+ }
242
+ if (!Object.keys(out).length)
243
+ return undefined;
244
+ writePriceCache(out, url, etag);
245
+ return Object.keys(out).length;
246
+ }
247
+ catch {
248
+ return undefined;
249
+ }
250
+ }
251
+ /**
252
+ * Price for a model. Explicit overrides win, then a refreshed dataset (exact
253
+ * id, then normalized), then the built-in table. Undefined means "unknown",
254
+ * which the ledger renders as — rather than guessing.
255
+ */
256
+ export function priceFor(modelId, override) {
257
+ if (override?.in && override?.out)
258
+ return { in: override.in, out: override.out };
259
+ const norm = normalizeModelId(modelId);
260
+ const cached = readPriceCache()?.prices;
261
+ const hit = cached?.[modelId] ?? cached?.[norm] ?? BUILTIN_PRICES[modelId] ?? BUILTIN_PRICES[norm];
262
+ return hit;
263
+ }
264
+ /** Multipliers used when a model's exact cache prices aren't known. */
265
+ export const CACHE_READ_RATIO = 0.1;
266
+ export const CACHE_WRITE_RATIO = 1.25;
267
+ export function cacheReadPrice(p) {
268
+ return p.cacheRead ?? p.in * CACHE_READ_RATIO;
269
+ }
270
+ export function cacheWritePrice(p) {
271
+ return p.cacheWrite ?? p.in * CACHE_WRITE_RATIO;
272
+ }