amicus 4.9.3 → 4.9.5

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 (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +324 -0
  3. package/README.md +1 -1
  4. package/docs/ROADMAP.md +8 -5
  5. package/docs/architecture-map.md +736 -0
  6. package/docs/configuration.md +165 -26
  7. package/docs/council.md +9 -0
  8. package/docs/doc-system.md +12 -9
  9. package/docs/testing.md +2 -1
  10. package/docs/troubleshooting.md +113 -0
  11. package/docs/usage.md +11 -6
  12. package/package.json +1 -1
  13. package/schemas/model-catalog.schema.json +2 -1
  14. package/schemas/run.schema.json +13 -0
  15. package/scripts/postinstall.js +4 -0
  16. package/skills/sidecar/SKILL.md +1 -8
  17. package/src/cli-handlers-doctor.js +3 -0
  18. package/src/cli-handlers-fanout.js +10 -1
  19. package/src/cli-handlers-resume-continue.js +25 -0
  20. package/src/cli.js +5 -8
  21. package/src/council/briefings-chair.js +4 -2
  22. package/src/council/run-assemble.js +7 -2
  23. package/src/council/run-retry-notes.js +21 -1
  24. package/src/council/run-stages.js +8 -1
  25. package/src/headless.js +125 -7
  26. package/src/mcp-server.js +26 -0
  27. package/src/mcp-tools.js +4 -4
  28. package/src/opencode-client.js +84 -8
  29. package/src/pack/pack-validate.js +3 -0
  30. package/src/session-manager.js +2 -2
  31. package/src/sidecar/continue.js +6 -1
  32. package/src/sidecar/conversation-mirror.js +35 -11
  33. package/src/sidecar/electron-install.js +81 -81
  34. package/src/sidecar/electron-provision.js +179 -0
  35. package/src/sidecar/electron-trust.js +299 -0
  36. package/src/sidecar/fanout-leg-fallback.js +1 -0
  37. package/src/sidecar/fanout-leg.js +10 -2
  38. package/src/sidecar/fanout.js +2 -2
  39. package/src/sidecar/interactive.js +31 -4
  40. package/src/sidecar/models-ceiling-line.js +72 -0
  41. package/src/sidecar/models.js +4 -2
  42. package/src/sidecar/reopen-notices.js +97 -0
  43. package/src/sidecar/reopen-spend.js +3 -2
  44. package/src/sidecar/resume.js +15 -2
  45. package/src/sidecar/session-finalize.js +4 -1
  46. package/src/sidecar/session-utils.js +5 -1
  47. package/src/sidecar/start-metadata.js +1 -1
  48. package/src/sidecar/start.js +10 -5
  49. package/src/sidecar/unzip.js +40 -0
  50. package/src/utils/config.js +33 -12
  51. package/src/utils/curated-models.js +8 -8
  52. package/src/utils/degrade.js +7 -0
  53. package/src/utils/doctor-output-budget-check.js +198 -0
  54. package/src/utils/engine-output-flag.js +105 -0
  55. package/src/utils/engine-variants.js +298 -0
  56. package/src/utils/http-get.js +284 -0
  57. package/src/utils/model-catalog.js +36 -4
  58. package/src/utils/model-ceilings-modelsdev.js +230 -0
  59. package/src/utils/model-fetcher.js +12 -36
  60. package/src/utils/model-output-limit.js +21 -13
  61. package/src/utils/output-length.js +90 -0
  62. package/src/utils/result-schema.js +7 -2
  63. package/src/utils/spend-ledger.js +5 -1
  64. package/src/utils/thinking-validators.js +27 -80
  65. package/src/utils/validators.js +2 -3
@@ -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
+ };
@@ -0,0 +1,298 @@
1
+ /**
2
+ * @module engine-variants
3
+ * The effort lever (#218 PR 4): --thinking sent as the engine's variant field, validated against the engine's own declaration.
4
+ * The engine's prompt endpoint selects reasoning
5
+ * effort through `variant: string` (probe F2); the `reasoning` object amicus sent
6
+ * for every `--thinking` until now was never a prompt field and reached nothing
7
+ * (F1). A variant the model does not DECLARE is a silent no-op that the engine
8
+ * still echoes on the assistant message (F3 on a known model, M7 on one with
9
+ * `variants {}`), so a run's own artifact can claim an effort the wire never
10
+ * saw. This module is the one place amicus asks the engine what a model declares
11
+ * (`/config/providers` -> `variants` + `limit`, M0) and decides, per leg, to send,
12
+ * refuse, or send unverified.
13
+ *
14
+ * Measured, engine 1.18.15 (BACKLOG "v4.9.4 records", the PR 4 table):
15
+ * - a model newer than the engine's bundled catalogue reads `limit 0/0,
16
+ * variants {}` until the startup models.dev refresh lands (M0: qwen3.8-max-0902,
17
+ * glm-5.3 on a cold engine) and is known on the first poll of a WARM engine (M12: 36 ms on one run) —
18
+ * so a read that finds the model unknown WAITS, bounded, before deciding;
19
+ * - a variant does not move the reservation on OpenRouter (M1, M9), direct
20
+ * Google (M15), direct DeepSeek (M16) or an adaptive-thinking Anthropic model
21
+ * (M10b); only an entry shaped `thinking: {type: 'enabled', budgetTokens: N}`
22
+ * adds N ON TOP of the reservation (M2: 24000 + 16000 = 40000; K2/K11),
23
+ * clamped to the model's ceiling (K3/K4/K10);
24
+ * - N is the engine's, not a formula of the ceiling (M0: opus-4-5 declares
25
+ * 16000 for low, medium AND high, and no max), so the post-spawn dump is the
26
+ * only source of N — and nothing changes a descriptor after the spawn: a
27
+ * runtime PATCH /config changes nothing the engine serves and writes a
28
+ * config.json into its cwd (M3/M4/M11). The exact pre-spawn fit (descriptor
29
+ * = budget − N, proven M17) is filed, not built; this module REFUSES the
30
+ * over-budget shape with the numbers instead (BACKLOG C1's second clause).
31
+ * - /config/providers echoes a descriptor amicus wrote (M3: a 24000 limit.output reads back as 24000), so the ceiling a fit judges against comes from amicus's own catalog when it knows the model and from the dump only for a bare descriptor. The echo overwrites `limit` and NOTHING else (M23), so it never hides whose row it is: the name, family, release date, prices, capabilities and variants beside it are filled by the engine's MERGED view of its own catalogue and the user's opencode config (council #235 r4, C3 — a config `reasoning: true` even synthesizes a variants map), and `engineSourced` below reads those.
32
+ */
33
+ 'use strict';
34
+
35
+ const { positiveCount } = require('./model-output-limit');
36
+
37
+ /** Every level the curated routes declare between them (M0), in effort order. */
38
+ const VARIANT_LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
39
+
40
+ /** How long a read waits for the engine's startup refresh to make a model known (the cold wait is unmeasured — BACKLOG item 3; M12's 36 ms was a warm read). */
41
+ const DECLARATION_WAIT_MS = 5000;
42
+ const DECLARATION_POLL_MS = 500; // council #235 r1 (D2): ten reads across the bound, not twenty
43
+ const DECLARATION_READ_TIMEOUT_MS = 2000; // council #235 r2 (A1): ONE read's own bound. A warm read measured 36-285 ms (M12), so 2 s is generous; without it a stalled endpoint runs to undici's ~306 s default and the 5 s "bound" below caps only the NUMBER of reads.
44
+
45
+ /**
46
+ * Defang engine-sourced text before it enters a message. `/config/providers` is
47
+ * filled from the engine's remote models.dev refresh, so variant names are not
48
+ * ours; control characters and fence/tag characters are stripped so a poisoned
49
+ * catalogue cannot forge terminal escapes or markdown structure in a reason
50
+ * string. Mirrors sidecar/progress-fields.js :: sanitizePreview, which does the
51
+ * same job for briefings crossing the MCP boundary. Named mutant "RAWENGINETEXT".
52
+ * @param {string} s @returns {string}
53
+ */
54
+ // eslint-disable-next-line no-control-regex
55
+ const defang = (s) => String(s).replace(/[\u0000-\u001F\u007F]/g, '').replace(/[`<>]/g, '').replace(/\s+/g, ' ').trim();
56
+
57
+ /** Thrown by opencode-client.js :: sendPrompt BEFORE any request when a variant is refused. */
58
+ class VariantRefusedError extends Error {
59
+ constructor(code, message) {
60
+ super(message);
61
+ this.name = 'VariantRefusedError';
62
+ this.code = code;
63
+ }
64
+ }
65
+
66
+ /** A non-empty string cell. */
67
+ const filled = (v) => typeof v === 'string' && v.trim() !== '';
68
+
69
+ /**
70
+ * Whose row is this — the engine's own catalogue, or nothing but the descriptor amicus registered? Amicus writes exactly ONE cell into a model's entry (`limit`, src/utils/config.js:406) and /config/providers echoes it (M3), so `limit` can never answer that; everything else can. Measured with the IDENTICAL descriptor on both rows (record M23, engine 1.18.15): an engine row keeps its display name, family, release date, prices, capabilities and variants, a config-only row reads `name === modelID`, empty family/release_date, cost 0, `variants {}`.
71
+ * THE DISJUNCTS ARE EXACTLY THESE EIGHT: `id`, `providerID`, `api`, `status`, `options`, `headers`, `capabilities.toolcall` and `capabilities.input/output.text` are populated on a config-only row TOO (M23), so adding any of them reads every model the engine has not learned yet as a declaration and refuses it falsely — named mutants "TOOLCALLDISJUNCT" and "ECHOSOURCED" (add `limit`); "ONEDISJUNCT" shrinks the OR. Cost is compared `> 0`, never through positiveCount, which FLOORS $0.05 to 0 — mutant "COSTVIAPOSITIVECOUNT".
72
+ * @param {string} modelID model half of the executable id
73
+ * @param {object|null} m the dump's entry for it
74
+ * @returns {boolean}
75
+ */
76
+ function engineSourced(modelID, m) {
77
+ if (!m || typeof m !== 'object') { return false; }
78
+ const cost = (m.cost && typeof m.cost === 'object') ? m.cost : {};
79
+ const caps = (m.capabilities && typeof m.capabilities === 'object') ? m.capabilities : {};
80
+ const variants = (m.variants && typeof m.variants === 'object') ? m.variants : {};
81
+ return filled(m.release_date)
82
+ || filled(m.family)
83
+ || (filled(m.name) && m.name !== modelID)
84
+ || (typeof cost.input === 'number' && cost.input > 0)
85
+ || (typeof cost.output === 'number' && cost.output > 0)
86
+ || Object.keys(variants).length > 0
87
+ || caps.temperature === true || caps.reasoning === true || caps.attachment === true;
88
+ }
89
+
90
+ /**
91
+ * One read of `/config/providers` for one model. `known` is "the engine's own
92
+ * catalogue supplied this row" (engineSourced above, record M23) — NOT
93
+ * `limit.context > 0`, which reads the one cell amicus itself writes and the
94
+ * dump echoes back (M3). A model amicus registered that the engine's catalogue
95
+ * lacks carries nothing but that descriptor (J1, M0); a provider or model
96
+ * missing from the dump altogether is unknown too. Named mutant "LIMITISKNOWN".
97
+ * `limitOutput` is whatever the dump says — the engine's own ceiling for a bare descriptor, and the ECHO of a descriptor amicus wrote otherwise (M3).
98
+ * @param {object} client SDK client
99
+ * @param {string} providerID provider half of the executable id
100
+ * @param {string} modelID model half of the executable id
101
+ * @param {{signal?: object, readTimeoutMs?: number}} [opts] `signal` — the caller's abandon signal, joined to this read's own deadline when it is a real AbortSignal; `readTimeoutMs` — that deadline (default DECLARATION_READ_TIMEOUT_MS).
102
+ * @returns {Promise<{known: boolean, variants: object, limitOutput: number|null, unreadable: string|null}>}
103
+ */
104
+ async function readDeclarationOnce(client, providerID, modelID, opts = {}) {
105
+ const readTimeoutMs = positiveCount(opts.readTimeoutMs) || DECLARATION_READ_TIMEOUT_MS;
106
+ // council #235 r2 (A1): the read gets its OWN deadline, and the caller's abandon
107
+ // signal is joined to it so an abort cancels the read in flight rather than only
108
+ // ending the loop. Named mutant "UNBOUNDEDREAD" (tests/utils/engine-variants.test.js):
109
+ // drop the signal from the call and the read runs to the transport's own default
110
+ // (the SDK deletes its own — node_modules/@opencode-ai/sdk/dist/client.js sets
111
+ // `req.timeout = false` — leaving undici's ~306 s; the triage measured one read at
112
+ // 306,639 ms against a socket that accepts and never answers).
113
+ const timeoutSignal = AbortSignal.timeout(readTimeoutMs);
114
+ // AbortSignal.any REJECTS a duck-typed `{aborted}` (TypeError: not of type AbortSignal),
115
+ // and both the documented `options.signal` type and the tests use that shape; only the
116
+ // real AbortController signal headless passes can be joined. The loop's own `signal.aborted`
117
+ // check still ends the wait for the duck-typed case — it just cannot cancel a read already
118
+ // in flight, which is exactly what the timeout above now bounds.
119
+ const joinable = opts.signal && typeof AbortSignal !== 'undefined' && opts.signal instanceof AbortSignal;
120
+ const readSignal = joinable ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
121
+ let r;
122
+ try { r = await client.config.providers({ signal: readSignal }); } catch (err) {
123
+ // council #235 r1 (C1/D1/A2): a THROWN read — a transport error, a dead engine — is
124
+ // unreadable exactly like the returned non-2xx tuple below: one read, no wait, the level
125
+ // sent unverified with the note naming the error. Before this the rejection escaped
126
+ // sendPrompt and the leg died on it. Named mutant "THROWNREADFAILS"
127
+ // (tests/utils/engine-variants.test.js): drop the try/catch.
128
+ return { known: false, variants: {}, limitOutput: null, unreadable: `read threw: ${(err && err.message) || String(err)}` };
129
+ }
130
+ // #218 PR 4 whole-branch review (EP-3): the SDK returns a non-2xx as a VALUE ({error,
131
+ // response}, no data — opencode-client.js :: providerErrorReason keys on the same shape).
132
+ // A response with no providers array is UNREADABLE, not "model unknown": the wait must not
133
+ // burn 5 s on it and the note must not claim a read that never happened.
134
+ // Named mutant "UNREADABLEISCOLD" (tests/utils/engine-variants.test.js): `list = []` on that shape.
135
+ const list = (r && r.data && Array.isArray(r.data.providers)) ? r.data.providers : null;
136
+ if (list === null) {
137
+ const status = r && ((r.response && r.response.status) || (r.error && r.error.status));
138
+ return { known: false, variants: {}, limitOutput: null, unreadable: typeof status === 'number' ? `HTTP ${status}` : 'no providers array in the response' };
139
+ }
140
+ const p = list.find((x) => x && x.id === providerID);
141
+ const m = (p && p.models && Object.prototype.hasOwnProperty.call(p.models, modelID)) ? p.models[modelID] : null;
142
+ const limit = (m && m.limit && typeof m.limit === 'object') ? m.limit : {};
143
+ return {
144
+ known: engineSourced(modelID, m),
145
+ variants: (m && m.variants && typeof m.variants === 'object') ? m.variants : {},
146
+ limitOutput: positiveCount(limit.output),
147
+ unreadable: null,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * The ceiling amicus's own catalog knows for `model` — the number
153
+ * config.js :: buildProviderModels clamps a budget-derived descriptor to — or
154
+ * null when it knows none (the descriptor was then bare).
155
+ * @param {string} model executable id
156
+ * @param {Function} [readCache] test seam for model-catalog.js :: readCache
157
+ * @returns {number|null}
158
+ */
159
+ function catalogCeilingFor(model, readCache) {
160
+ try {
161
+ const { buildLimitLookup } = require('./model-output-limit');
162
+ const cache = (readCache || require('./model-catalog').readCache)();
163
+ const row = buildLimitLookup(cache && cache.models).get(model);
164
+ return row ? positiveCount(row.maxOutputTokens) : null;
165
+ } catch { return null; }
166
+ }
167
+
168
+ /**
169
+ * The engine's declaration for `model` ('provider/model', split at the FIRST
170
+ * slash — an OpenRouter id keeps its vendor path), waiting up to `waitMs` for
171
+ * the catalogue to know it. Named mutant "NOWAIT" (tests/utils/engine-variants.test.js).
172
+ * @param {object} client SDK client
173
+ * @param {string} model executable id
174
+ * @param {{waitMs?: number, pollMs?: number, sleep?: Function, now?: Function, catalogCeiling?: number|null, readCache?: Function, signal?: {aborted: boolean}, readTimeoutMs?: number}} [opts] test seams — `catalogCeiling` (explicit) wins over `readCache`. `readTimeoutMs` — each individual `/config/providers` read's own deadline (council #235 r2 A1).
175
+ * @returns {Promise<{known: boolean, variants: object, limitOutput: number|null, unreadable: string|null, ceiling: number|null, ceilingFrom: string, waitedMs: number}>} `ceiling` is what the fit judges against: the amicus catalog's ceiling when it knows the model, else `limitOutput`; `ceilingFrom` is `'catalog'` or `'engine'` accordingly, and checkVariant's OVER_BUDGET remedy reads it.
176
+ */
177
+ async function readModelDeclaration(client, model, opts = {}) {
178
+ const idx = typeof model === 'string' ? model.indexOf('/') : -1;
179
+ const providerID = idx > 0 ? model.slice(0, idx) : String(model);
180
+ const modelID = idx > 0 ? model.slice(idx + 1) : '';
181
+ const waitMs = opts.waitMs === undefined ? DECLARATION_WAIT_MS : opts.waitMs;
182
+ const pollMs = opts.pollMs === undefined ? DECLARATION_POLL_MS : opts.pollMs;
183
+ const sleep = opts.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
184
+ const now = opts.now || Date.now;
185
+ const catalogCeiling = opts.catalogCeiling !== undefined ? opts.catalogCeiling : catalogCeilingFor(model, opts.readCache);
186
+ const signal = opts.signal || null; // #218 PR 4 whole-branch review (EP-2): the caller's abandon signal
187
+ // council #235 r3 (C1/B1): the wait asks `known` and NOTHING about the budget. `known` is now
188
+ // "the engine's catalogue supplied this row" (engineSourced, M23), so the row that used to be
189
+ // indistinguishable from an echo — amicus's descriptor and nothing else — is positively
190
+ // identified and polled, and an engine row declaring no variants settles on the first read and
191
+ // is refused; both in EITHER budget state. The deleted `budgetInForce`/`couldBeEcho`/`ambiguous`
192
+ // machinery managed an ambiguity the response never had. Named mutant "COLDECHOKNOWN": stop polling once the dump reports any `limit.output` — the echo ends the wait on the first read again. Council #235 r4 (A2/C4): the bound is the WHOLE wait, not the number of reads. The condition is checked BEFORE the sleep+read that follows it, so an unclamped pair used to carry the wait to `waitMs + pollMs + readTimeoutMs` (7.5 s on the shipped 5000/500/2000; measured 7,361 ms against an endpoint answering in 1.9 s, which killed a leg under an `AMICUS_NO_OUTPUT_BACKSTOP_MS=6000` the docs present as safely above the wait). No read now starts without a poll interval left, and each in-loop read gets only the remaining budget. Named mutant "UNCLAMPEDPOLL": restore the plain `now() - start < waitMs` condition with an unclamped in-loop read.
193
+ const readTimeout = positiveCount(opts.readTimeoutMs) || DECLARATION_READ_TIMEOUT_MS;
194
+ const start = now();
195
+ let d = await readDeclarationOnce(client, providerID, modelID, { signal, readTimeoutMs: opts.readTimeoutMs });
196
+ while (!d.unreadable && !d.known && !(signal && signal.aborted) && waitMs - (now() - start) > pollMs) {
197
+ await sleep(pollMs);
198
+ const left = waitMs - (now() - start);
199
+ if (left < 1) { break; } // < 1, not <= 0: positiveCount FLOORS a sub-millisecond remainder to null, which would hand the read the full 2 s default back
200
+ const next = await readDeclarationOnce(client, providerID, modelID, { signal, readTimeoutMs: Math.min(readTimeout, left) });
201
+ if (next.unreadable && left < readTimeout) { break; } // council #235 r4 wave 5 repair: a read the WAIT'S OWN remainder truncated is not evidence the endpoint could not be read, and `unreadable` is what formatUnverifiedVariantNote turns into "could not be read (...; one read, no wait)". Measured: with the clamp above and no suppression here, an endpoint answering in 285 ms — the top of M12's warm range — ended 7 good reads and a full 5 s wait reported unreadable. The cold-catalogue outcome (`waitedMs`, no `unreadable`) is the true one; only a read that got its FULL deadline and still failed is a read failure. Named mutants "BUDGETABORTUNREADABLE" (drop this line) and "SUPPRESSALLINLOOP" (drop the `left < readTimeout` conjunct).
202
+ d = next;
203
+ }
204
+ // #218 PR 4 (found by probe row M20 in Task 2): /config/providers ECHOES the
205
+ // descriptor amicus wrote -- a budget-derived limit.output 24000 reads back as
206
+ // 24000 (M3's dump-after) -- so with a budget in force the dump cannot tell
207
+ // the model's ceiling. The fit judges against the ceiling amicus clamped that
208
+ // descriptor to (its own catalog's maxOutputTokens, the same number
209
+ // config.js :: buildProviderModels used) and against the dump's value only
210
+ // when the catalog has none: then the descriptor was bare and the dump is the
211
+ // engine's own ceiling (K5/K12). Named mutant "ECHOEDCEILING"
212
+ // (tests/utils/engine-variants.test.js): `ceiling: d.limitOutput` unconditionally.
213
+ // council #235 r2 (B1): `ceilingFrom` says whose number this is. 'engine' = a bare descriptor,
214
+ // so the dump IS the engine's ceiling (K5/K12) and the remedy below is provable. 'catalog' =
215
+ // the dump echoes what amicus wrote (M3), so the engine's real ceiling may be HIGHER and
216
+ // raising the budget to it can land inside the unrefused window. Named mutant "CEILINGPROVENANCE".
217
+ return { ...d, ceiling: catalogCeiling !== null ? catalogCeiling : d.limitOutput, ceilingFrom: catalogCeiling !== null ? 'catalog' : 'engine', waitedMs: now() - start };
218
+ }
219
+
220
+ /**
221
+ * Pure: send, refuse, or send unverified — in that order of tests.
222
+ * 1. unknown model -> {ok: true, verified: false} (mutant "UNKNOWNREFUSED");
223
+ * 2. known, undeclared -> VARIANT_UNDECLARED in one of TWO shapes (own properties only — mutant
224
+ * "PROTOLOOKUP"): the declared set named when the row lists one, else the empty-set reason,
225
+ * which says whose row it is and that a budget cannot change the verdict (mutants
226
+ * "EMPTYSETSILENT", "MESSAGEOVERCLAIMS");
227
+ * 3. declared, entry `thinking: {type: 'enabled', budgetTokens: N}`, a positive budget B, and either a ceiling C above it or NO ceiling at all -> VARIANT_OVER_BUDGET reserving min(B + N, C), or the unclamped B + N when nothing declares a C (council #235 r4 C7, mutant "NULLCEILINGSENDS"); mutants "ALWAYSREFUSE": drop `B < C`; "FITWITHOUTBUDGET": default a null budget to 32000; "ANTHROPICONLY": key on the provider id instead of the shape.
228
+ * The remedy has THREE shapes. With a C, keyed on `declaration.ceilingFrom` (council #235 r2 B1): an ENGINE-sourced ceiling gets "raise to at least C (the sum is then clamped, K4)", which the dump proves; a CATALOG-sourced one names C as amicus's own number and says to run `amicus models --refresh` first, because the engine's real ceiling may be higher and a budget in that gap is never re-checked (mutant "REMEDYALWAYSCATALOG"). With NO C it names the `limit` to declare and the only value that fits, B - N (M17), because declaring C = B silences the fit instead of shrinking the reservation;
229
+ * 4. otherwise {ok: true, verified: true, entry}.
230
+ * @param {{variant: string, model: string, declaration: object, outputBudget?: number|null}} a
231
+ * @returns {{ok: true, verified: boolean, entry?: object} | {ok: false, code: string, reason: string}}
232
+ */
233
+ function checkVariant({ variant, model, declaration, outputBudget }) {
234
+ if (!declaration || !declaration.known) { return { ok: true, verified: false }; }
235
+ const variants = (declaration.variants && typeof declaration.variants === 'object') ? declaration.variants : {};
236
+ if (!Object.prototype.hasOwnProperty.call(variants, variant)) {
237
+ const names = Object.keys(variants);
238
+ // council #235 r3 (C1/B1): the EMPTY set is its own answer and gets its own reason. The
239
+ // row reached this branch because `known` says the engine's own catalogue supplied it
240
+ // (engineSourced, M23), so "declares no variants" is an observation, not an unfinished
241
+ // read — and it says so without claiming WHICH cell carried the evidence, because the
242
+ // predicate is an OR (10 of 50 openai rows have `name === id`; 26 of 361 openrouter
243
+ // `:free` rows price at zero, and both are engine-sourced through other cells). Named
244
+ // mutants "EMPTYSETSILENT" (fall back to the listed wording) and "MESSAGEOVERCLAIMS"
245
+ // (assert the row carries its name, family, release date AND prices). Council #235 r3 wave 4 repair, three more ways one string misleads: the enumeration also names the DISPLAY NAME, since `name !== modelID` is a disjunct too ("NAMECELLUNNAMED"); the reason echoes the level the user typed, which docs/troubleshooting.md promises and a fanout needs ("MESSAGEDROPSLEVEL"); and the mirror hint says "sometimes", not "often" — measured during the wave-4 review on a live dump, 2 of 196 variant-less engine rows had a mirror that declares levels, and gpt-4o's own (openrouter/openai/gpt-4o-2024-08-06) does not ("MIRROROFTEN"). Council #235 r4 (C3), the message only: `/config/providers` serves the MERGED config-and-catalogue view, so metadata a user declares in their own opencode.json (name, release_date, cost, reasoning) satisfies `engineSourced` without the engine's catalogue knowing the model — the verdict is still honest there (a merged view declaring no variants makes the send a no-op either way), so the sentence stops asserting that the engine's own catalogue is the source and says the row reads as a declaration in that merged view. The second `config.get()` read that would subtract config-set cells is FILED, not built (BACKLOG, #218 PR 4). Named mutant "CATALOGUEPROVENANCE": restore "carries cells only the engine's own catalogue fills".
246
+ if (names.length === 0) {
247
+ return { ok: false, code: 'VARIANT_UNDECLARED', reason: `VARIANT_UNDECLARED: ${model} declares no variants at all, so '${variant}' is not among them — the row the engine returned for it (/config/providers, the engine's merged view of its own catalogue and your opencode config) carries catalogue-style metadata (a display name, family, release date, pricing or capabilities), so it reads as a declaration and not an unfinished read; an undeclared variant is a silent no-op on the wire (probe F3/M7), so nothing was sent. Omit --thinking to run at the provider's own default effort, or pick a route whose row declares levels (a gateway mirror of the same model sometimes does). Setting an outputBudget does not change this verdict (council #235 r3, C1/B1); on a first engine start the bundled catalogue can declare a smaller set than the live one, so the same level can be accepted on the next run` };
248
+ }
249
+ // council #235 r2 (B4): the names come from the engine's remote models.dev refresh, so
250
+ // they are defanged before they enter a message that reaches a log and a terminal.
251
+ // `model` is NOT defanged and must not be: it is amicus's own resolved config id, not
252
+ // engine-sourced text.
253
+ const listed = defang(names.join(', '));
254
+ return { ok: false, code: 'VARIANT_UNDECLARED', reason: `VARIANT_UNDECLARED: ${model} does not declare a '${variant}' variant — the engine's catalogue lists ${listed} for it (/config/providers); an undeclared variant is a silent no-op on the wire (probe F3/M7), so nothing was sent. Pick one of the listed levels, or omit --thinking to run at the provider's own default effort` };
255
+ }
256
+ const entry = variants[variant];
257
+ const thinking = (entry && entry.thinking && typeof entry.thinking === 'object') ? entry.thinking : null;
258
+ const budgetTokens = (thinking && thinking.type === 'enabled') ? positiveCount(thinking.budgetTokens) : null;
259
+ const budget = positiveCount(outputBudget);
260
+ const ceiling = declaration.ceiling;
261
+ if (budgetTokens !== null && budget !== null && (ceiling === null || budget < ceiling)) {
262
+ const sum = budget + budgetTokens;
263
+ const reservation = ceiling === null ? sum : Math.min(sum, ceiling);
264
+ const how = ceiling === null ? `${budget} + ${budgetTokens}, with no ceiling declared anywhere to clamp it` : (sum > ceiling ? `${budget} + ${budgetTokens}, clamped to the model's ${ceiling} ceiling` : `${budget} + ${budgetTokens}`);
265
+ // council #235 r2 (B1): the remedy must not walk the user into the one unrefused window.
266
+ // With a budget in force the dump ECHOES the descriptor amicus wrote (M3), so `ceiling` is
267
+ // amicus's own catalog number; if the engine's real ceiling is higher, a budget raised to
268
+ // exactly this number sits in [C_catalog, C_engine) — the fit falls silent there and the leg
269
+ // still reserves up to C_catalog + N on the wire. Named mutant "REMEDYALWAYSCATALOG".
270
+ // council #235 r4 (C7): a NULL ceiling is "no clamp anyone declared", not "no risk" — a row that declares the level with the `enabled + budgetTokens` shape and carries no `limit.output` anywhere (a model declared in the user's own opencode.json with a variants block and no limit, which amicus's catalog has no row for either) used to skip the fit entirely and send verified, while `budget + N` with N >= 1 always exceeds the budget. Its remedy must name the ceiling that is MISSING, never tell the user to raise the budget to a number nothing declares. Named mutant "NULLCEILINGSENDS": restore the `ceiling !== null` conjunct above. Wave 5 repair, that remedy's own two false notes: a declared `limit.output` is a DESCRIPTOR, not a clamp on the sum (K9 measured `limit.output 40000` + `max` reserving 63999; K3/K4/K10 record the sum clamped at the model's REAL ceiling "regardless of what the descriptor or the flag said"), and the obvious value to declare — the budget itself — makes `budget < ceiling` false, so the fit falls SILENT and the same over-budget leg sends verified (the r2 B1 window again). The remedy names the fitting value instead, `budget - N` (the exact fit, M17). Named mutants "DECLAREDLIMITCLAMPS" and "REMEDYWITHOUTFIT".
271
+ const raise = ceiling !== null ? (declaration.ceilingFrom === 'catalog' ? `Raise outputBudget to at least ${ceiling} — the ceiling amicus's own catalog carries for this model, which is what the fit can read once a budget is set (M3); if the engine's real ceiling is higher, a budget in that gap is not re-checked, so prefer \`amicus models --refresh\` first` : `Raise outputBudget to at least ${ceiling} (the sum is then clamped to the ceiling, K4)`) : `Declare a \`limit\` for this model in your opencode config — its \`output\` is the ceiling this fit judges against and has to leave room for the ${budgetTokens} the engine adds ON TOP (${budget > budgetTokens ? `at most ${budget - budgetTokens}, the value that lands the sum exactly on the budget — the exact fit, M17` : `no value fits — the engine adds ${budgetTokens} on top of whatever it reserves, more than this whole budget`}); a value at or above ${budget} silences this fit while the leg still reserves ${budgetTokens} over the budget, and declaring one does not clamp the sum — only the model's real ceiling does that (K3/K9/K10), and nothing declares one here — or clear outputBudget, which leaves the leg the engine's own default reservation with the ${budgetTokens} tokens added to it`;
272
+ return { ok: false, code: 'VARIANT_OVER_BUDGET', reason: `VARIANT_OVER_BUDGET: the '${variant}' variant on ${model} carries a ${budgetTokens}-token thinking budget that the engine adds ON TOP of the reservation on this route (probe M2: 24000 + 16000 = 40000; K2), so with outputBudget ${budget} this leg would reserve ${reservation} (${how}) — ${reservation - budget} over the budget; nothing was sent. ${raise}, route the model through OpenRouter (a variant leaves the reservation at the budget there — M1: 8000 stayed 8000 under 'low'; M9: 32000 with 'high' on both of the engine's catalogues), or use an adaptive-thinking model such as claude-sonnet-5 (M10b)` };
273
+ }
274
+ return { ok: true, verified: true, entry };
275
+ }
276
+
277
+ /**
278
+ * The log line for a variant sent to a model the catalogue did not know in time.
279
+ * TWO shapes with TWO tails (council #235 r5, J4/A1/B2): they used to share one, and the shared
280
+ * tail said the level applies once the engine LEARNS the model — true of a cold catalogue, false
281
+ * of a read that failed, where nothing was learned or unlearned. Both tails now also say that the
282
+ * over-budget fit did not run for this leg, because `known === false` entails an empty variants
283
+ * map (it is one of the disjuncts of `known`), so the fit had no budgetTokens to read and CANNOT
284
+ * be made to run here -- the refusal the seats asked for is refused; naming what did not run is
285
+ * the whole remedy for the first-run-versus-later-run inconsistency. Named mutant "NOTEHIDESFIT".
286
+ * @param {{model: string, variant: string, waitedMs: number, unreadable?: string|null}} a
287
+ * @returns {string}
288
+ */
289
+ function formatUnverifiedVariantNote({ model, variant, waitedMs, unreadable }) {
290
+ if (unreadable) {
291
+ // council #235 r2 (B4): `unreadable` carries an engine/transport error message -- defanged before it reaches a log line and a terminal. `model` stays raw: it is amicus's own id.
292
+ return `the engine's /config/providers could not be read (${defang(unreadable)}; one read, no wait), so '${variant}' was sent unverified: NEITHER check ran for this leg — not the declared-set check and not the over-budget fit — so the level applies if and only if ${model} declares it, and on the direct-Anthropic additive shape (a variant entry whose thinking type is 'enabled' with a budgetTokens N) the engine adds that N on top of the output budget (probe M2: 24000 + 16000 = 40000; K2) with nothing here to catch it.`;
293
+ }
294
+ // council #235 r3 (C1/B1): two shapes, not three. The `ambiguous` branch named a state the dump never had -- a row carrying only amicus's descriptor is positively identified now.
295
+ return `the engine's catalogue did not know ${model} within ${waitedMs} ms (its /config/providers entry carries nothing but the descriptor amicus registered, or the model is absent from the dump), so '${variant}' was sent unverified: it applies only if the engine learns the model before it builds the request (its startup models.dev refresh — probe M12 saw qwen3.8-max-0902 known on the first poll of a warm engine, 36 ms on one run, and unknown at the first read of a cold one, M0) and is a silent no-op otherwise (M7). The over-budget fit did not run for this leg either, so once the engine's catalogue knows the model the same command can refuse with VARIANT_OVER_BUDGET.`;
296
+ }
297
+
298
+ module.exports = { VARIANT_LEVELS, VariantRefusedError, readModelDeclaration, checkVariant, formatUnverifiedVariantNote };