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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +139 -0
- package/README.md +1 -1
- package/bin/amicus.js +6 -0
- package/docs/configuration.md +52 -0
- package/docs/usage.md +4 -1
- package/electron/main.js +25 -2
- package/electron/setup-ui-alias-groups.js +161 -0
- package/electron/setup-ui-alias-script.js +70 -4
- package/electron/setup-ui-aliases.js +25 -21
- package/electron/setup-ui.js +11 -1
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +10 -0
- package/src/cli-handlers-doctor.js +9 -16
- package/src/cli-handlers.js +17 -1
- package/src/council/run-retry-window.js +62 -0
- package/src/council/run-retry.js +7 -10
- package/src/council/run-stage2.js +47 -3
- package/src/council/tally.js +12 -0
- package/src/council/verdict-seats-reviewed.js +60 -0
- package/src/council/verdict.js +23 -0
- package/src/headless.js +90 -9
- package/src/utils/api-key-validation.js +183 -94
- package/src/utils/config.js +43 -1
- package/src/utils/degrade.js +8 -0
- package/src/utils/doctor-credit-check.js +61 -0
- package/src/utils/doctor-key-auth-check.js +271 -0
- package/src/utils/live-probes.js +53 -0
- package/src/utils/model-fetcher.js +2 -0
- package/src/utils/model-output-limit.js +124 -0
- package/src/utils/openrouter-credit.js +104 -0
- package/src/utils/session-status.js +73 -0
- package/src/utils/ttft.js +17 -6
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
|
-
|
|
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) —
|
|
124
|
-
* than B53's 180 s TOOL_CALL_STALL_MS, so anything at that scale would kill
|
|
125
|
-
* healthy `task` leg 10 s short of its answer.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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:
|
|
220
|
+
resolve({ valid: false, status: code, error: `Unexpected response (${code})` });
|
|
67
221
|
}
|
|
68
|
-
|
|
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
|
|
235
|
+
resolve({ valid: false, status: null, error: redactSecret(messageOf(err), trimmedKey) });
|
|
86
236
|
});
|
|
87
237
|
});
|
|
88
238
|
}
|
|
89
239
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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,
|
package/src/utils/config.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/utils/degrade.js
CHANGED
|
@@ -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 };
|