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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +43 -0
- package/README.md +12 -3
- package/electron/setup-ui.js +27 -3
- package/package.json +1 -1
- 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/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/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/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.
|
package/src/utils/config.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const crypto = require('crypto');
|
|
11
|
-
const {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
245
|
-
if (!fullModel || typeof fullModel !== 'string') {
|
|
274
|
+
const addRoute = (fullModel) => {
|
|
275
|
+
if (!fullModel || typeof fullModel !== 'string') { return; }
|
|
246
276
|
const parts = fullModel.split('/');
|
|
247
|
-
if (parts.length < 2) {
|
|
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
|
-
|
|
259
|
-
|
|
303
|
+
for (const resolved of resolvedRoutes) {
|
|
304
|
+
addRoute(resolved);
|
|
305
|
+
}
|
|
260
306
|
|
|
261
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|