amicus 2.2.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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +80 -0
  3. package/README.md +13 -8
  4. package/bin/amicus.js +5 -0
  5. package/electron/close-guard.js +4 -4
  6. package/electron/fold.js +8 -8
  7. package/electron/ipc-guard.js +3 -3
  8. package/electron/main.js +31 -31
  9. package/electron/opencode-theme.js +3 -3
  10. package/electron/preload-content.js +1 -1
  11. package/electron/setup-ui.js +27 -3
  12. package/package.json +4 -3
  13. package/skills/second-opinion/SKILL.md +2 -0
  14. package/skills/sidecar/SKILL.md +66 -38
  15. package/src/cli-handlers-resume-continue.js +31 -4
  16. package/src/cli-handlers-run.js +15 -4
  17. package/src/cli.js +13 -0
  18. package/src/mcp-server.js +99 -12
  19. package/src/mcp-tools.js +26 -4
  20. package/src/opencode-client.js +18 -2
  21. package/src/sidecar/continue.js +10 -3
  22. package/src/sidecar/electron-install.js +9 -8
  23. package/src/sidecar/fanout-leg.js +26 -1
  24. package/src/sidecar/fanout-output.js +5 -0
  25. package/src/sidecar/fanout-validate.js +81 -0
  26. package/src/sidecar/fanout.js +65 -77
  27. package/src/sidecar/session-utils.js +6 -0
  28. package/src/sidecar/setup.js +2 -1
  29. package/src/utils/alias-resolver.js +6 -35
  30. package/src/utils/api-key-store.js +1 -9
  31. package/src/utils/auth-json.js +1 -1
  32. package/src/utils/config.js +98 -16
  33. package/src/utils/curated-models.js +33 -4
  34. package/src/utils/gateway-router.js +115 -0
  35. package/src/utils/input-validators.js +12 -42
  36. package/src/utils/model-classification.js +65 -0
  37. package/src/utils/model-descriptor.js +72 -0
  38. package/src/utils/model-fetcher.js +35 -9
  39. package/src/utils/model-input-default.js +32 -0
  40. package/src/utils/model-validator.js +68 -84
  41. package/src/utils/node-version-guard.js +16 -0
  42. package/src/utils/provider-registry.js +57 -0
  43. package/src/utils/quick-picks.js +11 -3
  44. package/src/utils/result-schema-rebuild.js +98 -0
  45. package/src/utils/result-schema.js +6 -76
  46. package/src/utils/route-error.js +137 -0
  47. package/src/utils/route-launch.js +179 -0
  48. package/src/utils/start-helpers.js +96 -43
  49. package/src/utils/validators.js +1 -8
