amicus 3.1.0 → 3.1.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [3.1.1] - 2026-07-16
9
+
10
+ ### Fixed
11
+
12
+ - **Anthropic model aliases now route correctly for direct-Anthropic-key users.** `--model opus` /
13
+ `haiku` / `claude` / `sonnet` previously resolved to OpenRouter's dot-form id (e.g.
14
+ `anthropic/claude-opus-4.8`), which the direct Anthropic API rejects with `model_not_found` (it uses
15
+ dashes/date suffixes: `claude-opus-4-8`, `claude-haiku-4-5-20251001`). Aliases now carry per-gateway
16
+ executable ids and the router emits the selected gateway's native id. OpenRouter-only users were
17
+ unaffected.
18
+
19
+ ### Changed
20
+
21
+ - `--model claude` / `--model sonnet` default target moves from Claude Sonnet 4.6 to **Claude Sonnet
22
+ 5**; the offline model floor was refreshed to the current Anthropic family.
23
+ - Availability-aware routing: a model not served on the selected gateway (e.g. Fable, which is
24
+ OpenRouter-only) routes to the gateway that has it, or errors clearly under an explicit `--gateway`.
25
+
26
+ ### Added
27
+
28
+ - `amicus models --check --strict` exits non-zero on curated default-alias drift; a scheduled
29
+ `model-drift` CI workflow audits the per-gateway ids against the live (keyless) OpenRouter catalog.
30
+
8
31
  ## [3.1.0] - 2026-07-15
9
32
 
10
33
  ### Added
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **A multi-model LLM Council for Claude — with a parallel AI window underneath.**
6
6
 
7
- ![The Amicus council mid-ritual: five models — Gemini 3 Pro, Llama 4, Grok 4, Claude Opus — reading the same material independently, chaired by GPT-5](./docs/council.png)
7
+ ![The Amicus council mid-ritual: five models — Gemini, Llama, Grok, Claude Opus — reading the same material independently, chaired by GPT](./docs/council.png)
8
8
 
9
9
  Hand Claude a plan, a design, a diff, an architecture decision, a manuscript — anything — and say *council review this*: Amicus routes it through several models from different families, has them anonymously cross-review each other, and a non-Claude chair synthesizes a verdict you turn into accept/deny edits. Or skip the ceremony and **fork** a single conversation to Gemini, GPT, DeepSeek, or any other model — it works in parallel with full context, and you **fold** the result back when you're ready. Claude orchestrates throughout; you stay in your editor.
10
10
 
@@ -319,7 +319,7 @@ $ amicus status demo123 --json
319
319
  "taskId": "demo123",
320
320
  "status": "complete",
321
321
  "elapsed": "5m 0s",
322
- "version": "3.1.0",
322
+ "version": "3.1.1",
323
323
  "model": "google/gemini-2.5-flash",
324
324
  "phase": "terminal"
325
325
  }
