faberwright 0.3.0 → 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.
- package/README.md +136 -22
- package/dist/agent.js +9 -1
- package/dist/config.js +48 -5
- package/dist/credentials.js +134 -0
- package/dist/index.js +225 -9
- package/dist/llm.js +79 -3
- package/dist/models.js +70 -0
- package/dist/onboard.js +254 -0
- package/dist/pricing.js +254 -0
- package/dist/prompt.js +79 -2
- package/dist/routes.js +143 -0
- package/dist/settings.js +55 -0
- package/dist/sigv4.js +177 -0
- package/dist/usage.js +129 -29
- package/package.json +10 -2
package/dist/index.js
CHANGED
|
@@ -32,12 +32,18 @@ import { Agent } from "./agent.js";
|
|
|
32
32
|
import { FatalError, CancelledError } from "./errors.js";
|
|
33
33
|
import { SessionStore } from "./memory/sessions.js";
|
|
34
34
|
import { isRepo, isDirty, ensureStateIgnored } from "./git.js";
|
|
35
|
-
import { select, setSelectGuard } from "./prompt.js";
|
|
35
|
+
import { select, setSelectGuard, readSecret } from "./prompt.js";
|
|
36
36
|
import { createComposedInput, restore, describeComposed } from "./input.js";
|
|
37
37
|
import { Composer } from "./editor.js";
|
|
38
38
|
import { StatusLine } from "./status.js";
|
|
39
39
|
import { renderMarkdown, StreamRenderer } from "./markdown.js";
|
|
40
|
-
import { renderUsagePanel } from "./usage.js";
|
|
40
|
+
import { renderUsagePanel, UsageLedger } from "./usage.js";
|
|
41
|
+
import { ROUTES, getRoute, describeModel, resolveModel, vendors, baseUrlFor, DEFAULT_REGION } from "./routes.js";
|
|
42
|
+
import { loadSettings, saveSettings, updateActive, settingsPath } from "./settings.js";
|
|
43
|
+
import { needsOnboarding, interactive, runOnboarding, reportSetup, setupComplete } from "./onboard.js";
|
|
44
|
+
import { saveCredential, deleteCredential, listCredentialNames, getCredential, maskCredential, credentialsPath, resolveCredential, looksLikeKey, } from "./credentials.js";
|
|
45
|
+
import { readCache, writeCache, clearCache, buildPicker } from "./models.js";
|
|
46
|
+
import { refreshPrices, priceFor } from "./pricing.js";
|
|
41
47
|
/** Version comes from package.json — one source of truth for banner and --version. */
|
|
42
48
|
const VERSION = (() => {
|
|
43
49
|
try {
|
|
@@ -150,14 +156,64 @@ async function main() {
|
|
|
150
156
|
const totalIn = u.input + u.cacheRead + u.cacheWrite;
|
|
151
157
|
const cachePct = totalIn > 0 ? Math.round((u.cacheRead / totalIn) * 100) : 0;
|
|
152
158
|
let line = `tokens: ${k(totalIn)} in (${cachePct}% cached) / ${k(u.output)} out · ${u.calls} call${u.calls === 1 ? "" : "s"}`;
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
155
|
-
const usd = (u.input * pIn + u.cacheRead * pIn * 0.1 + u.cacheWrite * pIn * 1.25 + u.output * pOut) / 1e6;
|
|
159
|
+
const usd = UsageLedger.cost(u, priceFor(agent.model, { in: config.priceIn, out: config.priceOut }));
|
|
160
|
+
if (usd !== undefined)
|
|
156
161
|
line += ` · ~$${usd.toFixed(3)}`;
|
|
157
|
-
}
|
|
158
162
|
console.log(pc.dim(line));
|
|
159
163
|
},
|
|
160
164
|
};
|
|
165
|
+
// Setup must be verified BEFORE the agent is built: the LLM client throws
|
|
166
|
+
// on a missing key, which would pre-empt the guided fix with a raw error.
|
|
167
|
+
// Every launch checks that setup is COMPLETE, not merely that a settings
|
|
168
|
+
// file exists. What counts depends on the route, so a Bedrock profile with
|
|
169
|
+
// an IAM role passes while an Anthropic profile with no key does not —
|
|
170
|
+
// and the gap is fixed here rather than surfacing as a 401 mid-task.
|
|
171
|
+
if (needsOnboarding() && interactive()) {
|
|
172
|
+
console.log(pc.cyan(`Faber v${VERSION}`));
|
|
173
|
+
const setup = await runOnboarding(rl, { first: true });
|
|
174
|
+
reportSetup(setup);
|
|
175
|
+
// Aborted setup saved nothing, so there is no model to reach — exit here
|
|
176
|
+
// rather than letting the LLM client throw a less useful error.
|
|
177
|
+
if (setup.aborted) {
|
|
178
|
+
rl.close();
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
config = loadConfig(config.workspace);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const state = await setupComplete(config);
|
|
186
|
+
if (!state.complete) {
|
|
187
|
+
if (!interactive()) {
|
|
188
|
+
console.error(pc.red(`Setup isn't finished — ${state.missing}.`));
|
|
189
|
+
console.error(pc.dim(` Run faber interactively to complete setup, or: ${state.fix}`));
|
|
190
|
+
rl.close();
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Start over from the top rather than resuming at the missing step:
|
|
195
|
+
// someone without a key for this route usually wants a DIFFERENT route,
|
|
196
|
+
// and resuming would trap them on the one that just failed.
|
|
197
|
+
console.log(pc.cyan(`Faber v${VERSION}`));
|
|
198
|
+
console.log(pc.yellow(`Setup isn't finished — ${state.missing}. Starting over.`));
|
|
199
|
+
const setup = await runOnboarding(rl, { first: true });
|
|
200
|
+
reportSetup(setup);
|
|
201
|
+
if (setup.aborted) {
|
|
202
|
+
rl.close();
|
|
203
|
+
process.exitCode = 1;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
config = loadConfig(config.workspace);
|
|
207
|
+
const after = await setupComplete(config);
|
|
208
|
+
if (!after.complete) {
|
|
209
|
+
console.log(pc.yellow(`Setup still incomplete — ${after.missing}.`));
|
|
210
|
+
console.log(pc.dim(` ${after.fix}, then run faber again.`));
|
|
211
|
+
rl.close();
|
|
212
|
+
process.exitCode = 1;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
161
217
|
const resumeId = flags.has("--resume") ? SessionStore.latestId(config.sessionsDir) : undefined;
|
|
162
218
|
let agent;
|
|
163
219
|
try {
|
|
@@ -326,8 +382,164 @@ async function main() {
|
|
|
326
382
|
agent.shortTerm.clear();
|
|
327
383
|
console.log("Short-term memory cleared.");
|
|
328
384
|
break;
|
|
385
|
+
case "/model": {
|
|
386
|
+
const route = getRoute(config.route) ?? ROUTES[0];
|
|
387
|
+
if (args[0] === "--save") {
|
|
388
|
+
updateActive({ model: agent.model });
|
|
389
|
+
console.log(`Saved ${agent.model} as the default for profile "${config.profileName}".`);
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
if (args[0] && args[0] !== "--refresh") { // direct: /model haiku
|
|
393
|
+
const id = resolveModel(args[0], route, config.modelPins);
|
|
394
|
+
agent.setModel(id);
|
|
395
|
+
console.log(`Model for this session: ${describeModel(id, route, config.modelPins)}`);
|
|
396
|
+
console.log(pc.dim(" /model --save make it the profile default"));
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
// Ask the provider what this key can actually use; cached for a day.
|
|
400
|
+
if (args[0] === "--refresh")
|
|
401
|
+
clearCache(route.id);
|
|
402
|
+
let discovered = readCache(route.id);
|
|
403
|
+
if (!discovered) {
|
|
404
|
+
process.stdout.write(pc.dim(" fetching available models… "));
|
|
405
|
+
discovered = await agent.llm.listModels();
|
|
406
|
+
process.stdout.write("\r\x1b[2K");
|
|
407
|
+
if (discovered.length)
|
|
408
|
+
writeCache(route.id, discovered);
|
|
409
|
+
}
|
|
410
|
+
const entries = buildPicker(route, discovered ?? [], agent.model);
|
|
411
|
+
if (!entries.length) {
|
|
412
|
+
console.log(`No model list available for ${route.label}. Set one with: /model <id>`);
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
const width = Math.min(34, Math.max(...entries.map((e) => e.label.length)) + 2);
|
|
416
|
+
const labels = entries.map((e) => `${e.label.padEnd(width)}${pc.dim(e.blurb)}${e.live ? pc.dim(" ·live") : ""}`);
|
|
417
|
+
const curIdx = entries.findIndex((e) => e.value === agent.model || resolveModel(e.value, route, config.modelPins) === agent.model);
|
|
418
|
+
const pick = await select(rl, "Select model (this session)", labels, curIdx < 0 ? 0 : curIdx);
|
|
419
|
+
const chosen = entries[pick];
|
|
420
|
+
const id = resolveModel(chosen.value, route, config.modelPins);
|
|
421
|
+
agent.setModel(id);
|
|
422
|
+
console.log(`Model for this session: ${describeModel(id, route, config.modelPins)}`);
|
|
423
|
+
console.log(pc.dim(" /model --save keep it · /model --refresh re-check the provider"));
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
case "/key": {
|
|
427
|
+
const route = getRoute(config.route) ?? ROUTES[0];
|
|
428
|
+
const name = args[1] ?? route.keyEnv ?? "ANTHROPIC_API_KEY";
|
|
429
|
+
if (args[0] === "set") {
|
|
430
|
+
const val = await readSecret(rl, ` ${name} (hidden): `);
|
|
431
|
+
if (!val) {
|
|
432
|
+
console.log("Nothing entered.");
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
const problem = looksLikeKey(name, val);
|
|
436
|
+
if (problem) {
|
|
437
|
+
console.log(pc.yellow(` ${problem}.`));
|
|
438
|
+
const ok = await select(rl, "Save it anyway?", ["No, discard it", "Yes, save it"]);
|
|
439
|
+
if (ok === 0) {
|
|
440
|
+
console.log("Discarded.");
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
saveCredential(name, val);
|
|
445
|
+
console.log(`Saved ${name} (${maskCredential(val)}) — kept on this computer only.`);
|
|
446
|
+
console.log(pc.dim(` ${credentialsPath()}, readable only by you — restart faber to use it`));
|
|
447
|
+
}
|
|
448
|
+
else if (args[0] === "rm") {
|
|
449
|
+
console.log(deleteCredential(name) ? `Removed ${name}.` : `No stored ${name}.`);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
const names = listCredentialNames();
|
|
453
|
+
if (!names.length)
|
|
454
|
+
console.log(`No stored credentials. Add one with: /key set [${name}]`);
|
|
455
|
+
for (const n of names) {
|
|
456
|
+
const stored = getCredential(n);
|
|
457
|
+
const shadowed = process.env[n] ? pc.dim(" (env var takes precedence)") : "";
|
|
458
|
+
console.log(` ${n.padEnd(24)} ${maskCredential(stored)}${shadowed}`);
|
|
459
|
+
}
|
|
460
|
+
console.log(pc.dim(` ${credentialsPath()}`));
|
|
461
|
+
}
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
case "/setup": {
|
|
465
|
+
const setup = await runOnboarding(rl);
|
|
466
|
+
reportSetup(setup);
|
|
467
|
+
console.log(pc.dim(" restart faber to apply"));
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
case "/route": {
|
|
471
|
+
const groups = vendors();
|
|
472
|
+
const vi = await select(rl, "Select vendor", groups.map((g) => g.vendor));
|
|
473
|
+
const rs = groups[vi].routes;
|
|
474
|
+
const ri = await select(rl, "Select route", rs.map((r) => `${r.label.padEnd(28)} ${pc.dim(r.hint)}${r.implemented ? "" : pc.yellow(" (not yet wired)")}`));
|
|
475
|
+
const chosen = rs[ri];
|
|
476
|
+
if (!chosen.implemented) {
|
|
477
|
+
console.log(pc.yellow(`${chosen.label} isn't wired up yet — see the roadmap in the README.`));
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
let region;
|
|
481
|
+
if (chosen.needsRegion) {
|
|
482
|
+
const ans = (await rl.question(`AWS region [${DEFAULT_REGION}]: `)).trim();
|
|
483
|
+
region = ans || DEFAULT_REGION;
|
|
484
|
+
}
|
|
485
|
+
let baseUrl = chosen.baseUrl;
|
|
486
|
+
if (chosen.needsBaseUrl) {
|
|
487
|
+
baseUrl = (await rl.question("Base URL (OpenAI-compatible): ")).trim() || undefined;
|
|
488
|
+
}
|
|
489
|
+
updateActive({ route: chosen.id, baseUrl, region, apiKeyEnv: chosen.keyEnv });
|
|
490
|
+
console.log(`Route set to ${chosen.label}.`);
|
|
491
|
+
const url = baseUrl ?? baseUrlFor(chosen, region);
|
|
492
|
+
if (url)
|
|
493
|
+
console.log(pc.dim(` endpoint: ${url}`));
|
|
494
|
+
if (chosen.keyEnv && !resolveCredential(chosen.keyEnv)) {
|
|
495
|
+
if (chosen.id === "bedrock") {
|
|
496
|
+
const { discoverAwsCredentials } = await import("./sigv4.js");
|
|
497
|
+
const aws = await discoverAwsCredentials();
|
|
498
|
+
console.log(aws
|
|
499
|
+
? pc.dim(` no ${chosen.keyEnv} needed — signing with AWS credentials from ${aws.source}`)
|
|
500
|
+
: pc.yellow(` set ${chosen.keyEnv}, or run where an IAM role is available (SageMaker, ECS, EC2)`));
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
console.log(pc.yellow(` ${chosen.keyEnv} is not set.`));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (chosen.aliasesArePinned) {
|
|
507
|
+
console.log(pc.dim(" model ids differ on this route — set one with: /model <id>"));
|
|
508
|
+
}
|
|
509
|
+
console.log(pc.dim(" restart faber to apply"));
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
case "/profile": {
|
|
513
|
+
const st = loadSettings();
|
|
514
|
+
if (!args[0]) {
|
|
515
|
+
for (const [name, p] of Object.entries(st.profiles)) {
|
|
516
|
+
const mark = name === st.activeProfile ? pc.cyan("❯") : " ";
|
|
517
|
+
const r = getRoute(p.route);
|
|
518
|
+
console.log(`${mark} ${name.padEnd(12)} ${(r?.label ?? p.route).padEnd(26)} ${pc.dim(p.model ?? "")}`);
|
|
519
|
+
}
|
|
520
|
+
console.log(pc.dim(` ${settingsPath()}`));
|
|
521
|
+
console.log(pc.dim(" switch with: /profile <name>"));
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
if (!st.profiles[args[0]]) {
|
|
525
|
+
console.log(`No profile "${args[0]}".`);
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
st.activeProfile = args[0];
|
|
529
|
+
saveSettings(st);
|
|
530
|
+
console.log(`Active profile: ${args[0]}. Restart faber to apply.`);
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
329
533
|
case "/usage": {
|
|
330
|
-
|
|
534
|
+
if (args[0] === "--refresh-prices") {
|
|
535
|
+
process.stdout.write(pc.dim(" fetching current prices… "));
|
|
536
|
+
const n = await refreshPrices(undefined, { baseUrl: config.baseUrl, force: true });
|
|
537
|
+
process.stdout.write("\r\x1b[2K");
|
|
538
|
+
console.log(n === "unchanged" ? "Prices confirmed current."
|
|
539
|
+
: typeof n === "number" ? `Prices updated (${n} models).`
|
|
540
|
+
: pc.yellow("Couldn't fetch prices — keeping the rates already known."));
|
|
541
|
+
}
|
|
542
|
+
console.log(renderUsagePanel(agent.usage, { in: config.priceIn, out: config.priceOut }));
|
|
331
543
|
break;
|
|
332
544
|
}
|
|
333
545
|
case "/verbose":
|
|
@@ -356,7 +568,11 @@ async function main() {
|
|
|
356
568
|
return;
|
|
357
569
|
}
|
|
358
570
|
console.log(pc.cyan(`Faber v${VERSION} — agentic coding assistant`));
|
|
359
|
-
console.log(pc.dim(`workspace: ${config.workspace}\
|
|
571
|
+
console.log(pc.dim(`workspace: ${config.workspace}\n` +
|
|
572
|
+
`model: ${describeModel(config.model, getRoute(config.route) ?? ROUTES[0], config.modelPins)}` +
|
|
573
|
+
` route: ${getRoute(config.route)?.label ?? config.route}` +
|
|
574
|
+
(config.profileName !== "default" ? ` profile: ${config.profileName}` : "") +
|
|
575
|
+
` approval: ${approvalMode}`));
|
|
360
576
|
if (isRepo(config.workspace) && isDirty(config.workspace)) {
|
|
361
577
|
console.log(pc.yellow("Git: you have uncommitted changes — consider committing before letting me edit."));
|
|
362
578
|
}
|
|
@@ -426,7 +642,7 @@ const HELP = `
|
|
|
426
642
|
/restore <id> jump files back to before a specific task
|
|
427
643
|
/clear clear short-term conversation memory
|
|
428
644
|
/ask | /auto approval mode: preview diffs and commands (default) / apply freely
|
|
429
|
-
env:
|
|
645
|
+
env: FABER_APPROVAL=auto to change default; FABER_GIT=commit for one git commit per task
|
|
430
646
|
/exit quit
|
|
431
647
|
`;
|
|
432
648
|
main().catch((e) => { console.error(pc.red(String(e?.stack ?? e))); process.exit(1); });
|
package/dist/llm.js
CHANGED
|
@@ -1,14 +1,88 @@
|
|
|
1
|
+
import { signRequest, discoverAwsCredentials } from "./sigv4.js";
|
|
1
2
|
import { FatalError, TransientAPIError, withRetries, CancelledError } from "./errors.js";
|
|
2
3
|
const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504, 529]);
|
|
4
|
+
/** AWS credentials discovered once per process for SigV4 routes. */
|
|
5
|
+
let awsCredsPromise;
|
|
3
6
|
export class LLMClient {
|
|
4
7
|
config;
|
|
5
8
|
constructor(config) {
|
|
6
9
|
this.config = config;
|
|
10
|
+
// Local servers (Ollama, LM Studio, vLLM on localhost) accept any bearer
|
|
11
|
+
// token, so requiring a key there would block the one route that is free.
|
|
12
|
+
const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(config.baseUrl);
|
|
13
|
+
// Routes that sign with AWS credentials need no API key at all — in
|
|
14
|
+
// SageMaker Studio, ECS or Lambda the execution role supplies them.
|
|
15
|
+
if (!config.apiKey && config.route === "bedrock")
|
|
16
|
+
return;
|
|
17
|
+
if (!config.apiKey && isLocal) {
|
|
18
|
+
this.config = { ...config, apiKey: "local" };
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
7
21
|
if (!config.apiKey) {
|
|
8
22
|
const v = config.provider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
|
|
9
|
-
throw new FatalError(`No API key found. Set ${v} or
|
|
23
|
+
throw new FatalError(`No API key found. Set ${v}, or run /route to pick a local model that doesn't need one.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Ask the provider which models this credential can actually use.
|
|
28
|
+
* Anthropic: GET /v1/models -> { data: [{ id, display_name }] }, newest first.
|
|
29
|
+
* OpenAI-compatible (incl. Bedrock mantle, Ollama): GET /models, same shape
|
|
30
|
+
* minus display_name. Returns [] on any failure — this is a convenience,
|
|
31
|
+
* never a blocker, so an offline or restricted key just falls back.
|
|
32
|
+
*/
|
|
33
|
+
async listModels(signal) {
|
|
34
|
+
const anthropic = this.config.provider === "anthropic";
|
|
35
|
+
const url = anthropic
|
|
36
|
+
? `${this.config.baseUrl}/v1/models?limit=100`
|
|
37
|
+
: `${this.config.baseUrl}/models`;
|
|
38
|
+
const headers = anthropic
|
|
39
|
+
? { "x-api-key": this.config.apiKey, "anthropic-version": "2023-06-01" }
|
|
40
|
+
: { authorization: `Bearer ${this.config.apiKey}` };
|
|
41
|
+
try {
|
|
42
|
+
const ctl = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => ctl.abort(), 6000);
|
|
44
|
+
signal?.addEventListener("abort", () => ctl.abort(), { once: true });
|
|
45
|
+
const res = await fetch(url, { headers, signal: ctl.signal });
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
if (!res.ok)
|
|
48
|
+
return [];
|
|
49
|
+
const body = await res.json();
|
|
50
|
+
return (body.data ?? [])
|
|
51
|
+
.filter((m) => typeof m.id === "string")
|
|
52
|
+
.map((m) => ({ id: m.id, name: m.display_name }));
|
|
10
53
|
}
|
|
54
|
+
catch {
|
|
55
|
+
return []; // offline, no permission, or an endpoint without the route
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Auth for one request. An API key is a header; AWS credentials mean signing
|
|
60
|
+
* the whole request, which is how an IAM role authenticates with no key.
|
|
61
|
+
*/
|
|
62
|
+
async authHeaders(url, body) {
|
|
63
|
+
if (this.config.apiKey)
|
|
64
|
+
return { "x-api-key": this.config.apiKey };
|
|
65
|
+
if (this.config.route !== "bedrock")
|
|
66
|
+
return {};
|
|
67
|
+
awsCredsPromise ??= discoverAwsCredentials();
|
|
68
|
+
const creds = await awsCredsPromise;
|
|
69
|
+
if (!creds) {
|
|
70
|
+
throw new FatalError("No AWS credentials found for the Bedrock route. Set BEDROCK_API_KEY, " +
|
|
71
|
+
"or run in an environment with an IAM role (SageMaker, ECS, EC2) " +
|
|
72
|
+
"or `aws configure`.");
|
|
73
|
+
}
|
|
74
|
+
return signRequest({
|
|
75
|
+
method: "POST",
|
|
76
|
+
url,
|
|
77
|
+
body,
|
|
78
|
+
region: this.config.region ?? "us-east-1",
|
|
79
|
+
service: "bedrock",
|
|
80
|
+
credentials: creds,
|
|
81
|
+
headers: { "content-type": "application/json" },
|
|
82
|
+
});
|
|
11
83
|
}
|
|
84
|
+
/** Switch model at runtime (/model). Cache prefixes are per-model. */
|
|
85
|
+
setModel(id) { this.config = { ...this.config, model: id }; }
|
|
12
86
|
complete(system, messages, tools, onText, signal, modelOverride) {
|
|
13
87
|
return withRetries(() => this.config.provider === "anthropic"
|
|
14
88
|
? this.anthropicStream(system, messages, tools, onText, signal, modelOverride)
|
|
@@ -41,8 +115,10 @@ export class LLMClient {
|
|
|
41
115
|
};
|
|
42
116
|
if (tools.length)
|
|
43
117
|
body.tools = cachedTools;
|
|
44
|
-
const
|
|
45
|
-
|
|
118
|
+
const url = `${this.config.baseUrl}/v1/messages`;
|
|
119
|
+
const authHeaders = await this.authHeaders(url, JSON.stringify(body));
|
|
120
|
+
const res = await this.post(url, {
|
|
121
|
+
...authHeaders,
|
|
46
122
|
"anthropic-version": "2023-06-01",
|
|
47
123
|
"content-type": "application/json",
|
|
48
124
|
}, body, signal);
|
package/dist/models.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model discovery: ask the provider what this credential can actually use,
|
|
3
|
+
* cache it, and fall back to the built-in list when that isn't possible.
|
|
4
|
+
*
|
|
5
|
+
* Why bother: hardcoded model lists go stale the moment a provider ships
|
|
6
|
+
* something new, and they can't know which models YOUR account is entitled to.
|
|
7
|
+
* A live list is always current and always accurate for the caller.
|
|
8
|
+
*
|
|
9
|
+
* Discovery is strictly a convenience. Every failure path — offline, a key
|
|
10
|
+
* without models:list permission, an endpoint that doesn't implement it —
|
|
11
|
+
* falls back to the static aliases rather than blocking the picker.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { modelsForRoute } from "./routes.js";
|
|
17
|
+
const TTL_MS = 24 * 60 * 60 * 1000; // a day: new models are rare, staleness is cheap
|
|
18
|
+
function cacheFile(routeId) {
|
|
19
|
+
return path.join(os.homedir(), ".faber", "cache", `models-${routeId}.json`);
|
|
20
|
+
}
|
|
21
|
+
export function readCache(routeId, now = Date.now()) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(fs.readFileSync(cacheFile(routeId), "utf8"));
|
|
24
|
+
if (!Array.isArray(raw.models) || now - raw.fetchedAt > TTL_MS)
|
|
25
|
+
return undefined;
|
|
26
|
+
return raw.models;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function writeCache(routeId, models, now = Date.now()) {
|
|
33
|
+
try {
|
|
34
|
+
const f = cacheFile(routeId);
|
|
35
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
36
|
+
fs.writeFileSync(f, JSON.stringify({ fetchedAt: now, models }, null, 2));
|
|
37
|
+
}
|
|
38
|
+
catch { /* cache is best-effort */ }
|
|
39
|
+
}
|
|
40
|
+
export function clearCache(routeId) {
|
|
41
|
+
try {
|
|
42
|
+
fs.unlinkSync(cacheFile(routeId));
|
|
43
|
+
}
|
|
44
|
+
catch { /* already gone */ }
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Build the /model menu: built-in aliases first (stable names people type),
|
|
48
|
+
* then anything discovered that an alias doesn't already cover.
|
|
49
|
+
* Provider lists arrive newest-first, and that order is preserved.
|
|
50
|
+
*/
|
|
51
|
+
export function buildPicker(route, discovered, currentId) {
|
|
52
|
+
const aliases = modelsForRoute(route);
|
|
53
|
+
const entries = aliases.map((a) => ({
|
|
54
|
+
value: a.alias,
|
|
55
|
+
label: a.alias,
|
|
56
|
+
blurb: a.blurb,
|
|
57
|
+
live: false,
|
|
58
|
+
}));
|
|
59
|
+
const covered = new Set(aliases.map((a) => a.id));
|
|
60
|
+
for (const m of discovered) {
|
|
61
|
+
if (covered.has(m.id))
|
|
62
|
+
continue;
|
|
63
|
+
entries.push({ value: m.id, label: m.id, blurb: m.name ?? "", live: true });
|
|
64
|
+
}
|
|
65
|
+
// if the model in use is neither an alias nor discovered, keep it visible
|
|
66
|
+
if (!entries.some((e) => e.value === currentId) && !covered.has(currentId)) {
|
|
67
|
+
entries.push({ value: currentId, label: currentId, blurb: "current", live: false });
|
|
68
|
+
}
|
|
69
|
+
return entries;
|
|
70
|
+
}
|