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
|
@@ -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 };
|