@@ -400,6 +400,10 @@ async function getSessionStatus(client, sessionId, directory) {
400
400
  * @param {AbortSignal} [options.signal] - Abort signal to stop server
401
401
  * @param {object} [options.mcp] - MCP server configurations
402
402
  * @param {string} [options.model] - Default model
403
+ * @param {string[]} [options.models] - Resolved executable id(s) actually launched (#61 Task
404
+ * 4.6/7.3 sole-input invariant) — a multi-model shared server (fanout) has no single
405
+ * default model, so ALL of them register in provider.models via this instead of `model`.
406
+ * Takes precedence over the `options.model` single-id fallback when non-empty.
403
407
  * @param {string} [options.client] - Client type ('cowork', 'code-local', etc.)
404
408
  * @param {string} [options.systemPrompt] - System prompt to set on agent config (hidden from UI)
405
409
  * @param {string} [options.agentName] - Agent to set systemPrompt on (default: 'chat')
@@ -473,9 +477,17 @@ function buildServerOptions(options = {}) {
473
477
  }
474
478
 
475
479
  // Sync sidecar aliases into OpenCode's provider.models so the UI
476
- // model picker shows all configured models (single source of truth).
480
+ // model picker shows all configured models (single source of truth),
481
+ // and register the actually-resolved launch route's provider so it
482
+ // always matches config.model (the "sole input" invariant — see #61).
483
+ // A multi-model shared server (fanout, #61 Task 7.3) has no single
484
+ // config.model — options.models carries EVERY leg's resolved id instead,
485
+ // and takes precedence over the single-id options.model fallback.
477
486
  const { buildProviderModels } = require('./utils/config');
478
- config.provider = buildProviderModels();
487
+ const resolvedForProvider = (Array.isArray(options.models) && options.models.length)
488
+ ? options.models
489
+ : (options.model ? [options.model] : []);
490
+ config.provider = buildProviderModels(resolvedForProvider);
479
491
 
480
492
  // Register custom 'chat' agent: reads auto-approved, writes/bash require permission
481
493
  const chatAgent = {
@@ -605,6 +617,10 @@ function buildServerHandle(sdkServer, deps = {}) {
605
617
  * @param {AbortSignal} [options.signal] - Abort signal to stop server
606
618
  * @param {object} [options.mcp] - MCP server configurations
607
619
  * @param {string} [options.model] - Default model
620
+ * @param {string[]} [options.models] - Resolved executable id(s) actually launched (#61 Task
621
+ * 4.6/7.3 sole-input invariant) — a multi-model shared server (fanout) has no single
622
+ * default model, so ALL of them register in provider.models via this instead of `model`.
623
+ * Takes precedence over the `options.model` single-id fallback when non-empty.
608
624
  * @param {string} [options.client] - Client type ('cowork', 'code-local', etc.)
609
625
  * @returns {Promise<{client: object, server: {url: string, close: Function}}>}
610
626
  */
@@ -88,7 +88,7 @@ Build on the previous sidecar's findings. The user wants to continue or extend t
88
88
 
89
89
  /** Create session metadata for continuation */
90
90
  function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
91
- const { model, briefing, headless, agent } = options;
91
+ const { model, briefing, headless, agent, gateway, resolutionVersion } = options;
92
92
 
93
93
  const sessionDir = SessionPaths.sessionDir(project, taskId);
94
94
  fs.mkdirSync(sessionDir, { recursive: true });
@@ -104,6 +104,12 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
104
104
  createdAt: new Date().toISOString(),
105
105
  continuesFrom: oldTaskId
106
106
  };
107
+ // #61 Task 5.2 (best-effort provenance): only present when THIS continue
108
+ // call freshly routed an explicit --model through the gateway router — the
109
+ // no-model inherit path never re-resolves, so gateway/resolutionVersion
110
+ // stay undefined there and are simply omitted (never written as `undefined`).
111
+ if (gateway !== undefined) { metadata.gateway = gateway; }
112
+ if (resolutionVersion !== undefined) { metadata.resolutionVersion = resolutionVersion; }
107
113
 
108
114
  writeFileAtomic(SessionPaths.metadataFile(sessionDir), JSON.stringify(metadata, null, 2));
109
115
 
@@ -123,7 +129,8 @@ async function continueSidecar(options) {
123
129
  headless = false,
124
130
  timeout = 15,
125
131
  agent,
126
- mcp, mcpConfig, client, noMcp, excludeMcp, json = false
132
+ mcp, mcpConfig, client, noMcp, excludeMcp, json = false,
133
+ gateway, resolutionVersion, // #61 Task 5.2 (best-effort provenance)
127
134
  } = options;
128
135
 
129
136
  // Load previous session data
@@ -168,7 +175,7 @@ async function continueSidecar(options) {
168
175
  logger.info('New continuation task', { newTaskId, oldTaskId });
169
176
 
170
177
  const sessionDir = createContinueSessionMetadata(newTaskId, project, {
171
- model, briefing, headless, agent: effectiveAgent
178
+ model, briefing, headless, agent: effectiveAgent, gateway, resolutionVersion,
172
179
  }, oldTaskId);
173
180
 
174
181
  // Lock the NEW continuation session dir too — not just the previous one — so a
@@ -146,7 +146,7 @@ function cacheRootFor(env = process.env) {
146
146
  * @returns {Promise<void>}
147
147
  */
148
148
  async function controlledProvision({
149
- electronDir, platform, arch, version, downloadArtifact, extract, fs, env = process.env,
149
+ electronDir, platform, arch, version, downloadArtifact, extract, fs, env = process.env, downloadMs = 480000,
150
150
  }) {
151
151
  const zip = await downloadArtifact({
152
152
  version,
@@ -155,11 +155,7 @@ async function controlledProvision({
155
155
  cacheRoot: cacheRootFor(env),
156
156
  platform,
157
157
  arch,
158
- checksums: undefined,
159
- // Bound the fetch so a stalled/blocked network aborts (got v11 timeouts:
160
- // socket = inactivity, request = total) instead of hanging the repair —
161
- // a hung-then-killed download is what orphaned the single-flight lock.
162
- downloadOptions: { timeout: { socket: 60000, request: 480000 } },
158
+ downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
163
159
  });
164
160
  await extractFromCache({ zip, electronDir, platform, extract, fs });
165
161
  }
@@ -214,7 +210,11 @@ async function repairElectron({
214
210
  const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs || 480000 }));
215
211
  const findZip = deps.cachedZip || ((o) => cachedZip(o));
216
212
  const acquireLock = deps.acquireLock || ((o) => acquireRepairLock({ ...o, fs }));
217
- const downloadArtifact = deps.downloadArtifact || require('@electron/get').downloadArtifact;
213
+ // Lazy: import the ESM-only @electron/get only on the network path, so cacheOnly
214
+ // repairs and injected mocks stay parseable under Jest (which can't import() ESM).
215
+ const resolveDownloadArtifact = deps.downloadArtifact
216
+ ? async () => deps.downloadArtifact
217
+ : async () => (await import('@electron/get')).downloadArtifact;
218
218
 
219
219
  if (!version) {
220
220
  try {
@@ -268,8 +268,9 @@ async function repairElectron({
268
268
  // download that produced no usable exe is a FAILURE (no false success; #53).
269
269
  let controlledExtracted = false;
270
270
  try {
271
+ const downloadArtifact = await resolveDownloadArtifact();
271
272
  await controlledProvision({
272
- electronDir, platform, arch, version, downloadArtifact, extract, fs, env: process.env,
273
+ electronDir, platform, arch, version, downloadArtifact, extract, fs, env: process.env, downloadMs: timeoutMs,
273
274
  });
274
275
  controlledExtracted = true; // download + extract returned without throwing
275
276
  } catch {
@@ -35,6 +35,31 @@ function writeLegPatch(legDir, patch) {
35
35
  return merged;
36
36
  }
37
37
 
38
+ /**
39
+ * Synthesize an error run document for a leg whose MODEL never routed (#61
40
+ * Task 7.3) — the gateway router returned `error` (or, in principle,
41
+ * `selection_required`) for this leg's requested model before any session was
42
+ * created, so there is nothing to run. Mirrors the shape runLeg's own catch
43
+ * branch already produces for "setup threw before the session dir existed":
44
+ * no legDir, no summary, an `error` status — this never touches the shared
45
+ * server or the Promise.all in runFanout, so it can never sink a sibling leg.
46
+ * @param {{leg: {modelInput: string, routeResult: object}, legId: string,
47
+ * waveId: string, quiet?: boolean}} args
48
+ * @returns {object} run document (status 'error')
49
+ */
50
+ function buildRoutingFailureLeg({ leg, legId, waveId, quiet }) {
51
+ const { toCliMessage } = require('../utils/route-error');
52
+ const { buildRunResult } = require('../utils/result-schema');
53
+ const message = toCliMessage(leg.routeResult);
54
+ if (!quiet) {
55
+ process.stderr.write(`[fanout] leg ${legId} (${leg.modelInput}): error (routing)\n`);
56
+ }
57
+ return buildRunResult({
58
+ taskId: legId, metadata: {}, result: { error: message, completed: false },
59
+ summary: null, modelInput: leg.modelInput, sessionDir: null, waveId,
60
+ });
61
+ }
62
+
38
63
  /**
39
64
  * Run one leg end-to-end: session record → runHeadless (shared server) →
40
65
  * leg finalize. Never throws — always resolves to a run document.
@@ -129,4 +154,4 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
129
154
  });
130
155
  }
131
156
 
132
- module.exports = { legStatusFromResult, writeLegPatch, runLeg };
157
+ module.exports = { legStatusFromResult, writeLegPatch, runLeg, buildRoutingFailureLeg };
@@ -32,6 +32,11 @@ function formatWaveHuman(wave) {
32
32
  lines.push('');
33
33
  }
34
34
  if (wave.error) { lines.push(`Error: ${wave.error}`); }
35
+ // #61 whole-branch review FIX 2: advisory per-leg migration notices (no
36
+ // routing effect) surfaced here since fanout has no CLI-single stderr path.
37
+ if (Array.isArray(wave.notices) && wave.notices.length) {
38
+ for (const notice of wave.notices) { lines.push(`Notice: ${notice}`); }
39
+ }
35
40
  lines.push('─'.repeat(40));
36
41
  const counts = wave.counts || { complete: '?', total: '?' };
37
42
  lines.push(`Wave ${wave.waveId}: ${wave.status} — ${counts.complete}/${counts.total} complete in ${fmtDuration(wave.durationMs)}`);
@@ -0,0 +1,81 @@
1
+ // src/sidecar/fanout-validate.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module fanout-validate
6
+ * Fan-out --models parsing + per-leg gateway routing. Split out of fanout.js
7
+ * (#61 Task 7.3) to keep both files under the 300-line size gate — this
8
+ * module owns everything about turning a raw --models string into resolved
9
+ * (or per-leg-failed) legs; fanout.js owns running the wave itself.
10
+ */
11
+
12
+ /** Default max legs per wave (env-overridable). */
13
+ const DEFAULT_MAX_LEGS = 10;
14
+
15
+ /**
16
+ * Split a --models value into trimmed, non-empty entries (duplicates allowed).
17
+ * @param {string|boolean|undefined} modelsArg
18
+ * @returns {string[]}
19
+ */
20
+ function parseModelsList(modelsArg) {
21
+ if (typeof modelsArg !== 'string') { return []; }
22
+ return modelsArg.split(',').map(s => s.trim()).filter(Boolean);
23
+ }
24
+
25
+ /**
26
+ * Resolve + gate every requested model through the gateway router (#61 Task
27
+ * 7.3), one leg at a time. Unlike the pre-#61 fail-fast validator, an
28
+ * individual leg's routing failure does NOT abort the whole wave: it comes
29
+ * back with `ok:false` and the router's RouteResult attached, so runFanout
30
+ * can synthesize a failed leg document (buildRoutingFailureLeg) while sibling
31
+ * legs still launch normally. Only whole-list problems — an empty list or
32
+ * exceeding the leg-count cap — remain wave-level fatal and are returned as
33
+ * a top-level `{error, code}` (nothing to route yet at that point).
34
+ * @param {string} modelsArg - Raw --models value
35
+ * @param {{noValidateModel?: boolean, gatewayMode?: string}} [opts]
36
+ * @returns {Promise<{legs: Array<{modelInput: string, ok: boolean, model?: string,
37
+ * pricing?: object, gateway?: string, provenance?: object, routeResult?: object}>}
38
+ * | {error: string, code: string}>}
39
+ */
40
+ async function validateFanoutModels(modelsArg, opts = {}) {
41
+ const raw = parseModelsList(modelsArg);
42
+ if (raw.length === 0) {
43
+ return { error: 'Error: --models requires a comma-separated list (e.g. gemini,gpt,deepseek)', code: 'BAD_ARGS' };
44
+ }
45
+ // Invalid or non-positive AMICUS_FANOUT_MAX_LEGS (0, negative, garbage) falls back to the default.
46
+ const envCap = Number(process.env.AMICUS_FANOUT_MAX_LEGS);
47
+ const maxLegs = (Number.isInteger(envCap) && envCap > 0) ? envCap : DEFAULT_MAX_LEGS;
48
+ if (raw.length > maxLegs) {
49
+ return { error: `Error: --models exceeds the fan-out cap of ${maxLegs} legs (set AMICUS_FANOUT_MAX_LEGS to raise)`, code: 'BAD_ARGS' };
50
+ }
51
+
52
+ const { resolveRouteForLaunch } = require('../utils/route-launch');
53
+ const { lookupPricing } = require('../utils/pricing');
54
+ const validateModel = !opts.noValidateModel;
55
+ const gatewayMode = opts.gatewayMode || 'auto';
56
+ const legs = [];
57
+ for (const modelInput of raw) {
58
+ const routeResult = await resolveRouteForLaunch({
59
+ model: modelInput, gatewayMode, source: 'cli', allowSelection: false, validateModel,
60
+ });
61
+ if (routeResult.kind === 'resolved') {
62
+ legs.push({
63
+ modelInput, ok: true, model: routeResult.executableId,
64
+ pricing: lookupPricing(routeResult.executableId),
65
+ gateway: routeResult.gateway, provenance: routeResult.provenance,
66
+ // FIX 2 (#61 whole-branch review): resolveRouteForLaunch already
67
+ // burned the one-shot migration_notified flag for this vendor when it
68
+ // built routeResult — carry the notice out so runFanout can surface
69
+ // it on the wave doc (fanout had no other path to show it).
70
+ notice: routeResult.notice || null,
71
+ });
72
+ } else {
73
+ // 'error' (headless: allowSelection false, so the router never hands
74
+ // back 'selection_required' here — see gateway-router.js's catalogGate).
75
+ legs.push({ modelInput, ok: false, routeResult });
76
+ }
77
+ }
78
+ return { legs };
79
+ }
80
+
81
+ module.exports = { parseModelsList, DEFAULT_MAX_LEGS, validateFanoutModels };
@@ -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.