amicus 3.0.0 → 3.1.1
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 +66 -0
- package/README.md +13 -4
- package/electron/setup-ui-aliases.js +1 -1
- package/electron/setup-ui.js +27 -3
- package/package.json +2 -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 +17 -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/models.js +39 -17
- 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 +99 -8
- package/src/utils/gateway-route-audit.js +103 -0
- package/src/utils/gateway-route-catalog.js +92 -0
- package/src/utils/gateway-router.js +131 -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 +46 -14
- 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 +15 -78
- package/src/utils/route-error.js +154 -0
- package/src/utils/route-launch.js +219 -0
- package/src/utils/start-helpers.js +96 -43
- package/src/utils/validators.js +1 -8
|
@@ -121,9 +121,10 @@ function waveExitCode(waveStatus) {
|
|
|
121
121
|
* @param {string|null} [opts.createdAt]
|
|
122
122
|
* @param {string|null} [opts.completedAt]
|
|
123
123
|
* @param {string|null} [opts.status] - Override (e.g. 'aborted' on signal); default aggregates legs
|
|
124
|
+
* @param {string[]} [opts.notices] - Advisory per-leg migration notices (#61 FIX 2); never affects status/exitCode.
|
|
124
125
|
* @returns {object} wave document
|
|
125
126
|
*/
|
|
126
|
-
function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null }) {
|
|
127
|
+
function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [] }) {
|
|
127
128
|
const { sumWaveUsage } = require('./pricing');
|
|
128
129
|
// Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
|
|
129
130
|
// 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
|
|
@@ -149,84 +150,13 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
|
|
|
149
150
|
completedAt,
|
|
150
151
|
durationMs,
|
|
151
152
|
usage: sumWaveUsage(legs),
|
|
153
|
+
notices: Array.isArray(notices) ? notices.filter(Boolean) : [],
|
|
152
154
|
};
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
* @param {string} taskId
|
|
159
|
-
* @returns {object} run document
|
|
160
|
-
* @throws {Error} if the session does not exist or metadata.json is missing/corrupt
|
|
161
|
-
*/
|
|
162
|
-
function buildRunResultFromSession(project, taskId) {
|
|
163
|
-
const fs = require('fs');
|
|
164
|
-
const path = require('path');
|
|
165
|
-
const { resolveExistingSessionDir } = require('../session-manager');
|
|
166
|
-
const sessionDir = resolveExistingSessionDir(project, taskId);
|
|
167
|
-
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
168
|
-
if (!fs.existsSync(metaPath)) {
|
|
169
|
-
throw new Error(`Session ${taskId} not found`);
|
|
170
|
-
}
|
|
171
|
-
let metadata;
|
|
172
|
-
try {
|
|
173
|
-
metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
174
|
-
} catch (err) {
|
|
175
|
-
throw new Error(`Session ${taskId}: metadata is corrupt (${err.message})`);
|
|
176
|
-
}
|
|
177
|
-
const summaryPath = path.join(sessionDir, 'summary.md');
|
|
178
|
-
const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : null;
|
|
179
|
-
return buildRunResult({ taskId, metadata, summary, sessionDir });
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Rebuild a wave document. Prefers the stored wave.json (written atomically at
|
|
184
|
-
* fanout exit); falls back to a live rebuild from leg sessions (e.g. after a
|
|
185
|
-
* hard kill of the fanout process).
|
|
186
|
-
* @param {string} project
|
|
187
|
-
* @param {string} waveId
|
|
188
|
-
* @returns {object} wave document
|
|
189
|
-
* @throws {Error} if the wave session does not exist or metadata.json is missing/corrupt
|
|
190
|
-
*/
|
|
191
|
-
function buildWaveResultFromSession(project, waveId) {
|
|
192
|
-
const fs = require('fs');
|
|
193
|
-
const path = require('path');
|
|
194
|
-
const { resolveExistingSessionDir } = require('../session-manager');
|
|
195
|
-
const waveDir = resolveExistingSessionDir(project, waveId);
|
|
196
|
-
const wavePath = path.join(waveDir, 'wave.json');
|
|
197
|
-
if (fs.existsSync(wavePath)) {
|
|
198
|
-
try {
|
|
199
|
-
return JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
|
|
200
|
-
} catch {
|
|
201
|
-
// Corrupt wave.json (e.g. hard-kill mid-write) — fall through to live rebuild
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
const metaPath = path.join(waveDir, 'metadata.json');
|
|
205
|
-
if (!fs.existsSync(metaPath)) {
|
|
206
|
-
throw new Error(`Wave ${waveId} not found`);
|
|
207
|
-
}
|
|
208
|
-
let meta;
|
|
209
|
-
try {
|
|
210
|
-
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
211
|
-
} catch (err) {
|
|
212
|
-
throw new Error(`Wave ${waveId}: metadata is corrupt (${err.message})`);
|
|
213
|
-
}
|
|
214
|
-
const legs = (meta.legs || []).map((legId) => {
|
|
215
|
-
try { return buildRunResultFromSession(project, legId); }
|
|
216
|
-
catch (err) {
|
|
217
|
-
const { logger } = require('./logger');
|
|
218
|
-
logger.warn('Failed to rebuild leg session; using unknown stub', { legId, error: err.message });
|
|
219
|
-
return buildRunResult({ taskId: legId, metadata: { status: 'unknown', parentWave: waveId } });
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
|
-
return buildWaveResult({
|
|
223
|
-
waveId,
|
|
224
|
-
legs,
|
|
225
|
-
promptMeta: meta.promptMeta || null,
|
|
226
|
-
createdAt: meta.createdAt || null,
|
|
227
|
-
completedAt: meta.completedAt || null,
|
|
228
|
-
});
|
|
229
|
-
}
|
|
157
|
+
// buildRunResultFromSession/buildWaveResultFromSession live in
|
|
158
|
+
// ./result-schema-rebuild.js (size-gate split); re-exported below.
|
|
159
|
+
const { buildRunResultFromSession, buildWaveResultFromSession } = require('./result-schema-rebuild');
|
|
230
160
|
|
|
231
161
|
/**
|
|
232
162
|
* Build a model-catalog document (`models [--search] [--refresh] --json`).
|
|
@@ -252,15 +182,22 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
|
|
|
252
182
|
|
|
253
183
|
/**
|
|
254
184
|
* Build an alias-audit document (`models --check --json`).
|
|
255
|
-
*
|
|
185
|
+
* `gatewayFindings` (Task 6, #gwid) is additive: the per-gateway-form audit
|
|
186
|
+
* of curated DEFAULT aliases (toGatewayRoutes() vs. the live catalog),
|
|
187
|
+
* distinct from the flat `stale` audit above. Defaults to [] so existing
|
|
188
|
+
* callers that omit it are unaffected.
|
|
189
|
+
* @param {{stale: Array<{alias,model,source,suggestions}>, catalogAvailable: boolean,
|
|
190
|
+
* gatewayFindings?: Array<{alias,gateway,kind,model,expected?}>}} opts
|
|
256
191
|
*/
|
|
257
|
-
function buildAuditDoc({ stale, catalogAvailable }) {
|
|
192
|
+
function buildAuditDoc({ stale, catalogAvailable, gatewayFindings = [] }) {
|
|
258
193
|
return {
|
|
259
194
|
schemaVersion: SCHEMA_VERSION,
|
|
260
195
|
type: 'alias-audit',
|
|
261
196
|
catalogAvailable,
|
|
262
197
|
staleCount: stale.length,
|
|
263
198
|
stale,
|
|
199
|
+
gatewayFindingsCount: gatewayFindings.length,
|
|
200
|
+
gatewayFindings,
|
|
264
201
|
};
|
|
265
202
|
}
|
|
266
203
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module route-error
|
|
3
|
+
* Shared renderer (#61 Task 6.1): turns a router RouteResult — an error or a
|
|
4
|
+
* selection_required — into the two surfaces that need to explain it:
|
|
5
|
+
* - `toStructuredError` -> the MCP-facing structured object
|
|
6
|
+
* - `toCliMessage` -> a human stderr string for the CLI
|
|
7
|
+
*
|
|
8
|
+
* Pure module: no I/O, no requires of launch modules (cli.js/headless.js/
|
|
9
|
+
* mcp-server.js/etc). Wired into live launch paths — start-helpers.js,
|
|
10
|
+
* sidecar/fanout-leg.js, and mcp-server.js all render RouteResults through
|
|
11
|
+
* toStructuredError/toCliMessage.
|
|
12
|
+
*
|
|
13
|
+
* Router error shape (src/utils/model-descriptor.js `routeError()`):
|
|
14
|
+
* {kind:'error', type:'model_route_error', field, requested, reason,
|
|
15
|
+
* preferredGateway, suggestions}
|
|
16
|
+
* Selection shape (`selectionRequired()`):
|
|
17
|
+
* {kind:'selection_required', requested, suggestions}
|
|
18
|
+
*
|
|
19
|
+
* The router's error `reason` is NOT limited to the 7 values in
|
|
20
|
+
* ROUTE_ERROR_REASONS below — that array is just the original/base set,
|
|
21
|
+
* intentionally pinned as-is (see its own doc comment). The router can also
|
|
22
|
+
* emit availability reasons (`direct_unavailable`, `openrouter_unavailable`),
|
|
23
|
+
* which have REASON_TEXT/FIX_HINTS entries but are deliberately excluded from
|
|
24
|
+
* ROUTE_ERROR_REASONS. A `selection_required` result has no `reason` of its
|
|
25
|
+
* own — it is synthesized here as SELECTION_REQUIRED_REASON, kept in the same
|
|
26
|
+
* documented REASON_TEXT map rather than invented ad hoc, so callers can
|
|
27
|
+
* treat every rendered structured error the same way regardless of which
|
|
28
|
+
* RouteResult produced it.
|
|
29
|
+
*/
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The original/base set of router error reasons — NOT an exhaustive list of
|
|
34
|
+
* every reason a router error can carry. Pinned to exactly these 7 values by
|
|
35
|
+
* a back-compat test (route-error.test.js:14-24), so this array must not be
|
|
36
|
+
* extended when new reasons are added. The router also emits
|
|
37
|
+
* `direct_unavailable` and `openrouter_unavailable` (REASON_TEXT/FIX_HINTS
|
|
38
|
+
* below have entries for both); those are intentionally left out of this
|
|
39
|
+
* array. Do not use ROUTE_ERROR_REASONS as an exhaustive switch/allow-list.
|
|
40
|
+
*/
|
|
41
|
+
const ROUTE_ERROR_REASONS = Object.freeze([
|
|
42
|
+
'gateway_conflict',
|
|
43
|
+
'no_openrouter_key',
|
|
44
|
+
'no_direct_integration',
|
|
45
|
+
'no_direct_key',
|
|
46
|
+
'no_key_for_vendor',
|
|
47
|
+
'model_not_found',
|
|
48
|
+
'invalid_descriptor',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Synthesized reason for a `selection_required` RouteResult. Deliberately
|
|
53
|
+
* distinct from 'model_not_found': the model wasn't missing, it was ambiguous
|
|
54
|
+
* (multiple catalog candidates) and the router is asking the caller to pick.
|
|
55
|
+
*/
|
|
56
|
+
const SELECTION_REQUIRED_REASON = 'selection_required';
|
|
57
|
+
|
|
58
|
+
/** One-line, non-technical explanation of what went wrong, keyed by reason. */
|
|
59
|
+
const REASON_TEXT = Object.freeze({
|
|
60
|
+
gateway_conflict: 'This model must go through OpenRouter, but --gateway direct was forced.',
|
|
61
|
+
no_openrouter_key: 'No OpenRouter API key is configured.',
|
|
62
|
+
no_direct_integration: 'This vendor has no direct API integration.',
|
|
63
|
+
no_direct_key: "No API key is configured for this vendor's direct API.",
|
|
64
|
+
no_key_for_vendor: 'No API key was found for this vendor via any gateway.',
|
|
65
|
+
model_not_found: 'The requested model was not found in the catalog.',
|
|
66
|
+
invalid_descriptor: 'The model identifier could not be parsed.',
|
|
67
|
+
direct_unavailable: "This model isn't available on the vendor's direct API; use OpenRouter or a different model.",
|
|
68
|
+
openrouter_unavailable: "This model isn't on OpenRouter; use --gateway direct or a different model.",
|
|
69
|
+
[SELECTION_REQUIRED_REASON]: 'Multiple models match your request; a specific one must be selected.',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** Copy-paste fix guidance appended after the REASON_TEXT sentence. */
|
|
73
|
+
const FIX_HINTS = Object.freeze({
|
|
74
|
+
gateway_conflict: "An openrouter/... model can't be forced with --gateway direct.",
|
|
75
|
+
no_openrouter_key: 'Set OPENROUTER_API_KEY, or use --gateway direct.',
|
|
76
|
+
no_direct_integration: 'This vendor has no direct integration; drop --gateway direct.',
|
|
77
|
+
no_direct_key: 'Add a key with `amicus key <vendor> <key>`, or use --gateway openrouter.',
|
|
78
|
+
no_key_for_vendor: 'Add a provider key or an OpenRouter key.',
|
|
79
|
+
model_not_found: 'Run `amicus models --refresh`, or pass --no-validate-model.',
|
|
80
|
+
invalid_descriptor: 'Use a vendor/model id or a configured alias.',
|
|
81
|
+
direct_unavailable: 'Drop --gateway direct (use auto or --gateway openrouter), or pick a different model.',
|
|
82
|
+
openrouter_unavailable: 'Use --gateway direct, or pick a different model.',
|
|
83
|
+
[SELECTION_REQUIRED_REASON]: 'Pick one of the suggestions below, or narrow the model id.',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** @returns {Array} suggestions normalized to an array. */
|
|
87
|
+
function normalizeSuggestions(suggestions) {
|
|
88
|
+
return Array.isArray(suggestions) ? suggestions : [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Render a router RouteResult (error or selection_required) into the
|
|
93
|
+
* MCP-facing structured object. Pass-through/normalize for an `error` result;
|
|
94
|
+
* synthesized for a `selection_required` result.
|
|
95
|
+
* @param {object} result a RouteResult with kind 'error' or 'selection_required'
|
|
96
|
+
* @returns {{type:'model_route_error', field:string, requested:*, reason:string,
|
|
97
|
+
* preferredGateway:(string|null), suggestions:Array}}
|
|
98
|
+
*/
|
|
99
|
+
function toStructuredError(result) {
|
|
100
|
+
const r = result || {};
|
|
101
|
+
if (r.kind === 'selection_required') {
|
|
102
|
+
return {
|
|
103
|
+
type: 'model_route_error',
|
|
104
|
+
field: 'model',
|
|
105
|
+
requested: r.requested,
|
|
106
|
+
reason: SELECTION_REQUIRED_REASON,
|
|
107
|
+
preferredGateway: null,
|
|
108
|
+
suggestions: normalizeSuggestions(r.suggestions),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// Router `error` result (kind:'error'): pass through/normalize.
|
|
112
|
+
return {
|
|
113
|
+
type: 'model_route_error',
|
|
114
|
+
field: r.field || 'model',
|
|
115
|
+
requested: r.requested,
|
|
116
|
+
reason: r.reason,
|
|
117
|
+
preferredGateway: r.preferredGateway || null,
|
|
118
|
+
suggestions: normalizeSuggestions(r.suggestions),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Render a router RouteResult into a human stderr message: the reason's
|
|
124
|
+
* one-line explanation, an optional "Did you mean" suggestion list, and a
|
|
125
|
+
* fix hint.
|
|
126
|
+
* @param {object} result a RouteResult with kind 'error' or 'selection_required'
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
function toCliMessage(result) {
|
|
130
|
+
const err = toStructuredError(result);
|
|
131
|
+
const sentence = REASON_TEXT[err.reason] || `Model routing error (${err.reason}).`;
|
|
132
|
+
const lines = [err.requested ? `${sentence} (requested "${err.requested}")` : sentence];
|
|
133
|
+
|
|
134
|
+
if (err.suggestions.length > 0) {
|
|
135
|
+
lines.push('Did you mean:');
|
|
136
|
+
for (const s of err.suggestions) {
|
|
137
|
+
const note = s && s.note ? ` — ${s.note}` : '';
|
|
138
|
+
lines.push(` - ${s && s.model} (${s && s.gateway})${note}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const hint = FIX_HINTS[err.reason];
|
|
143
|
+
if (hint) { lines.push(hint); }
|
|
144
|
+
|
|
145
|
+
return lines.join('\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
toStructuredError,
|
|
150
|
+
toCliMessage,
|
|
151
|
+
REASON_TEXT,
|
|
152
|
+
ROUTE_ERROR_REASONS,
|
|
153
|
+
SELECTION_REQUIRED_REASON,
|
|
154
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route-launch views (#61 gateway routing integration, Task 4.2).
|
|
3
|
+
*
|
|
4
|
+
* Read-only helpers consumed by resolveRouteForLaunch, which IS wired into
|
|
5
|
+
* live launch paths (start-helpers.js, mcp-server.js, sidecar/fanout-validate.js).
|
|
6
|
+
* Pure-ish: all I/O goes through the stubbable api-key-store / auth-json /
|
|
7
|
+
* model-catalog modules.
|
|
8
|
+
*/
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { readApiKeys } = require('./api-key-store');
|
|
12
|
+
const { readAuthJsonKeys } = require('./auth-json');
|
|
13
|
+
const { KNOWN_PROVIDERS } = require('./provider-registry');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Per-provider key presence across BOTH sources: env/.env (readApiKeys) and
|
|
17
|
+
* OpenCode's auth.json (readAuthJsonKeys). True if either source has a key
|
|
18
|
+
* for that provider (Foundation carry-forward, Decision 5).
|
|
19
|
+
* @returns {Object<string,boolean>} map of provider id -> key present
|
|
20
|
+
*/
|
|
21
|
+
function buildLaunchKeys() {
|
|
22
|
+
const env = readApiKeys(); // {openrouter:bool, google:bool, openai:bool, anthropic:bool, deepseek:bool}
|
|
23
|
+
const authKeys = readAuthJsonKeys(); // {provider:string,...} (only providers with keys)
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const p of KNOWN_PROVIDERS) {
|
|
26
|
+
out[p] = !!env[p] || !!authKeys[p];
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Thin wrapper over model-catalog.getCatalogInfo() for route-resolution
|
|
33
|
+
* callers that only need the models list and the last-refresh error, not the
|
|
34
|
+
* full cache metadata. Never throws: a catalog error resolves to an empty
|
|
35
|
+
* list with a sentinel error string.
|
|
36
|
+
* @returns {Promise<{models: Array, lastRefreshError: string|null}>}
|
|
37
|
+
*/
|
|
38
|
+
async function getRouteCatalogInfo() {
|
|
39
|
+
// Lazy-required so jest.doMock('./model-catalog', ...) can intercept it
|
|
40
|
+
// per-test, matching the pattern model-catalog.js itself uses for its deps.
|
|
41
|
+
const { getCatalogInfo } = require('./model-catalog');
|
|
42
|
+
try {
|
|
43
|
+
const info = await getCatalogInfo();
|
|
44
|
+
return { models: info.models || [], lastRefreshError: info.lastRefreshError || null };
|
|
45
|
+
} catch {
|
|
46
|
+
return { models: [], lastRefreshError: 'catalog-unavailable' };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Module version stamped onto `resolved` results' provenance (carry-forward). */
|
|
51
|
+
const ROUTE_VERSION = 1;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build up to ~6 labeled alternatives for a `selection_required` RouteResult
|
|
55
|
+
* (#61 Task 6.3, spec Decision 10). Pure: reads only the already-parsed
|
|
56
|
+
* descriptor plus the live keys/catalogInfo/gatewayIds the caller already
|
|
57
|
+
* assembled.
|
|
58
|
+
*
|
|
59
|
+
* Two categories, in order:
|
|
60
|
+
* 1. The SAME model via OpenRouter — only when an OpenRouter key is present
|
|
61
|
+
* AND the OR-namespaced id is actually present in the catalog (never
|
|
62
|
+
* suggest an id we can't confirm exists). For divergent vendors (e.g.
|
|
63
|
+
* Anthropic) `descriptor.model` may be the DASH-form direct id, so a
|
|
64
|
+
* reconstructed `openrouter/<vendor>/<model>` would never match the
|
|
65
|
+
* catalog's dot-form OR id -- when the caller's `gatewayIds.openrouter`
|
|
66
|
+
* is available (the catalog-correct form), it is used instead of
|
|
67
|
+
* reconstructing. Falls back to reconstruction when `gatewayIds` is
|
|
68
|
+
* absent (non-alias / full-id / non-divergent requests), so behavior
|
|
69
|
+
* there is unchanged.
|
|
70
|
+
* 2. Up to 5 OTHER models in the same direct vendor namespace (ids starting
|
|
71
|
+
* `<vendor>/`, excluding the requested id itself and excluding any
|
|
72
|
+
* `openrouter/`-prefixed rows, which share the `<vendor>/` prefix check
|
|
73
|
+
* only when vendor === 'openrouter' and are filtered out defensively).
|
|
74
|
+
*
|
|
75
|
+
* @param {{vendor?: string, model?: string}} descriptor parsed Descriptor for
|
|
76
|
+
* the request that produced the selection_required (canonical or
|
|
77
|
+
* openrouter-literal — both carry vendor/model)
|
|
78
|
+
* @param {Object<string,boolean>} keys per-provider key-presence map (buildLaunchKeys() shape)
|
|
79
|
+
* @param {{models: Array<{id:string}>}} catalogInfo
|
|
80
|
+
* @param {{direct?: string, openrouter?: string}} [gatewayIds] the same
|
|
81
|
+
* per-gateway id map resolveRouteForLaunch threads through resolveRoute
|
|
82
|
+
* (Task 3's bridge for divergent curated aliases); absent for non-alias /
|
|
83
|
+
* full-id / non-divergent requests
|
|
84
|
+
* @returns {Array<{model:string, gateway:string, note:string}>}
|
|
85
|
+
*/
|
|
86
|
+
function buildSuggestions(descriptor, keys, catalogInfo, gatewayIds) {
|
|
87
|
+
const suggestions = [];
|
|
88
|
+
const vendor = descriptor && descriptor.vendor;
|
|
89
|
+
const model = descriptor && descriptor.model;
|
|
90
|
+
if (!vendor || !model) { return suggestions; }
|
|
91
|
+
|
|
92
|
+
const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
|
|
93
|
+
const requestedDirectId = `${vendor}/${model}`;
|
|
94
|
+
|
|
95
|
+
if (keys && keys.openrouter) {
|
|
96
|
+
const orId = (gatewayIds && gatewayIds.openrouter) || `openrouter/${vendor}/${model}`;
|
|
97
|
+
if (models.some(m => m && m.id === orId)) {
|
|
98
|
+
suggestions.push({ model: orId, gateway: 'openrouter', note: 'same model via OpenRouter' });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const nsPrefix = `${vendor}/`;
|
|
103
|
+
const sameVendor = models.filter(m =>
|
|
104
|
+
m && typeof m.id === 'string' &&
|
|
105
|
+
m.id.startsWith(nsPrefix) &&
|
|
106
|
+
!m.id.startsWith('openrouter/') &&
|
|
107
|
+
m.id !== requestedDirectId
|
|
108
|
+
).slice(0, 5);
|
|
109
|
+
for (const m of sameVendor) {
|
|
110
|
+
suggestions.push({ model: m.id, gateway: 'direct', note: `${vendor} model` });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return suggestions.slice(0, 6);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One-time per-vendor notice when auto-routing migrates a both-key holder off
|
|
118
|
+
* OpenRouter onto direct (#61 Task 5.1 — visible-migration guarantee: never
|
|
119
|
+
* silent). Advisory only — never changes the routing decision, and a failed
|
|
120
|
+
* persist (markMigrationNotified is itself best-effort) never blocks the
|
|
121
|
+
* launch. Fires only when ALL of these hold:
|
|
122
|
+
* - the result actually resolved to gateway 'direct'
|
|
123
|
+
* - the caller did NOT explicitly force a gateway (gatewayMode === 'auto');
|
|
124
|
+
* an explicit --gateway direct means the user chose direct, no notice
|
|
125
|
+
* - the descriptor is not itself an explicit `openrouter/...` literal
|
|
126
|
+
* - the user holds an OpenRouter key (otherwise nothing is being migrated
|
|
127
|
+
* FROM)
|
|
128
|
+
* - this vendor hasn't already been notified (getRoutingConfig().migration_notified)
|
|
129
|
+
* @param {{result:object, descriptor:object, gatewayMode:string, keys:object}} args
|
|
130
|
+
* @returns {object} the (possibly mutated) result
|
|
131
|
+
*/
|
|
132
|
+
function maybeMigrationNotice({ result, descriptor, gatewayMode, keys }) {
|
|
133
|
+
if (result.kind !== 'resolved' || result.gateway !== 'direct') { return result; }
|
|
134
|
+
if (gatewayMode !== 'auto') { return result; }
|
|
135
|
+
if (descriptor.isExplicitOpenRouter) { return result; }
|
|
136
|
+
if (!keys.openrouter) { return result; }
|
|
137
|
+
try {
|
|
138
|
+
// Lazy-required so jest.doMock('./config', ...) can intercept it per-test.
|
|
139
|
+
const { getRoutingConfig, markMigrationNotified } = require('./config');
|
|
140
|
+
if (getRoutingConfig().migration_notified[descriptor.vendor]) { return result; }
|
|
141
|
+
const notice = `Routing ${descriptor.vendor} via direct API (previously OpenRouter). ` +
|
|
142
|
+
'Set routing.prefer: "openrouter" (or use --gateway openrouter) to restore.';
|
|
143
|
+
result.notice = result.notice ? `${result.notice} ${notice}` : notice;
|
|
144
|
+
markMigrationNotified(descriptor.vendor);
|
|
145
|
+
} catch (_err) {
|
|
146
|
+
// Advisory only: never let a lookup/persist failure change the routing
|
|
147
|
+
// decision or block the launch.
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Bridge: alias -> descriptor -> resolveRoute (Task 4.4; gatewayIds bridging
|
|
154
|
+
* Task 3 of #61's gateway-correct-model-ids fix).
|
|
155
|
+
* Resolves a raw model string to a Descriptor — if it is a known no-slash
|
|
156
|
+
* alias (per getEffectiveAliases()), its concrete id is parsed instead, so an
|
|
157
|
+
* alias pointing at an `openrouter/...` value is treated as an explicit,
|
|
158
|
+
* force-OR literal while an alias pointing at a bare `vendor/model` is
|
|
159
|
+
* policy-routed like any other canonical id.
|
|
160
|
+
*
|
|
161
|
+
* When the alias is UNMODIFIED from its curated default (effective value ===
|
|
162
|
+
* getDefaultAliases()[alias]), this also looks up curated-models'
|
|
163
|
+
* toGatewayRoutes()[alias] and threads it through as `gatewayIds`, and parses
|
|
164
|
+
* the descriptor from the gateway-native direct form (falling back to the
|
|
165
|
+
* openrouter form) rather than the single pinned alias string — the pinned
|
|
166
|
+
* string can be the wrong per-gateway form for divergent vendors (e.g.
|
|
167
|
+
* Anthropic's dash ids vs. OpenRouter's dot ids), so `toGatewayRoutes()` is
|
|
168
|
+
* the source of truth for actually-correct ids. A USER OVERRIDE (the
|
|
169
|
+
* effective alias differs from the curated default) or an alias with no
|
|
170
|
+
* curated route map intentionally skips all of this: no gatewayIds, and the
|
|
171
|
+
* user's own alias string is parsed as before — we must never impose curated
|
|
172
|
+
* Anthropic-style ids onto a target the user chose themselves. Full-id /
|
|
173
|
+
* non-alias inputs are likewise unaffected (no gatewayIds).
|
|
174
|
+
*
|
|
175
|
+
* Assembles live key/catalog state and delegates the actual decision to the
|
|
176
|
+
* pure gateway-router. Wired into start-helpers.js, mcp-server.js, and
|
|
177
|
+
* sidecar/fanout-validate.js.
|
|
178
|
+
* @param {{model:string, gatewayMode:string, source:string, allowSelection?:boolean, validateModel?:boolean}} opts
|
|
179
|
+
* @returns {Promise<object>} RouteResult (resolved | selection_required | error)
|
|
180
|
+
*/
|
|
181
|
+
async function resolveRouteForLaunch({ model, gatewayMode, source, allowSelection, validateModel }) {
|
|
182
|
+
// Lazy-required so jest.doMock('./config' | './model-descriptor' | './gateway-router' | './curated-models', ...)
|
|
183
|
+
// can intercept them per-test, matching the pattern already used above for model-catalog.
|
|
184
|
+
const { getEffectiveAliases, getDefaultAliases } = require('./config');
|
|
185
|
+
const { parseDescriptor } = require('./model-descriptor');
|
|
186
|
+
const { resolveRoute } = require('./gateway-router');
|
|
187
|
+
const { toGatewayRoutes } = require('./curated-models');
|
|
188
|
+
const aliases = getEffectiveAliases();
|
|
189
|
+
const isAlias = typeof model === 'string' && !model.includes('/') && !!aliases[model];
|
|
190
|
+
let concrete = isAlias ? aliases[model] : model;
|
|
191
|
+
let gatewayIds;
|
|
192
|
+
if (isAlias && aliases[model] === getDefaultAliases()[model]) {
|
|
193
|
+
const routes = toGatewayRoutes()[model];
|
|
194
|
+
if (routes) {
|
|
195
|
+
gatewayIds = routes;
|
|
196
|
+
concrete = routes.direct || routes.openrouter;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const descriptor = parseDescriptor(concrete, { aliases });
|
|
200
|
+
const keys = buildLaunchKeys();
|
|
201
|
+
// Skip the catalog fetch entirely under --no-validate-model: gateway-router's
|
|
202
|
+
// catalogGate short-circuits to { ok:true } as soon as validateModel === false,
|
|
203
|
+
// never consulting catalogInfo, so fetching it here would be wasted
|
|
204
|
+
// latency/network (and can hit the network on a cold cache) for no benefit.
|
|
205
|
+
// Strict === false (not just falsy) so this stays in lockstep with catalogGate's
|
|
206
|
+
// own `=== false` guard: any other value (incl. an omitted flag) still fetches,
|
|
207
|
+
// so a caller can never skip the fetch while the gate still classifies against it.
|
|
208
|
+
const catalogInfo = validateModel === false ? { models: [], lastRefreshError: null } : await getRouteCatalogInfo();
|
|
209
|
+
let result = resolveRoute({ descriptor, source, gatewayMode, allowSelection, validateModel, keys, catalogInfo, gatewayIds });
|
|
210
|
+
if (result.kind === 'resolved') {
|
|
211
|
+
result.provenance = { ...result.provenance, resolutionVersion: ROUTE_VERSION };
|
|
212
|
+
result = maybeMigrationNotice({ result, descriptor, gatewayMode, keys });
|
|
213
|
+
} else if (result.kind === 'selection_required') {
|
|
214
|
+
result.suggestions = buildSuggestions(descriptor, keys, catalogInfo, gatewayIds);
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { buildLaunchKeys, getRouteCatalogInfo, resolveRouteForLaunch, buildSuggestions, ROUTE_VERSION };
|
|
@@ -6,71 +6,124 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Derive the alias used for resolution (needed downstream by the budget gate,
|
|
10
|
+
* which reports `alias || args.model` as the pricing lookup's modelInput).
|
|
11
|
+
* Per the #61 Task 4.5 design, only ever returns a no-slash token: an
|
|
12
|
+
* explicit `--model` containing '/' or a slash-bearing config default both
|
|
13
|
+
* resolve to undefined here.
|
|
11
14
|
* @param {object} args - Parsed CLI arguments
|
|
12
|
-
* @returns {
|
|
15
|
+
* @returns {string|undefined}
|
|
13
16
|
*/
|
|
14
|
-
function
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
try {
|
|
19
|
-
model = resolveModel(args.model);
|
|
20
|
-
} catch (err) {
|
|
21
|
-
console.error(err.message);
|
|
22
|
-
process.exit(1);
|
|
17
|
+
function deriveAlias(args) {
|
|
18
|
+
const raw = args.model;
|
|
19
|
+
if (raw !== undefined && raw !== null && !raw.includes('/')) {
|
|
20
|
+
return raw;
|
|
23
21
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
let alias = rawAlias;
|
|
27
|
-
if (alias === undefined) {
|
|
22
|
+
if (raw === undefined || raw === null) {
|
|
23
|
+
const { loadConfig } = require('./config');
|
|
28
24
|
const cfg = loadConfig();
|
|
29
25
|
if (cfg && cfg.default && !cfg.default.includes('/')) {
|
|
30
|
-
|
|
26
|
+
return cfg.default;
|
|
31
27
|
}
|
|
32
28
|
}
|
|
33
|
-
return
|
|
29
|
+
return undefined;
|
|
34
30
|
}
|
|
35
31
|
|
|
36
32
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
33
|
+
* Resolve the model for a launch through the Foundation gateway router (#61
|
|
34
|
+
* Task 4.5), used by every launch path — start, continue (Task 7.3), and the
|
|
35
|
+
* MCP amicus_start handler (via the same router, Task 6.2). This replaced the
|
|
36
|
+
* legacy resolveModelFromArgs + validateFallbackModel pair, which was retired
|
|
37
|
+
* once no launch path depended on it (#61 Task 4.7).
|
|
38
|
+
*
|
|
39
|
+
* On `resolved`, returns `{ model, alias, gateway, provenance }` (and prints
|
|
40
|
+
* any advisory `notice` to stderr). On `error` or `selection_required`,
|
|
41
|
+
* renders the appropriate message to stderr and exits(1) — this never
|
|
42
|
+
* returns in that case.
|
|
40
43
|
* @param {object} args - Parsed CLI arguments
|
|
41
|
-
* @
|
|
42
|
-
* @returns {Promise<string>} Validated model string
|
|
44
|
+
* @returns {Promise<{model: string, alias: string|undefined, gateway: string, provenance: object}>}
|
|
43
45
|
*/
|
|
44
|
-
async function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
async function resolveLaunchModel(args) {
|
|
47
|
+
const { resolveGatewayMode } = require('./config');
|
|
48
|
+
const { resolveRouteForLaunch } = require('./route-launch');
|
|
49
|
+
const { toCliMessage, toStructuredError } = require('./route-error');
|
|
50
|
+
const { resolveModelInputOrDefault } = require('./model-input-default');
|
|
48
51
|
|
|
49
|
-
const
|
|
50
|
-
const
|
|
52
|
+
const gatewayMode = resolveGatewayMode(args.gateway);
|
|
53
|
+
const validateModel = !args['no-validate-model'];
|
|
54
|
+
// Interactive alternatives picker (#61 Task 6.3): only offered on a real
|
|
55
|
+
// interactive TTY session that hasn't opted out with --no-ui. A headless/
|
|
56
|
+
// non-TTY run (CI, piped output, --no-ui) keeps allowSelection false, so a
|
|
57
|
+
// direct miss there still produces the structured error/selection_required
|
|
58
|
+
// rendered below rather than an unhandled prompt.
|
|
59
|
+
const allowSelection = !args['no-ui'] && !!process.stdin.isTTY;
|
|
60
|
+
|
|
61
|
+
// No --model given: the parser does not inject a default, so resolve the
|
|
62
|
+
// configured default here before handing off to the router. Without this, `undefined`
|
|
63
|
+
// would reach resolveRouteForLaunch -> parseDescriptor(undefined) -> an
|
|
64
|
+
// `invalid` result, breaking the common `amicus start` (no --model) case.
|
|
65
|
+
// Shared with the MCP amicus_start handler (mcp-server.js, #61 Task 6.2)
|
|
66
|
+
// via model-input-default.js, so this lookup lives in exactly one place.
|
|
67
|
+
const modelInput = resolveModelInputOrDefault(args.model);
|
|
68
|
+
if (modelInput === undefined) {
|
|
69
|
+
process.stderr.write(
|
|
70
|
+
'No model specified and no default configured. Run \'amicus setup\' to set a default model.\n'
|
|
71
|
+
);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
51
74
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
75
|
+
const result = await resolveRouteForLaunch({
|
|
76
|
+
model: modelInput,
|
|
77
|
+
gatewayMode,
|
|
78
|
+
source: 'cli',
|
|
79
|
+
allowSelection,
|
|
80
|
+
validateModel,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (result.kind === 'resolved') {
|
|
84
|
+
if (result.notice) {
|
|
85
|
+
process.stderr.write(`${result.notice}\n`);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
model: result.executableId,
|
|
89
|
+
alias: deriveAlias(args),
|
|
90
|
+
gateway: result.gateway,
|
|
91
|
+
provenance: result.provenance,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Interactive alternatives picker (#61 Task 6.3): only reachable when
|
|
96
|
+
// allowSelection was true above (real TTY, no --no-ui), so the router only
|
|
97
|
+
// ever hands back selection_required here in that same interactive case.
|
|
98
|
+
if (result.kind === 'selection_required') {
|
|
99
|
+
const { promptRouteSelection } = require('./model-validator');
|
|
55
100
|
try {
|
|
56
|
-
|
|
101
|
+
const chosen = await promptRouteSelection(result, deriveAlias(args));
|
|
102
|
+
return {
|
|
103
|
+
model: chosen.model,
|
|
104
|
+
alias: deriveAlias(args),
|
|
105
|
+
gateway: chosen.gateway,
|
|
106
|
+
provenance: result.provenance || {},
|
|
107
|
+
};
|
|
57
108
|
} catch (err) {
|
|
58
|
-
|
|
109
|
+
process.stderr.write(`${err.message || 'Model selection cancelled.'}\n`);
|
|
59
110
|
process.exit(1);
|
|
60
111
|
}
|
|
61
112
|
}
|
|
62
113
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
114
|
+
// 'error': render and exit. (A non-interactive run never reaches
|
|
115
|
+
// kind:'selection_required' — allowSelection is false there, so the router
|
|
116
|
+
// resolves a catalog miss straight to kind:'error' with reason
|
|
117
|
+
// 'model_not_found' instead; see gateway-router.js's catalogGate.)
|
|
118
|
+
if (args.json) {
|
|
119
|
+
process.stderr.write(`${JSON.stringify(toStructuredError(result))}\n`);
|
|
120
|
+
} else {
|
|
121
|
+
process.stderr.write(`${toCliMessage(result)}\n`);
|
|
70
122
|
}
|
|
123
|
+
process.exit(1);
|
|
71
124
|
}
|
|
72
125
|
|
|
73
126
|
module.exports = {
|
|
74
|
-
|
|
75
|
-
|
|
127
|
+
resolveLaunchModel,
|
|
128
|
+
deriveAlias,
|
|
76
129
|
};
|