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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +242 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +5 -4
- package/docs/architecture-map.md +732 -0
- package/docs/configuration.md +148 -26
- package/docs/council.md +9 -0
- package/docs/doc-system.md +12 -9
- package/docs/testing.md +2 -1
- package/docs/troubleshooting.md +76 -0
- package/docs/usage.md +11 -6
- package/package.json +1 -1
- package/schemas/model-catalog.schema.json +2 -1
- package/schemas/run.schema.json +13 -0
- package/skills/sidecar/SKILL.md +1 -8
- package/src/cli-handlers-doctor.js +3 -0
- package/src/cli-handlers-fanout.js +10 -1
- package/src/cli-handlers-resume-continue.js +25 -0
- package/src/cli.js +5 -8
- package/src/council/briefings-chair.js +4 -2
- package/src/council/run-assemble.js +7 -2
- package/src/council/run-retry-notes.js +21 -1
- package/src/council/run-stages.js +8 -1
- package/src/headless.js +125 -7
- package/src/mcp-server.js +26 -0
- package/src/mcp-tools.js +4 -4
- package/src/opencode-client.js +84 -8
- package/src/pack/pack-validate.js +3 -0
- package/src/session-manager.js +2 -2
- package/src/sidecar/continue.js +6 -1
- package/src/sidecar/conversation-mirror.js +35 -11
- package/src/sidecar/fanout-leg-fallback.js +1 -0
- package/src/sidecar/fanout-leg.js +10 -2
- package/src/sidecar/fanout.js +2 -2
- package/src/sidecar/interactive.js +31 -4
- package/src/sidecar/models-ceiling-line.js +72 -0
- package/src/sidecar/models.js +4 -2
- package/src/sidecar/reopen-notices.js +97 -0
- package/src/sidecar/reopen-spend.js +3 -2
- package/src/sidecar/resume.js +15 -2
- package/src/sidecar/session-finalize.js +4 -1
- package/src/sidecar/session-utils.js +5 -1
- package/src/sidecar/start-metadata.js +1 -1
- package/src/sidecar/start.js +10 -5
- package/src/utils/config.js +33 -12
- package/src/utils/curated-models.js +8 -8
- package/src/utils/degrade.js +7 -0
- package/src/utils/doctor-output-budget-check.js +198 -0
- package/src/utils/engine-output-flag.js +105 -0
- package/src/utils/engine-variants.js +298 -0
- package/src/utils/http-get.js +284 -0
- package/src/utils/model-catalog.js +36 -4
- package/src/utils/model-ceilings-modelsdev.js +230 -0
- package/src/utils/model-fetcher.js +12 -36
- package/src/utils/model-output-limit.js +21 -13
- package/src/utils/output-length.js +90 -0
- package/src/utils/result-schema.js +7 -2
- package/src/utils/spend-ledger.js +5 -1
- package/src/utils/thinking-validators.js +27 -80
- package/src/utils/validators.js +2 -3
package/src/sidecar/start.js
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
package/src/utils/config.js
CHANGED
|
@@ -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 `{}`
|
|
619
|
-
* pre-#218 behaviour exactly. Set it and
|
|
620
|
-
* model's real ceiling)
|
|
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
|
-
* ⚠️
|
|
623
|
-
* `
|
|
624
|
-
*
|
|
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)
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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' } },
|
package/src/utils/degrade.js
CHANGED
|
@@ -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
|
+
};
|