faberwright 0.4.0 → 0.4.2

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/dist/onboard.js CHANGED
@@ -3,13 +3,15 @@ import { select, readSecret } from "./prompt.js";
3
3
  import { vendors, modelsForRoute, baseUrlFor, getRoute, DEFAULT_REGION, } from "./routes.js";
4
4
  import * as fs from "node:fs";
5
5
  import { loadSettings, saveSettings, settingsPath } from "./settings.js";
6
- import { credentialSource, saveCredential, maskCredential, credentialsPath, looksLikeKey, } from "./credentials.js";
6
+ import { credentialSource, saveCredential, maskCredential, credentialsPath, looksLikeKey, deleteCredential, } from "./credentials.js";
7
7
  /** First run = no settings file yet. */
8
8
  /** Where a credential comes from, so setup can point people at the right page. */
9
9
  const KEY_SOURCE = {
10
10
  ANTHROPIC_API_KEY: "https://console.anthropic.com/settings/keys",
11
11
  OPENAI_API_KEY: "https://platform.openai.com/api-keys",
12
12
  BEDROCK_API_KEY: "the AWS console (Bedrock → API keys)",
13
+ AZURE_OPENAI_API_KEY: "the Azure portal (your OpenAI resource → Keys and Endpoint)",
14
+ AZURE_FOUNDRY_API_KEY: "the Azure portal (your Foundry resource → Keys and Endpoint)",
13
15
  };
