amicus 4.9.3 → 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 (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +242 -0
  3. package/README.md +1 -1
  4. package/docs/ROADMAP.md +5 -4
  5. package/docs/architecture-map.md +732 -0
  6. package/docs/configuration.md +148 -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 +76 -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/skills/sidecar/SKILL.md +1 -8
  16. package/src/cli-handlers-doctor.js +3 -0
  17. package/src/cli-handlers-fanout.js +10 -1
  18. package/src/cli-handlers-resume-continue.js +25 -0
  19. package/src/cli.js +5 -8
  20. package/src/council/briefings-chair.js +4 -2
  21. package/src/council/run-assemble.js +7 -2
  22. package/src/council/run-retry-notes.js +21 -1
  23. package/src/council/run-stages.js +8 -1
  24. package/src/headless.js +125 -7
  25. package/src/mcp-server.js +26 -0
  26. package/src/mcp-tools.js +4 -4
  27. package/src/opencode-client.js +84 -8
  28. package/src/pack/pack-validate.js +3 -0
  29. package/src/session-manager.js +2 -2
  30. package/src/sidecar/continue.js +6 -1
  31. package/src/sidecar/conversation-mirror.js +35 -11
  32. package/src/sidecar/fanout-leg-fallback.js +1 -0
  33. package/src/sidecar/fanout-leg.js +10 -2
  34. package/src/sidecar/fanout.js +2 -2
  35. package/src/sidecar/interactive.js +31 -4
  36. package/src/sidecar/models-ceiling-line.js +72 -0
  37. package/src/sidecar/models.js +4 -2
  38. package/src/sidecar/reopen-notices.js +97 -0
  39. package/src/sidecar/reopen-spend.js +3 -2
  40. package/src/sidecar/resume.js +15 -2
  41. package/src/sidecar/session-finalize.js +4 -1
  42. package/src/sidecar/session-utils.js +5 -1
  43. package/src/sidecar/start-metadata.js +1 -1
  44. package/src/sidecar/start.js +10 -5
  45. package/src/utils/config.js +33 -12
  46. package/src/utils/curated-models.js +8 -8
  47. package/src/utils/degrade.js +7 -0
  48. package/src/utils/doctor-output-budget-check.js +198 -0
  49. package/src/utils/engine-output-flag.js +105 -0
  50. package/src/utils/engine-variants.js +298 -0
  51. package/src/utils/http-get.js +284 -0
  52. package/src/utils/model-catalog.js +36 -4
  53. package/src/utils/model-ceilings-modelsdev.js +230 -0
  54. package/src/utils/model-fetcher.js +12 -36
  55. package/src/utils/model-output-limit.js +21 -13
  56. package/src/utils/output-length.js +90 -0
  57. package/src/utils/result-schema.js +7 -2
  58. package/src/utils/spend-ledger.js +5 -1
  59. package/src/utils/thinking-validators.js +27 -80
  60. package/src/utils/validators.js +2 -3
@@ -125,7 +125,7 @@ async function startSidecar(options) {
125
125
  mcp, mcpConfig, clientType: client, noMcp, excludeMcp, projectDir: effectiveProject
126
126
  });
127
127
  const taskId = options.taskId || generateTaskId();
128
- const reasoning = thinking ? { effort: thinking } : undefined;
128
+ const variant = thinking || undefined; // #218 PR 4: the level itself is the engine's `variant` field (named mutant "STARTVARIANTDROPPED", tests/sidecar/start.test.js: drop `variant` from the two options objects below)
129
129
  // 15b.3: one nonce per run, generated BEFORE prompt construction so the
130
130
  // SAME value can be baked into the prompt's instruction (buildPrompts) and
131
131
  // handed to the detector (runHeadless.options.nonce / the GUI fold writer
