amicus 1.0.0 → 1.2.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/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +19 -0
- package/CHANGELOG.md +86 -0
- package/LICENSE +22 -1
- package/README.md +14 -3
- package/bin/amicus.js +17 -162
- package/electron/ipc-setup.js +30 -9
- package/electron/main.js +13 -5
- package/electron/preload.js +30 -10
- package/electron/setup-ui-keys.js +9 -0
- package/electron/setup-ui-model.js +33 -23
- package/electron/setup-ui-styles.js +6 -1
- package/electron/setup-ui.js +91 -38
- package/electron/toolbar.js +4 -5
- package/package.json +7 -5
- package/scripts/postinstall.js +16 -7
- package/skills/second-opinion/COUNCIL-DESIGN.md +36 -34
- package/skills/second-opinion/MODEL-NOTES.md +23 -17
- package/skills/second-opinion/SKILL.md +84 -51
- package/{skill → skills/sidecar}/SKILL.md +14 -4
- package/src/cli-handlers-council.js +59 -0
- package/src/cli-handlers-doctor.js +173 -0
- package/src/cli-handlers-run.js +196 -0
- package/src/cli-handlers.js +66 -1
- package/src/cli.js +16 -2
- package/src/council/findings.js +48 -0
- package/src/council/ledger.js +82 -0
- package/src/council/tally.js +108 -0
- package/src/council/verdict.js +48 -0
- package/src/headless.js +43 -149
- package/src/mcp-server.js +6 -0
- package/src/sidecar/budget.js +83 -0
- package/src/sidecar/conversation-mirror.js +128 -0
- package/src/sidecar/fanout-leg.js +4 -1
- package/src/sidecar/fanout.js +34 -7
- package/src/sidecar/interactive-mirror.js +66 -0
- package/src/sidecar/interactive.js +35 -21
- package/src/sidecar/models.js +41 -10
- package/src/sidecar/session-finalize.js +26 -0
- package/src/sidecar/session-utils.js +5 -5
- package/src/sidecar/setup.js +55 -42
- package/src/sidecar/start.js +19 -6
- package/src/utils/activity-poller.js +47 -0
- package/src/utils/alias-resolver.js +1 -1
- package/src/utils/config.js +4 -4
- package/src/utils/curated-models.js +88 -45
- package/src/utils/error-doc.js +55 -0
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/model-catalog.js +1 -1
- package/src/utils/model-fetcher.js +16 -2
- package/src/utils/pricing.js +93 -0
- package/src/utils/quick-picks.js +81 -0
- package/src/utils/result-schema.js +21 -2
- package/src/utils/session-abort.js +40 -13
- package/src/utils/validators.js +17 -17
package/src/sidecar/setup.js
CHANGED
|
@@ -13,15 +13,6 @@ const readline = require('readline');
|
|
|
13
13
|
const { loadConfig, saveConfig, getDefaultAliases, getConfigDir } = require('../utils/config');
|
|
14
14
|
const { logger } = require('../utils/logger');
|
|
15
15
|
|
|
16
|
-
const { getCuratedModels } = require('../utils/curated-models');
|
|
17
|
-
/**
|
|
18
|
-
* Model choices presented during readline setup — derived from curated-models (F5).
|
|
19
|
-
* @type {Array<{number: number, alias: string, label: string}>}
|
|
20
|
-
*/
|
|
21
|
-
const MODEL_CHOICES = getCuratedModels().map((c, i) => ({
|
|
22
|
-
number: i + 1, alias: c.alias, label: `${c.label} (${c.blurb})`
|
|
23
|
-
}));
|
|
24
|
-
|
|
25
16
|
/**
|
|
26
17
|
* Add a model alias to the existing config (or create config if none exists)
|
|
27
18
|
* @param {string} name - Alias name
|
|
@@ -68,7 +59,7 @@ function createDefaultConfig(defaultModel) {
|
|
|
68
59
|
|
|
69
60
|
/**
|
|
70
61
|
* Detect available API keys from .env file and process.env
|
|
71
|
-
* @returns {{openrouter: boolean, google: boolean, openai: boolean, anthropic: boolean}}
|
|
62
|
+
* @returns {{openrouter: boolean, google: boolean, openai: boolean, anthropic: boolean, deepseek: boolean}}
|
|
72
63
|
*/
|
|
73
64
|
function detectApiKeys() {
|
|
74
65
|
const { readApiKeys } = require('../utils/api-key-store');
|
|
@@ -90,21 +81,28 @@ function askQuestion(rl, prompt) {
|
|
|
90
81
|
}
|
|
91
82
|
|
|
92
83
|
/**
|
|
93
|
-
* Resolve
|
|
94
|
-
* @
|
|
95
|
-
*
|
|
84
|
+
* Resolve readline input against the live picks.
|
|
85
|
+
* @returns {{alias?: string, modelId?: string, noUpgrade?: boolean}|null}
|
|
86
|
+
* alias → numbered/named quick pick (upgrades that alias unless noUpgrade)
|
|
87
|
+
* modelId → free-form full model id (default only, no alias writes)
|
|
96
88
|
*/
|
|
97
|
-
function resolveChoice(input) {
|
|
89
|
+
function resolveChoice(input, picks, catalog) {
|
|
98
90
|
const num = parseInt(input, 10);
|
|
99
|
-
if (num >= 1 && num <=
|
|
100
|
-
return
|
|
91
|
+
if (num >= 1 && num <= picks.length) {
|
|
92
|
+
return { alias: picks[num - 1].alias };
|
|
101
93
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
94
|
+
if (input.includes('/')) {
|
|
95
|
+
const known = (catalog || []).some(m => m && m.id === input);
|
|
96
|
+
if (!known) {
|
|
97
|
+
console.log(`Warning: '${input}' not found in the model catalog (offline or new model) — using it anyway.`); // eslint-disable-line no-console
|
|
98
|
+
}
|
|
99
|
+
return { modelId: input };
|
|
100
|
+
}
|
|
101
|
+
const cfg = loadConfig();
|
|
102
|
+
const aliases = { ...getDefaultAliases(), ...((cfg && cfg.aliases) || {}) };
|
|
103
|
+
if (aliases[input] !== undefined) {
|
|
104
|
+
return { alias: input, noUpgrade: true };
|
|
106
105
|
}
|
|
107
|
-
|
|
108
106
|
return null;
|
|
109
107
|
}
|
|
110
108
|
|
|
@@ -159,8 +157,8 @@ async function seedCatalog(print) {
|
|
|
159
157
|
*
|
|
160
158
|
* Guides the user through:
|
|
161
159
|
* 1. API key detection
|
|
162
|
-
* 2. Default model selection
|
|
163
|
-
* 3. Config file
|
|
160
|
+
* 2. Default model selection from live quick-picks (read-modify-write, no clobber)
|
|
161
|
+
* 3. Config file save
|
|
164
162
|
*/
|
|
165
163
|
async function runReadlineSetup() {
|
|
166
164
|
const rl = readline.createInterface({
|
|
@@ -170,7 +168,7 @@ async function runReadlineSetup() {
|
|
|
170
168
|
|
|
171
169
|
try {
|
|
172
170
|
console.log('');
|
|
173
|
-
console.log('===
|
|
171
|
+
console.log('=== Amicus Setup Wizard ===');
|
|
174
172
|
console.log('');
|
|
175
173
|
|
|
176
174
|
const keys = detectApiKeys();
|
|
@@ -183,38 +181,54 @@ async function runReadlineSetup() {
|
|
|
183
181
|
console.log(`API keys detected: ${foundKeys.join(', ')}`);
|
|
184
182
|
} else {
|
|
185
183
|
console.log('No API keys detected.');
|
|
186
|
-
console.log('Set OPENROUTER_API_KEY to get started, or run:
|
|
184
|
+
console.log('Set OPENROUTER_API_KEY to get started, or run: amicus setup');
|
|
187
185
|
}
|
|
188
186
|
console.log('');
|
|
189
187
|
|
|
188
|
+
const { getCatalog } = require('../utils/model-catalog');
|
|
189
|
+
const { resolveQuickPicks, toLiveSeedAliases } = require('../utils/quick-picks');
|
|
190
|
+
let catalog = [];
|
|
191
|
+
try { catalog = await getCatalog(); } catch (_err) { /* offline: pinned */ }
|
|
192
|
+
const picks = resolveQuickPicks(catalog);
|
|
193
|
+
|
|
190
194
|
console.log('Choose your default model:');
|
|
191
195
|
console.log('');
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
picks.forEach((p, i) => {
|
|
197
|
+
const badge = p.source === 'fallback' ? ' [offline list]' : '';
|
|
198
|
+
console.log(` ${i + 1}) ${p.alias} - ${p.label} (${p.blurb}) → ${p.routes.openrouter}${badge}`);
|
|
199
|
+
});
|
|
195
200
|
console.log('');
|
|
196
201
|
|
|
197
|
-
const answer = await askQuestion(rl,
|
|
198
|
-
|
|
202
|
+
const answer = await askQuestion(rl,
|
|
203
|
+
`Pick a default (1-${picks.length}, alias name, or any full model id): `);
|
|
204
|
+
const chosen = resolveChoice(answer, picks, catalog);
|
|
199
205
|
|
|
200
206
|
if (!chosen) {
|
|
201
|
-
console.log(`Invalid choice: "${answer}".
|
|
202
|
-
const cfg = createDefaultConfig('gemini');
|
|
203
|
-
await seedCatalog();
|
|
204
|
-
const aliasCount = Object.keys(cfg.aliases).length;
|
|
205
|
-
console.log('');
|
|
206
|
-
console.log(`Config created with ${aliasCount} aliases.`);
|
|
207
|
-
console.log(`Config path: ${path.join(getConfigDir(), 'config.json')}`);
|
|
207
|
+
console.log(`Invalid choice: "${answer}". Keeping configuration unchanged.`);
|
|
208
208
|
return;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
// Read-modify-write — never rebuild the alias table (no-clobber rule).
|
|
212
|
+
const cfg = loadConfig() || { aliases: toLiveSeedAliases(catalog) };
|
|
213
|
+
if (!cfg.aliases) { cfg.aliases = {}; }
|
|
214
|
+
if (chosen.alias) {
|
|
215
|
+
cfg.default = chosen.alias;
|
|
216
|
+
const pick = picks.find(p => p.alias === chosen.alias);
|
|
217
|
+
if (pick && !chosen.noUpgrade) {
|
|
218
|
+
cfg.aliases[chosen.alias] = pick.routes.openrouter || Object.values(pick.routes)[0];
|
|
219
|
+
} else if (cfg.aliases[chosen.alias] === undefined) {
|
|
220
|
+
const fallback = getDefaultAliases()[chosen.alias];
|
|
221
|
+
if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
cfg.default = chosen.modelId;
|
|
225
|
+
}
|
|
226
|
+
saveConfig(cfg);
|
|
212
227
|
await seedCatalog();
|
|
213
|
-
const aliasCount = Object.keys(cfg.aliases).length;
|
|
214
228
|
|
|
215
229
|
console.log('');
|
|
216
|
-
console.log(`Default model set to: ${
|
|
217
|
-
console.log(`Config
|
|
230
|
+
console.log(`Default model set to: ${cfg.default}`);
|
|
231
|
+
console.log(`Config saved (${Object.keys(cfg.aliases).length} aliases).`);
|
|
218
232
|
console.log(`Config path: ${path.join(getConfigDir(), 'config.json')}`);
|
|
219
233
|
} finally {
|
|
220
234
|
rl.close();
|
|
@@ -276,5 +290,4 @@ module.exports = {
|
|
|
276
290
|
runReadlineSetup,
|
|
277
291
|
runApiKeySetup,
|
|
278
292
|
seedCatalog,
|
|
279
|
-
MODEL_CHOICES,
|
|
280
293
|
};
|
package/src/sidecar/start.js
CHANGED
|
@@ -218,15 +218,26 @@ async function startSidecar(options) {
|
|
|
218
218
|
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
//
|
|
222
|
-
|
|
221
|
+
// Map the run result to a definitive terminal status + exit code (single source of truth).
|
|
222
|
+
const { resolveTerminalState } = require('./session-finalize');
|
|
223
|
+
const terminal = resolveTerminalState(result);
|
|
224
|
+
if (terminal.status === 'error') {
|
|
223
225
|
meta.status = 'error';
|
|
224
|
-
meta.reason = result.error;
|
|
226
|
+
meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
225
227
|
meta.completedAt = new Date().toISOString();
|
|
226
228
|
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
227
|
-
logger.error('Session completed with error', { taskId, error:
|
|
229
|
+
logger.error('Session completed with error', { taskId, error: meta.reason });
|
|
228
230
|
} else {
|
|
229
|
-
|
|
231
|
+
// complete / timed-out / aborted: persist the (possibly partial) summary with the correct status.
|
|
232
|
+
finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json, status: terminal.status });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const { resolveUsage } = require('../utils/pricing');
|
|
236
|
+
const runUsage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
|
|
237
|
+
if (runUsage) {
|
|
238
|
+
const m = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
239
|
+
m.usage = runUsage;
|
|
240
|
+
fs.writeFileSync(metaPath, JSON.stringify(m, null, 2), { mode: 0o600 });
|
|
230
241
|
}
|
|
231
242
|
|
|
232
243
|
if (json) {
|
|
@@ -234,10 +245,12 @@ async function startSidecar(options) {
|
|
|
234
245
|
const finalMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
235
246
|
const doc = buildRunResult({
|
|
236
247
|
taskId, metadata: finalMeta, result, summary,
|
|
237
|
-
modelInput, sessionDir: sessDir,
|
|
248
|
+
modelInput, sessionDir: sessDir, usage: runUsage,
|
|
238
249
|
});
|
|
239
250
|
console.log(JSON.stringify(doc, null, 2));
|
|
240
251
|
}
|
|
252
|
+
|
|
253
|
+
return terminal.exitCode;
|
|
241
254
|
}
|
|
242
255
|
|
|
243
256
|
module.exports = {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Poll an async status source on an interval and fire onActivity() on any non-idle state.
|
|
5
|
+
* Best-effort: getStatus errors are swallowed and polling continues. Timers are unref'd
|
|
6
|
+
* so the poller never keeps the process alive.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {() => Promise<{type?:string}>} opts.getStatus
|
|
10
|
+
* @param {() => void} opts.onActivity
|
|
11
|
+
* @param {number} [opts.intervalMs=30000]
|
|
12
|
+
* @returns {{ stop: () => void }}
|
|
13
|
+
*/
|
|
14
|
+
function createActivityPoller({ getStatus, onActivity, intervalMs = 30000 }) {
|
|
15
|
+
let timer = null;
|
|
16
|
+
let stopped = false;
|
|
17
|
+
|
|
18
|
+
const schedule = () => {
|
|
19
|
+
if (stopped) { return; }
|
|
20
|
+
timer = setTimeout(tick, intervalMs);
|
|
21
|
+
if (timer.unref) { timer.unref(); }
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function tick() {
|
|
25
|
+
if (stopped) { return; }
|
|
26
|
+
try {
|
|
27
|
+
const status = await getStatus();
|
|
28
|
+
if (status && status.type && status.type !== 'idle') { onActivity(); }
|
|
29
|
+
} catch { /* best-effort */ }
|
|
30
|
+
schedule();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
schedule();
|
|
34
|
+
return {
|
|
35
|
+
stop() {
|
|
36
|
+
stopped = true;
|
|
37
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** SIGTERM a child process if it exists and isn't already killed. */
|
|
43
|
+
function killIfAlive(child) {
|
|
44
|
+
if (child && !child.killed) { child.kill('SIGTERM'); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { createActivityPoller, killIfAlive };
|
|
@@ -67,7 +67,7 @@ function autoRepairAlias(alias, config, defaultAliases, saveConfig) {
|
|
|
67
67
|
}
|
|
68
68
|
throw new Error(
|
|
69
69
|
`Alias '${alias}' is configured but has no model value. ` +
|
|
70
|
-
`Fix with:
|
|
70
|
+
`Fix with: amicus setup --add-alias ${alias}=provider/model`
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
package/src/utils/config.js
CHANGED
|
@@ -93,7 +93,7 @@ function getDefaultAliases() {
|
|
|
93
93
|
* Resolution order:
|
|
94
94
|
* 1. If modelArg contains '/' -> return as-is (full model string)
|
|
95
95
|
* 2. If modelArg is a key in config.aliases -> return resolved string
|
|
96
|
-
* 3. If modelArg is unknown alias -> throw Error mentioning '
|
|
96
|
+
* 3. If modelArg is unknown alias -> throw Error mentioning 'amicus setup'
|
|
97
97
|
* 4. If modelArg is undefined and config.default exists -> resolve that alias
|
|
98
98
|
* 5. If no default -> throw Error
|
|
99
99
|
*
|
|
@@ -124,14 +124,14 @@ function resolveModel(modelArg) {
|
|
|
124
124
|
|
|
125
125
|
// Unknown alias
|
|
126
126
|
throw new Error(
|
|
127
|
-
`Unknown model alias '${modelArg}'. Run '
|
|
127
|
+
`Unknown model alias '${modelArg}'. Run 'amicus setup' to configure aliases.`
|
|
128
128
|
);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// modelArg is undefined - use default
|
|
132
132
|
if (!config || !config.default) {
|
|
133
133
|
throw new Error(
|
|
134
|
-
'No model specified and no default configured. Run \'
|
|
134
|
+
'No model specified and no default configured. Run \'amicus setup\' to set a default model.'
|
|
135
135
|
);
|
|
136
136
|
}
|
|
137
137
|
|
|
@@ -153,7 +153,7 @@ function resolveModel(modelArg) {
|
|
|
153
153
|
|
|
154
154
|
// Default alias not found anywhere
|
|
155
155
|
throw new Error(
|
|
156
|
-
`Default alias '${defaultValue}' not found in aliases. Run '
|
|
156
|
+
`Default alias '${defaultValue}' not found in aliases. Run 'amicus setup' to fix configuration.`
|
|
157
157
|
);
|
|
158
158
|
}
|
|
159
159
|
|
|
@@ -1,77 +1,120 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
1
|
+
/** Family definitions + pinned fallbacks for the wizard model picker (v2). */
|
|
2
|
+
/*
|
|
3
|
+
* Families are MATCH RULES over the live catalog, not pinned truths:
|
|
4
|
+
* src/utils/quick-picks.js resolves each family to the current catalog
|
|
5
|
+
* flagship at setup time. The pinned `fallback` ids are used only when
|
|
6
|
+
* the catalog cannot resolve a route (offline / unkeyed provider) and to
|
|
7
|
+
* derive the static DEFAULT_ALIASES (runtime alias resolution must never
|
|
8
|
+
* wait on the network). `amicus models --check` audits every pinned route
|
|
9
|
+
* here against the live catalog AND warns when a fallback falls behind
|
|
10
|
+
* the live resolution.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
'use strict';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Wizard quick-pick families. `idPattern` matches the model segment after
|
|
17
|
+
* `<vendorPath>/` (openrouter ns) or `<provider>/` (direct ns).
|
|
18
|
+
* `directProviders` lists direct namespaces the quick-picks resolver may
|
|
19
|
+
* resolve live from the catalog. A per-provider `fallback` entry is
|
|
20
|
+
* OPTIONAL: when absent and the catalog cannot resolve that namespace,
|
|
21
|
+
* the direct route is omitted (no pinned guess is better than a wrong one).
|
|
22
|
+
* `gpt`'s pattern intentionally matches any plain numeric flagship id
|
|
23
|
+
* (gpt-5.5, gpt-6) and excludes suffixed variants (-pro/-mini/-codex).
|
|
24
|
+
* Pinned ids verified against the live catalog 2026-06-11.
|
|
19
25
|
*/
|
|
20
|
-
const
|
|
21
|
-
{ alias: 'gemini', label: 'Gemini
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{ alias: '
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
const FAMILIES = [
|
|
27
|
+
{ alias: 'gemini', label: 'Gemini Flash-class', blurb: 'fast, large context',
|
|
28
|
+
vendorPath: 'google',
|
|
29
|
+
idPattern: /^gemini-[\d.]+-flash(-preview|-exp|-latest)?$/,
|
|
30
|
+
directProviders: ['google'],
|
|
31
|
+
fallback: { openrouter: 'openrouter/google/gemini-3.5-flash',
|
|
32
|
+
google: 'google/gemini-3.5-flash' } },
|
|
33
|
+
{ alias: 'gemini-pro', label: 'Gemini Pro-class', blurb: 'advanced reasoning',
|
|
34
|
+
vendorPath: 'google',
|
|
35
|
+
idPattern: /^gemini-[\d.]+-pro(-preview|-exp|-latest)?$/,
|
|
36
|
+
directProviders: ['google'],
|
|
37
|
+
fallback: { openrouter: 'openrouter/google/gemini-3.1-pro-preview' } },
|
|
38
|
+
{ alias: 'gpt', label: 'GPT flagship', blurb: 'strong coding',
|
|
39
|
+
vendorPath: 'openai',
|
|
40
|
+
idPattern: /^gpt-[\d.]+$/,
|
|
41
|
+
directProviders: ['openai'],
|
|
42
|
+
fallback: { openrouter: 'openrouter/openai/gpt-5.5' } },
|
|
43
|
+
{ alias: 'opus', label: 'Claude Opus-class', blurb: 'deep analysis',
|
|
44
|
+
vendorPath: 'anthropic',
|
|
45
|
+
idPattern: /^claude-opus-[\d.-]+$/,
|
|
46
|
+
directProviders: ['anthropic'],
|
|
47
|
+
fallback: { openrouter: 'openrouter/anthropic/claude-opus-4.8',
|
|
48
|
+
anthropic: 'anthropic/claude-opus-4-6' } },
|
|
49
|
+
{ alias: 'deepseek', label: 'DeepSeek flagship', blurb: 'open-source',
|
|
50
|
+
vendorPath: 'deepseek',
|
|
51
|
+
idPattern: /^deepseek-v[\d.]+(-pro)?$/,
|
|
52
|
+
directProviders: ['deepseek'],
|
|
53
|
+
fallback: { openrouter: 'openrouter/deepseek/deepseek-v4-pro',
|
|
54
|
+
deepseek: 'deepseek/deepseek-chat' } },
|
|
35
55
|
];
|
|
36
56
|
|
|
37
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Alias-only entries (no wizard quick pick); openrouter route only.
|
|
59
|
+
* Refreshed against the live catalog 2026-06-11.
|
|
60
|
+
*/
|
|
38
61
|
const CARDLESS = [
|
|
39
|
-
{ alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.
|
|
62
|
+
{ alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.5-pro' } },
|
|
40
63
|
// codex: newest codex-specific model on OpenRouter (verified 2026-06-09).
|
|
41
64
|
{ alias: 'codex', routes: { openrouter: 'openrouter/openai/gpt-5.3-codex' } },
|
|
42
65
|
{ alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-4.6' } },
|
|
43
66
|
{ alias: 'sonnet', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-4.6' } },
|
|
44
67
|
{ alias: 'haiku', routes: { openrouter: 'openrouter/anthropic/claude-haiku-4.5' } },
|
|
45
|
-
{ alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.
|
|
68
|
+
{ alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.7-max' } },
|
|
46
69
|
{ alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
|
|
47
|
-
{ alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.
|
|
48
|
-
{ alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-
|
|
70
|
+
{ alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
|
|
71
|
+
{ alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-medium-3-5' } },
|
|
49
72
|
{ alias: 'devstral', routes: { openrouter: 'openrouter/mistralai/devstral-2512' } },
|
|
50
|
-
{ alias: 'glm', routes: { openrouter: 'openrouter/z-ai/glm-5' } },
|
|
51
|
-
{ alias: 'minimax', routes: { openrouter: 'openrouter/minimax/minimax-m2.
|
|
73
|
+
{ alias: 'glm', routes: { openrouter: 'openrouter/z-ai/glm-5.1' } },
|
|
74
|
+
{ alias: 'minimax', routes: { openrouter: 'openrouter/minimax/minimax-m2.7' } },
|
|
52
75
|
{ alias: 'grok', routes: { openrouter: 'openrouter/x-ai/grok-4.3' } },
|
|
53
|
-
{ alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.
|
|
54
|
-
{ alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-
|
|
76
|
+
{ alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.6' } },
|
|
77
|
+
{ alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-lite' } },
|
|
55
78
|
];
|
|
56
79
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
80
|
+
/**
|
|
81
|
+
* @returns {Array} shallow-spread copies of the family definitions;
|
|
82
|
+
* `idPattern` is intentionally a shared RegExp reference — safe because
|
|
83
|
+
* none use the g/y flags (no lastIndex state) and callers treat it read-only.
|
|
84
|
+
*/
|
|
85
|
+
function getFamilies() {
|
|
86
|
+
return FAMILIES.map(f => ({
|
|
87
|
+
...f,
|
|
88
|
+
directProviders: [...f.directProviders],
|
|
89
|
+
fallback: { ...f.fallback },
|
|
90
|
+
}));
|
|
60
91
|
}
|
|
61
92
|
|
|
62
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* @returns {Object<string,string>} alias → pinned route (openrouter first). STATIC — runtime-safe.
|
|
95
|
+
*/
|
|
63
96
|
function toDefaultAliases() {
|
|
64
97
|
const out = {};
|
|
65
|
-
for (const
|
|
98
|
+
for (const f of FAMILIES) {
|
|
99
|
+
out[f.alias] = f.fallback.openrouter || Object.values(f.fallback)[0];
|
|
100
|
+
}
|
|
101
|
+
for (const e of CARDLESS) {
|
|
66
102
|
out[e.alias] = e.routes.openrouter || Object.values(e.routes)[0];
|
|
67
103
|
}
|
|
68
104
|
return out;
|
|
69
105
|
}
|
|
70
106
|
|
|
71
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* @returns {Array<{alias,provider,model}>} every pinned route, flattened (for the alias audit).
|
|
109
|
+
*/
|
|
72
110
|
function listCuratedRoutes() {
|
|
73
111
|
const out = [];
|
|
74
|
-
for (const
|
|
112
|
+
for (const f of FAMILIES) {
|
|
113
|
+
for (const [provider, model] of Object.entries(f.fallback)) {
|
|
114
|
+
out.push({ alias: f.alias, provider, model });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const e of CARDLESS) {
|
|
75
118
|
for (const [provider, model] of Object.entries(e.routes)) {
|
|
76
119
|
out.push({ alias: e.alias, provider, model });
|
|
77
120
|
}
|
|
@@ -79,4 +122,4 @@ function listCuratedRoutes() {
|
|
|
79
122
|
return out;
|
|
80
123
|
}
|
|
81
124
|
|
|
82
|
-
module.exports = {
|
|
125
|
+
module.exports = { getFamilies, toDefaultAliases, listCuratedRoutes };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/utils/error-doc.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module error-doc
|
|
6
|
+
* Structured error envelope for the `--json` contract (WS-2 #6). Every
|
|
7
|
+
* pre-flight failure under --json writes one of these to STDOUT (with a stable
|
|
8
|
+
* code) so an automation caller doing JSON.parse(stdout) gets a typed result
|
|
9
|
+
* instead of an empty string. Non-JSON callers keep human text on stderr.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { SCHEMA_VERSION } = require('./result-schema');
|
|
13
|
+
|
|
14
|
+
/** Frozen — adding a code later is additive; renaming/removing is breaking. */
|
|
15
|
+
const ERROR_CODES = Object.freeze({
|
|
16
|
+
BAD_ARGS: 'BAD_ARGS', // bad/empty flag, mutually-exclusive flags, bad numeric/enum value
|
|
17
|
+
MISSING_PROMPT: 'MISSING_PROMPT', // no/empty --prompt or --prompt-file
|
|
18
|
+
BAD_MODEL: 'BAD_MODEL', // bad model format, not on provider, not in catalog
|
|
19
|
+
MISSING_KEY: 'MISSING_KEY', // provider API key absent
|
|
20
|
+
BAD_SESSION: 'BAD_SESSION', // task id missing / invalid / not found
|
|
21
|
+
BUDGET_EXCEEDED: 'BUDGET_EXCEEDED', // the WS-2 #10 spend gate
|
|
22
|
+
INTERNAL: 'INTERNAL', // unexpected pre-flight throw
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {{code: string, message: string, hint?: string, command?: string}} opts
|
|
27
|
+
* @returns {object} error document
|
|
28
|
+
*/
|
|
29
|
+
function buildErrorDoc({ code, message, hint = null, command = null }) {
|
|
30
|
+
return {
|
|
31
|
+
schemaVersion: SCHEMA_VERSION,
|
|
32
|
+
type: 'error',
|
|
33
|
+
ok: false,
|
|
34
|
+
error: { code, message, hint: hint || null, command: command || null },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Emit a pre-flight failure: JSON envelope to stdout when useJson, else the
|
|
40
|
+
* human message to stderr. Returns the exit code (always 1) so callers can
|
|
41
|
+
* `process.exit(failJson(...))`.
|
|
42
|
+
* @param {boolean} useJson
|
|
43
|
+
* @param {{code: string, message: string, hint?: string, command?: string}} opts
|
|
44
|
+
* @returns {number}
|
|
45
|
+
*/
|
|
46
|
+
function failJson(useJson, { code, message, hint = null, command = null }) {
|
|
47
|
+
if (useJson) {
|
|
48
|
+
process.stdout.write(JSON.stringify(buildErrorDoc({ code, message, hint, command }), null, 2) + '\n');
|
|
49
|
+
} else {
|
|
50
|
+
process.stderr.write(message + '\n');
|
|
51
|
+
}
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { ERROR_CODES, buildErrorDoc, failJson };
|
package/src/utils/lifecycle.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// when done (F3 #15). Deliberately EXCLUDED: `mcp` (long-lived server), and
|
|
13
13
|
// `setup`/`update` (no OpenCode server to leak, and `setup` can be a long-lived
|
|
14
14
|
// interactive Electron flow that must never be force-exited).
|
|
15
|
-
const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'read', 'abort', 'fanout', 'models']);
|
|
15
|
+
const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor' /* local-only: no OpenCode server, no stray handles */]);
|
|
16
16
|
|
|
17
17
|
/** @param {string} command @returns {boolean} */
|
|
18
18
|
function isOneShotCommand(command) {
|
|
@@ -100,4 +100,4 @@ async function getCatalogInfo(opts = {}) {
|
|
|
100
100
|
return { models, fetchedAt: cache ? cache.fetchedAt : null };
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
module.exports = { getCatalog, refreshCatalog, catalogPath, getCatalogInfo, CATALOG_SCHEMA_VERSION };
|
|
103
|
+
module.exports = { getCatalog, refreshCatalog, catalogPath, getCatalogInfo, readCache, CATALOG_SCHEMA_VERSION };
|
|
@@ -20,7 +20,8 @@ const PROVIDER_FAMILY_NAMES = {
|
|
|
20
20
|
openrouter: 'OpenRouter',
|
|
21
21
|
google: 'Google',
|
|
22
22
|
openai: 'OpenAI',
|
|
23
|
-
anthropic: 'Anthropic'
|
|
23
|
+
anthropic: 'Anthropic',
|
|
24
|
+
deepseek: 'DeepSeek'
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
/** Provider API configs for fetching model lists */
|
|
@@ -68,6 +69,19 @@ const PROVIDER_FETCH_CONFIG = {
|
|
|
68
69
|
pricing: null
|
|
69
70
|
}));
|
|
70
71
|
}
|
|
72
|
+
},
|
|
73
|
+
deepseek: {
|
|
74
|
+
url: 'https://api.deepseek.com/models',
|
|
75
|
+
authHeader: (key) => ({ 'Authorization': `Bearer ${key}` }),
|
|
76
|
+
normalize: (body) => {
|
|
77
|
+
const data = JSON.parse(body);
|
|
78
|
+
return (data.data || []).map(m => ({
|
|
79
|
+
id: `deepseek/${m.id}`,
|
|
80
|
+
name: m.id,
|
|
81
|
+
contextLength: null,
|
|
82
|
+
pricing: null
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
71
85
|
}
|
|
72
86
|
};
|
|
73
87
|
|
|
@@ -75,7 +89,7 @@ const FETCH_TIMEOUT_MS = 5000;
|
|
|
75
89
|
|
|
76
90
|
/**
|
|
77
91
|
* Fetch models from a single provider API
|
|
78
|
-
* @param {string} provider - Provider name (openrouter, google, openai, anthropic)
|
|
92
|
+
* @param {string} provider - Provider name (openrouter, google, openai, anthropic, deepseek)
|
|
79
93
|
* @param {string} key - API key
|
|
80
94
|
* @returns {Promise<Array<{id: string, name: string, contextLength: number|null, pricing: {prompt: string|null, completion: string|null}|null}>>} Normalized model list
|
|
81
95
|
*/
|