amicus 4.9.1 → 4.9.3

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/src/headless.js CHANGED
@@ -25,6 +25,8 @@ const { envNumber } = require('./utils/env-num');
25
25
  const { engineErrorForSession } = require('./utils/engine-log');
26
26
  // v4.9 W10 (#133 piece 3): the standing engine version-skew record, if any.
27
27
  const { currentEngineSkew, formatSkewSuffix } = require('./utils/engine-skew');
28
+ // #202: the session-status clause on a death report (see utils/session-status.js).
29
+ const { formatSessionStatusSuffix } = require('./utils/session-status');
28
30
  // v4.9 W13 Task A (PR #207 round 3, B3): the one honesty predicate every ttftMs
29
31
  // emit gate shares — see src/utils/ttft.js for why `typeof` was not it.
30
32
  const { isMeasuredTtft } = require('./utils/ttft');
@@ -85,7 +87,18 @@ const STABLE_FINISHED_POLLS = Number(process.env.AMICUS_STABLE_FINISHED_POLLS) |
85
87
  const STABLE_IDLE_POLLS = Number(process.env.AMICUS_STABLE_IDLE_POLLS) || 30; // ~60s at 2s — no completion signal
86
88
  const POLL_CALL_TIMEOUT_MS = Number(process.env.AMICUS_POLL_CALL_TIMEOUT_MS) || 30000; // per getMessages call (used by a later task)
87
89
  const MAX_CONSECUTIVE_POLL_FAILURES = Number(process.env.AMICUS_MAX_CONSECUTIVE_POLL_FAILURES) || 15; // ≈30s at 2s polls
88
- const TOOL_CALL_STALL_MS = Number(process.env.AMICUS_TOOL_CALL_STALL_MS) || 180000; // B53: wedged tool call w/ no progress
90
+ // B53: wedged tool call w/ no progress.
91
+ // ⚠️ 180000 -> 300000 (#219, council glm minor). 180 s was condemned by this
92
+ // file's OWN measurement — the 190.6 s `task` call recorded below, taken on a
93
+ // developer machine, not on CI. #202 widened only the CI override and left every
94
+ // local and library consumer on the number the evidence had already disproved.
95
+ // 300000 matches the sibling constant that same measurement set (USAGE/settle
96
+ // deferral below), so one measurement now governs both windows it bears on.
97
+ const TOOL_CALL_STALL_MS = Number(process.env.AMICUS_TOOL_CALL_STALL_MS) || 300000;
98
+ // #202: budget for the ONE session-status read on a death report. Short on
99
+ // purpose — this runs on a leg already known to be dying, so the report must not
100
+ // wait on the same engine that just failed to produce anything.
101
+ const STATUS_PROBE_MS = 5000;
89
102
  /**
90
103
  * v4.4 B1 — bounded post-loop usage reconciliation. The fold-marker (:~540) and
91
104
  * SDK-idle (:~568) fast paths break WITHOUT requiring `info.time.completed`, but
@@ -120,9 +133,11 @@ const USAGE_SETTLE_CALL_TIMEOUT_MS = envNumber('AMICUS_USAGE_SETTLE_CALL_TIMEOUT
120
133
  * unbounded wait is not an option.
121
134
  *
122
135
  * WHY 5 MINUTES. The measured duration of the real subagent call that exposed
123
- * this is **190.6 s** (`task`, 04:35:08.427 → 04:38:19.061) — already longer
124
- * than B53's 180 s TOOL_CALL_STALL_MS, so anything at that scale would kill a
125
- * healthy `task` leg 10 s short of its answer. 300 s clears the measured case
136
+ * this is **190.6 s** (`task`, 04:35:08.427 → 04:38:19.061) — which was longer
137
+ * than B53's THEN-180 s TOOL_CALL_STALL_MS, so anything at that scale would kill
138
+ * a healthy `task` leg 10 s short of its answer. (#219 finally moved B53 itself
139
+ * to 300 s for this exact reason; for four releases this measurement corrected
140
+ * the neighbour and left its own subject alone.) 300 s clears the measured case
126
141
  * with margin and still lands far inside the 15-minute default `--timeout`.
127
142
  * Set to 0 to disable the deferral entirely (pre-v4.4 behaviour).
128
143
  *
@@ -228,7 +243,7 @@ function withTimeout(promise, ms, label) {
228
243
  * engineSkew?: {server: string, installed: string}|null}} args
229
244
  * @returns {string}
230
245
  */
