amicus 4.9.2 → 4.9.4
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 +324 -0
- package/README.md +1 -1
- package/bin/amicus.js +6 -0
- package/docs/ROADMAP.md +5 -4
- package/docs/architecture-map.md +732 -0
- package/docs/configuration.md +175 -1
- package/docs/council.md +9 -0
- package/docs/doc-system.md +12 -9
- package/docs/testing.md +2 -1
- package/docs/troubleshooting.md +76 -0
- package/docs/usage.md +14 -6
- package/electron/main.js +25 -2
- package/electron/setup-ui-alias-groups.js +161 -0
- package/electron/setup-ui-alias-script.js +70 -4
- package/electron/setup-ui-aliases.js +25 -21
- package/electron/setup-ui.js +11 -1
- package/package.json +1 -1
- package/schemas/model-catalog.schema.json +2 -1
- package/schemas/run.schema.json +13 -0
- package/skills/sidecar/SKILL.md +1 -8
- package/src/cli-handlers-doctor.js +12 -16
- package/src/cli-handlers-fanout.js +10 -1
- package/src/cli-handlers-resume-continue.js +25 -0
- package/src/cli-handlers.js +17 -1
- package/src/cli.js +5 -8
- package/src/council/briefings-chair.js +4 -2
- package/src/council/run-assemble.js +7 -2
- package/src/council/run-retry-notes.js +21 -1
- package/src/council/run-stages.js +8 -1
- package/src/headless.js +125 -7
- package/src/mcp-server.js +26 -0
- package/src/mcp-tools.js +4 -4
- package/src/opencode-client.js +84 -8
- package/src/pack/pack-validate.js +3 -0
- package/src/session-manager.js +2 -2
- package/src/sidecar/continue.js +6 -1
- package/src/sidecar/conversation-mirror.js +35 -11
- package/src/sidecar/fanout-leg-fallback.js +1 -0
- package/src/sidecar/fanout-leg.js +10 -2
- package/src/sidecar/fanout.js +2 -2
- package/src/sidecar/interactive.js +31 -4
- package/src/sidecar/models-ceiling-line.js +72 -0
- package/src/sidecar/models.js +4 -2
- package/src/sidecar/reopen-notices.js +97 -0
- package/src/sidecar/reopen-spend.js +3 -2
- package/src/sidecar/resume.js +15 -2
- package/src/sidecar/session-finalize.js +4 -1
- package/src/sidecar/session-utils.js +5 -1
- package/src/sidecar/start-metadata.js +1 -1
- package/src/sidecar/start.js +10 -5
- package/src/utils/api-key-validation.js +183 -94
- package/src/utils/config.js +65 -2
- package/src/utils/curated-models.js +8 -8
- package/src/utils/degrade.js +7 -0
- package/src/utils/doctor-credit-check.js +61 -0
- package/src/utils/doctor-key-auth-check.js +271 -0
- package/src/utils/doctor-output-budget-check.js +198 -0
- package/src/utils/engine-output-flag.js +105 -0
- package/src/utils/engine-variants.js +298 -0
- package/src/utils/http-get.js +284 -0
- package/src/utils/live-probes.js +53 -0
- package/src/utils/model-catalog.js +36 -4
- package/src/utils/model-ceilings-modelsdev.js +230 -0
- package/src/utils/model-fetcher.js +14 -36
- package/src/utils/model-output-limit.js +132 -0
- package/src/utils/openrouter-credit.js +104 -0
- package/src/utils/output-length.js +90 -0
- package/src/utils/result-schema.js +7 -2
- package/src/utils/spend-ledger.js +5 -1
- package/src/utils/thinking-validators.js +27 -80
- package/src/utils/validators.js +2 -3
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module doctor-key-auth-check
|
|
3
|
+
* The `key-auth` doctor row (issue #210), split out of src/cli-handlers-doctor.js
|
|
4
|
+
* to keep that file under the 300-line size gate (mirrors
|
|
5
|
+
* doctor-base-url-check.js / doctor-alias-check.js / doctor-local-providers-check.js).
|
|
6
|
+
*
|
|
7
|
+
* WHY IT EXISTS: doctor's `keys` row tests PRESENCE only — readApiKeys()
|
|
8
|
+
* returns booleans — and validateApiKey() was called at exactly two SAVE-TIME
|
|
9
|
+
* sites (electron/ipc-setup.js, src/cli-handlers.js). A key that rotted after
|
|
10
|
+
* it was entered, or that reached .env by any path other than the wizard /
|
|
11
|
+
* `amicus key`, was never re-checked: #210's reporter had a green doctor while
|
|
12
|
+
* the stored DeepSeek key returned 401 and the catalog served 0 deepseek rows.
|
|
13
|
+
*
|
|
14
|
+
* THE STATUS RULE (the whole design problem is FALSE ALARMS):
|
|
15
|
+
* - HTTP 401 → 'error'. That is the server saying "this credential is not
|
|
16
|
+
* accepted", and nothing else in the space says it that plainly.
|
|
17
|
+
* - EVERYTHING else → 'warn'. Timeout, DNS/socket error, 5xx, 429, an
|
|
18
|
+
* unexpected 4xx, a result with no status at all: none of these
|
|
19
|
+
* distinguish a rotted key from a laptop on a plane. Unrecognised input
|
|
20
|
+
* falls to the ambiguous side ON PURPOSE — the failure mode of a false
|
|
21
|
+
* 'error' here is a user re-entering a perfectly good key.
|
|
22
|
+
* - HTTP 403 is explicitly on the WARN side (council finding 1, PR 221). It
|
|
23
|
+
* read as definitive at first, which was wrong: Google returns 403 for
|
|
24
|
+
* "API not enabled" and for quota, and a WAF returns it for bot
|
|
25
|
+
* protection. Treating it as a verdict traded the false-GREEN this check
|
|
26
|
+
* was built to kill for a false-ALARM of the same shape.
|
|
27
|
+
* - a stored key with NO validation endpoint → 'warn'. It cannot be probed,
|
|
28
|
+
* so this check cannot vouch for it (council finding C1, first review).
|
|
29
|
+
* - no keys stored → 'ok' + "skipped" (mirrors the openrouter-credit skip).
|
|
30
|
+
*
|
|
31
|
+
* The evidence is the STRUCTURED `status` field, not the prose.
|
|
32
|
+
* classifyProbeFailure used to regex "(401)" out of the error string, which
|
|
33
|
+
* made control flow depend on message wording — a reword upstream that dropped
|
|
34
|
+
* the parentheses would have degraded every row to a permanent "unverified"
|
|
35
|
+
* with nothing failing to announce it (council finding 2, PR 221).
|
|
36
|
+
*
|
|
37
|
+
* NO KEY MATERIAL, NO URLs, EVER. Four layers, each structural:
|
|
38
|
+
* 1. Only provider IDS and a reason built here reach the row — the raw error
|
|
39
|
+
* string is never echoed.
|
|
40
|
+
* 2. Every live authenticated request realDeps() can make goes through the
|
|
41
|
+
* probe wrappers below (probeApiKey, probeOpenRouterCredit), which share
|
|
42
|
+
* one gate. An earlier version of this note claimed probeApiKey alone was
|
|
43
|
+
* that gate while checkOpenRouterCredit sat beside it unguarded — the
|
|
44
|
+
* claim is now true rather than merely written down.
|
|
45
|
+
* 3. api-key-validation.js redacts the key from any message before it
|
|
46
|
+
* escapes, and resolves rather than rejecting on a synchronous throw from
|
|
47
|
+
* https.get. That is the ROOT fix (council finding 3, PR 221): the Google
|
|
48
|
+
* probe embeds the key as `?key=...`, and the two save-time call sites
|
|
49
|
+
* have no protection of their own — electron/ipc-setup.js hands
|
|
50
|
+
* `err.message` to the renderer and logs it, and src/cli-handlers.js has
|
|
51
|
+
* no try/catch at all.
|
|
52
|
+
* 4. Each probe here is STILL individually caught, because this module must
|
|
53
|
+
* not depend on an injected validator honouring that contract.
|
|
54
|
+
*
|
|
55
|
+
* Probes run in PARALLEL: validateApiKey's own timeout is 10s, so five stored
|
|
56
|
+
* keys probed sequentially would add up to 50s to a `doctor` run. Fan-out is
|
|
57
|
+
* bounded by the number of configured providers (5 today), so no pool is needed.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
'use strict';
|
|
61
|
+
|
|
62
|
+
const { VALIDATION_ENDPOINTS } = require('./api-key-validation');
|
|
63
|
+
|
|
64
|
+
const ID = 'key-auth';
|
|
65
|
+
const NAME = 'API key auth';
|
|
66
|
+
|
|
67
|
+
/** Statuses that are an authoritative "this credential is not accepted". */
|
|
68
|
+
// 401 only. 403 was here and is not a credential verdict — see
|
|
69
|
+
// classifyProbeFailure (council finding 1, PR 221).
|
|
70
|
+
const DEFINITIVE_STATUSES = new Set([401]);
|
|
71
|
+
|
|
72
|
+
const REENTER_HINT = (providers) =>
|
|
73
|
+
`amicus key <provider> <key> (re-enter the rejected key for: ${providers.join(', ')})`;
|
|
74
|
+
|
|
75
|
+
const UNPROBEABLE_HINT =
|
|
76
|
+
'Not a rejection — amicus has no validation endpoint for this provider, so the '
|
|
77
|
+
+ 'key could not be checked either way. Add one in utils/api-key-validation.js '
|
|
78
|
+
+ '(VALIDATION_ENDPOINTS) to bring it under this check.';
|
|
79
|
+
|
|
80
|
+
const UNVERIFIED_HINT =
|
|
81
|
+
'Not a rejection — the probe could not reach the provider (offline, DNS failure, '
|
|
82
|
+
+ '5xx or timeout). Re-run `amicus doctor` when connectivity is restored.';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether a live authenticated request is allowed right now. The shared gate
|
|
86
|
+
* for BOTH probes below — that sharing is the point, see layer 2 in the header.
|
|
87
|
+
*
|
|
88
|
+
* Council finding 7 (PR 221): `runDoctorChecks` merges a caller's deps over
|
|
89
|
+
* `realDeps()`, so ANY suite that builds deps by hand and forgets to inject a
|
|
90
|
+
* double — i.e. bypasses tests/helpers/doctor-base-deps.js — would fire live
|
|
91
|
+
* authenticated HTTPS requests using the developer's REAL
|
|
92
|
+
* ~/.config/amicus/.env keys. Nothing would fail; the suite would just be
|
|
93
|
+
* slower and quietly spending someone's credentials. Suites that DO inject a
|
|
94
|
+
* double are unaffected: theirs wins the merge and these are never called.
|
|
95
|
+
*
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
function liveProbesDisabled() {
|
|
99
|
+
return !require('./live-probes').liveProbesAllowed();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Marker string the classifier recognises, so a skip reads as a skip. */
|
|
103
|
+
const SKIPPED_REASON = 'probe skipped (live probes disabled)';
|
|
104
|
+
|
|
105
|
+
const SKIPPED = () => Promise.resolve({
|
|
106
|
+
valid: false, status: null, skipped: true, error: SKIPPED_REASON,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The key-validation probe `realDeps()` injects, behind the shared gate.
|
|
111
|
+
* A skip resolves as an ordinary unverified result, so the row warns rather
|
|
112
|
+
* than claiming health it did not establish.
|
|
113
|
+
* @returns {Promise<{valid: boolean, status: number|null, error?: string}>}
|
|
114
|
+
*/
|
|
115
|
+
function probeApiKey(provider, key) {
|
|
116
|
+
if (liveProbesDisabled()) { return SKIPPED(); }
|
|
117
|
+
return require('./api-key-validation').validateApiKey(provider, key);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The OpenRouter credit probe, behind the SAME guard.
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ This exists because the claim above was FALSE when first written. The
|
|
124
|
+
* header said probeApiKey was "the one place" a live authenticated request is
|
|
125
|
+
* decided, while realDeps() still injected `checkOpenRouterCredit` as an
|
|
126
|
+
* unguarded raw require — an authenticated call to openrouter.ai/api/v1/key
|
|
127
|
+
* with the developer's stored key, reachable by exactly the hand-built dep
|
|
128
|
+
* suites the guard was written for. Council review of PR 222 falsified the
|
|
129
|
+
* claim; routing both probes through one gate makes it true instead of
|
|
130
|
+
* deleting it. Resolves the same shape checkOpenRouterCredit does, so the
|
|
131
|
+
* caller's `res.warning` branch is unaffected.
|
|
132
|
+
*/
|
|
133
|
+
function probeOpenRouterCredit(key) {
|
|
134
|
+
if (liveProbesDisabled()) {
|
|
135
|
+
// ⚠️ `warning: null` alone is what checkOpenRouterCredit resolves when the
|
|
136
|
+
// account is FINE, so returning it here made a skipped probe render as
|
|
137
|
+
// "credit ok" — a false green reporting a funded account nobody checked.
|
|
138
|
+
// Introduced in the same commit that removed the identical shape from the
|
|
139
|
+
// anthropic branch, one function over, with a test blessing it. Council
|
|
140
|
+
// review of PR 222. `skipped` is what the row branches on now.
|
|
141
|
+
return Promise.resolve({
|
|
142
|
+
skipped: true, checked: false,
|
|
143
|
+
warning: null, isFreeTier: false, limitRemaining: null, limit: null, usage: null,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return require('./api-key-validation').checkOpenRouterCredit(key);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Classify one validateApiKey failure as a definitive auth rejection or an
|
|
151
|
+
* ambiguous one, and produce the SANITIZED reason that may be printed.
|
|
152
|
+
*
|
|
153
|
+
* Reads the STRUCTURED `status` field. This used to regex the status out of
|
|
154
|
+
* `error` — which made a control-flow decision depend on message wording, so a
|
|
155
|
+
* reword upstream that dropped the parentheses would have silently degraded
|
|
156
|
+
* every row to a permanent "unverified" with no test failing to say so
|
|
157
|
+
* (council finding 2, PR 221). api-key-validation.js returns the status as
|
|
158
|
+
* data now. A result with no status is ambiguous, which is the fail-safe side.
|
|
159
|
+
*
|
|
160
|
+
* The returned `reason` is built here from scratch — it never contains any
|
|
161
|
+
* substring of `error`, which is what makes the no-URL/no-key guarantee
|
|
162
|
+
* structural rather than a hope about provider prose.
|
|
163
|
+
*
|
|
164
|
+
* @param {{status: number|null, error?: string}} [res] validateApiKey's result
|
|
165
|
+
* @returns {{definitive: boolean, reason: string}}
|
|
166
|
+
*/
|
|
167
|
+
function classifyProbeFailure(res) {
|
|
168
|
+
const r = (res && typeof res === 'object') ? res : {};
|
|
169
|
+
const status = (typeof r.status === 'number' && Number.isFinite(r.status)) ? r.status : null;
|
|
170
|
+
|
|
171
|
+
if (status !== null && DEFINITIVE_STATUSES.has(status)) {
|
|
172
|
+
return { definitive: true, reason: `rejected (HTTP ${status})` };
|
|
173
|
+
}
|
|
174
|
+
if (status === 403) {
|
|
175
|
+
// Deliberately NOT definitive (council finding 1). Google returns 403 for
|
|
176
|
+
// "API not enabled" and for quota; a WAF returns it for bot protection.
|
|
177
|
+
// Telling someone to re-enter a working key is the false-ALARM twin of the
|
|
178
|
+
// false-GREEN this check exists to kill, and this check's whole design
|
|
179
|
+
// premise is that a false error costs more than a warn.
|
|
180
|
+
return { definitive: false, reason: 'unverified (HTTP 403 — forbidden, may not be the key)' };
|
|
181
|
+
}
|
|
182
|
+
if (status !== null) {
|
|
183
|
+
return { definitive: false, reason: `unverified (HTTP ${status})` };
|
|
184
|
+
}
|
|
185
|
+
if (r.skipped) {
|
|
186
|
+
// Named, not folded into "unreachable" — the two have different fixes and
|
|
187
|
+
// conflating them is the kind of small dishonesty this row exists to avoid.
|
|
188
|
+
return { definitive: false, reason: 'unverified (probe skipped)' };
|
|
189
|
+
}
|
|
190
|
+
if (/timed?\s*out|timeout/i.test(typeof r.error === 'string' ? r.error : '')) {
|
|
191
|
+
return { definitive: false, reason: 'unverified (timed out)' };
|
|
192
|
+
}
|
|
193
|
+
return { definitive: false, reason: 'unverified (unreachable)' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Probe one stored key. Always resolves; never surfaces `error`/`e.message`.
|
|
198
|
+
* @returns {Promise<{provider: string, part: string, rejected: boolean, unverified: boolean}>}
|
|
199
|
+
*/
|
|
200
|
+
async function probeOne(d, provider, key) {
|
|
201
|
+
let res;
|
|
202
|
+
try {
|
|
203
|
+
res = await d.validateApiKey(provider, key);
|
|
204
|
+
} catch (_e) {
|
|
205
|
+
// See rule 2 in the file header: _e.message can carry the Google probe URL,
|
|
206
|
+
// key included. It is deliberately dropped rather than logged.
|
|
207
|
+
return { provider, part: `${provider}: unverified (probe failed)`, rejected: false, unverified: true };
|
|
208
|
+
}
|
|
209
|
+
if (res && res.valid) {
|
|
210
|
+
return { provider, part: `${provider}: valid`, rejected: false, unverified: false };
|
|
211
|
+
}
|
|
212
|
+
const { definitive, reason } = classifyProbeFailure(res);
|
|
213
|
+
return { provider, part: `${provider}: ${reason}`, rejected: definitive, unverified: !definitive };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {{readApiKeyValues: () => Object<string,string>,
|
|
218
|
+
* validateApiKey: (provider:string, key:string)
|
|
219
|
+
* => Promise<{valid:boolean, status:number|null, error?:string}>}} d
|
|
220
|
+
* @returns {Promise<{id:string, name:string, status:string, message:string, hint:?string}>}
|
|
221
|
+
*/
|
|
222
|
+
async function evaluateKeyAuth(d) {
|
|
223
|
+
const values = d.readApiKeyValues() || {};
|
|
224
|
+
// Object.keys + bracket access (never `for..in`) — same prototype-chain
|
|
225
|
+
// discipline as doctor-local-providers-check.js.
|
|
226
|
+
const stored = Object.keys(values).filter((p) => values[p]);
|
|
227
|
+
|
|
228
|
+
if (stored.length === 0) {
|
|
229
|
+
return { id: ID, name: NAME, status: 'ok', message: 'no keys stored — skipped', hint: null };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A stored key for a provider with no validation endpoint cannot be probed at
|
|
233
|
+
// all — so this check cannot vouch for it, and must not imply that it can.
|
|
234
|
+
//
|
|
235
|
+
// ⚠️ This originally reported such a key but still returned `ok`, on the
|
|
236
|
+
// reasoning that adding a provider to provider-registry.js ahead of an
|
|
237
|
+
// endpoint in api-key-validation.js should not turn every doctor run yellow.
|
|
238
|
+
// Council finding C1 on PR #221 (raised by `gpt`) is right that this is the
|
|
239
|
+
// EXACT false-green class #210 exists to close: a row that says "ok" while a
|
|
240
|
+
// stored credential was never checked. It warns now. The cost is zero today —
|
|
241
|
+
// PROVIDER_ENV_MAP and VALIDATION_ENDPOINTS carry identical key sets, so
|
|
242
|
+
// `unprobeable` is always empty and this can only fire once someone actually
|
|
243
|
+
// creates the gap. Silence is not health.
|
|
244
|
+
const probeable = stored.filter((p) => VALIDATION_ENDPOINTS[p]);
|
|
245
|
+
const unprobeable = stored.filter((p) => !VALIDATION_ENDPOINTS[p]);
|
|
246
|
+
|
|
247
|
+
// Parallel by construction — see the file header.
|
|
248
|
+
const results = await Promise.all(probeable.map((p) => probeOne(d, p, values[p])));
|
|
249
|
+
|
|
250
|
+
const parts = results.map((r) => r.part)
|
|
251
|
+
.concat(unprobeable.map((p) => `${p}: not probeable (no validation endpoint)`));
|
|
252
|
+
const message = parts.join('; ');
|
|
253
|
+
|
|
254
|
+
const rejected = results.filter((r) => r.rejected).map((r) => r.provider);
|
|
255
|
+
if (rejected.length > 0) {
|
|
256
|
+
return { id: ID, name: NAME, status: 'error', message, hint: REENTER_HINT(rejected) };
|
|
257
|
+
}
|
|
258
|
+
// Unprobeable counts as unverified (C1): 'ok' here would vouch for a key
|
|
259
|
+
// nothing checked. A definitive rejection still outranks it above.
|
|
260
|
+
if (results.some((r) => r.unverified) || unprobeable.length > 0) {
|
|
261
|
+
const hint = unprobeable.length > 0 && !results.some((r) => r.unverified)
|
|
262
|
+
? UNPROBEABLE_HINT : UNVERIFIED_HINT;
|
|
263
|
+
return { id: ID, name: NAME, status: 'warn', message, hint };
|
|
264
|
+
}
|
|
265
|
+
return { id: ID, name: NAME, status: 'ok', message, hint: null };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = {
|
|
269
|
+
evaluateKeyAuth, classifyProbeFailure,
|
|
270
|
+
probeApiKey, probeOpenRouterCredit, liveProbesDisabled, SKIPPED_REASON,
|
|
271
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module doctor-output-budget-check
|
|
3
|
+
* #218 PR 2: the 'output-budget' doctor row.
|
|
4
|
+
*
|
|
5
|
+
* VERIFIABLE voice (same rule as doctor-base-url-check.js): the row states only
|
|
6
|
+
* what it read — the configured `outputBudget` as stored, the ambient
|
|
7
|
+
* OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX this process sees, and which alias
|
|
8
|
+
* routes the cached catalog can clamp. It never claims what a provider will do.
|
|
9
|
+
*
|
|
10
|
+
* What earns a WARN, and why (probe rows in brackets, BACKLOG "v4.9.4 records"):
|
|
11
|
+
* - a malformed budget or a malformed ambient flag: the engine falls back to
|
|
12
|
+
* 32000 SILENTLY (D1/D2) — the one failure the product principle forbids;
|
|
13
|
+
* only a plain decimal integer (digits, no leading zero) is measured to be
|
|
14
|
+
* honoured, so any other form is reported as unmeasured, never healthy;
|
|
15
|
+
* - a value above the engine default with alias routes the catalog cannot
|
|
16
|
+
* clamp: the engine clamps routes its own catalog knows (K5), but a model
|
|
17
|
+
* neither catalog knows receives the value as-is (J2/K13);
|
|
18
|
+
* - a reservation of at least half a route's context window: input plus
|
|
19
|
+
* max_tokens has to fit the window, and the engine subtracts the same
|
|
20
|
+
* reservation from the window before compaction (read in the binary), so
|
|
21
|
+
* such a value starves the prompt;
|
|
22
|
+
* - nothing for a direct openai route: it carries no reservation field at
|
|
23
|
+
* all (M5/M13/M22), so it is listed apart from the clamped/unclamped counts.
|
|
24
|
+
* A configured budget and a valid ambient flag get the SAME analysis: they
|
|
25
|
+
* govern the same spawns (council #231 r2 D2). So does a valid ambient flag
|
|
26
|
+
* beside a malformed budget (r4 B1).
|
|
27
|
+
*/
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const { normalizeOutputBudget, buildLimitLookup, computeModelLimit, positiveCount } = require('./model-output-limit');
|
|
31
|
+
const {
|
|
32
|
+
OUTPUT_TOKEN_FLAG, ENGINE_DEFAULT_OUTPUT_TOKENS, outputTokenFlagValue, PLAIN_OUTPUT_TOKEN_FLAG,
|
|
33
|
+
} = require('./engine-output-flag');
|
|
34
|
+
|
|
35
|
+
const ID = 'output-budget';
|
|
36
|
+
const NAME = 'Output budget';
|
|
37
|
+
const row = (status, message, hint = null) => ({ id: ID, name: NAME, status, message, hint });
|
|
38
|
+
|
|
39
|
+
/** Up to three names, then "+N more". @param {string[]} names @returns {string} */
|
|
40
|
+
function shortList(names) {
|
|
41
|
+
const head = names.slice(0, 3).join(', ');
|
|
42
|
+
return names.length > 3 ? `${head}, +${names.length - 3} more` : head;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What a per-leg value reaches, by the cached catalog's numbers: which alias
|
|
47
|
+
* routes have a ceiling to clamp against, which do not, and which would give
|
|
48
|
+
* at least half their context window to the reservation. `value` is the
|
|
49
|
+
* configured budget or — with none configured — the ambient flag: the engine
|
|
50
|
+
* spawn is governed the same way either way (K5/K12: min(value, the ceiling
|
|
51
|
+
* the engine knows); J2/K13: the value as-is on a model neither catalog knows),
|
|
52
|
+
* so both get the same analysis (council #231 r2 D2).
|
|
53
|
+
* @param {object} d doctor deps
|
|
54
|
+
* @param {?object} cache readCache() result
|
|
55
|
+
* @param {number} value positive integer
|
|
56
|
+
* @param {string} lowerHint the hint that names the knob to lower
|
|
57
|
+
* @returns {{clauses:string, status:'ok'|'warn', hint:?string}}
|
|
58
|
+
*/
|
|
59
|
+
function analyseRoutes(d, cache, value, lowerHint) {
|
|
60
|
+
const shown = outputTokenFlagValue(value);
|
|
61
|
+
const aboveDefault = value > ENGINE_DEFAULT_OUTPUT_TOKENS;
|
|
62
|
+
if (!cache || !Array.isArray(cache.models)) {
|
|
63
|
+
// At or below the engine default the flag alone never raises what goes
|
|
64
|
+
// out (K12), so a missing cache is informational; above it an unknown model
|
|
65
|
+
// receives the value as-is (J2/K13) and the cache is what would name a
|
|
66
|
+
// ceiling. Named mutant "NOCACHEALWAYSWARN": make this branch warn regardless.
|
|
67
|
+
// Named mutant "NOCACHEOPENAI" (tests/doctor-output-budget.test.js): drop the openai clause.
|
|
68
|
+
return {
|
|
69
|
+
clauses: `; no catalog cache, so no route has a known ceiling here (the engine clamps routes its own catalog knows; an unknown model receives ${shown} as-is; an openai/ id carries no output reservation on a leg that resolves DIRECT — the Responses API request has no output-limit field, probe M5/M13/M22 — while the openrouter/openai/… form of the same model does, M1/M9; which gateway a leg takes is a launch-time decision this row cannot read)`,
|
|
70
|
+
status: aboveDefault ? 'warn' : 'ok',
|
|
71
|
+
hint: aboveDefault ? 'amicus models --refresh — with no cache nothing can be checked; a model neither catalog knows receives the value unclamped, so lower it if any route is one' : null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const limits = buildLimitLookup(cache.models);
|
|
75
|
+
const routes = [...new Set(d.collectAliasSources()
|
|
76
|
+
.map((s) => s && s.model)
|
|
77
|
+
.filter((m) => typeof m === 'string' && m.length > 0))];
|
|
78
|
+
// #218 PR 4 (probe M5/M13/M22): the engine drives the direct `openai` provider
|
|
79
|
+
// through the Responses API, whose request body carries NO output-limit field
|
|
80
|
+
// — with a bare descriptor, with limit.output 8000 and the flag at 8000, for
|
|
81
|
+
// gpt-5.6-terra and gpt-4o alike. Neither lever reaches that route, so it is
|
|
82
|
+
// reported apart, never as clamped. Named mutant "OPENAIGOVERNED".
|
|
83
|
+
// council #235 r4 (C6): the id prefix partitions the LIST — it does not predict the ROUTE. This
|
|
84
|
+
// row cannot see a future invocation's `--gateway`, `routing.prefer` or which keys are present,
|
|
85
|
+
// and the `openrouter/openai/…` form of the same model DOES carry the reservation (M1/M9), so the
|
|
86
|
+
// clause states the outcome of a leg that resolves DIRECT and says the choice is made at launch.
|
|
87
|
+
// Named mutant "OPENAIOUTCOMEASSERTED": restore the unconditional "carries no output reservation
|
|
88
|
+
// at all … so the value does not apply there" in both clauses below.
|
|
89
|
+
const ungoverned = routes.filter((id) => id.startsWith('openai/'));
|
|
90
|
+
const governed = routes.filter((id) => !id.startsWith('openai/'));
|
|
91
|
+
const unclamped = [];
|
|
92
|
+
const starved = [];
|
|
93
|
+
for (const id of governed) {
|
|
94
|
+
const limit = computeModelLimit(limits.get(id), value);
|
|
95
|
+
if (!limit) { unclamped.push(id); continue; }
|
|
96
|
+
if (limit.output * 2 >= limit.context) { starved.push(`${id} (${limit.output} of ${limit.context})`); }
|
|
97
|
+
}
|
|
98
|
+
let clauses = `; ${governed.length - unclamped.length} of ${governed.length} alias routes have a known catalog ceiling`;
|
|
99
|
+
let status = 'ok';
|
|
100
|
+
let hint = null;
|
|
101
|
+
if (unclamped.length > 0) {
|
|
102
|
+
clauses += `; ${unclamped.length} without one (${shortList(unclamped)}) — the engine clamps those its own catalog knows, an unknown model receives ${shown} as-is`;
|
|
103
|
+
// At or below the default the flag never raises what the engine sends
|
|
104
|
+
// (K12); above it an unknown model is the one place the number goes out
|
|
105
|
+
// unclamped (J2/K13), so that is the only case worth a warning.
|
|
106
|
+
// Named mutant "NODEFAULTGATE": drop this condition.
|
|
107
|
+
if (aboveDefault) {
|
|
108
|
+
status = 'warn';
|
|
109
|
+
hint = 'lower the value if one of those routes is a model neither catalog knows (it receives it unclamped); amicus models --refresh if the catalog is just stale';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (starved.length > 0) {
|
|
113
|
+
clauses += `; reserves at least half the context window of ${shortList(starved)}`;
|
|
114
|
+
status = 'warn';
|
|
115
|
+
// Starvation leads: a catalog refresh cannot fix it, lowering the value can.
|
|
116
|
+
hint = lowerHint + (hint ? `; ${hint}` : '');
|
|
117
|
+
}
|
|
118
|
+
if (ungoverned.length > 0) {
|
|
119
|
+
const plural = ungoverned.length === 1 ? '' : 's';
|
|
120
|
+
const carry = ungoverned.length === 1 ? 'carries' : 'carry';
|
|
121
|
+
clauses += `; ${ungoverned.length} openai/ alias route${plural} (${shortList(ungoverned)}) ${carry} no output reservation on a leg that resolves DIRECT to that provider — the engine drives it through the Responses API, whose request has no output-limit field (probe M5/M13/M22); which gateway a leg takes is a launch-time decision this row cannot read (--gateway, routing.prefer, key presence), and the openrouter/openai/… form of the same model DOES carry the reservation (M1/M9)`;
|
|
122
|
+
}
|
|
123
|
+
return { clauses, status, hint };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {{readOutputBudgetRaw:Function, readCache:Function, collectAliasSources:Function,
|
|
128
|
+
* getConfigDir?:Function, env?:NodeJS.ProcessEnv}} d
|
|
129
|
+
* @returns {{id:string,name:string,status:string,message:string,hint:?string}}
|
|
130
|
+
*/
|
|
131
|
+
function evaluateOutputBudget(d) {
|
|
132
|
+
const env = d.env || process.env;
|
|
133
|
+
const ambient = env[OUTPUT_TOKEN_FLAG];
|
|
134
|
+
const raw = d.readOutputBudgetRaw();
|
|
135
|
+
// Not "32000 per leg": under the default a leg reserves min(32000, the
|
|
136
|
+
// ceiling the engine's catalog knows for it) — probe B sent 4096 for a
|
|
137
|
+
// 4096-ceiling row with no flag at all.
|
|
138
|
+
const dflt = `the engine default applies (OUTPUT_TOKEN_MAX ${ENGINE_DEFAULT_OUTPUT_TOKENS}: each leg reserves min(${ENGINE_DEFAULT_OUTPUT_TOKENS}, the ceiling the engine's catalog knows for it))`;
|
|
139
|
+
|
|
140
|
+
// Only PLAIN_OUTPUT_TOKEN_FLAG (engine-output-flag.js) is measured to be honoured:
|
|
141
|
+
// it is the shape amicus itself writes (engine-output-flag.js ::
|
|
142
|
+
// outputTokenFlagValue) and the shape the probe ran (C1, K5, K12). `64000abc` and
|
|
143
|
+
// `0` fall back to 32000 silently (D1/D2); ' 64000 ', '064000', '1e5', '0x10' and
|
|
144
|
+
// '64000.7' have never been probed, so they are reported as unmeasured rather than
|
|
145
|
+
// as healthy (council #231 r1 finding 3, r2 D5).
|
|
146
|
+
const ambientOk = (ambient !== undefined && PLAIN_OUTPUT_TOKEN_FLAG.test(ambient)) ? positiveCount(Number(ambient)) : null;
|
|
147
|
+
const ambientBad = ambient !== undefined && ambientOk === null;
|
|
148
|
+
const ambientBadText = `${OUTPUT_TOKEN_FLAG}=${ambient} in this environment is not a plain positive integer — the only form measured to be honoured (probe D1/D2: 64000abc and 0 fell back to ${ENGINE_DEFAULT_OUTPUT_TOKENS} silently); any other form is unmeasured`;
|
|
149
|
+
const ambientHint = `unset ${OUTPUT_TOKEN_FLAG}, or set it to a plain positive integer`;
|
|
150
|
+
|
|
151
|
+
if (raw === undefined) {
|
|
152
|
+
if (ambient === undefined) { return row('ok', `not set — ${dflt}`); }
|
|
153
|
+
if (ambientBad) { return row('warn', `not set — ${ambientBadText}`, ambientHint); }
|
|
154
|
+
// A valid ambient value governs every engine amicus starts exactly as a
|
|
155
|
+
// budget would, so it gets the same route analysis (council #231 r2 D2).
|
|
156
|
+
const shown = outputTokenFlagValue(ambientOk);
|
|
157
|
+
const lead = `not set — ${OUTPUT_TOKEN_FLAG}=${ambient} in this environment sets the engine's OUTPUT_TOKEN_MAX to ${shown} (its default is ${ENGINE_DEFAULT_OUTPUT_TOKENS}): each leg reserves min(${shown}, the ceiling the engine's catalog knows for it), and ${shown} as-is on a model it does not know`;
|
|
158
|
+
const a = analyseRoutes(d, d.readCache(), ambientOk, `lower ${OUTPUT_TOKEN_FLAG} — input plus the reservation must fit the context window`);
|
|
159
|
+
return row(a.status, lead + a.clauses, a.hint);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const budget = normalizeOutputBudget(raw);
|
|
163
|
+
if (budget === null) {
|
|
164
|
+
// A malformed budget sets no flag (engine-output-flag.js), so whatever is
|
|
165
|
+
// ambient governs the spawn — the row has to say which value that leaves,
|
|
166
|
+
// and a VALID ambient value gets the same route analysis a budget would
|
|
167
|
+
// (council #231 r4 B1). Named mutant "MALFORMEDNOANALYSIS": drop the
|
|
168
|
+
// analyseRoutes call below.
|
|
169
|
+
// `getConfigDir` is always in the doctor deps; the fallback keeps a hand-built
|
|
170
|
+
// deps object from printing "undefined/config.json" (council #231 r2 C2).
|
|
171
|
+
const cfgDir = typeof d.getConfigDir === 'function' ? d.getConfigDir() : '~/.config/amicus';
|
|
172
|
+
const fixHint = `set outputBudget to a positive integer in ${cfgDir}/config.json, or remove it`;
|
|
173
|
+
const lead = `${JSON.stringify(raw)} is not a positive integer — ignored; `;
|
|
174
|
+
if (ambient === undefined) { return row('warn', lead + dflt, fixHint); }
|
|
175
|
+
if (ambientBad) { return row('warn', `${lead}${dflt} (${ambientBadText})`, fixHint); }
|
|
176
|
+
const shownA = outputTokenFlagValue(ambientOk);
|
|
177
|
+
const a = analyseRoutes(d, d.readCache(), ambientOk, `lower ${OUTPUT_TOKEN_FLAG} — input plus the reservation must fit the context window`);
|
|
178
|
+
return row('warn',
|
|
179
|
+
`${lead}${OUTPUT_TOKEN_FLAG}=${ambient} in this environment governs engines amicus starts (OUTPUT_TOKEN_MAX ${shownA}: each leg reserves min(${shownA}, the ceiling the engine's catalog knows for it))${a.clauses}`,
|
|
180
|
+
fixHint + (a.hint ? `; ${a.hint}` : ''));
|
|
181
|
+
}
|
|
182
|
+
// A malformed ambient value never reaches an engine amicus starts — the
|
|
183
|
+
// budget overrides it — so the row stays ok, but it says the value is
|
|
184
|
+
// malformed rather than only "overridden" (council #231 r4 D1).
|
|
185
|
+
const overridden = ambient === undefined ? ''
|
|
186
|
+
: (ambientBad
|
|
187
|
+
? `; ${OUTPUT_TOKEN_FLAG}=${ambient} in this environment is not a plain positive integer and is overridden by outputBudget for engines amicus starts (an engine started outside amicus would read it and fall back to ${ENGINE_DEFAULT_OUTPUT_TOKENS} silently)`
|
|
188
|
+
: `; ${OUTPUT_TOKEN_FLAG}=${ambient} in this environment is overridden by outputBudget for engines amicus starts`);
|
|
189
|
+
// normalizeOutputBudget floors a fractional number; say so rather than report
|
|
190
|
+
// a value the user never typed as if they had (council #231 B2).
|
|
191
|
+
const floored = (typeof raw === 'number' && raw !== budget) ? ` (floored from ${raw})` : '';
|
|
192
|
+
const shown = outputTokenFlagValue(budget); // plain digits even above 1e21, the same form the flag carries
|
|
193
|
+
const lead = `budget ${shown}${floored} — each leg reserves min(${shown}, its ceiling where one is known)`;
|
|
194
|
+
const a = analyseRoutes(d, d.readCache(), budget, 'lower outputBudget — input plus the reservation must fit the context window');
|
|
195
|
+
return row(a.status, lead + a.clauses + overridden, a.hint);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { evaluateOutputBudget };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module engine-output-flag
|
|
3
|
+
* #218 PR 2 — the one engine env flag amicus sets: OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX.
|
|
4
|
+
*
|
|
5
|
+
* WHY A FLAG AT ALL. The per-model `limit` descriptor (model-output-limit.js)
|
|
6
|
+
* can only LOWER a leg's max_tokens reservation: the pinned engine computes
|
|
7
|
+
* `Math.min(limit.output, OUTPUT_TOKEN_MAX)` and OUTPUT_TOKEN_MAX defaults to
|
|
8
|
+
* 32000. Raising it is this flag's job. The engine reads the flag from the env
|
|
9
|
+
* it is SPAWNED with, as a positive integer; `64000abc` and `0` fall back to
|
|
10
|
+
* 32000 with no error anywhere (probe rows D1/D2), and a negative value is
|
|
11
|
+
* rejected by the same positive-integer check, read in the pinned binary
|
|
12
|
+
* (`Number.isInteger(w) && w > 0`), not wire-measured.
|
|
13
|
+
*
|
|
14
|
+
* THE ONE RULE: when `outputBudget` is configured, the flag is set TO THE BUDGET
|
|
15
|
+
* for every engine amicus starts — around the synchronous spawn only, restored
|
|
16
|
+
* before anything is awaited. Measured on the wire by scripts/probe-max-tokens.js
|
|
17
|
+
* (BACKLOG "v4.9.4 records", the PR 2 record):
|
|
18
|
+
* - a route the amicus catalog can clamp gets min(budget, ceiling) through the
|
|
19
|
+
* descriptor, and the flag never exceeds it (C2, K6);
|
|
20
|
+
* - a bare `{}` route the ENGINE knows gets min(engine ceiling, budget)
|
|
21
|
+
* (C3, K5, K12) — the flag reaches rows the amicus catalog cannot name;
|
|
22
|
+
* - a route neither knows gets the budget as-is (J2, K13), exactly as it got
|
|
23
|
+
* the raw 32000 before;
|
|
24
|
+
* - an ambient value the user exported themselves is honoured untouched when no
|
|
25
|
+
* budget is configured, and overridden for the spawn (then restored) when one is.
|
|
26
|
+
*
|
|
27
|
+
* WHY AROUND THE SYNCHRONOUS CALL. The pinned @opencode-ai/sdk spreads
|
|
28
|
+
* process.env into the child's env inside createOpencodeServer BEFORE its first
|
|
29
|
+
* await (node_modules/@opencode-ai/sdk/dist/server.js), so the flag has to be in
|
|
30
|
+
* process.env at call time and may be gone by the time the promise settles.
|
|
31
|
+
* Restoring in `finally` keeps it out of every OTHER child amicus spawns
|
|
32
|
+
* (Electron, the MCP child, on-complete hooks) and out of the caller's own env.
|
|
33
|
+
* The unit pin is tests/opencode-client-output-flag.test.js; the SDK-side pin is
|
|
34
|
+
* tests/opencode-client-sdk-spawn-timing.test.js (the real SDK against a fake engine
|
|
35
|
+
* on PATH); the engine-side canary is the probe's K6/K12/K13 rows, which CI's
|
|
36
|
+
* keyless job runs on every push (tests/probe-flag-canary.integration.test.js).
|
|
37
|
+
*/
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const { positiveCount } = require('./model-output-limit');
|
|
41
|
+
|
|
42
|
+
const OUTPUT_TOKEN_FLAG = 'OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX';
|
|
43
|
+
/** The engine's own default when the flag is absent or malformed (probe rows A, D1, D2). */
|
|
44
|
+
const ENGINE_DEFAULT_OUTPUT_TOKENS = 32000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The ONE form of OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX measured to be honoured:
|
|
48
|
+
* a plain decimal integer with no leading zero -- the shape amicus writes
|
|
49
|
+
* (outputTokenFlagValue) and the shape the probe ran (C1, K5, K12). `64000abc`
|
|
50
|
+
* and `0` fell back to 32000 silently (D1/D2); ' 64000 ', '064000', '1e5',
|
|
51
|
+
* '0x10' and '64000.7' have never been probed. Shared by the doctor row
|
|
52
|
+
* (doctor-output-budget-check.js :: evaluateOutputBudget) and the death report
|
|
53
|
+
* (output-length.js :: formatOutputLengthReason) so the two gates cannot drift.
|
|
54
|
+
*/
|
|
55
|
+
const PLAIN_OUTPUT_TOKEN_FLAG = /^[1-9]\d*$/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The flag value a budget produces, or null when no flag should be set.
|
|
59
|
+
* Exactly normalizeOutputBudget's acceptance rule (a positive finite integer,
|
|
60
|
+
* floored), rendered as plain decimal digits even above 1e21; everything else
|
|
61
|
+
* is "no flag".
|
|
62
|
+
* @param {*} budget raw or normalized outputBudget
|
|
63
|
+
* @returns {string|null}
|
|
64
|
+
*/
|
|
65
|
+
function outputTokenFlagValue(budget) {
|
|
66
|
+
const n = positiveCount(budget);
|
|
67
|
+
if (n === null) { return null; }
|
|
68
|
+
// String(1e21) is '1e+21', which the engine would read as malformed (D1) and
|
|
69
|
+
// the doctor row would report as unmeasured. BigInt renders every
|
|
70
|
+
// integer-valued double as plain digits, so the flag always has the one shape
|
|
71
|
+
// measured to be honoured and a configured budget is never silently dropped
|
|
72
|
+
// (council #231 B1/C2).
|
|
73
|
+
return n >= 1e21 ? BigInt(n).toString() : String(n);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Run `fn` with the flag set to `budget` in `env`, restoring the previous state
|
|
78
|
+
* (absent, or the ambient value) before returning — whether `fn` returned a
|
|
79
|
+
* value, returned a promise, or threw. With no usable budget `fn` runs untouched.
|
|
80
|
+
*
|
|
81
|
+
* `delete`, not `= undefined`: assigning undefined to a process.env key stores
|
|
82
|
+
* the string 'undefined', which the engine would read as a malformed flag.
|
|
83
|
+
* @template T
|
|
84
|
+
* @param {*} budget outputBudget (positive integer, else no-op)
|
|
85
|
+
* @param {() => T} fn called synchronously, exactly once
|
|
86
|
+
* @param {NodeJS.ProcessEnv} [env] defaults to process.env — the env the SDK spreads
|
|
87
|
+
* @returns {T} whatever fn returned (a promise is returned, never awaited here)
|
|
88
|
+
*/
|
|
89
|
+
function withOutputTokenFlag(budget, fn, env = process.env) {
|
|
90
|
+
const value = outputTokenFlagValue(budget);
|
|
91
|
+
if (value === null) { return fn(); }
|
|
92
|
+
const had = Object.prototype.hasOwnProperty.call(env, OUTPUT_TOKEN_FLAG);
|
|
93
|
+
const saved = env[OUTPUT_TOKEN_FLAG];
|
|
94
|
+
env[OUTPUT_TOKEN_FLAG] = value;
|
|
95
|
+
try {
|
|
96
|
+
return fn();
|
|
97
|
+
} finally {
|
|
98
|
+
if (had) { env[OUTPUT_TOKEN_FLAG] = saved; } else { delete env[OUTPUT_TOKEN_FLAG]; }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
withOutputTokenFlag, outputTokenFlagValue, OUTPUT_TOKEN_FLAG, ENGINE_DEFAULT_OUTPUT_TOKENS,
|
|
104
|
+
PLAIN_OUTPUT_TOKEN_FLAG,
|
|
105
|
+
};
|