amicus 4.6.1 → 4.6.3
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 +152 -0
- package/README.md +7 -8
- package/docs/ROADMAP.md +38 -4
- package/docs/configuration.md +18 -12
- package/docs/council.md +7 -2
- package/docs/troubleshooting.md +49 -20
- package/docs/usage.md +30 -3
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +3 -0
- package/electron/workspace-ui/live-model.js +146 -2
- package/electron/workspace-ui/workspace-app.js +8 -3
- package/electron/workspace-ui/workspace-panels.js +9 -10
- package/electron/workspace-ui/workspace-render.js +9 -3
- package/electron/workspace-ui/workspace-seats.js +132 -0
- package/electron/workspace-ui/workspace-verbs.js +2 -1
- package/electron/workspace-ui/workspace.css +6 -0
- package/package.json +1 -1
- package/schemas/alias-audit.schema.json +6 -1
- package/schemas/council-run.schema.json +14 -0
- package/src/cli-handlers-council.js +9 -0
- package/src/cli-handlers-doctor.js +25 -7
- package/src/cli.js +4 -0
- package/src/council/presets-cli.js +6 -2
- package/src/council/run-chair.js +55 -6
- package/src/headless.js +119 -9
- package/src/mcp-council-awareness.js +1 -0
- package/src/opencode-client.js +21 -0
- package/src/session-manager.js +6 -2
- package/src/sidecar/fanout-leg.js +2 -2
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/models-probe.js +119 -0
- package/src/sidecar/models.js +81 -6
- package/src/utils/alias-audit.js +71 -1
- package/src/utils/base-url-classify.js +74 -0
- package/src/utils/council-presets.js +6 -2
- package/src/utils/curated-models.js +71 -16
- package/src/utils/doctor-base-url-check.js +41 -0
- package/src/utils/gateway-route-audit.js +16 -3
- package/src/utils/model-fetcher.js +9 -6
- package/src/utils/model-tiers.js +28 -7
- package/src/utils/no-output-backstop.js +48 -0
- package/src/utils/remediation-hints.js +14 -0
- package/src/utils/result-schema.js +29 -2
- package/src/utils/session-metadata-tmp-sweep.js +136 -0
- package/src/workspace/live-normalize.js +1 -0
package/src/sidecar/models.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* amicus models --search <q> substring filter over id+name
|
|
6
6
|
* amicus models --refresh force-refresh the cache
|
|
7
7
|
* amicus models --check stale-alias audit (exit = stale count, max 100)
|
|
8
|
+
* amicus models --check --live + probe every stored alias with a real leg (spends)
|
|
8
9
|
* --json on all of the above versioned documents (result-schema)
|
|
9
10
|
*
|
|
10
11
|
* Returns an exit code; bin/amicus.js plumbs it like fanout's.
|
|
@@ -13,11 +14,13 @@
|
|
|
13
14
|
'use strict';
|
|
14
15
|
|
|
15
16
|
const { getCatalogInfo, refreshCatalog, catalogPath } = require('../utils/model-catalog');
|
|
16
|
-
const { collectAliasSources, findStaleAliases, suggestReplacements } = require('../utils/alias-audit');
|
|
17
|
+
const { collectAliasSources, findStaleAliases, findDriftedStoredAliases, suggestReplacements } = require('../utils/alias-audit');
|
|
17
18
|
const { auditGatewayRoutes } = require('../utils/gateway-route-audit');
|
|
18
19
|
const { buildCatalogDoc, buildAuditDoc } = require('../utils/result-schema');
|
|
19
20
|
const { getFamilies } = require('../utils/curated-models');
|
|
20
21
|
const { pickCurrent } = require('../utils/quick-picks');
|
|
22
|
+
const { probeStoredAliases, selectStoredAliases } = require('./models-probe');
|
|
23
|
+
const { DEFAULT_MAX_LEGS } = require('./fanout-validate');
|
|
21
24
|
|
|
22
25
|
const CHECK_EXIT_CAP = 100;
|
|
23
26
|
|
|
@@ -91,9 +94,19 @@ async function runList(args) {
|
|
|
91
94
|
return 0;
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
// v4.6.2 PR3 Task 4: shared --live skip line; reason doubles as the JSON probeSkipped slug.
|
|
98
|
+
function fmtLiveSkipped(reason) {
|
|
99
|
+
return `--live skipped: ${reason} — nothing was probed`;
|
|
100
|
+
}
|
|
101
|
+
|
|
94
102
|
async function runRefresh(args) {
|
|
95
103
|
const models = await refreshCatalog();
|
|
96
104
|
const { fetchedAt, lastRefreshAttempt, lastRefreshError } = await getCatalogInfo({ maxAgeMs: Number.POSITIVE_INFINITY });
|
|
105
|
+
// --refresh short-circuits --check below (args.check is guaranteed true here) — must announce, not silently skip.
|
|
106
|
+
if (args.live) {
|
|
107
|
+
const line = fmtLiveSkipped('refresh-precedes-check');
|
|
108
|
+
(args.json ? process.stderr : process.stdout).write(line + '\n');
|
|
109
|
+
}
|
|
97
110
|
if (args.json) {
|
|
98
111
|
process.stdout.write(JSON.stringify(buildCatalogDoc({
|
|
99
112
|
models, fetchedAt, refreshed: true, lastRefreshAttempt, lastRefreshError
|
|
@@ -128,41 +141,87 @@ function fmtGatewayFinding(f) {
|
|
|
128
141
|
return ` GATEWAY DIVERGENT: ${f.alias} direct form ${f.model} no longer matches catalog (now ${f.expected})`;
|
|
129
142
|
}
|
|
130
143
|
|
|
144
|
+
const PROBE_LABELS = { served: 'SERVED', 'accepted-but-silent': 'SILENT', error: 'ERROR' };
|
|
145
|
+
|
|
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
|
+
|
|
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} (accepted but not serving)`; }
|
|
161
|
+
return `${head} — ${r.detail}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
131
164
|
async function runCheck(args) {
|
|
132
165
|
const catalogInfo = await getCatalogInfo();
|
|
133
166
|
const catalog = catalogInfo.models;
|
|
134
167
|
if (!catalog || catalog.length === 0) {
|
|
168
|
+
const probeSkipped = args.live ? 'catalog-unavailable' : null;
|
|
135
169
|
if (args.json) {
|
|
136
170
|
process.stdout.write(JSON.stringify(buildAuditDoc({
|
|
137
|
-
stale: [], catalogAvailable: false
|
|
171
|
+
stale: [], catalogAvailable: false, probeSkipped
|
|
138
172
|
}), null, 2) + '\n');
|
|
139
173
|
} else {
|
|
140
174
|
process.stdout.write('Catalog unavailable (offline or no providers reachable); cannot check.\n');
|
|
175
|
+
if (probeSkipped) { process.stdout.write(fmtLiveSkipped(probeSkipped) + '\n'); }
|
|
141
176
|
}
|
|
142
177
|
return 0;
|
|
143
178
|
}
|
|
144
179
|
const sources = collectAliasSources();
|
|
145
180
|
const stale = findStaleAliases(sources, catalog)
|
|
146
181
|
.map(s => ({ ...s, suggestions: suggestReplacements(s.model, catalog) }));
|
|
182
|
+
const drifted = findDriftedStoredAliases(sources, catalog);
|
|
147
183
|
// Task 6 (#gwid): per-gateway-form audit of the curated DEFAULTS
|
|
148
184
|
// (toGatewayRoutes()) — additive to the flat audit above. Informational by
|
|
149
185
|
// default; --strict promotes it to a build-breaking exit code (CI gate).
|
|
150
186
|
const gatewayFindings = auditGatewayRoutes(catalogInfo);
|
|
151
187
|
const legacyExitCode = Math.min(stale.length, CHECK_EXIT_CAP);
|
|
152
|
-
|
|
188
|
+
let exitCode = args.strict
|
|
153
189
|
? Math.max(legacyExitCode, Math.min(gatewayFindings.length, CHECK_EXIT_CAP))
|
|
154
190
|
: legacyExitCode;
|
|
155
191
|
|
|
192
|
+
// v4.6.2 PR3 (spec §6, D5): opt-in --live probe of stored aliases with real
|
|
193
|
+
// engine legs. Never spends without --live — probeStoredAliases is only
|
|
194
|
+
// ever called inside this block (regression-tested: a mocked module must
|
|
195
|
+
// see zero calls when the flag is absent). The cap pre-check runs BEFORE
|
|
196
|
+
// the call so a doomed wave never spends a token (Task 2 review carry-in:
|
|
197
|
+
// without it, runFanout fails wave-creation and models-probe.js degrades
|
|
198
|
+
// every row to a generic error, losing the real reason).
|
|
199
|
+
let probeResults = [];
|
|
200
|
+
if (args.live) {
|
|
201
|
+
const storedCount = selectStoredAliases(sources).length;
|
|
202
|
+
const envCap = Number(process.env.AMICUS_FANOUT_MAX_LEGS);
|
|
203
|
+
const maxLegs = (Number.isInteger(envCap) && envCap > 0) ? envCap : DEFAULT_MAX_LEGS;
|
|
204
|
+
if (storedCount > maxLegs) {
|
|
205
|
+
process.stderr.write(`Error: --live would probe ${storedCount} stored aliases, exceeding the `
|
|
206
|
+
+ `fan-out cap of ${maxLegs} (set AMICUS_FANOUT_MAX_LEGS to raise)\n`);
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
const probe = await probeStoredAliases({ project: args.cwd || process.cwd() });
|
|
210
|
+
probeResults = probe.results;
|
|
211
|
+
const nonServed = probeResults.filter(r => r.outcome !== 'served').length;
|
|
212
|
+
exitCode = Math.max(exitCode, Math.min(nonServed, CHECK_EXIT_CAP));
|
|
213
|
+
}
|
|
214
|
+
|
|
156
215
|
if (args.json) {
|
|
157
216
|
process.stdout.write(JSON.stringify(buildAuditDoc({
|
|
158
|
-
stale, catalogAvailable: true, gatewayFindings
|
|
217
|
+
stale, catalogAvailable: true, gatewayFindings, drifted, probe: probeResults
|
|
159
218
|
}), null, 2) + '\n');
|
|
160
219
|
return exitCode;
|
|
161
220
|
}
|
|
162
221
|
const driftLines = buildFallbackDriftReport(catalog);
|
|
163
|
-
if (stale.length === 0) {
|
|
222
|
+
if (stale.length === 0 && drifted.length === 0) {
|
|
164
223
|
process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
|
|
165
|
-
} else {
|
|
224
|
+
} else if (stale.length > 0) {
|
|
166
225
|
for (const s of stale) {
|
|
167
226
|
process.stdout.write(`STALE: ${s.alias} -> ${s.model} (${s.source})\n`);
|
|
168
227
|
if (s.suggestions.length > 0) {
|
|
@@ -173,10 +232,22 @@ async function runCheck(args) {
|
|
|
173
232
|
}
|
|
174
233
|
}
|
|
175
234
|
}
|
|
235
|
+
for (const dr of drifted) {
|
|
236
|
+
process.stdout.write(`DRIFTED: ${dr.alias} -> ${dr.stored} (stored; current resolution: ${dr.current})\n`);
|
|
237
|
+
process.stdout.write(` stored aliases don't follow catalog updates — refresh: amicus setup --add-alias ${dr.alias}=${dr.current}\n`);
|
|
238
|
+
}
|
|
176
239
|
if (driftLines.length > 0) {
|
|
177
240
|
process.stdout.write('Pinned fallback drift:\n');
|
|
178
241
|
for (const l of driftLines) { process.stdout.write(l + '\n'); }
|
|
179
242
|
}
|
|
243
|
+
if (args.live) {
|
|
244
|
+
if (probeResults.length === 0) {
|
|
245
|
+
process.stdout.write('Live probe: no stored aliases to probe\n');
|
|
246
|
+
} else {
|
|
247
|
+
process.stdout.write(`Live probe (${probeResults.length} stored aliases):\n`);
|
|
248
|
+
for (const r of probeResults) { process.stdout.write(fmtProbeLine(r) + '\n'); }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
180
251
|
if (gatewayFindings.length > 0) {
|
|
181
252
|
process.stdout.write('Per-gateway route audit (curated defaults):\n');
|
|
182
253
|
for (const f of gatewayFindings) { process.stdout.write(fmtGatewayFinding(f) + '\n'); }
|
|
@@ -209,6 +280,10 @@ async function handleModels(args) {
|
|
|
209
280
|
process.stderr.write('Error: --search requires a value\n');
|
|
210
281
|
return 1;
|
|
211
282
|
}
|
|
283
|
+
if (args.live && !args.check) {
|
|
284
|
+
process.stderr.write('Error: --live requires --check\n');
|
|
285
|
+
return 1;
|
|
286
|
+
}
|
|
212
287
|
if (args.refresh) { return runRefresh(args); }
|
|
213
288
|
if (args.check) { return runCheck(args); }
|
|
214
289
|
return runList(args);
|
package/src/utils/alias-audit.js
CHANGED
|
@@ -62,6 +62,12 @@ function idsByProvider(catalog) {
|
|
|
62
62
|
* at least one live resolution from any other source. This ensures the
|
|
63
63
|
* suggested `--add-alias` fix actually clears the warning, and prevents
|
|
64
64
|
* permanently unclearable noise when the default openrouter route is live.
|
|
65
|
+
*
|
|
66
|
+
* A stale 'defaults' entry is additionally suppressed when it is the alias's
|
|
67
|
+
* DERIVED direct-form pin (per curated-models.directFormProvenance()) and the
|
|
68
|
+
* alias is either covered live from another source or declares gatewayOnly
|
|
69
|
+
* (v4.6.3 PR1, spec D2) — an AUTHORED defaults pin and user-config rows are
|
|
70
|
+
* never suppressed this way.
|
|
65
71
|
* @param {Array<{alias,model,source}>} sources
|
|
66
72
|
* @param {Array<{id:string}>} catalog
|
|
67
73
|
*/
|
|
@@ -75,11 +81,24 @@ function findStaleAliases(sources, catalog) {
|
|
|
75
81
|
const covered = new Set(
|
|
76
82
|
sources.filter(({ model }) => isLive(model) === true).map(({ alias }) => alias)
|
|
77
83
|
);
|
|
84
|
+
// v4.6.3 PR1 (spec D2). Lazy-required so suites that doMock curated-models
|
|
85
|
+
// for other cases keep working; a stub without the accessor simply gets no
|
|
86
|
+
// suppression (fail-open toward reporting).
|
|
87
|
+
const cm = require('./curated-models');
|
|
88
|
+
const provenance = typeof cm.directFormProvenance === 'function' ? (cm.directFormProvenance() || {}) : {};
|
|
78
89
|
return sources.filter(({ alias, model, source }) => {
|
|
79
90
|
const ids = byProvider.get(model.split('/')[0]);
|
|
80
91
|
if (!ids) { return false; } // provider unverifiable
|
|
81
92
|
if (ids.has(model)) { return false; } // live
|
|
82
93
|
if (source.startsWith('curated-route') && covered.has(alias)) { return false; }
|
|
94
|
+
// A 'defaults' pin that is the alias's DERIVED direct form: its absence
|
|
95
|
+
// from the direct namespace is a routing fact, not staleness, while the
|
|
96
|
+
// alias has live coverage (or declares gatewayOnly). The fix: suggestion
|
|
97
|
+
// this row would otherwise print is a retarget nobody should run — the
|
|
98
|
+
// 2026-08-05 release-gate false positive (v4.6.3 spec §3).
|
|
99
|
+
const prov = provenance[alias];
|
|
100
|
+
if (source === 'defaults' && prov && prov.directForm === 'derived' &&
|
|
101
|
+
(prov.gatewayOnly || covered.has(alias))) { return false; }
|
|
83
102
|
return true;
|
|
84
103
|
});
|
|
85
104
|
}
|
|
@@ -108,4 +127,55 @@ function suggestReplacements(staleModel, catalog, n = 3) {
|
|
|
108
127
|
.slice(0, n);
|
|
109
128
|
}
|
|
110
129
|
|
|
111
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Stored aliases whose target is LIVE in the catalog but no longer what a
|
|
132
|
+
* fresh `amicus setup` would seed today — the v4.6.1 release-gate class
|
|
133
|
+
* (stored `gemini` -> 3.1-flash-lite-preview: still catalog-listed so
|
|
134
|
+
* findStaleAliases passes it, no longer what the family resolves to).
|
|
135
|
+
* Report + suggest, never auto-repair (this module's charter).
|
|
136
|
+
*
|
|
137
|
+
* Only user-config rows are checked (defaults/curated follow the catalog by
|
|
138
|
+
* construction), only for aliases that are quick-pick families (a custom
|
|
139
|
+
* alias has no "current" to drift from), and only when the stored target is
|
|
140
|
+
* itself catalog-live (a dead target is findStaleAliases's finding, not
|
|
141
|
+
* ours). The `current` display value goes through toStorableRoute() — the
|
|
142
|
+
* guarded 4.1.2 helper — never a bare prefix strip (spec D3).
|
|
143
|
+
*
|
|
144
|
+
* Drift membership, however, is NOT a raw compare against that single
|
|
145
|
+
* canonicalized display string. toStorableRoute() canonicalizes a
|
|
146
|
+
* direct-capable vendor's OpenRouter pick down to the bare direct form
|
|
147
|
+
* (e.g. 'google/gemini-3.6-flash'), but a stored alias may legitimately hold
|
|
148
|
+
* the gateway-prefixed form of that SAME model ('openrouter/google/gemini-
|
|
149
|
+
* 3.6-flash' — the exact route pickCurrent/resolveQuickPicks resolves live,
|
|
150
|
+
* and what a STALE fix's own suggestion may have pointed a user to store).
|
|
151
|
+
* Comparing only against the canonicalized string would false-positive that
|
|
152
|
+
* as drift. Instead, a stored row only counts as drift when its model is
|
|
153
|
+
* absent from the family's FULL live route-value set (every value in that
|
|
154
|
+
* family's `routes` map — openrouter form and any direct form together, per
|
|
155
|
+
* resolveQuickPicks) — i.e. it names a genuinely different model, not the
|
|
156
|
+
* same model under a different gateway form.
|
|
157
|
+
* @param {Array<{alias:string,model:string,source:string}>} sources
|
|
158
|
+
* @param {Array<{id:string}>} catalog
|
|
159
|
+
* @returns {Array<{alias:string,stored:string,current:string}>}
|
|
160
|
+
*/
|
|
161
|
+
function findDriftedStoredAliases(sources, catalog) {
|
|
162
|
+
if (!catalog || catalog.length === 0) { return []; }
|
|
163
|
+
const { resolveQuickPicks, toStorableRoute } = require('./quick-picks');
|
|
164
|
+
const current = new Map();
|
|
165
|
+
for (const r of resolveQuickPicks(catalog)) {
|
|
166
|
+
if (r.source !== 'live') { continue; }
|
|
167
|
+
const stored = toStorableRoute(r);
|
|
168
|
+
if (stored) { current.set(r.alias, { display: stored, routeValues: new Set(Object.values(r.routes)) }); }
|
|
169
|
+
}
|
|
170
|
+
const byProvider = idsByProvider(catalog);
|
|
171
|
+
return sources
|
|
172
|
+
.filter(({ source }) => source === 'user-config')
|
|
173
|
+
.filter(({ model }) => {
|
|
174
|
+
const ids = byProvider.get(model.split('/')[0]);
|
|
175
|
+
return !!(ids && ids.has(model));
|
|
176
|
+
})
|
|
177
|
+
.filter(({ alias, model }) => current.has(alias) && !current.get(alias).routeValues.has(model))
|
|
178
|
+
.map(({ alias, model }) => ({ alias, stored: model, current: current.get(alias).display }));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { collectAliasSources, findStaleAliases, findDriftedStoredAliases, suggestReplacements };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module base-url-classify
|
|
3
|
+
* v4.6.2 PR1 (spec §4, D1/D2): ANTHROPIC_BASE_URL classification, the
|
|
4
|
+
* normalization decision, and the once-per-process notice.
|
|
5
|
+
*
|
|
6
|
+
* The convention split (field-proven by a control pair on run 0084d48c):
|
|
7
|
+
* Anthropic SDKs — including Claude Code itself — treat the var as a HOST and
|
|
8
|
+
* append /v1 themselves; OpenCode's provider layer treats it as the FULL
|
|
9
|
+
* prefix and appends /messages. A host-form value is therefore correct for
|
|
10
|
+
* Claude Code and fatal for every OpenCode direct-anthropic leg
|
|
11
|
+
* (host/messages -> 404 "Not Found").
|
|
12
|
+
*
|
|
13
|
+
* Forms: absent (unset/blank) · host (path '' or '/') · v1 (path ends /v1)
|
|
14
|
+
* · other (any other path, or unparseable — passed through untouched; an
|
|
15
|
+
* exotic proxy serving /messages at a custom root stays possible, D1).
|
|
16
|
+
*/
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
/** @param {string|undefined|null} value @returns {{form:string, normalized:string|null}} */
|
|
20
|
+
function classifyBaseUrl(value) {
|
|
21
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
22
|
+
return { form: 'absent', normalized: null };
|
|
23
|
+
}
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
let url;
|
|
26
|
+
try { url = new URL(trimmed); } catch { return { form: 'other', normalized: null }; }
|
|
27
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
28
|
+
if (path === '') {
|
|
29
|
+
return { form: 'host', normalized: trimmed.replace(/\/+$/, '') + '/v1' };
|
|
30
|
+
}
|
|
31
|
+
if (path.endsWith('/v1')) { return { form: 'v1', normalized: null }; }
|
|
32
|
+
return { form: 'other', normalized: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The baseURL override the OpenCode server config should carry, or null.
|
|
37
|
+
* Null when: var absent, already /v1, nonstandard path, or normalization
|
|
38
|
+
* disabled via AMICUS_BASE_URL_NORMALIZE=0 (D1's escape hatch).
|
|
39
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
40
|
+
* @returns {string|null}
|
|
41
|
+
*/
|
|
42
|
+
function resolveBaseUrlOverride(env = process.env) {
|
|
43
|
+
if (env.AMICUS_BASE_URL_NORMALIZE === '0') { return null; }
|
|
44
|
+
const { form, normalized } = classifyBaseUrl(env.ANTHROPIC_BASE_URL);
|
|
45
|
+
return form === 'host' ? normalized : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let noticeShown = false;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One notice per process (D2): the server may start many times (shared-server
|
|
52
|
+
* retries, fanout waves) and the treatment is identical every time.
|
|
53
|
+
* @param {string} value - the raw env value seen
|
|
54
|
+
* @param {string} normalized - the value handed to the engine config
|
|
55
|
+
* @param {{write?:Function, logger?:object}} [deps] - test seams
|
|
56
|
+
*/
|
|
57
|
+
function announceBaseUrlNormalizationOnce(value, normalized, deps = {}) {
|
|
58
|
+
if (noticeShown) { return; }
|
|
59
|
+
noticeShown = true;
|
|
60
|
+
const write = deps.write || (s => process.stderr.write(s));
|
|
61
|
+
const log = deps.logger || require('./logger').logger;
|
|
62
|
+
write(`Notice: ANTHROPIC_BASE_URL is host-form (${value}); passing ${normalized} to the engine `
|
|
63
|
+
+ '(Anthropic SDKs append /v1 themselves; OpenCode treats the value as a full prefix; '
|
|
64
|
+
+ 'set AMICUS_BASE_URL_NORMALIZE=0 to disable).\n');
|
|
65
|
+
log.info('ANTHROPIC_BASE_URL normalized for engine config', { value, normalized });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Test seam: reset the once-guard. */
|
|
69
|
+
function _resetBaseUrlNotice() { noticeShown = false; }
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
classifyBaseUrl, resolveBaseUrlOverride,
|
|
73
|
+
announceBaseUrlNormalizationOnce, _resetBaseUrlNotice,
|
|
74
|
+
};
|
|
@@ -42,13 +42,17 @@ const BUDGET_ALIASES = ['minimax', 'qwen-coder', 'deepseek'];
|
|
|
42
42
|
/**
|
|
43
43
|
* Frontier bench: three premium-flagship DEFAULT_ALIASES entries, one per
|
|
44
44
|
* vendor family, verified against the same catalog snapshot:
|
|
45
|
-
* gpt-pro openrouter/openai/gpt-5.
|
|
46
|
-
* opus openrouter/anthropic/claude-opus-
|
|
45
|
+
* gpt-pro openrouter/openai/gpt-5.6-sol-pro $0.000005 / $0.00003
|
|
46
|
+
* opus openrouter/anthropic/claude-opus-5 $0.000005 / $0.000025
|
|
47
47
|
* gemini-pro openrouter/google/gemini-3.1-pro-preview $0.000002 / $0.000012
|
|
48
48
|
* These are the three highest total (prompt+completion) prices in
|
|
49
49
|
* DEFAULT_ALIASES that are also each a distinct vendor family (OpenAI /
|
|
50
50
|
* Anthropic / Google) — `gpt` and `codex` (also OpenAI) and `claude`/`sonnet`
|
|
51
51
|
* (also Anthropic) were skipped as same-family duplicates of the pick above.
|
|
52
|
+
* (opus re-pinned to claude-opus-5 on 2026-08-04 at the same live price;
|
|
53
|
+
* gpt-pro retargeted to gpt-5.6-sol-pro on 2026-08-04 — cheaper than the
|
|
54
|
+
* old gpt-5.5-pro pin but still OpenAI's premium tier, so the selection
|
|
55
|
+
* logic above is unchanged.)
|
|
52
56
|
*/
|
|
53
57
|
const FRONTIER_ALIASES = ['gpt-pro', 'opus', 'gemini-pro'];
|
|
54
58
|
|
|
@@ -21,9 +21,11 @@ const { isDirectProvider } = require('./provider-registry');
|
|
|
21
21
|
* resolve live from the catalog. A per-provider `fallback` entry is
|
|
22
22
|
* OPTIONAL: when absent and the catalog cannot resolve that namespace,
|
|
23
23
|
* the direct route is omitted (no pinned guess is better than a wrong one).
|
|
24
|
-
* `gpt`'s pattern intentionally matches
|
|
25
|
-
* (gpt-5.5, gpt-6)
|
|
26
|
-
*
|
|
24
|
+
* `gpt`'s pattern intentionally matches a plain numeric flagship id
|
|
25
|
+
* (gpt-5.5, gpt-6) OR that id's `-terra` tier variant (gpt-5.6-terra), and
|
|
26
|
+
* excludes every other suffixed variant (-pro/-mini/-codex/-sol/-luna) —
|
|
27
|
+
* see the tier-semantics comment on the entry below.
|
|
28
|
+
* Pinned ids verified against the live catalog 2026-08-04.
|
|
27
29
|
*/
|
|
28
30
|
const FAMILIES = [
|
|
29
31
|
{ alias: 'gemini', label: 'Gemini Flash-class', blurb: 'fast, large context',
|
|
@@ -37,17 +39,26 @@ const FAMILIES = [
|
|
|
37
39
|
idPattern: /^gemini-[\d.]+-pro(-preview|-exp|-latest)?$/,
|
|
38
40
|
directProviders: ['google'],
|
|
39
41
|
fallback: { openrouter: 'openrouter/google/gemini-3.1-pro-preview' } },
|
|
42
|
+
// 5.6 split the flagship into tiers: sol (premium, $5/$30), terra (mid,
|
|
43
|
+
// $1/$6), luna (economy, $0.10/$0.60), each with a -pro sibling, plus the
|
|
44
|
+
// unrelated gpt-5.3-codex family. Owner ruling: `gpt` tracks the TERRA
|
|
45
|
+
// (mid) tier — sol/luna/pro variants and codex are excluded deliberately.
|
|
46
|
+
// Bare numeric ids (gpt-5.5-style) stay matched as a within-family
|
|
47
|
+
// fallback if the terra naming ever disappears from the catalog.
|
|
40
48
|
{ alias: 'gpt', label: 'GPT flagship', blurb: 'strong coding',
|
|
41
49
|
vendorPath: 'openai',
|
|
42
|
-
idPattern: /^gpt-[\d.]
|
|
50
|
+
idPattern: /^gpt-[\d.]+(-terra)?$/,
|
|
43
51
|
directProviders: ['openai'],
|
|
44
|
-
fallback: { openrouter: 'openrouter/openai/gpt-5.
|
|
52
|
+
fallback: { openrouter: 'openrouter/openai/gpt-5.6-terra' } },
|
|
45
53
|
{ alias: 'opus', label: 'Claude Opus-class', blurb: 'deep analysis',
|
|
46
54
|
vendorPath: 'anthropic',
|
|
47
55
|
idPattern: /^claude-opus-[\d.-]+$/,
|
|
48
56
|
directProviders: ['anthropic'],
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
// claude-opus-5 has no dotted version segment, so the two forms coincide —
|
|
58
|
+
// the anthropic: route is still AUTHORED (DIVERGENT_VENDORS), never derived.
|
|
59
|
+
// Direct id verified against Anthropic docs 2026-08-04.
|
|
60
|
+
fallback: { openrouter: 'openrouter/anthropic/claude-opus-5',
|
|
61
|
+
anthropic: 'anthropic/claude-opus-5' } },
|
|
51
62
|
{ alias: 'deepseek', label: 'DeepSeek flagship', blurb: 'open-source',
|
|
52
63
|
vendorPath: 'deepseek',
|
|
53
64
|
idPattern: /^deepseek-v[\d.]+(-pro)?$/,
|
|
@@ -57,11 +68,23 @@ const FAMILIES = [
|
|
|
57
68
|
];
|
|
58
69
|
|
|
59
70
|
/**
|
|
60
|
-
* Alias-only entries (no wizard quick pick)
|
|
61
|
-
*
|
|
71
|
+
* Alias-only entries (no wizard quick pick). Every entry authors an
|
|
72
|
+
* openrouter route; entries whose vendor's direct API genuinely serves the
|
|
73
|
+
* model also author a direct route (claude/sonnet/haiku/fable).
|
|
74
|
+
* Refreshed against the live catalog 2026-08-04.
|
|
62
75
|
*/
|
|
63
76
|
const CARDLESS = [
|
|
64
|
-
|
|
77
|
+
// gpt-pro: the 5.6 premium (sol) tier's pro sibling, priced at its base
|
|
78
|
+
// tier ($5/$30 per Mtok). Owner ruling 2026-08-04: retargeted off
|
|
79
|
+
// gpt-5.5-pro ($30/$180 — still served, but expected to sunset with the
|
|
80
|
+
// 5.5 line). `gpt-pro` tracks SOL while the `gpt` family tracks terra —
|
|
81
|
+
// see the tier-semantics comment on the `gpt` family above.
|
|
82
|
+
// gatewayOnly (owner ruling 2026-08-05, recorded in the v4.6.3 spec): the
|
|
83
|
+
// openrouter-only route is a deliberate routing choice — OpenAI's direct
|
|
84
|
+
// namespace does not serve gpt-5.6-sol-pro, so the DERIVED direct form
|
|
85
|
+
// must never be audited as stale and no direct pairing may be suggested.
|
|
86
|
+
{ alias: 'gpt-pro', gatewayOnly: true,
|
|
87
|
+
routes: { openrouter: 'openrouter/openai/gpt-5.6-sol-pro' } },
|
|
65
88
|
// codex: newest codex-specific model on OpenRouter (verified 2026-06-09).
|
|
66
89
|
{ alias: 'codex', routes: { openrouter: 'openrouter/openai/gpt-5.3-codex' } },
|
|
67
90
|
{ alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-5',
|
|
@@ -70,12 +93,19 @@ const CARDLESS = [
|
|
|
70
93
|
anthropic: 'anthropic/claude-sonnet-5' } },
|
|
71
94
|
{ alias: 'haiku', routes: { openrouter: 'openrouter/anthropic/claude-haiku-4.5',
|
|
72
95
|
anthropic: 'anthropic/claude-haiku-4-5-20251001' } },
|
|
73
|
-
|
|
96
|
+
// fable: direct route authored 2026-08-05 (owner ruling R2, v4.6.3 spec §3).
|
|
97
|
+
// Anthropic's /v1/models lists claude-fable-5 AND the direct route serves
|
|
98
|
+
// (live smoke wave 47278069) — the entry was OpenRouter-only at authoring.
|
|
99
|
+
{ alias: 'fable', routes: { openrouter: 'openrouter/anthropic/claude-fable-5',
|
|
100
|
+
anthropic: 'anthropic/claude-fable-5' } },
|
|
74
101
|
{ alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.7-max' } },
|
|
75
102
|
{ alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
|
|
76
103
|
{ alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
|
|
77
104
|
{ alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-medium-3-5' } },
|
|
78
|
-
|
|
105
|
+
// devstral was dropped 2026-08-04 (owner ruling): OpenRouter delisted the
|
|
106
|
+
// whole devstral family and the alias had no other route. No retarget — no
|
|
107
|
+
// served model is a devstral successor ("no pinned guess is better than a
|
|
108
|
+
// wrong one"); `mistral` remains the vendor's alias.
|
|
79
109
|
{ alias: 'glm', routes: { openrouter: 'openrouter/z-ai/glm-5.1' } },
|
|
80
110
|
{ alias: 'minimax', routes: { openrouter: 'openrouter/minimax/minimax-m2.7' } },
|
|
81
111
|
{ alias: 'grok', routes: { openrouter: 'openrouter/x-ai/grok-4.3' } },
|
|
@@ -141,7 +171,8 @@ function listCuratedRoutes() {
|
|
|
141
171
|
* Vendors whose direct-API ids differ from OpenRouter's (dot vs. dash
|
|
142
172
|
* versioning, distinct model names, etc.). NEVER derive a direct form for
|
|
143
173
|
* these — derivation would emit the wrong (dot) id, or invent a direct id
|
|
144
|
-
* for a model
|
|
174
|
+
* for a model the direct API does not serve (fable was that case until its
|
|
175
|
+
* direct route was verified and authored, 2026-08-05).
|
|
145
176
|
* Frozen so consumers can only read it (`.has()`) — a frozen Set still
|
|
146
177
|
* supports lookups, it just can't be `.add()`/`.delete()`/`.clear()`-ed.
|
|
147
178
|
*/
|
|
@@ -181,6 +212,28 @@ function gatewayRoutesFor(vendorPath, obj) {
|
|
|
181
212
|
return routes;
|
|
182
213
|
}
|
|
183
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Per-alias provenance of the `direct` form in toGatewayRoutes(), for the
|
|
217
|
+
* auditors (alias-audit.js / gateway-route-audit.js): an AUTHORED direct
|
|
218
|
+
* form absent from its namespace is stale; a DERIVED one is a computed
|
|
219
|
+
* convenience whose absence is a routing fact, not staleness, while the
|
|
220
|
+
* authoring openrouter route is live. `gatewayOnly` mirrors an entry's
|
|
221
|
+
* explicit routing-choice annotation (owner-ruled): suppress derived-form
|
|
222
|
+
* findings unconditionally and never suggest a direct pairing.
|
|
223
|
+
* @returns {Object<string, {directForm: 'authored'|'derived'|'none', gatewayOnly: boolean}>}
|
|
224
|
+
*/
|
|
225
|
+
function directFormProvenance() {
|
|
226
|
+
const out = {};
|
|
227
|
+
const entryProv = (vendorPath, obj, gatewayOnly) => {
|
|
228
|
+
const direct = directFormFor(vendorPath, obj);
|
|
229
|
+
const directForm = !direct ? 'none' : (obj[vendorPath] ? 'authored' : 'derived');
|
|
230
|
+
return { directForm, gatewayOnly: gatewayOnly === true };
|
|
231
|
+
};
|
|
232
|
+
for (const f of FAMILIES) { out[f.alias] = entryProv(f.vendorPath, f.fallback, f.gatewayOnly); }
|
|
233
|
+
for (const e of CARDLESS) { out[e.alias] = entryProv(vendorOf(e.routes.openrouter), e.routes, e.gatewayOnly); }
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
184
237
|
/**
|
|
185
238
|
* @returns {Object<string,{direct?: string, openrouter: string}>} alias →
|
|
186
239
|
* per-gateway executable ids. Unlike `toDefaultAliases` (a single pinned
|
|
@@ -204,8 +257,9 @@ function toGatewayRoutes() {
|
|
|
204
257
|
* disagree. It previously string-stripped `openrouter/` itself, which emitted
|
|
205
258
|
* OpenRouter's dot ids for divergent vendors (`anthropic/claude-opus-4.8` —
|
|
206
259
|
* the direct API only serves the dash form) and invented a bare direct id for
|
|
207
|
-
* OpenRouter-only models (`fable
|
|
208
|
-
* models --check` warn about the product's own
|
|
260
|
+
* then-OpenRouter-only models (`fable`, direct-authored 2026-08-05). Both made
|
|
261
|
+
* `amicus doctor` and `amicus models --check` warn about the product's own
|
|
262
|
+
* shipped defaults.
|
|
209
263
|
*/
|
|
210
264
|
function toDefaultAliases() {
|
|
211
265
|
const out = {};
|
|
@@ -216,5 +270,6 @@ function toDefaultAliases() {
|
|
|
216
270
|
}
|
|
217
271
|
|
|
218
272
|
module.exports = {
|
|
219
|
-
getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes,
|
|
273
|
+
getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes,
|
|
274
|
+
directFormProvenance, DIVERGENT_VENDORS
|
|
220
275
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module doctor-base-url-check
|
|
3
|
+
* v4.6.2 PR1 (spec §4): the 'anthropic-base-url' doctor row.
|
|
4
|
+
*
|
|
5
|
+
* VERIFIABLE voice (BACKLOG ruling): states only what it string-inspected.
|
|
6
|
+
* It always prints the value the process SEES — the var can live ONLY in a
|
|
7
|
+
* parent process env (the field case: set in the Claude Code app process,
|
|
8
|
+
* absent from every persisted scope on disk), so the seen value IS the
|
|
9
|
+
* diagnostic; "where it is set" may be unfindable.
|
|
10
|
+
*/
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { classifyBaseUrl } = require('./base-url-classify');
|
|
14
|
+
|
|
15
|
+
/** @param {{env?:NodeJS.ProcessEnv}} [d] @returns {{id,name,status,message,hint}} */
|
|
16
|
+
function evaluateAnthropicBaseUrl(d = {}) {
|
|
17
|
+
const id = 'anthropic-base-url'; const name = 'ANTHROPIC_BASE_URL';
|
|
18
|
+
const env = d.env || process.env;
|
|
19
|
+
const value = env.ANTHROPIC_BASE_URL;
|
|
20
|
+
const { form, normalized } = classifyBaseUrl(value);
|
|
21
|
+
if (form === 'absent') {
|
|
22
|
+
return { id, name, status: 'ok', message: 'not set', hint: null };
|
|
23
|
+
}
|
|
24
|
+
if (form === 'v1') {
|
|
25
|
+
return { id, name, status: 'ok', message: `${value} (full-prefix form)`, hint: null };
|
|
26
|
+
}
|
|
27
|
+
if (form === 'host') {
|
|
28
|
+
const disabled = env.AMICUS_BASE_URL_NORMALIZE === '0';
|
|
29
|
+
const treatment = disabled
|
|
30
|
+
? 'normalization is disabled (AMICUS_BASE_URL_NORMALIZE=0) — direct-anthropic legs will 404'
|
|
31
|
+
: `amicus passes ${normalized} to the engine`;
|
|
32
|
+
return {
|
|
33
|
+
id, name, status: 'warn',
|
|
34
|
+
message: `host-form: ${value} — Anthropic SDKs append /v1; OpenCode treats it as the full prefix; ${treatment}`,
|
|
35
|
+
hint: disabled ? `set ANTHROPIC_BASE_URL=${normalized} (or unset AMICUS_BASE_URL_NORMALIZE)` : null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { id, name, status: 'ok', message: `${value} (nonstandard path — passed through unchanged)`, hint: null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { evaluateAnthropicBaseUrl };
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
'use strict';
|
|
32
32
|
|
|
33
|
-
const { toGatewayRoutes } = require('./curated-models');
|
|
33
|
+
const { toGatewayRoutes, directFormProvenance } = require('./curated-models');
|
|
34
34
|
const { classifyModel } = require('./model-classification');
|
|
35
35
|
const { pairAcrossGateways } = require('./gateway-route-catalog');
|
|
36
36
|
const { isDirectProvider } = require('./provider-registry');
|
|
@@ -70,19 +70,32 @@ function isAuthoritative(catalogInfo, id) {
|
|
|
70
70
|
*/
|
|
71
71
|
function auditGatewayRoutes(catalogInfo) {
|
|
72
72
|
const routes = toGatewayRoutes();
|
|
73
|
+
const provenance = directFormProvenance();
|
|
73
74
|
const findings = [];
|
|
74
75
|
|
|
75
76
|
for (const [alias, forms] of Object.entries(routes)) {
|
|
77
|
+
const prov = provenance[alias] || { directForm: 'none', gatewayOnly: false };
|
|
76
78
|
for (const gateway of ['direct', 'openrouter']) {
|
|
77
79
|
const id = forms[gateway];
|
|
78
80
|
if (!id) { continue; }
|
|
79
|
-
if (classifyModel(id, gateway, catalogInfo)
|
|
80
|
-
|
|
81
|
+
if (classifyModel(id, gateway, catalogInfo) !== 'invalid') { continue; }
|
|
82
|
+
// v4.6.3 PR1 (spec D2): a DERIVED direct form is a computed convenience,
|
|
83
|
+
// not an authored claim. Its absence from the direct namespace is a
|
|
84
|
+
// routing fact — not staleness — while the authoring openrouter route
|
|
85
|
+
// is live, or when the entry declares gatewayOnly (an owner-ruled
|
|
86
|
+
// routing choice). An AUTHORED direct form absent from its namespace
|
|
87
|
+
// reports exactly as before.
|
|
88
|
+
if (gateway === 'direct' && prov.directForm === 'derived' &&
|
|
89
|
+
(prov.gatewayOnly ||
|
|
90
|
+
classifyModel(forms.openrouter, 'openrouter', catalogInfo) === 'valid')) {
|
|
91
|
+
continue;
|
|
81
92
|
}
|
|
93
|
+
findings.push({ alias, gateway, kind: 'stale', model: id });
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
const vendor = vendorOf(forms.openrouter);
|
|
85
97
|
if (!vendor || !isDirectProvider(vendor)) { continue; } // gateway-only vendor: no direct route ever possible
|
|
98
|
+
if (prov.gatewayOnly) { continue; } // declared routing choice: never suggest a direct pairing
|
|
86
99
|
const token = bareSegment(forms.openrouter, vendor);
|
|
87
100
|
if (!token) { continue; }
|
|
88
101
|
const paired = pairAcrossGateways(vendor, token, catalogInfo); // Task-5 contract: bare segment only
|
|
@@ -8,16 +8,19 @@
|
|
|
8
8
|
const https = require('https');
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Hardcoded Anthropic
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* Hardcoded Anthropic floor: the anthropic/ rows a KEYLESS user (or a
|
|
12
|
+
* failed live fetch) gets. Every id here must be one the direct API
|
|
13
|
+
* GENUINELY serves — classifyModel() returns 'valid' on a floor HIT before
|
|
14
|
+
* it ever checks `authoritative`, so a speculative row would mislabel a
|
|
15
|
+
* dead direct-API request as valid. (fable joined 2026-08-05 after live
|
|
16
|
+
* verification — /v1/models lists claude-fable-5 and a direct smoke leg
|
|
17
|
+
* served; v4.6.3 spec §3.)
|
|
17
18
|
*/
|
|
18
19
|
const ANTHROPIC_MODELS = [
|
|
20
|
+
{ id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', contextLength: null, pricing: null },
|
|
19
21
|
{ id: 'anthropic/claude-opus-4-8', name: 'Claude Opus 4.8', contextLength: null, pricing: null },
|
|
20
22
|
{ id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', contextLength: null, pricing: null },
|
|
23
|
+
{ id: 'anthropic/claude-fable-5', name: 'Claude Fable 5', contextLength: null, pricing: null },
|
|
21
24
|
{ id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
|
|
22
25
|
// Dated snapshot: the id Anthropic's /v1/models actually lists, and the
|
|
23
26
|
// `haiku` direct route curated-models.js authors. Without it the floor
|