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.
Files changed (72) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +324 -0
  3. package/README.md +1 -1
  4. package/bin/amicus.js +6 -0
  5. package/docs/ROADMAP.md +5 -4
  6. package/docs/architecture-map.md +732 -0
  7. package/docs/configuration.md +175 -1
  8. package/docs/council.md +9 -0
  9. package/docs/doc-system.md +12 -9
  10. package/docs/testing.md +2 -1
  11. package/docs/troubleshooting.md +76 -0
  12. package/docs/usage.md +14 -6
  13. package/electron/main.js +25 -2
  14. package/electron/setup-ui-alias-groups.js +161 -0
  15. package/electron/setup-ui-alias-script.js +70 -4
  16. package/electron/setup-ui-aliases.js +25 -21
  17. package/electron/setup-ui.js +11 -1
  18. package/package.json +1 -1
  19. package/schemas/model-catalog.schema.json +2 -1
  20. package/schemas/run.schema.json +13 -0
  21. package/skills/sidecar/SKILL.md +1 -8
  22. package/src/cli-handlers-doctor.js +12 -16
  23. package/src/cli-handlers-fanout.js +10 -1
  24. package/src/cli-handlers-resume-continue.js +25 -0
  25. package/src/cli-handlers.js +17 -1
  26. package/src/cli.js +5 -8
  27. package/src/council/briefings-chair.js +4 -2
  28. package/src/council/run-assemble.js +7 -2
  29. package/src/council/run-retry-notes.js +21 -1
  30. package/src/council/run-stages.js +8 -1
  31. package/src/headless.js +125 -7
  32. package/src/mcp-server.js +26 -0
  33. package/src/mcp-tools.js +4 -4
  34. package/src/opencode-client.js +84 -8
  35. package/src/pack/pack-validate.js +3 -0
  36. package/src/session-manager.js +2 -2
  37. package/src/sidecar/continue.js +6 -1
  38. package/src/sidecar/conversation-mirror.js +35 -11
  39. package/src/sidecar/fanout-leg-fallback.js +1 -0
  40. package/src/sidecar/fanout-leg.js +10 -2
  41. package/src/sidecar/fanout.js +2 -2
  42. package/src/sidecar/interactive.js +31 -4
  43. package/src/sidecar/models-ceiling-line.js +72 -0
  44. package/src/sidecar/models.js +4 -2
  45. package/src/sidecar/reopen-notices.js +97 -0
  46. package/src/sidecar/reopen-spend.js +3 -2
  47. package/src/sidecar/resume.js +15 -2
  48. package/src/sidecar/session-finalize.js +4 -1
  49. package/src/sidecar/session-utils.js +5 -1
  50. package/src/sidecar/start-metadata.js +1 -1
  51. package/src/sidecar/start.js +10 -5
  52. package/src/utils/api-key-validation.js +183 -94
  53. package/src/utils/config.js +65 -2
  54. package/src/utils/curated-models.js +8 -8
  55. package/src/utils/degrade.js +7 -0
  56. package/src/utils/doctor-credit-check.js +61 -0
  57. package/src/utils/doctor-key-auth-check.js +271 -0
  58. package/src/utils/doctor-output-budget-check.js +198 -0
  59. package/src/utils/engine-output-flag.js +105 -0
  60. package/src/utils/engine-variants.js +298 -0
  61. package/src/utils/http-get.js +284 -0
  62. package/src/utils/live-probes.js +53 -0
  63. package/src/utils/model-catalog.js +36 -4
  64. package/src/utils/model-ceilings-modelsdev.js +230 -0
  65. package/src/utils/model-fetcher.js +14 -36
  66. package/src/utils/model-output-limit.js +132 -0
  67. package/src/utils/openrouter-credit.js +104 -0
  68. package/src/utils/output-length.js +90 -0
  69. package/src/utils/result-schema.js +7 -2
  70. package/src/utils/spend-ledger.js +5 -1
  71. package/src/utils/thinking-validators.js +27 -80
  72. package/src/utils/validators.js +2 -3
