amicus 4.9.2 → 4.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +324 -0
  3. package/README.md +1 -1
  4. package/bin/amicus.js +6 -0
  5. package/docs/ROADMAP.md +5 -4
  6. package/docs/architecture-map.md +732 -0
  7. package/docs/configuration.md +175 -1
  8. package/docs/council.md +9 -0
  9. package/docs/doc-system.md +12 -9
  10. package/docs/testing.md +2 -1
  11. package/docs/troubleshooting.md +76 -0
  12. package/docs/usage.md +14 -6
  13. package/electron/main.js +25 -2
  14. package/electron/setup-ui-alias-groups.js +161 -0
  15. package/electron/setup-ui-alias-script.js +70 -4
  16. package/electron/setup-ui-aliases.js +25 -21
  17. package/electron/setup-ui.js +11 -1
  18. package/package.json +1 -1
  19. package/schemas/model-catalog.schema.json +2 -1
  20. package/schemas/run.schema.json +13 -0
  21. package/skills/sidecar/SKILL.md +1 -8
  22. package/src/cli-handlers-doctor.js +12 -16
  23. package/src/cli-handlers-fanout.js +10 -1
  24. package/src/cli-handlers-resume-continue.js +25 -0
  25. package/src/cli-handlers.js +17 -1
  26. package/src/cli.js +5 -8
  27. package/src/council/briefings-chair.js +4 -2
  28. package/src/council/run-assemble.js +7 -2
  29. package/src/council/run-retry-notes.js +21 -1
  30. package/src/council/run-stages.js +8 -1
  31. package/src/headless.js +125 -7
  32. package/src/mcp-server.js +26 -0
  33. package/src/mcp-tools.js +4 -4
  34. package/src/opencode-client.js +84 -8
  35. package/src/pack/pack-validate.js +3 -0
  36. package/src/session-manager.js +2 -2
  37. package/src/sidecar/continue.js +6 -1
  38. package/src/sidecar/conversation-mirror.js +35 -11
  39. package/src/sidecar/fanout-leg-fallback.js +1 -0
  40. package/src/sidecar/fanout-leg.js +10 -2
  41. package/src/sidecar/fanout.js +2 -2
  42. package/src/sidecar/interactive.js +31 -4
  43. package/src/sidecar/models-ceiling-line.js +72 -0
  44. package/src/sidecar/models.js +4 -2
  45. package/src/sidecar/reopen-notices.js +97 -0
  46. package/src/sidecar/reopen-spend.js +3 -2
  47. package/src/sidecar/resume.js +15 -2
  48. package/src/sidecar/session-finalize.js +4 -1
  49. package/src/sidecar/session-utils.js +5 -1
  50. package/src/sidecar/start-metadata.js +1 -1
  51. package/src/sidecar/start.js +10 -5
  52. package/src/utils/api-key-validation.js +183 -94
  53. package/src/utils/config.js +65 -2
  54. package/src/utils/curated-models.js +8 -8
  55. package/src/utils/degrade.js +7 -0
  56. package/src/utils/doctor-credit-check.js +61 -0
  57. package/src/utils/doctor-key-auth-check.js +271 -0
  58. package/src/utils/doctor-output-budget-check.js +198 -0
  59. package/src/utils/engine-output-flag.js +105 -0
  60. package/src/utils/engine-variants.js +298 -0
  61. package/src/utils/http-get.js +284 -0
  62. package/src/utils/live-probes.js +53 -0
  63. package/src/utils/model-catalog.js +36 -4
  64. package/src/utils/model-ceilings-modelsdev.js +230 -0
  65. package/src/utils/model-fetcher.js +14 -36
  66. package/src/utils/model-output-limit.js +132 -0
  67. package/src/utils/openrouter-credit.js +104 -0
  68. package/src/utils/output-length.js +90 -0
  69. package/src/utils/result-schema.js +7 -2
  70. package/src/utils/spend-ledger.js +5 -1
  71. package/src/utils/thinking-validators.js +27 -80
  72. package/src/utils/validators.js +2 -3
@@ -31,15 +31,123 @@ const VALIDATION_ENDPOINTS = {
31
31
  }
32
32
  };
33
33
 
