amicus 4.6.1 → 4.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +2 -2
  4. package/docs/ROADMAP.md +7 -2
  5. package/docs/configuration.md +13 -9
  6. package/docs/council.md +7 -2
  7. package/docs/troubleshooting.md +49 -20
  8. package/docs/usage.md +21 -1
  9. package/electron/setup-ui-aliases.js +2 -2
  10. package/electron/workspace-ui/index.html +3 -0
  11. package/electron/workspace-ui/live-model.js +71 -0
  12. package/electron/workspace-ui/workspace-app.js +2 -2
  13. package/electron/workspace-ui/workspace-panels.js +9 -10
  14. package/electron/workspace-ui/workspace-seats.js +117 -0
  15. package/electron/workspace-ui/workspace-verbs.js +1 -0
  16. package/electron/workspace-ui/workspace.css +6 -0
  17. package/package.json +1 -1
  18. package/schemas/alias-audit.schema.json +6 -1
  19. package/schemas/council-run.schema.json +14 -0
  20. package/src/cli-handlers-doctor.js +16 -4
  21. package/src/cli.js +4 -0
  22. package/src/council/run-chair.js +49 -3
  23. package/src/headless.js +119 -9
  24. package/src/mcp-council-awareness.js +1 -0
  25. package/src/opencode-client.js +21 -0
  26. package/src/sidecar/fanout-leg.js +2 -2
  27. package/src/sidecar/fanout.js +1 -1
  28. package/src/sidecar/models-probe.js +119 -0
  29. package/src/sidecar/models.js +81 -6
  30. package/src/utils/alias-audit.js +52 -1
  31. package/src/utils/base-url-classify.js +74 -0
  32. package/src/utils/council-presets.js +6 -2
  33. package/src/utils/curated-models.js +29 -10
  34. package/src/utils/doctor-base-url-check.js +41 -0
  35. package/src/utils/model-fetcher.js +1 -0
  36. package/src/utils/model-tiers.js +28 -7
  37. package/src/utils/no-output-backstop.js +48 -0
  38. package/src/utils/result-schema.js +29 -2
  39. package/src/workspace/live-normalize.js +1 -0
