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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +67 -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/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/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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.9.
|
|
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
package/docs/usage.md
CHANGED
package/electron/ipc-setup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
191
|
+
catalogInfo = await require('../src/utils/model-catalog').getCatalogInfo();
|
|
190
192
|
} catch (_err) { /* offline: pinned seeds */ }
|
|
191
|
-
cfg = { aliases: toLiveSeedAliases(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/electron/setup-ui.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
|
73
|
+
${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson)}
|
|
66
74
|
</body></html>`;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson
|
|
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
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
// (e.g. anthropic) that id can
|
|
318
|
-
// openrouter/<vendor>/... form
|
|
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
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
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.
|
|
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
|
-
(
|
|
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
|
+
};
|
package/src/sidecar/models.js
CHANGED
|
@@ -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,
|
|
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
|
|
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);
|
package/src/sidecar/setup.js
CHANGED
|
@@ -475,9 +475,17 @@ async function runReadlineSetup() {
|
|
|
475
475
|
await warnOnLowOpenRouterCredit();
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
-
const {
|
|
478
|
+
const { getCatalogInfo } = require('../utils/model-catalog');
|
|
479
479
|
let catalog = [];
|
|
480
|
-
|
|
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(
|
package/src/sidecar/start.js
CHANGED
|
@@ -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:
|
|
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
|
package/src/utils/alias-audit.js
CHANGED
|
@@ -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,
|
|
176
|
-
|
|
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,
|
|
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 (
|
|
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
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
|