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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// src/utils/pricing.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module pricing
|
|
6
|
+
* Token aggregation + cached-pricing lookup + layered cost resolution (WS-2 #2).
|
|
7
|
+
* Cost is resolved in layers and ALWAYS tagged with its source so it can never
|
|
8
|
+
* be mistaken for an authoritative figure it isn't:
|
|
9
|
+
* reported — OpenCode billed cost (msg.info.cost > 0)
|
|
10
|
+
* estimated — tokens × cached catalog pricing
|
|
11
|
+
* unknown — neither available (e.g. a direct provider with pricing:null)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function emptyUsageTotals() {
|
|
15
|
+
return { tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }, costReported: 0 };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sum per-message usage. The poll loop re-reads ALL messages each poll, so the
|
|
20
|
+
* caller stores the latest snapshot per message id in a Map; summing the Map's
|
|
21
|
+
* values avoids double-counting streamed growth.
|
|
22
|
+
* @param {Map<string,{tokens?:object, cost?:number}>} map
|
|
23
|
+
*/
|
|
24
|
+
function sumPerMessageUsage(map) {
|
|
25
|
+
const totals = emptyUsageTotals();
|
|
26
|
+
for (const v of map.values()) {
|
|
27
|
+
const t = v && v.tokens ? v.tokens : {};
|
|
28
|
+
totals.tokens.input += t.input || 0;
|
|
29
|
+
totals.tokens.output += t.output || 0;
|
|
30
|
+
totals.tokens.reasoning += t.reasoning || 0;
|
|
31
|
+
totals.tokens.cacheRead += (t.cache && t.cache.read) || 0;
|
|
32
|
+
totals.tokens.cacheWrite += (t.cache && t.cache.write) || 0;
|
|
33
|
+
if (typeof v.cost === 'number') { totals.costReported += v.cost; }
|
|
34
|
+
}
|
|
35
|
+
return totals;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Sync, non-refreshing cached-pricing lookup by full route id. @returns {{prompt,completion}|null} */
|
|
39
|
+
function lookupPricing(modelId) {
|
|
40
|
+
if (!modelId) { return null; }
|
|
41
|
+
let cache;
|
|
42
|
+
try { cache = require('./model-catalog').readCache(); } catch { return null; }
|
|
43
|
+
if (!cache || !Array.isArray(cache.models)) { return null; }
|
|
44
|
+
const row = cache.models.find(m => m && m.id === modelId);
|
|
45
|
+
if (!row || !row.pricing) { return null; }
|
|
46
|
+
const prompt = Number(row.pricing.prompt);
|
|
47
|
+
const completion = Number(row.pricing.completion);
|
|
48
|
+
if (!Number.isFinite(prompt) || !Number.isFinite(completion) || prompt < 0 || completion < 0) { return null; }
|
|
49
|
+
return { prompt, completion };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @returns {{amount:number|null, currency:'USD', source:'reported'|'estimated'|'unknown'}} */
|
|
53
|
+
function resolveLegCost({ reportedCost, tokens, pricing }) {
|
|
54
|
+
if (typeof reportedCost === 'number' && reportedCost > 0) {
|
|
55
|
+
return { amount: reportedCost, currency: 'USD', source: 'reported' };
|
|
56
|
+
}
|
|
57
|
+
if (pricing && tokens) {
|
|
58
|
+
const est = (tokens.input || 0) * pricing.prompt + (tokens.output || 0) * pricing.completion;
|
|
59
|
+
if (est > 0) { return { amount: est, currency: 'USD', source: 'estimated' }; }
|
|
60
|
+
}
|
|
61
|
+
return { amount: null, currency: 'USD', source: 'unknown' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve a single run/leg's final usage block from raw totals + the model id. */
|
|
65
|
+
function resolveUsage({ model, usageTotals }) {
|
|
66
|
+
const totals = usageTotals || emptyUsageTotals();
|
|
67
|
+
const cost = resolveLegCost({ reportedCost: totals.costReported, tokens: totals.tokens, pricing: lookupPricing(model) });
|
|
68
|
+
return { tokens: totals.tokens, cost };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Aggregate leg usage into a wave-level usage block. Legs without usage count as unpriced. */
|
|
72
|
+
function sumWaveUsage(legs) {
|
|
73
|
+
const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
|
|
74
|
+
let amount = 0; let anyAmount = false;
|
|
75
|
+
let reportedLegs = 0, estimatedLegs = 0, unpricedLegs = 0;
|
|
76
|
+
for (const leg of legs) {
|
|
77
|
+
const u = leg && leg.usage;
|
|
78
|
+
if (!u || !u.cost) { unpricedLegs++; continue; }
|
|
79
|
+
for (const k of Object.keys(tokens)) { tokens[k] += (u.tokens && u.tokens[k]) || 0; }
|
|
80
|
+
if (typeof u.cost.amount === 'number') { amount += u.cost.amount; anyAmount = true; }
|
|
81
|
+
if (u.cost.source === 'reported') { reportedLegs++; }
|
|
82
|
+
else if (u.cost.source === 'estimated') { estimatedLegs++; }
|
|
83
|
+
else { unpricedLegs++; }
|
|
84
|
+
}
|
|
85
|
+
let source;
|
|
86
|
+
if (reportedLegs > 0 && estimatedLegs === 0 && unpricedLegs === 0) { source = 'reported'; }
|
|
87
|
+
else if (estimatedLegs > 0 && reportedLegs === 0 && unpricedLegs === 0) { source = 'estimated'; }
|
|
88
|
+
else if (reportedLegs === 0 && estimatedLegs === 0) { source = 'unknown'; }
|
|
89
|
+
else { source = 'mixed'; }
|
|
90
|
+
return { tokens, cost: { amount: anyAmount ? amount : null, currency: 'USD', source, reportedLegs, estimatedLegs, unpricedLegs } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { emptyUsageTotals, sumPerMessageUsage, lookupPricing, resolveLegCost, resolveUsage, sumWaveUsage };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quick-pick resolution (wizard Step 2) — resolves each curated family to
|
|
3
|
+
* the current catalog flagship. Setup-time only; runtime alias resolution
|
|
4
|
+
* stays on the static DEFAULT_ALIASES (see curated-models.js).
|
|
5
|
+
*
|
|
6
|
+
* Ranking: numeric-descending over ids; a marker-suffixed variant
|
|
7
|
+
* (-preview/-exp/-beta/-latest/:free) loses ONLY to its own unmarked base,
|
|
8
|
+
* so gemini-3.1-pro-preview still beats the older stable gemini-2.5-pro.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { getFamilies, toDefaultAliases } = require('./curated-models');
|
|
14
|
+
|
|
15
|
+
const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
|
|
16
|
+
|
|
17
|
+
/** Numeric-desc comparator; same-base marker variant sorts after its base. */
|
|
18
|
+
function compareIdsDesc(a, b) {
|
|
19
|
+
const aBase = a.replace(MARKER_RE, '');
|
|
20
|
+
const bBase = b.replace(MARKER_RE, '');
|
|
21
|
+
if (aBase === bBase && a !== b) {
|
|
22
|
+
if (a === aBase) { return -1; }
|
|
23
|
+
if (b === bBase) { return 1; }
|
|
24
|
+
return a.localeCompare(b, 'en', { numeric: true });
|
|
25
|
+
}
|
|
26
|
+
return b.localeCompare(a, 'en', { numeric: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Newest catalog id under `<nsPrefix><vendorPath>/` whose model segment
|
|
31
|
+
* matches idPattern. nsPrefix is 'openrouter/' or '' (direct rows).
|
|
32
|
+
* @param {string} vendorPath - vendor segment in the OpenRouter namespace,
|
|
33
|
+
* or the provider name when matching direct-namespace rows (nsPrefix '').
|
|
34
|
+
* @returns {string|null} full catalog id
|
|
35
|
+
*/
|
|
36
|
+
function pickCurrent(catalog, nsPrefix, vendorPath, idPattern) {
|
|
37
|
+
const prefix = `${nsPrefix}${vendorPath}/`;
|
|
38
|
+
const ids = (Array.isArray(catalog) ? catalog : [])
|
|
39
|
+
.map(m => m && m.id)
|
|
40
|
+
.filter(id => typeof id === 'string' && id.startsWith(prefix))
|
|
41
|
+
.filter(id => idPattern.test(id.slice(prefix.length)));
|
|
42
|
+
if (ids.length === 0) { return null; }
|
|
43
|
+
return ids.sort(compareIdsDesc)[0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {Array<{id:string}>} catalog
|
|
48
|
+
* @returns {Array<{alias,label,blurb,source:'live'|'fallback',routes:Object<string,string>}>}
|
|
49
|
+
* `routes` may be empty if a family defines no fallback and the catalog has no match.
|
|
50
|
+
*/
|
|
51
|
+
function resolveQuickPicks(catalog) {
|
|
52
|
+
return getFamilies().map(f => {
|
|
53
|
+
const routes = {};
|
|
54
|
+
let live = false;
|
|
55
|
+
const orPick = pickCurrent(catalog, 'openrouter/', f.vendorPath, f.idPattern);
|
|
56
|
+
if (orPick) { routes.openrouter = orPick; live = true; }
|
|
57
|
+
else if (f.fallback.openrouter) { routes.openrouter = f.fallback.openrouter; }
|
|
58
|
+
for (const p of f.directProviders) {
|
|
59
|
+
const direct = pickCurrent(catalog, '', p, f.idPattern);
|
|
60
|
+
if (direct) { routes[p] = direct; live = true; }
|
|
61
|
+
else if (f.fallback[p]) { routes[p] = f.fallback[p]; }
|
|
62
|
+
}
|
|
63
|
+
return { alias: f.alias, label: f.label, blurb: f.blurb, routes,
|
|
64
|
+
source: live ? 'live' : 'fallback' };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Seed map for fresh configs: static defaults overlaid with live family
|
|
70
|
+
* openrouter routes (cardless aliases stay pinned).
|
|
71
|
+
* @returns {Object<string,string>}
|
|
72
|
+
*/
|
|
73
|
+
function toLiveSeedAliases(catalog) {
|
|
74
|
+
const seeds = toDefaultAliases();
|
|
75
|
+
for (const r of resolveQuickPicks(catalog || [])) {
|
|
76
|
+
if (r.source === 'live' && r.routes.openrouter) { seeds[r.alias] = r.routes.openrouter; }
|
|
77
|
+
}
|
|
78
|
+
return seeds;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { compareIdsDesc, pickCurrent, resolveQuickPicks, toLiveSeedAliases };
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* any rename/removal bumps SCHEMA_VERSION.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
const SCHEMA_VERSION =
|
|
12
|
+
const SCHEMA_VERSION = 2;
|
|
13
13
|
|
|
14
14
|
/** Leg/run statuses that count as terminal for wave aggregation. */
|
|
15
15
|
const TERMINAL_STATUSES = ['complete', 'error', 'timeout', 'aborted', 'crashed', 'idle-timeout'];
|
|
@@ -48,7 +48,7 @@ function durationBetween(createdAt, completedAt) {
|
|
|
48
48
|
* @param {string|null} [opts.waveId] - Explicit wave id (falls back to metadata.parentWave)
|
|
49
49
|
* @returns {object} run document
|
|
50
50
|
*/
|
|
51
|
-
function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null }) {
|
|
51
|
+
function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null, usage = null }) {
|
|
52
52
|
const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
|
|
53
53
|
const createdAt = metadata.createdAt || null;
|
|
54
54
|
const completedAt = metadata.completedAt || metadata.abortedAt || null;
|
|
@@ -69,6 +69,7 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
|
|
|
69
69
|
durationMs,
|
|
70
70
|
sessionDir,
|
|
71
71
|
opencodeSessionId: metadata.opencodeSessionId || null,
|
|
72
|
+
usage: usage !== null ? usage : (metadata.usage || null),
|
|
72
73
|
};
|
|
73
74
|
}
|
|
74
75
|
|
|
@@ -115,6 +116,7 @@ function waveExitCode(waveStatus) {
|
|
|
115
116
|
* @returns {object} wave document
|
|
116
117
|
*/
|
|
117
118
|
function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null }) {
|
|
119
|
+
const { sumWaveUsage } = require('./pricing');
|
|
118
120
|
const counts = {
|
|
119
121
|
total: legs.length,
|
|
120
122
|
complete: legs.filter(l => l.status === 'complete').length,
|
|
@@ -135,6 +137,7 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
|
|
|
135
137
|
createdAt,
|
|
136
138
|
completedAt,
|
|
137
139
|
durationMs,
|
|
140
|
+
usage: sumWaveUsage(legs),
|
|
138
141
|
};
|
|
139
142
|
}
|
|
140
143
|
|
|
@@ -246,6 +249,21 @@ function buildAuditDoc({ stale, catalogAvailable }) {
|
|
|
246
249
|
};
|
|
247
250
|
}
|
|
248
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Build a doctor health-check document (`doctor --json`).
|
|
254
|
+
* @param {{version: string, timestamp: string, checks: Array<{id,name,status,message,hint}>}} opts
|
|
255
|
+
*/
|
|
256
|
+
function buildDoctorDoc({ version, timestamp, checks }) {
|
|
257
|
+
return {
|
|
258
|
+
schemaVersion: SCHEMA_VERSION,
|
|
259
|
+
type: 'doctor',
|
|
260
|
+
ok: checks.every(c => c.status !== 'error'),
|
|
261
|
+
version,
|
|
262
|
+
timestamp,
|
|
263
|
+
checks,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
249
267
|
module.exports = {
|
|
250
268
|
SCHEMA_VERSION,
|
|
251
269
|
TERMINAL_STATUSES,
|
|
@@ -258,4 +276,5 @@ module.exports = {
|
|
|
258
276
|
buildWaveResultFromSession,
|
|
259
277
|
buildCatalogDoc,
|
|
260
278
|
buildAuditDoc,
|
|
279
|
+
buildDoctorDoc,
|
|
261
280
|
};
|
|
@@ -1,29 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session abort
|
|
2
|
+
* Session abort utilities: signal handler installation and terminal metadata writes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Handles two related responsibilities: (1) installing process signal handlers
|
|
5
|
+
* (SIGINT, SIGTERM, SIGBREAK) that trigger abort callbacks, and (2) synchronously
|
|
6
|
+
* writing terminal status to session metadata (markTerminal and markAborted).
|
|
7
|
+
* These work together to ensure that when a session process is signaled, the
|
|
8
|
+
* session is marked as aborted immediately, preventing orphaned sessions from
|
|
9
|
+
* consuming API credits and ensuring `amicus list` reflects the true state.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
const fs = require('fs');
|
|
11
13
|
const path = require('path');
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
|
-
* Synchronously
|
|
16
|
+
* Synchronously write a terminal status to a session's metadata. Best-effort: never throws.
|
|
17
|
+
* `aborted` uses `abortedAt`; every other status uses `completedAt`.
|
|
15
18
|
* @param {string} sessionDir
|
|
16
|
-
* @param {
|
|
17
|
-
* @
|
|
19
|
+
* @param {'aborted'|'timed-out'|'error'|'complete'} status
|
|
20
|
+
* @param {string} reason
|
|
21
|
+
* @returns {boolean} true if written
|
|
18
22
|
*/
|
|
19
|
-
function
|
|
23
|
+
function markTerminal(sessionDir, status, reason) {
|
|
20
24
|
try {
|
|
21
25
|
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
22
26
|
if (!fs.existsSync(metaPath)) { return false; }
|
|
23
27
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
24
|
-
meta.status =
|
|
25
|
-
meta.reason =
|
|
26
|
-
meta
|
|
28
|
+
meta.status = status;
|
|
29
|
+
meta.reason = reason;
|
|
30
|
+
meta[status === 'aborted' ? 'abortedAt' : 'completedAt'] = new Date().toISOString();
|
|
27
31
|
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
28
32
|
return true;
|
|
29
33
|
} catch {
|
|
@@ -31,6 +35,11 @@ function markAborted(sessionDir, reason) {
|
|
|
31
35
|
}
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
/** Mark a session aborted (preserves prior behavior). */
|
|
39
|
+
function markAborted(sessionDir, reason) {
|
|
40
|
+
return markTerminal(sessionDir, 'aborted', `Aborted (${reason})`);
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
/**
|
|
35
44
|
* Register signal handlers that call onAbort(signal). Returns an uninstall fn.
|
|
36
45
|
* @param {{onAbort: (signal: string) => void, signals?: string[]}} opts
|
|
@@ -50,4 +59,22 @@ function installSignalAbort({ onAbort, signals = ['SIGINT', 'SIGTERM', 'SIGBREAK
|
|
|
50
59
|
};
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Idle-backstop teardown: mark timed-out, write a stub summary, close the owned server.
|
|
64
|
+
* Returns the exit code (always 2).
|
|
65
|
+
* @param {string} sessionDir
|
|
66
|
+
* @param {{close: () => void}|null} server
|
|
67
|
+
* @param {boolean} externalServer - when true, the server is not ours to close
|
|
68
|
+
* @returns {2}
|
|
69
|
+
*/
|
|
70
|
+
function idleBackstopTeardown(sessionDir, server, externalServer) {
|
|
71
|
+
try {
|
|
72
|
+
markTerminal(sessionDir, 'timed-out', 'Idle backstop timeout');
|
|
73
|
+
fs.writeFileSync(path.join(sessionDir, 'summary.md'),
|
|
74
|
+
'Session timed out — idle backstop fired before completion.\n', { mode: 0o600 });
|
|
75
|
+
} catch { /* best-effort */ }
|
|
76
|
+
if (!externalServer && server) { try { server.close(); } catch { /* best-effort */ } }
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { markTerminal, markAborted, installSignalAbort, idleBackstopTeardown };
|
package/src/utils/validators.js
CHANGED
|
@@ -168,7 +168,7 @@ function validateExplicitSession(session, _projectPath) {
|
|
|
168
168
|
if (!found) {
|
|
169
169
|
return {
|
|
170
170
|
valid: false,
|
|
171
|
-
error: `Error: --session '${session}' not found. Use '
|
|
171
|
+
error: `Error: --session '${session}' not found. Use 'amicus list' to see available sessions or omit --session for most recent.`
|
|
172
172
|
};
|
|
173
173
|
}
|
|
174
174
|
|
|
@@ -176,12 +176,7 @@ function validateExplicitSession(session, _projectPath) {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
/**
|
|
179
|
-
* Validate agent mode
|
|
180
|
-
*
|
|
181
|
-
* Accepts:
|
|
182
|
-
* - OpenCode native agents: Build, Plan, General, Explore
|
|
183
|
-
* - Custom agents: any non-empty string (for user-defined OpenCode agents)
|
|
184
|
-
*
|
|
179
|
+
* Validate agent mode (native OpenCode agents or any non-empty custom agent string).
|
|
185
180
|
* @param {string} agent
|
|
186
181
|
* @returns {{valid: boolean, error?: string}}
|
|
187
182
|
*/
|
|
@@ -239,11 +234,8 @@ const { MODEL_THINKING_SUPPORT, getSupportedThinkingLevels, validateThinkingLeve
|
|
|
239
234
|
|
|
240
235
|
/**
|
|
241
236
|
* Validate API key is present for the given model's provider.
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* sources (sidecar .env, auth.json) into process.env.
|
|
245
|
-
*
|
|
246
|
-
* @param {string} model - The model string (e.g., 'openrouter/google/gemini-2.5-flash')
|
|
237
|
+
* Assumes loadCredentials() has already run (projects .env + auth.json → process.env).
|
|
238
|
+
* @param {string} model - e.g. 'openrouter/google/gemini-2.5-flash'
|
|
247
239
|
* @returns {{valid: boolean, error?: string}}
|
|
248
240
|
*/
|
|
249
241
|
function validateApiKey(model) {
|
|
@@ -259,14 +251,22 @@ function validateApiKey(model) {
|
|
|
259
251
|
}
|
|
260
252
|
|
|
261
253
|
if (!process.env[providerInfo.key]) {
|
|
254
|
+
const keyName = providerInfo.key;
|
|
255
|
+
const isWin = process.platform === 'win32';
|
|
256
|
+
const persist = isWin
|
|
257
|
+
? ` - Persist it for new shells: setx ${keyName} <your-key>\n` +
|
|
258
|
+
` (or add $env:${keyName} to your PowerShell $PROFILE)\n`
|
|
259
|
+
: ` - Persist it across shells: add 'export ${keyName}=<your-key>' to ~/.zshenv\n` +
|
|
260
|
+
' (non-interactive shells like Claude Code and CI do not source ~/.zshrc)\n';
|
|
262
261
|
return {
|
|
263
262
|
valid: false,
|
|
264
|
-
|
|
265
|
-
|
|
263
|
+
code: 'MISSING_KEY',
|
|
264
|
+
error:
|
|
265
|
+
`Error: ${keyName} not found for ${providerInfo.name}.\n\n` +
|
|
266
266
|
'Fix with one of:\n' +
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
' -
|
|
267
|
+
` - Store it in Amicus (recommended): amicus key ${provider} <apikey>\n` +
|
|
268
|
+
persist +
|
|
269
|
+
' - Or add it to ~/.local/share/opencode/auth.json\n',
|
|
270
270
|
};
|
|
271
271
|
}
|
|
272
272
|
|