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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +124 -0
- package/README.md +1 -1
- package/docs/usage.md +1 -1
- package/electron/ipc-setup.js +5 -3
- package/electron/main.js +19 -5
- package/electron/setup-ui.js +28 -31
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +10 -0
- package/src/council/run-retry-window.js +62 -0
- package/src/council/run-retry.js +7 -10
- package/src/council/run-stage2.js +47 -3
- package/src/council/tally.js +12 -0
- package/src/council/verdict-seats-reviewed.js +60 -0
- package/src/council/verdict.js +23 -0
- package/src/headless.js +90 -9
- package/src/sidecar/fanout-leg-fallback.js +2 -1
- package/src/sidecar/models-render.js +71 -0
- package/src/sidecar/models.js +12 -45
- package/src/sidecar/reopen-spend.js +2 -1
- package/src/sidecar/setup.js +13 -4
- package/src/sidecar/start.js +2 -1
- package/src/utils/alias-audit.js +10 -3
- package/src/utils/alias-shadow.js +2 -2
- package/src/utils/curated-models.js +8 -6
- package/src/utils/degrade.js +8 -0
- package/src/utils/gateway-router.js +11 -1
- package/src/utils/model-canonicalization.js +55 -6
- package/src/utils/model-catalog.js +26 -8
- package/src/utils/model-fetcher.js +69 -16
- package/src/utils/model-shortlist.js +5 -2
- package/src/utils/provider-default-picker.js +6 -3
- package/src/utils/quick-picks.js +45 -7
- package/src/utils/result-schema.js +7 -1
- package/src/utils/session-status.js +73 -0
- package/src/utils/ttft.js +17 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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 =
|
|
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').
|
|
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({
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
147
|
+
* @returns {Promise<{rows: Array, failure: {reason: string, status?: number, detail?: string}|null}>}
|
|
144
148
|
*/
|
|
145
|
-
function
|
|
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
|
-
|
|
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', () =>
|
|
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
|
-
|
|
169
|
-
} catch (
|
|
170
|
-
|
|
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
|
-
|
|
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<{
|
|
230
|
+
* @returns {Promise<{rows: Array, failures: Array<{provider: string, reason: string, status?: number, detail?: string}>}>}
|
|
194
231
|
*/
|
|
195
|
-
async function
|
|
232
|
+
async function fetchAllModelsDetailed(keys) {
|
|
196
233
|
const providers = providersToFetch(keys);
|
|
197
|
-
const results = await Promise.all(providers.map(p =>
|
|
198
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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
|
|
package/src/utils/quick-picks.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
'use strict';
|
|
12
12
|
|
|
13
|
-
const { getFamilies, toDefaultAliases,
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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`,
|
|
15
|
-
* `council/run-stats-entry.js :: buildRunStatsEntry
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
* ⚠️
|
|
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
|