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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +13 -8
- package/bin/amicus.js +5 -0
- package/electron/close-guard.js +4 -4
- package/electron/fold.js +8 -8
- package/electron/ipc-guard.js +3 -3
- package/electron/main.js +31 -31
- package/electron/opencode-theme.js +3 -3
- package/electron/preload-content.js +1 -1
- package/electron/setup-ui.js +27 -3
- package/package.json +4 -3
- package/skills/second-opinion/SKILL.md +2 -0
- package/skills/sidecar/SKILL.md +66 -38
- package/src/cli-handlers-resume-continue.js +31 -4
- package/src/cli-handlers-run.js +15 -4
- package/src/cli.js +13 -0
- package/src/mcp-server.js +99 -12
- package/src/mcp-tools.js +26 -4
- package/src/opencode-client.js +18 -2
- package/src/sidecar/continue.js +10 -3
- package/src/sidecar/electron-install.js +9 -8
- package/src/sidecar/fanout-leg.js +26 -1
- package/src/sidecar/fanout-output.js +5 -0
- package/src/sidecar/fanout-validate.js +81 -0
- package/src/sidecar/fanout.js +65 -77
- package/src/sidecar/session-utils.js +6 -0
- package/src/sidecar/setup.js +2 -1
- package/src/utils/alias-resolver.js +6 -35
- package/src/utils/api-key-store.js +1 -9
- package/src/utils/auth-json.js +1 -1
- package/src/utils/config.js +98 -16
- package/src/utils/curated-models.js +33 -4
- package/src/utils/gateway-router.js +115 -0
- package/src/utils/input-validators.js +12 -42
- package/src/utils/model-classification.js +65 -0
- package/src/utils/model-descriptor.js +72 -0
- package/src/utils/model-fetcher.js +35 -9
- package/src/utils/model-input-default.js +32 -0
- package/src/utils/model-validator.js +68 -84
- package/src/utils/node-version-guard.js +16 -0
- package/src/utils/provider-registry.js +57 -0
- package/src/utils/quick-picks.js +11 -3
- package/src/utils/result-schema-rebuild.js +98 -0
- package/src/utils/result-schema.js +6 -76
- package/src/utils/route-error.js +137 -0
- package/src/utils/route-launch.js +179 -0
- package/src/utils/start-helpers.js +96 -43
- package/src/utils/validators.js +1 -8
package/src/opencode-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*/
|
package/src/sidecar/continue.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -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?,
|
|
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
|
-
|
|
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(
|
|
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) =>
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
package/src/sidecar/setup.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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
|
|
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 = {
|
package/src/utils/auth-json.js
CHANGED
|
@@ -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 =
|
|
53
|
+
const { KNOWN_PROVIDERS } = require('./provider-registry');
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* Extract key value from an auth.json provider entry.
|