@@ -158,7 +158,7 @@ async function startSidecar(options) {
158
158
  result = await runHeadless(
159
159
  model, systemPrompt, userMessage, taskId, effectiveProject,
160
160
  timeout * 60 * 1000, agent || 'build',
161
- { mcp: mcpServers, summaryLength, reasoning, port: opencodePort, nonce: foldNonce }
161
+ { mcp: mcpServers, summaryLength, variant, port: opencodePort, nonce: foldNonce }
162
162
  );
163
163
  } catch (err) {
164
164
  if (!json) { throw err; }
@@ -174,7 +174,7 @@ async function startSidecar(options) {
174
174
  logger.info('Launching interactive sidecar', { taskId, model, agent: effectiveAgent });
175
175
  result = await runInteractive(
176
176
  model, systemPrompt, userMessage, taskId, effectiveProject,
177
- { agent, mcp: mcpServers, reasoning, client, windowPosition: position, foldNonce }
177
+ { agent, mcp: mcpServers, variant, client, windowPosition: position, foldNonce }
178
178
  );
179
179
  summary = result.summary || '';
180
180
  if (result.error) { logger.error('Interactive task error', { taskId, error: result.error }); }
@@ -200,12 +200,15 @@ async function startSidecar(options) {
200
200
  if (terminal.status === 'error') {
201
201
  meta.status = 'error';
202
202
  meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
203
+ if (result && typeof result.finish === 'string') { meta.finish = result.finish; } // #218 PR 3: emit-when-set; a fresh session's metadata has no prior finish to remove (resume's does -- resume.js)
204
+ if (result && typeof result.variant === 'string') { meta.variant = result.variant; } // #218 PR 4: emit-when-set, like finish (named mutant "SOLOERRORNOVARIANT", tests/start-terminal-status.test.js)
205
+ if (result && result.variantUnverified === true) { meta.variantUnverified = true; }
203
206
  meta.completedAt = new Date().toISOString();
204
207
  writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
205
208
  logger.error('Session completed with error', { taskId, error: meta.reason });
206
209
  } else {
207
210
  // complete / timed-out / aborted: persist the (possibly partial) summary with the correct status.
208
- finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json, status: terminal.status });
211
+ finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json, status: terminal.status, finish: result && result.finish, variant: result && result.variant, variantUnverified: result && result.variantUnverified });
209
212
  }
210
213
 
211
214
  const { resolveUsage } = require('../utils/pricing');
