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/llm.js CHANGED
@@ -1,6 +1,16 @@
1
1
  import { signRequest, discoverAwsCredentials } from "./sigv4.js";
2
+ import { priceFor } from "./pricing.js";
2
3
  import { FatalError, TransientAPIError, withRetries, CancelledError } from "./errors.js";
3
4
  const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504, 529]);
5
+ /**
6
+ * Why the last model listing came back empty. "Couldn't reach the provider"
7
+ * is not a useful thing to tell someone whose key demonstrably works — the
8
+ * status code or error usually says exactly what went wrong.
9
+ */
10
+ /** Azure pins the API surface by date; this one covers tools and streaming. */
11
+ export const AZURE_API_VERSION = "2024-10-21";
12
+ let lastListError;
13
+ export function lastModelListError() { return lastListError; }
4
14
  /** AWS credentials discovered once per process for SigV4 routes. */
5
15
  let awsCredsPromise;
6
16
  export class LLMClient {
@@ -30,28 +40,146 @@ export class LLMClient {
30
40
  * minus display_name. Returns [] on any failure — this is a convenience,
31
41
  * never a blocker, so an offline or restricted key just falls back.
32
42
  */
43
+ /**
44
+ * Check whether the provider accepts this credential.
45
+ * "rejected" means the key is definitively wrong (401/403) — that is a
46
+ * failed setup. "unreachable" covers being offline or an endpoint without a
47
+ * models route, which says nothing about the key and must not block setup.
48
+ */
49
+ async verifyKey() {
50
+ if (this.config.route === "bedrock") {
51
+ // The mantle endpoint has no listing to probe, so treat a working
52
+ // credential discovery as sufficient; a bad one fails on first use.
53
+ return (await this.listBedrockModels()).length ? "ok" : "unreachable";
54
+ }
55
+ const anthropic = this.config.provider === "anthropic";
56
+ const url = anthropic ? `${this.config.baseUrl}/v1/models?limit=1`
57
+ : `${this.config.baseUrl}/models`;
58
+ const auth = await this.authHeaders(url, "", "GET");
59
+ const headers = anthropic
60
+ ? { ...auth, "anthropic-version": "2023-06-01" }
61
+ : this.config.route === "azure-openai"
62
+ ? { "api-key": this.config.apiKey ?? "" }
63
+ : (this.config.apiKey ? { authorization: `Bearer ${this.config.apiKey}` } : auth);
64
+ try {
65
+ const ctl = new AbortController();
66
+ const timer = setTimeout(() => ctl.abort(), 8000);
67
+ const res = await fetch(url, { headers, signal: ctl.signal });
68
+ clearTimeout(timer);
69
+ if (res.status === 401 || res.status === 403)
70
+ return "rejected";
71
+ return res.ok ? "ok" : "unreachable";
72
+ }
73
+ catch {
74
+ return "unreachable";
75
+ }
76
+ }
77
+ /**
78
+ * Bedrock has no Messages-style listing: the mantle endpoint serves
79
+ * /v1/messages but returns 404 for /v1/models. AWS's own ListFoundationModels
80
+ * is the catalogue, on a different host and signed as a normal AWS call.
81
+ */
82
+ async listBedrockModels() {
83
+ const region = this.config.region ?? "us-east-1";
84
+ const url = this.config.bedrockCatalogUrl
85
+ ?? `https://bedrock.${region}.amazonaws.com/foundation-models`;
86
+ try {
87
+ const auth = await this.authHeaders(url, "", "GET");
88
+ const ctl = new AbortController();
89
+ const timer = setTimeout(() => ctl.abort(), 8000);
90
+ const res = await fetch(url, { headers: auth, signal: ctl.signal });
91
+ clearTimeout(timer);
92
+ if (!res.ok) {
93
+ lastListError = `HTTP ${res.status} from ListFoundationModels`;
94
+ return [];
95
+ }
96
+ const body = await res.json();
97
+ return (body.modelSummaries ?? [])
98
+ .filter((m) => typeof m.modelId === "string"
99
+ && /anthropic/i.test(m.providerName ?? m.modelId)
100
+ // skip image and embedding variants; we only run text
101
+ && (!m.outputModalities || m.outputModalities.includes("TEXT")))
102
+ .map((m) => ({ id: m.modelId, name: m.modelName }));
103
+ }
104
+ catch (e) {
105
+ lastListError = e instanceof Error ? e.message : String(e);
106
+ return [];
107
+ }
108
+ }
109
+ /**
110
+ * Azure exposes the deployments a subscription has created, not OpenAI's
111
+ * catalogue: you address `my-gpt5-codex`, a name someone chose, and the
112
+ * underlying model is a property of it.
113
+ */
114
+ async listAzureDeployments() {
115
+ const base = this.config.baseUrl.replace(/\/openai\/deployments\/[^/]+\/?$/, "");
116
+ const url = `${base}/openai/deployments?api-version=${AZURE_API_VERSION}`;
117
+ try {
118
+ const ctl = new AbortController();
119
+ const timer = setTimeout(() => ctl.abort(), 8000);
120
+ const res = await fetch(url, {
121
+ headers: { "api-key": this.config.apiKey ?? "" }, signal: ctl.signal,
122
+ });
123
+ clearTimeout(timer);
124
+ if (!res.ok) {
125
+ lastListError = `HTTP ${res.status} from ${url}`;
126
+ return [];
127
+ }
128
+ const body = await res.json();
129
+ return (body.data ?? [])
130
+ .filter((d) => typeof d.id === "string")
131
+ // the deployment name is what you call; the model is what it runs
132
+ .map((d) => ({ id: d.id, name: d.model }));
133
+ }
134
+ catch (e) {
135
+ lastListError = e instanceof Error ? e.message : String(e);
136
+ return [];
137
+ }
138
+ }
33
139
  async listModels(signal) {
140
+ if (this.config.route === "bedrock")
141
+ return this.listBedrockModels();
142
+ if (this.config.route === "azure-openai")
143
+ return this.listAzureDeployments();
144
+ lastListError = undefined;
34
145
  const anthropic = this.config.provider === "anthropic";
35
146
  const url = anthropic
36
147
  ? `${this.config.baseUrl}/v1/models?limit=100`
37
148
  : `${this.config.baseUrl}/models`;
149
+ // Sign when the route authenticates with AWS credentials. Sending an
150
+ // empty x-api-key here failed silently, which left setup with no models
151
+ // and a default that doesn't exist on Bedrock.
152
+ const auth = await this.authHeaders(url, "", "GET");
38
153
  const headers = anthropic
39
- ? { "x-api-key": this.config.apiKey, "anthropic-version": "2023-06-01" }
40
- : { authorization: `Bearer ${this.config.apiKey}` };
154
+ ? { ...auth, "anthropic-version": "2023-06-01" }
155
+ : this.config.route === "azure-openai"
156
+ ? { "api-key": this.config.apiKey ?? "" }
157
+ : (this.config.apiKey ? { authorization: `Bearer ${this.config.apiKey}` } : auth);
41
158
  try {
42
159
  const ctl = new AbortController();
43
160
  const timer = setTimeout(() => ctl.abort(), 6000);
44
161
  signal?.addEventListener("abort", () => ctl.abort(), { once: true });
45
162
  const res = await fetch(url, { headers, signal: ctl.signal });
46
163
  clearTimeout(timer);
47
- if (!res.ok)
164
+ if (!res.ok) {
165
+ lastListError = `HTTP ${res.status} from ${url}`;
48
166
  return [];
167
+ }
49
168
  const body = await res.json();
50
169
  return (body.data ?? [])
51
170
  .filter((m) => typeof m.id === "string")
52
- .map((m) => ({ id: m.id, name: m.display_name }));
171
+ .map((m) => ({
172
+ id: m.id,
173
+ name: m.display_name,
174
+ // OpenAI sends a unix timestamp; Anthropic an ISO date. Either way
175
+ // this is the release date the picker sorts on.
176
+ created: typeof m.created === "number" ? m.created
177
+ : m.created_at ? Math.floor(Date.parse(m.created_at) / 1000) || undefined
178
+ : undefined,
179
+ }));
53
180
  }
54
- catch {
181
+ catch (e) {
182
+ lastListError = e instanceof Error ? e.message : String(e);
55
183
  return []; // offline, no permission, or an endpoint without the route
56
184
  }
57
185
  }
@@ -59,7 +187,14 @@ export class LLMClient {
59
187
  * Auth for one request. An API key is a header; AWS credentials mean signing
60
188
  * the whole request, which is how an IAM role authenticates with no key.
61
189
  */
62
- async authHeaders(url, body) {
190
+ async authHeaders(url, body, method = "POST") {
191
+ // Foundry authenticates the Azure way: an api-key header, or a bearer
192
+ // token if one was minted from Entra ID.
193
+ if (this.config.route === "foundry" && this.config.apiKey) {
194
+ return /^ey[A-Za-z0-9_-]+\./.test(this.config.apiKey)
195
+ ? { authorization: `Bearer ${this.config.apiKey}` } // an Entra token
196
+ : { "api-key": this.config.apiKey };
197
+ }
63
198
  if (this.config.apiKey)
64
199
  return { "x-api-key": this.config.apiKey };
65
200
  if (this.config.route !== "bedrock")
@@ -72,7 +207,7 @@ export class LLMClient {
72
207
  "or `aws configure`.");
73
208
  }
74
209
  return signRequest({
75
- method: "POST",
210
+ method,
76
211
  url,
77
212
  body,
78
213
  region: this.config.region ?? "us-east-1",
@@ -84,9 +219,14 @@ export class LLMClient {
84
219
  /** Switch model at runtime (/model). Cache prefixes are per-model. */
85
220
  setModel(id) { this.config = { ...this.config, model: id }; }
86
221
  complete(system, messages, tools, onText, signal, modelOverride) {
222
+ // Which OpenAI API a model speaks isn't a user setting — the dataset
223
+ // records it per model, so route on that rather than asking anyone.
224
+ const model = modelOverride ?? this.config.model;
87
225
  return withRetries(() => this.config.provider === "anthropic"
88
226
  ? this.anthropicStream(system, messages, tools, onText, signal, modelOverride)
89
- : this.openaiStream(system, messages, tools, onText, signal, modelOverride), { maxAttempts: this.config.retryMaxAttempts, signal });
227
+ : usesResponsesApi(model)
228
+ ? this.responsesStream(system, messages, tools, onText, signal, modelOverride)
229
+ : this.openaiWithResponsesFallback(system, messages, tools, onText, signal, modelOverride), { maxAttempts: this.config.retryMaxAttempts, signal });
90
230
  }
91
231
  /** Internal summarization: routed to the cheap weak model when configured. */
92
232
  async summarize(text, instruction) {
@@ -205,8 +345,19 @@ export class LLMClient {
205
345
  function: { name: t.name, description: t.description, parameters: t.input_schema },
206
346
  }));
207
347
  }
208
- const res = await this.post(`${this.config.baseUrl}/chat/completions`, {
209
- Authorization: `Bearer ${this.config.apiKey}`,
348
+ // Azure authenticates with api-key rather than a bearer token, and pins
349
+ // the API version as a query parameter.
350
+ const azure = this.config.route === "azure-openai";
351
+ // On Azure the deployment name goes in the path, and the model field in
352
+ // the body is ignored — the deployment decides which model runs.
353
+ const chatUrl = azure
354
+ ? `${this.config.baseUrl}/deployments/${encodeURIComponent(modelOverride ?? this.config.model)}` +
355
+ `/chat/completions?api-version=${AZURE_API_VERSION}`
356
+ : `${this.config.baseUrl}/chat/completions`;
357
+ const res = await this.post(chatUrl, {
358
+ ...(azure
359
+ ? { "api-key": this.config.apiKey }
360
+ : { Authorization: `Bearer ${this.config.apiKey}` }),
210
361
  "content-type": "application/json",
211
362
  }, body, signal);
212
363
  let text = "";
@@ -252,6 +403,145 @@ export class LLMClient {
252
403
  rawContent.push({ type: "tool_use", id: c.id, name: c.name, input: c.input });
253
404
  return { text, toolCalls, rawContent, stopReason: toolCalls.length ? "tool_use" : "end_turn", usage };
254
405
  }
406
+ /**
407
+ * Chat completions, retrying on the Responses API if the provider says the
408
+ * model belongs there.
409
+ *
410
+ * Routing normally comes from the dataset, but a model can be missing from
411
+ * it, or the local copy can predate the fields we read. Rather than failing
412
+ * with a 404 the user can do nothing about, take the provider at its word
413
+ * and retry on the endpoint it named.
414
+ */
415
+ async openaiWithResponsesFallback(system, messages, tools, onText, signal, modelOverride) {
416
+ try {
417
+ return await this.openaiStream(system, messages, tools, onText, signal, modelOverride);
418
+ }
419
+ catch (e) {
420
+ const msg = e instanceof Error ? e.message : String(e);
421
+ if (!/responses/i.test(msg) || !/endpoint|not supported/i.test(msg))
422
+ throw e;
423
+ return this.responsesStream(system, messages, tools, onText, signal, modelOverride);
424
+ }
425
+ }
426
+ // ------------------------------------------------------------- responses
427
+ /**
428
+ * OpenAI's Responses API — the only way to reach the codex models, which are
429
+ * the coding-tuned ones a coding agent actually wants.
430
+ *
431
+ * Three things differ from chat completions, and each is a place to get it
432
+ * wrong: the request carries `instructions` and `input` rather than a
433
+ * `messages` array; the stream is a sequence of named events keyed by a
434
+ * `type` field rather than `choices[].delta`; and usage arrives as
435
+ * input_tokens/output_tokens, which the cost ledger reads directly, so a
436
+ * mismatch here silently under-reports spend rather than failing loudly.
437
+ */
438
+ async responsesStream(system, messages, tools, onText, signal, modelOverride) {
439
+ const input = [];
440
+ for (const m of messages)
441
+ input.push(...toResponsesInput(m));
442
+ const body = {
443
+ model: modelOverride ?? this.config.model,
444
+ instructions: system,
445
+ input,
446
+ stream: true,
447
+ max_output_tokens: this.config.maxTokens,
448
+ };
449
+ if (tools.length) {
450
+ // Flatter than chat completions: no nested `function` object.
451
+ body.tools = tools.map((t) => ({
452
+ type: "function",
453
+ name: t.name,
454
+ description: t.description,
455
+ parameters: t.input_schema,
456
+ }));
457
+ }
458
+ const res = await this.post(`${this.config.baseUrl}/responses`, {
459
+ Authorization: `Bearer ${this.config.apiKey}`,
460
+ "content-type": "application/json",
461
+ }, body, signal);
462
+ let text = "";
463
+ const usage = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
464
+ // Tool calls arrive as items announced first, then filled in by argument
465
+ // deltas addressed to the item's id.
466
+ const calls = new Map();
467
+ for await (const evt of sseEvents(res, signal)) {
468
+ const e = evt;
469
+ switch (e.type) {
470
+ case "response.output_text.delta": {
471
+ const d = typeof e.delta === "string" ? e.delta : "";
472
+ if (d) {
473
+ text += d;
474
+ onText?.(d);
475
+ }
476
+ break;
477
+ }
478
+ case "response.output_item.added": {
479
+ const item = e.item;
480
+ if (item?.type === "function_call") {
481
+ calls.set(String(item.id ?? item.call_id), {
482
+ id: String(item.call_id ?? item.id ?? ""),
483
+ name: String(item.name ?? ""),
484
+ args: typeof item.arguments === "string" ? item.arguments : "",
485
+ });
486
+ }
487
+ break;
488
+ }
489
+ case "response.function_call_arguments.delta": {
490
+ const key = String(e.item_id ?? "");
491
+ const cur = calls.get(key) ?? { id: key, name: "", args: "" };
492
+ cur.args += typeof e.delta === "string" ? e.delta : "";
493
+ calls.set(key, cur);
494
+ break;
495
+ }
496
+ case "response.function_call_arguments.done": {
497
+ // Some responses send the complete arguments here instead of deltas.
498
+ const key = String(e.item_id ?? "");
499
+ const cur = calls.get(key);
500
+ if (cur && !cur.args && typeof e.arguments === "string")
501
+ cur.args = e.arguments;
502
+ break;
503
+ }
504
+ case "response.completed":
505
+ case "response.incomplete": {
506
+ const u = e.response?.usage;
507
+ if (u) {
508
+ const cached = u.input_tokens_details?.cached_tokens ?? 0;
509
+ usage.input = (u.input_tokens ?? 0) - cached;
510
+ usage.cacheRead = cached;
511
+ usage.output = u.output_tokens ?? 0;
512
+ }
513
+ break;
514
+ }
515
+ case "error":
516
+ case "response.failed": {
517
+ const msg = e.message ?? e.response?.error?.message ?? "stream failed";
518
+ throw new FatalError(`Responses API error: ${String(msg)}`);
519
+ }
520
+ default: break; // ignore lifecycle events we don't need
521
+ }
522
+ }
523
+ const toolCalls = [...calls.values()]
524
+ .filter((c) => c.name)
525
+ .map((c) => {
526
+ let parsed = {};
527
+ try {
528
+ parsed = c.args ? JSON.parse(c.args) : {};
529
+ }
530
+ catch { /* leave empty */ }
531
+ return { id: c.id, name: c.name, input: parsed };
532
+ });
533
+ const rawContent = [];
534
+ if (text)
535
+ rawContent.push({ type: "text", text });
536
+ for (const c of toolCalls) {
537
+ rawContent.push({ type: "tool_use", id: c.id, name: c.name, input: c.input });
538
+ }
539
+ return {
540
+ text, toolCalls, rawContent,
541
+ stopReason: toolCalls.length ? "tool_use" : "end_turn",
542
+ usage,
543
+ };
544
+ }
255
545
  // ------------------------------------------------------------------ http
256
546
  async post(url, headers, body, signal) {
257
547
  let res;
@@ -270,8 +560,28 @@ export class LLMClient {
270
560
  if (res.status === 401 || res.status === 403) {
271
561
  throw new FatalError(`Authentication failed (HTTP ${res.status}). Check your API key.`);
272
562
  }
273
- if (!res.ok)
274
- throw new FatalError(`API error HTTP ${res.status}: ${(await res.text()).slice(0, 500)}`);
563
+ if (!res.ok) {
564
+ const body = (await res.text()).slice(0, 500);
565
+ // Record what the provider just told us. Neither OpenAI's catalogue nor
566
+ // its /v1/models response flags retired models or says which endpoint a
567
+ // model needs, so the failing request is the only reliable signal — and
568
+ // remembering it keeps the model out of every future menu.
569
+ const { classifyModelError, markUnusable } = await import("./models.js");
570
+ const why = classifyModelError(body);
571
+ if (why)
572
+ markUnusable(this.config.model, why);
573
+ // Some newer OpenAI models are only served by the Responses API, which
574
+ // Faber doesn't speak yet. The raw 404 doesn't say what to do about it.
575
+ if (/v1\/responses endpoint/i.test(body)) {
576
+ // Not fatal: the caller retries this request on the Responses API.
577
+ throw new FatalError(`${this.config.model} is served by the responses endpoint, not chat completions.`);
578
+ }
579
+ if (/has been deprecated/i.test(body)) {
580
+ throw new FatalError(`${this.config.model} has been deprecated by the provider.\n` +
581
+ ` It won't be offered again. Pick another with /model.`);
582
+ }
583
+ throw new FatalError(`API error HTTP ${res.status}: ${body}`);
584
+ }
275
585
  return res;
276
586
  }
277
587
  }
@@ -310,6 +620,58 @@ async function* sseEvents(res, signal) {
310
620
  reader.releaseLock();
311
621
  }
312
622
  }
623
+ /**
624
+ * Convert a message to Responses API input items.
625
+ *
626
+ * The Responses API doesn't take a `messages` array of chat turns. It takes a
627
+ * flat list of items where a tool call and its result are siblings rather than
628
+ * nested inside an assistant turn — so one Faber message can expand into
629
+ * several items. Tool calls are joined to their results by `call_id`.
630
+ */
631
+ /**
632
+ * Does this model require the Responses API? Answered by the pricing dataset's
633
+ * `mode` and `supported_endpoints`, refreshed on every launch, so a model
634
+ * released tomorrow routes correctly without a Faber update.
635
+ */
636
+ export function usesResponsesApi(modelId) {
637
+ const p = priceFor(modelId);
638
+ if (p?.mode === "responses")
639
+ return true;
640
+ if (p?.endpoints?.length) {
641
+ return p.endpoints.includes("/v1/responses")
642
+ && !p.endpoints.some((e) => e.includes("chat/completions"));
643
+ }
644
+ return false;
645
+ }
646
+ function toResponsesInput(m) {
647
+ const out = [];
648
+ const texts = [];
649
+ for (const b of m.content) {
650
+ if (b.type === "text")
651
+ texts.push(b.text);
652
+ else if (b.type === "tool_use") {
653
+ out.push({
654
+ type: "function_call",
655
+ call_id: b.id,
656
+ name: b.name,
657
+ arguments: JSON.stringify(b.input),
658
+ });
659
+ }
660
+ else if (b.type === "tool_result") {
661
+ out.push({
662
+ type: "function_call_output",
663
+ call_id: b.tool_use_id,
664
+ output: typeof b.content === "string" ? b.content : JSON.stringify(b.content),
665
+ });
666
+ }
667
+ }
668
+ if (texts.length) {
669
+ // input_text for what we send, output_text for what the model said
670
+ const kind = m.role === "assistant" ? "output_text" : "input_text";
671
+ out.unshift({ role: m.role, content: [{ type: kind, text: texts.join("\n") }] });
672
+ }
673
+ return out;
674
+ }
313
675
  function toOpenAI(m) {
314
676
  const out = [];
315
677
  const texts = [];
package/dist/models.js CHANGED
@@ -14,6 +14,34 @@ import * as fs from "node:fs";
14
14
  import * as os from "node:os";
15
15
  import * as path from "node:path";
16
16
  import { modelsForRoute } from "./routes.js";
17
+ import { priceFor } from "./pricing.js";
18
+ /**
19
+ * Order the model list the way someone choosing one would want it.
20
+ *
21
+ * On OpenAI the coding-tuned models matter most to a coding agent, so those
22
+ * come first as a group; everything else follows. Within each group, newest
23
+ * first, since a release date is the only ranking the provider actually gives
24
+ * us. Anthropic has no such split, so it's purely newest first.
25
+ */
26
+ export function isCodexModel(id) {
27
+ return /codex/i.test(id);
28
+ }
29
+ export function sortModels(models, wire) {
30
+ const byDate = (a, b) => {
31
+ if (a.created && b.created)
32
+ return b.created - a.created;
33
+ if (a.created)
34
+ return -1; // dated entries above undated ones
35
+ if (b.created)
36
+ return 1;
37
+ return a.id.localeCompare(b.id);
38
+ };
39
+ if (wire !== "openai")
40
+ return [...models].sort(byDate);
41
+ const codex = models.filter((m) => isCodexModel(m.id)).sort(byDate);
42
+ const rest = models.filter((m) => !isCodexModel(m.id)).sort(byDate);
43
+ return [...codex, ...rest];
44
+ }
17
45
  const TTL_MS = 24 * 60 * 60 * 1000; // a day: new models are rare, staleness is cheap
18
46
  function cacheFile(routeId) {
19
47
  return path.join(os.homedir(), ".faber", "cache", `models-${routeId}.json`);
@@ -43,6 +71,43 @@ export function clearCache(routeId) {
43
71
  }
44
72
  catch { /* already gone */ }
45
73
  }
74
+ /**
75
+ * Can this model actually run an agent loop?
76
+ *
77
+ * Provider catalogues list everything the key can call: embeddings, speech,
78
+ * transcription, moderation, image generation. None of them accept a chat
79
+ * request, so offering them as a choice is offering a guaranteed failure.
80
+ * Excluding by capability keyword is deliberately conservative — anything
81
+ * unrecognised is kept, since a new chat model must never be filtered out.
82
+ */
83
+ const NOT_CHAT = /(^|[-_/])(embedding|embed|tts|whisper|moderation|dall-e|dalle|image|audio|transcribe|realtime|speech|rerank|search-preview|codex-mini-latest)([-_/]|$)/i;
84
+ /** Retired completion-only families that predate the chat API. */
85
+ const LEGACY = /^(davinci|babbage|curie|ada|text-davinci|code-davinci)/i;
86
+ export function isChatModel(id) {
87
+ // The dataset states each model's mode and endpoints, so use that rather
88
+ // than reading the name. Guessing from an id misclassifies every new naming
89
+ // scheme; this is the provider's own answer, refreshed on every launch.
90
+ const p = priceFor(id);
91
+ // "responses" counts as a chat model now that Faber speaks that API too.
92
+ if (p?.mode)
93
+ return p.mode === "chat" || p.mode === "completion" || p.mode === "responses";
94
+ if (p?.endpoints?.length) {
95
+ return p.endpoints.some((e) => e.includes("chat/completions") || e === "/v1/responses");
96
+ }
97
+ // Only fall back to name-based rules when the dataset has never seen it,
98
+ // which is the case for a brand new model or a local one.
99
+ return !NOT_CHAT.test(id) && !LEGACY.test(id);
100
+ }
101
+ /**
102
+ * Does this model need an API Faber doesn't speak? The dataset records it —
103
+ * gpt-5.3-codex reports mode "responses" and endpoint /v1/responses — so
104
+ * these can be labelled honestly instead of failing at request time.
105
+ */
106
+ export function requiresUnsupportedApi(_id) {
107
+ // Responses-only models are supported now, so nothing is excluded on this
108
+ // basis. Kept as the hook for the next API Faber doesn't yet speak.
109
+ return undefined;
110
+ }
46
111
  /**
47
112
  * Build the /model menu: built-in aliases first (stable names people type),
48
113
  * then anything discovered that an alias doesn't already cover.
@@ -57,9 +122,16 @@ export function buildPicker(route, discovered, currentId) {
57
122
  live: false,
58
123
  }));
59
124
  const covered = new Set(aliases.map((a) => a.id));
60
- for (const m of discovered) {
125
+ const unusable = readUnusable();
126
+ for (const m of sortModels(discovered, route.wire)) {
61
127
  if (covered.has(m.id))
62
128
  continue;
129
+ if (!isChatModel(m.id))
130
+ continue; // embeddings, speech, moderation…
131
+ // Only genuinely dead models are hidden. An endpoint mismatch is handled
132
+ // by retrying on the right endpoint, so it must not remove the model.
133
+ if (unusable[m.id] === "deprecated")
134
+ continue;
63
135
  entries.push({ value: m.id, label: m.id, blurb: m.name ?? "", live: true });
64
136
  }
65
137
  // if the model in use is neither an alias nor discovered, keep it visible
@@ -68,3 +140,41 @@ export function buildPicker(route, discovered, currentId) {
68
140
  }
69
141
  return entries;
70
142
  }
143
+ function unusableFile() {
144
+ return path.join(os.homedir(), ".faber", "cache", "unusable-models.json");
145
+ }
146
+ export function readUnusable() {
147
+ try {
148
+ const raw = JSON.parse(fs.readFileSync(unusableFile(), "utf8"));
149
+ return raw && typeof raw === "object" ? raw : {};
150
+ }
151
+ catch {
152
+ return {};
153
+ }
154
+ }
155
+ export function markUnusable(modelId, why) {
156
+ try {
157
+ const f = unusableFile();
158
+ fs.mkdirSync(path.dirname(f), { recursive: true });
159
+ fs.writeFileSync(f, JSON.stringify({ ...readUnusable(), [modelId]: why }, null, 2));
160
+ }
161
+ catch { /* best effort */ }
162
+ }
163
+ export function clearUnusable() {
164
+ try {
165
+ fs.unlinkSync(unusableFile());
166
+ }
167
+ catch { /* already gone */ }
168
+ }
169
+ /** Classify a provider error, so a failure teaches us something. */
170
+ export function classifyModelError(message) {
171
+ const m = message.toLowerCase();
172
+ if (m.includes("deprecat") || m.includes("model_not_found") || m.includes("has been retired")) {
173
+ return "deprecated";
174
+ }
175
+ if (m.includes("/v1/responses") || m.includes("responses endpoint")
176
+ || m.includes("not supported in the v1/chat/completions")) {
177
+ return "wrong-endpoint";
178
+ }
179
+ return undefined;
180
+ }