amicus 4.8.0 → 4.8.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 +106 -0
- package/README.md +3 -3
- package/docs/CITATIONS.md +13 -5
- package/docs/configuration.md +1 -1
- package/docs/usage.md +1 -1
- package/electron/ipc-setup.js +18 -2
- package/electron/main.js +46 -3
- package/electron/setup-ui-model.js +99 -9
- package/electron/setup-ui-styles.js +22 -0
- package/electron/setup-ui.js +231 -29
- package/electron/workspace-ui/live-seats.js +4 -4
- package/package.json +2 -1
- package/src/cli-handlers-doctor.js +11 -14
- package/src/council/parse-stage2.js +1 -1
- package/src/sidecar/setup.js +124 -0
- package/src/utils/alias-audit.js +81 -3
- package/src/utils/curated-models.js +5 -5
- package/src/utils/doctor-alias-check.js +152 -0
- package/src/utils/model-canonicalization.js +64 -0
- package/src/utils/model-shortlist.js +100 -0
- package/src/utils/provider-default-picker.js +93 -45
- package/src/utils/provider-default-prompt.js +1 -1
- package/src/utils/quick-picks.js +2 -2
- package/src/utils/remediation-hints.js +24 -0
- package/src/workspace/run-detail.js +3 -3
package/src/utils/alias-audit.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Alias Audit (F5) — report + suggest
|
|
2
|
+
* Alias Audit (F5) — report + suggest for most classes; doctor --fix auto-repairs one narrow, mechanically-unambiguous class (B3).
|
|
3
3
|
*
|
|
4
4
|
* Finds aliases/routes pointing at models absent from the catalog and
|
|
5
5
|
* suggests current same-vendor replacements. Pure functions over inputs;
|
|
6
6
|
* collectAliasSources() does the gathering. Consumed by `amicus models
|
|
7
7
|
* --check` and the npm wrapper scripts.
|
|
8
|
+
*
|
|
9
|
+
* `findFabricatedAliasRepairs()` (B3, council review of PR 198) is the one
|
|
10
|
+
* exception to "never auto-repair": it detects the single class `doctor
|
|
11
|
+
* --fix` can safely rewrite unattended -- a bare id `classifyModel` proves
|
|
12
|
+
* `invalid` on the `direct` gateway AND for which the catalog holds an
|
|
13
|
+
* unambiguous OpenRouter twin (`pairAcrossGateways`, never string
|
|
14
|
+
* concatenation) -- still pure detection here; the actual write lives in
|
|
15
|
+
* `doctor-alias-check.js`'s `repairAlias()`. Every OTHER class this module
|
|
16
|
+
* finds (typo, retired model, ambiguous twin, drifted-but-live) stays
|
|
17
|
+
* report-and-suggest only, same as before -- this module does not become a
|
|
18
|
+
* general auto-repair tool.
|
|
8
19
|
*/
|
|
9
20
|
|
|
10
21
|
'use strict';
|
|
@@ -132,7 +143,10 @@ function suggestReplacements(staleModel, catalog, n = 3) {
|
|
|
132
143
|
* fresh `amicus setup` would seed today — the v4.6.1 release-gate class
|
|
133
144
|
* (stored `gemini` -> 3.1-flash-lite-preview: still catalog-listed so
|
|
134
145
|
* findStaleAliases passes it, no longer what the family resolves to).
|
|
135
|
-
* Report + suggest
|
|
146
|
+
* Report + suggest only -- unlike `findFabricatedAliasRepairs` below (B3's
|
|
147
|
+
* narrow, mechanically-unambiguous exception), THIS function never
|
|
148
|
+
* auto-repairs: a drifted-but-live target has no single unambiguous
|
|
149
|
+
* "correct" answer to converge on the way a fabricated id does.
|
|
136
150
|
*
|
|
137
151
|
* Only user-config rows are checked (defaults/curated follow the catalog by
|
|
138
152
|
* construction), only for aliases that are quick-pick families (a custom
|
|
@@ -178,4 +192,68 @@ function findDriftedStoredAliases(sources, catalog) {
|
|
|
178
192
|
.map(({ alias, model }) => ({ alias, stored: model, current: current.get(alias).display }));
|
|
179
193
|
}
|
|
180
194
|
|
|
181
|
-
|
|
195
|
+
/**
|
|
196
|
+
* B3 (council review of PR 198, issue 195): the narrow, mechanically-
|
|
197
|
+
* unambiguous class of stored alias `doctor --fix` may repair -- a bare
|
|
198
|
+
* `<vendor>/<model>` id in a 'user-config' row (what's actually persisted to
|
|
199
|
+
* `config.aliases`, the only source a repair can rewrite) that
|
|
200
|
+
* `classifyModel` proves `invalid` on the `direct` gateway AND for which the
|
|
201
|
+
* catalog contains an unambiguous OpenRouter twin (`pairAcrossGateways` --
|
|
202
|
+
* never string-concatenation). This is exactly the class the pre-fix
|
|
203
|
+
* v4.8.0 `chooseRowId`/`applyProviderDefault` could persist (an unconditional
|
|
204
|
+
* `openrouter/` strip with no catalog evidence); PR 198's
|
|
205
|
+
* `directFormIfProven` (model-canonicalization.js) now refuses to write it
|
|
206
|
+
* going forward, so this function finds the ones already ON DISK and
|
|
207
|
+
* converges them onto the same OpenRouter-prefixed answer the picker would
|
|
208
|
+
* offer today, rather than inventing a third one.
|
|
209
|
+
*
|
|
210
|
+
* Deliberately narrower than findStaleAliases: a typo'd, retired, or
|
|
211
|
+
* user-invented id also classifies `invalid`, but `pairAcrossGateways` can
|
|
212
|
+
* only find a twin when the extracted vendor + version token names a REAL
|
|
213
|
+
* model that still exists, under that exact normalized name, in the
|
|
214
|
+
* catalog's OpenRouter namespace -- a typo or a genuinely dead model can
|
|
215
|
+
* never satisfy that. Ambiguous (>1 match) or absent twins are left
|
|
216
|
+
* untouched -- still reported as stale by findStaleAliases, never repaired.
|
|
217
|
+
*
|
|
218
|
+
* DIVERGENT_VENDORS (e.g. anthropic) are excluded on purpose:
|
|
219
|
+
* model-canonicalization.js gates that set FIRST and unconditionally, so its
|
|
220
|
+
* predicates never strip a divergent vendor's prefix in the first place --
|
|
221
|
+
* a bare divergent-vendor alias could not have been produced by the bug
|
|
222
|
+
* this repairs, so treating one as fabricated here would be inventing a new
|
|
223
|
+
* class, not converging on the picker's own answer.
|
|
224
|
+
*
|
|
225
|
+
* Empty/absent catalog -> [] : classifyModel can only return 'unknown' with
|
|
226
|
+
* no catalog rows, and 'unknown' never authorises a repair -- no positive
|
|
227
|
+
* evidence, no write.
|
|
228
|
+
* @param {Array<{alias:string,model:string,source:string}>} sources
|
|
229
|
+
* @param {Array<{id:string}>} catalog
|
|
230
|
+
* @returns {Array<{alias:string,oldId:string,newId:string}>}
|
|
231
|
+
*/
|
|
232
|
+
function findFabricatedAliasRepairs(sources, catalog) {
|
|
233
|
+
if (!catalog || catalog.length === 0) { return []; }
|
|
234
|
+
const { classifyModel } = require('./model-classification');
|
|
235
|
+
const { pairAcrossGateways } = require('./gateway-route-catalog');
|
|
236
|
+
const { DIVERGENT_VENDORS } = require('./curated-models');
|
|
237
|
+
const catalogInfo = { models: catalog };
|
|
238
|
+
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const { alias, model, source } of sources) {
|
|
241
|
+
if (source !== 'user-config') { continue; }
|
|
242
|
+
if (typeof model !== 'string' || model.startsWith('openrouter/')) { continue; } // not bare
|
|
243
|
+
const parts = model.split('/');
|
|
244
|
+
if (parts.length < 2) { continue; } // not a <vendor>/<model> shape
|
|
245
|
+
const vendor = parts[0];
|
|
246
|
+
if (DIVERGENT_VENDORS.has(vendor)) { continue; }
|
|
247
|
+
const versionToken = parts.slice(1).join('/');
|
|
248
|
+
if (classifyModel(model, 'direct', catalogInfo) !== 'invalid') { continue; }
|
|
249
|
+
const paired = pairAcrossGateways(vendor, versionToken, catalogInfo);
|
|
250
|
+
if (!paired.openrouter) { continue; } // ambiguous or absent twin -- leave as a warning
|
|
251
|
+
out.push({ alias, oldId: model, newId: paired.openrouter });
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
collectAliasSources, findStaleAliases, findDriftedStoredAliases, suggestReplacements,
|
|
258
|
+
findFabricatedAliasRepairs,
|
|
259
|
+
};
|
|
@@ -112,11 +112,11 @@ const CARDLESS = [
|
|
|
112
112
|
{ alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.6' } },
|
|
113
113
|
{ alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-lite' } },
|
|
114
114
|
// inkling added 2026-08-14: the council-review workflow's default bench
|
|
115
|
-
// names it, and
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
// seat wants the flagship's
|
|
119
|
-
//
|
|
115
|
+
// names it, and this table is the FLOOR a runner falls back to when no
|
|
116
|
+
// alias map is provisioned (workflow_call callers, forks) — there, a
|
|
117
|
+
// locally defined alias still resolves to nothing. Pinned to the full
|
|
118
|
+
// model, not `inkling-small`: the bench seat wants the flagship's
|
|
119
|
+
// judgment. `:batch` is not pinned (wrong for an interactive council leg).
|
|
120
120
|
{ alias: 'inkling', routes: { openrouter: 'openrouter/thinkingmachines/inkling' } },
|
|
121
121
|
];
|
|
122
122
|
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// src/utils/doctor-alias-check.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module utils/doctor-alias-check
|
|
6
|
+
* The `aliases` doctor check ("Model aliases"), split out of
|
|
7
|
+
* src/cli-handlers-doctor.js to keep that file under the 300-line gate
|
|
8
|
+
* (mirrors doctor-engine-check.js / doctor-electron-mcp-check.js /
|
|
9
|
+
* doctor-base-url-check.js / doctor-local-providers-check.js -- same reason,
|
|
10
|
+
* a different check).
|
|
11
|
+
*
|
|
12
|
+
* B3 (council review of PR 198, issue 195): `doctor --fix` repairs exactly
|
|
13
|
+
* one narrow class of stored alias -- see alias-audit.js's
|
|
14
|
+
* `findFabricatedAliasRepairs` for the detection rule (classifies `invalid`
|
|
15
|
+
* on the `direct` gateway AND has an unambiguous OpenRouter twin) and why it
|
|
16
|
+
* cannot false-positive a typo, a retired model, or a user-invented id.
|
|
17
|
+
* Repair = rewrite `config.aliases[alias]` to that catalog-confirmed
|
|
18
|
+
* OpenRouter id -- read-modify-write, no-clobber (mirrors
|
|
19
|
+
* `applyProviderDefault`, provider-default-picker.js). Every OTHER
|
|
20
|
+
* stale/drifted alias is left untouched and stays a warning, hinting at
|
|
21
|
+
* `amicus models --check` same as before this PR.
|
|
22
|
+
*
|
|
23
|
+
* A3 (council review of PR 198): the repair ACTION additionally requires the
|
|
24
|
+
* cached catalog to be FRESH (same `MAX_CATALOG_AGE_MS` window as doctor's
|
|
25
|
+
* own `catalog` check, cli-handlers-doctor.js). `readCache()` here reads the
|
|
26
|
+
* exact same cache doctor's `catalog` check may independently report as
|
|
27
|
+
* `stale (Nh old)` -- without this gate, `--fix` would rewrite a user's
|
|
28
|
+
* config from data the SAME run just called untrustworthy. A stale catalog
|
|
29
|
+
* can be missing rows that would make a "fabricated" id look repairable when
|
|
30
|
+
* it is merely unfetched, so a stale catalog declines the repair (explaining
|
|
31
|
+
* why via `repairFabricatedAliasStaleCatalog`) rather than writing on
|
|
32
|
+
* unverified evidence; detection/reporting is unaffected either way.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const HINTS = require('./remediation-hints');
|
|
36
|
+
|
|
37
|
+
// Mirrors cli-handlers-doctor.js's own MAX_CATALOG_AGE_MS (which itself
|
|
38
|
+
// mirrors model-catalog.js's DEFAULT_MAX_AGE_MS) -- duplicated rather than
|
|
39
|
+
// imported to avoid a require cycle (cli-handlers-doctor.js requires this
|
|
40
|
+
// module at load time, before its own module.exports exists).
|
|
41
|
+
const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{fetchedAt?: number}|null} cache
|
|
45
|
+
* @returns {boolean} true when `cache` exists, has a numeric `fetchedAt`, and
|
|
46
|
+
* is no older than `MAX_CATALOG_AGE_MS` -- the same test doctor's `catalog`
|
|
47
|
+
* check applies to decide `ok` vs `stale (Nh old)`.
|
|
48
|
+
*/
|
|
49
|
+
function isCatalogFresh(cache) {
|
|
50
|
+
if (!cache || typeof cache.fetchedAt !== 'number') { return false; }
|
|
51
|
+
return (Date.now() - cache.fetchedAt) <= MAX_CATALOG_AGE_MS;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Rewrite one alias's stored value in place. Read-modify-write / no-clobber
|
|
56
|
+
* -- preserves `config.default` and every other alias/key (same contract as
|
|
57
|
+
* `applyProviderDefault`, provider-default-picker.js).
|
|
58
|
+
* @param {string} alias
|
|
59
|
+
* @param {string} newId verbatim catalog id (an OpenRouter-namespace id from
|
|
60
|
+
* `pairAcrossGateways` -- never hand-derived by string concatenation)
|
|
61
|
+
*/
|
|
62
|
+
function repairAlias(alias, newId) {
|
|
63
|
+
const { loadConfig, saveConfig } = require('./config');
|
|
64
|
+
const config = loadConfig() || {};
|
|
65
|
+
if (!config.aliases || typeof config.aliases !== 'object') { config.aliases = {}; }
|
|
66
|
+
config.aliases[alias] = newId;
|
|
67
|
+
saveConfig(config);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One pass: sources + both existing audits + the repairable set, over the same catalog. */
|
|
71
|
+
function computeState(d, catalog) {
|
|
72
|
+
const sources = d.collectAliasSources();
|
|
73
|
+
return {
|
|
74
|
+
sources,
|
|
75
|
+
stale: d.findStaleAliases(sources, catalog),
|
|
76
|
+
drifted: d.findDriftedStoredAliases(sources, catalog),
|
|
77
|
+
repairable: d.findFabricatedAliasRepairs(sources, catalog),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {{readCache: () => ({models?: Array}|null), collectAliasSources: () => Array,
|
|
83
|
+
* findStaleAliases: (s:Array, c:Array) => Array, findDriftedStoredAliases: (s:Array, c:Array) => Array,
|
|
84
|
+
* findFabricatedAliasRepairs: (s:Array, c:Array) => Array<{alias:string,oldId:string,newId:string}>,
|
|
85
|
+
* fix?: boolean, repairAlias?: (alias:string, newId:string) => void}} d
|
|
86
|
+
* @returns {{id,name,status,message,hint,fixed?,fixDetail?}}
|
|
87
|
+
*/
|
|
88
|
+
function evaluateAliasesCheck(d) {
|
|
89
|
+
const id = 'aliases';
|
|
90
|
+
const name = 'Model aliases';
|
|
91
|
+
const cache = d.readCache();
|
|
92
|
+
const catalog = (cache && cache.models) || [];
|
|
93
|
+
const catalogFresh = isCatalogFresh(cache);
|
|
94
|
+
|
|
95
|
+
let state = computeState(d, catalog);
|
|
96
|
+
let fixFields = {};
|
|
97
|
+
|
|
98
|
+
// Only under --fix, only when there is something in the narrow,
|
|
99
|
+
// mechanically-unambiguous class to repair (rule 1), and only on a FRESH
|
|
100
|
+
// catalog (A3) -- a failed individual rewrite is best-effort -- it simply
|
|
101
|
+
// stays a warning, same as one findStaleAliases could never resolve.
|
|
102
|
+
if (d.fix && state.repairable.length > 0 && catalogFresh) {
|
|
103
|
+
const repaired = [];
|
|
104
|
+
for (const r of state.repairable) {
|
|
105
|
+
try { d.repairAlias(r.alias, r.newId); repaired.push(r); }
|
|
106
|
+
catch { /* best-effort -- an unrepaired alias just stays a warning below */ }
|
|
107
|
+
}
|
|
108
|
+
if (repaired.length > 0) {
|
|
109
|
+
// Rule 6: announce every repair, naming the alias and both ids -- this
|
|
110
|
+
// fixDetail flows into the 'heal' degrade's `why` field (doctor-degrade.js).
|
|
111
|
+
const detail = repaired.map((r) => `'${r.alias}' (${r.oldId} -> ${r.newId})`).join('; ');
|
|
112
|
+
fixFields = {
|
|
113
|
+
fixed: true,
|
|
114
|
+
fixDetail: `rewrote ${repaired.length} fabricated alias(es) to its catalog-confirmed OpenRouter id: ${detail}`,
|
|
115
|
+
};
|
|
116
|
+
// Rule 5 (idempotency): recompute from a fresh config read so both this
|
|
117
|
+
// run's message and a second --fix run see the post-repair reality, not
|
|
118
|
+
// the pre-repair snapshot -- a repaired alias must not still count as
|
|
119
|
+
// stale/repairable below.
|
|
120
|
+
state = computeState(d, catalog);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { stale, drifted, repairable } = state;
|
|
125
|
+
if (stale.length === 0 && drifted.length === 0) {
|
|
126
|
+
return {
|
|
127
|
+
id, name, status: 'ok',
|
|
128
|
+
message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null,
|
|
129
|
+
...fixFields,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const parts = [];
|
|
133
|
+
if (stale.length) { parts.push(`${stale.length} stale: ${stale.map((s) => s.alias).join(', ')}`); }
|
|
134
|
+
if (drifted.length) { parts.push(`${drifted.length} drifted: ${drifted.map((s) => s.alias).join(', ')}`); }
|
|
135
|
+
// Rule 1: without --fix, report the repairable count and the hint, change
|
|
136
|
+
// nothing. A3: when the catalog is stale, say so explicitly rather than
|
|
137
|
+
// offering a fix that will silently decline to write.
|
|
138
|
+
if (repairable.length) {
|
|
139
|
+
parts.push(catalogFresh
|
|
140
|
+
? `${repairable.length} fixable via doctor --fix`
|
|
141
|
+
: `${repairable.length} fixable via doctor --fix once the catalog is refreshed (catalog is stale)`);
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
id, name, status: 'warn', message: parts.join('; '),
|
|
145
|
+
hint: repairable.length === 0
|
|
146
|
+
? 'amicus models --check'
|
|
147
|
+
: (catalogFresh ? HINTS.repairFabricatedAlias : HINTS.repairFabricatedAliasStaleCatalog),
|
|
148
|
+
...fixFields,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { evaluateAliasesCheck, repairAlias };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct-first id canonicalization, guarded by `classifyModel`
|
|
3
|
+
* (model-classification.js). Extracted out of `provider-default-picker.js`
|
|
4
|
+
* (issue 195 follow-up, F1/B4 -- council review of PR 198) purely to keep
|
|
5
|
+
* that file under the 300-line size gate; the two function bodies below are
|
|
6
|
+
* an unmodified move, not a rewrite.
|
|
7
|
+
*
|
|
8
|
+
* Two call sites, same predicate (`classifyModel`), opposite defaults --
|
|
9
|
+
* named separately on purpose so neither behavior can be reached by
|
|
10
|
+
* accident (e.g. forgetting to pass an option):
|
|
11
|
+
*
|
|
12
|
+
* - `directFormIfSafe` -- LIST-BUILDING (`chooseRowId`/`canonicalizeResolved`
|
|
13
|
+
* in provider-default-picker.js, building the picker's row list). With no
|
|
14
|
+
* catalog evidence either way, a bare policy-routed id is a reasonable
|
|
15
|
+
* GUESS to offer: optimistic, strips the `openrouter/` prefix unless
|
|
16
|
+
* `classifyModel` can prove the bare form `invalid`.
|
|
17
|
+
* - `directFormIfProven` -- PERSISTENCE (`applyProviderDefault`, writing
|
|
18
|
+
* `config.aliases[vendor]`). By the time an id is persisted, the caller
|
|
19
|
+
* has already handed in whatever prefix it decided on -- that prefix IS
|
|
20
|
+
* the user's choice, carrying information an empty or absent catalog does
|
|
21
|
+
* not contradict. Strips only on POSITIVE evidence (`classifyModel`
|
|
22
|
+
* returns `valid`, i.e. the bare id is an actual catalog row); anything
|
|
23
|
+
* else -- `unknown` (including a failed/absent catalog fetch, which is
|
|
24
|
+
* exactly the case that used to silently re-fabricate the id issue 195
|
|
25
|
+
* fixed) or `invalid` -- preserves the id exactly as given.
|
|
26
|
+
*
|
|
27
|
+
* Both gate `DIVERGENT_VENDORS` FIRST and internally, always returning the
|
|
28
|
+
* input unchanged for one of those vendors (dot vs. dash direct ids, e.g.
|
|
29
|
+
* anthropic) -- a caller cannot reach the optimistic OR the proven strip for
|
|
30
|
+
* a divergent vendor by forgetting to check the set itself.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
const { toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
|
|
36
|
+
const { classifyModel } = require('./model-classification');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} vendor
|
|
40
|
+
* @param {string} orId an `openrouter/<vendor>/<rest>` id (or already-bare)
|
|
41
|
+
* @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
|
|
42
|
+
* @returns {string} the bare direct id when not proven invalid, else `orId` unchanged
|
|
43
|
+
*/
|
|
44
|
+
function directFormIfSafe(vendor, orId, catalogInfo) {
|
|
45
|
+
if (DIVERGENT_VENDORS.has(vendor)) { return orId; }
|
|
46
|
+
const bare = toCanonicalDefault(orId);
|
|
47
|
+
if (bare === orId) { return orId; } // gateway-only vendor -- no direct integration at all
|
|
48
|
+
return classifyModel(bare, 'direct', catalogInfo) === 'invalid' ? orId : bare;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} vendor
|
|
53
|
+
* @param {string} orId an `openrouter/<vendor>/<rest>` id (or already-bare)
|
|
54
|
+
* @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
|
|
55
|
+
* @returns {string} the bare direct id only when PROVEN valid, else `orId` unchanged
|
|
56
|
+
*/
|
|
57
|
+
function directFormIfProven(vendor, orId, catalogInfo) {
|
|
58
|
+
if (DIVERGENT_VENDORS.has(vendor)) { return orId; }
|
|
59
|
+
const bare = toCanonicalDefault(orId);
|
|
60
|
+
if (bare === orId) { return orId; }
|
|
61
|
+
return classifyModel(bare, 'direct', catalogInfo) === 'valid' ? bare : orId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { directFormIfSafe, directFormIfProven };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor model shortlist (#138) -- the family -> model second level.
|
|
3
|
+
*
|
|
4
|
+
* Turns a vendor's deduped, priced picker rows into a
|
|
5
|
+
* `{suggested, rest}` split so a surface can show a short list without
|
|
6
|
+
* hiding anything: the GUI renders both groups in one <select>, readline
|
|
7
|
+
* prints `suggested` and offers `a` to print `rest` too.
|
|
8
|
+
*
|
|
9
|
+
* PURE and transport-agnostic, exactly like `provider-default-picker.js`:
|
|
10
|
+
* the catalog is always injected, and nothing here formats for a renderer.
|
|
11
|
+
*
|
|
12
|
+
* Why the caller supplies `recommendedId`: the picker's own preselect is
|
|
13
|
+
* COST-TIER driven (`computePreselectedId`), which disagrees with the
|
|
14
|
+
* wizard card's family flagship -- for DeepSeek, `v3.2` vs `v4-pro`.
|
|
15
|
+
* Owner ruling (#138, 2026-08-24) is that the flagship wins, so that
|
|
16
|
+
* opening the drill-down and accepting is a guaranteed no-op. The tier
|
|
17
|
+
* preselect remains the fallback when the flagship matches no row.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const { buildProviderDefaultChoices } = require('./provider-default-picker');
|
|
23
|
+
const { pairAcrossGateways } = require('./gateway-route-catalog');
|
|
24
|
+
|
|
25
|
+
/** Rows shown before the "all models" group. */
|
|
26
|
+
const SHORTLIST_LIMIT = 8;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Both gateway spellings of a row id, so a surface can honour an explicit
|
|
30
|
+
* "via OpenRouter" choice for a drilled-down model. NEVER derives one form
|
|
31
|
+
* from the other -- `pairAcrossGateways` reads real catalog rows, which is
|
|
32
|
+
* the only safe move for DIVERGENT_VENDORS (anthropic's direct and
|
|
33
|
+
* OpenRouter ids are different strings, not differently prefixed).
|
|
34
|
+
* @param {string} vendor
|
|
35
|
+
* @param {string} id verbatim picker row id
|
|
36
|
+
* @param {Array<{id:string}>} catalog
|
|
37
|
+
* @returns {{directId: (string|null), openrouterId: (string|null)}}
|
|
38
|
+
*/
|
|
39
|
+
function routeFormsFor(vendor, id, catalog) {
|
|
40
|
+
const token = id.replace(/^openrouter\//, '').replace(`${vendor}/`, '');
|
|
41
|
+
const paired = pairAcrossGateways(vendor, token, { models: catalog });
|
|
42
|
+
return {
|
|
43
|
+
directId: paired.direct || null,
|
|
44
|
+
openrouterId: paired.openrouter || null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Recommended-first, then price-ascending, nulls last -- the same order
|
|
50
|
+
* `provider-default-picker.js`'s `compareRows` already establishes, so the
|
|
51
|
+
* drill-down reads like the picker the user may already have seen.
|
|
52
|
+
*/
|
|
53
|
+
function compareShortlistRows(a, b) {
|
|
54
|
+
if (a.isRecommended !== b.isRecommended) { return a.isRecommended ? -1 : 1; }
|
|
55
|
+
if (a.pricePerMInput === null && b.pricePerMInput === null) { return 0; }
|
|
56
|
+
if (a.pricePerMInput === null) { return 1; }
|
|
57
|
+
if (b.pricePerMInput === null) { return -1; }
|
|
58
|
+
return a.pricePerMInput - b.pricePerMInput;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} vendor e.g. 'deepseek'
|
|
63
|
+
* @param {{catalog?: Array<object>, recommendedId?: string, limit?: number}} [options]
|
|
64
|
+
* @returns {{recommendedId: (string|null), suggested: Array<object>,
|
|
65
|
+
* rest: Array<object>, total: number}}
|
|
66
|
+
*/
|
|
67
|
+
function buildModelShortlist(vendor, options = {}) {
|
|
68
|
+
const catalog = Array.isArray(options.catalog) ? options.catalog : [];
|
|
69
|
+
const limit = Number.isInteger(options.limit) && options.limit > 0
|
|
70
|
+
? options.limit : SHORTLIST_LIMIT;
|
|
71
|
+
|
|
72
|
+
const { preselectedId, rows } = buildProviderDefaultChoices(vendor, { catalog });
|
|
73
|
+
if (!rows || rows.length === 0) {
|
|
74
|
+
return { recommendedId: null, suggested: [], rest: [], total: 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Owner ruling: the family flagship wins when it names a real row;
|
|
78
|
+
// otherwise keep the picker's tier preselect rather than inventing one.
|
|
79
|
+
const wanted = options.recommendedId;
|
|
80
|
+
const recommendedId = (wanted && rows.some(r => r.id === wanted)) ? wanted : preselectedId;
|
|
81
|
+
|
|
82
|
+
const annotated = rows.map(r => Object.assign({
|
|
83
|
+
id: r.id,
|
|
84
|
+
name: r.name,
|
|
85
|
+
contextLength: r.contextLength,
|
|
86
|
+
pricePerMInput: r.pricePerMInput,
|
|
87
|
+
isRecommended: r.id === recommendedId,
|
|
88
|
+
}, routeFormsFor(vendor, r.id, catalog)));
|
|
89
|
+
|
|
90
|
+
annotated.sort(compareShortlistRows);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
recommendedId,
|
|
94
|
+
suggested: annotated.slice(0, limit),
|
|
95
|
+
rest: annotated.slice(limit),
|
|
96
|
+
total: annotated.length,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { buildModelShortlist, compareShortlistRows, SHORTLIST_LIMIT };
|