amicus 3.1.0 → 3.2.0

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.
@@ -187,8 +187,12 @@ async function seedCatalog(print) {
187
187
  * (Enter = the vendor-diverse default), seeds aliases + councils.free, and
188
188
  * never touches config.default.
189
189
  * @param {readline.Interface} rl
190
+ * @param {Array<object>} [catalogArg] pre-fetched catalog from the caller
191
+ * (`runReadlineSetup` already fetches one for the per-provider phase) --
192
+ * reused as-is to avoid a second `getCatalog()` round trip. Falls back to
193
+ * fetching its own when omitted (e.g. direct unit-test callers).
190
194
  */
191
- async function runFreeCouncilBranch(rl) {
195
+ async function runFreeCouncilBranch(rl, catalogArg) {
192
196
  const keys = detectApiKeys();
193
197
  if (!keys.openrouter) {
194
198
  console.log('');
@@ -196,10 +200,14 @@ async function runFreeCouncilBranch(rl) {
196
200
  console.log('Set OPENROUTER_API_KEY and re-run: amicus setup. No changes made.');
197
201
  return;
198
202
  }
199
- const { getCatalog } = require('../utils/model-catalog');
200
203
  const { listFreeModels, suggestFreeCouncil, PINNED_FREE_MODELS } = require('../utils/free-models');
201
204
  let catalog = [];
202
- try { catalog = await getCatalog(); } catch (_e) { /* offline */ }
205
+ if (Array.isArray(catalogArg)) {
206
+ catalog = catalogArg;
207
+ } else {
208
+ const { getCatalog } = require('../utils/model-catalog');
209
+ try { catalog = await getCatalog(); } catch (_e) { /* offline */ }
210
+ }
203
211
  let free = listFreeModels(catalog);
204
212
  if (free.length === 0) {
205
213
  console.log('Live free-model list unavailable (offline?) — using a small pinned set.');
@@ -237,14 +245,63 @@ async function runFreeCouncilBranch(rl) {
237
245
  console.log('unless you enable data-sharing at openrouter.ai/settings/privacy.');
238
246
  }
239
247
 
248
+ /**
249
+ * Run the shared per-provider default-model picker (Task 6, `runProviderDefaultFlow`)
250
+ * once for each keyed provider, in `foundKeys` detection order -- whichever
251
+ * comes first seeds `config.default` (`applyProviderDefault`'s
252
+ * seed-only-when-absent rule; see `provider-default-picker.js`). Each provider
253
+ * is its own read-modify-write against the real config file, so an alias
254
+ * written here is on disk before the standard model step's own `loadConfig()`
255
+ * runs -- no clobber, no shared in-memory object to race.
256
+ *
257
+ * Skippable and crash-proof: `runProviderDefaultFlow` already degrades to a
258
+ * graceful summary line on an empty/offline catalog (never calls `ask`), and
259
+ * a per-provider try/catch means one provider's failure can't block the rest
260
+ * of setup (mirrors `cli-handlers.js`'s `offerProviderDefault`). It's also a
261
+ * graceful no-op for a gateway provider (`openrouter`) -- see
262
+ * `runProviderDefaultFlow`'s `isDirectProvider` gate.
263
+ * @param {readline.Interface} rl
264
+ * @param {string[]} foundKeys keyed providers, in detection order
265
+ * @param {Array<object>} catalog
266
+ * @returns {Promise<Set<string>>} vendor alias NAMES actually written this run
267
+ * (i.e. `runProviderDefaultFlow` returned a non-null `chosenId`) -- the
268
+ * standard model step must not clobber these when a quick-pick family
269
+ * alias collides with one (see the standard-model-step alias-upgrade guard).
270
+ */
271
+ async function runProviderDefaultPickers(rl, foundKeys, catalog) {
272
+ const written = new Set();
273
+ if (foundKeys.length === 0) { return written; }
274
+ const { runProviderDefaultFlow } = require('../utils/provider-default-prompt');
275
+ for (const provider of foundKeys) {
276
+ try {
277
+ const { chosenId, summaryLine } = await runProviderDefaultFlow(provider, {
278
+ interactive: true,
279
+ ask: (q) => askQuestion(rl, q),
280
+ catalog,
281
+ print: console.log,
282
+ });
283
+ if (chosenId) { written.add(provider); }
284
+ console.log(summaryLine);
285
+ } catch (err) {
286
+ console.log(
287
+ `Note: couldn't set a default for ${provider} (${err.message}). ` +
288
+ `Run \`amicus key ${provider}\` again later.`
289
+ );
290
+ }
291
+ }
292
+ console.log('');
293
+ return written;
294
+ }
295
+
240
296
  /**
241
297
  * Run the readline-based setup wizard (headless fallback)
242
298
  *
243
299
  * Guides the user through:
244
300
  * 1. API key detection
245
- * 2. Mode selection (standard or free council)
246
- * 3. Default model selection from live quick-picks (read-modify-write, no clobber)
247
- * 4. Config file save
301
+ * 2. Per-provider default-model picker (Task 7) -- once per keyed provider
302
+ * 3. Mode selection (standard or free council)
303
+ * 4. Default model selection from live quick-picks (read-modify-write, no clobber)
304
+ * 5. Config file save
248
305
  */
249
306
  async function runReadlineSetup() {
250
307
  const rl = readline.createInterface({
@@ -279,18 +336,25 @@ async function runReadlineSetup() {
279
336
  await warnOnLowOpenRouterCredit();
280
337
  }
281
338
 
339
+ const { getCatalog } = require('../utils/model-catalog');
340
+ let catalog = [];
341
+ try { catalog = await getCatalog(); } catch (_err) { /* offline: pinned */ }
342
+
343
+ // Task 7 (cost-aware defaults P2): per-provider picker, once per keyed
344
+ // provider, BEFORE the mode prompt -- orthogonal to standard-vs-free-council.
345
+ // `vendorAliasesWritten` is consulted by the standard model step below so
346
+ // it never clobbers a vendor alias this phase just wrote (Fix 2).
347
+ const vendorAliasesWritten = await runProviderDefaultPickers(rl, foundKeys, catalog);
348
+
282
349
  const mode = await askQuestion(rl,
283
350
  'Setup mode — 1) Standard (pick a default model) 2) Free OpenRouter council: ');
284
351
  if (mode === '2') {
285
- await runFreeCouncilBranch(rl);
352
+ await runFreeCouncilBranch(rl, catalog);
286
353
  return;
287
354
  }
288
355
 
289
- const { getCatalog } = require('../utils/model-catalog');
290
356
  const { resolveQuickPicks, toLiveSeedAliases } = require('../utils/quick-picks');
291
357
  const { toCanonicalDefault } = require('../utils/curated-models');
292
- let catalog = [];
293
- try { catalog = await getCatalog(); } catch (_err) { /* offline: pinned */ }
294
358
  const picks = resolveQuickPicks(catalog);
295
359
 
296
360
  console.log('Choose your default model:');
@@ -316,7 +380,12 @@ async function runReadlineSetup() {
316
380
  if (chosen.alias) {
317
381
  cfg.default = chosen.alias;
318
382
  const pick = picks.find(p => p.alias === chosen.alias);
319
- if (pick && !chosen.noUpgrade) {
383
+ // Fix 2: a quick-pick family alias (e.g. 'deepseek') can collide with a
384
+ // vendor alias the per-provider phase just wrote this run. `config.default`
385
+ // pointing at that alias name is fine (the user's explicit overall-default
386
+ // choice), but the alias's VALUE must stay the vendor phase's tier choice --
387
+ // skip the curated-flagship upgrade so it isn't discarded.
388
+ if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
320
389
  cfg.aliases[chosen.alias] = toCanonicalDefault(pick.routes.openrouter || Object.values(pick.routes)[0]);
321
390
  } else if (cfg.aliases[chosen.alias] === undefined) {
322
391
  const fallback = getDefaultAliases()[chosen.alias];
@@ -419,6 +419,60 @@ function markMigrationNotified(vendor) {
419
419
  }
420
420
  }
421
421
 
422
+ /**
423
+ * Existing-user one-time onboarding offer (Part 2, Task 9). Mirrors
424
+ * markMigrationNotified's flag pattern: a single boolean persisted at
425
+ * config.routing.tier_onboarded once the notice has fired, so it never
426
+ * repeats.
427
+ * @returns {boolean} true once the notice has fired
428
+ */
429
+ function hasTierOnboarded() {
430
+ const config = loadConfig() || {};
431
+ return !!(config.routing && config.routing.tier_onboarded === true);
432
+ }
433
+
434
+ /**
435
+ * Persist the one-time onboarding-notice flag, preserving any other routing
436
+ * keys (prefer, tier, migration_notified). Best-effort: swallows any
437
+ * saveConfig failure so a persistence hiccup never breaks the command that
438
+ * triggered it (mirrors markMigrationNotified).
439
+ */
440
+ function markTierOnboarded() {
441
+ try {
442
+ const config = loadConfig() || {};
443
+ if (!config.routing || typeof config.routing !== 'object') { config.routing = {}; }
444
+ config.routing.tier_onboarded = true;
445
+ saveConfig(config);
446
+ } catch (_err) {
447
+ // best-effort: never fail the command over a persistence error
448
+ }
449
+ }
450
+
451
+ /** Global cost-tier preference (Part 2, Task 1) — priciest-to-cheapest. */
452
+ const COST_TIERS = ['frontier', 'balanced', 'economy'];
453
+
454
+ /** @returns {'frontier'|'balanced'|'economy'} config.routing.tier, defaulting/coercing to 'balanced' */
455
+ function getCostTier() {
456
+ const config = loadConfig() || {};
457
+ const tier = config.routing && config.routing.tier;
458
+ return COST_TIERS.includes(tier) ? tier : 'balanced';
459
+ }
460
+
461
+ /**
462
+ * Persist the global cost-tier preference under routing.tier, preserving any
463
+ * other routing keys (prefer, migration_notified).
464
+ * @param {string} tier one of COST_TIERS
465
+ * @throws {Error} when tier is not a recognized cost tier
466
+ */
467
+ function setCostTier(tier) {
468
+ if (!COST_TIERS.includes(tier)) {
469
+ throw new Error(`Invalid cost tier '${tier}'. Must be one of: ${COST_TIERS.join(', ')}`);
470
+ }
471
+ const config = loadConfig() || {};
472
+ config.routing = { ...(config.routing || {}), tier };
473
+ saveConfig(config);
474
+ }
475
+
422
476
  module.exports = {
423
477
  getConfigDir,
424
478
  getConfigPath,
@@ -440,4 +494,9 @@ module.exports = {
440
494
  getRoutingConfig,
441
495
  resolveGatewayMode,
442
496
  markMigrationNotified,
497
+ COST_TIERS,
498
+ getCostTier,
499
+ setCostTier,
500
+ hasTierOnboarded,
501
+ markTierOnboarded,
443
502
  };
@@ -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,64 @@ 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
+ * Frozen so consumers can only read it (`.has()`) — a frozen Set still
164
+ * supports lookups, it just can't be `.add()`/`.delete()`/`.clear()`-ed.
165
+ */
166
+ const DIVERGENT_VENDORS = Object.freeze(new Set(['anthropic']));
167
+
168
+ /**
169
+ * @param {string} orRoute e.g. 'openrouter/anthropic/claude-sonnet-5'
170
+ * @returns {string} the vendor segment, e.g. 'anthropic'
171
+ */
172
+ function vendorOf(orRoute) {
173
+ const rest = orRoute.slice('openrouter/'.length);
174
+ return rest.slice(0, rest.indexOf('/'));
175
+ }
176
+
177
+ /**
178
+ * @param {string} vendorPath
179
+ * @param {Object<string,string>} obj a family.fallback or cardless.routes map
180
+ * @returns {string|undefined} the direct-API executable id, or undefined
181
+ * when no direct form is available for this alias.
182
+ */
183
+ function directFormFor(vendorPath, obj) {
184
+ if (obj[vendorPath]) { return obj[vendorPath]; } // explicit, authored, current direct id
185
+ if (DIVERGENT_VENDORS.has(vendorPath)) { return undefined; } // no explicit form + divergent → omit
186
+ const bare = toCanonicalDefault(obj.openrouter); // safe only when ids are identical across gateways
187
+ return bare !== obj.openrouter ? bare : undefined; // gateway-only vendor → undefined
188
+ }
189
+
190
+ /**
191
+ * @param {string} vendorPath
192
+ * @param {Object<string,string>} obj a family.fallback or cardless.routes map
193
+ * @returns {{direct?: string, openrouter: string}}
194
+ */
195
+ function gatewayRoutesFor(vendorPath, obj) {
196
+ const routes = { openrouter: obj.openrouter };
197
+ const direct = directFormFor(vendorPath, obj);
198
+ if (direct) { routes.direct = direct; }
199
+ return routes;
200
+ }
201
+
202
+ /**
203
+ * @returns {Object<string,{direct?: string, openrouter: string}>} alias →
204
+ * per-gateway executable ids. Unlike `toDefaultAliases` (a single pinned
205
+ * string per alias, used for display/`config.default`), this carries BOTH
206
+ * gateway-native forms so the router (Task 3) can route direct-first
207
+ * without corrupting divergent-vendor ids (e.g. Anthropic's dash format).
208
+ */
209
+ function toGatewayRoutes() {
210
+ const out = {};
211
+ for (const f of FAMILIES) { out[f.alias] = gatewayRoutesFor(f.vendorPath, f.fallback); }
212
+ for (const e of CARDLESS) { out[e.alias] = gatewayRoutesFor(vendorOf(e.routes.openrouter), e.routes); }
213
+ return out;
214
+ }
215
+
216
+ module.exports = {
217
+ getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes, DIVERGENT_VENDORS
218
+ };
@@ -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');