amicus 4.9.2 → 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.
@@ -17,6 +17,8 @@ const baseUrlCheck = require('./utils/doctor-base-url-check');
17
17
  // B3 (council review of PR 198, issue 195) — the 'aliases' check body,
18
18
  // including its --fix repair of fabricated bare ids. Same split rationale.
19
19
  const aliasCheck = require('./utils/doctor-alias-check');
20
+ const creditCheck = require('./utils/doctor-credit-check');
21
+ const keyAuthCheck = require('./utils/doctor-key-auth-check'); // #210 — 'keys' tests presence only; this re-validates.
20
22
 
21
23
  const { DEFAULT_MAX_AGE_MS: MAX_CATALOG_AGE_MS } = require('./utils/model-catalog'); // 24h — single source
22
24
 
@@ -32,7 +34,8 @@ function realDeps() {
32
34
  nodeVersion: process.version,
33
35
  readApiKeys: () => require('./utils/api-key-store').readApiKeys(),
34
36
  readApiKeyValues: () => require('./utils/api-key-store').readApiKeyValues(),
35
- checkOpenRouterCredit: (key) => require('./utils/api-key-validation').checkOpenRouterCredit(key),
37
+ checkOpenRouterCredit: (key) => keyAuthCheck.probeOpenRouterCredit(key), // #210 — same gate as validateApiKey
38
+ validateApiKey: (p, k) => keyAuthCheck.probeApiKey(p, k), // #210
36
39
  getCwd: () => process.cwd(),
37
40
  readProjectMarkers: (dir) => {
38
41
  const exists = (name) => { try { return fs.existsSync(path.join(dir, name)); } catch (_e) { return false; } };
@@ -141,6 +144,7 @@ async function runDoctorChecks(depsOverride = {}) {
141
144
  : { id: 'keys', name: 'API keys', status: 'error', message: 'no provider keys configured', hint: 'amicus key <provider> <key> (or run: amicus setup)' };
142
145
  }));
143
146
 
147
+ checks.push(await guardAsync('key-auth', 'API key auth', () => keyAuthCheck.evaluateKeyAuth(d))); // #210
144
148
  checks.push((() => {
145
149
  try {
146
150
  const model = d.resolveModel();
@@ -211,21 +215,10 @@ async function runDoctorChecks(depsOverride = {}) {
211
215
 
212
216
  checks.push(guard('session-metadata-tmp', 'Session metadata tmp files', () => metaSweep.evaluateSessionMetadataTmpSweep(d)));
213
217
 
214
- // #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
215
- checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
216
- const values = d.readApiKeyValues() || {};
217
- const key = values.openrouter;
218
- if (!key) {
219
- return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'ok', message: 'no OpenRouter key — skipped', hint: null };
220
- }
221
- // Reuses the #38 non-blocking probe; resolves warning:null on any failure.
222
- const res = (await d.checkOpenRouterCredit(key)) || {};
223
- if (res.warning) {
224
- return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'warn', message: res.warning, hint: 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).' };
225
- }
226
- const remaining = (typeof res.limitRemaining === 'number') ? ` ($${res.limitRemaining} remaining)` : '';
227
- return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'ok', message: `credit ok${remaining}`, hint: null };
228
- }));
218
+ // #43: OpenRouter credit/free-tier — warns (never errors); skipped when no
219
+ // key. Body in utils/doctor-credit-check.js (same split as the others).
220
+ checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit',
221
+ () => creditCheck.evaluateOpenRouterCredit(d)));
229
222
 
230
223
  // v4.2 §4.7 C8: configured local / OpenAI-compatible providers (Ollama, LM
231
224
  // Studio, vLLM, generic) — reachability only; warn, never error (a napping