34
- /** Validate an API key by making a test request to the provider's API */
34
+ /**
35
+ * A printable message from any throwable — Error, string, or object (#224).
36
+ *
37
+ * ⚠️ EVERY step is inside the try, including reading `.message`. `String(x)`
38
+ * THROWS for a null-prototype object ("Cannot convert object to primitive
39
+ * value") and for one whose toString throws, and a `message` getter can throw
40
+ * too. This runs synchronously inside the res.on('error') listener added for
41
+ * #224, so a throw here escapes the promise as an uncaught async exception —
42
+ * trading the crash that issue removed for a narrower one. Caught in the
43
+ * council review of PR 225; measured, not hypothesised.
44
+ *
45
+ * (A Symbol is NOT among the hazards: String(Symbol()) is well-defined. Only
46
+ * `'' + sym` throws, and this never does that.)
47
+ */
48
+ function messageOf(err) {
49
+ try {
50
+ if (err === null || err === undefined) { return ''; }
51
+ const m = err.message;
52
+ if (typeof m === 'string' && m.length > 0) { return m; }
53
+ return String(err);
54
+ } catch (_e) {
55
+ return '(unprintable error)';
56
+ }
57
+ }
58
+
59
+ const FORBIDDEN_MESSAGE =
60
+ 'Forbidden (403) — the request was rejected, but this may be a disabled API, '
61
+ + 'a quota, or a region/bot block rather than the key';
62
+
63
+ /**
64
+ * Strip every spelling of a secret out of a message before it can escape.
65
+ *
66
+ * The Google probe embeds the key in the URL as `?key=...`, so ANY error text
67
+ * that quotes the request URL quotes the key with it — Node's ERR_INVALID_URL
68
+ * and several socket errors do exactly that. Redacting here, at the source,
69
+ * is what protects the callers that never look: electron/ipc-setup.js returns
70
+ * `err.message` straight to the renderer AND logs it, and src/cli-handlers.js
71
+ * awaits this function with no try/catch at all. (Council finding 3, PR 221.)
72
+ *
73
+ * Two passes, in this order:
74
+ * 1. Mask `key=` / `api_key=` / `access_token=` query parameters
75
+ * structurally. This is what actually protects the Google probe, and it
76
+ * works for a key of ANY length.
77
+ * 2. Remove the key's own spellings — raw, percent-encoded, and the
78
+ * form-encoded `+`-for-space variant — but only when the key is at least
79
+ * 8 characters. Blind substring replacement of a 1-2 character key
80
+ * rewrote unrelated diagnostics (every "a" in a DNS error became ***)
81
+ * while protecting nothing real. Council review of PR 222.
82
+ */
83
+ function redactSecret(text, key) {
84
+ // Coerce, don't discard. This returned '' for anything that was not a
85
+ // string, so a thrown string or a non-Error object (`throw 'boom'`) turned
86
+ // a diagnostic into a blank message — the caller then reported a failure it
87
+ // could not describe. Issue #224.
88
+ // Same hazard as messageOf: this is exported and callers may hand it
89
+ // anything, so the coercion cannot be allowed to throw (council review of
90
+ // PR 225).
91
+ let raw;
92
+ if (typeof text === 'string') {
93
+ raw = text;
94
+ } else if (text === null || text === undefined) {
95
+ raw = '';
96
+ } else {
97
+ try { raw = String(text); } catch (_e) { raw = '(unprintable error)'; }
98
+ }
99
+ if (raw.length === 0) { return ''; }
100
+ if (!key) { return raw; }
101
+ const text_ = raw;
102
+
103
+ // 1. Mask the query parameter STRUCTURALLY, before touching the key at all.
104
+ // This is what actually protects the Google probe (`?key=...`) and it
105
+ // works for a key of any length, including ones too short to substring-
106
+ // match safely.
107
+ let out = text_.replace(/([?&](?:key|api_key|access_token)=)[^&\s"')]+/gi, '$1***');
108
+
109
+ // 2. Then remove the key itself — but only when it is long enough that a
110
+ // substring match means something. Blind replacement of a 1-2 character
111
+ // key rewrote unrelated text ("a" -> "***" across a whole DNS error),
112
+ // which corrupts diagnostics without protecting anything real. Council
113
+ // review of PR 222.
114
+ const MIN_SUBSTRING_LEN = 8;
115
+ if (key.length >= MIN_SUBSTRING_LEN) {
116
+ const spellings = new Set([key]);
117
+ try {
118
+ const encoded = encodeURIComponent(key);
119
+ spellings.add(encoded);
120
+ // application/x-www-form-urlencoded spells a space '+', not '%20'.
121
+ spellings.add(encoded.replace(/%20/g, '+'));
122
+ } catch (_e) { /* un-encodable key: the raw spelling below still runs */ }
123
+ for (const s of spellings) {
124
+ if (s) { out = out.split(s).join('***'); }
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * Validate an API key by making a test request to the provider's API.
132
+ *
133
+ * ALWAYS RESOLVES — never rejects, even on a synchronous throw from https.get
134
+ * (see redactSecret). Two of the three call sites have no catch.
135
+ *
136
+ * @returns {Promise<{valid: boolean, status: number|null, error?: string}>}
137
+ * `status` is the HTTP status, or null when no response was received. It is
138
+ * returned as DATA so callers never have to recover it by parsing `error`
139
+ * (council finding 2): that coupled control flow to message wording, and a
140
+ * reword that dropped the parentheses would have silently degraded detection
141
+ * to a permanent "unverified" with nothing failing to announce it.
142
+ */
35
143
  function validateApiKey(provider, key) {
36
144
  if (!key || key.trim().length === 0) {
37
- return Promise.resolve({ valid: false, error: 'API key is required' });
145
+ return Promise.resolve({ valid: false, status: null, error: 'API key is required' });
38
146
  }
39
147
 
40
148
  const endpoint = VALIDATION_ENDPOINTS[provider];
41
149
  if (!endpoint) {
42
- return Promise.resolve({ valid: false, error: `Unknown provider: ${provider}` });
150
+ return Promise.resolve({ valid: false, status: null, error: `Unknown provider: ${provider}` });
43
151
  }
44
152
 
45
153
  const trimmedKey = key.trim();
@@ -53,114 +161,95 @@ function validateApiKey(provider, key) {
53
161
  const headers = endpoint.authHeader(trimmedKey);
54
162
 
55
163
  return new Promise((resolve) => {
56
- const req = https.get(url, { headers }, (res) => {
57
- res.on('data', () => {});
58
- res.on('end', () => {
59
- // Anthropic returns 401 for invalid key, 400/405 for valid key with no body
60
- if (provider === 'anthropic') {
61
- if (res.statusCode === 401) {
62
- resolve({ valid: false, error: 'Invalid API key (401)' });
63
- } else if (res.statusCode >= 500 || res.statusCode === 429) {
64
- resolve({ valid: false, error: `Server error (${res.statusCode})` });
164
+ let req;
165
+ try {
166
+ req = https.get(url, { headers }, (res) => {
167
+ const code = res.statusCode;
168
+ // ⚠️ req.on('error') below covers the CONNECTION phase only. An error
169
+ // once `res` exists a socket reset mid-body, a TLS failure after
170
+ // headers is an 'error' event on this emitter, and an unhandled one
171
+ // is a THROW, not a rejection: the promise never settles and the
172
+ // process dies. Two of the three callers have no protection against
173
+ // that (src/cli-handlers.js has no try/catch; electron/ipc-setup.js
174
+ // has one, but the throw lands on a later tick outside it). Issue #224.
175
+ res.on('error', (err) => {
176
+ resolve({ valid: false, status: null, error: redactSecret(messageOf(err), trimmedKey) });
177
+ });
178
+ res.on('data', () => {});
179
+ res.on('end', () => {
180
+ // Anthropic: the probe is a GET against /v1/messages, so in practice
181
+ // a WORKING key answers with a method/shape complaint (400/404/405).
182
+ // 200 is allowed too — defensively, in case the endpoint ever answers
183
+ // one — which is why it appears in the allowlist below. The earlier
184
+ // wording said "never 200" while listing 200 as success; the comment
185
+ // and the code disagreed (council review of PR 222).
186
+ //
187
+ // ⚠️ This used to be `else { valid: true }` — every code that was not
188
+ // 401/429/5xx passed, INCLUDING 403. A region block or WAF therefore
189
+ // reported the key as good: a false GREEN, inside the very function
190
+ // rewritten to stop treating 403 as a verdict, and the exact failure
191
+ // class #210 exists to kill. Council review of PR 222 caught it.
192
+ // Success is an ALLOWLIST now; anything unlisted is not-valid.
193
+ if (provider === 'anthropic') {
194
+ if (code === 401) {
195
+ resolve({ valid: false, status: code, error: 'Invalid API key (401)' });
196
+ } else if (code === 403) {
197
+ resolve({ valid: false, status: code, error: FORBIDDEN_MESSAGE });
198
+ } else if (code >= 500 || code === 429) {
199
+ resolve({ valid: false, status: code, error: `Server error (${code})` });
200
+ } else if (code === 200 || code === 400 || code === 404 || code === 405) {
201
+ resolve({ valid: true, status: code });
202
+ } else {
203
+ resolve({ valid: false, status: code, error: `Unexpected response (${code})` });
204
+ }
205
+ return;
206
+ }
207
+
208
+ if (code === 200) {
209
+ resolve({ valid: true, status: code });
210
+ } else if (code === 401) {
211
+ resolve({ valid: false, status: code, error: 'Invalid API key (401)' });
212
+ } else if (code === 403) {
213
+ // NOT stated as a bad key (council finding 1). Google returns 403
214
+ // for "API not enabled" and for quota; a WAF returns it for bot
215
+ // protection. Calling that an invalid credential sends someone off
216
+ // to re-enter a key that works — the false-ALARM twin of the
217
+ // false-GREEN this endpoint exists to catch. Callers decide.
218
+ resolve({ valid: false, status: code, error: FORBIDDEN_MESSAGE });
65
219
  } else {
66
- resolve({ valid: true });
220
+ resolve({ valid: false, status: code, error: `Unexpected response (${code})` });
67
221
  }
68
- return;
69
- }
70
-
71
- if (res.statusCode === 200) {
72
- resolve({ valid: true });
73
- } else if (res.statusCode === 401 || res.statusCode === 403) {
74
- resolve({ valid: false, error: `Invalid API key (${res.statusCode})` });
75
- } else {
76
- resolve({ valid: false, error: `Unexpected response (${res.statusCode})` });
77
- }
222
+ });
78
223
  });
79
- });
224
+ } catch (err) {
225
+ // Synchronous throw (ERR_INVALID_URL and friends). Its message quotes the
226
+ // request URL — key included for Google. Redact, resolve, never reject.
227
+ resolve({ valid: false, status: null, error: redactSecret(messageOf(err), trimmedKey) });
228
+ return;
229
+ }
80
230
  req.setTimeout(10000, () => {
81
231
  req.destroy();
82
- resolve({ valid: false, error: 'Request timed out' });
232
+ resolve({ valid: false, status: null, error: 'Request timed out' });
83
233
  });
84
234
  req.on('error', (err) => {
85
- resolve({ valid: false, error: err.message });
235
+ resolve({ valid: false, status: null, error: redactSecret(messageOf(err), trimmedKey) });
86
236
  });
87
237
  });
88
238
  }
89
239
 
90
- /** Warning string for a zero-credit OpenRouter key (paid models will 402). */
91
- const OPENROUTER_NO_CREDIT_WARNING =
92
- 'OpenRouter key has no remaining credit — paid models will fail (402). ' +
93
- 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).';
94
-
95
- /** Warning string for a free-tier OpenRouter key. */
96
- const OPENROUTER_FREE_TIER_WARNING =
97
- 'OpenRouter key is free tier — only :free models will route; paid models will fail (402). ' +
98
- 'Add credit at openrouter.ai/credits to use paid models.';
99
-
100
- /**
101
- * Non-blocking credit/limit check for an OpenRouter key.
102
- *
103
- * Hits GET https://openrouter.ai/api/v1/key (returns limit, usage,
104
- * is_free_tier, limit_remaining) and produces a WARNING — never an error —
105
- * when is_free_tier is true or limit_remaining <= 0. Any failure (non-200,
106
- * network error, malformed body) resolves with warning:null so setup is
107
- * never blocked. Free-tier councils against free models are legitimate.
108
- *
109
- * @param {string} key OpenRouter API key
110
- * @returns {Promise<{warning: string|null, isFreeTier: boolean,
111
- * limitRemaining: number|null, limit: number|null, usage: number|null}>}
112
- */
113
- function checkOpenRouterCredit(key) {
114
- const none = {
115
- warning: null, isFreeTier: false, limitRemaining: null, limit: null, usage: null
116
- };
117
- if (!key || key.trim().length === 0) {
118
- return Promise.resolve(none);
119
- }
120
-
121
- const headers = { 'Authorization': `Bearer ${key.trim()}` };
122
-
123
- return new Promise((resolve) => {
124
- const req = https.get('https://openrouter.ai/api/v1/key', { headers }, (res) => {
125
- let body = '';
126
- res.on('data', (chunk) => { body += chunk; });
127
- res.on('end', () => {
128
- if (res.statusCode !== 200) { resolve(none); return; }
129
- let data;
130
- try {
131
- data = (JSON.parse(body) || {}).data || {};
132
- } catch (_e) {
133
- resolve(none);
134
- return;
135
- }
136
- const isFreeTier = data.is_free_tier === true;
137
- const limitRemaining = (typeof data.limit_remaining === 'number')
138
- ? data.limit_remaining : null;
139
- const limit = (typeof data.limit === 'number') ? data.limit : null;
140
- const usage = (typeof data.usage === 'number') ? data.usage : null;
141
-
142
- let warning = null;
143
- if (limitRemaining !== null && limitRemaining <= 0) {
144
- warning = OPENROUTER_NO_CREDIT_WARNING;
145
- } else if (isFreeTier) {
146
- warning = OPENROUTER_FREE_TIER_WARNING;
147
- }
148
- resolve({ warning, isFreeTier, limitRemaining, limit, usage });
149
- });
150
- });
151
- req.setTimeout(10000, () => {
152
- req.destroy();
153
- resolve(none);
154
- });
155
- req.on('error', () => { resolve(none); });
156
- });
157
- }
240
+ // The OpenRouter credit probe lives in utils/openrouter-credit.js (same
241
+ // size-gate split rationale as this file's own header). Re-exported below so
242
+ // existing call sites are unaffected.
243
+ const {
244
+ checkOpenRouterCredit, OPENROUTER_NO_CREDIT_WARNING, OPENROUTER_FREE_TIER_WARNING,
245
+ } = require('./openrouter-credit');
158
246
 
159
247
  // Backwards compat alias
160
248
  const validateOpenRouterKey = validateApiKey;
161
249
 
162
250
  module.exports = {
163
251
  validateApiKey,
252
+ redactSecret,
164
253
  validateOpenRouterKey,
165
254
  checkOpenRouterCredit,
166
255
  OPENROUTER_NO_CREDIT_WARNING,
@@ -340,11 +340,33 @@ 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
+ // #218: opt-in per-model output budget. `budget === null` (the default) makes
354
+ // computeModelLimit return null for every model, so each descriptor stays `{}`
355
+ // exactly as before. The catalog read is lazy and failure-tolerant: no cache,
356
+ // a corrupt one, or rows predating the maxOutputTokens field all degrade to
357
+ // `{}` rather than to a PARTIAL limit -- a limit missing `context` is a fatal
358
+ // ConfigInvalidError that poisons the whole config, not a per-model degrade.
359
+ const { normalizeOutputBudget, buildLimitLookup, computeModelLimit } =
360
+ require('./model-output-limit');
361
+ const budget = normalizeOutputBudget(outputBudget === undefined ? getOutputBudget() : outputBudget);
362
+ let limits = new Map();
363
+ if (budget !== null) {
364
+ try {
365
+ const cache = require('./model-catalog').readCache();
366
+ limits = buildLimitLookup(cache && cache.models);
367
+ } catch (_e) { /* no catalog -> no limits -> unchanged behaviour */ }
368
+ }
369
+
348
370
  const addRoute = (fullModel) => {
349
371
  if (!fullModel || typeof fullModel !== 'string') { return; }
350
372
  const parts = fullModel.split('/');
@@ -365,7 +387,23 @@ function buildProviderModels(resolvedRoutes = []) {
365
387
  if (!Object.prototype.hasOwnProperty.call(providers, providerID)) {
366
388
  providers[providerID] = { models: {} };
367
389
  }
368
- providers[providerID].models[modelID] = {};
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.
404
+ // #218: `{}` unless a budget is set AND the catalog knows both numbers.
405
+ const limit = computeModelLimit(limits.get(fullModel), budget);
406
+ providers[providerID].models[modelID] = limit ? { limit } : {};
369
407
  };
370
408
 
371
409
  for (const [alias, fullModel] of Object.entries(aliases)) {
@@ -593,6 +631,30 @@ function resolveCouncilMembers(name, catalog = []) {
593
631
  return { models, dropped, droppedMembers };
594
632
  }
595
633
 
634
+ /**
635
+ * #218: the configured per-leg output budget, or null when unset.
636
+ *
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).
646
+ *
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.
650
+ *
651
+ * @returns {number|null} positive integer, or null when unset/malformed
652
+ */
653
+ function getOutputBudget() {
654
+ const config = loadConfig() || {};
655
+ return require('./model-output-limit').normalizeOutputBudget(config.outputBudget);
656
+ }
657
+
596
658
  /** @returns {{prefer:'direct'|'openrouter', migration_notified:Object}} routing config with defaults */
597
659
  function getRoutingConfig() {
598
660
  const config = loadConfig() || {};
@@ -716,6 +778,7 @@ module.exports = {
716
778
  classifyCouncilMembers,
717
779
  resolveCouncilMembers,
718
780
  getRoutingConfig,
781
+ getOutputBudget,
719
782
  resolveGatewayMode,
720
783
  markMigrationNotified,
721
784
  COST_TIERS,
@@ -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,61 @@
1
+ /**
2
+ * @module doctor-credit-check
3
+ * The `openrouter-credit` doctor row (#43), split out of
4
+ * src/cli-handlers-doctor.js to keep that file under the 300-line size gate
5
+ * (mirrors doctor-base-url-check.js / doctor-alias-check.js / doctor-key-auth-check.js).
6
+ *
7
+ * Warns, never errors: a zero-credit or free-tier key is a real constraint but
8
+ * not a broken install, and free councils against :free models are legitimate.
9
+ *
10
+ * ⚠️ "credit ok" MEANS CHECKED. checkOpenRouterCredit resolves `warning: null`
11
+ * for a healthy account, for a skipped probe, AND for every failure — so
12
+ * branching on `warning` alone reported a funded account for one nobody
13
+ * reached, concealing quota exhaustion behind a green row.
14
+ *
15
+ * Two flags separate the three cases, and both are required: `skipped` (we
16
+ * chose not to look) and `checked` (we looked and got an answer). Fixing only
17
+ * `skipped` left the false green alive on the network-failure path — which is
18
+ * exactly what the fourth council pass on PR 222 found, because the test
19
+ * pinning the fix ran with the gate CLOSED and could never enter the path it
20
+ * claimed to protect.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const ID = 'openrouter-credit';
26
+ const NAME = 'OpenRouter credit';
27
+
28
+ /**
29
+ * @param {{readApiKeyValues: () => Object<string,string>,
30
+ * checkOpenRouterCredit: (key:string) => Promise<object>}} d
31
+ * @returns {Promise<{id,name,status,message,hint}>}
32
+ */
33
+ async function evaluateOpenRouterCredit(d) {
34
+
35
+ const values = d.readApiKeyValues() || {};
36
+ const key = values.openrouter;
37
+ if (!key) {
38
+ return { id: ID, name: NAME, status: 'ok', message: 'no OpenRouter key — skipped', hint: null };
39
+ }
40
+ // Reuses the #38 non-blocking probe; resolves warning:null on any failure.
41
+ const res = (await d.checkOpenRouterCredit(key)) || {};
42
+ if (res.skipped) {
43
+ // Never 'ok': nothing was checked, so quota exhaustion or a free-tier cap
44
+ // would be concealed behind a green row (council review of PR 222).
45
+ return { id: ID, name: NAME, status: 'warn', message: 'credit not checked — live probes disabled', hint: 'Unset AMICUS_NO_NETWORK_PROBES, or run `amicus doctor` from the CLI.' };
46
+ }
47
+ // A probe that ran but got no answer (timeout, 5xx, socket reset, garbage
48
+ // body) is NOT evidence of a funded account. Distinct message from the
49
+ // skip above: one means "we chose not to look", this means "we looked and
50
+ // could not see".
51
+ if (res.checked !== true) {
52
+ return { id: ID, name: NAME, status: 'warn', message: 'credit could not be checked — the probe did not complete', hint: 'Transient: re-run `amicus doctor` when the network settles.' };
53
+ }
54
+ if (res.warning) {
55
+ return { id: ID, name: NAME, status: 'warn', message: res.warning, hint: 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).' };
56
+ }
57
+ const remaining = (typeof res.limitRemaining === 'number') ? ` ($${res.limitRemaining} remaining)` : '';
58
+ return { id: ID, name: NAME, status: 'ok', message: `credit ok${remaining}`, hint: null };
59
+ }
60
+
61
+ module.exports = { evaluateOpenRouterCredit };