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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * @module doctor-key-auth-check
3
+ * The `key-auth` doctor row (issue #210), split out of src/cli-handlers-doctor.js
4
+ * to keep that file under the 300-line size gate (mirrors
5
+ * doctor-base-url-check.js / doctor-alias-check.js / doctor-local-providers-check.js).
6
+ *
7
+ * WHY IT EXISTS: doctor's `keys` row tests PRESENCE only — readApiKeys()
8
+ * returns booleans — and validateApiKey() was called at exactly two SAVE-TIME
9
+ * sites (electron/ipc-setup.js, src/cli-handlers.js). A key that rotted after
10
+ * it was entered, or that reached .env by any path other than the wizard /
11
+ * `amicus key`, was never re-checked: #210's reporter had a green doctor while
12
+ * the stored DeepSeek key returned 401 and the catalog served 0 deepseek rows.
13
+ *
14
+ * THE STATUS RULE (the whole design problem is FALSE ALARMS):
15
+ * - HTTP 401 → 'error'. That is the server saying "this credential is not
16
+ * accepted", and nothing else in the space says it that plainly.
17
+ * - EVERYTHING else → 'warn'. Timeout, DNS/socket error, 5xx, 429, an
18
+ * unexpected 4xx, a result with no status at all: none of these
19
+ * distinguish a rotted key from a laptop on a plane. Unrecognised input
20
+ * falls to the ambiguous side ON PURPOSE — the failure mode of a false
21
+ * 'error' here is a user re-entering a perfectly good key.
22
+ * - HTTP 403 is explicitly on the WARN side (council finding 1, PR 221). It
23
+ * read as definitive at first, which was wrong: Google returns 403 for
24
+ * "API not enabled" and for quota, and a WAF returns it for bot
25
+ * protection. Treating it as a verdict traded the false-GREEN this check
26
+ * was built to kill for a false-ALARM of the same shape.
27
+ * - a stored key with NO validation endpoint → 'warn'. It cannot be probed,
28
+ * so this check cannot vouch for it (council finding C1, first review).
29
+ * - no keys stored → 'ok' + "skipped" (mirrors the openrouter-credit skip).
30
+ *
31
+ * The evidence is the STRUCTURED `status` field, not the prose.
32
+ * classifyProbeFailure used to regex "(401)" out of the error string, which
33
+ * made control flow depend on message wording — a reword upstream that dropped
34
+ * the parentheses would have degraded every row to a permanent "unverified"
35
+ * with nothing failing to announce it (council finding 2, PR 221).
36
+ *
37
+ * NO KEY MATERIAL, NO URLs, EVER. Four layers, each structural:
38
+ * 1. Only provider IDS and a reason built here reach the row — the raw error
39
+ * string is never echoed.
40
+ * 2. Every live authenticated request realDeps() can make goes through the
41
+ * probe wrappers below (probeApiKey, probeOpenRouterCredit), which share
42
+ * one gate. An earlier version of this note claimed probeApiKey alone was
43
+ * that gate while checkOpenRouterCredit sat beside it unguarded — the
44
+ * claim is now true rather than merely written down.
45
+ * 3. api-key-validation.js redacts the key from any message before it
46
+ * escapes, and resolves rather than rejecting on a synchronous throw from
47
+ * https.get. That is the ROOT fix (council finding 3, PR 221): the Google
48
+ * probe embeds the key as `?key=...`, and the two save-time call sites
49
+ * have no protection of their own — electron/ipc-setup.js hands
50
+ * `err.message` to the renderer and logs it, and src/cli-handlers.js has
51
+ * no try/catch at all.
52
+ * 4. Each probe here is STILL individually caught, because this module must
53
+ * not depend on an injected validator honouring that contract.
54
+ *
55
+ * Probes run in PARALLEL: validateApiKey's own timeout is 10s, so five stored
56
+ * keys probed sequentially would add up to 50s to a `doctor` run. Fan-out is
57
+ * bounded by the number of configured providers (5 today), so no pool is needed.
58
+ */
59
+
60
+ 'use strict';
61
+
62
+ const { VALIDATION_ENDPOINTS } = require('./api-key-validation');
63
+
64
+ const ID = 'key-auth';
65
+ const NAME = 'API key auth';
66
+
67
+ /** Statuses that are an authoritative "this credential is not accepted". */
68
+ // 401 only. 403 was here and is not a credential verdict — see
69
+ // classifyProbeFailure (council finding 1, PR 221).
70
+ const DEFINITIVE_STATUSES = new Set([401]);
71
+
72
+ const REENTER_HINT = (providers) =>
73
+ `amicus key <provider> <key> (re-enter the rejected key for: ${providers.join(', ')})`;
74
+
75
+ const UNPROBEABLE_HINT =
76
+ 'Not a rejection — amicus has no validation endpoint for this provider, so the '
77
+ + 'key could not be checked either way. Add one in utils/api-key-validation.js '
78
+ + '(VALIDATION_ENDPOINTS) to bring it under this check.';
79
+
80
+ const UNVERIFIED_HINT =
81
+ 'Not a rejection — the probe could not reach the provider (offline, DNS failure, '
82
+ + '5xx or timeout). Re-run `amicus doctor` when connectivity is restored.';
83
+
84
+ /**
85
+ * Whether a live authenticated request is allowed right now. The shared gate
86
+ * for BOTH probes below — that sharing is the point, see layer 2 in the header.
87
+ *
88
+ * Council finding 7 (PR 221): `runDoctorChecks` merges a caller's deps over
89
+ * `realDeps()`, so ANY suite that builds deps by hand and forgets to inject a
90
+ * double — i.e. bypasses tests/helpers/doctor-base-deps.js — would fire live
91
+ * authenticated HTTPS requests using the developer's REAL
92
+ * ~/.config/amicus/.env keys. Nothing would fail; the suite would just be
93
+ * slower and quietly spending someone's credentials. Suites that DO inject a
94
+ * double are unaffected: theirs wins the merge and these are never called.
95
+ *
96
+ * @returns {boolean}
97
+ */
98
+ function liveProbesDisabled() {
99
+ return !require('./live-probes').liveProbesAllowed();
100
+ }
101
+
102
+ /** Marker string the classifier recognises, so a skip reads as a skip. */
103
+ const SKIPPED_REASON = 'probe skipped (live probes disabled)';
104
+
105
+ const SKIPPED = () => Promise.resolve({
106
+ valid: false, status: null, skipped: true, error: SKIPPED_REASON,
107
+ });
108
+
109
+ /**
110
+ * The key-validation probe `realDeps()` injects, behind the shared gate.
111
+ * A skip resolves as an ordinary unverified result, so the row warns rather
112
+ * than claiming health it did not establish.
113
+ * @returns {Promise<{valid: boolean, status: number|null, error?: string}>}
114
+ */
115
+ function probeApiKey(provider, key) {
116
+ if (liveProbesDisabled()) { return SKIPPED(); }
117
+ return require('./api-key-validation').validateApiKey(provider, key);
118
+ }
119
+
120
+ /**
121
+ * The OpenRouter credit probe, behind the SAME guard.
122
+ *
123
+ * ⚠️ This exists because the claim above was FALSE when first written. The
124
+ * header said probeApiKey was "the one place" a live authenticated request is
125
+ * decided, while realDeps() still injected `checkOpenRouterCredit` as an
126
+ * unguarded raw require — an authenticated call to openrouter.ai/api/v1/key
127
+ * with the developer's stored key, reachable by exactly the hand-built dep
128
+ * suites the guard was written for. Council review of PR 222 falsified the
129
+ * claim; routing both probes through one gate makes it true instead of
130
+ * deleting it. Resolves the same shape checkOpenRouterCredit does, so the
131
+ * caller's `res.warning` branch is unaffected.
132
+ */
133
+ function probeOpenRouterCredit(key) {
134
+ if (liveProbesDisabled()) {
135
+ // ⚠️ `warning: null` alone is what checkOpenRouterCredit resolves when the
136
+ // account is FINE, so returning it here made a skipped probe render as
137
+ // "credit ok" — a false green reporting a funded account nobody checked.
138
+ // Introduced in the same commit that removed the identical shape from the
139
+ // anthropic branch, one function over, with a test blessing it. Council
140
+ // review of PR 222. `skipped` is what the row branches on now.
141
+ return Promise.resolve({
142
+ skipped: true, checked: false,
143
+ warning: null, isFreeTier: false, limitRemaining: null, limit: null, usage: null,
144
+ });
145
+ }
146
+ return require('./api-key-validation').checkOpenRouterCredit(key);
147
+ }
148
+
149
+ /**
150
+ * Classify one validateApiKey failure as a definitive auth rejection or an
151
+ * ambiguous one, and produce the SANITIZED reason that may be printed.
152
+ *
153
+ * Reads the STRUCTURED `status` field. This used to regex the status out of
154
+ * `error` — which made a control-flow decision depend on message wording, so a
155
+ * reword upstream that dropped the parentheses would have silently degraded
156
+ * every row to a permanent "unverified" with no test failing to say so
157
+ * (council finding 2, PR 221). api-key-validation.js returns the status as
158
+ * data now. A result with no status is ambiguous, which is the fail-safe side.
159
+ *
160
+ * The returned `reason` is built here from scratch — it never contains any
161
+ * substring of `error`, which is what makes the no-URL/no-key guarantee
162
+ * structural rather than a hope about provider prose.
163
+ *
164
+ * @param {{status: number|null, error?: string}} [res] validateApiKey's result
165
+ * @returns {{definitive: boolean, reason: string}}
166
+ */
167
+ function classifyProbeFailure(res) {
168
+ const r = (res && typeof res === 'object') ? res : {};
169
+ const status = (typeof r.status === 'number' && Number.isFinite(r.status)) ? r.status : null;
170
+
171
+ if (status !== null && DEFINITIVE_STATUSES.has(status)) {
172
+ return { definitive: true, reason: `rejected (HTTP ${status})` };
173
+ }
174
+ if (status === 403) {
175
+ // Deliberately NOT definitive (council finding 1). Google returns 403 for
176
+ // "API not enabled" and for quota; a WAF returns it for bot protection.
177
+ // Telling someone to re-enter a working key is the false-ALARM twin of the
178
+ // false-GREEN this check exists to kill, and this check's whole design
179
+ // premise is that a false error costs more than a warn.
180
+ return { definitive: false, reason: 'unverified (HTTP 403 — forbidden, may not be the key)' };
181
+ }
182
+ if (status !== null) {
183
+ return { definitive: false, reason: `unverified (HTTP ${status})` };
184
+ }
185
+ if (r.skipped) {
186
+ // Named, not folded into "unreachable" — the two have different fixes and
187
+ // conflating them is the kind of small dishonesty this row exists to avoid.
188
+ return { definitive: false, reason: 'unverified (probe skipped)' };
189
+ }
190
+ if (/timed?\s*out|timeout/i.test(typeof r.error === 'string' ? r.error : '')) {
191
+ return { definitive: false, reason: 'unverified (timed out)' };
192
+ }
193
+ return { definitive: false, reason: 'unverified (unreachable)' };
194
+ }
195
+
196
+ /**
197
+ * Probe one stored key. Always resolves; never surfaces `error`/`e.message`.
198
+ * @returns {Promise<{provider: string, part: string, rejected: boolean, unverified: boolean}>}
199
+ */
200
+ async function probeOne(d, provider, key) {
201
+ let res;
202
+ try {
203
+ res = await d.validateApiKey(provider, key);
204
+ } catch (_e) {
205
+ // See rule 2 in the file header: _e.message can carry the Google probe URL,
206
+ // key included. It is deliberately dropped rather than logged.
207
+ return { provider, part: `${provider}: unverified (probe failed)`, rejected: false, unverified: true };
208
+ }
209
+ if (res && res.valid) {
210
+ return { provider, part: `${provider}: valid`, rejected: false, unverified: false };
211
+ }
212
+ const { definitive, reason } = classifyProbeFailure(res);
213
+ return { provider, part: `${provider}: ${reason}`, rejected: definitive, unverified: !definitive };
214
+ }
215
+
216
+ /**
217
+ * @param {{readApiKeyValues: () => Object<string,string>,
218
+ * validateApiKey: (provider:string, key:string)
219
+ * => Promise<{valid:boolean, status:number|null, error?:string}>}} d
220
+ * @returns {Promise<{id:string, name:string, status:string, message:string, hint:?string}>}
221
+ */
222
+ async function evaluateKeyAuth(d) {
223
+ const values = d.readApiKeyValues() || {};
224
+ // Object.keys + bracket access (never `for..in`) — same prototype-chain
225
+ // discipline as doctor-local-providers-check.js.
226
+ const stored = Object.keys(values).filter((p) => values[p]);
227
+
228
+ if (stored.length === 0) {
229
+ return { id: ID, name: NAME, status: 'ok', message: 'no keys stored — skipped', hint: null };
230
+ }
231
+
232
+ // A stored key for a provider with no validation endpoint cannot be probed at
233
+ // all — so this check cannot vouch for it, and must not imply that it can.
234
+ //
235
+ // ⚠️ This originally reported such a key but still returned `ok`, on the
236
+ // reasoning that adding a provider to provider-registry.js ahead of an
237
+ // endpoint in api-key-validation.js should not turn every doctor run yellow.
238
+ // Council finding C1 on PR #221 (raised by `gpt`) is right that this is the
239
+ // EXACT false-green class #210 exists to close: a row that says "ok" while a
240
+ // stored credential was never checked. It warns now. The cost is zero today —
241
+ // PROVIDER_ENV_MAP and VALIDATION_ENDPOINTS carry identical key sets, so
242
+ // `unprobeable` is always empty and this can only fire once someone actually
243
+ // creates the gap. Silence is not health.
244
+ const probeable = stored.filter((p) => VALIDATION_ENDPOINTS[p]);
245
+ const unprobeable = stored.filter((p) => !VALIDATION_ENDPOINTS[p]);
246
+
247
+ // Parallel by construction — see the file header.
248
+ const results = await Promise.all(probeable.map((p) => probeOne(d, p, values[p])));
249
+
250
+ const parts = results.map((r) => r.part)
251
+ .concat(unprobeable.map((p) => `${p}: not probeable (no validation endpoint)`));
252
+ const message = parts.join('; ');
253
+
254
+ const rejected = results.filter((r) => r.rejected).map((r) => r.provider);
255
+ if (rejected.length > 0) {
256
+ return { id: ID, name: NAME, status: 'error', message, hint: REENTER_HINT(rejected) };
257
+ }
258
+ // Unprobeable counts as unverified (C1): 'ok' here would vouch for a key
259
+ // nothing checked. A definitive rejection still outranks it above.
260
+ if (results.some((r) => r.unverified) || unprobeable.length > 0) {
261
+ const hint = unprobeable.length > 0 && !results.some((r) => r.unverified)
262
+ ? UNPROBEABLE_HINT : UNVERIFIED_HINT;
263
+ return { id: ID, name: NAME, status: 'warn', message, hint };
264
+ }
265
+ return { id: ID, name: NAME, status: 'ok', message, hint: null };
266
+ }
267
+
268
+ module.exports = {
269
+ evaluateKeyAuth, classifyProbeFailure,
270
+ probeApiKey, probeOpenRouterCredit, liveProbesDisabled, SKIPPED_REASON,
271
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @module live-probes
3
+ * The single gate on outbound AUTHENTICATED network probes made by diagnostics
4
+ * (`amicus doctor` / `init` / the setup finale), which run with the user's real
5
+ * stored keys.
6
+ *
7
+ * OPT-IN, NOT OPT-OUT. `bin/amicus.js` — the one entry point every command
8
+ * routes through — calls enable() at startup. Nothing else does, so a module
9
+ * required directly (by a test, a script, another tool) can never probe.
10
+ *
11
+ * ⚠️ WHY IT IS SHAPED THIS WAY. The first version detected test RUNNERS
12
+ * instead: JEST_WORKER_ID, VITEST, NODE_TEST_CONTEXT. That is a blocklist
13
+ * heuristic, and a blocklist is only as good as its enumeration — a bespoke
14
+ * runner, a plain `node script.js`, or a harness nobody thought of walks
15
+ * straight past it and spends the developer's credentials. Council review of
16
+ * PR 222. Detecting "am I in a test?" is unbounded; asserting "I am the CLI"
17
+ * is a single known fact, so the burden moved to the side that can actually
18
+ * discharge it.
19
+ *
20
+ * ⚠️ AND WHY THAT IS SAFE. Inverting a default risks the opposite failure: miss
21
+ * an entry point and production silently stops probing. That is survivable
22
+ * ONLY because a skipped probe is never reported as healthy — callers must
23
+ * surface it as unverified (see doctor-key-auth-check.js and the
24
+ * `openrouter-credit` row). Worst case is a visible warn, never a false green.
25
+ * If you add an entry point that should probe, call enable() there; if you
26
+ * forget, `doctor` says so out loud.
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ let enabled = false;
32
+
33
+ /** Allow live probes for the remainder of this process. Called by bin/amicus.js. */
34
+ function enableLiveProbes() {
35
+ enabled = true;
36
+ }
37
+
38
+ /**
39
+ * Whether a live authenticated probe may run right now.
40
+ * `AMICUS_NO_NETWORK_PROBES=1` forces off even for the CLI — an escape hatch
41
+ * for air-gapped or rate-limited environments.
42
+ */
43
+ function liveProbesAllowed() {
44
+ if (process.env.AMICUS_NO_NETWORK_PROBES === '1') { return false; }
45
+ return enabled;
46
+ }
47
+
48
+ /** Test-only: restore the default (disabled) state. */
49
+ function _resetLiveProbes() {
50
+ enabled = false;
51
+ }
52
+
53
+ module.exports = { enableLiveProbes, liveProbesAllowed, _resetLiveProbes };
@@ -45,6 +45,8 @@ const PROVIDER_FETCH_CONFIG = {
45
45
  id: `openrouter/${m.id}`,
46
46
  name: m.name || m.id,
47
47
  contextLength: m.context_length ?? null,
48
+ // #218: real output ceiling (411/417 rows); clamps outputBudget -- see utils/model-output-limit.js.
49
+ maxOutputTokens: (m.top_provider && m.top_provider.max_completion_tokens) ?? null,
48
50
  pricing: m.pricing
49
51
  ? { prompt: m.pricing.prompt ?? null,
50
52
  completion: m.pricing.completion ?? null }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @module model-output-limit
3
+ * Issue #218 — the per-model `limit` descriptor amicus hands opencode.
4
+ *
5
+ * THE PROBLEM: every council leg reserved `max_tokens: 32000` regardless of the
6
+ * model's real ceiling. OpenRouter validates that RESERVATION against remaining
7
+ * credit BEFORE serving, so legs died in 2.2s with zero tokens and a literal
8
+ * "You requested up to 32000 tokens, but can only afford 354". amicus never set
9
+ * the value — `buildProviderModels` registered every model as `{}`, leaving
10
+ * opencode's own default to govern.
11
+ *
12
+ * WHERE 32000 COMES FROM (measured, in the pinned 1.18.15 binary, not inferred):
13
+ *
14
+ * var MY=32000
15
+ * function Hy($,Z=MY){return Math.min($.limit.output,Z)||Z}
16
+ *
17
+ * i.e. `ProviderTransform.maxOutputTokens(model) = Math.min(model.limit.output,
18
+ * OUTPUT_TOKEN_MAX)`. Three consequences, each a trap:
19
+ *
20
+ * 1. Supplying the model's REAL ceiling is ARITHMETICALLY INERT. kimi-k3's
21
+ * true ceiling is 943,718 and Math.min(943718, 32000) is still 32000. Only
22
+ * a value BELOW 32000 changes the outbound request. The issue's headline
23
+ * framing ("a 32,000 reservation against a 943,718 ceiling is arbitrary")
24
+ * reads as though feeding the real ceiling would help. It would not.
25
+ * 2. `limit.context` is MANDATORY whenever `limit` is present. A `limit` with
26
+ * only `output` is a hard ConfigInvalidError — and it poisons the ENTIRE
27
+ * config for the server's lifetime, not just that model. Measured against
28
+ * a live `opencode serve` + GET /config/providers.
29
+ * 3. `output: 0` is swallowed by the `|| Z` and falls back to 32000.
30
+ *
31
+ * WHAT THIS DOES NOT FIX. #218 conflates two modes that pull in OPPOSITE
32
+ * directions on this one knob. Mode 1 (credit rejection) needs the reservation
33
+ * LOWERED — that is what this module enables. Mode 2 (a leg spending its whole
34
+ * allowance on reasoning and emitting 0-2 output tokens) would need it RAISED,
35
+ * which the descriptor cannot do at all because of the Math.min; its real cause
36
+ * is reasoning effort, a knob amicus already owns (`sidecar/fanout.js` →
37
+ * `body.reasoning`). Lowering the budget makes those legs fail faster and
38
+ * cheaper. It does not make them produce output. No claim is made that it does.
39
+ *
40
+ * POLICY: opt-in, no default change. With no configured budget every model is
41
+ * still registered as `{}` — byte-identical to pre-#218 behaviour.
42
+ */
43
+
44
+ 'use strict';
45
+
46
+ /**
47
+ * A usable positive, finite INTEGER count. Rejects strings, booleans, NaN,
48
+ * Infinity, and anything that floors to zero.
49
+ *
50
+ * ⚠️ Order matters, and getting it wrong was council finding C2 on PR #221.
51
+ * Testing `v > 0` BEFORE flooring let 0.5 through and returned `Math.floor(0.5)`
52
+ * === 0 — breaking this function's own "positive integer, or null" contract.
53
+ * Downstream that was worse than a bad number: computeModelLimit's
54
+ * `Math.max(1, ...)` guard, which exists to stop `output: 0` reaching opencode,
55
+ * laundered the bogus 0 into a bogus `output: 1` — a ONE-TOKEN reservation on
56
+ * every leg. A hardening masking the very input it was meant to reject. Floor
57
+ * first, then test positivity, so a sub-1 value is rejected outright.
58
+ */
59
+ function positiveCount(v) {
60
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) { return null; }
61
+ const n = Math.floor(v);
62
+ return n > 0 ? n : null;
63
+ }
64
+
65
+ /**
66
+ * Coerce a configured output budget to a usable integer, or null.
67
+ * null means "emit no limit" — the opt-in default, and today's behaviour.
68
+ * @param {*} v raw `config.outputBudget`
69
+ * @returns {number|null}
70
+ */
71
+ function normalizeOutputBudget(v) {
72
+ // `typeof true === 'boolean'` and `'8000'` is a string: both rejected. A
73
+ // config knob is only honoured when it is unambiguously a number.
74
+ return positiveCount(v);
75
+ }
76
+
77
+ /**
78
+ * Index catalog rows by full route id for O(1) lookup during route walking.
79
+ * A Map (not an object) so a model id colliding with an Object.prototype member
80
+ * — 'constructor', 'toString' — cannot resolve to an inherited function. Same
81
+ * discipline as the `__proto__: null` alias table in config.js.
82
+ * @param {Array<{id:string, contextLength:?number, maxOutputTokens:?number}>} models
83
+ * @returns {Map<string, {contextLength:?number, maxOutputTokens:?number}>}
84
+ */
85
+ function buildLimitLookup(models) {
86
+ const out = new Map();
87
+ if (!Array.isArray(models)) { return out; }
88
+ for (const m of models) {
89
+ if (!m || typeof m !== 'object' || typeof m.id !== 'string') { continue; }
90
+ out.set(m.id, {
91
+ contextLength: m.contextLength ?? null,
92
+ maxOutputTokens: m.maxOutputTokens ?? null,
93
+ });
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * The `limit` descriptor for one model, or null when it must not be emitted.
100
+ *
101
+ * Returns null — meaning "register as `{}`, exactly as before" — whenever any
102
+ * input is missing or unusable. That is deliberate: a partial descriptor is not
103
+ * a partial improvement here, it is a fatal config error (rule 2 above).
104
+ *
105
+ * @param {?{contextLength:?number, maxOutputTokens:?number}} row catalog row
106
+ * @param {?number} budget normalized output budget
107
+ * @returns {?{context:number, output:number}}
108
+ */
109
+ function computeModelLimit(row, budget) {
110
+ const want = positiveCount(budget);
111
+ if (want === null || !row || typeof row !== 'object') { return null; }
112
+
113
+ const context = positiveCount(row.contextLength);
114
+ const ceiling = positiveCount(row.maxOutputTokens);
115
+ // Both or neither. Without a known ceiling a blanket budget would REGRESS a
116
+ // small model: an 8000 budget against a real 4096 ceiling sends an
117
+ // over-ceiling max_tokens, where today opencode's own Math.min keeps it
118
+ // correct. The ceiling is a hard requirement for clamping, not a nicety.
119
+ if (context === null || ceiling === null) { return null; }
120
+
121
+ return { context, output: Math.max(1, Math.min(ceiling, want)) };
122
+ }
123
+
124
+ module.exports = { normalizeOutputBudget, buildLimitLookup, computeModelLimit };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @module openrouter-credit
3
+ * The OpenRouter credit/limit probe, split out of api-key-validation.js to keep
4
+ * that module under the 300-line size gate — the same reason it was itself
5
+ * split out of api-key-store.js. Different concern, too: this asks what the
6
+ * ACCOUNT can afford, not whether the credential is accepted.
7
+ *
8
+ * Re-exported from api-key-validation.js so every existing call site keeps
9
+ * working unchanged.
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const https = require('https');
15
+
16
+ /** Warning string for a zero-credit OpenRouter key (paid models will 402). */
17
+ const OPENROUTER_NO_CREDIT_WARNING =
18
+ 'OpenRouter key has no remaining credit — paid models will fail (402). ' +
19
+ 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).';
20
+
21
+ /** Warning string for a free-tier OpenRouter key. */
22
+ const OPENROUTER_FREE_TIER_WARNING =
23
+ 'OpenRouter key is free tier — only :free models will route; paid models will fail (402). ' +
24
+ 'Add credit at openrouter.ai/credits to use paid models.';
25
+
26
+ /**
27
+ * Non-blocking credit/limit check for an OpenRouter key.
28
+ *
29
+ * Hits GET https://openrouter.ai/api/v1/key (returns limit, usage,
30
+ * is_free_tier, limit_remaining) and produces a WARNING — never an error —
31
+ * when is_free_tier is true or limit_remaining <= 0. Any failure (non-200,
32
+ * network error, malformed body) resolves with warning:null so setup is
33
+ * never blocked. Free-tier councils against free models are legitimate.
34
+ *
35
+ * @param {string} key OpenRouter API key
36
+ * @returns {Promise<{checked: boolean, warning: string|null, isFreeTier: boolean,
37
+ * limitRemaining: number|null, limit: number|null, usage: number|null}>}
38
+ * `checked` is false whenever no answer was obtained. Never infer health
39
+ * from `warning: null` alone — see the note on `none` below.
40
+ */
41
+ function checkOpenRouterCredit(key) {
42
+ // ⚠️ `checked: false` is the whole point. Every failure path below resolves
43
+ // THIS object, and `warning: null` is also what a perfectly healthy account
44
+ // resolves — so a caller branching on `warning` alone cannot tell "the
45
+ // account is fine" from "the probe never got an answer", and renders the
46
+ // first for the second. That is the false green the fourth council pass
47
+ // found still alive on the network-failure path after it had been fixed only
48
+ // for the gate-disabled one. Intent-to-probe and result-of-probe are
49
+ // different facts and now have different fields.
50
+ const none = {
51
+ checked: false,
52
+ warning: null, isFreeTier: false, limitRemaining: null, limit: null, usage: null
53
+ };
54
+ if (!key || key.trim().length === 0) {
55
+ return Promise.resolve(none);
56
+ }
57
+
58
+ const headers = { 'Authorization': `Bearer ${key.trim()}` };
59
+
60
+ return new Promise((resolve) => {
61
+ const req = https.get('https://openrouter.ai/api/v1/key', { headers }, (res) => {
62
+ let body = '';
63
+ // Same response-stream gap as validateApiKey (#224). `none` carries
64
+ // checked:false, so a mid-flight death reports "could not be checked"
65
+ // rather than falling through to "credit ok".
66
+ res.on('error', () => { resolve(none); });
67
+ res.on('data', (chunk) => { body += chunk; });
68
+ res.on('end', () => {
69
+ if (res.statusCode !== 200) { resolve(none); return; }
70
+ let data;
71
+ try {
72
+ data = (JSON.parse(body) || {}).data || {};
73
+ } catch (_e) {
74
+ resolve(none);
75
+ return;
76
+ }
77
+ const isFreeTier = data.is_free_tier === true;
78
+ const limitRemaining = (typeof data.limit_remaining === 'number')
79
+ ? data.limit_remaining : null;
80
+ const limit = (typeof data.limit === 'number') ? data.limit : null;
81
+ const usage = (typeof data.usage === 'number') ? data.usage : null;
82
+
83
+ let warning = null;
84
+ if (limitRemaining !== null && limitRemaining <= 0) {
85
+ warning = OPENROUTER_NO_CREDIT_WARNING;
86
+ } else if (isFreeTier) {
87
+ warning = OPENROUTER_FREE_TIER_WARNING;
88
+ }
89
+ resolve({ checked: true, warning, isFreeTier, limitRemaining, limit, usage });
90
+ });
91
+ });
92
+ req.setTimeout(10000, () => {
93
+ req.destroy();
94
+ resolve(none);
95
+ });
96
+ req.on('error', () => { resolve(none); });
97
+ });
98
+ }
99
+
100
+ module.exports = {
101
+ checkOpenRouterCredit,
102
+ OPENROUTER_NO_CREDIT_WARNING,
103
+ OPENROUTER_FREE_TIER_WARNING,
104
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @module utils/session-status
3
+ * #202: render the engine's SESSION STATUS as a clause on a leg's death report.
4
+ *
5
+ * ⚠️ The JSDoc leads this file, ahead of `'use strict'`, matching
6
+ * `utils/ttft.js` / `utils/text-sanitize.js` / `utils/engine-skew.js`:
7
+ * `scripts/generate-docs.js` only reads a block comment that starts at byte
8
+ * zero, so a `// path` line above it would leave this module's CLAUDE.md row
9
+ * blank.
10
+ *
11
+ * WHY THIS EXISTS. `headless.js` asks the engine for session status only inside
12
+ * `if (mirror.output.length > 0)` — a gate a zero-output leg never satisfies. So
13
+ * the one leg that needs diagnosing is precisely the one that never asks, and
14
+ * every silent death was reported as "no output in Ns" with no cause attached.
15
+ * The pinned SDK publishes `SessionStatus` as
16
+ * `{type:'idle'} | {type:'retry', attempt, message, next} | {type:'busy'}`, and
17
+ * the `retry` arm carries the upstream error verbatim.
18
+ *
19
+ * NONE of the three types is suppressed as uninteresting — they point in
20
+ * DIFFERENT directions, and which one comes back is the discrimination #202
21
+ * spent six CI runs failing to make by argument:
22
+ * · `busy` — the engine is still waiting on the provider ⇒ provider-side.
23
+ * · `idle` — the engine believes it is DONE having produced nothing ⇒
24
+ * engine-side, which is the shape #133 turned out to be.
25
+ * · `retry` — the engine is re-attempting, and says why ⇒ the named cause.
26
+ *
27
+ * ⚠️ APPEND-ONLY, exactly like `engine-skew.js :: formatSkewSuffix`: no status
28
+ * (or an unusable one) returns `''`, so a reason string built without one is
29
+ * byte-for-byte what it was before this module existed, and
30
+ * `sidecar/models-probe.js`'s `/^NO_OUTPUT_BACKSTOP:/` classification — a PREFIX
31
+ * test — is unaffected either way.
32
+ *
33
+ * ⚠️ `message` is UNTRUSTED third-party text: it originates at the provider,
34
+ * lands in run.json, and on CI is rendered into a sticky PR comment. It goes
35
+ * through the house sanitizer (`text-sanitize.js :: collapseExcerpt`) at a short
36
+ * cap rather than being trusted to the workflow's downstream sed rules — one
37
+ * sanitizer, one dialect, per that module's own ruling.
38
+ */
39
+
40
+ 'use strict';
41
+
42
+ const { collapseExcerpt } = require('./text-sanitize');
43
+
44
+ /** Short cap: this is a clause on a one-line death report, not a log dump. */
45
+ const MAX_STATUS_MESSAGE_CHARS = 200;
46
+
47
+ /**
48
+ * The death-report clause for an engine session status.
49
+ * @param {*} status - an SDK SessionStatus, or anything at all
50
+ * @returns {string} ` (session: …)`, or '' when nothing usable was observed
51
+ */
52
+ function formatSessionStatusSuffix(status) {
53
+ if (!status || typeof status !== 'object') { return ''; }
54
+ // A non-string `type` is DROPPED rather than coerced: `String({})` renders
55
+ // '[object Object]', which would read as an observation rather than as the
56
+ // absence it actually is.
57
+ if (typeof status.type !== 'string') { return ''; }
58
+ // ⚠️ CLASSIFY on the RAW value, RENDER the sanitized one (#219 round 2,
59
+ // deepseek). Branching on the sanitized type let the sanitizer's own
60
+ // normalisation decide the arm — anything collapsing to 'retry' took the retry
61
+ // path — so a future SDK identifier could be misclassified by a function whose
62
+ // job is display, not semantics. Only the exact published identifier routes.
63
+ const type = collapseExcerpt(status.type, 40);
64
+ if (!type) { return ''; }
65
+ // An unrecognised type is still reported. A future SDK arm must not read as
66
+ // "no status was observed" — that silence is what this clause removes.
67
+ if (status.type !== 'retry') { return ` (session: ${type})`; }
68
+ const attempt = Number.isFinite(status.attempt) ? ` attempt ${status.attempt}` : '';
69
+ const raw = collapseExcerpt(status.message, MAX_STATUS_MESSAGE_CHARS);
70
+ return ` (session: retry${attempt}${raw ? ` — ${raw}` : ''})`;
71
+ }
72
+
73
+ module.exports = { formatSessionStatusSuffix, MAX_STATUS_MESSAGE_CHARS };