@@ -9,7 +9,7 @@
9
9
  const ALIAS_GROUPS = [
10
10
  { name: 'Gemini', keys: ['gemini', 'gemini-pro'] },
11
11
  { name: 'GPT', keys: ['gpt', 'gpt-pro', 'codex'] },
12
- { name: 'Claude', keys: ['claude', 'sonnet', 'opus', 'haiku'] },
12
+ { name: 'Claude', keys: ['claude', 'sonnet', 'opus', 'haiku', 'fable'] },
13
13
  { name: 'DeepSeek', keys: ['deepseek'] },
14
14
  { name: 'Qwen', keys: ['qwen', 'qwen-coder', 'qwen-flash'] },
15
15
  { name: 'Mistral', keys: ['mistral', 'devstral'] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -59,6 +59,7 @@
59
59
  "refresh-models": "node bin/amicus.js models --refresh",
60
60
  "models:info": "node bin/amicus.js models",
61
61
  "models:check": "node bin/amicus.js models --check",
62
+ "models:check:strict": "node bin/amicus.js models --check --strict",
62
63
  "generate-icon": "node scripts/generate-icon.js",
63
64
  "generate-docs": "node scripts/generate-docs.js",
64
65
  "generate-docs:check": "node scripts/generate-docs.js --check",
package/src/cli.js CHANGED
@@ -138,6 +138,7 @@ function isBooleanFlag(key) {
138
138
  'html', // council report: emit a self-contained HTML page
139
139
  'md', // council report: emit Markdown (default)
140
140
  'fix', // doctor: self-heal fixable checks in place (#56)
141
+ 'strict', // models --check: exit non-zero on curated per-gateway drift (#gwid Task 6)
141
142
  ];
142
143
  return booleanFlags.includes(key);
143
144
  }
@@ -436,6 +437,9 @@ Options for 'models':
436
437
  --search <q> Filter by substring over model id and name
437
438
  --refresh Force-refresh the catalog from provider APIs
438
439
  --check Audit aliases against the catalog (exit = stale count)
440
+ --strict With --check: also exit non-zero on curated
441
+ per-gateway drift (stale/divergent direct or
442
+ openrouter forms). Informational without it.
439
443
  --json Machine-readable output
440
444
  `,
441
445
  list: `
@@ -14,6 +14,7 @@
14
14
 
15
15
  const { getCatalogInfo, refreshCatalog, catalogPath } = require('../utils/model-catalog');
16
16
  const { collectAliasSources, findStaleAliases, suggestReplacements } = require('../utils/alias-audit');
17
+ const { auditGatewayRoutes } = require('../utils/gateway-route-audit');
17
18
  const { buildCatalogDoc, buildAuditDoc } = require('../utils/result-schema');
18
19
  const { getFamilies } = require('../utils/curated-models');
19
20
  const { pickCurrent } = require('../utils/quick-picks');
@@ -116,8 +117,20 @@ async function runRefresh(args) {
116
117
  return 0;
117
118
  }
118
119
 
120
+ /** One readable line per gateway-route finding (Task 6, #gwid). @param {object} f @returns {string} */
121
+ function fmtGatewayFinding(f) {
122
+ if (f.kind === 'stale') {
123
+ return ` GATEWAY STALE (${f.gateway}): ${f.alias} -> ${f.model}`;
124
+ }
125
+ if (f.kind === 'divergent-missing') {
126
+ return ` GATEWAY DIVERGENT: ${f.alias} has no direct form; catalog confirms ${f.model}`;
127
+ }
128
+ return ` GATEWAY DIVERGENT: ${f.alias} direct form ${f.model} no longer matches catalog (now ${f.expected})`;
129
+ }
130
+
119
131
  async function runCheck(args) {
120
- const { models: catalog } = await getCatalogInfo();
132
+ const catalogInfo = await getCatalogInfo();
133
+ const catalog = catalogInfo.models;
121
134
  if (!catalog || catalog.length === 0) {
122
135
  if (args.json) {
123
136
  process.stdout.write(JSON.stringify(buildAuditDoc({
@@ -131,35 +144,44 @@ async function runCheck(args) {
131
144
  const sources = collectAliasSources();
132
145
  const stale = findStaleAliases(sources, catalog)
133
146
  .map(s => ({ ...s, suggestions: suggestReplacements(s.model, catalog) }));
147
+ // Task 6 (#gwid): per-gateway-form audit of the curated DEFAULTS
148
+ // (toGatewayRoutes()) — additive to the flat audit above. Informational by
149
+ // default; --strict promotes it to a build-breaking exit code (CI gate).
150
+ const gatewayFindings = auditGatewayRoutes(catalogInfo);
151
+ const legacyExitCode = Math.min(stale.length, CHECK_EXIT_CAP);
152
+ const exitCode = args.strict
153
+ ? Math.max(legacyExitCode, Math.min(gatewayFindings.length, CHECK_EXIT_CAP))
154
+ : legacyExitCode;
155
+
134
156
  if (args.json) {
135
157
  process.stdout.write(JSON.stringify(buildAuditDoc({
136
- stale, catalogAvailable: true
158
+ stale, catalogAvailable: true, gatewayFindings
137
159
  }), null, 2) + '\n');
138
- return Math.min(stale.length, CHECK_EXIT_CAP);
160
+ return exitCode;
139
161
  }
140
162
  const driftLines = buildFallbackDriftReport(catalog);
141
163
  if (stale.length === 0) {
142
164
  process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
143
- if (driftLines.length > 0) {
144
- process.stdout.write('Pinned fallback drift:\n');
145
- for (const l of driftLines) { process.stdout.write(l + '\n'); }
146
- }
147
- return 0;
148
- }
149
- for (const s of stale) {
150
- process.stdout.write(`STALE: ${s.alias} -> ${s.model} (${s.source})\n`);
151
- if (s.suggestions.length > 0) {
152
- process.stdout.write(` candidates: ${s.suggestions.join(', ')}\n`);
153
- process.stdout.write(` fix: amicus setup --add-alias ${s.alias}=${s.suggestions[0]}\n`);
154
- } else {
155
- process.stdout.write(' no same-vendor candidates in catalog\n');
165
+ } else {
166
+ for (const s of stale) {
167
+ process.stdout.write(`STALE: ${s.alias} -> ${s.model} (${s.source})\n`);
168
+ if (s.suggestions.length > 0) {
169
+ process.stdout.write(` candidates: ${s.suggestions.join(', ')}\n`);
170
+ process.stdout.write(` fix: amicus setup --add-alias ${s.alias}=${s.suggestions[0]}\n`);
171
+ } else {
172
+ process.stdout.write(' no same-vendor candidates in catalog\n');
173
+ }
156
174
  }
157
175
  }
158
176
  if (driftLines.length > 0) {
159
177
  process.stdout.write('Pinned fallback drift:\n');
160
178
  for (const l of driftLines) { process.stdout.write(l + '\n'); }
161
179
  }
162
- return Math.min(stale.length, CHECK_EXIT_CAP);
180
+ if (gatewayFindings.length > 0) {
181
+ process.stdout.write('Per-gateway route audit (curated defaults):\n');
182
+ for (const f of gatewayFindings) { process.stdout.write(fmtGatewayFinding(f) + '\n'); }
183
+ }
184
+ return exitCode;
163
185
  }
164
186
 
165
187
  /**
@@ -47,7 +47,7 @@ const FAMILIES = [
47
47
  idPattern: /^claude-opus-[\d.-]+$/,
48
48
  directProviders: ['anthropic'],
49
49
  fallback: { openrouter: 'openrouter/anthropic/claude-opus-4.8',
50
- anthropic: 'anthropic/claude-opus-4-6' } },
50
+ anthropic: 'anthropic/claude-opus-4-8' } },
51
51
  { alias: 'deepseek', label: 'DeepSeek flagship', blurb: 'open-source',
52
52
  vendorPath: 'deepseek',
53
53
  idPattern: /^deepseek-v[\d.]+(-pro)?$/,
@@ -64,9 +64,13 @@ const CARDLESS = [
64
64
  { alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.5-pro' } },
65
65
  // codex: newest codex-specific model on OpenRouter (verified 2026-06-09).
66
66
  { alias: 'codex', routes: { openrouter: 'openrouter/openai/gpt-5.3-codex' } },
67
- { alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-4.6' } },
68
- { alias: 'sonnet', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-4.6' } },
69
- { alias: 'haiku', routes: { openrouter: 'openrouter/anthropic/claude-haiku-4.5' } },
67
+ { alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-5',
68
+ anthropic: 'anthropic/claude-sonnet-5' } },
69
+ { alias: 'sonnet', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-5',
70
+ anthropic: 'anthropic/claude-sonnet-5' } },
71
+ { alias: 'haiku', routes: { openrouter: 'openrouter/anthropic/claude-haiku-4.5',
72
+ anthropic: 'anthropic/claude-haiku-4-5-20251001' } },
73
+ { alias: 'fable', routes: { openrouter: 'openrouter/anthropic/claude-fable-5' } },
70
74
  { alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.7-max' } },
71
75
  { alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
72
76
  { alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
@@ -151,4 +155,62 @@ function listCuratedRoutes() {
151
155
  return out;
152
156
  }
153
157
 
154
- module.exports = { getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes };
158
+ /**
159
+ * Vendors whose direct-API ids differ from OpenRouter's (dot vs. dash
160
+ * versioning, distinct model names, etc.). NEVER derive a direct form for
161
+ * these — derivation would emit the wrong (dot) id, or invent a direct id
162
+ * for a model that is OpenRouter-only today (e.g. fable).
163
+ */
164
+ const DIVERGENT_VENDORS = new Set(['anthropic']);
165
+
166
+ /**
167
+ * @param {string} orRoute e.g. 'openrouter/anthropic/claude-sonnet-5'
168
+ * @returns {string} the vendor segment, e.g. 'anthropic'
169
+ */
170
+ function vendorOf(orRoute) {
171
+ const rest = orRoute.slice('openrouter/'.length);
172
+ return rest.slice(0, rest.indexOf('/'));
173
+ }
174
+
175
+ /**
176
+ * @param {string} vendorPath
177
+ * @param {Object<string,string>} obj a family.fallback or cardless.routes map
178
+ * @returns {string|undefined} the direct-API executable id, or undefined
179
+ * when no direct form is available for this alias.
180
+ */
181
+ function directFormFor(vendorPath, obj) {
182
+ if (obj[vendorPath]) { return obj[vendorPath]; } // explicit, authored, current direct id
183
+ if (DIVERGENT_VENDORS.has(vendorPath)) { return undefined; } // no explicit form + divergent → omit
184
+ const bare = toCanonicalDefault(obj.openrouter); // safe only when ids are identical across gateways
185
+ return bare !== obj.openrouter ? bare : undefined; // gateway-only vendor → undefined
186
+ }
187
+
188
+ /**
189
+ * @param {string} vendorPath
190
+ * @param {Object<string,string>} obj a family.fallback or cardless.routes map
191
+ * @returns {{direct?: string, openrouter: string}}
192
+ */
193
+ function gatewayRoutesFor(vendorPath, obj) {
194
+ const routes = { openrouter: obj.openrouter };
195
+ const direct = directFormFor(vendorPath, obj);
196
+ if (direct) { routes.direct = direct; }
197
+ return routes;
198
+ }
199
+
200
+ /**
201
+ * @returns {Object<string,{direct?: string, openrouter: string}>} alias →
202
+ * per-gateway executable ids. Unlike `toDefaultAliases` (a single pinned
203
+ * string per alias, used for display/`config.default`), this carries BOTH
204
+ * gateway-native forms so the router (Task 3) can route direct-first
205
+ * without corrupting divergent-vendor ids (e.g. Anthropic's dash format).
206
+ */
207
+ function toGatewayRoutes() {
208
+ const out = {};
209
+ for (const f of FAMILIES) { out[f.alias] = gatewayRoutesFor(f.vendorPath, f.fallback); }
210
+ for (const e of CARDLESS) { out[e.alias] = gatewayRoutesFor(vendorOf(e.routes.openrouter), e.routes); }
211
+ return out;
212
+ }
213
+
214
+ module.exports = {
215
+ getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes
216
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Per-gateway-form audit for curated DEFAULT aliases (Task 6, #gwid).
3
+ *
4
+ * Complements the flat alias-audit.js (which audits the single pinned string
5
+ * per alias from `toDefaultAliases()`/`listCuratedRoutes()`): this audits
6
+ * BOTH gateway-native forms from `curated-models.toGatewayRoutes()` against
7
+ * the live catalog.
8
+ *
9
+ * - STALE a stored form's id is absent from its namespace.
10
+ * - DIVERGENT a direct-capable vendor's alias is missing a `direct` form
11
+ * the catalog can now confirm ('divergent-missing'), or its
12
+ * stored `direct` form no longer matches what the catalog
13
+ * pairs ('divergent-mismatch').
14
+ *
15
+ * Never reports against data it cannot trust: a direct namespace the process
16
+ * has no key for is skipped, not flagged --
17
+ * - STALE relies on classifyModel(), whose 'unknown' already covers this
18
+ * (empty namespace, or every row a non-authoritative floor-fallback).
19
+ * - DIVERGENT additionally re-checks `authoritative` itself, because
20
+ * pairAcrossGateways() (Task 5) is a pure string matcher that has no
21
+ * concept of authoritative vs. floor-fallback rows -- left unguarded, it
22
+ * would happily "confirm" a direct pairing against the hardcoded
23
+ * Anthropic offline floor (e.g. matching a dated id like
24
+ * claude-haiku-4-5-20251001 to the floor's undated claude-haiku-4-5) and
25
+ * report a false mismatch with no key present at all.
26
+ *
27
+ * Consumed by `amicus models --check` (Task 6); `--strict` gates the exit
28
+ * code on these findings.
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ const { toGatewayRoutes } = require('./curated-models');
34
+ const { classifyModel } = require('./model-classification');
35
+ const { pairAcrossGateways } = require('./gateway-route-catalog');
36
+ const { isDirectProvider } = require('./provider-registry');
37
+
38
+ const OR_PREFIX = 'openrouter/';
39
+
40
+ /** @param {string} orId e.g. 'openrouter/anthropic/claude-sonnet-5' @returns {string|null} vendor segment */
41
+ function vendorOf(orId) {
42
+ if (typeof orId !== 'string' || !orId.startsWith(OR_PREFIX)) { return null; }
43
+ const rest = orId.slice(OR_PREFIX.length);
44
+ const i = rest.indexOf('/');
45
+ return i > 0 ? rest.slice(0, i) : null;
46
+ }
47
+
48
+ /**
49
+ * Bare model segment for pairAcrossGateways' versionToken -- Task-5's locked
50
+ * calling convention: strip BOTH the `openrouter/` and `<vendor>/` prefixes
51
+ * before calling, never pass the route string verbatim.
52
+ * @param {string} orId @param {string} vendor @returns {string|null}
53
+ */
54
+ function bareSegment(orId, vendor) {
55
+ const prefix = `${OR_PREFIX}${vendor}/`;
56
+ return typeof orId === 'string' && orId.startsWith(prefix) ? orId.slice(prefix.length) : null;
57
+ }
58
+
59
+ /** @returns {boolean} true only when `id` names a live-fetched (not floor-fallback) catalog row */
60
+ function isAuthoritative(catalogInfo, id) {
61
+ const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
62
+ const row = models.find(m => m && m.id === id);
63
+ return !!row && row.authoritative !== false;
64
+ }
65
+
66
+ /**
67
+ * @param {{models: Array<{id:string, authoritative?: boolean}>, lastRefreshError?: string|null}} catalogInfo
68
+ * @returns {Array<{alias:string, gateway:'direct'|'openrouter',
69
+ * kind:'stale'|'divergent-missing'|'divergent-mismatch', model:string, expected?:string}>}
70
+ */
71
+ function auditGatewayRoutes(catalogInfo) {
72
+ const routes = toGatewayRoutes();
73
+ const findings = [];
74
+
75
+ for (const [alias, forms] of Object.entries(routes)) {
76
+ for (const gateway of ['direct', 'openrouter']) {
77
+ const id = forms[gateway];
78
+ if (!id) { continue; }
79
+ if (classifyModel(id, gateway, catalogInfo) === 'invalid') {
80
+ findings.push({ alias, gateway, kind: 'stale', model: id });
81
+ }
82
+ }
83
+
84
+ const vendor = vendorOf(forms.openrouter);
85
+ if (!vendor || !isDirectProvider(vendor)) { continue; } // gateway-only vendor: no direct route ever possible
86
+ const token = bareSegment(forms.openrouter, vendor);
87
+ if (!token) { continue; }
88
+ const paired = pairAcrossGateways(vendor, token, catalogInfo); // Task-5 contract: bare segment only
89
+ if (!paired.direct || !isAuthoritative(catalogInfo, paired.direct)) { continue; } // unconfirmed -- never guess
90
+
91
+ if (!forms.direct) {
92
+ findings.push({ alias, gateway: 'direct', kind: 'divergent-missing', model: paired.direct });
93
+ } else if (forms.direct !== paired.direct) {
94
+ findings.push({
95
+ alias, gateway: 'direct', kind: 'divergent-mismatch', model: forms.direct, expected: paired.direct
96
+ });
97
+ }
98
+ }
99
+
100
+ return findings;
101
+ }
102
+
103
+ module.exports = { auditGatewayRoutes };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Conservative cross-gateway catalog pairing helper (Task 5, #gwid).
3
+ *
4
+ * The same model has a different id per gateway namespace: direct
5
+ * `anthropic/claude-opus-4-8` (dashes, sometimes a trailing date suffix)
6
+ * vs. OpenRouter `openrouter/anthropic/claude-opus-4.8` (dots). This module
7
+ * pairs the two rows for a given vendor + version token, using a normalized
8
+ * comparison key ONLY to decide whether two catalog rows refer to the same
9
+ * model -- it never derives, transforms, or invents an id. Every id this
10
+ * module returns is copied verbatim from `catalogInfo.models[].id`.
11
+ *
12
+ * Used ONLY by `amicus models --check` (Task 6) to audit/refresh the curated
13
+ * per-gateway route map -- NOT on any launch hot path. Correctness-when-
14
+ * uncertain matters more than cleverness here: when a namespace has zero or
15
+ * more than one plausible match, that side is OMITTED rather than guessed.
16
+ *
17
+ * Pure function: no I/O, no network, no catalog fetch.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const OPENROUTER_PREFIX = 'openrouter/';
23
+ /** Trailing 8-digit date suffix (e.g. '-20251001'), comparison-only. */
24
+ const TRAILING_DATE_RE = /-\d{8}$/;
25
+
26
+ /**
27
+ * Normalize a catalog id or a caller-supplied version token into a
28
+ * comparison-only key: strip a leading `openrouter/`, strip a leading
29
+ * `<vendor>/`, lowercase, unify '.'/'-' separators (dots become dashes), and
30
+ * drop a trailing 8-digit date suffix. The result is NEVER returned to
31
+ * callers -- it exists solely to decide whether two strings name the same
32
+ * model.
33
+ * @param {string} raw
34
+ * @param {string} vendor
35
+ * @returns {string|null} normalized key, or null when `raw` isn't a string
36
+ */
37
+ function normalizeKey(raw, vendor) {
38
+ if (typeof raw !== 'string' || raw.length === 0) { return null; }
39
+ let s = raw;
40
+ if (s.startsWith(OPENROUTER_PREFIX)) { s = s.slice(OPENROUTER_PREFIX.length); }
41
+ const vendorPrefix = `${vendor}/`;
42
+ if (s.startsWith(vendorPrefix)) { s = s.slice(vendorPrefix.length); }
43
+ s = s.toLowerCase().replace(/\./g, '-');
44
+ s = s.replace(TRAILING_DATE_RE, '');
45
+ return s;
46
+ }
47
+
48
+ /**
49
+ * Find, in `catalogInfo.models`, the direct-namespace id and the
50
+ * OpenRouter-namespace id that both correspond to the model named by
51
+ * `versionToken` for `vendor`.
52
+ *
53
+ * Matching is conservative: a side is only included when EXACTLY ONE row in
54
+ * that namespace normalizes to the same key as `versionToken`. Zero matches
55
+ * or more than one plausible match (ambiguous) both result in that side
56
+ * being omitted -- never a guessed/fuzzy pick.
57
+ *
58
+ * @param {string} vendor e.g. 'anthropic'
59
+ * @param {string} versionToken e.g. 'claude-opus-4-8' or 'claude-opus-4.8'
60
+ * @param {{models: Array<{id: string}>}} catalogInfo
61
+ * @returns {{direct?: string, openrouter?: string}} verbatim catalog ids only
62
+ */
63
+ function pairAcrossGateways(vendor, versionToken, catalogInfo) {
64
+ const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
65
+ const targetKey = normalizeKey(versionToken, vendor);
66
+ const result = {};
67
+ if (targetKey === null) { return result; }
68
+
69
+ const directPrefix = `${vendor}/`;
70
+ const openrouterPrefix = `${OPENROUTER_PREFIX}${vendor}/`;
71
+
72
+ const directMatches = [];
73
+ const openrouterMatches = [];
74
+
75
+ for (const row of models) {
76
+ if (!row || typeof row.id !== 'string') { continue; }
77
+ const id = row.id;
78
+ if (id.startsWith(openrouterPrefix)) {
79
+ if (normalizeKey(id, vendor) === targetKey) { openrouterMatches.push(id); }
80
+ } else if (id.startsWith(directPrefix)) {
81
+ if (normalizeKey(id, vendor) === targetKey) { directMatches.push(id); }
82
+ }
83
+ }
84
+
85
+ // Exactly one candidate required per side -- ambiguity (>1) is treated the
86
+ // same as absence (0): omit rather than guess.
87
+ if (directMatches.length === 1) { result.direct = directMatches[0]; }
88
+ if (openrouterMatches.length === 1) { result.openrouter = openrouterMatches[0]; }
89
+ return result;
90
+ }
91
+
92
+ module.exports = { pairAcrossGateways };
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Pure gateway router (#61). Decides direct vs OpenRouter for a request using
3
3
  * only injected state (keys, catalogInfo, gatewayMode) — no I/O. Returns a
4
- * RouteResult (resolved | selection_required | error). Wiring into launch paths
5
- * is Plan 2; this module is behavior-neutral until then.
4
+ * RouteResult (resolved | selection_required | error). Wired into live launch
5
+ * paths via route-launch.js's resolveRouteForLaunch (start-helpers.js,
6
+ * mcp-server.js, sidecar/fanout-validate.js).
6
7
  */
7
8
  'use strict';
8
9
 
@@ -36,9 +37,18 @@ function catalogGate({ id, gateway, req }) {
36
37
  preferredGateway: gateway, suggestions: [] }) };
37
38
  }
38
39
 
40
+ /**
41
+ * True when the given gateway is usable for this request: either the caller
42
+ * didn't supply per-gateway ids at all (back-compat: nothing to check), or it
43
+ * did and this specific gateway has a form in it.
44
+ */
45
+ function hasForm(req, gateway) {
46
+ return !req.gatewayIds || req.gatewayIds[gateway] !== undefined;
47
+ }
48
+
39
49
  /** Resolve to a concrete gateway after the catalog gate passes. */
40
50
  function finish(gateway, vendor, model, req) {
41
- const id = executableFor(gateway, vendor, model);
51
+ const id = (req.gatewayIds && req.gatewayIds[gateway]) || executableFor(gateway, vendor, model);
42
52
  const gate = catalogGate({ id, gateway, req });
43
53
  if (!gate.ok) { return gate.result; }
44
54
  return resolved({ model: id, gateway, executableId: id,
@@ -93,6 +103,9 @@ function resolveRoute(req) {
93
103
  if (!rq.keys.openrouter) {
94
104
  return routeError({ requested: d.raw, reason: 'no_openrouter_key', preferredGateway: 'openrouter', suggestions: [] });
95
105
  }
106
+ if (!hasForm(rq, 'openrouter')) {
107
+ return routeError({ requested: d.raw, reason: 'openrouter_unavailable', preferredGateway: 'openrouter', suggestions: [] });
108
+ }
96
109
  return finish('openrouter', vendor, model, rq);
97
110
  }
98
111
  // 6. Explicit --gateway direct
@@ -100,13 +113,16 @@ function resolveRoute(req) {
100
113
  if (!rq.keys[vendor]) {
101
114
  return routeError({ requested: d.raw, reason: 'no_direct_key', preferredGateway: 'direct', suggestions: [] });
102
115
  }
116
+ if (!hasForm(rq, 'direct')) {
117
+ return routeError({ requested: d.raw, reason: 'direct_unavailable', preferredGateway: 'direct', suggestions: [] });
118
+ }
103
119
  return finish('direct', vendor, model, rq);
104
120
  }
105
121
  // 7. auto (direct-first)
106
- if (rq.keys[vendor]) {
122
+ if (rq.keys[vendor] && hasForm(rq, 'direct')) {
107
123
  return finish('direct', vendor, model, rq);
108
124
  }
109
- if (rq.keys.openrouter) {
125
+ if (rq.keys.openrouter && hasForm(rq, 'openrouter')) {
110
126
  return finish('openrouter', vendor, model, rq);
111
127
  }
112
128
  return routeError({ requested: d.raw, reason: 'no_key_for_vendor', preferredGateway: 'direct', suggestions: [] });
@@ -7,13 +7,19 @@
7
7
 
8
8
  const https = require('https');
9
9
 
10
- /** Hardcoded Anthropic models (no public listing endpoint) */
10
+ /**
11
+ * Hardcoded Anthropic models (no public listing endpoint). This is the
12
+ * DIRECT-API floor only — Fable is OpenRouter-only (see curated-models.js
13
+ * DIVERGENT_VENDORS / CARDLESS 'fable' entry, which has no `anthropic` route)
14
+ * and must never appear here: classifyModel() returns 'valid' on a floor HIT
15
+ * before it ever checks `authoritative`, so listing an OR-only model here
16
+ * would mislabel a direct-API request for it as valid.
17
+ */
11
18
  const ANTHROPIC_MODELS = [
12
- { id: 'anthropic/claude-opus-4-6', name: 'Claude Opus 4.6', contextLength: null, pricing: null },
13
- { id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextLength: null, pricing: null },
19
+ { id: 'anthropic/claude-opus-4-8', name: 'Claude Opus 4.8', contextLength: null, pricing: null },
20
+ { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', contextLength: null, pricing: null },
14
21
  { id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
15
- { id: 'anthropic/claude-sonnet-4-5', name: 'Claude Sonnet 4.5', contextLength: null, pricing: null },
16
- { id: 'anthropic/claude-3-5-haiku', name: 'Claude 3.5 Haiku', contextLength: null, pricing: null }
22
+ { id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextLength: null, pricing: null }
17
23
  ];
18
24
 
19
25
  const { PROVIDER_FAMILY_NAMES } = require('./provider-registry');
@@ -182,15 +182,22 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
182
182
 
183
183
  /**
184
184
  * Build an alias-audit document (`models --check --json`).
185
- * @param {{stale: Array<{alias,model,source,suggestions}>, catalogAvailable: boolean}} opts
185
+ * `gatewayFindings` (Task 6, #gwid) is additive: the per-gateway-form audit
186
+ * of curated DEFAULT aliases (toGatewayRoutes() vs. the live catalog),
187
+ * distinct from the flat `stale` audit above. Defaults to [] so existing
188
+ * callers that omit it are unaffected.
189
+ * @param {{stale: Array<{alias,model,source,suggestions}>, catalogAvailable: boolean,
190
+ * gatewayFindings?: Array<{alias,gateway,kind,model,expected?}>}} opts
186
191
  */
187
- function buildAuditDoc({ stale, catalogAvailable }) {
192
+ function buildAuditDoc({ stale, catalogAvailable, gatewayFindings = [] }) {
188
193
  return {
189
194
  schemaVersion: SCHEMA_VERSION,
190
195
  type: 'alias-audit',
191
196
  catalogAvailable,
192
197
  staleCount: stale.length,
193
198
  stale,
199
+ gatewayFindingsCount: gatewayFindings.length,
200
+ gatewayFindings,
194
201
  };
195
202
  }
196
203
 
@@ -6,8 +6,9 @@
6
6
  * - `toCliMessage` -> a human stderr string for the CLI
7
7
  *
8
8
  * Pure module: no I/O, no requires of launch modules (cli.js/headless.js/
9
- * mcp-server.js/etc). Additive only not imported by any launch path yet;
10
- * wiring is a later task in the #61 Integration plan.
9
+ * mcp-server.js/etc). Wired into live launch paths start-helpers.js,
10
+ * sidecar/fanout-leg.js, and mcp-server.js all render RouteResults through
11
+ * toStructuredError/toCliMessage.
11
12
  *
12
13
  * Router error shape (src/utils/model-descriptor.js `routeError()`):
13
14
  * {kind:'error', type:'model_route_error', field, requested, reason,
@@ -15,16 +16,28 @@
15
16
  * Selection shape (`selectionRequired()`):
16
17
  * {kind:'selection_required', requested, suggestions}
17
18
  *
18
- * The router's error `reason` is a closed set of 7 values (ROUTE_ERROR_REASONS
19
- * below). A `selection_required` result has no `reason` of its own — it is
20
- * synthesized here as SELECTION_REQUIRED_REASON, kept in the same documented
21
- * REASON_TEXT map rather than invented ad hoc, so callers can treat every
22
- * rendered structured error the same way regardless of which RouteResult
23
- * produced it.
19
+ * The router's error `reason` is NOT limited to the 7 values in
20
+ * ROUTE_ERROR_REASONS below that array is just the original/base set,
21
+ * intentionally pinned as-is (see its own doc comment). The router can also
22
+ * emit availability reasons (`direct_unavailable`, `openrouter_unavailable`),
23
+ * which have REASON_TEXT/FIX_HINTS entries but are deliberately excluded from
24
+ * ROUTE_ERROR_REASONS. A `selection_required` result has no `reason` of its
25
+ * own — it is synthesized here as SELECTION_REQUIRED_REASON, kept in the same
26
+ * documented REASON_TEXT map rather than invented ad hoc, so callers can
27
+ * treat every rendered structured error the same way regardless of which
28
+ * RouteResult produced it.
24
29
  */
25
30
  'use strict';
26
31
 
27
- /** The closed set of reasons a router `error` result can carry. */
32
+ /**
33
+ * The original/base set of router error reasons — NOT an exhaustive list of
34
+ * every reason a router error can carry. Pinned to exactly these 7 values by
35
+ * a back-compat test (route-error.test.js:14-24), so this array must not be
36
+ * extended when new reasons are added. The router also emits
37
+ * `direct_unavailable` and `openrouter_unavailable` (REASON_TEXT/FIX_HINTS
38
+ * below have entries for both); those are intentionally left out of this
39
+ * array. Do not use ROUTE_ERROR_REASONS as an exhaustive switch/allow-list.
40
+ */
28
41
  const ROUTE_ERROR_REASONS = Object.freeze([
29
42
  'gateway_conflict',
30
43
  'no_openrouter_key',
@@ -51,6 +64,8 @@ const REASON_TEXT = Object.freeze({
51
64
  no_key_for_vendor: 'No API key was found for this vendor via any gateway.',
52
65
  model_not_found: 'The requested model was not found in the catalog.',
53
66
  invalid_descriptor: 'The model identifier could not be parsed.',
67
+ direct_unavailable: "This model isn't available on the vendor's direct API; use OpenRouter or a different model.",
68
+ openrouter_unavailable: "This model isn't on OpenRouter; use --gateway direct or a different model.",
54
69
  [SELECTION_REQUIRED_REASON]: 'Multiple models match your request; a specific one must be selected.',
55
70
  });
56
71
 
@@ -63,6 +78,8 @@ const FIX_HINTS = Object.freeze({
63
78
  no_key_for_vendor: 'Add a provider key or an OpenRouter key.',
64
79
  model_not_found: 'Run `amicus models --refresh`, or pass --no-validate-model.',
65
80
  invalid_descriptor: 'Use a vendor/model id or a configured alias.',
81
+ direct_unavailable: 'Drop --gateway direct (use auto or --gateway openrouter), or pick a different model.',
82
+ openrouter_unavailable: 'Use --gateway direct, or pick a different model.',
66
83
  [SELECTION_REQUIRED_REASON]: 'Pick one of the suggestions below, or narrow the model id.',
67
84
  });
68
85
 
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Route-launch views (#61 gateway routing integration, Task 4.2).
3
3
  *
4
- * Additive, read-only helpers consumed by Task 4.4's resolveRouteForLaunch
5
- * (not wired into any launch path yet). Pure-ish: all I/O goes through the
6
- * stubbable api-key-store / auth-json / model-catalog modules.
4
+ * Read-only helpers consumed by resolveRouteForLaunch, which IS wired into
5
+ * live launch paths (start-helpers.js, mcp-server.js, sidecar/fanout-validate.js).
6
+ * Pure-ish: all I/O goes through the stubbable api-key-store / auth-json /
7
+ * model-catalog modules.
7
8
  */
8
9
  'use strict';
9
10
 
@@ -52,12 +53,20 @@ const ROUTE_VERSION = 1;
52
53
  /**
53
54
  * Build up to ~6 labeled alternatives for a `selection_required` RouteResult
54
55
  * (#61 Task 6.3, spec Decision 10). Pure: reads only the already-parsed
55
- * descriptor plus the live keys/catalogInfo the caller already assembled.
56
+ * descriptor plus the live keys/catalogInfo/gatewayIds the caller already
57
+ * assembled.
56
58
  *
57
59
  * Two categories, in order:
58
60
  * 1. The SAME model via OpenRouter — only when an OpenRouter key is present
59
- * AND the OR-namespaced id (`openrouter/<vendor>/<model>`) is actually
60
- * present in the catalog (never suggest an id we can't confirm exists).
61
+ * AND the OR-namespaced id is actually present in the catalog (never
62
+ * suggest an id we can't confirm exists). For divergent vendors (e.g.
63
+ * Anthropic) `descriptor.model` may be the DASH-form direct id, so a
64
+ * reconstructed `openrouter/<vendor>/<model>` would never match the
65
+ * catalog's dot-form OR id -- when the caller's `gatewayIds.openrouter`
66
+ * is available (the catalog-correct form), it is used instead of
67
+ * reconstructing. Falls back to reconstruction when `gatewayIds` is
68
+ * absent (non-alias / full-id / non-divergent requests), so behavior
69
+ * there is unchanged.
61
70
  * 2. Up to 5 OTHER models in the same direct vendor namespace (ids starting
62
71
  * `<vendor>/`, excluding the requested id itself and excluding any
63
72
  * `openrouter/`-prefixed rows, which share the `<vendor>/` prefix check
@@ -68,9 +77,13 @@ const ROUTE_VERSION = 1;
68
77
  * openrouter-literal — both carry vendor/model)
69
78
  * @param {Object<string,boolean>} keys per-provider key-presence map (buildLaunchKeys() shape)
70
79
  * @param {{models: Array<{id:string}>}} catalogInfo
80
+ * @param {{direct?: string, openrouter?: string}} [gatewayIds] the same
81
+ * per-gateway id map resolveRouteForLaunch threads through resolveRoute
82
+ * (Task 3's bridge for divergent curated aliases); absent for non-alias /
83
+ * full-id / non-divergent requests
71
84
  * @returns {Array<{model:string, gateway:string, note:string}>}
72
85
  */
73
- function buildSuggestions(descriptor, keys, catalogInfo) {
86
+ function buildSuggestions(descriptor, keys, catalogInfo, gatewayIds) {
74
87
  const suggestions = [];
75
88
  const vendor = descriptor && descriptor.vendor;
76
89
  const model = descriptor && descriptor.model;
@@ -80,7 +93,7 @@ function buildSuggestions(descriptor, keys, catalogInfo) {
80
93
  const requestedDirectId = `${vendor}/${model}`;
81
94
 
82
95
  if (keys && keys.openrouter) {
83
- const orId = `openrouter/${vendor}/${model}`;
96
+ const orId = (gatewayIds && gatewayIds.openrouter) || `openrouter/${vendor}/${model}`;
84
97
  if (models.some(m => m && m.id === orId)) {
85
98
  suggestions.push({ model: orId, gateway: 'openrouter', note: 'same model via OpenRouter' });
86
99
  }
@@ -137,25 +150,52 @@ function maybeMigrationNotice({ result, descriptor, gatewayMode, keys }) {
137
150
  }
138
151
 
139
152
  /**
140
- * Bridge: alias -> descriptor -> resolveRoute (Task 4.4).
153
+ * Bridge: alias -> descriptor -> resolveRoute (Task 4.4; gatewayIds bridging
154
+ * Task 3 of #61's gateway-correct-model-ids fix).
141
155
  * Resolves a raw model string to a Descriptor — if it is a known no-slash
142
156
  * alias (per getEffectiveAliases()), its concrete id is parsed instead, so an
143
157
  * alias pointing at an `openrouter/...` value is treated as an explicit,
144
158
  * force-OR literal while an alias pointing at a bare `vendor/model` is
145
- * policy-routed like any other canonical id. Assembles live key/catalog state
146
- * and delegates the actual decision to the pure gateway-router. Additive:
147
- * not wired into any launch path yet.
159
+ * policy-routed like any other canonical id.
160
+ *
161
+ * When the alias is UNMODIFIED from its curated default (effective value ===
162
+ * getDefaultAliases()[alias]), this also looks up curated-models'
163
+ * toGatewayRoutes()[alias] and threads it through as `gatewayIds`, and parses
164
+ * the descriptor from the gateway-native direct form (falling back to the
165
+ * openrouter form) rather than the single pinned alias string — the pinned
166
+ * string can be the wrong per-gateway form for divergent vendors (e.g.
167
+ * Anthropic's dash ids vs. OpenRouter's dot ids), so `toGatewayRoutes()` is
168
+ * the source of truth for actually-correct ids. A USER OVERRIDE (the
169
+ * effective alias differs from the curated default) or an alias with no
170
+ * curated route map intentionally skips all of this: no gatewayIds, and the
171
+ * user's own alias string is parsed as before — we must never impose curated
172
+ * Anthropic-style ids onto a target the user chose themselves. Full-id /
173
+ * non-alias inputs are likewise unaffected (no gatewayIds).
174
+ *
175
+ * Assembles live key/catalog state and delegates the actual decision to the
176
+ * pure gateway-router. Wired into start-helpers.js, mcp-server.js, and
177
+ * sidecar/fanout-validate.js.
148
178
  * @param {{model:string, gatewayMode:string, source:string, allowSelection?:boolean, validateModel?:boolean}} opts
149
179
  * @returns {Promise<object>} RouteResult (resolved | selection_required | error)
150
180
  */
151
181
  async function resolveRouteForLaunch({ model, gatewayMode, source, allowSelection, validateModel }) {
152
- // Lazy-required so jest.doMock('./config' | './model-descriptor' | './gateway-router', ...)
182
+ // Lazy-required so jest.doMock('./config' | './model-descriptor' | './gateway-router' | './curated-models', ...)
153
183
  // can intercept them per-test, matching the pattern already used above for model-catalog.
154
- const { getEffectiveAliases } = require('./config');
184
+ const { getEffectiveAliases, getDefaultAliases } = require('./config');
155
185
  const { parseDescriptor } = require('./model-descriptor');
156
186
  const { resolveRoute } = require('./gateway-router');
187
+ const { toGatewayRoutes } = require('./curated-models');
157
188
  const aliases = getEffectiveAliases();
158
- const concrete = (typeof model === 'string' && !model.includes('/') && aliases[model]) ? aliases[model] : model;
189
+ const isAlias = typeof model === 'string' && !model.includes('/') && !!aliases[model];
190
+ let concrete = isAlias ? aliases[model] : model;
191
+ let gatewayIds;
192
+ if (isAlias && aliases[model] === getDefaultAliases()[model]) {
193
+ const routes = toGatewayRoutes()[model];
194
+ if (routes) {
195
+ gatewayIds = routes;
196
+ concrete = routes.direct || routes.openrouter;
197
+ }
198
+ }
159
199
  const descriptor = parseDescriptor(concrete, { aliases });
160
200
  const keys = buildLaunchKeys();
161
201
  // Skip the catalog fetch entirely under --no-validate-model: gateway-router's
@@ -166,12 +206,12 @@ async function resolveRouteForLaunch({ model, gatewayMode, source, allowSelectio
166
206
  // own `=== false` guard: any other value (incl. an omitted flag) still fetches,
167
207
  // so a caller can never skip the fetch while the gate still classifies against it.
168
208
  const catalogInfo = validateModel === false ? { models: [], lastRefreshError: null } : await getRouteCatalogInfo();
169
- let result = resolveRoute({ descriptor, source, gatewayMode, allowSelection, validateModel, keys, catalogInfo });
209
+ let result = resolveRoute({ descriptor, source, gatewayMode, allowSelection, validateModel, keys, catalogInfo, gatewayIds });
170
210
  if (result.kind === 'resolved') {
171
211
  result.provenance = { ...result.provenance, resolutionVersion: ROUTE_VERSION };
172
212
  result = maybeMigrationNotice({ result, descriptor, gatewayMode, keys });
173
213
  } else if (result.kind === 'selection_required') {
174
- result.suggestions = buildSuggestions(descriptor, keys, catalogInfo);
214
+ result.suggestions = buildSuggestions(descriptor, keys, catalogInfo, gatewayIds);
175
215
  }
176
216
  return result;
177
217
  }