faberwright 0.3.1 → 0.4.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,254 @@
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, } 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
+ console.log(pc.dim(` Using ${route.keyEnv} saved on this computer (${maskCredential(found.value)})`));
128
+ return {};
129
+ }
130
+ if (found?.from === "environment") {
131
+ console.log();
132
+ console.log(`Found ${route.keyEnv} in your environment (${maskCredential(found.value)}).`);
133
+ const choice = await select(rl, "Use it?", [
134
+ `Yes, and remember it here ${pc.dim("works in any shell, even without the env var")}`,
135
+ `Yes, but don't save it ${pc.dim("keep reading it from the environment")}`,
136
+ `No, use a different key ${pc.dim("paste another one")}`,
137
+ ]);
138
+ if (choice === 0) {
139
+ saveCredential(route.keyEnv, found.value);
140
+ console.log(pc.dim(` kept on this computer only, at ${credentialsPath()}`));
141
+ }
142
+ else if (choice === 1) {
143
+ console.log(pc.dim(" not saved — Faber will read it from the environment each run"));
144
+ }
145
+ else {
146
+ const key = await readKey(rl, route.keyEnv, ` ${route.keyEnv} (hidden): `);
147
+ if (key) {
148
+ saveCredential(route.keyEnv, key);
149
+ profile.preferStoredKey = true; // an explicit choice beats the env var
150
+ console.log(pc.dim(` saved (${maskCredential(key)}) — Faber will use this one, not the environment`));
151
+ }
152
+ else {
153
+ console.log(pc.dim(" not saved — keeping the key from your environment"));
154
+ }
155
+ }
156
+ return {};
157
+ }
158
+ if (route.id === "bedrock") {
159
+ // Bedrock can sign with an IAM role, so a key may not be needed at all.
160
+ const { discoverAwsCredentials } = await import("./sigv4.js");
161
+ const aws = await discoverAwsCredentials();
162
+ console.log();
163
+ console.log(aws
164
+ ? pc.dim(` No key needed — signing with AWS credentials from ${aws.source}.`)
165
+ : pc.yellow(` No AWS credentials found. Set ${route.keyEnv}, or run where an IAM role is available.`));
166
+ return {};
167
+ }
168
+ // No key anywhere: say where to get one, read it hidden, and if they don't
169
+ // have one, offer real alternatives rather than a dead end.
170
+ console.log();
171
+ console.log(`Faber needs an API key for ${route.label}.`);
172
+ const where = KEY_SOURCE[route.keyEnv];
173
+ if (where)
174
+ console.log(pc.dim(` Get one at ${where}`));
175
+ const key = await readKey(rl, route.keyEnv, ` ${route.keyEnv} (hidden): `);
176
+ if (key) {
177
+ saveCredential(route.keyEnv, key);
178
+ console.log(pc.dim(` saved (${maskCredential(key)}) — kept on this computer only, at ${credentialsPath()}`));
179
+ return {};
180
+ }
181
+ // No key means setup didn't complete. Nothing is written, so the next run
182
+ // starts over from the beginning rather than resuming into a broken state.
183
+ return { switchedTo: "quit" };
184
+ }
185
+ async function finish(rl, route, base) {
186
+ const profile = { ...base, route: route.id };
187
+ const activeRoute = route;
188
+ if (route.needsRegion) {
189
+ const ans = (await rl.question(`AWS region [${DEFAULT_REGION}]: `)).trim();
190
+ profile.region = ans || DEFAULT_REGION;
191
+ }
192
+ if (route.needsBaseUrl) {
193
+ const ans = (await rl.question("Base URL of the OpenAI-compatible endpoint: ")).trim();
194
+ if (ans)
195
+ profile.baseUrl = ans;
196
+ }
197
+ if (route.keyEnv) {
198
+ profile.apiKeyEnv = route.keyEnv;
199
+ const outcome = await ensureCredential(rl, activeRoute, profile);
200
+ if (outcome.switchedTo === "quit") {
201
+ // Nothing is written: an incomplete profile would make the next run
202
+ // look configured when it isn't.
203
+ return { profile, route: activeRoute, aborted: true };
204
+ }
205
+ }
206
+ // model: pick from aliases, or ask for an id on routes where ids vary
207
+ const choices = modelsForRoute(activeRoute);
208
+ if (choices.length) {
209
+ const mi = await select(rl, "Default model", choices.map((m) => `${m.alias.padEnd(8)} ${pc.dim(m.blurb)}`));
210
+ profile.model = choices[mi].alias;
211
+ }
212
+ else {
213
+ const hint = activeRoute.id === "ollama" ? "qwen2.5-coder" : "";
214
+ const ans = (await rl.question(`Model id${hint ? ` [${hint}]` : ""} ${pc.dim("(ids differ on this route)")}: `)).trim();
215
+ profile.model = ans || hint || undefined;
216
+ }
217
+ // Fetch prices once during setup: costs are frozen per task, so starting
218
+ // with current rates keeps day-one history accurate.
219
+ const { refreshPrices } = await import("./pricing.js");
220
+ process.stdout.write(pc.dim(" fetching current prices… "));
221
+ const n = await refreshPrices(undefined, { baseUrl: profile.baseUrl ?? activeRoute.baseUrl });
222
+ process.stdout.write("\r\x1b[2K");
223
+ if (!n)
224
+ console.log(pc.dim(" (couldn't fetch prices — using built-in rates)"));
225
+ const settings = loadSettings();
226
+ settings.profiles[settings.activeProfile] = profile;
227
+ saveSettings(settings);
228
+ const missingKeyEnv = activeRoute.keyEnv && !credentialSource(activeRoute.keyEnv)
229
+ ? activeRoute.keyEnv : undefined;
230
+ return { profile, route: activeRoute, missingKeyEnv };
231
+ }
232
+ /** Summary printed after onboarding, including anything still to be done. */
233
+ export function reportSetup(r) {
234
+ if (r.aborted) {
235
+ console.log();
236
+ console.log(pc.yellow("Setup not completed — nothing was saved."));
237
+ console.log(pc.dim(" Run faber again when you have a key, or choose a local model."));
238
+ console.log();
239
+ return;
240
+ }
241
+ const url = r.profile.baseUrl ?? baseUrlFor(r.route, r.profile.region);
242
+ console.log();
243
+ console.log(`Route: ${r.route.label}`);
244
+ if (url)
245
+ console.log(pc.dim(`Endpoint: ${url}`));
246
+ if (r.profile.model)
247
+ console.log(pc.dim(`Model: ${r.profile.model}`));
248
+ if (r.missingKeyEnv) {
249
+ console.log();
250
+ console.log(pc.yellow(`One thing left — ${r.missingKeyEnv} isn't set:`));
251
+ console.log(pc.dim(` /key set ${r.missingKeyEnv}`));
252
+ }
253
+ console.log();
254
+ }
@@ -0,0 +1,254 @@
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
+ /** Wait this long before retrying after a failed refresh. */
54
+ export const RETRY_AFTER_FAILURE_MS = 24 * 60 * 60 * 1000;
55
+ /** How old the cached prices are, in days. undefined = never fetched. */
56
+ export function priceAgeDays(now = Date.now()) {
57
+ const c = readPriceCache();
58
+ return c ? (now - c.fetchedAt) / 86_400_000 : undefined;
59
+ }
60
+ /** Prices this old are worth re-checking before they get baked into history. */
61
+ export const STALE_AFTER_DAYS = 14;
62
+ export function pricesAreStale(now = Date.now()) {
63
+ const age = priceAgeDays(now);
64
+ return age === undefined || age > STALE_AFTER_DAYS;
65
+ }
66
+ export function readPriceCache() {
67
+ try {
68
+ const raw = JSON.parse(fs.readFileSync(cacheFile(), "utf8"));
69
+ return raw.prices && typeof raw.prices === "object" ? raw : undefined;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ export function writePriceCache(prices, source, etag) {
76
+ try {
77
+ const f = cacheFile();
78
+ fs.mkdirSync(path.dirname(f), { recursive: true });
79
+ const now = Date.now();
80
+ fs.writeFileSync(f, JSON.stringify({ fetchedAt: now, lastAttempt: now, source, etag, prices }, null, 2));
81
+ }
82
+ catch { /* best effort */ }
83
+ }
84
+ /** Prices were re-confirmed unchanged (a 304) — still current, nothing to store. */
85
+ export function markPricesConfirmed(now = Date.now()) {
86
+ try {
87
+ const existing = readPriceCache();
88
+ if (!existing)
89
+ return;
90
+ const f = cacheFile();
91
+ fs.writeFileSync(f, JSON.stringify({ ...existing, fetchedAt: now, lastAttempt: now }, null, 2));
92
+ }
93
+ catch { /* best effort */ }
94
+ }
95
+ /**
96
+ * Remember that we tried, even when the fetch failed. Without this an offline
97
+ * machine would retry on every single launch, which is exactly the behaviour
98
+ * a background refresh must not have.
99
+ */
100
+ export function markRefreshAttempt(now = Date.now()) {
101
+ try {
102
+ const f = cacheFile();
103
+ fs.mkdirSync(path.dirname(f), { recursive: true });
104
+ const existing = readPriceCache();
105
+ fs.writeFileSync(f, JSON.stringify({
106
+ fetchedAt: existing?.fetchedAt ?? 0,
107
+ lastAttempt: now,
108
+ source: existing?.source ?? "none",
109
+ etag: existing?.etag, // must survive, or conditional GET breaks
110
+ prices: existing?.prices ?? {},
111
+ }, null, 2));
112
+ }
113
+ catch { /* best effort */ }
114
+ }
115
+ /**
116
+ * Should a background refresh run now?
117
+ *
118
+ * Yes on every launch — a conditional request is ~0 bytes when nothing has
119
+ * changed, so there's no reason to tolerate a stale price window. The only
120
+ * exception is backing off after a FAILED attempt, so an offline machine
121
+ * isn't making a doomed request every time you start.
122
+ */
123
+ export function shouldAutoRefresh(now = Date.now()) {
124
+ const c = readPriceCache();
125
+ if (!c)
126
+ return true; // never fetched
127
+ const failedRecently = c.lastAttempt !== undefined
128
+ && c.lastAttempt > c.fetchedAt // attempt newer than success
129
+ && now - c.lastAttempt < RETRY_AFTER_FAILURE_MS;
130
+ return !failedRecently;
131
+ }
132
+ const DATASET_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
133
+ const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
134
+ /** True when this endpoint is OpenRouter, which publishes live prices. */
135
+ export function isOpenRouter(baseUrl) {
136
+ return Boolean(baseUrl && /(^|\/\/)([^/]*\.)?openrouter\.ai/.test(baseUrl));
137
+ }
138
+ /**
139
+ * Live prices straight from OpenRouter. Values are USD per token as strings;
140
+ * we convert to per-Mtok. No API key needed for the catalogue.
141
+ */
142
+ export async function fetchOpenRouterPrices(url = OPENROUTER_MODELS_URL) {
143
+ try {
144
+ const ctl = new AbortController();
145
+ const timer = setTimeout(() => ctl.abort(), 15000);
146
+ const res = await fetch(url, { signal: ctl.signal });
147
+ clearTimeout(timer);
148
+ if (!res.ok)
149
+ return undefined;
150
+ const body = await res.json();
151
+ const out = {};
152
+ const perM = (s) => {
153
+ const n = Number(s);
154
+ return Number.isFinite(n) ? Math.round(n * 1e6 * 1e6) / 1e6 : undefined;
155
+ };
156
+ for (const m of body.data ?? []) {
157
+ if (!m.id || !m.pricing)
158
+ continue;
159
+ const inC = perM(m.pricing["prompt"]), outC = perM(m.pricing["completion"]);
160
+ if (inC === undefined || outC === undefined)
161
+ continue;
162
+ const price = { in: inC, out: outC };
163
+ const cr = perM(m.pricing["input_cache_read"]);
164
+ const cw = perM(m.pricing["input_cache_write"]);
165
+ if (cr !== undefined)
166
+ price.cacheRead = cr;
167
+ if (cw !== undefined)
168
+ price.cacheWrite = cw;
169
+ out[m.id] = price;
170
+ }
171
+ return Object.keys(out).length ? out : undefined;
172
+ }
173
+ catch {
174
+ return undefined;
175
+ }
176
+ }
177
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
178
+ export async function refreshPrices(url = DATASET_URL, opts = {}) {
179
+ markRefreshAttempt(); // recorded first, so a crash mid-fetch still backs off
180
+ // If the active endpoint publishes its own prices, that beats any dataset.
181
+ if (isOpenRouter(opts.baseUrl)) {
182
+ const live = await fetchOpenRouterPrices();
183
+ if (live) {
184
+ const merged = { ...(readPriceCache()?.prices ?? {}), ...live };
185
+ writePriceCache(merged, OPENROUTER_MODELS_URL);
186
+ return Object.keys(live).length;
187
+ }
188
+ }
189
+ try {
190
+ const ctl = new AbortController();
191
+ const timer = setTimeout(() => ctl.abort(), 15000);
192
+ // Conditional request: if the price list hasn't changed the server answers
193
+ // 304 with no body, so checking every launch costs ~0 bytes instead of 1.6MB.
194
+ const known = readPriceCache();
195
+ const headers = {};
196
+ if (known?.etag && !opts.force)
197
+ headers["if-none-match"] = known.etag;
198
+ const res = await fetch(url, { signal: ctl.signal, headers });
199
+ clearTimeout(timer);
200
+ if (res.status === 304) {
201
+ markPricesConfirmed();
202
+ return "unchanged";
203
+ }
204
+ if (!res.ok)
205
+ return undefined;
206
+ const etag = res.headers.get("etag") ?? undefined;
207
+ const raw = await res.json();
208
+ const out = {};
209
+ for (const [id, v] of Object.entries(raw)) {
210
+ const inC = v["input_cost_per_token"], outC = v["output_cost_per_token"];
211
+ if (typeof inC !== "number" || typeof outC !== "number")
212
+ continue;
213
+ // per-token floats round badly at 1e6 (2e-7 -> 0.19999999999999998),
214
+ // and these values get written to a file people read.
215
+ const perM = (n) => Math.round(n * 1e6 * 1e6) / 1e6;
216
+ const price = { in: perM(inC), out: perM(outC) };
217
+ const cr = v["cache_read_input_token_cost"], cw = v["cache_creation_input_token_cost"];
218
+ if (typeof cr === "number")
219
+ price.cacheRead = perM(cr);
220
+ if (typeof cw === "number")
221
+ price.cacheWrite = perM(cw);
222
+ out[id] = price;
223
+ }
224
+ if (!Object.keys(out).length)
225
+ return undefined;
226
+ writePriceCache(out, url, etag);
227
+ return Object.keys(out).length;
228
+ }
229
+ catch {
230
+ return undefined;
231
+ }
232
+ }
233
+ /**
234
+ * Price for a model. Explicit overrides win, then a refreshed dataset (exact
235
+ * id, then normalized), then the built-in table. Undefined means "unknown",
236
+ * which the ledger renders as — rather than guessing.
237
+ */
238
+ export function priceFor(modelId, override) {
239
+ if (override?.in && override?.out)
240
+ return { in: override.in, out: override.out };
241
+ const norm = normalizeModelId(modelId);
242
+ const cached = readPriceCache()?.prices;
243
+ const hit = cached?.[modelId] ?? cached?.[norm] ?? BUILTIN_PRICES[modelId] ?? BUILTIN_PRICES[norm];
244
+ return hit;
245
+ }
246
+ /** Multipliers used when a model's exact cache prices aren't known. */
247
+ export const CACHE_READ_RATIO = 0.1;
248
+ export const CACHE_WRITE_RATIO = 1.25;
249
+ export function cacheReadPrice(p) {
250
+ return p.cacheRead ?? p.in * CACHE_READ_RATIO;
251
+ }
252
+ export function cacheWritePrice(p) {
253
+ return p.cacheWrite ?? p.in * CACHE_WRITE_RATIO;
254
+ }
package/dist/prompt.js CHANGED
@@ -2,10 +2,18 @@ import pc from "picocolors";
2
2
  let guardFn;
3
3
  /** Wired by the CLI: pauses the composed-input pipe while the selector owns stdin. */
4
4
  export function setSelectGuard(fn) { guardFn = fn; }
5
- export async function select(rl, question, options, defaultIndex = 0) {
5
+ async function selectRaw(rl, question, options, defaultIndex = 0) {
6
6
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
7
7
  const menu = options.map((o, i) => ` ${i + 1}) ${o}`).join("\n");
8
- const ans = (await rl.question(`${question}\n${menu}\nChoose [1-${options.length}] (default ${defaultIndex + 1}): `)).trim();
8
+ let ans;
9
+ try {
10
+ ans = (await rl.question(`${question}\n${menu}\nChoose [1-${options.length}] (default ${defaultIndex + 1}): `)).trim();
11
+ }
12
+ catch {
13
+ // stdin closed mid-prompt (piped input ran out, or Ctrl-D):
14
+ // take the default rather than surfacing a readline stack trace.
15
+ return defaultIndex;
16
+ }
9
17
  const n = Number.parseInt(ans, 10);
10
18
  return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : defaultIndex;
11
19
  }
@@ -68,6 +76,9 @@ export async function select(rl, question, options, defaultIndex = 0) {
68
76
  finish(idx);
69
77
  done = true;
70
78
  }
79
+ // Ctrl-C or Esc cancels. Returning -1 used to leak out as an array
80
+ // index, crashing the caller with "cannot read properties of
81
+ // undefined" — a cancel must be a clean exit, not a bad index.
71
82
  else if (key === "\x03" || key === "\x1b") {
72
83
  finish(-1);
73
84
  done = true;
@@ -78,3 +89,69 @@ export async function select(rl, question, options, defaultIndex = 0) {
78
89
  stdin.on("data", onData);
79
90
  });
80
91
  }
92
+ /**
93
+ * Read a secret without echoing it. A pasted API key that appears on screen
94
+ * survives in terminal scrollback, `script` logs, and screen recordings — so
95
+ * the characters are consumed in raw mode and only a masked length is shown.
96
+ * Falls back to a normal read when there's no TTY (CI piping a key in).
97
+ */
98
+ export async function readSecret(rl, promptText, io) {
99
+ const stdin = io?.stdin ?? process.stdin;
100
+ const stdout = io?.stdout ?? process.stdout;
101
+ if (!stdin.isTTY || !stdout.isTTY) {
102
+ return (await rl.question(promptText)).trim();
103
+ }
104
+ guardFn?.(true);
105
+ rl.pause();
106
+ const wasRaw = stdin.isRaw ?? false;
107
+ stdin.setRawMode(true);
108
+ stdin.resume();
109
+ stdout.write(promptText);
110
+ return new Promise((resolve) => {
111
+ let value = "";
112
+ const done = () => {
113
+ stdin.removeListener("data", onData);
114
+ stdin.setRawMode(wasRaw);
115
+ guardFn?.(false);
116
+ rl.resume();
117
+ stdout.write("\n");
118
+ resolve(value.trim());
119
+ };
120
+ const onData = (buf) => {
121
+ for (const ch of buf.toString("utf8")) {
122
+ if (ch === "\r" || ch === "\n")
123
+ return done();
124
+ if (ch === "\x03") {
125
+ value = "";
126
+ return done();
127
+ } // Ctrl-C
128
+ if (ch === "\x7f" || ch === "\b") { // backspace
129
+ if (value.length) {
130
+ value = value.slice(0, -1);
131
+ stdout.write("\b \b");
132
+ }
133
+ continue;
134
+ }
135
+ if (ch >= " ") {
136
+ value += ch;
137
+ stdout.write("•");
138
+ }
139
+ }
140
+ };
141
+ stdin.on("data", onData);
142
+ });
143
+ }
144
+ /**
145
+ * Arrow-key menu. Cancelling (Ctrl-C or Esc) exits the process cleanly rather
146
+ * than returning a sentinel index that every caller would have to check —
147
+ * one forgotten check produced a raw TypeError mid-setup.
148
+ */
149
+ export async function select(rl, question, options, defaultIndex = 0) {
150
+ const picked = await selectRaw(rl, question, options, defaultIndex);
151
+ if (picked < 0 || picked >= options.length) {
152
+ process.stdout.write("\n");
153
+ rl.close();
154
+ process.exit(130); // 128 + SIGINT, the conventional cancel code
155
+ }
156
+ return picked;
157
+ }