231
- function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSkew }) {
246
+ function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSkew, sessionStatus }) {
232
247
  const observed = 'NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in '
233
248
  + `${Math.round(ms / 1000)}s — `
234
249
  + (fromEnv
@@ -237,7 +252,11 @@ function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSke
237
252
  // Append-only: absent/empty excerpt ⇒ the string above, unchanged byte for byte.
238
253
  const quoted = engineLogExcerpt ? `${observed} — engine log: ${engineLogExcerpt}` : observed;
239
254
  // Append-only for the same reason: no skew ⇒ formatSkewSuffix returns ''.
240
- return `${quoted}${formatSkewSuffix(engineSkew)}`;
255
+ // #202 adds a THIRD clause on the same terms, and LAST so both clauses above
256
+ // stay byte-stable: no status (or an unreadable one) ⇒ '' — see
257
+ // utils/session-status.js. With none of the three the string is byte-for-byte
258
+ // what it was before any of them existed.
259
+ return `${quoted}${formatSkewSuffix(engineSkew)}${formatSessionStatusSuffix(sessionStatus)}`;
241
260
  }
242
261
 
243
262
  /**
@@ -259,6 +278,45 @@ function engineErrorExcerptSafe(sessionId, engineLogOptions) {
259
278
  }
260
279
  }
261
280
 
281
+ /**
282
+ * #202: read the engine's session status FOR A DEATH REPORT — best-effort and
283
+ * bounded, with the same "never become the failure it reports on" discipline as
284
+ * `engineErrorExcerptSafe` above, and one more constraint that read does not
285
+ * have: this one does I/O against the very engine that just failed to produce
286
+ * anything, so it must also be unable to HANG. Both belts are load-bearing and
287
+ * both are pinned (S-W4 rejects, S-W5 hangs).
288
+ *
289
+ * A failed probe returns `null`, which `formatSessionStatusSuffix` renders as
290
+ * '' — so a leg whose status could not be read carries the byte-for-byte reason
291
+ * string it carried before #202, rather than a clause claiming nothing was
292
+ * happening. Absence keeps its one meaning.
293
+ *
294
+ * `readStatus` is a parameter rather than a module import because
295
+ * `getSessionStatus` is destructured inside runHeadless from the injectable
296
+ * client module — taking it here keeps this helper pure and directly testable.
297
+ * @returns {Promise<object|null>}
298
+ */
299
+ async function sessionStatusSafe(readStatus, client, sessionId, dirArgs, ms) {
300
+ // `!(ms > 0)` covers 0 (the documented disable), negatives and NaN — and it is
301
+ // why 0 is never handed to withTimeout, which would read it as UNBOUNDED.
302
+ if (typeof readStatus !== 'function' || !sessionId || !(ms > 0)) { return null; }
303
+ try {
304
+ return await withTimeout(
305
+ readStatus(client, sessionId, ...(dirArgs || [])), ms, 'getSessionStatus(death-report)');
306
+ } catch (err) {
307
+ // #219 (council, deepseek minor): returning null is right for the REPORT —
308
+ // absence keeps its one meaning — but it made a probe that timed out on a
309
+ // loaded engine indistinguishable from a leg whose engine reported nothing,
310
+ // i.e. a silent revert to pre-#202 behaviour. The engine log is where that
311
+ // belongs: the death report stays byte-identical, and the degradation
312
+ // becomes diagnosable instead of invisible.
313
+ logger.debug('session-status probe failed; the death report will carry no session clause', {
314
+ sessionId, error: err && err.message,
315
+ });
316
+ return null;
317
+ }
318
+ }
319
+
262
320
  /**
263
321
  * Wait for the OpenCode server to be ready using SDK health check
264
322
  */
@@ -616,6 +674,19 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
616
674
  // "time since the leg asked for output" (see the block comment above), so
617
675
  // the TTFT probe below measures from exactly the instant the backstop
618
676
  // starts counting — the two can never disagree about when the wait began.
677
+ // #202: resolved HERE, not with the poll-loop options below. The pre-send
678
+ // firing site calls noOutputBackstopReason() upstream of that block, so a
679
+ // later `const` put this in its TDZ and the death report became
680
+ // "Cannot access 'statusProbeMs' before initialization" — the exact class of
681
+ // silent-diagnosis loss #202 exists to remove. Pinned by S-W6.
682
+ // ⚠️ `=== undefined`, not `||` (#219 round 2, deepseek): 0 is a meaningful
683
+ // value in this codebase's convention (usageSettlePolls,
684
+ // AMICUS_NO_OUTPUT_BACKSTOP_MS) and must survive injection. It cannot be
685
+ // FORWARDED as 0 though — withTimeout reads `ms <= 0` as NO timeout, so an
686
+ // honest-looking 0 would make this probe unbounded on a leg already known to
687
+ // be dying. sessionStatusSafe therefore SKIPS on a non-positive window.
688
+ const statusProbeMs = options.statusProbeMs === undefined
689
+ ? STATUS_PROBE_MS : options.statusProbeMs;
619
690
  const outputClockStartedAt = Date.now();
620
691
  const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: outputClockStartedAt });
