amicus 4.9.0 → 4.9.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.
@@ -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
 
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @module utils/session-status
3
+ * #202: render the engine's SESSION STATUS as a clause on a leg's death report.
4
+ *
5
+ * ⚠️ The JSDoc leads this file, ahead of `'use strict'`, matching
6
+ * `utils/ttft.js` / `utils/text-sanitize.js` / `utils/engine-skew.js`:
7
+ * `scripts/generate-docs.js` only reads a block comment that starts at byte
8
+ * zero, so a `// path` line above it would leave this module's CLAUDE.md row
9
+ * blank.
10
+ *
11
+ * WHY THIS EXISTS. `headless.js` asks the engine for session status only inside
12
+ * `if (mirror.output.length > 0)` — a gate a zero-output leg never satisfies. So
13
+ * the one leg that needs diagnosing is precisely the one that never asks, and
14
+ * every silent death was reported as "no output in Ns" with no cause attached.
15
+ * The pinned SDK publishes `SessionStatus` as
16
+ * `{type:'idle'} | {type:'retry', attempt, message, next} | {type:'busy'}`, and
17
+ * the `retry` arm carries the upstream error verbatim.
18
+ *
19
+ * NONE of the three types is suppressed as uninteresting — they point in
20
+ * DIFFERENT directions, and which one comes back is the discrimination #202
21
+ * spent six CI runs failing to make by argument:
22
+ * · `busy` — the engine is still waiting on the provider ⇒ provider-side.
23
+ * · `idle` — the engine believes it is DONE having produced nothing ⇒
24
+ * engine-side, which is the shape #133 turned out to be.
25
+ * · `retry` — the engine is re-attempting, and says why ⇒ the named cause.
26
+ *
27
+ * ⚠️ APPEND-ONLY, exactly like `engine-skew.js :: formatSkewSuffix`: no status
28
+ * (or an unusable one) returns `''`, so a reason string built without one is
29
+ * byte-for-byte what it was before this module existed, and
30
+ * `sidecar/models-probe.js`'s `/^NO_OUTPUT_BACKSTOP:/` classification — a PREFIX
31
+ * test — is unaffected either way.
32
+ *
33
+ * ⚠️ `message` is UNTRUSTED third-party text: it originates at the provider,
34
+ * lands in run.json, and on CI is rendered into a sticky PR comment. It goes
35
+ * through the house sanitizer (`text-sanitize.js :: collapseExcerpt`) at a short
36
+ * cap rather than being trusted to the workflow's downstream sed rules — one
37
+ * sanitizer, one dialect, per that module's own ruling.
38
+ */
39
+
40
+ 'use strict';
41
+
42
+ const { collapseExcerpt } = require('./text-sanitize');
43
+
44
+ /** Short cap: this is a clause on a one-line death report, not a log dump. */
45
+ const MAX_STATUS_MESSAGE_CHARS = 200;
46
+
47
+ /**
48
+ * The death-report clause for an engine session status.
49
+ * @param {*} status - an SDK SessionStatus, or anything at all
50
+ * @returns {string} ` (session: …)`, or '' when nothing usable was observed
51
+ */
52
+ function formatSessionStatusSuffix(status) {
53
+ if (!status || typeof status !== 'object') { return ''; }
54
+ // A non-string `type` is DROPPED rather than coerced: `String({})` renders
55
+ // '[object Object]', which would read as an observation rather than as the
56
+ // absence it actually is.
57
+ if (typeof status.type !== 'string') { return ''; }
58
+ // ⚠️ CLASSIFY on the RAW value, RENDER the sanitized one (#219 round 2,
59
+ // deepseek). Branching on the sanitized type let the sanitizer's own
60
+ // normalisation decide the arm — anything collapsing to 'retry' took the retry
61
+ // path — so a future SDK identifier could be misclassified by a function whose
62
+ // job is display, not semantics. Only the exact published identifier routes.
63
+ const type = collapseExcerpt(status.type, 40);
64
+ if (!type) { return ''; }
65
+ // An unrecognised type is still reported. A future SDK arm must not read as
66
+ // "no status was observed" — that silence is what this clause removes.
67
+ if (status.type !== 'retry') { return ` (session: ${type})`; }
68
+ const attempt = Number.isFinite(status.attempt) ? ` attempt ${status.attempt}` : '';
69
+ const raw = collapseExcerpt(status.message, MAX_STATUS_MESSAGE_CHARS);
70
+ return ` (session: retry${attempt}${raw ? ` — ${raw}` : ''})`;
71
+ }
72
+
73
+ module.exports = { formatSessionStatusSuffix, MAX_STATUS_MESSAGE_CHARS };
package/src/utils/ttft.js CHANGED
@@ -9,12 +9,23 @@
9
9
  * blank the way `utils/result-schema.js`'s already is.
10
10
  *
11
11
  * `ttftMs` is produced once — in `src/headless.js`'s poll loop, as a
12
- * `Date.now()` delta — and then passes four EMIT GATES on its way to a
12
+ * `Date.now()` delta — and then passes five EMIT GATES on its way to a
13
13
  * document: `headless.js`'s three returns, `sidecar/fanout-leg.js`'s leg patch,
14
- * `utils/result-schema.js :: buildRunResult`, and
15
- * `council/run-stats-entry.js :: buildRunStatsEntry`. Every gate used to spell
16
- * its own `typeof x === 'number'` test, which is four chances to disagree and
17
- * four ways to publish a value both schemas forbid.
14
+ * `utils/result-schema.js :: buildRunResult`,
15
+ * `council/run-stats-entry.js :: buildRunStatsEntry`, and
16
+ * `council/tally.js :: tally`'s runStats re-projection. Every gate used to spell
17
+ * its own `typeof x === 'number'` test, which is five chances to disagree and
18
+ * five ways to publish a value both schemas forbid.
19
+ *
20
+ * ⚠️ The fifth gate is different in KIND from the four above it, which is why it
21
+ * was missed for a release (#202). Those four are PRODUCERS — each writes the
22
+ * field onto a document it is building. `tally.js` is a RE-PROJECTION: it copies
23
+ * an already-built row through a hand-maintained allowlist, so omitting the field
24
+ * there does not fail to produce it, it DESTROYS one already produced. Between
25
+ * v4.9.0 and v4.9.1 that is exactly what happened — the probe wrote real values
26
+ * into tally-input.json and every one was stripped before tally.json and
27
+ * verdict.json, the only run artifacts CI uploads (MEASURED, run 33030485388:
28
+ * 11 of 12 rows carried it going in, 0 of 12 coming out).
18
29
  *
19
30
  * ⚠️ `typeof` is not the schema's contract. `schemas/run.schema.json` and
20
31
  * `schemas/council-tally.schema.json` both declare this field
@@ -35,7 +46,7 @@
35
46
  * already means "no honest measurement was made", and a dishonest number is
36
47
  * exactly that. `0` itself stays a real, emittable measurement.
37
48
  *
38
- * ⚠️ Three of the four gates import this. The fourth,
49
+ * ⚠️ Four of the five gates import this. The one that does not,
39
50
  * `council/run-stats-entry.js`, is pinned REQUIRE-FREE (P3,
40
51
  * tests/council/run-stats-entry.test.js — the pin fires on the character
41
52
  * sequence anywhere in that file, comments included) so require-free consumers