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.
Files changed (72) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +324 -0
  3. package/README.md +1 -1
  4. package/bin/amicus.js +6 -0
  5. package/docs/ROADMAP.md +5 -4
  6. package/docs/architecture-map.md +732 -0
  7. package/docs/configuration.md +175 -1
  8. package/docs/council.md +9 -0
  9. package/docs/doc-system.md +12 -9
  10. package/docs/testing.md +2 -1
  11. package/docs/troubleshooting.md +76 -0
  12. package/docs/usage.md +14 -6
  13. package/electron/main.js +25 -2
  14. package/electron/setup-ui-alias-groups.js +161 -0
  15. package/electron/setup-ui-alias-script.js +70 -4
  16. package/electron/setup-ui-aliases.js +25 -21
  17. package/electron/setup-ui.js +11 -1
  18. package/package.json +1 -1
  19. package/schemas/model-catalog.schema.json +2 -1
  20. package/schemas/run.schema.json +13 -0
  21. package/skills/sidecar/SKILL.md +1 -8
  22. package/src/cli-handlers-doctor.js +12 -16
  23. package/src/cli-handlers-fanout.js +10 -1
  24. package/src/cli-handlers-resume-continue.js +25 -0
  25. package/src/cli-handlers.js +17 -1
  26. package/src/cli.js +5 -8
  27. package/src/council/briefings-chair.js +4 -2
  28. package/src/council/run-assemble.js +7 -2
  29. package/src/council/run-retry-notes.js +21 -1
  30. package/src/council/run-stages.js +8 -1
  31. package/src/headless.js +125 -7
  32. package/src/mcp-server.js +26 -0
  33. package/src/mcp-tools.js +4 -4
  34. package/src/opencode-client.js +84 -8
  35. package/src/pack/pack-validate.js +3 -0
  36. package/src/session-manager.js +2 -2
  37. package/src/sidecar/continue.js +6 -1
  38. package/src/sidecar/conversation-mirror.js +35 -11
  39. package/src/sidecar/fanout-leg-fallback.js +1 -0
  40. package/src/sidecar/fanout-leg.js +10 -2
  41. package/src/sidecar/fanout.js +2 -2
  42. package/src/sidecar/interactive.js +31 -4
  43. package/src/sidecar/models-ceiling-line.js +72 -0
  44. package/src/sidecar/models.js +4 -2
  45. package/src/sidecar/reopen-notices.js +97 -0
  46. package/src/sidecar/reopen-spend.js +3 -2
  47. package/src/sidecar/resume.js +15 -2
  48. package/src/sidecar/session-finalize.js +4 -1
  49. package/src/sidecar/session-utils.js +5 -1
  50. package/src/sidecar/start-metadata.js +1 -1
  51. package/src/sidecar/start.js +10 -5
  52. package/src/utils/api-key-validation.js +183 -94
  53. package/src/utils/config.js +65 -2
  54. package/src/utils/curated-models.js +8 -8
  55. package/src/utils/degrade.js +7 -0
  56. package/src/utils/doctor-credit-check.js +61 -0
  57. package/src/utils/doctor-key-auth-check.js +271 -0
  58. package/src/utils/doctor-output-budget-check.js +198 -0
  59. package/src/utils/engine-output-flag.js +105 -0
  60. package/src/utils/engine-variants.js +298 -0
  61. package/src/utils/http-get.js +284 -0
  62. package/src/utils/live-probes.js +53 -0
  63. package/src/utils/model-catalog.js +36 -4
  64. package/src/utils/model-ceilings-modelsdev.js +230 -0
  65. package/src/utils/model-fetcher.js +14 -36
  66. package/src/utils/model-output-limit.js +132 -0
  67. package/src/utils/openrouter-credit.js +104 -0
  68. package/src/utils/output-length.js +90 -0
  69. package/src/utils/result-schema.js +7 -2
  70. package/src/utils/spend-ledger.js +5 -1
  71. package/src/utils/thinking-validators.js +27 -80
  72. 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
+ };