@@ -176,7 +176,23 @@ async function handleKey(args) {
176
176
 
177
177
  console.log(`Validating ${provider} key...`);
178
178
  const validation = await validateApiKey(provider, keyArg);
179
- if (!validation.valid) {
179
+ // 401 is the only status that means "this credential is not accepted".
180
+ // Everything else — 403 (disabled API, quota, region/bot block), 429, any
181
+ // 5xx, a 404 from a moved endpoint, a Cloudflare 52x during an origin
182
+ // outage, or no status at all because the machine is offline — says
183
+ // something about the REQUEST, not the key. Refusing to save on those is
184
+ // the false ALARM the doctor classifier stopped raising.
185
+ //
186
+ // ⚠️ An ALLOWLIST of what blocks, deliberately. This was a blocklist of
187
+ // what does NOT block, which left every unenumerated status falling through
188
+ // to process.exit(1) while the comment above it claimed only 401 blocked —
189
+ // the code and the narrative disagreed, and the narrative was the nicer of
190
+ // the two. An allowlist cannot rot as new status codes appear.
191
+ const BLOCKS_SAVE = new Set([401]);
192
+ if (!validation.valid && !BLOCKS_SAVE.has(validation.status)) {
193
+ console.warn(`Warning: ${validation.error}`);
194
+ console.warn('Saving the key anyway — run `amicus doctor` to re-check it later.');
195
+ } else if (!validation.valid) {
180
196
  console.error(`Error: ${validation.error}`);
181
197
  process.exit(1);
182
198
  }
@@ -31,15 +31,123 @@ const VALIDATION_ENDPOINTS = {
31
31
  }
32
32
  };
33
33
 
34
- /** Validate an API key by making a test request to the provider's API */
34
+ /**
35
+ * A printable message from any throwable — Error, string, or object (#224).
36
+ *
37
+ * ⚠️ EVERY step is inside the try, including reading `.message`. `String(x)`
38
+ * THROWS for a null-prototype object ("Cannot convert object to primitive
39
+ * value") and for one whose toString throws, and a `message` getter can throw
40
+ * too. This runs synchronously inside the res.on('error') listener added for
41
+ * #224, so a throw here escapes the promise as an uncaught async exception —
42
+ * trading the crash that issue removed for a narrower one. Caught in the
43
+ * council review of PR 225; measured, not hypothesised.
44
+ *
45
+ * (A Symbol is NOT among the hazards: String(Symbol()) is well-defined. Only
46
+ * `'' + sym` throws, and this never does that.)
47
+ */
48
+ function messageOf(err) {
49
+ try {
50
+ if (err === null || err === undefined) { return ''; }
51
+ const m = err.message;
52
+ if (typeof m === 'string' && m.length > 0) { return m; }
53
+ return String(err);
54
+ } catch (_e) {
55
+ return '(unprintable error)';
56
+ }
57
+ }
58
+
59
+ const FORBIDDEN_MESSAGE =
60
+ 'Forbidden (403) — the request was rejected, but this may be a disabled API, '
61
+ + 'a quota, or a region/bot block rather than the key';
62
+
63
+ /**
64
+ * Strip every spelling of a secret out of a message before it can escape.
65
+ *
66
+ * The Google probe embeds the key in the URL as `?key=...`, so ANY error text
67
+ * that quotes the request URL quotes the key with it — Node's ERR_INVALID_URL
68
+ * and several socket errors do exactly that. Redacting here, at the source,
69
+ * is what protects the callers that never look: electron/ipc-setup.js returns
70
+ * `err.message` straight to the renderer AND logs it, and src/cli-handlers.js
71
+ * awaits this function with no try/catch at all. (Council finding 3, PR 221.)
72
+ *
73
+ * Two passes, in this order:
74
+ * 1. Mask `key=` / `api_key=` / `access_token=` query parameters
75
+ * structurally. This is what actually protects the Google probe, and it
76
+ * works for a key of ANY length.
77
+ * 2. Remove the key's own spellings — raw, percent-encoded, and the
78
+ * form-encoded `+`-for-space variant — but only when the key is at least
79
+ * 8 characters. Blind substring replacement of a 1-2 character key
80
+ * rewrote unrelated diagnostics (every "a" in a DNS error became ***)
81
+ * while protecting nothing real. Council review of PR 222.
82
+ */
83
+ function redactSecret(text, key) {
84
+ // Coerce, don't discard. This returned '' for anything that was not a
85
+ // string, so a thrown string or a non-Error object (`throw 'boom'`) turned
86
+ // a diagnostic into a blank message — the caller then reported a failure it
87
+ // could not describe. Issue #224.
88
+ // Same hazard as messageOf: this is exported and callers may hand it
89
+ // anything, so the coercion cannot be allowed to throw (council review of
90
+ // PR 225).
91
+ let raw;
92
+ if (typeof text === 'string') {
93
+ raw = text;
94
+ } else if (text === null || text === undefined) {
95
+ raw = '';
96
+ } else {
97
+ try { raw = String(text); } catch (_e) { raw = '(unprintable error)'; }
98
+ }
99
+ if (raw.length === 0) { return ''; }
100
+ if (!key) { return raw; }
101
+ const text_ = raw;
102
+
103
+ // 1. Mask the query parameter STRUCTURALLY, before touching the key at all.
104
+ // This is what actually protects the Google probe (`?key=...`) and it
105
+ // works for a key of any length, including ones too short to substring-
106
+ // match safely.
107
+ let out = text_.replace(/([?&](?:key|api_key|access_token)=)[^&\s"')]+/gi, '$1***');
108
+
109
+ // 2. Then remove the key itself — but only when it is long enough that a
110
+ // substring match means something. Blind replacement of a 1-2 character
111
+ // key rewrote unrelated text ("a" -> "***" across a whole DNS error),
112
+ // which corrupts diagnostics without protecting anything real. Council
113
+ // review of PR 222.
114
+ const MIN_SUBSTRING_LEN = 8;
115
+ if (key.length >= MIN_SUBSTRING_LEN) {
116
+ const spellings = new Set([key]);
117
+ try {
118
+ const encoded = encodeURIComponent(key);
119
+ spellings.add(encoded);
120
+ // application/x-www-form-urlencoded spells a space '+', not '%20'.
121
+ spellings.add(encoded.replace(/%20/g, '+'));
122
+ } catch (_e) { /* un-encodable key: the raw spelling below still runs */ }
123
+ for (const s of spellings) {
124
+ if (s) { out = out.split(s).join('***'); }
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * Validate an API key by making a test request to the provider's API.
132
+ *
133
+ * ALWAYS RESOLVES — never rejects, even on a synchronous throw from https.get
134
+ * (see redactSecret). Two of the three call sites have no catch.
135
+ *
136
+ * @returns {Promise<{valid: boolean, status: number|null, error?: string}>}
137
+ * `status` is the HTTP status, or null when no response was received. It is
138
+ * returned as DATA so callers never have to recover it by parsing `error`
139
+ * (council finding 2): that coupled control flow to message wording, and a
140
+ * reword that dropped the parentheses would have silently degraded detection
141
+ * to a permanent "unverified" with nothing failing to announce it.
142
+ */
35
143
  function validateApiKey(provider, key) {
36
144
  if (!key || key.trim().length === 0) {
37
- return Promise.resolve({ valid: false, error: 'API key is required' });
145
+ return Promise.resolve({ valid: false, status: null, error: 'API key is required' });
38
146
  }
39
147
 
40
148
  const endpoint = VALIDATION_ENDPOINTS[provider];
41
149
  if (!endpoint) {
42
- return Promise.resolve({ valid: false, error: `Unknown provider: ${provider}` });
150
+ return Promise.resolve({ valid: false, status: null, error: `Unknown provider: ${provider}` });
43
151
  }
44
152
 
45
153
  const trimmedKey = key.trim();
@@ -53,114 +161,95 @@ function validateApiKey(provider, key) {
53
161
  const headers = endpoint.authHeader(trimmedKey);
54
162
 
55
163
  return new Promise((resolve) => {
56
- const req = https.get(url, { headers }, (res) => {
57
- res.on('data', () => {});
58
- res.on('end', () => {
59
- // Anthropic returns 401 for invalid key, 400/405 for valid key with no body
60
- if (provider === 'anthropic') {
61
- if (res.statusCode === 401) {
62
- resolve({ valid: false, error: 'Invalid API key (401)' });
63
- } else if (res.statusCode >= 500 || res.statusCode === 429) {
64
- resolve({ valid: false, error: `Server error (${res.statusCode})` });
164
+ let req;
165
+ try {
166
+ req = https.get(url, { headers }, (res) => {
167
+ const code = res.statusCode;
168
+ // ⚠️ req.on('error') below covers the CONNECTION phase only. An error
169
+ // once `res` exists a socket reset mid-body, a TLS failure after
170
+ // headers is an 'error' event on this emitter, and an unhandled one
171
+ // is a THROW, not a rejection: the promise never settles and the
172
+ // process dies. Two of the three callers have no protection against
173
+ // that (src/cli-handlers.js has no try/catch; electron/ipc-setup.js
174
+ // has one, but the throw lands on a later tick outside it). Issue #224.
175
+ res.on('error', (err) => {
176
+ resolve({ valid: false, status: null, error: redactSecret(messageOf(err), trimmedKey) });
177
+ });
178
+ res.on('data', () => {});
179
+ res.on('end', () => {
180
+ // Anthropic: the probe is a GET against /v1/messages, so in practice
181
+ // a WORKING key answers with a method/shape complaint (400/404/405).
182
+ // 200 is allowed too — defensively, in case the endpoint ever answers
183
+ // one — which is why it appears in the allowlist below. The earlier
184
+ // wording said "never 200" while listing 200 as success; the comment
185
+ // and the code disagreed (council review of PR 222).
186
+ //
187
+ // ⚠️ This used to be `else { valid: true }` — every code that was not
188
+ // 401/429/5xx passed, INCLUDING 403. A region block or WAF therefore
189
+ // reported the key as good: a false GREEN, inside the very function
190
+ // rewritten to stop treating 403 as a verdict, and the exact failure
191
+ // class #210 exists to kill. Council review of PR 222 caught it.
192
+ // Success is an ALLOWLIST now; anything unlisted is not-valid.
193
+ if (provider === 'anthropic') {
194
+ if (code === 401) {
195
+ resolve({ valid: false, status: code, error: 'Invalid API key (401)' });
196
+ } else if (code === 403) {
197
+ resolve({ valid: false, status: code, error: FORBIDDEN_MESSAGE });
198
+ } else if (code >= 500 || code === 429) {
199
+ resolve({ valid: false, status: code, error: `Server error (${code})` });
200
+ } else if (code === 200 || code === 400 || code === 404 || code === 405) {
201
+ resolve({ valid: true, status: code });
202
+ } else {
203
+ resolve({ valid: false, status: code, error: `Unexpected response (${code})` });
204
+ }
205
+ return;
206
+ }
207
+
208
+ if (code === 200) {
209
+ resolve({ valid: true, status: code });
210
+ } else if (code === 401) {
211
+ resolve({ valid: false, status: code, error: 'Invalid API key (401)' });
212
+ } else if (code === 403) {
213
+ // NOT stated as a bad key (council finding 1). Google returns 403
214
+ // for "API not enabled" and for quota; a WAF returns it for bot
215
+ // protection. Calling that an invalid credential sends someone off
216
+ // to re-enter a key that works — the false-ALARM twin of the
217
+ // false-GREEN this endpoint exists to catch. Callers decide.
218
+ resolve({ valid: false, status: code, error: FORBIDDEN_MESSAGE });
65
219
  } else {
66
- resolve({ valid: true });
220
+ resolve({ valid: false, status: code, error: `Unexpected response (${code})` });
67
221
  }
68
- return;
69
- }
70
-
71
- if (res.statusCode === 200) {
72
- resolve({ valid: true });
73
- } else if (res.statusCode === 401 || res.statusCode === 403) {
74
- resolve({ valid: false, error: `Invalid API key (${res.statusCode})` });
75
- } else {
76
- resolve({ valid: false, error: `Unexpected response (${res.statusCode})` });
77
- }
222
+ });
78
223
  });
79
- });
224
+ } catch (err) {
225
+ // Synchronous throw (ERR_INVALID_URL and friends). Its message quotes the
226
+ // request URL — key included for Google. Redact, resolve, never reject.
227
+ resolve({ valid: false, status: null, error: redactSecret(messageOf(err), trimmedKey) });
228
+ return;
229
+ }
80
230
  req.setTimeout(10000, () => {
81
231
  req.destroy();
82
- resolve({ valid: false, error: 'Request timed out' });
232
+ resolve({ valid: false, status: null, error: 'Request timed out' });
83
233
  });
84
234
  req.on('error', (err) => {
85
- resolve({ valid: false, error: err.message });
235
+ resolve({ valid: false, status: null, error: redactSecret(messageOf(err), trimmedKey) });
86
236
  });
87
237
  });
88
238
  }
89
239
 
90
- /** Warning string for a zero-credit OpenRouter key (paid models will 402). */
91
- const OPENROUTER_NO_CREDIT_WARNING =
92
- 'OpenRouter key has no remaining credit — paid models will fail (402). ' +
93
- 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).';
94
-
95
- /** Warning string for a free-tier OpenRouter key. */
96
- const OPENROUTER_FREE_TIER_WARNING =
97
- 'OpenRouter key is free tier — only :free models will route; paid models will fail (402). ' +
98
- 'Add credit at openrouter.ai/credits to use paid models.';
99
-
100
- /**
101
- * Non-blocking credit/limit check for an OpenRouter key.
102
- *
103
- * Hits GET https://openrouter.ai/api/v1/key (returns limit, usage,
104
- * is_free_tier, limit_remaining) and produces a WARNING — never an error —
105
- * when is_free_tier is true or limit_remaining <= 0. Any failure (non-200,
106
- * network error, malformed body) resolves with warning:null so setup is
107
- * never blocked. Free-tier councils against free models are legitimate.
108
- *
109
- * @param {string} key OpenRouter API key
110
- * @returns {Promise<{warning: string|null, isFreeTier: boolean,
111
- * limitRemaining: number|null, limit: number|null, usage: number|null}>}
112
- */
113
- function checkOpenRouterCredit(key) {
114
- const none = {
115
- warning: null, isFreeTier: false, limitRemaining: null, limit: null, usage: null
116
- };
117
- if (!key || key.trim().length === 0) {
118
- return Promise.resolve(none);
119
- }
120
-
121
- const headers = { 'Authorization': `Bearer ${key.trim()}` };
122
-
123
- return new Promise((resolve) => {
124
- const req = https.get('https://openrouter.ai/api/v1/key', { headers }, (res) => {
125
- let body = '';
126
- res.on('data', (chunk) => { body += chunk; });
127
- res.on('end', () => {
128
- if (res.statusCode !== 200) { resolve(none); return; }
129
- let data;
130
- try {
131
- data = (JSON.parse(body) || {}).data || {};
132
- } catch (_e) {
133
- resolve(none);
134
- return;
135
- }
136
- const isFreeTier = data.is_free_tier === true;
137
- const limitRemaining = (typeof data.limit_remaining === 'number')
138
- ? data.limit_remaining : null;
139
- const limit = (typeof data.limit === 'number') ? data.limit : null;
140
- const usage = (typeof data.usage === 'number') ? data.usage : null;
141
-
142
- let warning = null;
143
- if (limitRemaining !== null && limitRemaining <= 0) {
144
- warning = OPENROUTER_NO_CREDIT_WARNING;
145
- } else if (isFreeTier) {
146
- warning = OPENROUTER_FREE_TIER_WARNING;
147
- }
148
- resolve({ warning, isFreeTier, limitRemaining, limit, usage });
149
- });
150
- });
151
- req.setTimeout(10000, () => {
152
- req.destroy();
153
- resolve(none);
154
- });
155
- req.on('error', () => { resolve(none); });
156
- });
157
- }
240
+ // The OpenRouter credit probe lives in utils/openrouter-credit.js (same
241
+ // size-gate split rationale as this file's own header). Re-exported below so
242
+ // existing call sites are unaffected.
243
+ const {
244
+ checkOpenRouterCredit, OPENROUTER_NO_CREDIT_WARNING, OPENROUTER_FREE_TIER_WARNING,
245
+ } = require('./openrouter-credit');
158
246
 
159
247
  // Backwards compat alias
160
248
  const validateOpenRouterKey = validateApiKey;
161
249
 
162
250
  module.exports = {
163
251
  validateApiKey,
252
+ redactSecret,
164
253
  validateOpenRouterKey,
165
254
  checkOpenRouterCredit,
166
255
  OPENROUTER_NO_CREDIT_WARNING,
@@ -345,6 +345,23 @@ function buildProviderModels(resolvedRoutes = []) {
345
345
  const aliases = getEffectiveAliases();
346
346
  const providers = {};
347
347
 
348
+ // #218: opt-in per-model output budget. `budget === null` (the default) makes
349
+ // computeModelLimit return null for every model, so each descriptor stays `{}`
350
+ // exactly as before. The catalog read is lazy and failure-tolerant: no cache,
351
+ // a corrupt one, or rows predating the maxOutputTokens field all degrade to
352
+ // `{}` rather than to a PARTIAL limit -- a limit missing `context` is a fatal
353
+ // ConfigInvalidError that poisons the whole config, not a per-model degrade.
354
+ const { normalizeOutputBudget, buildLimitLookup, computeModelLimit } =
355
+ require('./model-output-limit');
356
+ const budget = normalizeOutputBudget(getOutputBudget());
357
+ let limits = new Map();
358
+ if (budget !== null) {
359
+ try {
360
+ const cache = require('./model-catalog').readCache();
361
+ limits = buildLimitLookup(cache && cache.models);
362
+ } catch (_e) { /* no catalog -> no limits -> unchanged behaviour */ }
363
+ }
364
+
348
365
  const addRoute = (fullModel) => {
349
366
  if (!fullModel || typeof fullModel !== 'string') { return; }
350
367
  const parts = fullModel.split('/');
@@ -365,7 +382,9 @@ function buildProviderModels(resolvedRoutes = []) {
365
382
  if (!Object.prototype.hasOwnProperty.call(providers, providerID)) {
366
383
  providers[providerID] = { models: {} };
367
384
  }
368
- providers[providerID].models[modelID] = {};
385
+ // #218: `{}` unless a budget is set AND the catalog knows both numbers.
386
+ const limit = computeModelLimit(limits.get(fullModel), budget);
387
+ providers[providerID].models[modelID] = limit ? { limit } : {};
369
388
  };
370
389
 
371
390
  for (const [alias, fullModel] of Object.entries(aliases)) {
@@ -593,6 +612,28 @@ function resolveCouncilMembers(name, catalog = []) {
593
612
  return { models, dropped, droppedMembers };
594
613
  }
595
614
 
615
+ /**
616
+ * #218: the configured per-leg output budget, or null when unset.
617
+ *
618
+ * OPT-IN BY DESIGN — unset means "register every model as `{}`", which is
619
+ * pre-#218 behaviour exactly. Set it and each leg reserves min(budget, the
620
+ * model's real ceiling) instead of opencode's fixed 32000 default.
621
+ *
622
+ * ⚠️ Values >= 32000 are ACCEPTED but INERT: opencode computes
623
+ * `Math.min(limit.output, 32000)` (measured in the 1.18.15 binary), so the
624
+ * reservation can only be lowered here, never raised. Raising it at all
625
+ * requires OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX, a different lever.
626
+ *
627
+ * ⚠️ Requires a catalog refreshed since #218 added `maxOutputTokens`
628
+ * (`amicus models --refresh`); older rows have no ceiling and stay `{}`.
629
+ *
630
+ * @returns {number|null} positive integer, or null when unset/malformed
631
+ */
632
+ function getOutputBudget() {
633
+ const config = loadConfig() || {};
634
+ return require('./model-output-limit').normalizeOutputBudget(config.outputBudget);
635
+ }
636
+
596
637
  /** @returns {{prefer:'direct'|'openrouter', migration_notified:Object}} routing config with defaults */
597
638
  function getRoutingConfig() {
598
639
  const config = loadConfig() || {};
@@ -716,6 +757,7 @@ module.exports = {
716
757
  classifyCouncilMembers,
717
758
  resolveCouncilMembers,
718
759
  getRoutingConfig,
760
+ getOutputBudget,
719
761
  resolveGatewayMode,
720
762
  markMigrationNotified,
721
763
  COST_TIERS,
@@ -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 };