14
16
  export function needsOnboarding() {
15
17
  return !hasAnySettingsFile();
@@ -33,8 +35,12 @@ export async function setupComplete(cfg) {
33
35
  if (!route.implemented) {
34
36
  return { complete: false, missing: `${route.label} isn't wired up yet`, fix: "/route" };
35
37
  }
36
- if (!cfg.model)
38
+ // Not merely "is it set": an interrupted prompt used to store control
39
+ // characters, which are non-empty and so passed every naive check.
40
+ // eslint-disable-next-line no-control-regex
41
+ if (!cfg.model || !cfg.model.trim() || /[\x00-\x1f]/.test(cfg.model)) {
37
42
  return { complete: false, missing: "no model chosen", fix: "/model" };
43
+ }
38
44
  if (route.needsBaseUrl && !cfg.baseUrl) {
39
45
  return { complete: false, missing: "no endpoint URL for this route", fix: "/route" };
40
46
  }
@@ -87,6 +93,40 @@ async function readKey(rl, envName, promptText) {
87
93
  }
88
94
  return key;
89
95
  }
96
+ /**
97
+ * Read a plain answer, treating an interrupt as cancellation.
98
+ *
99
+ * A raw readline prompt collects Ctrl-C as the character \x03 rather than
100
+ * aborting, so pressing it during the region or model question stored control
101
+ * characters as the answer — and since they aren't empty, every completeness
102
+ * check passed and the profile was saved as valid.
103
+ */
104
+ async function askText(rl, prompt) {
105
+ let raw;
106
+ try {
107
+ raw = await rl.question(prompt);
108
+ }
109
+ catch {
110
+ return undefined;
111
+ }
112
+ // Ctrl-C is the only thing that means "stop". Everything else that isn't
113
+ // printable is terminal noise: a paste arrives wrapped in bracketed-paste
114
+ // markers, so rejecting all control characters made pasting an answer
115
+ // abort setup — which is exactly what someone does with a resource name.
116
+ // eslint-disable-next-line no-control-regex
117
+ if (/[\x03\x04]/.test(raw))
118
+ return undefined;
119
+ const cleaned = raw
120
+ // eslint-disable-next-line no-control-regex
121
+ .replace(/\x1b\[20[01]~/g, "")
122
+ // eslint-disable-next-line no-control-regex
123
+ .replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "")
124
+ // eslint-disable-next-line no-control-regex
125
+ .replace(/[\x00-\x1f\x7f]/g, "");
126
+ return cleaned.trim();
127
+ }
128
+ /** Exposed for tests: paste handling here decides whether setup can finish. */
129
+ export const __askTextForTest = askText;
90
130
  /**
91
131
  * Walk vendor -> route -> region/endpoint -> model. Returns the profile it
92
132
  * saved so the caller can report what still needs doing (e.g. an unset key).
@@ -124,7 +164,32 @@ export async function ensureCredential(rl, route, profile) {
124
164
  return {};
125
165
  const found = credentialSource(route.keyEnv);
126
166
  if (found?.from === "store") {
127
- console.log(pc.dim(` Using ${route.keyEnv} saved on this computer (${maskCredential(found.value)})`));
167
+ // Running setup again usually means something needs changing — often the
168
+ // key itself, because it expired or was wrong. Silently reusing the saved
169
+ // one makes that impossible, so offer the same choice as for an
170
+ // environment key.
171
+ console.log();
172
+ console.log(`${route.keyEnv} is already saved on this computer (${maskCredential(found.value)}).`);
173
+ const choice = await select(rl, "Use it?", [
174
+ `Keep using it ${pc.dim("no change")}`,
175
+ `Replace it ${pc.dim("paste a new key")}`,
176
+ `Remove it ${pc.dim("delete the saved key and stop")}`,
177
+ ]);
178
+ if (choice === 1) {
179
+ const key = await readKey(rl, route.keyEnv, ` ${route.keyEnv} (hidden): `);
180
+ if (key) {
181
+ saveCredential(route.keyEnv, key);
182
+ console.log(pc.dim(` replaced (${maskCredential(key)})`));
183
+ }
184
+ else {
185
+ console.log(pc.dim(" nothing entered — keeping the saved key"));
186
+ }
187
+ }
188
+ else if (choice === 2) {
189
+ deleteCredential(route.keyEnv);
190
+ console.log(pc.dim(` removed. Run faber again to set one up.`));
191
+ return { switchedTo: "quit" };
192
+ }
128
193
  return {};
129
194
  }
130
195
  if (found?.from === "environment") {
@@ -156,13 +221,42 @@ export async function ensureCredential(rl, route, profile) {
156
221
  return {};
157
222
  }
158
223
  if (route.id === "bedrock") {
159
- // Bedrock can sign with an IAM role, so a key may not be needed at all.
224
+ // Two ways in, and which one you pick can matter for billing: an IAM role
225
+ // charges through the account that owns it, a Bedrock API key can belong
226
+ // to a different arrangement entirely. So ask rather than assume, even
227
+ // when one option is obvious from the environment.
160
228
  const { discoverAwsCredentials } = await import("./sigv4.js");
161
229
  const aws = await discoverAwsCredentials();
162
230
  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.`));
231
+ const how = await select(rl, "How should Faber authenticate with AWS?", [
232
+ `AWS credentials (IAM role) ${pc.dim(aws ? `found: ${aws.source}` : "none found here")}`,
233
+ `Bedrock API key ${pc.dim("billed through that key instead")}`,
234
+ ]);
235
+ if (how === 0) {
236
+ if (!aws) {
237
+ // Nothing to sign with, so setup did not succeed and saves nothing.
238
+ console.log(pc.yellow(" No AWS credentials on this machine."));
239
+ console.log(pc.dim(" Run where an IAM role is available (SageMaker, EC2, ECS), or `aws configure`."));
240
+ return { switchedTo: "quit" };
241
+ }
242
+ console.log(pc.dim(` Signing automatically with your AWS role. No key needed.`));
243
+ delete profile.apiKeyEnv; // this route authenticates by signing
244
+ return {};
245
+ }
246
+ console.log();
247
+ console.log("Faber needs a Bedrock API key.");
248
+ const where = KEY_SOURCE[route.keyEnv];
249
+ if (where)
250
+ console.log(pc.dim(` Get one at ${where}`));
251
+ const key = await readKey(rl, route.keyEnv, ` ${route.keyEnv} (hidden): `);
252
+ if (!key) {
253
+ console.log(pc.yellow(" No key entered — setup not completed, nothing saved."));
254
+ return { switchedTo: "quit" };
255
+ }
256
+ saveCredential(route.keyEnv, key);
257
+ profile.apiKeyEnv = route.keyEnv;
258
+ profile.preferStoredKey = true; // an explicit choice beats a role
259
+ console.log(pc.dim(` saved (${maskCredential(key)}) — kept on this computer only`));
166
260
  return {};
167
261
  }
168
262
  // No key anywhere: say where to get one, read it hidden, and if they don't
@@ -182,18 +276,55 @@ export async function ensureCredential(rl, route, profile) {
182
276
  // starts over from the beginning rather than resuming into a broken state.
183
277
  return { switchedTo: "quit" };
184
278
  }
279
+ /** Does the provider accept this credential? Never blocks on being offline. */
280
+ async function verifyCredential(route, profile) {
281
+ if (!route.keyEnv)
282
+ return "ok";
283
+ try {
284
+ const { LLMClient } = await import("./llm.js");
285
+ const { resolveCredential } = await import("./credentials.js");
286
+ const key = resolveCredential(route.keyEnv);
287
+ const baseUrl = profile.baseUrl ?? baseUrlFor(route, profile.region);
288
+ if (!key || !baseUrl)
289
+ return "unreachable";
290
+ process.stdout.write(pc.dim(" checking your key… "));
291
+ const llm = new LLMClient({
292
+ provider: route.wire, baseUrl, apiKey: key, model: "x", route: route.id,
293
+ });
294
+ const verdict = await llm.verifyKey();
295
+ process.stdout.write("\r\x1b[2K");
296
+ return verdict;
297
+ }
298
+ catch {
299
+ process.stdout.write("\r\x1b[2K");
300
+ return "unreachable";
301
+ }
302
+ }
303
+ /** Ask the provider which models this key can use. Empty on any failure. */
304
+ async function discoverModels(route, profile) {
305
+ try {
306
+ const { LLMClient } = await import("./llm.js");
307
+ const { resolveCredential } = await import("./credentials.js");
308
+ const key = route.keyEnv ? resolveCredential(route.keyEnv) : undefined;
309
+ const baseUrl = profile.baseUrl ?? baseUrlFor(route, profile.region);
310
+ if (!baseUrl)
311
+ return [];
312
+ process.stdout.write(pc.dim(" checking which models your key can use… "));
313
+ const llm = new LLMClient({
314
+ provider: route.wire, baseUrl, apiKey: key, model: "x", route: route.id,
315
+ });
316
+ const models = await llm.listModels();
317
+ process.stdout.write("\r\x1b[2K");
318
+ return models;
319
+ }
320
+ catch {
321
+ process.stdout.write("\r\x1b[2K");
322
+ return [];
323
+ }
324
+ }
185
325
  async function finish(rl, route, base) {
186
326
  const profile = { ...base, route: route.id };
187
327
  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
328
  if (route.keyEnv) {
198
329
  profile.apiKeyEnv = route.keyEnv;
199
330
  const outcome = await ensureCredential(rl, activeRoute, profile);
@@ -203,30 +334,138 @@ async function finish(rl, route, base) {
203
334
  return { profile, route: activeRoute, aborted: true };
204
335
  }
205
336
  }
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;
337
+ if (route.id === "foundry") {
338
+ // Same question as Azure OpenAI: the resource name is what people know.
339
+ const res = await askText(rl, "Azure resource name (from your endpoint URL): ");
340
+ if (res === undefined)
341
+ return { profile, route: activeRoute, aborted: true };
342
+ if (res) {
343
+ profile.baseUrl = /^https?:\/\//.test(res)
344
+ ? res.replace(/\/+$/, "")
345
+ : `https://${res}.services.ai.azure.com/anthropic`;
346
+ }
347
+ }
348
+ else if (route.id === "azure-openai") {
349
+ // Ask for the resource name rather than a URL: it's the part people know
350
+ // from the portal, and the rest of the endpoint is fixed.
351
+ const res = await askText(rl, "Azure resource name (from your endpoint URL): ");
352
+ if (res === undefined)
353
+ return { profile, route: activeRoute, aborted: true };
354
+ if (res) {
355
+ profile.baseUrl = /^https?:\/\//.test(res)
356
+ ? res.replace(/\/+$/, "")
357
+ : `https://${res}.openai.azure.com/openai`;
358
+ }
359
+ }
360
+ else if (route.needsBaseUrl) {
361
+ const ans = await askText(rl, "Base URL of the OpenAI-compatible endpoint: ");
362
+ if (ans === undefined)
363
+ return { profile, route: activeRoute, aborted: true };
364
+ if (ans)
365
+ profile.baseUrl = ans;
366
+ }
367
+ if (route.needsRegion) {
368
+ // Only ask when the environment hasn't already answered.
369
+ const { discoverAwsRegion } = await import("./sigv4.js");
370
+ const found = discoverAwsRegion();
371
+ if (found) {
372
+ profile.region = found.region;
373
+ console.log(pc.dim(` Region ${found.region} (from ${found.source}) · /route to change`));
374
+ }
375
+ else {
376
+ const ans = await askText(rl, `AWS region [${DEFAULT_REGION}]: `);
377
+ if (ans === undefined)
378
+ return { profile, route: activeRoute, aborted: true };
379
+ profile.region = ans || DEFAULT_REGION;
380
+ }
381
+ }
382
+ // Model choice, with real ids and real prices.
383
+ //
384
+ // Aliases like "gpt" or "sonnet" hide what you're actually paying for, and
385
+ // the difference is not small: gpt-4o costs about 17x gpt-4o-mini. Since the
386
+ // credential is saved by now, ask the provider what this key can really use
387
+ // and show each model's rate, so nothing about the bill is implicit.
388
+ const { refreshPrices, priceFor, readPriceCache } = await import("./pricing.js");
389
+ if (!readPriceCache())
390
+ process.stdout.write(pc.dim(" fetching prices… "));
391
+ await refreshPrices(undefined, { baseUrl: profile.baseUrl ?? activeRoute.baseUrl });
392
+ process.stdout.write("\r\x1b[2K");
393
+ // Verify the credential before going further. A rejected key means setup
394
+ // did not succeed, so nothing is saved and the next run starts over — the
395
+ // same rule as skipping the key entirely.
396
+ const verdict = await verifyCredential(activeRoute, profile);
397
+ if (verdict === "rejected") {
398
+ if (activeRoute.keyEnv)
399
+ deleteCredential(activeRoute.keyEnv);
400
+ console.log();
401
+ console.log(pc.yellow(`${activeRoute.label} rejected that key.`));
402
+ console.log(pc.dim(" Nothing was saved. Run faber again with a working key."));
403
+ return { profile, route: activeRoute, aborted: true };
404
+ }
405
+ const { isChatModel, sortModels, requiresUnsupportedApi } = await import("./models.js");
406
+ const discovered = sortModels((await discoverModels(activeRoute, profile))
407
+ .filter((m) => isChatModel(m.id) && !requiresUnsupportedApi(m.id)), activeRoute.wire);
408
+ const fallback = modelsForRoute(activeRoute).map((m) => ({ id: m.id, name: m.blurb }));
409
+ const options = discovered.length ? discovered : fallback;
410
+ if (options.length) {
411
+ // Say which list this is. A built-in fallback and a live list look
412
+ // identical otherwise, and the user can't tell whether the models shown
413
+ // are the ones their key can actually reach.
414
+ console.log();
415
+ if (discovered.length) {
416
+ console.log(pc.dim(` ${discovered.length} models available to this key`));
417
+ }
418
+ else {
419
+ const { lastModelListError } = await import("./llm.js");
420
+ const why = lastModelListError();
421
+ console.log(pc.yellow(" Couldn't list models — showing built-in defaults."));
422
+ console.log(pc.dim(why ? ` ${why}` : " No response from the provider."));
423
+ console.log(pc.dim(" /model re-checks later."));
424
+ }
425
+ const width = Math.min(34, Math.max(...options.map((m) => m.id.length)) + 2);
426
+ const labels = options.map((m) => {
427
+ const p = priceFor(m.id);
428
+ const cost = p ? `$${p.in}/$${p.out} per Mtok` : "price unknown";
429
+ return `${m.id.padEnd(width)}${pc.dim(cost)}${m.name ? pc.dim(" " + m.name) : ""}`;
430
+ });
431
+ console.log();
432
+ const mi = await select(rl, "Which model? (input/output cost per million tokens)", labels);
433
+ profile.model = options[mi].id; // a concrete id, never an alias
211
434
  }
212
435
  else {
213
436
  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();
437
+ const ans = await askText(rl, `Model id${hint ? ` [${hint}]` : ""}: `);
438
+ if (ans === undefined)
439
+ return { profile, route: activeRoute, aborted: true };
215
440
  profile.model = ans || hint || undefined;
216
441
  }
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)"));
442
+ // Nothing is written unless the result is genuinely usable. Every earlier
443
+ // exit already returns aborted, but this is the single place that decides,
444
+ // so a future step can't accidentally save a half-finished profile.
445
+ const check = await setupComplete({
446
+ route: profile.route,
447
+ model: profile.model,
448
+ baseUrl: profile.baseUrl ?? baseUrlFor(activeRoute, profile.region),
449
+ apiKey: activeRoute.keyEnv ? credentialSource(activeRoute.keyEnv)?.value : undefined,
450
+ });
451
+ if (!check.complete) {
452
+ console.log();
453
+ console.log(pc.yellow(`Setup not completed — ${check.missing}.`));
454
+ console.log(pc.dim(" Nothing was saved. Run faber again to start over."));
455
+ return { profile, route: activeRoute, aborted: true };
456
+ }
225
457
  const settings = loadSettings();
226
458
  settings.profiles[settings.activeProfile] = profile;
227
459
  saveSettings(settings);
228
- const missingKeyEnv = activeRoute.keyEnv && !credentialSource(activeRoute.keyEnv)
460
+ // Bedrock signs with an IAM role when one is available, so a missing key is
461
+ // not "one thing left" — saying so contradicts the line printed moments ago.
462
+ let missingKeyEnv = activeRoute.keyEnv && !credentialSource(activeRoute.keyEnv)
229
463
  ? activeRoute.keyEnv : undefined;
464
+ if (missingKeyEnv && activeRoute.id === "bedrock") {
465
+ const { discoverAwsCredentials } = await import("./sigv4.js");
466
+ if (await discoverAwsCredentials())
467
+ missingKeyEnv = undefined;
468
+ }
230
469
  return { profile, route: activeRoute, missingKeyEnv };
231
470
  }