@@ -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 };
@@ -20,6 +20,10 @@ const path = require('path');
20
20
  function _getConfigDir() { return require('./config').getConfigDir(); }
21
21
  function _readApiKeyValues() { return require('./api-key-store').readApiKeyValues(); }
22
22
  async function _fetchAllModels(keys) { return require('./model-fetcher').fetchAllModelsDetailed(keys); }
23
+ async function _enrichCeilings(rows) { return require('./model-ceilings-modelsdev').enrichCeilings(rows); }
24
+ function _emptyOutcome(failure) { return require('./model-ceilings-modelsdev').emptyOutcome(failure); }
25
+ /** #218 P3 opt-out: `modelsDevCeilings: false` in config.json, and ONLY a literal false. */
26
+ function _modelsDevEnabled() { return require('./config').loadConfig()?.modelsDevCeilings !== false; }
23
27
 
24
28
  const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
25
29
  const CATALOG_SCHEMA_VERSION = 2;
@@ -68,8 +72,13 @@ function writeCacheDoc(doc) {
68
72
  }
69
73
  }
70
74
 
71
- /** Write a successful fetch: fresh models/fetchedAt, outcome fields cleared. @param {Array} models */
72
- function writeCache(models, providerFailures) {
75
+ /**
76
+ * Write a successful fetch: fresh models/fetchedAt, outcome fields cleared.
77
+ * @param {Array} models
78
+ * @param {Array} [providerFailures]
79
+ * @param {object|null} [ceilingEnrichment] #218 P3 outcome for THESE rows (model-ceilings-modelsdev.js)
80
+ */
81
+ function writeCache(models, providerFailures, ceilingEnrichment) {
73
82
  writeCacheDoc({
74
83
  schemaVersion: CATALOG_SCHEMA_VERSION,
75
84
  fetchedAt: Date.now(),
@@ -78,6 +87,7 @@ function writeCache(models, providerFailures) {
78
87
  // alongside the rows because it describes THESE rows -- a cache served later
79
88
  // is still a catalog whose deepseek namespace is empty for a reason.
80
89
  providerFailures: Array.isArray(providerFailures) ? providerFailures : [],
90
+ ceilingEnrichment: ceilingEnrichment || null,
81
91
  });
82
92
  }
83
93
 
@@ -124,7 +134,27 @@ async function refreshCatalog() {
124
134
  writeRefreshFailure(reason, providerFailures);
125
135
  return [];
126
136
  }
127
- writeCache(models, providerFailures);
137
+ // #218 P3: fill direct-provider ceilings from models.dev AFTER the floor-only
138
+ // check (a failed refresh is never enriched, so "stale cache stands" holds)
139
+ // and IN PLACE on the fresh row objects, so `authoritative`/`local` ride
140
+ // through untouched. enrichCeilings never rejects; the belt-and-braces catch
141
+ // keeps a bug there from failing a refresh that already succeeded.
142
+ // Council #230 D1/C2: `modelsDevCeilings: false` means models.dev is never
143
+ // contacted. The persisted outcome still carries the full counter set, so
144
+ // `--json` readers and the `Ceilings:` line see "disabled", not a blank.
145
+ let ceilingEnrichment;
146
+ if (!_modelsDevEnabled()) {
147
+ ceilingEnrichment = { ..._emptyOutcome(null), skipped: 'disabled' };
148
+ } else {
149
+ try {
150
+ ceilingEnrichment = await _enrichCeilings(models);
151
+ } catch (err) {
152
+ // Same shape enrichCeilings' own failures use (council #230 C4/D5), so every
153
+ // reader of ceilingEnrichment sees the full counter set however it failed.
154
+ ceilingEnrichment = _emptyOutcome({ reason: 'exception', detail: err.message });
155
+ }
156
+ }
157
+ writeCache(models, providerFailures, ceilingEnrichment);
128
158
  return models;
129
159
  }
130
160
 
@@ -154,7 +184,7 @@ async function getCatalog(opts = {}) {
154
184
  * #13: also threads the last-refresh outcome so callers can tell "current"
155
185
  * apart from "stale because refreshing keeps failing" — null/null when the
156
186
  * last attempt on record succeeded (or none has happened yet).
157
- * @returns {Promise<{models: Array, fetchedAt: number|null, lastRefreshAttempt: number|null, lastRefreshError: string|null}>}
187
+ * @returns {Promise<{models: Array, fetchedAt: number|null, lastRefreshAttempt: number|null, lastRefreshError: string|null, providerFailures: Array, ceilingEnrichment: object|null}>}
158
188
  */
159
189
  async function getCatalogInfo(opts = {}) {
160
190
  const models = await getCatalog(opts);
@@ -167,6 +197,8 @@ async function getCatalogInfo(opts = {}) {
167
197
  lastRefreshError: (doc && doc.lastRefreshError) || null,
168
198
  // #209: namespace-level fetch outcomes for the CACHED rows above.
169
199
  providerFailures: (doc && Array.isArray(doc.providerFailures)) ? doc.providerFailures : [],
200
+ // #218 P3: where the direct-provider ceilings came from, or why they did not.
201
+ ceilingEnrichment: (doc && doc.ceilingEnrichment) || null,
170
202
  };
171
203
  }
172
204
 
@@ -0,0 +1,230 @@
1
+ /**
2
+ * @module model-ceilings-modelsdev
3
+ * #218 P3 — output ceilings for the direct-provider catalog rows.
4
+ *
5
+ * WHY: computeModelLimit (model-output-limit.js) refuses to emit a `limit`
6
+ * descriptor unless the catalog knows BOTH a model's context and its output
7
+ * ceiling — a blanket budget against an unknown ceiling would send an
8
+ * over-ceiling max_tokens. OpenRouter publishes its ceiling on /models; the
9
+ * direct openai/anthropic/deepseek lists do not (google's does, lifted
10
+ * first-party in model-fetcher.js). models.dev publishes all of them, keyless.
11
+ *
12
+ * WHAT THIS DOES NOT CHANGE: the engine already resolves every `{}` descriptor's
13
+ * limit from its own models.dev copy. This gives AMICUS the same numbers so it
14
+ * can clamp an outputBudget on the direct anthropic/deepseek routes through the
15
+ * descriptor (a route it cannot clamp still gets the budget through the engine
16
+ * flag — engine-output-flag.js) and name a reservation in a dead-leg note. The
17
+ * direct openai route is the exception: the engine drives that provider through
18
+ * the Responses API, whose request carries no output-limit field at all, so the
19
+ * ceiling filled here shows in `amicus models` but neither the descriptor nor
20
+ * the flag reaches the wire there — a direct openai row sends no output
21
+ * reservation at all (#218 PR 4, probe M5/M13/M22). It reads models.dev
22
+ * directly, not the engine's cache file, because that file's path and refresh
23
+ * flags are engine-private.
24
+ *
25
+ * RULES (measured 2026-09-04 against live data, see the plan):
26
+ * - the provider's own value WINS: models.dev fills a field only when the
27
+ * provider gave no usable positive integer — null, 0, negative or malformed
28
+ * — and a usable provider value is never overwritten (OpenRouter and
29
+ * models.dev disagree on 24 of 344 openrouter ceilings);
30
+ * - a zero/absent models.dev limit is never written (openai image rows);
31
+ * - `openrouter/openrouter/*` meta-routers are skipped (models.dev says
32
+ * 2,000,000 for `auto`, a number no underlying model honours);
33
+ * - local rows are skipped; the fill is IN PLACE so `authoritative`/`local`
34
+ * flags on the row objects ride through untouched;
35
+ * - the FETCH ITSELF is skipped when no candidate row is missing a ceiling,
36
+ * and `model-catalog.js` skips this module entirely when the config key
37
+ * `modelsDevCeilings` is `false` (council #230 D1/C2): a refresh that has
38
+ * nothing to fill must not spend up to 10 s asking.
39
+ */
40
+ 'use strict';
41
+
42
+ const { positiveCount } = require('./model-output-limit');
43
+
44
+ const MODELS_DEV_URL = 'https://models.dev/api.json';
45
+ const MODELS_DEV_TIMEOUT_MS = 10000;
46
+ /** The vendors amicus catalogs under these exact id prefixes (model-fetcher.js normalizers). */
47
+ const VENDORS = ['anthropic', 'openai', 'google', 'deepseek', 'openrouter'];
48
+
49
+ /**
50
+ * Index a models.dev api.json document by amicus catalog id. A positive
51
+ * finite integer count, or null — reuses `positiveCount` from
52
+ * model-output-limit.js so this module holds the same discipline as that one.
53
+ * @param {*} api parsed https://models.dev/api.json
54
+ * @returns {Map<string, {context: number|null, output: number|null}>}
55
+ */
56
+ function limitsFromModelsDev(api) {
57
+ const out = new Map();
58
+ if (!api || typeof api !== 'object') { return out; }
59
+ for (const vendor of VENDORS) {
60
+ const models = api[vendor] && api[vendor].models;
61
+ if (!models || typeof models !== 'object') { continue; }
62
+ for (const [modelId, m] of Object.entries(models)) {
63
+ const limit = (m && typeof m === 'object' && m.limit) || {};
64
+ const context = positiveCount(limit.context);
65
+ const output = positiveCount(limit.output);
66
+ if (context === null && output === null) { continue; }
67
+ out.set(`${vendor}/${modelId}`, { context, output });
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * Why this row is not a fill candidate, or null when it is one. THE single
75
+ * definition: `fillCeilings` counts each class and `needsFillCount` collapses
76
+ * them to a boolean, so the pass and the nothing-to-fill check cannot disagree
77
+ * about which rows the fetch could serve.
78
+ * @param {*} row a catalog row
79
+ * @returns {null|'malformed'|'local'|'router'}
80
+ */
81
+ function skipClass(row) {
82
+ if (!row || typeof row !== 'object' || typeof row.id !== 'string') { return 'malformed'; }
83
+ if (row.local === true) { return 'local'; }
84
+ if (row.id.startsWith('openrouter/openrouter/')) { return 'router'; }
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Does this row still lack a number outputBudget needs? computeModelLimit
90
+ * refuses to emit a descriptor unless BOTH ceilings are known, so one usable
91
+ * field is not "known" for any purpose the fill exists to serve.
92
+ * @param {object} row a catalog row
93
+ * @returns {boolean}
94
+ */
95
+ function missingACeiling(row) {
96
+ return positiveCount(row.contextLength) === null || positiveCount(row.maxOutputTokens) === null;
97
+ }
98
+
99
+ /**
100
+ * Fill contextLength / maxOutputTokens in place. A field is filled ONLY when the
101
+ * provider gave no usable positive integer for it — `positiveCount(...) === null`,
102
+ * i.e. null, 0, negative, below 1 or non-numeric. (`positiveCount` FLOORS, so
103
+ * 1.5 is a usable 1; only a fraction below 1 falls through.) A usable provider
104
+ * value is never overwritten. A filled row is stamped `limitSource: 'models.dev'`,
105
+ * which marks a row where AT LEAST ONE field was filled from models.dev — not a
106
+ * claim that both numbers came from there (council #230 D2).
107
+ *
108
+ * COUNTERS. `filled` / `alreadyKnown` / `unknown` describe what the pass did;
109
+ * `stillMissing` describes the STATE it left behind and deliberately overlaps
110
+ * them. `alreadyKnown` means both fields were usable BEFORE the pass, and is
111
+ * decided BEFORE the models.dev lookup (council #230 A2) — a complete row
112
+ * models.dev does not list is `alreadyKnown`, not `unknown`. A row with one
113
+ * field known and the other unfillable is `stillMissing`, never "already known"
114
+ * (council #230 C1/D5): outputBudget cannot clamp it.
115
+ * @param {Array<object>} rows catalog rows (mutated)
116
+ * @param {Map<string, {context: number|null, output: number|null}>} limits
117
+ * @returns {{filled: number, alreadyKnown: number, unknown: number, stillMissing: number,
118
+ * skippedRouters: number, skippedLocal: number}}
119
+ */
120
+ function fillCeilings(rows, limits) {
121
+ const counts = { filled: 0, alreadyKnown: 0, unknown: 0, stillMissing: 0, skippedRouters: 0, skippedLocal: 0 };
122
+ for (const row of Array.isArray(rows) ? rows : []) {
123
+ const skip = skipClass(row);
124
+ if (skip === 'malformed') { continue; }
125
+ if (skip === 'local') { counts.skippedLocal++; continue; }
126
+ if (skip === 'router') { counts.skippedRouters++; continue; }
127
+ // BEFORE the lookup (council #230 A2): a row that already carries both
128
+ // usable numbers has nothing for models.dev to fill, so whether models.dev
129
+ // happens to list it is irrelevant. Looking first counted such a row
130
+ // `unknown` when models.dev did not have it — reporting a row outputBudget
131
+ // CAN clamp as one it knows nothing about.
132
+ if (!missingACeiling(row)) { counts.alreadyKnown++; continue; }
133
+ const lim = limits.get(row.id);
134
+ if (!lim) {
135
+ counts.unknown++;
136
+ } else {
137
+ let touched = false;
138
+ if (positiveCount(row.contextLength) === null && lim.context !== null) { row.contextLength = lim.context; touched = true; }
139
+ if (positiveCount(row.maxOutputTokens) === null && lim.output !== null) { row.maxOutputTokens = lim.output; touched = true; }
140
+ if (touched) { row.limitSource = 'models.dev'; counts.filled++; }
141
+ }
142
+ if (missingACeiling(row)) { counts.stillMissing++; }
143
+ }
144
+ return counts;
145
+ }
146
+
147
+ /**
148
+ * How many candidate rows the fetch could possibly help. Zero means the network
149
+ * call has nothing to do and is skipped entirely (council #230 D1/C2).
150
+ * @param {Array<object>} rows catalog rows
151
+ * @returns {number}
152
+ */
153
+ function needsFillCount(rows) {
154
+ let n = 0;
155
+ for (const row of Array.isArray(rows) ? rows : []) {
156
+ if (skipClass(row) === null && missingACeiling(row)) { n++; }
157
+ }
158
+ return n;
159
+ }
160
+
161
+ /**
162
+ * The outcome a FAILED — or SKIPPED — enrichment persists: the failure plus
163
+ * every counter at zero. One shape, so this module's own failure returns and
164
+ * `model-catalog.js :: refreshCatalog`'s belt-and-braces catch cannot drift
165
+ * apart and `models-ceiling-line.js :: fmtCeilingLine` always has the counters
166
+ * it prints. `skipped` defaults to null and a caller that skipped the fetch
167
+ * spreads its own reason over it.
168
+ * @param {null|{reason: string, status?: number, detail?: string}} failure
169
+ * @returns {{source: 'models.dev', failure: object|null, skipped: null, filled: number,
170
+ * alreadyKnown: number, unknown: number, stillMissing: number, skippedRouters: number,
171
+ * skippedLocal: number}}
172
+ */
173
+ function emptyOutcome(failure) {
174
+ return {
175
+ source: 'models.dev', failure, skipped: null,
176
+ filled: 0, alreadyKnown: 0, unknown: 0, stillMissing: 0, skippedRouters: 0, skippedLocal: 0,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Fetch models.dev and fill `rows`. ALWAYS resolves; the outcome travels with
182
+ * the rows it describes (model-catalog.js persists it as ceilingEnrichment).
183
+ * Failure reasons are http-get's (`timeout`, `network-error`, `http-status`,
184
+ * `too-large`, `parse-error`) plus `bad-shape` and `exception`.
185
+ *
186
+ * NO CALL IS MADE when no candidate row is missing a ceiling: the outcome is
187
+ * `skipped: 'nothing-to-fill'` and models.dev is never contacted (council #230
188
+ * D1/C2 — a refresh on a fully-known catalog paid up to 10 s for nothing).
189
+ * @param {Array<object>} rows catalog rows (mutated in place)
190
+ * @param {{getJson?: Function}} [deps] test seam
191
+ * @returns {Promise<{source: 'models.dev', failure: null|{reason: string, status?: number, detail?: string},
192
+ * skipped: null|'nothing-to-fill', filled: number, alreadyKnown: number, unknown: number,
193
+ * stillMissing: number, skippedRouters: number, skippedLocal: number}>}
194
+ */
195
+ async function enrichCeilings(rows, deps = {}) {
196
+ const getJson = deps.getJson || require('./http-get').getJson;
197
+ if (needsFillCount(rows) === 0) {
198
+ return { ...emptyOutcome(null), skipped: 'nothing-to-fill' };
199
+ }
200
+ let res;
201
+ try {
202
+ res = await getJson(MODELS_DEV_URL, {
203
+ timeoutMs: MODELS_DEV_TIMEOUT_MS,
204
+ followRedirects: true,
205
+ headers: { 'User-Agent': `amicus/${require('../../package.json').version}` },
206
+ });
207
+ } catch (err) {
208
+ return emptyOutcome({ reason: 'exception', detail: err.message });
209
+ }
210
+ if (!res || !res.ok) {
211
+ return emptyOutcome((res && res.failure) || { reason: 'exception', detail: 'no result' });
212
+ }
213
+ const limits = limitsFromModelsDev(res.json);
214
+ // A 200 that parses but carries no recognised vendor limits — `{}`, an error
215
+ // object, a reshaped api.json — would otherwise persist as a SUCCESSFUL
216
+ // enrichment with every candidate row `unknown`, silently leaving direct-provider
217
+ // ceilings unfilled and outputBudget unable to clamp them (council #230 C1).
218
+ // It is a failure, and the rows are not touched.
219
+ if (limits.size === 0) {
220
+ return emptyOutcome({ reason: 'bad-shape', detail: 'no recognised vendor limits in api.json' });
221
+ }
222
+ return { source: 'models.dev', failure: null, skipped: null, ...fillCeilings(rows, limits) };
223
+ }
224
+
225
+ // `enrichCeilings` — the entry point — is first so the generated architecture
226
+ // map, which lists a module's first five exports, actually names it.
227
+ module.exports = {
228
+ enrichCeilings, fillCeilings, needsFillCount, limitsFromModelsDev, emptyOutcome,
229
+ MODELS_DEV_URL, MODELS_DEV_TIMEOUT_MS,
230
+ };
@@ -5,7 +5,7 @@
5
5
  * Uses the same HTTPS pattern as api-key-store.js validateApiKey().
6
6
  */
7
7
 
8
- const https = require('https');
8
+ const { httpGetText } = require('./http-get');
9
9
 
10
10
  /**
11
11
  * Hardcoded Anthropic floor: the anthropic/ rows a KEYLESS user (or a
@@ -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 }
@@ -62,6 +64,9 @@ const PROVIDER_FETCH_CONFIG = {
62
64
  id: `google/${m.name.replace('models/', '')}`,
63
65
  name: m.displayName || m.name.replace('models/', ''),
64
66
  contextLength: m.inputTokenLimit ?? null,
67
+ // #218 P3: Google's ListModels publishes the ceiling first-party; models.dev fills only
68
+ // what the provider left empty or unusable.
69
+ maxOutputTokens: m.outputTokenLimit ?? null,
65
70
  pricing: null
66
71
  }));
67
72
  }
@@ -146,43 +151,16 @@ function fetchModelsFromProvider(provider, key) {
146
151
  * @param {string} key - API key
147
152
  * @returns {Promise<{rows: Array, failure: {reason: string, status?: number, detail?: string}|null}>}
148
153
  */
149
- function fetchViaConfigDetailed(provider, key) {
154
+ async function fetchViaConfigDetailed(provider, key) {
150
155
  const config = PROVIDER_FETCH_CONFIG[provider];
151
156
  const url = config.buildUrl ? config.buildUrl(key) : config.url;
152
- const headers = config.authHeader(key);
153
-
154
- return new Promise((resolve) => {
155
- let chunks = '';
156
- const ok = (rows) => resolve({ rows, failure: null });
157
- const fail = (failure) => resolve({ rows: [], failure });
158
-
159
- const timer = setTimeout(() => {
160
- req.destroy();
161
- fail({ reason: 'timeout', detail: `no response within ${FETCH_TIMEOUT_MS}ms` });
162
- }, FETCH_TIMEOUT_MS);
163
-
164
- const req = https.get(url, { headers }, (res) => {
165
- if (res.statusCode !== 200) {
166
- clearTimeout(timer);
167
- res.on('data', () => {});
168
- res.on('end', () => fail({ reason: 'http-status', status: res.statusCode }));
169
- return;
170
- }
171
- res.on('data', (chunk) => { chunks += chunk; });
172
- res.on('end', () => {
173
- clearTimeout(timer);
174
- try {
175
- ok(config.normalize(chunks));
176
- } catch (err) {
177
- fail({ reason: 'parse-error', detail: err.message });
178
- }
179
- });
180
- });
181
- req.on('error', (err) => {
182
- clearTimeout(timer);
183
- fail({ reason: 'network-error', detail: err.message });
184
- });
185
- });
157
+ const res = await httpGetText(url, { headers: config.authHeader(key), timeoutMs: FETCH_TIMEOUT_MS });
158
+ if (!res.ok) { return { rows: [], failure: res.failure }; }
159
+ try {
160
+ return { rows: config.normalize(res.body), failure: null };
161
+ } catch (err) {
162
+ return { rows: [], failure: { reason: 'parse-error', detail: err.message } };
163
+ }
186
164
  }
187
165
 
188
166
  /**
@@ -0,0 +1,132 @@
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 through THIS DESCRIPTOR ALONE is
21
+ * ARITHMETICALLY INERT. kimi-k3's true ceiling is 943,718 and
22
+ * Math.min(943718, 32000) is still 32000. Through the descriptor only a
23
+ * value BELOW OUTPUT_TOKEN_MAX changes the outbound request. Raising
24
+ * OUTPUT_TOKEN_MAX itself is engine-output-flag.js's job (PR 2): with the
25
+ * flag set to the same budget the two levers agree on min(budget, ceiling)
26
+ * — measured, probe rows C2 and K6.
27
+ * 2. `limit.context` is MANDATORY whenever `limit` is present. A `limit` with
28
+ * only `output` is a hard ConfigInvalidError — and it poisons the ENTIRE
29
+ * config for the server's lifetime, not just that model. Measured against
30
+ * a live `opencode serve` + GET /config/providers.
31
+ * 3. `output: 0` is swallowed by the `|| Z` and falls back to 32000.
32
+ *
33
+ * WHAT THIS DOES NOT FIX. #218 conflates two modes that pull in OPPOSITE
34
+ * directions on this one knob. Mode 1 (credit rejection) needs the reservation
35
+ * LOWERED — this descriptor does that. Mode 2 (a leg spending its whole
36
+ * allowance on reasoning and emitting 0-2 output tokens) needs it RAISED, which
37
+ * this descriptor cannot do (the Math.min) and the engine flag can
38
+ * (engine-output-flag.js) — but its real cause is reasoning effort, which #218
39
+ * PR 4 now delivers: `--thinking` reaches the engine as its `variant` field, not
40
+ * the `reasoning` object the prompt API never read (F1), and is checked against
41
+ * the model's own declaration first — so on the one route where a variant moves
42
+ * the reservation the leg is refused, not silently overshot (M2: 24000 + 16000 =
43
+ * 40000; the fit that lands the sum on the budget is M17). Lowering the budget
44
+ * makes such a leg fail faster and cheaper; raising it gives the reasoning more
45
+ * room. Neither makes it produce output, and no claim is made that either does.
46
+ *
47
+ * POLICY: opt-in, no default change. With no configured budget every model is
48
+ * still registered as `{}` and no engine flag is set — byte-identical to
49
+ * pre-#218 behaviour.
50
+ */
51
+
52
+ 'use strict';
53
+
54
+ /**
55
+ * A usable positive, finite INTEGER count. Rejects strings, booleans, NaN,
56
+ * Infinity, and anything that floors to zero.
57
+ *
58
+ * ⚠️ Order matters, and getting it wrong was council finding C2 on PR #221.
59
+ * Testing `v > 0` BEFORE flooring let 0.5 through and returned `Math.floor(0.5)`
60
+ * === 0 — breaking this function's own "positive integer, or null" contract.
61
+ * Downstream that was worse than a bad number: computeModelLimit's
62
+ * `Math.max(1, ...)` guard, which exists to stop `output: 0` reaching opencode,
63
+ * laundered the bogus 0 into a bogus `output: 1` — a ONE-TOKEN reservation on
64
+ * every leg. A hardening masking the very input it was meant to reject. Floor
65
+ * first, then test positivity, so a sub-1 value is rejected outright.
66
+ */
67
+ function positiveCount(v) {
68
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) { return null; }
69
+ const n = Math.floor(v);
70
+ return n > 0 ? n : null;
71
+ }
72
+
73
+ /**
74
+ * Coerce a configured output budget to a usable integer, or null.
75
+ * null means "emit no limit" — the opt-in default, and today's behaviour.
76
+ * @param {*} v raw `config.outputBudget`
77
+ * @returns {number|null}
78
+ */
79
+ function normalizeOutputBudget(v) {
80
+ // `typeof true === 'boolean'` and `'8000'` is a string: both rejected. A
81
+ // config knob is only honoured when it is unambiguously a number.
82
+ return positiveCount(v);
83
+ }
84
+
85
+ /**
86
+ * Index catalog rows by full route id for O(1) lookup during route walking.
87
+ * A Map (not an object) so a model id colliding with an Object.prototype member
88
+ * — 'constructor', 'toString' — cannot resolve to an inherited function. Same
89
+ * discipline as the `__proto__: null` alias table in config.js.
90
+ * @param {Array<{id:string, contextLength:?number, maxOutputTokens:?number}>} models
91
+ * @returns {Map<string, {contextLength:?number, maxOutputTokens:?number}>}
92
+ */
93
+ function buildLimitLookup(models) {
94
+ const out = new Map();
95
+ if (!Array.isArray(models)) { return out; }
96
+ for (const m of models) {
97
+ if (!m || typeof m !== 'object' || typeof m.id !== 'string') { continue; }
98
+ out.set(m.id, {
99
+ contextLength: m.contextLength ?? null,
100
+ maxOutputTokens: m.maxOutputTokens ?? null,
101
+ });
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * The `limit` descriptor for one model, or null when it must not be emitted.
108
+ *
109
+ * Returns null — meaning "register as `{}`, exactly as before" — whenever any
110
+ * input is missing or unusable. That is deliberate: a partial descriptor is not
111
+ * a partial improvement here, it is a fatal config error (rule 2 above).
112
+ *
113
+ * @param {?{contextLength:?number, maxOutputTokens:?number}} row catalog row
114
+ * @param {?number} budget normalized output budget
115
+ * @returns {?{context:number, output:number}}
116
+ */
117
+ function computeModelLimit(row, budget) {
118
+ const want = positiveCount(budget);
119
+ if (want === null || !row || typeof row !== 'object') { return null; }
120
+
121
+ const context = positiveCount(row.contextLength);
122
+ const ceiling = positiveCount(row.maxOutputTokens);
123
+ // Both or neither. Without a known ceiling a blanket budget would REGRESS a
124
+ // small model: an 8000 budget against a real 4096 ceiling sends an
125
+ // over-ceiling max_tokens, where today opencode's own Math.min keeps it
126
+ // correct. The ceiling is a hard requirement for clamping, not a nicety.
127
+ if (context === null || ceiling === null) { return null; }
128
+
129
+ return { context, output: Math.max(1, Math.min(ceiling, want)) };
130
+ }
131
+
132
+ module.exports = { normalizeOutputBudget, buildLimitLookup, computeModelLimit, positiveCount };