amicus 4.9.0 → 4.9.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.0",
3
+ "version": "4.9.1",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -3,6 +3,73 @@
3
3
  All notable changes to Amicus are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [4.9.1] - 2026-08-27
7
+
8
+ *A silent provider failure, and the unservable model ids it produced.*
9
+
10
+ A session on `deepseek` failed with `you passed deepseek-v4-flash-0731`. Tracing that one error
11
+ found a chain: a provider fetch failed silently, the empty namespace it left behind was
12
+ indistinguishable from "never fetched", and that ambiguity licensed the wizard to synthesise and
13
+ persist a direct model id nothing serves.
14
+
15
+ ### Fixed
16
+
17
+ - **Per-provider catalog fetch failures are reported instead of collapsing to `[]` (#209).**
18
+ `fetchAllModels()` returned a flat row array, so a provider whose fetch was REJECTED could not be
19
+ told apart from one that legitimately serves no models — all four failure modes (non-200, timeout,
20
+ network error, parse error) resolved to a bare `[]`, and the existing reporting fired only when
21
+ *every* provider returned nothing. A 401ing key silently zeroed that vendor's namespace and every
22
+ picker then offered gateway-only routes with no indication why. `fetchAllModelsDetailed()` now
23
+ carries `{rows, failures}`, the cache persists `providerFailures` alongside the rows they
24
+ describe, and `amicus models --check` prints
25
+ `PROVIDER FETCH FAILED: <provider> (HTTP 401)` (`--json` carries the array).
26
+ `fetchAllModels()` keeps its signature as a rows-only wrapper.
27
+ - **A rejected namespace no longer yields a fabricated direct model id (#208).**
28
+ `classifyModel` returns `'unknown'` for an empty namespace because it could not distinguish
29
+ "never fetched" from "fetch rejected", and that licensed `directFormIfSafe` to strip the
30
+ `openrouter/` prefix and produce an id the direct API does not serve. Optimism is now suppressed
31
+ when *that vendor's* namespace fetch was rejected, and preserved when it was simply never
32
+ attempted. Threaded through the picker, the shortlist, both Electron wizard entry points and the
33
+ CLI setup path — the picker rebuilds its own `catalogInfo`, so without threading the guard was
34
+ live in unit tests and dead in production.
35
+ - **The persistence path requires catalog evidence (#214).**
36
+ `toStorableRoute` — whose result is written straight into `config.aliases` and used to seed fresh
37
+ configs — decided direct-vs-gateway with no catalog evidence at all. It now routes through
38
+ `directFormIfSafe`, and `toLiveSeedAliases` stops discarding evidence it was already handed.
39
+ - **Alias drift is computed against the same evidence the writer uses.**
40
+ `findDriftedStoredAliases` resolved its "current" value without `providerFailures`, so a rejected
41
+ namespace produced false drift whose suggested repair would have written back the very id #208
42
+ removed.
43
+ - **Guards no longer depend on an optional argument.** `directFormIfSafe`/`directFormIfProven`
44
+ keyed both the `DIVERGENT_VENDORS` and namespace-rejection checks on a caller-supplied `vendor`
45
+ that the API marks optional, while the catalog check derived its own — so a caller using the
46
+ documented shape silently lost two of three guards. The vendor is now derived from the id when
47
+ omitted.
48
+ - **The setup wizard keeps direct-first routing when the catalog is unavailable.** With no catalog,
49
+ quick picks fell back to raw `openrouter/…` routes, which Amicus treats as an explicit
50
+ force-OpenRouter literal — an offline setup would have pinned the user to the gateway
51
+ permanently.
52
+ - **`pre-commit` no longer fails on a clone that has never stashed.** The hook gated its
53
+ lint-staged workaround on being inside a worktree; the real trigger is a missing `refs/stash`,
54
+ which is true of any fresh clone whose first commit precedes its first `git stash`.
55
+
56
+ ### Changed
57
+
58
+ - **`toCanonicalDefault` is renamed `stripGatewayPrefix`** (`src/utils/curated-models.js`). The old
59
+ name read as the correct answer and three separate callers took it at its word and persisted ids
60
+ the direct API may not serve. It cannot be made evidence-taking — it *produces* the candidate
61
+ `classifyModel` checks — so the name now says what it is. Callers deriving an id that will be
62
+ called or stored must use `directFormIfSafe`/`directFormIfProven`. Internal utility, not a
63
+ documented API surface, but importers reaching into `src/utils/` will need the new name.
64
+ - **Route canonicalisation for the setup wizard is decided in the main process.** The renderer
65
+ carried a hand-copy of `toCanonicalDefault` that had neither of the real primitive's guards; it is
66
+ deleted, and the safe form now ships to the page as data. Routing policy no longer reaches the
67
+ renderer at all.
68
+ - **`gatewayOf(id)`** replaces three verbatim copies of the gateway-classification one-liner.
69
+ - An ESLint rule now bans hand-rolled `openrouter/` prefix stripping outside an audited allowlist.
70
+ - CI council bench: `qwen` moves from `qwen3.8-max` to `qwen3.8-27b` — same 1M context, ~4.8x
71
+ cheaper input. Bench-only; the shipped alias table is unchanged.
72
+
6
73
  ## [4.9.0] - 2026-08-26
7
74
 
8
75
  *The council does new work.*
package/README.md CHANGED
@@ -455,7 +455,7 @@ $ amicus status demo123 --json
455
455
  "taskId": "demo123",
456
456
  "status": "complete",
457
457
  "elapsed": "5m 0s",
458
- "version": "4.9.0",
458
+ "version": "4.9.1",
459
459
  "model": "google/gemini-2.5-flash",
460
460
  "phase": "terminal"
461
461
  }
package/docs/usage.md CHANGED
@@ -538,7 +538,7 @@ $ amicus status demo123 --json
538
538
  "taskId": "demo123",
539
539
  "status": "complete",
540
540
  "elapsed": "5m 0s",
541
- "version": "4.9.0",
541
+ "version": "4.9.1",
542
542
  "model": "google/gemini-2.5-flash",
543
543
  "phase": "terminal"
544
544
  }
@@ -184,11 +184,13 @@ function registerSetupHandlers(getMainWindow, { ipcMain = require('electron').ip
184
184
  let cfg = loadConfig();
185
185
  if (!cfg) {
186
186
  const { toLiveSeedAliases } = require('../src/utils/quick-picks');
187
- let catalog = [];
187
+ // issue 214: getCatalogInfo, not getCatalog -- toLiveSeedAliases PERSISTS
188
+ // these routes, so it must see which namespaces were rejected.
189
+ let catalogInfo = { models: [] };
188
190
  try {
189
- catalog = await require('../src/utils/model-catalog').getCatalog();
191
+ catalogInfo = await require('../src/utils/model-catalog').getCatalogInfo();
190
192
  } catch (_err) { /* offline: pinned seeds */ }
191
- cfg = { aliases: toLiveSeedAliases(catalog) };
193
+ cfg = { aliases: toLiveSeedAliases(catalogInfo) };
192
194
  }
193
195
  if (!cfg.aliases) { cfg.aliases = {}; }
194
196
  if (defaultModel) { cfg.default = defaultModel; }
package/electron/main.js CHANGED
@@ -327,12 +327,20 @@ function createAmicusWindow() {
327
327
  async function createSetupWindow() {
328
328
  // Lazy-load setup UI to avoid loading it for sidecar mode
329
329
  const { buildSetupHTML } = require('./setup-ui');
330
- const { resolveQuickPicks, toStorableRoute } = require('../src/utils/quick-picks');
330
+ const { resolveQuickPicks, canonicalRoutesFor, toStorableRoute } = require('../src/utils/quick-picks');
331
331
  let quickPicks;
332
332
  const shortlists = {};
333
333
  try {
334
- const catalog = await require('../src/utils/model-catalog').getCatalog();
334
+ // issue 208: getCatalogInfo (not getCatalog) -- the shortlist needs the
335
+ // per-provider fetch outcomes, or directFormIfSafe's namespace-failure
336
+ // gate cannot fire and a rejected namespace still yields bare direct ids.
337
+ const catalogInfo = await require('../src/utils/model-catalog').getCatalogInfo();
338
+ const catalog = catalogInfo.models;
335
339
  quickPicks = resolveQuickPicks(catalog);
340
+ // issue 214: decide each pick's safe storable form HERE, with the catalog in
341
+ // hand, and ship it as data. The page cannot require() the canonicalisation
342
+ // primitives, and its hand-copy of them dropped their guards.
343
+ for (const p of quickPicks) { p.canonicalRoutes = canonicalRoutesFor(p, catalogInfo); }
336
344
 
337
345
  // issue 138: one vendor shortlist per family card, resolved server-side from
338
346
  // the same catalog the quick picks came from (no extra IPC round-trip).
@@ -341,7 +349,8 @@ async function createSetupWindow() {
341
349
  try {
342
350
  shortlists[p.alias] = buildModelShortlist(p.vendorPath, {
343
351
  catalog,
344
- recommendedId: toStorableRoute(p),
352
+ providerFailures: catalogInfo.providerFailures,
353
+ recommendedId: toStorableRoute(p, catalogInfo),
345
354
  });
346
355
  } catch (_e) { /* a shortlist failure must never block the wizard */ }
347
356
  }
@@ -520,21 +529,26 @@ function createSettingsChildWindow() {
520
529
  // createSetupWindow awaits. A missing or corrupt cache reads back as null
521
530
  // and degrades to the same pinned fallback buildSetupHTML already applies
522
531
  // when no quickPicks are given.
523
- const { resolveQuickPicks, toStorableRoute } = require('../src/utils/quick-picks');
532
+ const { resolveQuickPicks, canonicalRoutesFor, toStorableRoute } = require('../src/utils/quick-picks');
524
533
  const { readCache } = require('../src/utils/model-catalog');
525
534
  let quickPicks;
526
535
  const shortlists = {};
527
536
  try {
528
537
  const cacheDoc = readCache();
529
538
  const catalog = cacheDoc ? cacheDoc.models : [];
539
+ // issue 208: the cache doc carries the fetch outcomes for THESE rows.
540
+ const providerFailures = (cacheDoc && cacheDoc.providerFailures) || [];
530
541
  quickPicks = resolveQuickPicks(catalog);
542
+ // issue 214: see the note at the other resolveQuickPicks site.
543
+ for (const p of quickPicks) { p.canonicalRoutes = canonicalRoutesFor(p, { models: catalog, providerFailures }); }
531
544
 
532
545
  const { buildModelShortlist } = require('../src/utils/model-shortlist');
533
546
  for (const p of quickPicks) {
534
547
  try {
535
548
  shortlists[p.alias] = buildModelShortlist(p.vendorPath, {
536
549
  catalog,
537
- recommendedId: toStorableRoute(p),
550
+ providerFailures,
551
+ recommendedId: toStorableRoute(p, { models: catalog, providerFailures }),
538
552
  });
539
553
  } catch (_e) { /* a shortlist failure must never block the wizard */ }
540
554
  }
@@ -11,9 +11,8 @@ const { buildLocalSectionHTML } = require('./setup-ui-local');
11
11
  const { buildLocalScript } = require('./setup-ui-local-script');
12
12
  const { getDefaultAliases } = require('../src/utils/config');
13
13
  const { getBrandName } = require('./toolbar');
14
- const { resolveQuickPicks } = require('../src/utils/quick-picks');
14
+ const { resolveQuickPicks, canonicalRoutesFor } = require('../src/utils/quick-picks');
15
15
  const { PROVIDER_FAMILY_NAMES } = require('../src/utils/model-fetcher');
16
- const { listDirectProviders } = require('../src/utils/provider-registry');
17
16
 
18
17
  /**
19
18
  * @param {object} [options={}]
@@ -30,17 +29,26 @@ function buildSetupHTML(options = {}) {
30
29
  quickPicks = resolveQuickPicks([]), // pinned fallbacks when not provided
31
30
  shortlists = {},
32
31
  } = options;
32
+ // Council A1 (PR 215): a pick reaching the page WITHOUT canonicalRoutes makes
33
+ // pickRouteFor fall back to the raw openrouter/... route, which this codebase
34
+ // treats as an EXPLICIT force-OpenRouter literal that never reconsiders
35
+ // direct-first -- so an offline setup (pinned fallback above, catalog
36
+ // unavailable) would pin the user to the gateway permanently. Backfill from an
37
+ // EMPTY catalog: directFormIfSafe is optimistic when nothing disproves the bare
38
+ // form (restoring direct-first) while still refusing for DIVERGENT_VENDORS,
39
+ // which the deleted toBareIfDirect did not.
40
+ const picks = quickPicks.map(p =>
41
+ (p && p.canonicalRoutes) ? p : { ...p, canonicalRoutes: canonicalRoutesFor(p, { models: [] }) });
33
42
  const brandName = getBrandName(client);
34
43
  const keysHtml = buildKeysStepHTML(PROVIDERS);
35
- const modelHtml = buildModelStepHTML(quickPicks, undefined, undefined, shortlists);
44
+ const modelHtml = buildModelStepHTML(picks, undefined, undefined, shortlists);
36
45
  const aliasHtml = buildAliasEditorHTML(getDefaultAliases());
37
46
  const css = buildWizardCSS();
38
47
  const providersJson = JSON.stringify(PROVIDERS);
39
- const modelChoicesJson = JSON.stringify(quickPicks);
48
+ const modelChoicesJson = JSON.stringify(picks);
40
49
  const providerNamesJson = JSON.stringify(PROVIDER_NAMES);
41
50
  const defaultAliasesJson = JSON.stringify(getDefaultAliases());
42
51
  const familyNamesJson = JSON.stringify(PROVIDER_FAMILY_NAMES);
43
- const directProvidersJson = JSON.stringify(listDirectProviders());
44
52
  return `<!DOCTYPE html>
45
53
  <html><head><meta charset="utf-8"><title>Amicus Setup</title>
46
54
  <style>${css}</style></head><body>
@@ -62,11 +70,11 @@ function buildSetupHTML(options = {}) {
62
70
  </div>
63
71
  </div>
64
72
  <div class="footer"><div class="footer-brand"><svg width="15" height="15" viewBox="0 0 32 32" fill="none"><path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/><path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/><path class="brand-main" d="M19 8H28"/></svg> ${brandName}</div><div class="footer-nav"><button class="nav-btn" id="back-btn" style="display:none">Back</button><button class="nav-btn primary" id="next-btn" disabled>Next</button><button class="nav-btn primary" id="finish-btn" style="display:none">Finish</button></div></div>
65
- ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson, directProvidersJson)}
73
+ ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson)}
66
74
  </body></html>`;
67
75
  }
68
76
 
69
- function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson, directProvidersJson) {
77
+ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson) {
70
78
  const keysJs = buildKeysScript();
71
79
  const aliasJs = buildAliasScript();
72
80
  const councilJs = buildCouncilScript();
@@ -83,7 +91,6 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
83
91
  var providerNamesData = ${providerNamesJson};
84
92
  var defaultAliases = Object.assign(Object.create(null), ${defaultAliasesJson});
85
93
  var PROVIDER_FAMILY_NAMES = ${familyNamesJson};
86
- var directProviders = ${directProvidersJson};
87
94
  var routingChoices = {};
88
95
  var explicitRouteChoices = {};
89
96
  // issue 138: alias -> a SPECIFIC model id the user drilled down to. Empty
@@ -288,20 +295,6 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
288
295
  } else { nextBtn.disabled = false; }
289
296
  }
290
297
 
291
- // #61: an auto-selected (non-explicit) openrouter/<vendor>/<model> route
292
- // whose vendor also has a direct integration must be stored bare
293
- // (<vendor>/<model>) so the gateway router can policy-route it direct-first;
294
- // a stored openrouter/... string is treated as an explicit force-OpenRouter
295
- // literal and never reconsiders direct-first. Mirrors
296
- // src/utils/curated-models.js's toCanonicalDefault exactly. Gateway-only
297
- // vendors (not in directProviders, e.g. qwen/grok/glm/...) pass through
298
- // unchanged since OpenRouter is their only route anyway.
299
- function toBareIfDirect(route) {
300
- if (typeof route !== 'string' || route.indexOf('openrouter/') !== 0) { return route; }
301
- var rest = route.slice('openrouter/'.length);
302
- var vendor = rest.split('/')[0];
303
- return directProviders.indexOf(vendor) !== -1 ? rest : route;
304
- }
305
298
 
306
299
  // Single source of the route choice for a quick-pick row: explicit pill
307
300
  // choice if its key still exists, else first provider with a key, else
@@ -311,12 +304,12 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
311
304
  // issue 138: an explicit per-model choice overrides the family flagship.
312
305
  var picked = modelChoiceIds[mc.alias];
313
306
  if (picked) {
314
- // Deliberately NEVER toBareIfDirect(picked) here (unlike the auto-pick
315
- // canonicalization below): picked/modelOpenrouterIds come straight
316
- // from the shortlist's own id/data-or, and for a DIVERGENT_VENDOR
317
- // (e.g. anthropic) that id can already BE its only-callable
318
- // openrouter/<vendor>/... form -- stripping the prefix would
319
- // fabricate a direct id nothing serves.
307
+ // A drilled-down pick is returned VERBATIM -- never canonicalised (unlike
308
+ // the auto-pick below): picked/modelOpenrouterIds come straight from the
309
+ // shortlist's own id/data-or, which the picker already built through
310
+ // directFormIfSafe. For a DIVERGENT_VENDOR (e.g. anthropic) that id can
311
+ // already BE its only-callable openrouter/<vendor>/... form, so touching
312
+ // the prefix here would fabricate a direct id nothing serves.
320
313
  if (routingChoices[mc.alias] === 'openrouter' && explicitRouteChoices[mc.alias]) {
321
314
  return modelOpenrouterIds[mc.alias] || picked;
322
315
  }
@@ -332,9 +325,13 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
332
325
  if (!prov) { prov = provs[0]; }
333
326
  }
334
327
  var route = mc.routes[prov] || null;
335
- // Only canonicalize auto-picks; an explicit "via OpenRouter" pill click
336
- // is a deliberate choice and is returned unchanged.
337
- if (route && !explicitRouteChoices[mc.alias]) { route = toBareIfDirect(route); }
328
+ // issue 214: the SAFE storable form is decided server-side (quick-picks.js
329
+ // canonicalRoutesFor) and shipped with the pick. The page must not re-derive
330
+ // it: its old hand-copy of stripGatewayPrefix dropped both of that
331
+ // primitive's guards. An explicit "via OpenRouter" pill stays unchanged.
332
+ if (route && !explicitRouteChoices[mc.alias]) {
333
+ route = (mc.canonicalRoutes && mc.canonicalRoutes[prov]) || route;
334
+ }
338
335
  return route;
339
336
  }
340
337
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.0",
3
+ "version": "4.9.1",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -15,6 +15,7 @@ const fs = require('fs');
15
15
  const { logger } = require('../utils/logger');
16
16
  const { classifyLegError, isRetryable } = require('../utils/error-classify');
17
17
  const { deriveChain } = require('./fallback-chains');
18
+ const { gatewayOf } = require('../utils/gateway-router');
18
19
 
19
20
  /**
20
21
  * Append ONE attributed ledger row for a single attempt (spec 6.2/7.1). At
@@ -38,7 +39,7 @@ function recordAttemptSpend({ doc, leg, currentModel, legId, waveId, project, at
38
39
  // a substitution carries the substitute's resolved gateway on
39
40
  // `routeGateway` (threaded in by the loop, incl. v4.2 'local').
40
41
  const gateway = routeGateway || (leg && leg.gateway) ||
41
- (String(currentModel).startsWith('openrouter/') ? 'openrouter' : 'direct');
42
+ gatewayOf(currentModel);
42
43
  const row = {
43
44
  taskId: legId, waveId, model: currentModel, mode: 'leg', usage,
44
45
  op: 'leg', status: doc.status, gateway,
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Presentation helpers for `amicus models` -- pure string formatting, no I/O.
3
+ *
4
+ * Split out of models.js when the per-provider failure line (issue 209) pushed
5
+ * that file past the 300-line ceiling. Formatting and command flow are
6
+ * separable concerns, so the ceiling picked the seam: everything here takes a
7
+ * plain object and returns a string.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ /** '0.000003' per token → '3.00' per Mtok; '—' when unknown or variable (-1) */
13
+ function perMtok(perToken) {
14
+ if (perToken === null || perToken === undefined) { return '—'; }
15
+ const n = Number(perToken);
16
+ if (Number.isNaN(n) || n < 0) { return '—'; }
17
+ return (n * 1e6).toFixed(2);
18
+ }
19
+
20
+ function fmtRow(m, aliasesById) {
21
+ const alias = aliasesById.get(m.id);
22
+ const aliasCol = alias ? `[${alias}] ` : '';
23
+ const ctx = m.contextLength ?? '—';
24
+ const pIn = perMtok(m.pricing && m.pricing.prompt);
25
+ const pOut = perMtok(m.pricing && m.pricing.completion);
26
+ return `${aliasCol}${m.id}\n ${m.name} ctx ${ctx} $/Mtok in ${pIn} out ${pOut}`;
27
+ }
28
+
29
+ /** One readable line per gateway-route finding (Task 6, #gwid). @param {object} f @returns {string} */
30
+ function fmtGatewayFinding(f) {
31
+ if (f.kind === 'stale') {
32
+ return ` GATEWAY STALE (${f.gateway}): ${f.alias} -> ${f.model}`;
33
+ }
34
+ if (f.kind === 'divergent-missing') {
35
+ return ` GATEWAY DIVERGENT: ${f.alias} has no direct form; catalog confirms ${f.model}`;
36
+ }
37
+ return ` GATEWAY DIVERGENT: ${f.alias} direct form ${f.model} no longer matches catalog (now ${f.expected})`;
38
+ }
39
+
40
+ const PROBE_LABELS = { served: 'SERVED', 'accepted-but-silent': 'SILENT', error: 'ERROR' };
41
+
42
+ /** '$0.0004' | '$1.23' | '—' (unknown). Deliberately NOT formatCost (pricing.js):
43
+ * a probe result's `cost` is a bare number (models-probe.js doesn't carry the
44
+ * reported/estimated source tag), so this never claims a precision it can't back. */
45
+ function fmtProbeCost(cost) {
46
+ if (cost === null || cost === undefined || Number.isNaN(cost)) { return '—'; }
47
+ return cost < 1 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
48
+ }
49
+
50
+ /** One readable line per probed alias (`--check --live`, v4.6.2 PR3): uppercase
51
+ * class prefix padded to a fixed column, two-space indent — mirrors the STALE/
52
+ * DRIFTED/GATEWAY line style above. @param {object} r probeStoredAliases() row */
53
+ function fmtProbeLine(r) {
54
+ const head = ` ${(PROBE_LABELS[r.outcome] + ':').padEnd(8)}${r.alias} -> ${r.target}`;
55
+ if (r.outcome === 'served') { return `${head} (${fmtProbeCost(r.cost)})`; }
56
+ if (r.outcome === 'accepted-but-silent') { return `${head} — ${r.detail} (no output within the probe window)`; }
57
+ return `${head} — ${r.detail}`;
58
+ }
59
+
60
+ /** One readable line per REJECTED provider fetch (issue 209): a namespace that is
61
+ * empty because its key was refused explains stale/absent aliases downstream.
62
+ * @param {{provider: string, reason: string, status?: number, detail?: string}} f
63
+ * @returns {string} */
64
+ function fmtProviderFailure(f) {
65
+ const why = f.reason === 'http-status' ? `HTTP ${f.status}` : (f.detail || f.reason);
66
+ return `PROVIDER FETCH FAILED: ${f.provider} (${why}) — its models are absent from the catalog`;
67
+ }
68
+
69
+ module.exports = {
70
+ perMtok, fmtRow, fmtGatewayFinding, PROBE_LABELS, fmtProbeCost, fmtProbeLine, fmtProviderFailure,
71
+ };
@@ -21,25 +21,11 @@ const { getFamilies } = require('../utils/curated-models');
21
21
  const { pickCurrent } = require('../utils/quick-picks');
22
22
  const { probeStoredAliases, selectStoredAliases } = require('./models-probe');
23
23
  const { DEFAULT_MAX_LEGS } = require('./fanout-validate');
24
+ const { fmtRow, fmtGatewayFinding, fmtProbeLine, fmtProviderFailure } = require('./models-render');
24
25
 
25
26
  const CHECK_EXIT_CAP = 100;
26
27
 
27
- /** '0.000003' per token → '3.00' per Mtok; '—' when unknown or variable (-1) */
28
- function perMtok(perToken) {
29
- if (perToken === null || perToken === undefined) { return '—'; }
30
- const n = Number(perToken);
31
- if (Number.isNaN(n) || n < 0) { return '—'; }
32
- return (n * 1e6).toFixed(2);
33
- }
34
28
 
35
- function fmtRow(m, aliasesById) {
36
- const alias = aliasesById.get(m.id);
37
- const aliasCol = alias ? `[${alias}] ` : '';
38
- const ctx = m.contextLength ?? '—';
39
- const pIn = perMtok(m.pricing && m.pricing.prompt);
40
- const pOut = perMtok(m.pricing && m.pricing.completion);
41
- return `${aliasCol}${m.id}\n ${m.name} ctx ${ctx} $/Mtok in ${pIn} out ${pOut}`;
42
- }
43
29
 
44
30
  /** alias marks: id → comma-joined alias names (effective user aliases) */
45
31
  function aliasMarks() {
@@ -130,36 +116,9 @@ async function runRefresh(args) {
130
116
  return 0;
131
117
  }
132
118
 
133
- /** One readable line per gateway-route finding (Task 6, #gwid). @param {object} f @returns {string} */
134
- function fmtGatewayFinding(f) {
135
- if (f.kind === 'stale') {
136
- return ` GATEWAY STALE (${f.gateway}): ${f.alias} -> ${f.model}`;
137
- }
138
- if (f.kind === 'divergent-missing') {
139
- return ` GATEWAY DIVERGENT: ${f.alias} has no direct form; catalog confirms ${f.model}`;
140
- }
141
- return ` GATEWAY DIVERGENT: ${f.alias} direct form ${f.model} no longer matches catalog (now ${f.expected})`;
142
- }
143
119
 
144
- const PROBE_LABELS = { served: 'SERVED', 'accepted-but-silent': 'SILENT', error: 'ERROR' };
145
120
 
146
- /** '$0.0004' | '$1.23' | '—' (unknown). Deliberately NOT formatCost (pricing.js):
147
- * a probe result's `cost` is a bare number (models-probe.js doesn't carry the
148
- * reported/estimated source tag), so this never claims a precision it can't back. */
149
- function fmtProbeCost(cost) {
150
- if (cost === null || cost === undefined || Number.isNaN(cost)) { return '—'; }
151
- return cost < 1 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
152
- }
153
121
 
154
- /** One readable line per probed alias (`--check --live`, v4.6.2 PR3): uppercase
155
- * class prefix padded to a fixed column, two-space indent — mirrors the STALE/
156
- * DRIFTED/GATEWAY line style above. @param {object} r probeStoredAliases() row */
157
- function fmtProbeLine(r) {
158
- const head = ` ${(PROBE_LABELS[r.outcome] + ':').padEnd(8)}${r.alias} -> ${r.target}`;
159
- if (r.outcome === 'served') { return `${head} (${fmtProbeCost(r.cost)})`; }
160
- if (r.outcome === 'accepted-but-silent') { return `${head} — ${r.detail} (no output within the probe window)`; }
161
- return `${head} — ${r.detail}`;
162
- }
163
122
 
164
123
  async function runCheck(args) {
165
124
  // v4.9 W13 Task B (BACKLOG C5). FIRST — ahead of the catalog-unavailable return
@@ -171,13 +130,17 @@ async function runCheck(args) {
171
130
  require('../utils/alias-shadow').auditAliasShadows();
172
131
  const catalogInfo = await getCatalogInfo();
173
132
  const catalog = catalogInfo.models;
133
+ const providerFailures = Array.isArray(catalogInfo.providerFailures) ? catalogInfo.providerFailures : [];
174
134
  if (!catalog || catalog.length === 0) {
175
135
  const probeSkipped = args.live ? 'catalog-unavailable' : null;
176
136
  if (args.json) {
177
137
  process.stdout.write(JSON.stringify(buildAuditDoc({
178
- stale: [], catalogAvailable: false, probeSkipped
138
+ stale: [], catalogAvailable: false, probeSkipped, providerFailures
179
139
  }), null, 2) + '\n');
180
140
  } else {
141
+ // Council C2 (PR 215): "catalog unavailable" is precisely when the user
142
+ // needs to know WHICH provider refused them.
143
+ for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
181
144
  process.stdout.write('Catalog unavailable (offline or no providers reachable); cannot check.\n');
182
145
  if (probeSkipped) { process.stdout.write(fmtLiveSkipped(probeSkipped) + '\n'); }
183
146
  }
@@ -186,7 +149,7 @@ async function runCheck(args) {
186
149
  const sources = collectAliasSources();
187
150
  const stale = findStaleAliases(sources, catalog)
188
151
  .map(s => ({ ...s, suggestions: suggestReplacements(s.model, catalog) }));
189
- const drifted = findDriftedStoredAliases(sources, catalog);
152
+ const drifted = findDriftedStoredAliases(sources, catalogInfo);
190
153
  // Task 6 (#gwid): per-gateway-form audit of the curated DEFAULTS
191
154
  // (toGatewayRoutes()) — additive to the flat audit above. Informational by
192
155
  // default; --strict promotes it to a build-breaking exit code (CI gate).
@@ -221,10 +184,14 @@ async function runCheck(args) {
221
184
 
222
185
  if (args.json) {
223
186
  process.stdout.write(JSON.stringify(buildAuditDoc({
224
- stale, catalogAvailable: true, gatewayFindings, drifted, probe: probeResults
187
+ stale, catalogAvailable: true, gatewayFindings, drifted, probe: probeResults, providerFailures
225
188
  }), null, 2) + '\n');
226
189
  return exitCode;
227
190
  }
191
+ // issue 209: report REJECTED provider fetches before the alias findings -- an
192
+ // empty namespace explains stale/absent aliases downstream, and staying
193
+ // silent about it is the original defect.
194
+ for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
228
195
  const driftLines = buildFallbackDriftReport(catalog);
229
196
  if (stale.length === 0 && drifted.length === 0) {
230
197
  process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
@@ -20,7 +20,8 @@ function finalizeSpendForReopen({ taskId, model, mode, op, result, status, proje
20
20
  metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
21
21
  try {
22
22
  const { appendSpend } = require('../utils/spend-ledger');
23
- const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
23
+ const { gatewayOf } = require('../utils/gateway-router');
24
+ const gateway = metadata.gateway || gatewayOf(model);
24
25
  // v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
25
26
  // metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:237.
26
27
  appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null }, ctx);
@@ -475,9 +475,17 @@ async function runReadlineSetup() {
475
475
  await warnOnLowOpenRouterCredit();
476
476
  }
477
477
 
478
- const { getCatalog } = require('../utils/model-catalog');
478
+ const { getCatalogInfo } = require('../utils/model-catalog');
479
479
  let catalog = [];
480
- try { catalog = await getCatalog(); } catch (_err) { /* offline: pinned */ }
480
+ // #208: carry the per-provider fetch outcomes alongside the rows, so the
481
+ // vendor shortlist below cannot synthesise a bare direct id for a
482
+ // namespace whose fetch was rejected rather than never attempted.
483
+ let providerFailures = [];
484
+ try {
485
+ const info = await getCatalogInfo();
486
+ catalog = info.models;
487
+ providerFailures = info.providerFailures || [];
488
+ } catch (_err) { /* offline: pinned */ }
481
489
 
482
490
  // Task 7 (cost-aware defaults P2): per-provider picker, once per keyed
483
491
  // provider, BEFORE the mode prompt -- orthogonal to standard-vs-free-council.
@@ -537,7 +545,7 @@ async function runReadlineSetup() {
537
545
  }
538
546
 
539
547
  // Read-modify-write — never rebuild the alias table (no-clobber rule).
540
- const cfg = loadConfig() || { aliases: toLiveSeedAliases(catalog) };
548
+ const cfg = loadConfig() || { aliases: toLiveSeedAliases({ models: catalog, providerFailures }) };
541
549
  if (!cfg.aliases) { cfg.aliases = {}; }
542
550
  if (chosen.alias) {
543
551
  cfg.default = chosen.alias;
@@ -548,7 +556,7 @@ async function runReadlineSetup() {
548
556
  // choice), but the alias's VALUE must stay the vendor phase's tier choice --
549
557
  // skip the curated-flagship upgrade so it isn't discarded.
550
558
  if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
551
- cfg.aliases[chosen.alias] = toStorableRoute(pick);
559
+ cfg.aliases[chosen.alias] = toStorableRoute(pick, { models: catalog, providerFailures });
552
560
  } else if (cfg.aliases[chosen.alias] === undefined) {
553
561
  const fallback = getDefaultAliases()[chosen.alias];
554
562
  if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
@@ -599,6 +607,7 @@ async function runReadlineSetup() {
599
607
  const { buildModelShortlist } = require('../utils/model-shortlist');
600
608
  const shortlist = buildModelShortlist(pick.vendorPath, {
601
609
  catalog,
610
+ providerFailures,
602
611
  recommendedId: cfg.aliases[chosen.alias],
603
612
  });
604
613
  const specific = await promptForVendorModel(
@@ -219,6 +219,7 @@ async function startSidecar(options) {
219
219
  try {
220
220
  const { appendSpend } = require('../utils/spend-ledger');
221
221
  const { statusFromResult } = require('../utils/result-schema');
222
+ const { gatewayOf } = require('../utils/gateway-router');
222
223
  appendSpend({
223
224
  taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive', usage: runUsage,
224
225
  op: 'start', status: statusFromResult(result), project: effectiveProject,
@@ -227,7 +228,7 @@ async function startSidecar(options) {
227
228
  // inside createSessionMetadata. Reading `metadata.gateway` throws a ReferenceError the
228
229
  // best-effort catch swallows → EVERY start-mode spend row silently dropped + start-json.test.js
229
230
  // goes red. Use an in-scope value (spec-complete for direct/openrouter):
230
- gateway: String(model).startsWith('openrouter/') ? 'openrouter' : 'direct',
231
+ gateway: gatewayOf(model),
231
232
  // (To also attribute v4.2 'local': thread the resolved route gateway — dropped today at
232
233
  // cli-handlers-run.js:47 — into createSessionMetadata and read `meta.gateway`, as continue.js:111 does.)
233
234
  // v4.7 F8 D16: same in-scope-value rule as gateway above — `m` is the
@@ -172,13 +172,20 @@ function suggestReplacements(staleModel, catalog, n = 3) {
172
172
  * @param {Array<{id:string}>} catalog
173
173
  * @returns {Array<{alias:string,stored:string,current:string}>}
174
174
  */
175
- function findDriftedStoredAliases(sources, catalog) {
176
- if (!catalog || catalog.length === 0) { return []; }
175
+ function findDriftedStoredAliases(sources, catalogOrInfo) {
176
+ // Council #216 A1/B2/C1: accepts catalogInfo (or a bare array, for existing
177
+ // callers). Passing models WITHOUT providerFailures made this compute the bare
178
+ // direct form for a REJECTED namespace while sidecar/setup.js persists the
179
+ // gateway form -- reporting drift that does not exist, and suggesting a repair
180
+ // that writes back the unservable direct id issue 208 removed.
181
+ const info = Array.isArray(catalogOrInfo) ? { models: catalogOrInfo } : (catalogOrInfo || { models: [] });
182
+ const catalog = info.models || [];
183
+ if (catalog.length === 0) { return []; }
177
184
  const { resolveQuickPicks, toStorableRoute } = require('./quick-picks');
178
185
  const current = new Map();
179
186
  for (const r of resolveQuickPicks(catalog)) {
180
187
  if (r.source !== 'live') { continue; }
181
- const stored = toStorableRoute(r);
188
+ const stored = toStorableRoute(r, info);
182
189
  if (stored) { current.set(r.alias, { display: stored, routeValues: new Set(Object.values(r.routes)) }); }
183
190
  }
184
191
  const byProvider = idsByProvider(catalog);
@@ -103,7 +103,7 @@ const { collapseExcerpt } = require('./text-sanitize');
103
103
  */
104
104
  function findAliasShadows(names) {
105
105
  const { loadConfig } = require('./config');
106
- const { toDefaultAliases, toCanonicalDefault } = require('./curated-models');
106
+ const { toDefaultAliases, stripGatewayPrefix } = require('./curated-models');
107
107
  const cfg = loadConfig();
108
108
  const userAliases = (cfg && cfg.aliases && typeof cfg.aliases === 'object') ? cfg.aliases : {};
109
109
  // Own keys only: a user config.json can carry a literal `__proto__`/`toString`
@@ -131,7 +131,7 @@ function findAliasShadows(names) {
131
131
  // ROUTING has its own audit (`models --check`'s per-gateway section); this
132
132
  // notice speaks only when the alias names a different MODEL. The rows still
133
133
  // report both sides RAW, so the user can grep their own config.
134
- if (toCanonicalDefault(local) === toCanonicalDefault(shipped)) { continue; }
134
+ if (stripGatewayPrefix(local) === stripGatewayPrefix(shipped)) { continue; }
135
135
  out.push({ alias, local, curated: shipped });
136
136
  }
137
137
  return out;
@@ -142,9 +142,11 @@ function getFamilies() {
142
142
  }
143
143
 
144
144
  /**
145
- * Direct-first canonicalization for a pinned `openrouter/<vendor>/<rest>`
146
- * route: when `<vendor>` has a direct integration (provider-registry
147
- * `isDirectProvider`), strip the `openrouter/` prefix so the resulting bare
145
+ * ⚠️ MECHANICAL primitive, NOT a routing decision (renamed from `toCanonicalDefault`,
146
+ * issue 214 model-canonicalization.js explains why that name was a trap). An id that
147
+ * will be CALLED or STORED must come from directFormIfSafe/directFormIfProven. Strips
148
+ * the `openrouter/` prefix off a pinned route when `<vendor>` has a direct integration
149
+ * (provider-registry `isDirectProvider`), so the resulting bare
148
150
  * `<vendor>/<rest>` id is policy-routed by the gateway router (direct when a
149
151
  * direct key exists, OpenRouter otherwise). Gateway-only vendors (no direct
150
152
  * integration — e.g. qwen, x-ai, z-ai, mistralai, minimax, moonshotai,
@@ -154,7 +156,7 @@ function getFamilies() {
154
156
  * @param {string} route
155
157
  * @returns {string}
156
158
  */
157
- function toCanonicalDefault(route) {
159
+ function stripGatewayPrefix(route) {
158
160
  if (typeof route === 'string' && route.startsWith('openrouter/')) {
159
161
  const rest = route.slice('openrouter/'.length); // '<vendor>/<rest...>'
160
162
  const slashIdx = rest.indexOf('/');
@@ -211,7 +213,7 @@ function vendorOf(orRoute) {
211
213
  function directFormFor(vendorPath, obj) {
212
214
  if (obj[vendorPath]) { return obj[vendorPath]; } // explicit, authored, current direct id
213
215
  if (DIVERGENT_VENDORS.has(vendorPath)) { return undefined; } // no explicit form + divergent → omit
214
- const bare = toCanonicalDefault(obj.openrouter); // safe only when ids are identical across gateways
216
+ const bare = stripGatewayPrefix(obj.openrouter); // safe only when ids are identical across gateways
215
217
  return bare !== obj.openrouter ? bare : undefined; // gateway-only vendor → undefined
216
218
  }
217
219
 
@@ -293,6 +295,6 @@ function toDefaultAliases() {
293
295
  }
294
296
 
295
297
  module.exports = {
296
- getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes,
298
+ getFamilies, toDefaultAliases, stripGatewayPrefix, listCuratedRoutes, toGatewayRoutes,
297
299
  directFormProvenance, DIVERGENT_VENDORS
298
300
  };
@@ -12,6 +12,16 @@ const { classifyModel } = require('./model-classification');
12
12
  const { isDirectProvider } = require('./provider-registry');
13
13
  const HINTS = require('./remediation-hints');
14
14
 
15
+ /**
16
+ * Which gateway an executable id routes through (issue 214). The inverse of
17
+ * `executableFor`. Non-string input reads as 'direct' rather than throwing --
18
+ * callers pass raw values off run metadata.
19
+ * @param {*} id @returns {'openrouter'|'direct'}
20
+ */
21
+ function gatewayOf(id) {
22
+ return typeof id === 'string' && id.startsWith('openrouter/') ? 'openrouter' : 'direct';
23
+ }
24
+
15
25
  /** Build the executable id for a gateway. */
16
26
  function executableFor(gateway, vendor, model) {
17
27
  return gateway === 'openrouter' ? `openrouter/${vendor}/${model}` : `${vendor}/${model}`;
@@ -199,4 +209,4 @@ function resolveRoute(req) {
199
209
  return routeError({ requested: d.raw, reason: 'no_key_for_vendor', preferredGateway: 'direct', suggestions: [] });
200
210
  }
201
211
 
202
- module.exports = { resolveRoute };
212
+ module.exports = { gatewayOf, resolveRoute };
@@ -32,7 +32,19 @@
32
32
 
33
33
  'use strict';
34
34
 
35
- const { toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
35
+ /*
36
+ * Why `stripGatewayPrefix` (curated-models.js) is not the function to reach for:
37
+ * under its old name `toCanonicalDefault` it read as the CORRECT answer, and three
38
+ * callers took it at its word and persisted ids the direct API may not serve — the
39
+ * wizard's hand-copy `toBareIfDirect`, `toStorableRoute`, and `toDefaultAliases`
40
+ * before it was rewritten. Issue 214 renamed it rather than giving it a
41
+ * `catalogInfo` parameter, because that is circular: it PRODUCES the candidate id
42
+ * that `classifyModel` then checks against the catalog. The evidence check belongs
43
+ * one level up, here. Direct use of the primitive is correct only when normalising
44
+ * two strings before COMPARING them (alias-shadow.js).
45
+ */
46
+
47
+ const { stripGatewayPrefix, DIVERGENT_VENDORS } = require('./curated-models');
36
48
  const { classifyModel } = require('./model-classification');
37
49
 
38
50
  /**
@@ -41,10 +53,47 @@ const { classifyModel } = require('./model-classification');
41
53
  * @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
42
54
  * @returns {string} the bare direct id when not proven invalid, else `orId` unchanged
43
55
  */
56
+ /**
57
+ * #208: did THIS vendor's direct namespace get ATTEMPTED and REJECTED for the
58
+ * catalog in hand? An empty namespace has two causes and `classifyModel`
59
+ * cannot tell them apart -- it returns 'unknown' for both. "Never fetched"
60
+ * (offline, no key) leaves optimism reasonable; "fetched and refused" means we
61
+ * know nothing about the namespace, and synthesising a direct id out of no
62
+ * knowledge is exactly how `deepseek/deepseek-v4-flash-0731` -- an id no
63
+ * gateway serves -- reached a real user config. Keyed on the VENDOR, never on
64
+ * "any failure": one provider's 401 says nothing about another's namespace.
65
+ * @param {string} vendor
66
+ * @param {{providerFailures?: Array<{provider: string}>}} catalogInfo
67
+ * @returns {boolean}
68
+ */
69
+ function namespaceFetchFailed(vendor, catalogInfo) {
70
+ const failures = catalogInfo && catalogInfo.providerFailures;
71
+ return Array.isArray(failures) && failures.some(f => f && f.provider === vendor);
72
+ }
73
+
74
+ /**
75
+ * Vendor segment of an executable id: `openrouter/<vendor>/<rest>` or
76
+ * `<vendor>/<rest>`. Council #216 (A2/B1): both guards below used to key on the
77
+ * CALLER's `vendor` argument while classifyModel derived its own from the id, so
78
+ * a caller passing none -- which toStorableRoute's JSDoc permits
79
+ * (`vendorPath?:string`) -- silently lost the DIVERGENT and namespace-rejection
80
+ * checks while the catalog check kept working. Deriving closes that asymmetry.
81
+ * @param {*} id @returns {string} '' when the id carries no vendor segment
82
+ */
83
+ function vendorOfId(id) {
84
+ if (typeof id !== 'string') { return ''; }
85
+ const rest = id.startsWith('openrouter/') ? id.slice('openrouter/'.length) : id;
86
+ const idx = rest.indexOf('/');
87
+ return idx > 0 ? rest.slice(0, idx) : '';
88
+ }
89
+
44
90
  function directFormIfSafe(vendor, orId, catalogInfo) {
45
- if (DIVERGENT_VENDORS.has(vendor)) { return orId; }
46
- const bare = toCanonicalDefault(orId);
91
+ const v = vendor || vendorOfId(orId);
92
+ if (DIVERGENT_VENDORS.has(v)) { return orId; }
93
+ const bare = stripGatewayPrefix(orId);
47
94
  if (bare === orId) { return orId; } // gateway-only vendor -- no direct integration at all
95
+ // Optimism is only justified when the namespace was never attempted.
96
+ if (namespaceFetchFailed(v, catalogInfo)) { return orId; }
48
97
  return classifyModel(bare, 'direct', catalogInfo) === 'invalid' ? orId : bare;
49
98
  }
50
99
 
@@ -55,10 +104,10 @@ function directFormIfSafe(vendor, orId, catalogInfo) {
55
104
  * @returns {string} the bare direct id only when PROVEN valid, else `orId` unchanged
56
105
  */
57
106
  function directFormIfProven(vendor, orId, catalogInfo) {
58
- if (DIVERGENT_VENDORS.has(vendor)) { return orId; }
59
- const bare = toCanonicalDefault(orId);
107
+ if (DIVERGENT_VENDORS.has(vendor || vendorOfId(orId))) { return orId; }
108
+ const bare = stripGatewayPrefix(orId);
60
109
  if (bare === orId) { return orId; }
61
110
  return classifyModel(bare, 'direct', catalogInfo) === 'valid' ? bare : orId;
62
111
  }
63
112
 
64
- module.exports = { directFormIfSafe, directFormIfProven };
113
+ module.exports = { directFormIfSafe, directFormIfProven, namespaceFetchFailed, vendorOfId };
@@ -19,7 +19,7 @@ const path = require('path');
19
19
  // this module is first required (the test pattern re-mocks mid-test).
20
20
  function _getConfigDir() { return require('./config').getConfigDir(); }
21
21
  function _readApiKeyValues() { return require('./api-key-store').readApiKeyValues(); }
22
- async function _fetchAllModels(keys) { return require('./model-fetcher').fetchAllModels(keys); }
22
+ async function _fetchAllModels(keys) { return require('./model-fetcher').fetchAllModelsDetailed(keys); }
23
23
 
24
24
  const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
25
25
  const CATALOG_SCHEMA_VERSION = 2;
@@ -69,8 +69,16 @@ function writeCacheDoc(doc) {
69
69
  }
70
70
 
71
71
  /** Write a successful fetch: fresh models/fetchedAt, outcome fields cleared. @param {Array} models */
72
- function writeCache(models) {
73
- writeCacheDoc({ schemaVersion: CATALOG_SCHEMA_VERSION, fetchedAt: Date.now(), models });
72
+ function writeCache(models, providerFailures) {
73
+ writeCacheDoc({
74
+ schemaVersion: CATALOG_SCHEMA_VERSION,
75
+ fetchedAt: Date.now(),
76
+ models,
77
+ // #209: which providers were ATTEMPTED and REJECTED for this fetch. Persisted
78
+ // alongside the rows because it describes THESE rows -- a cache served later
79
+ // is still a catalog whose deepseek namespace is empty for a reason.
80
+ providerFailures: Array.isArray(providerFailures) ? providerFailures : [],
81
+ });
74
82
  }
75
83
 
76
84
  /**
@@ -80,9 +88,17 @@ function writeCache(models) {
80
88
  * carries only the outcome fields (no models/fetchedAt to report).
81
89
  * @param {string} reason short error-class string
82
90
  */
83
- function writeRefreshFailure(reason) {
91
+ function writeRefreshFailure(reason, providerFailures) {
84
92
  const existing = readCache() || { schemaVersion: CATALOG_SCHEMA_VERSION };
85
- writeCacheDoc({ ...existing, lastRefreshAttempt: Date.now(), lastRefreshError: reason });
93
+ const doc = { ...existing, lastRefreshAttempt: Date.now(), lastRefreshError: reason };
94
+ // Council C1 (PR 215): a TOTAL outage is exactly when the per-provider
95
+ // breakdown matters most, and this path used to discard the failures the
96
+ // refresh had just computed. Only overwrite when this attempt produced some --
97
+ // an attempt that learned nothing must not erase a previous attempt's detail.
98
+ if (Array.isArray(providerFailures) && providerFailures.length > 0) {
99
+ doc.providerFailures = providerFailures;
100
+ }
101
+ writeCacheDoc(doc);
86
102
  }
87
103
 
88
104
  /**
@@ -91,7 +107,7 @@ function writeRefreshFailure(reason) {
91
107
  */
92
108
  async function refreshCatalog() {
93
109
  const keys = _readApiKeyValues();
94
- const models = await _fetchAllModels(keys);
110
+ const { rows: models, failures: providerFailures } = await _fetchAllModels(keys);
95
111
  // The anthropic rows are a hardcoded zero-network floor: a result containing
96
112
  // ONLY them means every network provider failed. Treat that as a failed
97
113
  // refresh — never clobber a previously-good cache with the floor (the
@@ -105,10 +121,10 @@ async function refreshCatalog() {
105
121
  const reason = (models || []).length > 0
106
122
  ? 'floor-only: all providers returned no network rows'
107
123
  : 'network-error: all providers unreachable';
108
- writeRefreshFailure(reason);
124
+ writeRefreshFailure(reason, providerFailures);
109
125
  return [];
110
126
  }
111
- writeCache(models);
127
+ writeCache(models, providerFailures);
112
128
  return models;
113
129
  }
114
130
 
@@ -149,6 +165,8 @@ async function getCatalogInfo(opts = {}) {
149
165
  fetchedAt: cache ? cache.fetchedAt : null,
150
166
  lastRefreshAttempt: (doc && doc.lastRefreshAttempt) || null,
151
167
  lastRefreshError: (doc && doc.lastRefreshError) || null,
168
+ // #209: namespace-level fetch outcomes for the CACHED rows above.
169
+ providerFailures: (doc && Array.isArray(doc.providerFailures)) ? doc.providerFailures : [],
152
170
  };
153
171
  }
154
172
 
@@ -136,48 +136,85 @@ function fetchModelsFromProvider(provider, key) {
136
136
  }
137
137
 
138
138
  /**
139
- * Perform the HTTPS fetch + normalize for a single configured provider.
140
- * Resolves to `[]` on any non-200 response, network error, timeout, or parse error.
139
+ * Perform the HTTPS fetch + normalize for a single configured provider,
140
+ * REPORTING why it failed (issue #209). The four failure modes used to
141
+ * collapse to a bare `[]`, which made a rejected fetch indistinguishable from
142
+ * a provider that legitimately serves no models -- and that ambiguity is what
143
+ * lets `classifyModel` return 'unknown' for a namespace whose fetch was
144
+ * actually refused (see #208).
141
145
  * @param {string} provider - Key into PROVIDER_FETCH_CONFIG
142
146
  * @param {string} key - API key
143
- * @returns {Promise<Array>} Normalized model rows, or [] on any failure
147
+ * @returns {Promise<{rows: Array, failure: {reason: string, status?: number, detail?: string}|null}>}
144
148
  */
145
- function fetchViaConfig(provider, key) {
149
+ function fetchViaConfigDetailed(provider, key) {
146
150
  const config = PROVIDER_FETCH_CONFIG[provider];
147
151
  const url = config.buildUrl ? config.buildUrl(key) : config.url;
148
152
  const headers = config.authHeader(key);
149
153
 
150
154
  return new Promise((resolve) => {
151
155
  let chunks = '';
156
+ const ok = (rows) => resolve({ rows, failure: null });
157
+ const fail = (failure) => resolve({ rows: [], failure });
158
+
152
159
  const timer = setTimeout(() => {
153
160
  req.destroy();
154
- resolve([]);
161
+ fail({ reason: 'timeout', detail: `no response within ${FETCH_TIMEOUT_MS}ms` });
155
162
  }, FETCH_TIMEOUT_MS);
156
163
 
157
164
  const req = https.get(url, { headers }, (res) => {
158
165
  if (res.statusCode !== 200) {
159
166
  clearTimeout(timer);
160
167
  res.on('data', () => {});
161
- res.on('end', () => resolve([]));
168
+ res.on('end', () => fail({ reason: 'http-status', status: res.statusCode }));
162
169
  return;
163
170
  }
164
171
  res.on('data', (chunk) => { chunks += chunk; });
165
172
  res.on('end', () => {
166
173
  clearTimeout(timer);
167
174
  try {
168
- resolve(config.normalize(chunks));
169
- } catch (_err) {
170
- resolve([]);
175
+ ok(config.normalize(chunks));
176
+ } catch (err) {
177
+ fail({ reason: 'parse-error', detail: err.message });
171
178
  }
172
179
  });
173
180
  });
174
- req.on('error', () => {
181
+ req.on('error', (err) => {
175
182
  clearTimeout(timer);
176
- resolve([]);
183
+ fail({ reason: 'network-error', detail: err.message });
177
184
  });
178
185
  });
179
186
  }
180
187
 
188
+ /**
189
+ * Rows-only view of `fetchViaConfigDetailed`, preserving the historical
190
+ * contract (`[]` on any failure) for existing callers.
191
+ * @param {string} provider @param {string} key @returns {Promise<Array>}
192
+ */
193
+ function fetchViaConfig(provider, key) {
194
+ return fetchViaConfigDetailed(provider, key).then(r => r.rows);
195
+ }
196
+
197
+ /**
198
+ * Per-provider fetch WITH failure reporting. Mirrors
199
+ * `fetchModelsFromProvider`'s special cases exactly:
200
+ * - anthropic without a key: the hardcoded floor, no network, NOT a failure.
201
+ * - anthropic with a key that yields nothing: floor rows, failure reported.
202
+ * - unknown provider: no rows, not a failure (nothing was attempted).
203
+ * @param {string} provider @param {string} key
204
+ * @returns {Promise<{rows: Array, failure: object|null}>}
205
+ */
206
+ function fetchModelsFromProviderDetailed(provider, key) {
207
+ const floor = () => ANTHROPIC_MODELS.map(r => ({ ...r, authoritative: false }));
208
+ if (provider === 'anthropic') {
209
+ if (!key) { return Promise.resolve({ rows: floor(), failure: null }); }
210
+ return fetchViaConfigDetailed('anthropic', key).then(({ rows, failure }) =>
211
+ (rows.length > 0 ? { rows, failure: null } : { rows: floor(), failure }));
212
+ }
213
+ const config = PROVIDER_FETCH_CONFIG[provider];
214
+ if (!config) { return Promise.resolve({ rows: [], failure: null }); }
215
+ return fetchViaConfigDetailed(provider, key);
216
+ }
217
+
181
218
  /** Providers to fetch: every keyed provider + openrouter (keyless-capable) + anthropic. */
182
219
  function providersToFetch(keys) {
183
220
  const set = new Set(Object.keys(keys).filter(p => keys[p]));
@@ -190,12 +227,16 @@ function providersToFetch(keys) {
190
227
  * Fetch models from all providers that have keys configured; openrouter is
191
228
  * always included (keyless public endpoint) as is anthropic (hardcoded list).
192
229
  * @param {Object<string, string>} keys - Map of provider → API key string
193
- * @returns {Promise<Array<{id: string, name: string, contextLength: number|null, pricing: object|null}>>} Combined model list
230
+ * @returns {Promise<{rows: Array, failures: Array<{provider: string, reason: string, status?: number, detail?: string}>}>}
194
231
  */
195
- async function fetchAllModels(keys) {
232
+ async function fetchAllModelsDetailed(keys) {
196
233
  const providers = providersToFetch(keys);
197
- const results = await Promise.all(providers.map(p => fetchModelsFromProvider(p, keys[p] || '')));
198
- const rows = results.flat();
234
+ const results = await Promise.all(providers.map(p =>
235
+ fetchModelsFromProviderDetailed(p, keys[p] || '').then(r => ({ provider: p, ...r }))));
236
+ const rows = results.flatMap(r => r.rows);
237
+ const failures = results
238
+ .filter(r => r.failure)
239
+ .map(r => ({ provider: r.provider, ...r.failure }));
199
240
  // v4.2 §4.4: append local-provider rows via the scheme-aware probe (5s, [] on failure).
200
241
  try {
201
242
  const { getLocalProviders } = require('./local-providers');
@@ -205,7 +246,17 @@ async function fetchAllModels(keys) {
205
246
  listLocalModels(e, { timeoutMs: 5000, bearer: e.apiKeyEnv ? process.env[e.apiKeyEnv] : undefined })));
206
247
  for (const r of localResults) { rows.push(...r); }
207
248
  } catch (_err) { /* local rows are best-effort — never break the cloud catalog */ }
208
- return rows;
249
+ return { rows, failures };
250
+ }
251
+
252
+ /**
253
+ * Rows-only view of `fetchAllModelsDetailed` — the historical signature, kept
254
+ * so existing callers and their tests are unaffected.
255
+ * @param {Object<string, string>} keys
256
+ * @returns {Promise<Array>} Combined model list
257
+ */
258
+ async function fetchAllModels(keys) {
259
+ return (await fetchAllModelsDetailed(keys)).rows;
209
260
  }
210
261
 
211
262
  /**
@@ -236,6 +287,8 @@ function groupModelsByFamily(models) {
236
287
  module.exports = {
237
288
  fetchModelsFromProvider,
238
289
  fetchAllModels,
290
+ fetchAllModelsDetailed,
291
+ fetchModelsFromProviderDetailed,
239
292
  providersToFetch,
240
293
  groupModelsByFamily,
241
294
  ANTHROPIC_MODELS,
@@ -60,7 +60,8 @@ function compareShortlistRows(a, b) {
60
60
 
61
61
  /**
62
62
  * @param {string} vendor e.g. 'deepseek'
63
- * @param {{catalog?: Array<object>, recommendedId?: string, limit?: number}} [options]
63
+ * @param {{catalog?: Array<object>, recommendedId?: string, limit?: number,
64
+ * providerFailures?: Array<{provider:string}>}} [options]
64
65
  * @returns {{recommendedId: (string|null), suggested: Array<object>,
65
66
  * rest: Array<object>, total: number}}
66
67
  */
@@ -69,7 +70,9 @@ function buildModelShortlist(vendor, options = {}) {
69
70
  const limit = Number.isInteger(options.limit) && options.limit > 0
70
71
  ? options.limit : SHORTLIST_LIMIT;
71
72
 
72
- const { preselectedId, rows } = buildProviderDefaultChoices(vendor, { catalog });
73
+ const { preselectedId, rows } = buildProviderDefaultChoices(vendor, {
74
+ catalog, providerFailures: options.providerFailures,
75
+ });
73
76
  if (!rows || rows.length === 0) {
74
77
  return { recommendedId: null, suggested: [], rest: [], total: 0 };
75
78
  }
@@ -118,9 +118,11 @@ function chooseRowId(vendor, isDirect, row, paired, catalogInfo) {
118
118
  * @param {string} vendor
119
119
  * @returns {Array<{id:string,name:string,contextLength:(number|null),pricePerMInput:(number|null),isPreselected:boolean}>}
120
120
  */
121
- function buildRows(catalog, vendor) {
121
+ function buildRows(catalog, vendor, providerFailures) {
122
122
  const byId = new Map(catalog.filter(r => r && typeof r.id === 'string').map(r => [r.id, r]));
123
- const catalogInfo = { models: catalog };
123
+ // issue 208: providerFailures MUST ride along -- this rebuilds catalogInfo from a bare
124
+ // array, so dropping it leaves directFormIfSafe's namespace gate dead in production.
125
+ const catalogInfo = { models: catalog, providerFailures: providerFailures || [] };
124
126
  const directPrefix = `${vendor}/`;
125
127
  const orPrefix = `openrouter/${vendor}/`;
126
128
  // Hoisted: `vendor` is fixed for the whole call, so this is decided once
@@ -229,7 +231,7 @@ function buildProviderDefaultChoices(vendor, options = {}) {
229
231
  if (typeof vendor !== 'string' || !vendor) { return { preselectedId: null, rows: [] }; }
230
232
 
231
233
  const catalog = Array.isArray(options.catalog) ? options.catalog : [];
232
- const rows = buildRows(catalog, vendor);
234
+ const rows = buildRows(catalog, vendor, options.providerFailures);
233
235
  if (rows.length === 0) { return { preselectedId: null, rows: [] }; }
234
236
 
235
237
  const preselectedId = computePreselectedId(vendor, options.tier, catalog, rows);
@@ -271,6 +273,7 @@ function buildProviderDefaultChoices(vendor, options = {}) {
271
273
  * @returns {{alias: string, setAsDefault: boolean}}
272
274
  */
273
275
  function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true, catalog } = {}) {
276
+ // Council C4 (PR 215): NO providerFailures -- directFormIfProven strips only on POSITIVE evidence, so a failed/empty namespace ('unknown') already returns chosenId untouched.
274
277
  const catalogInfo = { models: Array.isArray(catalog) ? catalog : [] };
275
278
  const storedId = directFormIfProven(vendor, chosenId, catalogInfo);
276
279
 
@@ -10,7 +10,8 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const { getFamilies, toDefaultAliases, toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
13
+ const { getFamilies, toDefaultAliases, DIVERGENT_VENDORS } = require('./curated-models');
14
+ const { directFormIfSafe } = require('./model-canonicalization');
14
15
 
15
16
  const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
16
17
 
@@ -84,12 +85,20 @@ function resolveQuickPicks(catalog) {
84
85
  * @param {{vendorPath?:string, routes?:Object<string,string>}} pick
85
86
  * @returns {string|undefined}
86
87
  */
87
- function toStorableRoute(pick) {
88
+ function toStorableRoute(pick, catalogInfo) {
88
89
  const routes = (pick && pick.routes) || {};
89
90
  if (pick && DIVERGENT_VENDORS.has(pick.vendorPath)) {
90
91
  return routes[pick.vendorPath] || routes.openrouter;
91
92
  }
92
- return toCanonicalDefault(routes.openrouter || Object.values(routes)[0]);
93
+ const route = routes.openrouter || Object.values(routes)[0];
94
+ if (!route) { return undefined; }
95
+ // issue 214 remedy 1: this value is PERSISTED (sidecar/setup.js writes it into
96
+ // config.aliases; toLiveSeedAliases seeds a fresh config with it), so it must
97
+ // not be a blind prefix strip. directFormIfSafe keeps the optimism for a
98
+ // namespace that was never fetched while refusing for one the catalog
99
+ // disproves OR whose fetch was rejected -- the gap #208 closed on the picker
100
+ // path and left open here.
101
+ return directFormIfSafe(pick.vendorPath, route, catalogInfo || { models: [] });
93
102
  }
94
103
 
95
104
  /**
@@ -98,15 +107,44 @@ function toStorableRoute(pick) {
98
107
  * overlaid value is not a raw prefix strip.
99
108
  * @returns {Object<string,string>}
100
109
  */
101
- function toLiveSeedAliases(catalog) {
110
+ function toLiveSeedAliases(catalogOrInfo) {
111
+ // Accepts the bare models array (historical callers) or a full catalogInfo.
112
+ // issue 214: the evidence was always handed in and then discarded.
113
+ const info = Array.isArray(catalogOrInfo)
114
+ ? { models: catalogOrInfo }
115
+ : (catalogOrInfo || { models: [] });
102
116
  const seeds = toDefaultAliases();
103
- for (const r of resolveQuickPicks(catalog || [])) {
117
+ for (const r of resolveQuickPicks(info.models || [])) {
104
118
  if (r.source === 'live' && r.routes.openrouter) {
105
- const stored = toStorableRoute(r);
119
+ const stored = toStorableRoute(r, info);
106
120
  if (stored) { seeds[r.alias] = stored; }
107
121
  }
108
122
  }
109
123
  return seeds;
110
124
  }
111
125
 
112
- module.exports = { compareIdsDesc, pickCurrent, resolveQuickPicks, toLiveSeedAliases, toStorableRoute };
126
+
127
+ /**
128
+ * Per-provider SAFE storable form for a resolved quick pick (issue 214).
129
+ *
130
+ * The wizard renderer used to derive this itself, via a hand-copy of
131
+ * `stripGatewayPrefix` (`toBareIfDirect`) that dropped both of the real
132
+ * primitive's guards: it stripped `openrouter/` for DIVERGENT_VENDORS
133
+ * (fabricating anthropic's dot id, which the direct API rejects) and stripped
134
+ * for a namespace whose fetch had failed. The renderer cannot `require()`, so
135
+ * the decision is made here -- once, with the catalog in hand -- and shipped
136
+ * to the page as data.
137
+ * @param {{vendorPath: string, routes: Object<string,string>}} pick
138
+ * @param {{models: Array<{id:string}>, providerFailures?: Array<{provider:string}>}} catalogInfo
139
+ * @returns {Object<string,string>} provider -> safe storable id
140
+ */
141
+ function canonicalRoutesFor(pick, catalogInfo) {
142
+ const out = {};
143
+ const routes = (pick && pick.routes) || {};
144
+ for (const [provider, route] of Object.entries(routes)) {
145
+ out[provider] = directFormIfSafe(pick.vendorPath, route, catalogInfo || { models: [] });
146
+ }
147
+ return out;
148
+ }
149
+
150
+ module.exports = { compareIdsDesc, canonicalRoutesFor, pickCurrent, resolveQuickPicks, toLiveSeedAliases, toStorableRoute };
@@ -231,7 +231,8 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
231
231
  * probeSkipped?: string|null}} opts
232
232
  */
233
233
  function buildAuditDoc({
234
- stale, catalogAvailable, gatewayFindings = [], drifted = [], probe = [], probeSkipped = null
234
+ stale, catalogAvailable, gatewayFindings = [], drifted = [], probe = [], probeSkipped = null,
235
+ providerFailures = []
235
236
  }) {
236
237
  return {
237
238
  schemaVersion: SCHEMA_VERSION,
@@ -246,6 +247,11 @@ function buildAuditDoc({
246
247
  probeCount: probe.length,
247
248
  probe,
248
249
  probeSkipped,
250
+ // issue 209: providers ATTEMPTED and REJECTED for the catalog in hand. An
251
+ // empty namespace is otherwise indistinguishable from a provider that
252
+ // genuinely serves nothing.
253
+ providerFailuresCount: providerFailures.length,
254
+ providerFailures,
249
255
  };
250
256
  }
251
257