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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +2 -2
- package/electron/setup-ui-aliases.js +1 -1
- package/package.json +2 -1
- package/src/cli.js +4 -0
- package/src/sidecar/models.js +39 -17
- package/src/utils/curated-models.js +67 -5
- package/src/utils/gateway-route-audit.js +103 -0
- package/src/utils/gateway-route-catalog.js +92 -0
- package/src/utils/gateway-router.js +21 -5
- package/src/utils/model-fetcher.js +11 -5
- package/src/utils/result-schema.js +9 -2
- package/src/utils/route-error.js +26 -9
- package/src/utils/route-launch.js +57 -17
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "3.1.
|
|
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
|
-

|
|
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.
|
|
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.
|
|
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: `
|
package/src/sidecar/models.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
68
|
-
|
|
69
|
-
{ alias: '
|
|
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
|
-
|
|
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).
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
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-
|
|
13
|
-
{ id: 'anthropic/claude-sonnet-
|
|
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-
|
|
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
|
-
*
|
|
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
|
|
package/src/utils/route-error.js
CHANGED
|
@@ -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).
|
|
10
|
-
*
|
|
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
|
|
19
|
-
* below
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* stubbable api-key-store / auth-json /
|
|
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
|
|
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
|
|
60
|
-
*
|
|
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.
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
|
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
|
}
|