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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +44 -0
- package/README.md +2 -2
- package/electron/ipc-setup.js +33 -0
- package/electron/preload-setup.js +2 -1
- package/electron/setup-ui-aliases.js +1 -1
- package/electron/setup-ui-keys-script.js +8 -1
- package/electron/setup-ui-provider-default.js +101 -0
- package/electron/setup-ui-styles.js +13 -1
- package/electron/setup-ui.js +5 -1
- package/package.json +2 -1
- package/src/cli-handlers-run.js +9 -1
- package/src/cli-handlers.js +40 -0
- package/src/cli.js +4 -0
- package/src/sidecar/models.js +39 -17
- package/src/sidecar/setup.js +80 -11
- package/src/utils/config.js +59 -0
- package/src/utils/curated-models.js +69 -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/model-tiers.js +120 -0
- package/src/utils/provider-default-picker.js +234 -0
- package/src/utils/provider-default-prompt.js +118 -0
- package/src/utils/result-schema.js +9 -2
- package/src/utils/route-error.js +26 -9
- package/src/utils/route-launch.js +147 -27
- package/src/utils/start-helpers.js +53 -0
package/src/sidecar/setup.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
246
|
-
* 3.
|
|
247
|
-
* 4.
|
|
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
|
-
|
|
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];
|
package/src/utils/config.js
CHANGED
|
@@ -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-
|
|
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,64 @@ 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
|
+
* 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).
|
|
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');
|