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/README.md +120 -42
- package/dist/agent.js +11 -0
- package/dist/commands.js +35 -0
- package/dist/index.js +53 -6
- package/dist/llm.js +374 -12
- package/dist/models.js +111 -1
- package/dist/onboard.js +270 -31
- package/dist/pricing.js +35 -2
- package/dist/prompt.js +94 -26
- package/dist/routes.js +33 -0
- package/dist/sigv4.js +50 -2
- package/dist/usage.js +127 -82
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 =
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
23
|
-
let
|
|
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 (
|
|
26
|
-
process.stdout.write(`\x1b[${
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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"
|
|
62
|
-
|
|
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"
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|