@@ -0,0 +1,119 @@
1
+ // src/sidecar/models-probe.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module models-probe
6
+ * v4.6.2 PR3 (spec §6, D5): `models --check --live` probe tier. Presence in
7
+ * the catalog is not proof of service — a stored alias can point at a model
8
+ * id the catalog still lists but the provider no longer actually serves (the
9
+ * v4.6.1 `gemini` incident: stored `google/gemini-3.1-flash-lite-preview`,
10
+ * catalog-live, silently dead). This module is the check that would have
11
+ * caught it: probe every STORED alias with one ordinary engine leg — real
12
+ * session dir, real spend-ledger row (D5) — on a single quiet fanout wave,
13
+ * and classify each leg served / accepted-but-silent / error.
14
+ *
15
+ * Never called without `--live`; the spend gate lives in the CLI layer
16
+ * (src/sidecar/models.js), not here — this module always spends when called.
17
+ */
18
+
19
+ /** Probe backstop override (spec D5) — a fixed constant, NOT env-configurable;
20
+ * the env knob (AMICUS_NO_OUTPUT_BACKSTOP_MS) stays the ordinary 120s leg default. */
21
+ const PROBE_WINDOW_MS = 30000;
22
+
23
+ /** Fixed tiny prompt — a probe leg only needs to prove the model answers at all. */
24
+ const PROBE_PROMPT = 'Reply with exactly: OK';
25
+
26
+ /**
27
+ * Classify one leg run-document (buildRunResult shape, src/utils/result-
28
+ * schema.js) per the plan's Global Constraints classification contract.
29
+ * Precedence matters: 'complete' wins outright; otherwise a NO_OUTPUT_
30
+ * BACKSTOP error (PR2's silent-leg detector, armed here at PROBE_WINDOW_MS
31
+ * instead of its 120s default) is the one specific error shape that means
32
+ * "the model accepted the request and never produced a token" rather than an
33
+ * ordinary routing/auth/timeout failure.
34
+ * @param {{status?:string, error?:string|null}} leg
35
+ * @returns {'served'|'accepted-but-silent'|'error'}
36
+ */
37
+ function classifyLeg(leg) {
38
+ if (leg.status === 'complete') { return 'served'; }
39
+ if (typeof leg.error === 'string' && /^NO_OUTPUT_BACKSTOP:/.test(leg.error)) { return 'accepted-but-silent'; }
40
+ return 'error';
41
+ }
42
+
43
+ /**
44
+ * Stored (user-config) aliases only — the `--live` probe's scope (spec §6):
45
+ * defaults/curated-route rows follow the catalog by construction and have no
46
+ * "was it actually served" question for a live probe to answer. Exported so
47
+ * the CLI's cap pre-check (models.js) and this module share one predicate.
48
+ * @param {Array<{source:string}>} sources collectAliasSources() output
49
+ * @returns {Array<{alias:string,model:string,source:string}>}
50
+ */
51
+ function selectStoredAliases(sources) {
52
+ return sources.filter(s => s.source === 'user-config');
53
+ }
54
+
55
+ /**
56
+ * Probe every STORED alias with one ordinary engine leg (real session dirs,
57
+ * real spend rows — D5) on one quiet fanout wave. Returns per-alias outcomes;
58
+ * never called without --live (the spend gate lives in the CLI layer).
59
+ * @param {{project?:string}} opts
60
+ * @param {{runFanout?:Function, collectAliasSources?:Function}} [deps]
61
+ * @returns {Promise<{results:Array<{alias:string,target:string,outcome:'served'|'accepted-but-silent'|'error',detail:string|null,cost:number|null}>, waveId:string|null}>}
62
+ */
63
+ async function probeStoredAliases(opts = {}, deps = {}) {
64
+ const collectAliasSources = deps.collectAliasSources || require('../utils/alias-audit').collectAliasSources;
65
+ const runFanout = deps.runFanout || require('./fanout').runFanout;
66
+
67
+ const stored = selectStoredAliases(collectAliasSources());
68
+ if (stored.length === 0) { return { results: [], waveId: null }; }
69
+
70
+ // runFanout's `models` is the same comma-separated STRING the CLI --models
71
+ // flag takes (validateFanoutModels -> parseModelsList splits it back apart)
72
+ // — NOT an array; see council/run-launch.js's launchWave for the identical
73
+ // `.join(',')` seam. An array here would parse to [] and fail the whole
74
+ // wave with BAD_ARGS.
75
+ const { wave, errorDoc } = await runFanout({
76
+ models: stored.map(s => s.model).join(','),
77
+ prompt: PROBE_PROMPT,
78
+ quiet: true,
79
+ noOutputBackstopMs: PROBE_WINDOW_MS,
80
+ timeout: 2, // minutes — the overall ceiling behind the backstop
81
+ project: opts.project,
82
+ });
83
+
84
+ // Final-review blocker 1: two classes of wave never reach leg-creation at
85
+ // all — the budget preflight refusing before any session exists (failPre ->
86
+ // {wave: null, errorDoc}) or the shared server failing to start (errorWave ->
87
+ // {legs: [], error: message}) — so EVERY stored alias would otherwise hit
88
+ // the `legs[i] || {}` fallback and fabricate a generic `detail: null` row,
89
+ // which the CLI's `${head} — ${r.detail}` template renders as the literal
90
+ // string "null", masking the real reason (worse with `quiet: true`, which
91
+ // suppresses every other print this failure would normally surface on).
92
+ // Both failure classes carry a real message; thread it onto every row that
93
+ // has no leg of its own to explain itself.
94
+ const waveFailure = errorDoc ? errorDoc.message : ((wave && wave.error) || null);
95
+
96
+ // Positional zip, not a model-id lookup: deriveLegIds (fanout.js) assigns
97
+ // legs 1:1 in --models order, and two stored aliases may legitimately share
98
+ // one target model, so a leg's own identity can't disambiguate which alias
99
+ // it answers for — only its index can.
100
+ const legs = (wave && wave.legs) || [];
101
+ const results = stored.map((s, i) => {
102
+ const leg = legs[i] || {};
103
+ const outcome = classifyLeg(leg);
104
+ const cost = (leg.usage && leg.usage.cost && typeof leg.usage.cost.amount === 'number')
105
+ ? leg.usage.cost.amount
106
+ : null;
107
+ return {
108
+ alias: s.alias,
109
+ target: s.model,
110
+ outcome,
111
+ detail: leg.error || waveFailure || null,
112
+ cost,
113
+ };
114
+ });
115
+
116
+ return { results, waveId: (wave && wave.waveId) || null };
117
+ }
118
+
119
+ module.exports = { probeStoredAliases, selectStoredAliases, PROBE_WINDOW_MS, PROBE_PROMPT };
@@ -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
- const exitCode = args.strict
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);
@@ -108,4 +108,55 @@ function suggestReplacements(staleModel, catalog, n = 3) {
108
108
  .slice(0, n);
109
109
  }
110
110
 
