amicus 4.7.1 → 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 +988 -1
- package/README.md +5 -4
- package/docs/CITATIONS.md +122 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/council.md +179 -32
- package/docs/doc-system.md +1 -1
- package/docs/publishing.md +2 -0
- package/docs/troubleshooting.md +3 -3
- package/docs/usage.md +2 -2
- package/electron/ipc-setup.js +18 -2
- package/electron/main.js +46 -3
- package/electron/setup-ui-aliases.js +2 -2
- 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/index.html +9 -0
- package/electron/workspace-ui/live-dead-seats.js +228 -0
- package/electron/workspace-ui/live-model.js +10 -236
- package/electron/workspace-ui/live-seats.js +126 -0
- package/electron/workspace-ui/workspace-app.js +6 -41
- package/electron/workspace-ui/workspace-banners.js +95 -0
- package/electron/workspace-ui/workspace-lazy.js +55 -12
- package/electron/workspace-ui/workspace-matrix.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +42 -10
- package/electron/workspace-ui/workspace-render.js +2 -2
- package/electron/workspace-ui/workspace-seats.js +101 -17
- package/package.json +4 -1
- package/schemas/council-run-live.schema.json +1 -0
- package/schemas/council-run.schema.json +19 -0
- package/schemas/council-tally.schema.json +34 -2
- package/schemas/council-verdict.schema.json +15 -0
- package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/SKILL.md +25 -11
- package/src/cli-handlers-doctor.js +18 -14
- package/src/council/anonymize.js +80 -11
- package/src/council/briefings-chair.js +272 -0
- package/src/council/briefings-stage2.js +12 -140
- package/src/council/debate.js +120 -15
- package/src/council/ledger-join.js +284 -0
- package/src/council/ledger-stats.js +100 -0
- package/src/council/ledger.js +177 -88
- package/src/council/parse-stage2.js +1 -1
- package/src/council/peer-split.js +196 -0
- package/src/council/report-html.js +12 -5
- package/src/council/report-md.js +146 -0
- package/src/council/report.js +188 -112
- package/src/council/run-assemble.js +100 -79
- package/src/council/run-chair.js +17 -1
- package/src/council/run-debate-revote.js +268 -0
- package/src/council/run-debate.js +92 -102
- package/src/council/run-finish.js +70 -0
- package/src/council/run-launch.js +45 -18
- package/src/council/run-retry-group.js +266 -0
- package/src/council/run-retry-keys.js +74 -0
- package/src/council/run-retry-launch.js +55 -0
- package/src/council/run-retry-notes.js +65 -13
- package/src/council/run-retry.js +166 -161
- package/src/council/run-stage1-launch.js +27 -10
- package/src/council/run-stage1-rows.js +220 -0
- package/src/council/run-stage1-superseded.js +156 -0
- package/src/council/run-stage2.js +65 -5
- package/src/council/run-stages.js +72 -69
- package/src/council/run-state.js +1 -1
- package/src/council/run-stats-entry.js +71 -0
- package/src/council/run-verdict-files.js +52 -0
- package/src/council/run.js +42 -52
- package/src/council/seats.js +262 -0
- package/src/council/stage1-bind.js +142 -0
- package/src/council/street-cred.js +258 -0
- package/src/council/tally.js +100 -49
- package/src/council/verdict.js +49 -1
- package/src/headless.js +20 -6
- package/src/mcp-tools.js +71 -1
- package/src/observe/council-legs.js +7 -1
- package/src/observe/live-doc.js +3 -3
- package/src/sidecar/fanout-leg.js +22 -1
- package/src/sidecar/fanout-wave-io.js +26 -1
- package/src/sidecar/fanout.js +4 -10
- package/src/sidecar/leg-ids.js +19 -0
- package/src/sidecar/models-probe.js +7 -4
- package/src/sidecar/reopen-spend.js +1 -1
- package/src/sidecar/setup.js +137 -2
- package/src/utils/alias-audit.js +81 -3
- package/src/utils/config.js +109 -11
- package/src/utils/curated-models.js +17 -2
- package/src/utils/degrade.js +5 -0
- 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/no-output-backstop.js +1 -1
- 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 +37 -0
- package/src/utils/session-index-prune.js +297 -0
- package/src/utils/session-metadata-tmp-sweep.js +1 -1
- package/src/workspace/artifact-guard.js +8 -114
- package/src/workspace/artifact-names.js +222 -0
- package/src/workspace/fold-format.js +9 -6
- package/src/workspace/live-normalize.js +6 -2
- package/src/workspace/matrix-model.js +141 -19
- package/src/workspace/run-detail.js +30 -4
- package/src/workspace/seat-space.js +143 -0
|
@@ -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 };
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
const { envNumber } = require('./env-num');
|
|
22
22
|
|
|
23
|
-
const DEFAULT_NO_OUTPUT_BACKSTOP_MS =
|
|
23
|
+
const DEFAULT_NO_OUTPUT_BACKSTOP_MS = 300000;
|
|
24
24
|
|
|
25
25
|
/** @param {object} [env] test seam; defaults to process.env */
|
|
26
26
|
function resolveNoOutputBackstopMs(env) {
|
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
const { resolveTier } = require('./model-tiers');
|
|
18
18
|
const { getCostTier, loadConfig, saveConfig } = require('./config');
|
|
19
19
|
const { pairAcrossGateways } = require('./gateway-route-catalog');
|
|
20
|
-
const { toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
|
|
21
20
|
const { isLocalProvider } = require('./local-providers');
|
|
21
|
+
// directFormIfSafe (list-building) / directFormIfProven (persistence): see
|
|
22
|
+
// model-canonicalization.js's module docstring for why these are two
|
|
23
|
+
// separately-named functions rather than one with an optional mode.
|
|
24
|
+
const { directFormIfSafe, directFormIfProven } = require('./model-canonicalization');
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* @param {{pricing?: {prompt?: string|number|null}|null}|null|undefined} orRow
|
|
@@ -49,8 +52,10 @@ function vendorRowsIn(catalog, vendor) {
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
/**
|
|
52
|
-
* Choose the
|
|
53
|
-
*
|
|
55
|
+
* Choose the id a single catalog `row` should surface as in the picker.
|
|
56
|
+
* Prefers a real, verbatim catalog id; only SYNTHESISES one (delegating to
|
|
57
|
+
* `directFormIfSafe`, model-canonicalization.js -- see its module docstring
|
|
58
|
+
* for the guard) for a non-divergent vendor with no direct twin at all.
|
|
54
59
|
*
|
|
55
60
|
* A direct-namespace row always keeps its own (real, direct-callable) id,
|
|
56
61
|
* regardless of what `pairAcrossGateways` could resolve for it -- this is
|
|
@@ -60,35 +65,55 @@ function vendorRowsIn(catalog, vendor) {
|
|
|
60
65
|
* and must not be dropped or silently merged.
|
|
61
66
|
*
|
|
62
67
|
* An OpenRouter-namespace row is collapsed onto a direct id only when
|
|
63
|
-
* `pairAcrossGateways` found exactly one (unambiguous) direct twin
|
|
64
|
-
* a real catalog id, safe to reuse for dedup. Otherwise: for
|
|
68
|
+
* `pairAcrossGateways` found exactly one (unambiguous) direct twin. For
|
|
65
69
|
* `DIVERGENT_VENDORS` (direct ids don't share a string form with
|
|
66
|
-
* OpenRouter's
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* safe because their direct and OpenRouter ids are identical once the
|
|
71
|
-
* `openrouter/<vendor>/` prefix is removed (mirrors curated-models.js's
|
|
72
|
-
* `directFormFor` policy).
|
|
70
|
+
* OpenRouter's, e.g. anthropic's dash-vs-dot versioning), the row keeps its
|
|
71
|
+
* OpenRouter-prefixed id as-is -- `directFormIfSafe` gates that set FIRST
|
|
72
|
+
* and internally (model-canonicalization.js), so stripping is never even
|
|
73
|
+
* attempted on their ids, which is not a string-safe operation.
|
|
73
74
|
* @param {string} vendor
|
|
74
75
|
* @param {boolean} isDirect whether `row.id` is itself a direct-namespace id
|
|
75
76
|
* @param {{id:string}} row
|
|
76
77
|
* @param {{direct?:string, openrouter?:string}} paired
|
|
78
|
+
* @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
|
|
77
79
|
* @returns {string|null}
|
|
78
80
|
*/
|
|
79
|
-
function chooseRowId(vendor, isDirect, row, paired) {
|
|
81
|
+
function chooseRowId(vendor, isDirect, row, paired, catalogInfo) {
|
|
80
82
|
if (isDirect) { return row.id; }
|
|
81
83
|
if (paired.direct) { return paired.direct; }
|
|
82
84
|
if (!paired.openrouter) { return null; }
|
|
83
|
-
return
|
|
85
|
+
return directFormIfSafe(vendor, paired.openrouter, catalogInfo);
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
/**
|
|
87
89
|
* Dedupe `vendor`'s catalog rows across gateways into one row per logical
|
|
88
90
|
* model, reusing `pairAcrossGateways` (never re-deriving the pairing logic).
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
91
|
+
* A direct row is never dropped even when its OpenRouter twin is ambiguous.
|
|
92
|
+
*
|
|
93
|
+
* Row ids are verbatim catalog ids whenever a real row carries them: a
|
|
94
|
+
* direct row keeps its own id, and an OpenRouter row collapses onto a
|
|
95
|
+
* direct twin only when `pairAcrossGateways` found exactly one. For a
|
|
96
|
+
* NON-divergent vendor with no direct twin at all, `chooseRowId` derives
|
|
97
|
+
* the bare form via `directFormIfSafe` -- SYNTHESISED, not verbatim, only
|
|
98
|
+
* when the vendor's namespace can't prove it invalid (issue 195). Measured
|
|
99
|
+
* 2026-08-24 against a real 601-row catalog: all 14 `deepseek` rows derive
|
|
100
|
+
* this way (empty namespace); `google` (19/69) and `openai` (51/175) rows
|
|
101
|
+
* used to derive the same unconditional way despite a populated,
|
|
102
|
+
* authoritative namespace omitting that specific id, and now stay
|
|
103
|
+
* OpenRouter-prefixed instead. Once a bare id IS synthesized (the deepseek
|
|
104
|
+
* case), it is safe for routing, but not for the reason an earlier version
|
|
105
|
+
* of this comment claimed: a bare id is NOT failure-routed direct-first-
|
|
106
|
+
* with-OpenRouter-fallback. gateway-router.js's auto-mode step 7 is
|
|
107
|
+
* `if (rq.keys[vendor] && hasForm(rq, 'direct'))`, and `hasForm` is
|
|
108
|
+
* `!req.gatewayIds || req.gatewayIds[gateway] !== undefined` -- a bare id
|
|
109
|
+
* carries no `gatewayIds` at all, so `hasForm(rq, 'direct')` is VACUOUSLY
|
|
110
|
+
* true on this path; the only real gate is `rq.keys[vendor]`. The fallback
|
|
111
|
+
* to OpenRouter is therefore KEY-ABSENCE-driven (no key for `vendor`), not
|
|
112
|
+
* a check that the direct form actually resolves -- that check only fires
|
|
113
|
+
* on the ALIAS path, where `gatewayIds` IS attached and `hasForm` can
|
|
114
|
+
* genuinely be false. It is not a catalog-confirmed direct id either way,
|
|
115
|
+
* and callers that need that distinction should consult
|
|
116
|
+
* `curated-models.js`'s `directFormProvenance()`.
|
|
92
117
|
* @param {Array<{id:string,name:string,contextLength:(number|null)}>} catalog
|
|
93
118
|
* @param {string} vendor
|
|
94
119
|
* @returns {Array<{id:string,name:string,contextLength:(number|null),pricePerMInput:(number|null),isPreselected:boolean}>}
|
|
@@ -109,7 +134,7 @@ function buildRows(catalog, vendor) {
|
|
|
109
134
|
const isDirect = row.id.startsWith(directPrefix);
|
|
110
135
|
const token = isDirect ? row.id.slice(directPrefix.length) : row.id.slice(orPrefix.length);
|
|
111
136
|
const paired = pairAcrossGateways(vendor, token, catalogInfo);
|
|
112
|
-
const chosenId = chooseRowId(vendor, isDirect, row, paired);
|
|
137
|
+
const chosenId = chooseRowId(vendor, isDirect, row, paired, catalogInfo);
|
|
113
138
|
if (!chosenId || seenIds.has(chosenId)) { continue; }
|
|
114
139
|
seenIds.add(chosenId);
|
|
115
140
|
|
|
@@ -124,7 +149,7 @@ function buildRows(catalog, vendor) {
|
|
|
124
149
|
// pricing (`sourceRow`, which for a local/direct row IS the catalog row
|
|
125
150
|
// itself) instead. Gated on `isLocal`, NOT on "no OpenRouter twin", so a
|
|
126
151
|
// direct (non-local) row with no twin still renders `pricePerMInput:
|
|
127
|
-
// null` (tests/provider-default-picker.test.js:
|
|
152
|
+
// null` (tests/provider-default-picker.test.js:60, pinned).
|
|
128
153
|
const localPrice = isLocal ? pricePerMInputFrom(sourceRow) : null;
|
|
129
154
|
|
|
130
155
|
rows.push({
|
|
@@ -140,22 +165,22 @@ function buildRows(catalog, vendor) {
|
|
|
140
165
|
|
|
141
166
|
/**
|
|
142
167
|
* Canonicalize `resolveTier`'s verbatim catalog-id output the same way row
|
|
143
|
-
* ids are built (`chooseRowId`), so it
|
|
144
|
-
* exactly. `resolveTier`
|
|
145
|
-
* the tier
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* `
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* their direct and OpenRouter ids are identical once the prefix is removed.
|
|
168
|
+
* ids are built (`chooseRowId`/`directFormIfSafe`), so it matches `rows`
|
|
169
|
+
* exactly. Only matters when `resolveTier` fell back to an OpenRouter id (no
|
|
170
|
+
* matching direct-namespace row for the tier). `directFormIfSafe` gates
|
|
171
|
+
* `DIVERGENT_VENDORS` itself and keeps that id OpenRouter-prefixed;
|
|
172
|
+
* non-divergent vendors go through the same `classifyModel`-guarded strip
|
|
173
|
+
* `chooseRowId` uses (issue 195) -- an unconditional strip here would
|
|
174
|
+
* produce a canonical id `rows` no longer carries whenever `directFormIfSafe`
|
|
175
|
+
* kept the row itself OpenRouter-prefixed, defeating the match below.
|
|
152
176
|
* @param {string} vendor
|
|
153
177
|
* @param {string|null} resolved verbatim catalog id from `resolveTier`, or null
|
|
178
|
+
* @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
|
|
154
179
|
* @returns {string|null}
|
|
155
180
|
*/
|
|
156
|
-
function canonicalizeResolved(vendor, resolved) {
|
|
181
|
+
function canonicalizeResolved(vendor, resolved, catalogInfo) {
|
|
157
182
|
if (!resolved) { return null; }
|
|
158
|
-
return
|
|
183
|
+
return directFormIfSafe(vendor, resolved, catalogInfo);
|
|
159
184
|
}
|
|
160
185
|
|
|
161
186
|
/**
|
|
@@ -173,7 +198,9 @@ function canonicalizeResolved(vendor, resolved) {
|
|
|
173
198
|
function computePreselectedId(vendor, tier, catalog, rows) {
|
|
174
199
|
const effectiveTier = tier || getCostTier();
|
|
175
200
|
const resolved = resolveTier(vendor, effectiveTier, catalog);
|
|
176
|
-
|
|
201
|
+
// F2 (B5): guard the same way applyProviderDefault does, so classifyModel
|
|
202
|
+
// never sees a non-array `models` regardless of what a caller passes.
|
|
203
|
+
const canonical = canonicalizeResolved(vendor, resolved, { models: Array.isArray(catalog) ? catalog : [] });
|
|
177
204
|
if (canonical && rows.some(r => r.id === canonical)) { return canonical; }
|
|
178
205
|
|
|
179
206
|
const priced = rows.filter(r => r.pricePerMInput !== null);
|
|
@@ -217,22 +244,35 @@ function buildProviderDefaultChoices(vendor, options = {}) {
|
|
|
217
244
|
* `config.default` on first use. Read-modify-write, NO-CLOBBER -- preserves
|
|
218
245
|
* an already-set `config.default` and every other existing alias/key.
|
|
219
246
|
*
|
|
220
|
-
* `chosenId`
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* `
|
|
227
|
-
*
|
|
228
|
-
*
|
|
247
|
+
* `chosenId` comes straight from `buildProviderDefaultChoices`: a real
|
|
248
|
+
* direct id; an `openrouter/`-prefixed id (`DIVERGENT_VENDORS`, or -- since
|
|
249
|
+
* issue 195 -- a non-divergent vendor whose bare form would classify
|
|
250
|
+
* `invalid`); or a bare id already SYNTHESISED via `directFormIfSafe`. By
|
|
251
|
+
* the time this function runs, `chosenId`'s prefix (or lack of one) IS the
|
|
252
|
+
* decision -- PERSISTENCE therefore uses `directFormIfProven`, not
|
|
253
|
+
* `directFormIfSafe`: it strips the `openrouter/` prefix only on POSITIVE
|
|
254
|
+
* evidence (`classifyModel` returns `valid` -- the bare id is an actual
|
|
255
|
+
* catalog row), never merely because the catalog couldn't disprove it.
|
|
256
|
+
*
|
|
257
|
+
* This matters because `directFormIfSafe`'s optimistic default is correct
|
|
258
|
+
* for LIST-BUILDING (an empty/absent catalog can't assert absence, so a
|
|
259
|
+
* bare guess is reasonable to offer) but wrong here: a catalog fetch that
|
|
260
|
+
* failed or came back empty (`catalog` omitted, or `[]`) must never be read
|
|
261
|
+
* as license to fabricate a direct id `chooseRowId` never actually offered
|
|
262
|
+
* -- that reintroduces the exact bug issue 195 fixed, silently, on every
|
|
263
|
+
* degraded fetch. `directFormIfProven` preserves `chosenId` verbatim
|
|
264
|
+
* whenever the catalog can't prove the bare form valid, so an empty/absent
|
|
265
|
+
* `catalog` option is inert (no canonicalization at all), not a fallback to
|
|
266
|
+
* the old unconditional strip.
|
|
229
267
|
* @param {string} vendor e.g. 'anthropic'
|
|
230
|
-
* @param {string} chosenId
|
|
231
|
-
*
|
|
268
|
+
* @param {string} chosenId id from the picker (see above -- not always
|
|
269
|
+
* catalog-verbatim)
|
|
270
|
+
* @param {{seedDefaultIfAbsent?: boolean, catalog?: Array<{id:string}>}} [options]
|
|
232
271
|
* @returns {{alias: string, setAsDefault: boolean}}
|
|
233
272
|
*/
|
|
234
|
-
function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true } = {}) {
|
|
235
|
-
const
|
|
273
|
+
function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true, catalog } = {}) {
|
|
274
|
+
const catalogInfo = { models: Array.isArray(catalog) ? catalog : [] };
|
|
275
|
+
const storedId = directFormIfProven(vendor, chosenId, catalogInfo);
|
|
236
276
|
|
|
237
277
|
const config = loadConfig() || {};
|
|
238
278
|
if (!config.aliases || typeof config.aliases !== 'object') { config.aliases = {}; }
|
|
@@ -246,4 +286,12 @@ function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true } =
|
|
|
246
286
|
return { alias: vendor, setAsDefault };
|
|
247
287
|
}
|
|
248
288
|
|
|
249
|
-
|
|
289
|
+
// directFormIfSafe/directFormIfProven are re-exported (not just used
|
|
290
|
+
// internally) so a caller that wants the canonicalization primitives
|
|
291
|
+
// directly doesn't need to know they now live in model-canonicalization.js
|
|
292
|
+
// -- see tests/model-canonicalization.test.js for the identity pin proving
|
|
293
|
+
// this is a re-export, not a second, divergeable copy.
|
|
294
|
+
module.exports = {
|
|
295
|
+
buildProviderDefaultChoices, applyProviderDefault, pricePerMInputFrom,
|
|
296
|
+
directFormIfSafe, directFormIfProven,
|
|
297
|
+
};
|
|
@@ -111,7 +111,7 @@ async function runProviderDefaultFlow(provider, options = {}) {
|
|
|
111
111
|
chosenId = await promptForChoice(ask, print, choices);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
const { setAsDefault } = applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true });
|
|
114
|
+
const { setAsDefault } = applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true, catalog });
|
|
115
115
|
const summaryLine = `\`amicus start --model ${provider}\` → ${chosenId}` +
|
|
116
116
|
(setAsDefault ? ', set as your default model' : '');
|
|
117
117
|
|
package/src/utils/quick-picks.js
CHANGED
|
@@ -79,8 +79,8 @@ function resolveQuickPicks(catalog) {
|
|
|
79
79
|
* Stripping the prefix there fabricates an id the direct API rejects, which
|
|
80
80
|
* `amicus doctor` then reports as a stale alias. The row's own direct route is
|
|
81
81
|
* used verbatim, falling back to the intact `openrouter/` form when the
|
|
82
|
-
* catalog offered no direct pick. Mirrors the guard
|
|
83
|
-
* `
|
|
82
|
+
* catalog offered no direct pick. Mirrors the DIVERGENT_VENDORS-first guard
|
|
83
|
+
* `model-canonicalization.js :: directFormIfSafe` uses internally.
|
|
84
84
|
* @param {{vendorPath?:string, routes?:Object<string,string>}} pick
|
|
85
85
|
* @returns {string|undefined}
|
|
86
86
|
*/
|
|
@@ -130,6 +130,43 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
130
130
|
*/
|
|
131
131
|
sweepSessionMetadataTmp:
|
|
132
132
|
'amicus doctor --fix (sweeps orphaned .metadata.json.*.tmp files left by an interrupted write)',
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Stale sessions-index.json entries (R16): session-index.js :: recordSession
|
|
136
|
+
* never removes a row, so a project that is deleted, renamed or moved
|
|
137
|
+
* leaves its taskId -> path entries behind forever, and every
|
|
138
|
+
* recordSession call pays for rewriting the WHOLE index — the per-start
|
|
139
|
+
* cost grows with total sessions ever, not live ones. `doctor --fix`
|
|
140
|
+
* removes only entries whose project path no longer exists on disk
|
|
141
|
+
* (liveness, never age — R16-2): a five-year-old entry for a project that
|
|
142
|
+
* still exists is left alone.
|
|
143
|
+
*/
|
|
144
|
+
pruneSessionIndex:
|
|
145
|
+
'amicus doctor --fix (removes sessions-index.json entries whose project no longer exists on disk — liveness-based, never by age)',
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Fabricated bare alias (B3, council review of PR 198 / issue 195): v4.8.0
|
|
149
|
+
* could persist a `<vendor>/<model>` id no catalog row carries. `doctor
|
|
150
|
+
* --fix` rewrites ONLY the narrow, mechanically-unambiguous class -- an
|
|
151
|
+
* alias that classifies `invalid` on the `direct` gateway AND has an
|
|
152
|
+
* unambiguous OpenRouter twin (`pairAcrossGateways`, alias-audit.js's
|
|
153
|
+
* `findFabricatedAliasRepairs`) -- to that twin. Every other stale/drifted
|
|
154
|
+
* alias (typo, retired model, user-invented id) is left for `amicus
|
|
155
|
+
* models --check` instead, never guessed at.
|
|
156
|
+
*/
|
|
157
|
+
repairFabricatedAlias:
|
|
158
|
+
'amicus doctor --fix (rewrites a fabricated bare alias to its catalog-confirmed OpenRouter id — safe only for the narrow class doctor can prove; use `amicus models --check` for anything else)',
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A3 (council review of PR 198): same repairable class as
|
|
162
|
+
* `repairFabricatedAlias` above, but the cached catalog `evaluateAliasesCheck`
|
|
163
|
+
* would repair FROM is itself stale (older than doctor's own `catalog`
|
|
164
|
+
* check's freshness window) -- a stale catalog can be missing rows that
|
|
165
|
+
* would make a "fabricated" id look repairable when it is merely unfetched,
|
|
166
|
+
* so the repair declines rather than write on unverified evidence.
|
|
167
|
+
*/
|
|
168
|
+
repairFabricatedAliasStaleCatalog:
|
|
169
|
+
'amicus models --refresh, then amicus doctor --fix (the alias looks fabricated but the cached catalog is stale — refresh it first so the repair rests on current data, not a possibly-incomplete snapshot)',
|
|
133
170
|
});
|
|
134
171
|
|
|
135
172
|
module.exports = REMEDIATION_HINTS;
|