232
471
  /** Summary printed after onboarding, including anything still to be done. */
package/dist/pricing.js CHANGED
@@ -50,6 +50,14 @@ export function normalizeModelId(id) {
50
50
  function cacheFile() {
51
51
  return path.join(os.homedir(), ".faber", "cache", "prices.json");
52
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;
53
61
  /** Wait this long before retrying after a failed refresh. */
54
62
  export const RETRY_AFTER_FAILURE_MS = 24 * 60 * 60 * 1000;
55
63
  /** How old the cached prices are, in days. undefined = never fetched. */
@@ -66,7 +74,11 @@ export function pricesAreStale(now = Date.now()) {
66
74
  export function readPriceCache() {
67
75
  try {
68
76
  const raw = JSON.parse(fs.readFileSync(cacheFile(), "utf8"));
69
- return raw.prices && typeof raw.prices === "object" ? raw : undefined;
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;
70
82
  }
71
83
  catch {
72
84
  return undefined;
@@ -77,7 +89,7 @@ export function writePriceCache(prices, source, etag) {
77
89
  const f = cacheFile();
78
90
  fs.mkdirSync(path.dirname(f), { recursive: true });
79
91
  const now = Date.now();
80
- fs.writeFileSync(f, JSON.stringify({ fetchedAt: now, lastAttempt: now, source, etag, prices }, null, 2));
92
+ fs.writeFileSync(f, JSON.stringify({ version: PRICE_CACHE_VERSION, fetchedAt: now, lastAttempt: now, source, etag, prices }, null, 2));
81
93
  }
82
94
  catch { /* best effort */ }
83
95
  }
@@ -103,6 +115,7 @@ export function markRefreshAttempt(now = Date.now()) {
103
115
  fs.mkdirSync(path.dirname(f), { recursive: true });
104
116
  const existing = readPriceCache();
105
117
  fs.writeFileSync(f, JSON.stringify({
118
+ version: PRICE_CACHE_VERSION,
106
119
  fetchedAt: existing?.fetchedAt ?? 0,
107
120
  lastAttempt: now,
108
121
  source: existing?.source ?? "none",
@@ -214,6 +227,11 @@ export async function refreshPrices(url = DATASET_URL, opts = {}) {
214
227
  // and these values get written to a file people read.
215
228
  const perM = (n) => Math.round(n * 1e6 * 1e6) / 1e6;
216
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");
217
235
  const cr = v["cache_read_input_token_cost"], cw = v["cache_creation_input_token_cost"];
218
236
  if (typeof cr === "number")
219
237
  price.cacheRead = perM(cr);
@@ -235,6 +253,21 @@ export async function refreshPrices(url = DATASET_URL, opts = {}) {
235
253
  * id, then normalized), then the built-in table. Undefined means "unknown",
236
254
  * which the ledger renders as — rather than guessing.
237
255
  */
256
+ /**
257
+ * Azure's US Data Zone deployments bill at 1.1x. Applying it keeps the ledger
258
+ * honest for teams who chose that deployment for data-residency reasons —
259
+ * under-reporting spend is the one direction a cost tool must not err in.
260
+ */
261
+ export const US_DATA_ZONE_MULTIPLIER = 1.1;
262
+ export function scalePrice(p, factor) {
263
+ const r = (n) => Math.round(n * factor * 1e6) / 1e6;
264
+ return {
265
+ ...p,
266
+ in: r(p.in), out: r(p.out),
267
+ cacheRead: p.cacheRead === undefined ? undefined : r(p.cacheRead),
268
+ cacheWrite: p.cacheWrite === undefined ? undefined : r(p.cacheWrite),
269
+ };
270
+ }
238
271
  export function priceFor(modelId, override) {
239
272
  if (override?.in && override?.out)
240
273
  return { in: override.in, out: override.out };
package/dist/prompt.js CHANGED
@@ -17,17 +17,48 @@ async function selectRaw(rl, question, options, defaultIndex = 0) {
17
17
  const n = Number.parseInt(ans, 10);
18
18
  return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : defaultIndex;
19
19
  }
20
- console.log(pc.bold(question) + pc.dim(" ↑/↓ then Enter, or 1-9"));
20
+ // Long lists (a provider can return dozens of models) are unusable with
21
+ // arrow keys alone, so typing filters the list as you go.
22
+ const searchable = options.length > 8;
23
+ console.log(pc.bold(question) +
24
+ pc.dim(searchable ? " ↑/↓ then Enter · type to filter" : " ↑/↓ then Enter, or 1-9"));
21
25
  return new Promise((resolve) => {
22
- let idx = defaultIndex;
23
- let firstRender = true;
26
+ let filter = "";
27
+ let view = options.map((_, i) => i); // indices currently shown
28
+ let cursor = Math.max(0, view.indexOf(defaultIndex));
29
+ let painted = 0; // rows drawn last time
30
+ const applyFilter = () => {
31
+ const q = filter.toLowerCase();
32
+ const next = options
33
+ .map((o, i) => [o, i])
34
+ .filter(([o]) => o.toLowerCase().includes(q))
35
+ .map(([, i]) => i);
36
+ view = next.length ? next : [];
37
+ cursor = 0;
38
+ };
24
39
  const render = () => {
25
- if (!firstRender)
26
- process.stdout.write(`\x1b[${options.length}A`);
27
- firstRender = false;
28
- for (let i = 0; i < options.length; i++) {
40
+ if (painted)
41
+ process.stdout.write(`\x1b[${painted}A`);
42
+ const rows = view.length ? view.length : 1;
43
+ const extra = filter ? 1 : 0;
44
+ for (let r = 0; r < view.length; r++) {
29
45
  process.stdout.write("\x1b[2K");
30
- process.stdout.write((i === idx ? pc.cyan(`❯ ${options[i]}`) : pc.dim(` ${options[i]}`)) + "\n");
46
+ const i = view[r];
47
+ process.stdout.write((r === cursor ? pc.cyan(`❯ ${options[i]}`) : pc.dim(` ${options[i]}`)) + "\n");
48
+ }
49
+ if (!view.length) {
50
+ process.stdout.write("\x1b[2K" + pc.yellow(` no match for "${filter}"`) + "\n");
51
+ }
52
+ if (filter) {
53
+ process.stdout.write("\x1b[2K" + pc.dim(` filter: ${filter}`) + "\n");
54
+ }
55
+ // clear any rows the previous, longer render left behind
56
+ for (let r = rows + extra; r < painted; r++)
57
+ process.stdout.write("\x1b[2K\n");
58
+ painted = Math.max(rows + extra, painted);
59
+ if (painted > rows + extra) {
60
+ process.stdout.write(`\x1b[${painted - (rows + extra)}A`);
61
+ painted = rows + extra;
31
62
  }
32
63
  };
33
64
  const stdin = process.stdin;
@@ -40,6 +71,14 @@ async function selectRaw(rl, question, options, defaultIndex = 0) {
40
71
  stdin.removeListener("data", onData);
41
72
  stdin.setRawMode(wasRaw);
42
73
  guardFn?.(false);
74
+ // Drop anything still buffered — typically the Enter that confirmed this
75
+ // menu. Left in place, readline hands it straight to the next question,
76
+ // which then returns an empty answer before the user can type a
77
+ // character: the prompt appears and vanishes in the same instant.
78
+ try {
79
+ while (stdin.read() !== null) { /* discard */ }
80
+ }
81
+ catch { /* not readable */ }
43
82
  rl.resume();
44
83
  resolve(result);
45
84
  };
@@ -58,24 +97,45 @@ async function selectRaw(rl, question, options, defaultIndex = 0) {
58
97
  key = s[i];
59
98
  i += 1;
60
99
  }
61
- if (key === "\x1b[A" || key === "k") {
62
- idx = (idx - 1 + options.length) % options.length;
100
+ if (key === "\x1b[A") {
101
+ if (view.length)
102
+ cursor = (cursor - 1 + view.length) % view.length;
63
103
  render();
64
104
  }
65
- else if (key === "\x1b[B" || key === "j") {
66
- idx = (idx + 1) % options.length;
105
+ else if (key === "\x1b[B") {
106
+ if (view.length)
107
+ cursor = (cursor + 1) % view.length;
67
108
  render();
68
109
  }
69
- else if (key >= "1" && key <= "9" && Number(key) <= options.length) {
70
- idx = Number(key) - 1;
71
- render();
72
- finish(idx);
73
- done = true;
74
- }
75
110
  else if (key === "\r" || key === "\n") {
76
- finish(idx);
111
+ if (view.length) {
112
+ finish(view[cursor]);
113
+ done = true;
114
+ }
115
+ }
116
+ else if (key === "\x7f" || key === "\b") { // backspace edits the filter
117
+ if (filter) {
118
+ filter = filter.slice(0, -1);
119
+ applyFilter();
120
+ render();
121
+ }
122
+ }
123
+ // Number shortcuts only while unfiltered; once you're typing, digits
124
+ // are part of the search term (model ids are full of them).
125
+ else if (!filter && !searchable && key >= "1" && key <= "9" && Number(key) <= options.length) {
126
+ finish(Number(key) - 1);
77
127
  done = true;
78
128
  }
129
+ else if (searchable && key >= " " && key !== "\x1b") {
130
+ filter += key;
131
+ applyFilter();
132
+ render();
133
+ }
134
+ else if (!searchable && (key === "k" || key === "j")) {
135
+ if (view.length)
136
+ cursor = (cursor + (key === "k" ? -1 : 1) + view.length) % view.length;
137
+ render();
138
+ }
79
139
  // Ctrl-C or Esc cancels. Returning -1 used to leak out as an array
80
140
  // index, crashing the caller with "cannot read properties of
81
141
  // undefined" — a cancel must be a clean exit, not a bad index.
@@ -118,7 +178,16 @@ export async function readSecret(rl, promptText, io) {
118
178
  resolve(value.trim());
119
179
  };
120
180
  const onData = (buf) => {
121
- for (const ch of buf.toString("utf8")) {
181
+ // Terminals wrap pasted text in bracketed-paste markers, ESC[200~ before
182
+ // and ESC[201~ after. The ESC byte is below space and gets dropped by the
183
+ // printable test below, but "[200~" is ordinary text and would be glued
184
+ // onto the secret — which is how a pasted key ends up rejected as
185
+ // malformed. Strip the markers, and any other escape sequence, first.
186
+ const chunk = buf.toString("utf8")
187
+ .replace(/\x1b\[20[01]~/g, "")
188
+ .replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "")
189
+ .replace(/\x1b./g, "");
190
+ for (const ch of chunk) {
122
191
  if (ch === "\r" || ch === "\n")
123
192
  return done();
124
193
  if (ch === "\x03") {
@@ -126,16 +195,15 @@ export async function readSecret(rl, promptText, io) {
126
195
  return done();
127
196
  } // Ctrl-C
128
197
  if (ch === "\x7f" || ch === "\b") { // backspace
129
- if (value.length) {
198
+ if (value.length)
130
199
  value = value.slice(0, -1);
131
- stdout.write("\b \b");
132
- }
133
200
  continue;
134
201
  }
135
- if (ch >= " ") {
202
+ // Echo nothing at all, the way sudo and ssh do. Masking characters
203
+ // would still reveal the key's length, and a hundred dots for a long
204
+ // key looks like something went wrong.
205
+ if (ch >= " ")
136
206
  value += ch;
137
- stdout.write("•");
138
- }
139
207
  }
140
208
  };
141
209
  stdin.on("data", onData);
package/dist/routes.js CHANGED
@@ -39,6 +39,24 @@ export const ROUTES = [
39
39
  aliasesArePinned: true, // Bedrock model ids differ per region/deployment
40
40
  implemented: true,
41
41
  },
42
+ {
43
+ // Claude hosted on Azure through Microsoft Foundry. It speaks the same
44
+ // Messages API as the direct route, so this is a base URL and an auth
45
+ // header rather than a new wire — the migration Microsoft describes as
46
+ // "swap the base URL and authentication, keep the calls the same".
47
+ id: "foundry",
48
+ vendor: "Anthropic",
49
+ label: "Microsoft Foundry (Azure)",
50
+ hint: "your Azure subscription owns auth and billing",
51
+ wire: "anthropic",
52
+ needsBaseUrl: true,
53
+ // Its own variable: a Foundry resource is separate from an Azure OpenAI
54
+ // one, with its own key. Sharing the name meant a key saved for GPT
55
+ // deployments was offered for Claude, where it cannot work.
56
+ keyEnv: "AZURE_FOUNDRY_API_KEY",
57
+ aliasesArePinned: true, // you address a deployment someone named
58
+ implemented: true,
59
+ },
42
60
  {
43
61
  id: "vertex",
44
62
  vendor: "Anthropic",
@@ -48,6 +66,21 @@ export const ROUTES = [
48
66
  aliasesArePinned: true,
49
67
  implemented: false, // needs Google OAuth
50
68
  },
69
+ {
70
+ // Azure hosts OpenAI's models under a company's own subscription, which is
71
+ // the answer for someone who wants codex billed through work rather than a
72
+ // personal card. Same wire as OpenAI, but the key travels in its own
73
+ // header and the deployment name lives in the URL.
74
+ id: "azure-openai",
75
+ vendor: "OpenAI",
76
+ label: "Azure OpenAI",
77
+ hint: "your Azure subscription owns auth and billing",
78
+ wire: "openai",
79
+ needsBaseUrl: true,
80
+ keyEnv: "AZURE_OPENAI_API_KEY",
81
+ aliasesArePinned: true, // you address deployments, not model ids
82
+ implemented: true,
83
+ },
51
84
  {
52
85
  id: "openai-api",
53
86
  vendor: "OpenAI",