@@ -223,6 +226,8 @@ async function startSidecar(options) {
223
226
  appendSpend({
224
227
  taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive', usage: runUsage,
225
228
  op: 'start', status: statusFromResult(result), project: effectiveProject,
229
+ finish: result && result.finish, // #218 PR 3: appendSpend keeps it only when it is a string
230
+ variant: result && result.variant, // #218 PR 4: same emit-when-set rule (named mutant "SOLOROWNOVARIANT", tests/start-json.test.js)
226
231
  // ⚠️ DE-ROT: `metadata` is NOT in scope at startSidecar's finalize site — the objects
227
232
  // there are `meta` (createSessionMetadata result) and `m`; `metadata` is a local only
228
233
  // inside createSessionMetadata. Reading `metadata.gateway` throws a ReferenceError the
@@ -232,7 +237,7 @@ async function startSidecar(options) {
232
237
  // (To also attribute v4.2 'local': thread the resolved route gateway — dropped today at
233
238
  // cli-handlers-run.js:47 — into createSessionMetadata and read `meta.gateway`, as continue.js:111 does.)
234
239
  // v4.7 F8 D16: same in-scope-value rule as gateway above — `m` is the
235
- // just-re-read metadata (line 214), which carries `tag` when
240
+ // just-re-read metadata (line 215), which carries `tag` when
236
241
  // createSessionMetadata stored one (absent otherwise); `|| null` folds
237
242
  // that into spend-ledger.js's null-not-absent dim convention.
238
243
  tag: m.tag || null,
@@ -340,8 +340,13 @@ const LOCAL_REQUEST_TIMEOUT_MS = 300000;
340
340
  * serve (fixed in v4.1.2). Aliases the user has overridden fall back to the
341
341
  * prefix form, since no authored gateway route describes them.
342
342
  * @param {string[]} [resolvedRoutes] executable model id(s) actually launched
343
+ * @param {number|null} [outputBudget] the budget to clamp with. `undefined` (every
344
+ * caller but startServer) reads config here; `null` means unset. #218 PR 3:
345
+ * opencode-client.js :: startServer reads config ONCE and hands the same value
346
+ * to this descriptor and to the engine flag, so a config write between two
347
+ * reads can no longer split the levers. Named mutant "PARAMIGNORED".
343
348
  * @returns {object} e.g. { openrouter: { models: { "x-ai/grok-4.3": {}, ... } } } */
344
- function buildProviderModels(resolvedRoutes = []) {
349
+ function buildProviderModels(resolvedRoutes = [], outputBudget) {
345
350
  const aliases = getEffectiveAliases();
346
351
  const providers = {};
347
352
 
@@ -353,7 +358,7 @@ function buildProviderModels(resolvedRoutes = []) {
353
358
  // ConfigInvalidError that poisons the whole config, not a per-model degrade.
354
359
  const { normalizeOutputBudget, buildLimitLookup, computeModelLimit } =
355
360
  require('./model-output-limit');
356
- const budget = normalizeOutputBudget(getOutputBudget());
361
+ const budget = normalizeOutputBudget(outputBudget === undefined ? getOutputBudget() : outputBudget);
357
362
  let limits = new Map();
358
363
  if (budget !== null) {
359
364
  try {
@@ -382,6 +387,20 @@ function buildProviderModels(resolvedRoutes = []) {
382
387
  if (!Object.prototype.hasOwnProperty.call(providers, providerID)) {
383
388
  providers[providerID] = { models: {} };
384
389
  }
390
+ // #218 PR 2: direct `anthropic/*` is no longer held out (council #230 A1
391
+ // held it out until descriptor x thinking-budget was measured). Measured,
392
+ // probe rows K1/K2/K3/K4/K9/K10: the descriptor lowers the reservation on
393
+ // that route exactly as on OpenRouter (K1: 8000); a thinking variant's
394
+ // budget is ADDED on top of it (K2: 8000 + 16000 = 24000); and the sum is
395
+ // clamped to the model's real ceiling whatever the descriptor or flag said
396
+ // (K3/K4/K10: 64000 for haiku). No descriptor can push a thinking leg over
397
+ // the ceiling. #218 PR 4 now sends `--thinking` to the engine as its `variant`
398
+ // field, validated against the model's own declaration before any request,
399
+ // so these are the numbers a thinking leg now reserves: M2 is K2's
400
+ // arithmetic at the shipped budget (24000 + 16000 = 40000, refused rather
401
+ // than sent), and M17 is the fit (8000 + 16000 = 24000) that would need the
402
+ // variant's budget before the spawn.
403
+ // Named mutant "ANTHROPICHELDOUT" in tests/build-provider-models-output-limit.test.js.
385
404
  // #218: `{}` unless a budget is set AND the catalog knows both numbers.
386
405
  const limit = computeModelLimit(limits.get(fullModel), budget);
387
406
  providers[providerID].models[modelID] = limit ? { limit } : {};
@@ -615,17 +634,19 @@ function resolveCouncilMembers(name, catalog = []) {
615
634
  /**
616
635
  * #218: the configured per-leg output budget, or null when unset.
617
636
  *
618
- * OPT-IN BY DESIGN — unset means "register every model as `{}`", which is
619
- * pre-#218 behaviour exactly. Set it and each leg reserves min(budget, the
620
- * model's real ceiling) instead of opencode's fixed 32000 default.
637
+ * OPT-IN BY DESIGN — unset means "register every model as `{}` and set no
638
+ * engine flag", which is pre-#218 behaviour exactly. Set it and every leg
639
+ * reserves min(budget, the model's real ceiling) wherever a ceiling is known.
640
+ * This one value feeds BOTH levers so they can never disagree: the per-model
641
+ * `limit` descriptor (buildProviderModels, for routes the amicus catalog knows)
642
+ * and OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX (opencode-client.js :: startServer,
643
+ * set to the budget for every engine amicus starts and clamped by the engine's
644
+ * own catalog). Values above 32000 are LIVE since PR 2 (probe K6: 100000 on the
645
+ * wire); a model neither catalog knows receives the budget as-is (J2/K13).
621
646
  *
622
- * ⚠️ Values >= 32000 are ACCEPTED but INERT: opencode computes
623
- * `Math.min(limit.output, 32000)` (measured in the 1.18.15 binary), so the
624
- * reservation can only be lowered here, never raised. Raising it at all
625
- * requires OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX, a different lever.
626
- *
627
- * ⚠️ Requires a catalog refreshed since #218 added `maxOutputTokens`
628
- * (`amicus models --refresh`); older rows have no ceiling and stay `{}`.
647
+ * ⚠️ The descriptor half needs a catalog refreshed since #218 added
648
+ * `maxOutputTokens` (`amicus models --refresh`); the flag half needs nothing.
649
+ * `amicus doctor`'s `output-budget` row says which routes get which.
629
650
  *
630
651
  * @returns {number|null} positive integer, or null when unset/malformed
631
652
  */
@@ -98,14 +98,14 @@ const CARDLESS = [
98
98
  // (live smoke wave 47278069) — the entry was OpenRouter-only at authoring.
99
99
  { alias: 'fable', routes: { openrouter: 'openrouter/anthropic/claude-fable-5',
100
100
  anthropic: 'anthropic/claude-fable-5' } },
101
- // qwen/kimi refreshed 2026-08-26 (v4.9 W13): both were a model generation
102
- // behind. These are the FALLBACK FLOOR a caller or fork with no CI alias map
103
- // resolves its whole bench through this table (see `inkling` below), while the
104
- // owner's machine and .github/amicus-ci-aliases.json already ran the newer ids.
105
- // Cardless entries have no `idPattern`, so `models --check` can only ask
106
- // whether the OLD id still EXISTS which is how a pin sits a generation back
107
- // with every gate green (scripts/check-ci-alias-pins.js asks the other one).
108
- { alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.8-max' } },
101
+ // qwen/kimi refreshed 2026-08-26 (v4.9 W13); qwen again 2026-09-05, when
102
+ // OpenRouter and models.dev dropped the un-dated `qwen3.8-max` for the dated
103
+ // `qwen3.8-max-0902` (the #218 PR 2 probe caught it: F4 went silent). These are
104
+ // the FALLBACK FLOOR a fork with no CI alias map resolves its bench here; the
105
+ // owner's machine and .github/amicus-ci-aliases.json (qwen3.8-27b) run newer ids.
106
+ // Cardless entries have no `idPattern`, so `models --check` only asks whether the
107
+ // OLD id still EXISTS (scripts/check-ci-alias-pins.js asks the other question).
108
+ { alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.8-max-0902' } },
109
109
  { alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
110
110
  { alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
111
111
  { alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-medium-3-5' } },
@@ -17,6 +17,13 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
17
17
  'dropped-members', 'chair-skipped-cost-ceiling', 'chair-failed',
18
18
  'thin-cross-review', 'debate-degraded', 'inexact-under-ceiling',
19
19
  'stage1-retry',
20
+ // #218 PR 3: a Stage-1 review the provider cut at the max_tokens reservation
21
+ // (the leg's `finish` is 'length' and it still carried answer text). kind
22
+ // 'info' only -- the review is in the packet, nothing was lost, the exit code
23
+ // does not move; the chair just reads a review that ends where the
24
+ // reservation ended. A cut with NO answer text is a dead leg (leg.error
25
+ // starts `OUTPUT_LENGTH:`) and rides `dead-leg` like every other death.
26
+ 'output-truncated',
20
27
  // v4.9 task mode: a task run writes no reliability-ledger rows — announced as kind:'info'.
21
28
  'ledger-skipped',
22
29
  // v4.8: the seat<->leg join failed. THREE shapes, one channel: a launched seat whose wave
@@ -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
+ };