621
692
  let backstopFired = false;
@@ -642,10 +713,20 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
642
713
  // was fixed mid-run, or one that belongs to another server this process
643
714
  // also talks to, cannot ride out on this death report. The read is a Map
644
715
  // lookup and does no I/O, so unlike the log read it needs no guard.
645
- const noOutputBackstopReason = () => formatNoOutputBackstopReason({
716
+ //
717
+ // #202 (piece 4): the closure is now ASYNC, because the third clause costs
718
+ // one bounded HTTP call. The engine's session status is asked for at
719
+ // :~1045 only when `mirror.output.length > 0` — a gate a zero-output leg
720
+ // never satisfies — so the leg that most needs diagnosing was the only one
721
+ // that never asked, and every silent death reported a window with no cause.
722
+ // Asked for HERE instead, at the two firing sites and nowhere else, so a
723
+ // living leg still makes no extra call. The read is best-effort and
724
+ // bounded: `sessionStatusSafe` can neither throw nor hang (S-W4/S-W5).
725
+ const noOutputBackstopReason = async () => formatNoOutputBackstopReason({
646
726
  ms: noOutputBackstopMs, fromEnv: backstopFromEnv,
647
727
  engineLogExcerpt: engineErrorExcerptSafe(sessionId, options._engineLog),
648
728
  engineSkew: currentEngineSkew(client),
729
+ sessionStatus: await sessionStatusSafe(getSessionStatus, client, sessionId, dirArgs, statusProbeMs),
649
730
  });
650
731
 
651
732
  // Send prompt asynchronously (returns immediately, we poll for results) —
@@ -696,7 +777,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
696
777
  // skipped entirely on this path — see the while-condition and the
697
778
  // backstop-abort block further down).
698
779
  if (backstopFired) {
699
- sessionError = noOutputBackstopReason();
780
+ sessionError = await noOutputBackstopReason();
700
781
  }
701
782
 
702
783
  // Hard provider failure detected at the client boundary (#37): a non-2xx /
@@ -1065,7 +1146,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1065
1146
  // loop; the post-loop block below mirrors the timeout path.
1066
1147
  if (noOutputBackstop.tick(substantiveActivity, Date.now()) === 'fired') {
1067
1148
  backstopFired = true;
1068
- sessionError = noOutputBackstopReason();
1149
+ sessionError = await noOutputBackstopReason();
1069
1150
  logger.warn('No-output backstop fired', { taskId, backstopMs: noOutputBackstopMs });
1070
1151
  break;
1071
1152
  }
@@ -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,
@@ -345,6 +345,23 @@ function buildProviderModels(resolvedRoutes = []) {
345
345
  const aliases = getEffectiveAliases();
346
346
  const providers = {};
347
347
 
348
+ // #218: opt-in per-model output budget. `budget === null` (the default) makes
349
+ // computeModelLimit return null for every model, so each descriptor stays `{}`
350
+ // exactly as before. The catalog read is lazy and failure-tolerant: no cache,
351
+ // a corrupt one, or rows predating the maxOutputTokens field all degrade to
352
+ // `{}` rather than to a PARTIAL limit -- a limit missing `context` is a fatal
353
+ // ConfigInvalidError that poisons the whole config, not a per-model degrade.
354
+ const { normalizeOutputBudget, buildLimitLookup, computeModelLimit } =
355
+ require('./model-output-limit');
356
+ const budget = normalizeOutputBudget(getOutputBudget());
357
+ let limits = new Map();
358
+ if (budget !== null) {
359
+ try {
360
+ const cache = require('./model-catalog').readCache();
361
+ limits = buildLimitLookup(cache && cache.models);
362
+ } catch (_e) { /* no catalog -> no limits -> unchanged behaviour */ }
363
+ }
364
+
348
365
  const addRoute = (fullModel) => {
349
366
  if (!fullModel || typeof fullModel !== 'string') { return; }
350
367
  const parts = fullModel.split('/');
@@ -365,7 +382,9 @@ function buildProviderModels(resolvedRoutes = []) {
365
382
  if (!Object.prototype.hasOwnProperty.call(providers, providerID)) {
366
383
  providers[providerID] = { models: {} };
367
384
  }
368
- providers[providerID].models[modelID] = {};
385
+ // #218: `{}` unless a budget is set AND the catalog knows both numbers.
386
+ const limit = computeModelLimit(limits.get(fullModel), budget);
387
+ providers[providerID].models[modelID] = limit ? { limit } : {};
369
388
  };
370
389
 
371
390
  for (const [alias, fullModel] of Object.entries(aliases)) {
@@ -593,6 +612,28 @@ function resolveCouncilMembers(name, catalog = []) {
593
612
  return { models, dropped, droppedMembers };
594
613
  }
595
614
 
615
+ /**
616
+ * #218: the configured per-leg output budget, or null when unset.
617
+ *
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.
621
+ *
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 `{}`.
629
+ *
630
+ * @returns {number|null} positive integer, or null when unset/malformed
631
+ */
632
+ function getOutputBudget() {
633
+ const config = loadConfig() || {};
634
+ return require('./model-output-limit').normalizeOutputBudget(config.outputBudget);
635
+ }
636
+
596
637
  /** @returns {{prefer:'direct'|'openrouter', migration_notified:Object}} routing config with defaults */
597
638
  function getRoutingConfig() {
598
639
  const config = loadConfig() || {};
@@ -716,6 +757,7 @@ module.exports = {
716
757
  classifyCouncilMembers,
717
758
  resolveCouncilMembers,
718
759
  getRoutingConfig,
760
+ getOutputBudget,
719
761
  resolveGatewayMode,
720
762
  markMigrationNotified,
721
763
  COST_TIERS,
@@ -24,6 +24,14 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
24
24
  // a leg that DID match a slot but whose join key names no judge the wave launched.
25
25
  // Never a guess — silent mis-attribution is the failure seat identity exists to kill (§4.4).
26
26
  'seat-unbound',
27
+ // #202: a Stage-2 JUDGE leg that came back dead — bound to its seat, so
28
+ // neither `seat-unbound` nor an orphan, and until now it had no channel at all
29
+ // and no case in run-stage2.js. Deliberately its own channel rather than
30
+ // `dead-leg`: that one is the Stage-1 BENCH roster's, feeds the retry pass and
31
+ // the seat-loss surface, and a judge death reused on it would be counted as a
32
+ // lost reviewer by consumers that only ever meant seats (verdict-seat-loss.js
33
+ // already gates the Stage-2 notes out of `seat-unbound` for the same reason).
34
+ 'stage2-judge',
27
35
  'internal',
28
36
  // doctor channels
29
37
  'doctor-check-failed', 'doctor-fix',
@@ -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 };