amicus 4.9.2 → 4.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +324 -0
- package/README.md +1 -1
- package/bin/amicus.js +6 -0
- package/docs/ROADMAP.md +5 -4
- package/docs/architecture-map.md +732 -0
- package/docs/configuration.md +175 -1
- package/docs/council.md +9 -0
- package/docs/doc-system.md +12 -9
- package/docs/testing.md +2 -1
- package/docs/troubleshooting.md +76 -0
- package/docs/usage.md +14 -6
- 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/model-catalog.schema.json +2 -1
- package/schemas/run.schema.json +13 -0
- package/skills/sidecar/SKILL.md +1 -8
- package/src/cli-handlers-doctor.js +12 -16
- package/src/cli-handlers-fanout.js +10 -1
- package/src/cli-handlers-resume-continue.js +25 -0
- package/src/cli-handlers.js +17 -1
- package/src/cli.js +5 -8
- package/src/council/briefings-chair.js +4 -2
- package/src/council/run-assemble.js +7 -2
- package/src/council/run-retry-notes.js +21 -1
- package/src/council/run-stages.js +8 -1
- package/src/headless.js +125 -7
- package/src/mcp-server.js +26 -0
- package/src/mcp-tools.js +4 -4
- package/src/opencode-client.js +84 -8
- package/src/pack/pack-validate.js +3 -0
- package/src/session-manager.js +2 -2
- package/src/sidecar/continue.js +6 -1
- package/src/sidecar/conversation-mirror.js +35 -11
- package/src/sidecar/fanout-leg-fallback.js +1 -0
- package/src/sidecar/fanout-leg.js +10 -2
- package/src/sidecar/fanout.js +2 -2
- package/src/sidecar/interactive.js +31 -4
- package/src/sidecar/models-ceiling-line.js +72 -0
- package/src/sidecar/models.js +4 -2
- package/src/sidecar/reopen-notices.js +97 -0
- package/src/sidecar/reopen-spend.js +3 -2
- package/src/sidecar/resume.js +15 -2
- package/src/sidecar/session-finalize.js +4 -1
- package/src/sidecar/session-utils.js +5 -1
- package/src/sidecar/start-metadata.js +1 -1
- package/src/sidecar/start.js +10 -5
- package/src/utils/api-key-validation.js +183 -94
- package/src/utils/config.js +65 -2
- package/src/utils/curated-models.js +8 -8
- package/src/utils/degrade.js +7 -0
- package/src/utils/doctor-credit-check.js +61 -0
- package/src/utils/doctor-key-auth-check.js +271 -0
- package/src/utils/doctor-output-budget-check.js +198 -0
- package/src/utils/engine-output-flag.js +105 -0
- package/src/utils/engine-variants.js +298 -0
- package/src/utils/http-get.js +284 -0
- package/src/utils/live-probes.js +53 -0
- package/src/utils/model-catalog.js +36 -4
- package/src/utils/model-ceilings-modelsdev.js +230 -0
- package/src/utils/model-fetcher.js +14 -36
- package/src/utils/model-output-limit.js +132 -0
- package/src/utils/openrouter-credit.js +104 -0
- package/src/utils/output-length.js +90 -0
- package/src/utils/result-schema.js +7 -2
- package/src/utils/spend-ledger.js +5 -1
- package/src/utils/thinking-validators.js +27 -80
- package/src/utils/validators.js +2 -3
|
@@ -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
|
@@ -340,11 +340,33 @@ const LOCAL_REQUEST_TIMEOUT_MS = 300000;
|
|
|
340
340
|
* serve (fixed in v4.1.2). Aliases the user has overridden fall back to the
|
|
341
341
|
* prefix form, since no authored gateway route describes them.
|
|
342
342
|
* @param {string[]} [resolvedRoutes] executable model id(s) actually launched
|
|
343
|
+
* @param {number|null} [outputBudget] the budget to clamp with. `undefined` (every
|
|
344
|
+
* caller but startServer) reads config here; `null` means unset. #218 PR 3:
|
|
345
|
+
* opencode-client.js :: startServer reads config ONCE and hands the same value
|
|
346
|
+
* to this descriptor and to the engine flag, so a config write between two
|
|
347
|
+
* reads can no longer split the levers. Named mutant "PARAMIGNORED".
|
|
343
348
|
* @returns {object} e.g. { openrouter: { models: { "x-ai/grok-4.3": {}, ... } } } */
|
|
344
|
-
function buildProviderModels(resolvedRoutes = []) {
|
|
349
|
+
function buildProviderModels(resolvedRoutes = [], outputBudget) {
|
|
345
350
|
const aliases = getEffectiveAliases();
|
|
346
351
|
const providers = {};
|
|
347
352
|
|
|
353
|
+
// #218: opt-in per-model output budget. `budget === null` (the default) makes
|
|
354
|
+
// computeModelLimit return null for every model, so each descriptor stays `{}`
|
|
355
|
+
// exactly as before. The catalog read is lazy and failure-tolerant: no cache,
|
|
356
|
+
// a corrupt one, or rows predating the maxOutputTokens field all degrade to
|
|
357
|
+
// `{}` rather than to a PARTIAL limit -- a limit missing `context` is a fatal
|
|
358
|
+
// ConfigInvalidError that poisons the whole config, not a per-model degrade.
|
|
359
|
+
const { normalizeOutputBudget, buildLimitLookup, computeModelLimit } =
|
|
360
|
+
require('./model-output-limit');
|
|
361
|
+
const budget = normalizeOutputBudget(outputBudget === undefined ? getOutputBudget() : outputBudget);
|
|
362
|
+
let limits = new Map();
|
|
363
|
+
if (budget !== null) {
|
|
364
|
+
try {
|
|
365
|
+
const cache = require('./model-catalog').readCache();
|
|
366
|
+
limits = buildLimitLookup(cache && cache.models);
|
|
367
|
+
} catch (_e) { /* no catalog -> no limits -> unchanged behaviour */ }
|
|
368
|
+
}
|
|
369
|
+
|
|
348
370
|
const addRoute = (fullModel) => {
|
|
349
371
|
if (!fullModel || typeof fullModel !== 'string') { return; }
|
|
350
372
|
const parts = fullModel.split('/');
|
|
@@ -365,7 +387,23 @@ function buildProviderModels(resolvedRoutes = []) {
|
|
|
365
387
|
if (!Object.prototype.hasOwnProperty.call(providers, providerID)) {
|
|
366
388
|
providers[providerID] = { models: {} };
|
|
367
389
|
}
|
|
368
|
-
|
|
390
|
+
// #218 PR 2: direct `anthropic/*` is no longer held out (council #230 A1
|
|
391
|
+
// held it out until descriptor x thinking-budget was measured). Measured,
|
|
392
|
+
// probe rows K1/K2/K3/K4/K9/K10: the descriptor lowers the reservation on
|
|
393
|
+
// that route exactly as on OpenRouter (K1: 8000); a thinking variant's
|
|
394
|
+
// budget is ADDED on top of it (K2: 8000 + 16000 = 24000); and the sum is
|
|
395
|
+
// clamped to the model's real ceiling whatever the descriptor or flag said
|
|
396
|
+
// (K3/K4/K10: 64000 for haiku). No descriptor can push a thinking leg over
|
|
397
|
+
// the ceiling. #218 PR 4 now sends `--thinking` to the engine as its `variant`
|
|
398
|
+
// field, validated against the model's own declaration before any request,
|
|
399
|
+
// so these are the numbers a thinking leg now reserves: M2 is K2's
|
|
400
|
+
// arithmetic at the shipped budget (24000 + 16000 = 40000, refused rather
|
|
401
|
+
// than sent), and M17 is the fit (8000 + 16000 = 24000) that would need the
|
|
402
|
+
// variant's budget before the spawn.
|
|
403
|
+
// Named mutant "ANTHROPICHELDOUT" in tests/build-provider-models-output-limit.test.js.
|
|
404
|
+
// #218: `{}` unless a budget is set AND the catalog knows both numbers.
|
|
405
|
+
const limit = computeModelLimit(limits.get(fullModel), budget);
|
|
406
|
+
providers[providerID].models[modelID] = limit ? { limit } : {};
|
|
369
407
|
};
|
|
370
408
|
|
|
371
409
|
for (const [alias, fullModel] of Object.entries(aliases)) {
|
|
@@ -593,6 +631,30 @@ function resolveCouncilMembers(name, catalog = []) {
|
|
|
593
631
|
return { models, dropped, droppedMembers };
|
|
594
632
|
}
|
|
595
633
|
|
|
634
|
+
/**
|
|
635
|
+
* #218: the configured per-leg output budget, or null when unset.
|
|
636
|
+
*
|
|
637
|
+
* OPT-IN BY DESIGN — unset means "register every model as `{}` and set no
|
|
638
|
+
* engine flag", which is pre-#218 behaviour exactly. Set it and every leg
|
|
639
|
+
* reserves min(budget, the model's real ceiling) wherever a ceiling is known.
|
|
640
|
+
* This one value feeds BOTH levers so they can never disagree: the per-model
|
|
641
|
+
* `limit` descriptor (buildProviderModels, for routes the amicus catalog knows)
|
|
642
|
+
* and OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX (opencode-client.js :: startServer,
|
|
643
|
+
* set to the budget for every engine amicus starts and clamped by the engine's
|
|
644
|
+
* own catalog). Values above 32000 are LIVE since PR 2 (probe K6: 100000 on the
|
|
645
|
+
* wire); a model neither catalog knows receives the budget as-is (J2/K13).
|
|
646
|
+
*
|
|
647
|
+
* ⚠️ The descriptor half needs a catalog refreshed since #218 added
|
|
648
|
+
* `maxOutputTokens` (`amicus models --refresh`); the flag half needs nothing.
|
|
649
|
+
* `amicus doctor`'s `output-budget` row says which routes get which.
|
|
650
|
+
*
|
|
651
|
+
* @returns {number|null} positive integer, or null when unset/malformed
|
|
652
|
+
*/
|
|
653
|
+
function getOutputBudget() {
|
|
654
|
+
const config = loadConfig() || {};
|
|
655
|
+
return require('./model-output-limit').normalizeOutputBudget(config.outputBudget);
|
|
656
|
+
}
|
|
657
|
+
|
|
596
658
|
/** @returns {{prefer:'direct'|'openrouter', migration_notified:Object}} routing config with defaults */
|
|
597
659
|
function getRoutingConfig() {
|
|
598
660
|
const config = loadConfig() || {};
|
|
@@ -716,6 +778,7 @@ module.exports = {
|
|
|
716
778
|
classifyCouncilMembers,
|
|
717
779
|
resolveCouncilMembers,
|
|
718
780
|
getRoutingConfig,
|
|
781
|
+
getOutputBudget,
|
|
719
782
|
resolveGatewayMode,
|
|
720
783
|
markMigrationNotified,
|
|
721
784
|
COST_TIERS,
|
|
@@ -98,14 +98,14 @@ const CARDLESS = [
|
|
|
98
98
|
// (live smoke wave 47278069) — the entry was OpenRouter-only at authoring.
|
|
99
99
|
{ alias: 'fable', routes: { openrouter: 'openrouter/anthropic/claude-fable-5',
|
|
100
100
|
anthropic: 'anthropic/claude-fable-5' } },
|
|
101
|
-
// qwen/kimi refreshed 2026-08-26 (v4.9 W13)
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
{ alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.8-max' } },
|
|
101
|
+
// qwen/kimi refreshed 2026-08-26 (v4.9 W13); qwen again 2026-09-05, when
|
|
102
|
+
// OpenRouter and models.dev dropped the un-dated `qwen3.8-max` for the dated
|
|
103
|
+
// `qwen3.8-max-0902` (the #218 PR 2 probe caught it: F4 went silent). These are
|
|
104
|
+
// the FALLBACK FLOOR — a fork with no CI alias map resolves its bench here; the
|
|
105
|
+
// owner's machine and .github/amicus-ci-aliases.json (qwen3.8-27b) run newer ids.
|
|
106
|
+
// Cardless entries have no `idPattern`, so `models --check` only asks whether the
|
|
107
|
+
// OLD id still EXISTS (scripts/check-ci-alias-pins.js asks the other question).
|
|
108
|
+
{ alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.8-max-0902' } },
|
|
109
109
|
{ alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
|
|
110
110
|
{ alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
|
|
111
111
|
{ alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-medium-3-5' } },
|
package/src/utils/degrade.js
CHANGED
|
@@ -17,6 +17,13 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
|
17
17
|
'dropped-members', 'chair-skipped-cost-ceiling', 'chair-failed',
|
|
18
18
|
'thin-cross-review', 'debate-degraded', 'inexact-under-ceiling',
|
|
19
19
|
'stage1-retry',
|
|
20
|
+
// #218 PR 3: a Stage-1 review the provider cut at the max_tokens reservation
|
|
21
|
+
// (the leg's `finish` is 'length' and it still carried answer text). kind
|
|
22
|
+
// 'info' only -- the review is in the packet, nothing was lost, the exit code
|
|
23
|
+
// does not move; the chair just reads a review that ends where the
|
|
24
|
+
// reservation ended. A cut with NO answer text is a dead leg (leg.error
|
|
25
|
+
// starts `OUTPUT_LENGTH:`) and rides `dead-leg` like every other death.
|
|
26
|
+
'output-truncated',
|
|
20
27
|
// v4.9 task mode: a task run writes no reliability-ledger rows — announced as kind:'info'.
|
|
21
28
|
'ledger-skipped',
|
|
22
29
|
// v4.8: the seat<->leg join failed. THREE shapes, one channel: a launched seat whose wave
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module doctor-credit-check
|
|
3
|
+
* The `openrouter-credit` doctor row (#43), split out of
|
|
4
|
+
* src/cli-handlers-doctor.js to keep that file under the 300-line size gate
|
|
5
|
+
* (mirrors doctor-base-url-check.js / doctor-alias-check.js / doctor-key-auth-check.js).
|
|
6
|
+
*
|
|
7
|
+
* Warns, never errors: a zero-credit or free-tier key is a real constraint but
|
|
8
|
+
* not a broken install, and free councils against :free models are legitimate.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ "credit ok" MEANS CHECKED. checkOpenRouterCredit resolves `warning: null`
|
|
11
|
+
* for a healthy account, for a skipped probe, AND for every failure — so
|
|
12
|
+
* branching on `warning` alone reported a funded account for one nobody
|
|
13
|
+
* reached, concealing quota exhaustion behind a green row.
|
|
14
|
+
*
|
|
15
|
+
* Two flags separate the three cases, and both are required: `skipped` (we
|
|
16
|
+
* chose not to look) and `checked` (we looked and got an answer). Fixing only
|
|
17
|
+
* `skipped` left the false green alive on the network-failure path — which is
|
|
18
|
+
* exactly what the fourth council pass on PR 222 found, because the test
|
|
19
|
+
* pinning the fix ran with the gate CLOSED and could never enter the path it
|
|
20
|
+
* claimed to protect.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const ID = 'openrouter-credit';
|
|
26
|
+
const NAME = 'OpenRouter credit';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{readApiKeyValues: () => Object<string,string>,
|
|
30
|
+
* checkOpenRouterCredit: (key:string) => Promise<object>}} d
|
|
31
|
+
* @returns {Promise<{id,name,status,message,hint}>}
|
|
32
|
+
*/
|
|
33
|
+
async function evaluateOpenRouterCredit(d) {
|
|
34
|
+
|
|
35
|
+
const values = d.readApiKeyValues() || {};
|
|
36
|
+
const key = values.openrouter;
|
|
37
|
+
if (!key) {
|
|
38
|
+
return { id: ID, name: NAME, status: 'ok', message: 'no OpenRouter key — skipped', hint: null };
|
|
39
|
+
}
|
|
40
|
+
// Reuses the #38 non-blocking probe; resolves warning:null on any failure.
|
|
41
|
+
const res = (await d.checkOpenRouterCredit(key)) || {};
|
|
42
|
+
if (res.skipped) {
|
|
43
|
+
// Never 'ok': nothing was checked, so quota exhaustion or a free-tier cap
|
|
44
|
+
// would be concealed behind a green row (council review of PR 222).
|
|
45
|
+
return { id: ID, name: NAME, status: 'warn', message: 'credit not checked — live probes disabled', hint: 'Unset AMICUS_NO_NETWORK_PROBES, or run `amicus doctor` from the CLI.' };
|
|
46
|
+
}
|
|
47
|
+
// A probe that ran but got no answer (timeout, 5xx, socket reset, garbage
|
|
48
|
+
// body) is NOT evidence of a funded account. Distinct message from the
|
|
49
|
+
// skip above: one means "we chose not to look", this means "we looked and
|
|
50
|
+
// could not see".
|
|
51
|
+
if (res.checked !== true) {
|
|
52
|
+
return { id: ID, name: NAME, status: 'warn', message: 'credit could not be checked — the probe did not complete', hint: 'Transient: re-run `amicus doctor` when the network settles.' };
|
|
53
|
+
}
|
|
54
|
+
if (res.warning) {
|
|
55
|
+
return { id: ID, name: NAME, status: 'warn', message: res.warning, hint: 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).' };
|
|
56
|
+
}
|
|
57
|
+
const remaining = (typeof res.limitRemaining === 'number') ? ` ($${res.limitRemaining} remaining)` : '';
|
|
58
|
+
return { id: ID, name: NAME, status: 'ok', message: `credit ok${remaining}`, hint: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { evaluateOpenRouterCredit };
|