111
- module.exports = { collectAliasSources, findStaleAliases, suggestReplacements };
111
+ /**
112
+ * Stored aliases whose target is LIVE in the catalog but no longer what a
113
+ * fresh `amicus setup` would seed today — the v4.6.1 release-gate class
114
+ * (stored `gemini` -> 3.1-flash-lite-preview: still catalog-listed so
115
+ * findStaleAliases passes it, no longer what the family resolves to).
116
+ * Report + suggest, never auto-repair (this module's charter).
117
+ *
118
+ * Only user-config rows are checked (defaults/curated follow the catalog by
119
+ * construction), only for aliases that are quick-pick families (a custom
120
+ * alias has no "current" to drift from), and only when the stored target is
121
+ * itself catalog-live (a dead target is findStaleAliases's finding, not
122
+ * ours). The `current` display value goes through toStorableRoute() — the
123
+ * guarded 4.1.2 helper — never a bare prefix strip (spec D3).
124
+ *
125
+ * Drift membership, however, is NOT a raw compare against that single
126
+ * canonicalized display string. toStorableRoute() canonicalizes a
127
+ * direct-capable vendor's OpenRouter pick down to the bare direct form
128
+ * (e.g. 'google/gemini-3.6-flash'), but a stored alias may legitimately hold
129
+ * the gateway-prefixed form of that SAME model ('openrouter/google/gemini-
130
+ * 3.6-flash' — the exact route pickCurrent/resolveQuickPicks resolves live,
131
+ * and what a STALE fix's own suggestion may have pointed a user to store).
132
+ * Comparing only against the canonicalized string would false-positive that
133
+ * as drift. Instead, a stored row only counts as drift when its model is
134
+ * absent from the family's FULL live route-value set (every value in that
135
+ * family's `routes` map — openrouter form and any direct form together, per
136
+ * resolveQuickPicks) — i.e. it names a genuinely different model, not the
137
+ * same model under a different gateway form.
138
+ * @param {Array<{alias:string,model:string,source:string}>} sources
139
+ * @param {Array<{id:string}>} catalog
140
+ * @returns {Array<{alias:string,stored:string,current:string}>}
141
+ */
142
+ function findDriftedStoredAliases(sources, catalog) {
143
+ if (!catalog || catalog.length === 0) { return []; }
144
+ const { resolveQuickPicks, toStorableRoute } = require('./quick-picks');
145
+ const current = new Map();
146
+ for (const r of resolveQuickPicks(catalog)) {
147
+ if (r.source !== 'live') { continue; }
148
+ const stored = toStorableRoute(r);
149
+ if (stored) { current.set(r.alias, { display: stored, routeValues: new Set(Object.values(r.routes)) }); }
150
+ }
151
+ const byProvider = idsByProvider(catalog);
152
+ return sources
153
+ .filter(({ source }) => source === 'user-config')
154
+ .filter(({ model }) => {
155
+ const ids = byProvider.get(model.split('/')[0]);
156
+ return !!(ids && ids.has(model));
157
+ })
158
+ .filter(({ alias, model }) => current.has(alias) && !current.get(alias).routeValues.has(model))
159
+ .map(({ alias, model }) => ({ alias, stored: model, current: current.get(alias).display }));
160
+ }
161
+
162
+ 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.5-pro $0.00003 / $0.00018
46
- * opus openrouter/anthropic/claude-opus-4.8 $0.000005 / $0.000025
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 any plain numeric flagship id
25
- * (gpt-5.5, gpt-6) and excludes suffixed variants (-pro/-mini/-codex).
26
- * Pinned ids verified against the live catalog 2026-06-24.
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.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
- fallback: { openrouter: 'openrouter/anthropic/claude-opus-4.8',
50
- anthropic: 'anthropic/claude-opus-4-8' } },
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)?$/,
@@ -58,10 +69,15 @@ const FAMILIES = [
58
69
 
59
70
  /**
60
71
  * Alias-only entries (no wizard quick pick); openrouter route only.
61
- * Refreshed against the live catalog 2026-06-11.
72
+ * Refreshed against the live catalog 2026-08-04.
62
73
  */
63
74
  const CARDLESS = [
64
- { alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.5-pro' } },
75
+ // gpt-pro: the 5.6 premium (sol) tier's pro sibling, priced at its base
76
+ // tier ($5/$30 per Mtok). Owner ruling 2026-08-04: retargeted off
77
+ // gpt-5.5-pro ($30/$180 — still served, but expected to sunset with the
78
+ // 5.5 line). `gpt-pro` tracks SOL while the `gpt` family tracks terra —
79
+ // see the tier-semantics comment on the `gpt` family above.
80
+ { alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.6-sol-pro' } },
65
81
  // codex: newest codex-specific model on OpenRouter (verified 2026-06-09).
66
82
  { alias: 'codex', routes: { openrouter: 'openrouter/openai/gpt-5.3-codex' } },
67
83
  { alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-5',
@@ -75,7 +91,10 @@ const CARDLESS = [
75
91
  { alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
76
92
  { alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
77
93
  { alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-medium-3-5' } },
78
- { alias: 'devstral', routes: { openrouter: 'openrouter/mistralai/devstral-2512' } },
94
+ // devstral was dropped 2026-08-04 (owner ruling): OpenRouter delisted the
95
+ // whole devstral family and the alias had no other route. No retarget — no
96
+ // served model is a devstral successor ("no pinned guess is better than a
97
+ // wrong one"); `mistral` remains the vendor's alias.
79
98
  { alias: 'glm', routes: { openrouter: 'openrouter/z-ai/glm-5.1' } },
80
99
  { alias: 'minimax', routes: { openrouter: 'openrouter/minimax/minimax-m2.7' } },
81
100
  { alias: 'grok', routes: { openrouter: 'openrouter/x-ai/grok-4.3' } },
@@ -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 };
@@ -16,6 +16,7 @@ const https = require('https');
16
16
  * would mislabel a direct-API request for it as valid.
17
17
  */
18
18
  const ANTHROPIC_MODELS = [
19
+ { id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', contextLength: null, pricing: null },
19
20
  { id: 'anthropic/claude-opus-4-8', name: 'Claude Opus 4.8', contextLength: null, pricing: null },
20
21
  { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', contextLength: null, pricing: null },
21
22
  { id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
@@ -28,7 +28,13 @@ const { pickCurrent } = require('./quick-picks');
28
28
  const { isDirectProvider } = require('./provider-registry');
29
29
  const { toDefaultAliases, listCuratedRoutes } = require('./curated-models');
30
30
 
31
- /** Tier regex table: pattern matches the model segment after `<vendor>/` (or `openrouter/<vendor>/`). */
31
+ /**
32
+ * Tier pattern table: each tier holds a regex — or an ORDERED regex list,
33
+ * tried first-match-wins — over the model segment after `<vendor>/` (or
34
+ * `openrouter/<vendor>/`). List order expresses preference, which a single
35
+ * regex cannot: ids sort numeric-descending, so `-pro` siblings would
36
+ * otherwise outrank their same-priced base.
37
+ */
32
38
  const TIERS = {
33
39
  anthropic: {
34
40
  economy: /^claude-haiku-/,
@@ -36,9 +42,17 @@ const TIERS = {
36
42
  frontier: /^claude-opus-/,
37
43
  },
38
44
  openai: {
39
- economy: /^gpt-[\d.]+-mini$/,
40
- balanced: /^gpt-[\d.]+$/,
41
- frontier: /^gpt-[\d.]+-pro$/,
45
+ // The 5.6 line renamed the flagship into tiers — luna ($0.10/$0.60 per
46
+ // Mtok in/out), terra ($1/$6), sol ($5/$30), each with a -pro sibling
47
+ // priced at its base tier; no bare/-mini/-pro 5.6 ids exist. Owner
48
+ // ruling 2026-08-04: economy→luna, balanced→terra, frontier→sol, each
49
+ // preferring the base name, then its -pro sibling, then the 5.5-era
50
+ // naming so a stale catalog still resolves. balanced's primary is the
51
+ // same bare-or-terra pattern the `gpt` family uses (curated-models.js),
52
+ // so a future return to bare flagship ids is tracked automatically.
53
+ economy: [/^gpt-[\d.]+-luna$/, /^gpt-[\d.]+-luna-pro$/, /^gpt-[\d.]+-mini$/],
54
+ balanced: [/^gpt-[\d.]+(-terra)?$/, /^gpt-[\d.]+-terra-pro$/],
55
+ frontier: [/^gpt-[\d.]+-sol$/, /^gpt-[\d.]+-sol-pro$/, /^gpt-[\d.]+-pro$/],
42
56
  },
43
57
  google: {
44
58
  economy: /^gemini-[\d.]+-flash-lite/,
@@ -77,9 +91,16 @@ function buildGatewayOnlyAliasMap() {
77
91
 
78
92
  const GATEWAY_ONLY_ALIAS = buildGatewayOnlyAliasMap();
79
93
 
80
- /** Newest catalog id matching `regex` under `vendor`; direct namespace preferred over OpenRouter's. */
81
- function pickForTier(catalog, vendor, regex) {
82
- return pickCurrent(catalog, '', vendor, regex) || pickCurrent(catalog, 'openrouter/', vendor, regex);
94
+ /**
95
+ * First match wins over a tier's ordered patterns; within each pattern the
96
+ * newest matching id is picked, direct namespace preferred over OpenRouter's.
97
+ */
98
+ function pickForTier(catalog, vendor, patterns) {
99
+ for (const regex of [].concat(patterns)) {
100
+ const pick = pickCurrent(catalog, '', vendor, regex) || pickCurrent(catalog, 'openrouter/', vendor, regex);
101
+ if (pick) { return pick; }
102
+ }
103
+ return null;
83
104
  }
84
105
 
85
106
  /** True when the catalog has ANY row (any tier) under this vendor's namespace, in either gateway. */