amicus 3.0.0 → 3.1.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.
Files changed (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +43 -0
  3. package/README.md +12 -3
  4. package/electron/setup-ui.js +27 -3
  5. package/package.json +1 -1
  6. package/skills/second-opinion/SKILL.md +2 -0
  7. package/skills/sidecar/SKILL.md +66 -38
  8. package/src/cli-handlers-resume-continue.js +31 -4
  9. package/src/cli-handlers-run.js +15 -4
  10. package/src/cli.js +13 -0
  11. package/src/mcp-server.js +99 -12
  12. package/src/mcp-tools.js +26 -4
  13. package/src/opencode-client.js +18 -2
  14. package/src/sidecar/continue.js +10 -3
  15. package/src/sidecar/fanout-leg.js +26 -1
  16. package/src/sidecar/fanout-output.js +5 -0
  17. package/src/sidecar/fanout-validate.js +81 -0
  18. package/src/sidecar/fanout.js +65 -77
  19. package/src/sidecar/session-utils.js +6 -0
  20. package/src/sidecar/setup.js +2 -1
  21. package/src/utils/alias-resolver.js +6 -35
  22. package/src/utils/api-key-store.js +1 -9
  23. package/src/utils/auth-json.js +1 -1
  24. package/src/utils/config.js +98 -16
  25. package/src/utils/curated-models.js +33 -4
  26. package/src/utils/gateway-router.js +115 -0
  27. package/src/utils/input-validators.js +12 -42
  28. package/src/utils/model-classification.js +65 -0
  29. package/src/utils/model-descriptor.js +72 -0
  30. package/src/utils/model-fetcher.js +35 -9
  31. package/src/utils/model-input-default.js +32 -0
  32. package/src/utils/model-validator.js +68 -84
  33. package/src/utils/provider-registry.js +57 -0
  34. package/src/utils/quick-picks.js +11 -3
  35. package/src/utils/result-schema-rebuild.js +98 -0
  36. package/src/utils/result-schema.js +6 -76
  37. package/src/utils/route-error.js +137 -0
  38. package/src/utils/route-launch.js +179 -0
  39. package/src/utils/start-helpers.js +96 -43
  40. package/src/utils/validators.js +1 -8
@@ -13,23 +13,11 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const { logger } = require('../utils/logger');
16
- const { runLeg } = require('./fanout-leg');
16
+ const { runLeg, buildRoutingFailureLeg } = require('./fanout-leg');
17
+ const { parseModelsList, DEFAULT_MAX_LEGS, validateFanoutModels } = require('./fanout-validate');
17
18
  const { ERROR_CODES } = require('../utils/error-doc');
18
19
  const { writeFileAtomic } = require('../utils/atomic-write');
19
20
 
20
- /** Default max legs per wave (env-overridable). */
21
- const DEFAULT_MAX_LEGS = 10;
22
-
23
- /**
24
- * Split a --models value into trimmed, non-empty entries (duplicates allowed).
25
- * @param {string|boolean|undefined} modelsArg
26
- * @returns {string[]}
27
- */
28
- function parseModelsList(modelsArg) {
29
- if (typeof modelsArg !== 'string') { return []; }
30
- return modelsArg.split(',').map(s => s.trim()).filter(Boolean);
31
- }
32
-
33
21
  /**
34
22
  * Derive leg task IDs: <waveId>-1 .. <waveId>-N (matches TASK_ID_PATTERN).
35
23
  * @param {string} waveId
@@ -40,53 +28,6 @@ function deriveLegIds(waveId, count) {
40
28
  return Array.from({ length: count }, (_, i) => `${waveId}-${i + 1}`);
41
29
  }
42
30
 
43
- /**
44
- * Fail-fast validation of the whole model list BEFORE any leg launches:
45
- * alias resolution, API-key presence, live-catalog validation (F3 machinery).
46
- * @param {string} modelsArg - Raw --models value
47
- * @param {{noValidateModel?: boolean}} [opts]
48
- * @returns {Promise<{legs: Array<{modelInput: string, model: string}>} | {error: string}>}
49
- */
50
- async function validateFanoutModels(modelsArg, opts = {}) {
51
- const raw = parseModelsList(modelsArg);
52
- if (raw.length === 0) {
53
- return { error: 'Error: --models requires a comma-separated list (e.g. gemini,gpt,deepseek)', code: 'BAD_ARGS' };
54
- }
55
- // Invalid or non-positive AMICUS_FANOUT_MAX_LEGS (0, negative, garbage) falls back to the default.
56
- const envCap = Number(process.env.AMICUS_FANOUT_MAX_LEGS);
57
- const maxLegs = (Number.isInteger(envCap) && envCap > 0) ? envCap : DEFAULT_MAX_LEGS;
58
- if (raw.length > maxLegs) {
59
- return { error: `Error: --models exceeds the fan-out cap of ${maxLegs} legs (set AMICUS_FANOUT_MAX_LEGS to raise)`, code: 'BAD_ARGS' };
60
- }
61
-
62
- const { tryResolveModel } = require('../utils/config');
63
- const { validateApiKey } = require('../utils/validators');
64
- const { validateAgainstCatalog } = require('../utils/model-validator');
65
- const { lookupPricing } = require('../utils/pricing');
66
- const legs = [];
67
- for (const modelInput of raw) {
68
- const resolved = tryResolveModel(modelInput);
69
- if (resolved.error) {
70
- return { error: `Error: model '${modelInput}': ${resolved.error}`, code: 'BAD_MODEL' };
71
- }
72
- let model = resolved.model;
73
- const keyCheck = validateApiKey(model);
74
- if (!keyCheck.valid) {
75
- return { error: keyCheck.error, code: 'MISSING_KEY' };
76
- }
77
- if (!opts.noValidateModel) {
78
- const alias = modelInput.includes('/') ? undefined : modelInput;
79
- try {
80
- model = await validateAgainstCatalog(model, alias);
81
- } catch (err) {
82
- return { error: err.message, code: 'BAD_MODEL' };
83
- }
84
- }
85
- legs.push({ modelInput, model, pricing: lookupPricing(model) });
86
- }
87
- return { legs };
88
- }
89
-
90
31
  /**
91
32
  * Write/merge wave metadata (preserves fields an MCP pre-spawn handler wrote).
92
33
  * Abort-wins: once existing status is 'aborted', a patch cannot demote it back
@@ -114,7 +55,9 @@ function writeWaveMetadata(waveDir, patch) {
114
55
  * thinking?, timeout? (minutes), summaryLength?, includeContext?, sessionId?,
115
56
  * coworkProcess? (#10: Cowork parent-session pin, forwarded to buildContext),
116
57
  * contextTurns?, contextSince?, contextMaxTokens?, mcp?, mcpConfig?, noMcp?,
117
- * excludeMcp?, noValidateModel?, json?, client?, quiet? (suppress stdout tests)
58
+ * excludeMcp?, noValidateModel?, gatewayMode? (#61 Task 7.3: --gateway merged
59
+ * with routing.prefer, applied per leg), json?, client?, quiet? (suppress
60
+ * stdout — tests)
118
61
  * @returns {Promise<{wave: object, exitCode: number}>} Never rejects for leg errors.
119
62
  */
120
63
  async function runFanout(options) {
@@ -158,19 +101,33 @@ async function runFanout(options) {
158
101
  return { wave: null, errorDoc: { code, message }, exitCode: 1 };
159
102
  };
160
103
 
161
- // 1. Fail-fast validation
162
- const validated = await validateFanoutModels(options.models, { noValidateModel: options.noValidateModel });
104
+ // 1. Fail-fast validation (list-level only — see validateFanoutModels).
105
+ // Per-leg routing is resolved here too (#61 Task 7.3): a leg that fails to
106
+ // route is NOT a wave-level failure — it comes back `ok:false` and still
107
+ // occupies its slot in `legs`, so sibling legs launch normally (step 6).
108
+ const validated = await validateFanoutModels(options.models, {
109
+ noValidateModel: options.noValidateModel,
110
+ gatewayMode: options.gatewayMode,
111
+ });
163
112
  if (validated.error) { return failPre(validated.code || 'BAD_ARGS', validated.error); }
164
113
  const legs = validated.legs;
114
+ const okLegs = legs.filter(l => l.ok);
115
+ // FIX 2 (#61 whole-branch review): a leg's migration notice has no CLI
116
+ // stderr to land on (fanout is one process resolving many legs, not one
117
+ // launch) — surface it on the wave doc instead, deduped in case two legs
118
+ // for the same vendor happen to both migrate (only the first ever fires
119
+ // since markMigrationNotified is one-shot per vendor, but dedupe defensively).
120
+ const notices = [...new Set(legs.map(l => l.notice).filter(Boolean))];
165
121
 
166
- // 1b. Budget gate (pre-creation; refuse before spending)
122
+ // 1b. Budget gate (pre-creation; refuse before spending). Only legs that
123
+ // will actually run cost anything — a leg that never routed never spends.
167
124
  if (!options.noCostGate) {
168
125
  const { checkBudget, formatBudgetError } = require('./budget');
169
126
  const { loadConfig } = require('../utils/config');
170
127
  const cfg = loadConfig() || {};
171
128
  const maxCostPerMtok = options.maxCostPerMtok !== undefined ? options.maxCostPerMtok : cfg.maxCostPerMtok;
172
129
  const promptChars = (options.promptMeta && options.promptMeta.chars) || (options.prompt ? options.prompt.length : 0);
173
- const budget = checkBudget(legs, { maxCostPerMtok, maxCost: options.maxCost !== null && options.maxCost !== undefined ? options.maxCost : cfg.maxCost, promptChars });
130
+ const budget = checkBudget(okLegs, { maxCostPerMtok, maxCost: options.maxCost !== null && options.maxCost !== undefined ? options.maxCost : cfg.maxCost, promptChars });
174
131
  if (!budget.ok) {
175
132
  return failPre(ERROR_CODES.BUDGET_EXCEEDED, 'Error: budget gate refused the wave', formatBudgetError(budget));
176
133
  }
@@ -184,12 +141,32 @@ async function runFanout(options) {
184
141
  fs.writeFileSync(path.join(waveDir, 'briefing.md'), options.prompt, { mode: 0o600 });
185
142
  writeWaveMetadata(waveDir, {
186
143
  taskId: waveId, type: 'wave', status: 'running', mode: 'headless',
187
- models: legs.map(l => l.model), legs: legIds,
144
+ models: legs.map(l => (l.ok ? l.model : l.modelInput)), legs: legIds,
188
145
  briefing: String(options.prompt).slice(0, 200),
189
146
  promptMeta: options.promptMeta || null,
190
147
  pid: process.pid, project, createdAt,
191
148
  });
192
149
 
150
+ // 2b. All legs failed to route (#61 perf): no leg will ever touch the
151
+ // shared server, so starting one (and immediately tearing it down) is pure
152
+ // waste. Short-circuit straight to the same routing-failure wave the
153
+ // normal path would eventually produce — same per-leg docs
154
+ // (buildRoutingFailureLeg), same aggregation (buildWaveResult /
155
+ // waveStatusFromLegs via the default status param), same exit-code mapping
156
+ // (waveExitCode) — just without the server round-trip.
157
+ if (okLegs.length === 0) {
158
+ const legDocs = legs.map((leg, i) => buildRoutingFailureLeg({ leg, legId: legIds[i], waveId, quiet: options.quiet }));
159
+ const completedAt = new Date().toISOString();
160
+ const wave = buildWaveResult({
161
+ waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt, notices,
162
+ });
163
+ const wavePath = path.join(waveDir, 'wave.json');
164
+ writeFileAtomic(wavePath, JSON.stringify(wave, null, 2), { mode: 0o600 });
165
+ writeWaveMetadata(waveDir, { status: wave.status, completedAt });
166
+ emit(wave);
167
+ return { wave, exitCode: waveExitCode(wave.status) };
168
+ }
169
+
193
170
  // 3. Context + prompts built ONCE (model-independent)
194
171
  const context = options.includeContext !== false
195
172
  ? buildContext(project, options.sessionId || 'current', {
@@ -207,14 +184,18 @@ async function runFanout(options) {
207
184
  options.prompt, context, project, true, options.agent || 'build', options.summaryLength, options.client, foldNonce
208
185
  );
209
186
 
210
- // 4. One shared OpenCode server
187
+ // 4. One shared OpenCode server. Sole-input invariant (#61 Task 4.6/7.3):
188
+ // register EVERY leg's actually-resolved executable id in provider.models,
189
+ // not just the alias-derived defaults, so a leg whose router decision
190
+ // diverges from its alias (e.g. alias stores openrouter/... but the router
191
+ // picked direct) still matches what the leg is actually told to launch.
211
192
  const mcpServers = buildMcpConfig({
212
193
  mcp: options.mcp, mcpConfig: options.mcpConfig, clientType: options.client,
213
194
  noMcp: options.noMcp, excludeMcp: options.excludeMcp,
214
195
  });
215
196
  let client, server;
216
197
  try {
217
- ({ client, server } = await startOpenCodeServer(mcpServers));
198
+ ({ client, server } = await startOpenCodeServer(mcpServers, { models: okLegs.map(l => l.model) }));
218
199
  } catch (err) {
219
200
  writeWaveMetadata(waveDir, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
220
201
  return errorWave(waveId, `Failed to start server: ${err.message}`);
@@ -244,7 +225,11 @@ async function runFanout(options) {
244
225
  },
245
226
  });
246
227
 
247
- // 6. Launch all legs concurrently (runLeg never rejects)
228
+ // 6. Launch all ROUTABLE legs concurrently (runLeg never rejects). A leg
229
+ // that failed to route (leg.ok === false) never touches the shared server —
230
+ // it resolves immediately to an error run document (buildRoutingFailureLeg)
231
+ // in its own slot, so it fails only itself, never the sibling legs or the
232
+ // whole wave (#61 Task 7.3).
248
233
  const heartbeat = options.quiet
249
234
  ? { stop() {} }
250
235
  : createWaveHeartbeat(
@@ -255,12 +240,15 @@ async function runFanout(options) {
255
240
  const reasoning = options.thinking ? { effort: options.thinking } : undefined;
256
241
  let legDocs;
257
242
  try {
258
- legDocs = await Promise.all(legs.map((leg, i) => runLeg({
259
- leg, legId: legIds[i], waveId, project, systemPrompt, userMessage,
260
- timeoutMs, agent: options.agent, client, server,
261
- summaryLength: options.summaryLength, reasoning, quiet: options.quiet,
262
- foldNonce,
263
- })));
243
+ legDocs = await Promise.all(legs.map((leg, i) => (leg.ok
244
+ ? runLeg({
245
+ leg, legId: legIds[i], waveId, project, systemPrompt, userMessage,
246
+ timeoutMs, agent: options.agent, client, server,
247
+ summaryLength: options.summaryLength, reasoning, quiet: options.quiet,
248
+ foldNonce,
249
+ })
250
+ : Promise.resolve(buildRoutingFailureLeg({ leg, legId: legIds[i], waveId, quiet: options.quiet }))
251
+ )));
264
252
  } finally {
265
253
  heartbeat.stop();
266
254
  uninstallSignals();
@@ -271,7 +259,7 @@ async function runFanout(options) {
271
259
  const completedAt = new Date().toISOString();
272
260
  const wave = buildWaveResult({
273
261
  waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt,
274
- status: signalled ? 'aborted' : null,
262
+ status: signalled ? 'aborted' : null, notices,
275
263
  });
276
264
  const wavePath = path.join(waveDir, 'wave.json');
277
265
  writeFileAtomic(wavePath, JSON.stringify(wave, null, 2), { mode: 0o600 });
@@ -224,6 +224,11 @@ async function executeMode(options) {
224
224
  * @param {string} [options.client] - Client type (e.g. 'cowork', 'code-local')
225
225
  * @param {string} [options.systemPrompt] - System prompt to set on agent config (hidden from UI)
226
226
  * @param {string} [options.agentName] - Agent to set systemPrompt on (default: 'chat')
227
+ * @param {string[]} [options.models] - Resolved executable id(s) actually launched on this
228
+ * server (#61 Task 4.6/7.3 sole-input invariant) — a multi-model shared server (fanout)
229
+ * has no single default `config.model`, so this registers ALL of them in provider.models
230
+ * instead. Additive alongside the single-model `options.model` path used by owned-server
231
+ * callers (start/continue); see opencode-client.js's buildServerOptions.
227
232
  * @returns {Promise<{client: object, server: object}>}
228
233
  * @throws {Error} If server fails to start or health check fails
229
234
  */
@@ -244,6 +249,7 @@ async function startOpenCodeServer(mcpConfig, options = {}) {
244
249
  const serverOptions = { port };
245
250
  if (mcpConfig) { serverOptions.mcp = mcpConfig; }
246
251
  if (options.client) { serverOptions.client = options.client; }
252
+ if (options.models) { serverOptions.models = options.models; }
247
253
  if (options.systemPrompt) { serverOptions.systemPrompt = options.systemPrompt; }
248
254
  if (options.agentName) { serverOptions.agentName = options.agentName; }
249
255
 
@@ -288,6 +288,7 @@ async function runReadlineSetup() {
288
288
 
289
289
  const { getCatalog } = require('../utils/model-catalog');
290
290
  const { resolveQuickPicks, toLiveSeedAliases } = require('../utils/quick-picks');
291
+ const { toCanonicalDefault } = require('../utils/curated-models');
291
292
  let catalog = [];
292
293
  try { catalog = await getCatalog(); } catch (_err) { /* offline: pinned */ }
293
294
  const picks = resolveQuickPicks(catalog);
@@ -316,7 +317,7 @@ async function runReadlineSetup() {
316
317
  cfg.default = chosen.alias;
317
318
  const pick = picks.find(p => p.alias === chosen.alias);
318
319
  if (pick && !chosen.noUpgrade) {
319
- cfg.aliases[chosen.alias] = pick.routes.openrouter || Object.values(pick.routes)[0];
320
+ cfg.aliases[chosen.alias] = toCanonicalDefault(pick.routes.openrouter || Object.values(pick.routes)[0]);
320
321
  } else if (cfg.aliases[chosen.alias] === undefined) {
321
322
  const fallback = getDefaultAliases()[chosen.alias];
322
323
  if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
@@ -1,41 +1,13 @@
1
1
  /**
2
2
  * Alias Resolver Utilities
3
3
  *
4
- * Handles alias auto-repair and direct API fallback logic,
5
- * extracted from config.js to keep it under the 300-line limit.
4
+ * Handles alias auto-repair, extracted from config.js to keep it under the
5
+ * 300-line limit. The direct-vs-OpenRouter gateway decision that used to live
6
+ * here (applyDirectApiFallback, a prefix-stripping heuristic) is now owned
7
+ * end-to-end by the gateway router (route-launch.js / gateway-router.js,
8
+ * #61) on every launch path — this module no longer makes that call.
6
9
  */
7
10
 
8
- const { PROVIDER_ENV_MAP, readApiKeyValues } = require('./api-key-store');
9
- const { logger } = require('./logger');
10
-
11
- /**
12
- * Strip openrouter/ prefix when direct provider API key is available
13
- * but OPENROUTER_API_KEY is not.
14
- * @param {string} model - Full model identifier
15
- * @returns {string} Model with or without openrouter/ prefix
16
- */
17
- function applyDirectApiFallback(model) {
18
- if (!model.startsWith('openrouter/')) {
19
- return model;
20
- }
21
- const persistedKeys = readApiKeyValues();
22
- if (process.env.OPENROUTER_API_KEY || persistedKeys.openrouter) {
23
- return model;
24
- }
25
- const direct = model.slice('openrouter/'.length);
26
- const provider = direct.split('/')[0];
27
- const envVar = PROVIDER_ENV_MAP[provider];
28
- if (envVar && (process.env[envVar] || persistedKeys[provider])) {
29
- logger.warn({ msg: 'Using direct provider API (OPENROUTER_API_KEY not set)', original: model, resolved: direct });
30
- process.stderr.write(
31
- `Notice: Using direct ${provider} API (OPENROUTER_API_KEY not set). ` +
32
- 'Model availability is validated automatically; pass --no-validate-model to skip.\n'
33
- );
34
- return direct;
35
- }
36
- return model;
37
- }
38
-
39
11
  /**
40
12
  * Auto-repair a null alias by falling back to DEFAULT_ALIASES.
41
13
  * Updates config on disk and warns to stderr.
@@ -63,7 +35,7 @@ function autoRepairAlias(alias, config, defaultAliases, saveConfig) {
63
35
  );
64
36
  }
65
37
  }
66
- return applyDirectApiFallback(defaultModel);
38
+ return defaultModel;
67
39
  }
68
40
  throw new Error(
69
41
  `Alias '${alias}' is configured but has no model value. ` +
@@ -72,6 +44,5 @@ function autoRepairAlias(alias, config, defaultAliases, saveConfig) {
72
44
  }
73
45
 
74
46
  module.exports = {
75
- applyDirectApiFallback,
76
47
  autoRepairAlias,
77
48
  };
@@ -5,15 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { validateApiKey, validateOpenRouterKey, VALIDATION_ENDPOINTS } = require('./api-key-validation');
8
-
9
- /** Maps provider IDs to environment variable names */
10
- const PROVIDER_ENV_MAP = {
11
- openrouter: 'OPENROUTER_API_KEY',
12
- google: 'GOOGLE_GENERATIVE_AI_API_KEY',
13
- openai: 'OPENAI_API_KEY',
14
- anthropic: 'ANTHROPIC_API_KEY',
15
- deepseek: 'DEEPSEEK_API_KEY'
16
- };
8
+ const { PROVIDER_ENV_MAP } = require('./provider-registry');
17
9
 
18
10
  /** Legacy key names that have been renamed (old -> new) */
19
11
  const LEGACY_KEY_NAMES = {
@@ -50,7 +50,7 @@ function resolveAuthJsonPath(env = process.env) {
50
50
  const AUTH_JSON_PATH = resolveAuthJsonPath();
51
51
 
52
52
  /** Known provider IDs that map to sidecar's PROVIDER_ENV_MAP */
53
- const KNOWN_PROVIDERS = ['openrouter', 'google', 'openai', 'anthropic', 'deepseek'];
53
+ const { KNOWN_PROVIDERS } = require('./provider-registry');
54
54
 
55
55
  /**
56
56
  * Extract key value from an auth.json provider entry.
@@ -8,7 +8,8 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const crypto = require('crypto');
11
- const { applyDirectApiFallback, autoRepairAlias } = require('./alias-resolver');
11
+ const { autoRepairAlias } = require('./alias-resolver');
12
+ const { isDirectProvider } = require('./provider-registry');
12
13
 
13
14
  /** Default model alias map — derived from the curated-models single source (F5) */
14
15
  const { toDefaultAliases } = require('./curated-models');
@@ -111,7 +112,9 @@ function resolveModel(modelArg) {
111
112
  if (!resolved || resolved === 'null') {
112
113
  return autoRepairAlias(modelArg, config, DEFAULT_ALIASES, saveConfig);
113
114
  }
114
- return applyDirectApiFallback(resolved);
115
+ // Router (route-launch.js / gateway-router.js, #61) owns the
116
+ // direct-vs-OpenRouter decision now — return the stored id verbatim.
117
+ return resolved;
115
118
  }
116
119
 
117
120
  // Unknown alias
@@ -140,7 +143,8 @@ function resolveModel(modelArg) {
140
143
  if (!resolved || resolved === 'null') {
141
144
  return autoRepairAlias(defaultValue, config, DEFAULT_ALIASES, saveConfig);
142
145
  }
143
- return applyDirectApiFallback(resolved);
146
+ // Router owns the direct-vs-OpenRouter decision now — return verbatim.
147
+ return resolved;
144
148
  }
145
149
 
146
150
  // Default alias not found anywhere
@@ -235,16 +239,42 @@ function tryResolveModel(modelArg) {
235
239
  }
236
240
  }
237
241
 
238
- /** Build OpenCode provider.models config from sidecar aliases.
242
+ /** Build OpenCode provider.models config from sidecar aliases, plus the
243
+ * actually-resolved launch route(s). The alias-derived entries let the UI
244
+ * model picker show every configured model (single source of truth for the
245
+ * picker); resolvedRoutes ensures the id OpenCode is ACTUALLY told to launch
246
+ * (config.model) is always registered under its correct provider, even when
247
+ * an alias maps to a different provider for the same model (e.g. an alias
248
+ * stores `openrouter/openai/gpt-5.5` but the router resolves DIRECT to
249
+ * `openai/gpt-5.5` — without this, only `openrouter` would be registered,
250
+ * mismatching config.model).
251
+ *
252
+ * Catalog broadening (#61 whole-branch review, FIX 1): a resolvedRoutes entry
253
+ * only covers the route(s) resolved AT SERVER-CREATION TIME. A long-lived,
254
+ * multi-session server (the MCP shared server, `utils/shared-server.js`) is
255
+ * created ONCE via `sharedServer.ensureServer()` and then serves MANY
256
+ * sessions over its lifetime, each of which independently asks the gateway
257
+ * router (direct-first policy) to route the SAME bare alias — some sessions
258
+ * land DIRECT, others land on OpenRouter, depending on which keys happen to
259
+ * be configured when each session starts. Since the shared server's
260
+ * `provider.models` is fixed at creation and never rebuilt per-session,
261
+ * threading only that first session's resolved id is insufficient. So for
262
+ * every alias that resolves to a BARE direct-capable-vendor id (post-#61
263
+ * default aliases are bare, e.g. `openai/gpt-5.5`), this ALSO registers the
264
+ * `openrouter/<vendor>/<model>` form — broadening the catalog to cover BOTH
265
+ * routes the router might pick, regardless of which session created the
266
+ * server. This does NOT change what the alias itself resolves to (still
267
+ * bare, still direct-first) — it only widens what's pre-registered.
268
+ * @param {string[]} [resolvedRoutes] executable model id(s) actually launched
239
269
  * @returns {object} e.g. { openrouter: { models: { "x-ai/grok-4.3": {}, ... } } } */
240
- function buildProviderModels() {
270
+ function buildProviderModels(resolvedRoutes = []) {
241
271
  const aliases = getEffectiveAliases();
242
272
  const providers = {};
243
273
 
244
- for (const fullModel of Object.values(aliases)) {
245
- if (!fullModel || typeof fullModel !== 'string') { continue; }
274
+ const addRoute = (fullModel) => {
275
+ if (!fullModel || typeof fullModel !== 'string') { return; }
246
276
  const parts = fullModel.split('/');
247
- if (parts.length < 2) { continue; }
277
+ if (parts.length < 2) { return; }
248
278
 
249
279
  const providerID = parts[0];
250
280
  const modelID = parts.slice(1).join('/');
@@ -253,16 +283,28 @@ function buildProviderModels() {
253
283
  providers[providerID] = { models: {} };
254
284
  }
255
285
  providers[providerID].models[modelID] = {};
286
+ };
287
+
288
+ for (const fullModel of Object.values(aliases)) {
289
+ addRoute(fullModel);
290
+
291
+ // Broaden: a bare direct-capable-vendor route also gets an OpenRouter
292
+ // mirror registered (see catalog-broadening note above). Gateway-only
293
+ // aliases (already `openrouter/...`, e.g. grok/qwen/x-ai) are untouched —
294
+ // OpenRouter is their only possible route anyway, already covered above.
295
+ if (typeof fullModel === 'string' && !fullModel.startsWith('openrouter/')) {
296
+ const vendor = fullModel.split('/')[0];
297
+ if (isDirectProvider(vendor)) {
298
+ addRoute(`openrouter/${fullModel}`);
299
+ }
300
+ }
256
301
  }
257
302
 
258
- return providers;
259
- }
303
+ for (const resolved of resolvedRoutes) {
304
+ addRoute(resolved);
305
+ }
260
306
 
261
- /** Detect if direct API fallback was applied during alias resolution */
262
- function detectFallback(alias, resolvedModel) {
263
- if (!alias || alias.includes('/')) { return false; }
264
- const val = getEffectiveAliases()[alias];
265
- return !!(val && val.startsWith('openrouter/') && !resolvedModel.startsWith('openrouter/'));
307
+ return providers;
266
308
  }
267
309
 
268
310
  /** @returns {Object<string,string[]>} the councils map (empty if none) */
@@ -339,6 +381,44 @@ function resolveCouncilMembers(name, catalog = []) {
339
381
  return { models, dropped };
340
382
  }
341
383
 
384
+ /** @returns {{prefer:'direct'|'openrouter', migration_notified:Object}} routing config with defaults */
385
+ function getRoutingConfig() {
386
+ const config = loadConfig() || {};
387
+ const r = (config.routing && typeof config.routing === 'object') ? config.routing : {};
388
+ const prefer = r.prefer === 'openrouter' ? 'openrouter' : 'direct';
389
+ const migration_notified = (r.migration_notified && typeof r.migration_notified === 'object') ? r.migration_notified : {};
390
+ return { prefer, migration_notified };
391
+ }
392
+
393
+ /** Merge --gateway (perCall) with routing.prefer into a router gatewayMode.
394
+ * @param {string|undefined} perCall 'auto'|'direct'|'openrouter'|undefined
395
+ * @returns {'auto'|'direct'|'openrouter'} */
396
+ function resolveGatewayMode(perCall) {
397
+ if (perCall && perCall !== 'auto') { return perCall; }
398
+ const { prefer } = getRoutingConfig();
399
+ return prefer === 'openrouter' ? 'openrouter' : 'auto';
400
+ }
401
+
402
+ /**
403
+ * Persist the one-time direct-migration notice flag for a vendor (#61 Task
404
+ * 5.1 — visible-migration guarantee). Best-effort: swallows any saveConfig
405
+ * failure so a persistence hiccup never breaks the launch that triggered it.
406
+ * @param {string} vendor
407
+ */
408
+ function markMigrationNotified(vendor) {
409
+ try {
410
+ const config = loadConfig() || {};
411
+ if (!config.routing || typeof config.routing !== 'object') { config.routing = {}; }
412
+ if (!config.routing.migration_notified || typeof config.routing.migration_notified !== 'object') {
413
+ config.routing.migration_notified = {};
414
+ }
415
+ config.routing.migration_notified[vendor] = true;
416
+ saveConfig(config);
417
+ } catch (_err) {
418
+ // best-effort: never fail the launch over a persistence error
419
+ }
420
+ }
421
+
342
422
  module.exports = {
343
423
  getConfigDir,
344
424
  getConfigPath,
@@ -346,7 +426,6 @@ module.exports = {
346
426
  saveConfig,
347
427
  getDefaultAliases,
348
428
  resolveModel,
349
- detectFallback,
350
429
  computeConfigHash,
351
430
  buildAliasTable,
352
431
  checkConfigChanged,
@@ -358,4 +437,7 @@ module.exports = {
358
437
  getCouncil,
359
438
  getCouncilWithSource,
360
439
  resolveCouncilMembers,
440
+ getRoutingConfig,
441
+ resolveGatewayMode,
442
+ markMigrationNotified,
361
443
  };
@@ -12,6 +12,8 @@
12
12
 
13
13
  'use strict';
14
14
 
15
+ const { isDirectProvider } = require('./provider-registry');
16
+
15
17
  /**
16
18
  * Wizard quick-pick families. `idPattern` matches the model segment after
17
19
  * `<vendorPath>/` (openrouter ns) or `<provider>/` (direct ns).
@@ -91,15 +93,42 @@ function getFamilies() {
91
93
  }
92
94
 
93
95
  /**
94
- * @returns {Object<string,string>} alias pinned route (openrouter first). STATIC — runtime-safe.
96
+ * Direct-first canonicalization for a pinned `openrouter/<vendor>/<rest>`
97
+ * route: when `<vendor>` has a direct integration (provider-registry
98
+ * `isDirectProvider`), strip the `openrouter/` prefix so the resulting bare
99
+ * `<vendor>/<rest>` id is policy-routed by the gateway router (direct when a
100
+ * direct key exists, OpenRouter otherwise). Gateway-only vendors (no direct
101
+ * integration — e.g. qwen, x-ai, z-ai, mistralai, minimax, moonshotai,
102
+ * bytedance-seed) are returned unchanged, since OpenRouter is their only
103
+ * route anyway. Non-openrouter routes (already bare, or malformed) pass
104
+ * through unchanged.
105
+ * @param {string} route
106
+ * @returns {string}
107
+ */
108
+ function toCanonicalDefault(route) {
109
+ if (typeof route === 'string' && route.startsWith('openrouter/')) {
110
+ const rest = route.slice('openrouter/'.length); // '<vendor>/<rest...>'
111
+ const slashIdx = rest.indexOf('/');
112
+ const vendor = slashIdx > 0 ? rest.slice(0, slashIdx) : null;
113
+ if (vendor && isDirectProvider(vendor)) { return rest; }
114
+ }
115
+ return route;
116
+ }
117
+
118
+ /**
119
+ * @returns {Object<string,string>} alias → pinned route, direct-first for
120
+ * direct-capable vendors (bare `vendor/model`), openrouter-prefixed for
121
+ * gateway-only vendors. STATIC — runtime-safe.
95
122
  */
96
123
  function toDefaultAliases() {
97
124
  const out = {};
98
125
  for (const f of FAMILIES) {
99
- out[f.alias] = f.fallback.openrouter || Object.values(f.fallback)[0];
126
+ const route = f.fallback.openrouter || Object.values(f.fallback)[0];
127
+ out[f.alias] = toCanonicalDefault(route);
100
128
  }
101
129
  for (const e of CARDLESS) {
102
- out[e.alias] = e.routes.openrouter || Object.values(e.routes)[0];
130
+ const route = e.routes.openrouter || Object.values(e.routes)[0];
131
+ out[e.alias] = toCanonicalDefault(route);
103
132
  }
104
133
  return out;
105
134
  }
@@ -122,4 +151,4 @@ function listCuratedRoutes() {
122
151
  return out;
123
152
  }
124
153
 
125
- module.exports = { getFamilies, toDefaultAliases, listCuratedRoutes };
154
+ module.exports = { getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes };