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,298 @@
1
+ /**
2
+ * @module engine-variants
3
+ * The effort lever (#218 PR 4): --thinking sent as the engine's variant field, validated against the engine's own declaration.
4
+ * The engine's prompt endpoint selects reasoning
5
+ * effort through `variant: string` (probe F2); the `reasoning` object amicus sent
6
+ * for every `--thinking` until now was never a prompt field and reached nothing
7
+ * (F1). A variant the model does not DECLARE is a silent no-op that the engine
8
+ * still echoes on the assistant message (F3 on a known model, M7 on one with
9
+ * `variants {}`), so a run's own artifact can claim an effort the wire never
10
+ * saw. This module is the one place amicus asks the engine what a model declares
11
+ * (`/config/providers` -> `variants` + `limit`, M0) and decides, per leg, to send,
12
+ * refuse, or send unverified.
13
+ *
14
+ * Measured, engine 1.18.15 (BACKLOG "v4.9.4 records", the PR 4 table):
15
+ * - a model newer than the engine's bundled catalogue reads `limit 0/0,
16
+ * variants {}` until the startup models.dev refresh lands (M0: qwen3.8-max-0902,
17
+ * glm-5.3 on a cold engine) and is known on the first poll of a WARM engine (M12: 36 ms on one run) —
18
+ * so a read that finds the model unknown WAITS, bounded, before deciding;
19
+ * - a variant does not move the reservation on OpenRouter (M1, M9), direct
20
+ * Google (M15), direct DeepSeek (M16) or an adaptive-thinking Anthropic model
21
+ * (M10b); only an entry shaped `thinking: {type: 'enabled', budgetTokens: N}`
22
+ * adds N ON TOP of the reservation (M2: 24000 + 16000 = 40000; K2/K11),
23
+ * clamped to the model's ceiling (K3/K4/K10);
24
+ * - N is the engine's, not a formula of the ceiling (M0: opus-4-5 declares
25
+ * 16000 for low, medium AND high, and no max), so the post-spawn dump is the
26
+ * only source of N — and nothing changes a descriptor after the spawn: a
27
+ * runtime PATCH /config changes nothing the engine serves and writes a
28
+ * config.json into its cwd (M3/M4/M11). The exact pre-spawn fit (descriptor
29
+ * = budget − N, proven M17) is filed, not built; this module REFUSES the
30
+ * over-budget shape with the numbers instead (BACKLOG C1's second clause).
31
+ * - /config/providers echoes a descriptor amicus wrote (M3: a 24000 limit.output reads back as 24000), so the ceiling a fit judges against comes from amicus's own catalog when it knows the model and from the dump only for a bare descriptor. The echo overwrites `limit` and NOTHING else (M23), so it never hides whose row it is: the name, family, release date, prices, capabilities and variants beside it are filled by the engine's MERGED view of its own catalogue and the user's opencode config (council #235 r4, C3 — a config `reasoning: true` even synthesizes a variants map), and `engineSourced` below reads those.
32
+ */
33
+ 'use strict';
34
+
35
+ const { positiveCount } = require('./model-output-limit');
36
+
37
+ /** Every level the curated routes declare between them (M0), in effort order. */
38
+ const VARIANT_LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
39
+
40
+ /** How long a read waits for the engine's startup refresh to make a model known (the cold wait is unmeasured — BACKLOG item 3; M12's 36 ms was a warm read). */
41
+ const DECLARATION_WAIT_MS = 5000;
42
+ const DECLARATION_POLL_MS = 500; // council #235 r1 (D2): ten reads across the bound, not twenty
43
+ const DECLARATION_READ_TIMEOUT_MS = 2000; // council #235 r2 (A1): ONE read's own bound. A warm read measured 36-285 ms (M12), so 2 s is generous; without it a stalled endpoint runs to undici's ~306 s default and the 5 s "bound" below caps only the NUMBER of reads.
44
+
45
+ /**
46
+ * Defang engine-sourced text before it enters a message. `/config/providers` is
47
+ * filled from the engine's remote models.dev refresh, so variant names are not
48
+ * ours; control characters and fence/tag characters are stripped so a poisoned
49
+ * catalogue cannot forge terminal escapes or markdown structure in a reason
50
+ * string. Mirrors sidecar/progress-fields.js :: sanitizePreview, which does the
51
+ * same job for briefings crossing the MCP boundary. Named mutant "RAWENGINETEXT".
52
+ * @param {string} s @returns {string}
53
+ */
54
+ // eslint-disable-next-line no-control-regex
55
+ const defang = (s) => String(s).replace(/[\u0000-\u001F\u007F]/g, '').replace(/[`<>]/g, '').replace(/\s+/g, ' ').trim();
56
+
57
+ /** Thrown by opencode-client.js :: sendPrompt BEFORE any request when a variant is refused. */
58
+ class VariantRefusedError extends Error {
59
+ constructor(code, message) {
60
+ super(message);
61
+ this.name = 'VariantRefusedError';
62
+ this.code = code;
63
+ }
64
+ }
65
+
66
+ /** A non-empty string cell. */
67
+ const filled = (v) => typeof v === 'string' && v.trim() !== '';
68
+
69
+ /**
70
+ * Whose row is this — the engine's own catalogue, or nothing but the descriptor amicus registered? Amicus writes exactly ONE cell into a model's entry (`limit`, src/utils/config.js:406) and /config/providers echoes it (M3), so `limit` can never answer that; everything else can. Measured with the IDENTICAL descriptor on both rows (record M23, engine 1.18.15): an engine row keeps its display name, family, release date, prices, capabilities and variants, a config-only row reads `name === modelID`, empty family/release_date, cost 0, `variants {}`.
71
+ * THE DISJUNCTS ARE EXACTLY THESE EIGHT: `id`, `providerID`, `api`, `status`, `options`, `headers`, `capabilities.toolcall` and `capabilities.input/output.text` are populated on a config-only row TOO (M23), so adding any of them reads every model the engine has not learned yet as a declaration and refuses it falsely — named mutants "TOOLCALLDISJUNCT" and "ECHOSOURCED" (add `limit`); "ONEDISJUNCT" shrinks the OR. Cost is compared `> 0`, never through positiveCount, which FLOORS $0.05 to 0 — mutant "COSTVIAPOSITIVECOUNT".
72
+ * @param {string} modelID model half of the executable id
73
+ * @param {object|null} m the dump's entry for it
74
+ * @returns {boolean}
75
+ */
76
+ function engineSourced(modelID, m) {
77
+ if (!m || typeof m !== 'object') { return false; }
78
+ const cost = (m.cost && typeof m.cost === 'object') ? m.cost : {};
79
+ const caps = (m.capabilities && typeof m.capabilities === 'object') ? m.capabilities : {};
80
+ const variants = (m.variants && typeof m.variants === 'object') ? m.variants : {};
81
+ return filled(m.release_date)
82
+ || filled(m.family)
83
+ || (filled(m.name) && m.name !== modelID)
84
+ || (typeof cost.input === 'number' && cost.input > 0)
85
+ || (typeof cost.output === 'number' && cost.output > 0)
86
+ || Object.keys(variants).length > 0
87
+ || caps.temperature === true || caps.reasoning === true || caps.attachment === true;
88
+ }
89
+
90
+ /**
91
+ * One read of `/config/providers` for one model. `known` is "the engine's own
92
+ * catalogue supplied this row" (engineSourced above, record M23) — NOT
93
+ * `limit.context > 0`, which reads the one cell amicus itself writes and the
94
+ * dump echoes back (M3). A model amicus registered that the engine's catalogue
95
+ * lacks carries nothing but that descriptor (J1, M0); a provider or model
96
+ * missing from the dump altogether is unknown too. Named mutant "LIMITISKNOWN".
97
+ * `limitOutput` is whatever the dump says — the engine's own ceiling for a bare descriptor, and the ECHO of a descriptor amicus wrote otherwise (M3).
98
+ * @param {object} client SDK client
99
+ * @param {string} providerID provider half of the executable id
100
+ * @param {string} modelID model half of the executable id
101
+ * @param {{signal?: object, readTimeoutMs?: number}} [opts] `signal` — the caller's abandon signal, joined to this read's own deadline when it is a real AbortSignal; `readTimeoutMs` — that deadline (default DECLARATION_READ_TIMEOUT_MS).
102
+ * @returns {Promise<{known: boolean, variants: object, limitOutput: number|null, unreadable: string|null}>}
103
+ */
104
+ async function readDeclarationOnce(client, providerID, modelID, opts = {}) {
105
+ const readTimeoutMs = positiveCount(opts.readTimeoutMs) || DECLARATION_READ_TIMEOUT_MS;
106
+ // council #235 r2 (A1): the read gets its OWN deadline, and the caller's abandon
107
+ // signal is joined to it so an abort cancels the read in flight rather than only
108
+ // ending the loop. Named mutant "UNBOUNDEDREAD" (tests/utils/engine-variants.test.js):
109
+ // drop the signal from the call and the read runs to the transport's own default
110
+ // (the SDK deletes its own — node_modules/@opencode-ai/sdk/dist/client.js sets
111
+ // `req.timeout = false` — leaving undici's ~306 s; the triage measured one read at
112
+ // 306,639 ms against a socket that accepts and never answers).
113
+ const timeoutSignal = AbortSignal.timeout(readTimeoutMs);
114
+ // AbortSignal.any REJECTS a duck-typed `{aborted}` (TypeError: not of type AbortSignal),
115
+ // and both the documented `options.signal` type and the tests use that shape; only the
116
+ // real AbortController signal headless passes can be joined. The loop's own `signal.aborted`
117
+ // check still ends the wait for the duck-typed case — it just cannot cancel a read already
118
+ // in flight, which is exactly what the timeout above now bounds.
119
+ const joinable = opts.signal && typeof AbortSignal !== 'undefined' && opts.signal instanceof AbortSignal;
120
+ const readSignal = joinable ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
121
+ let r;
122
+ try { r = await client.config.providers({ signal: readSignal }); } catch (err) {
123
+ // council #235 r1 (C1/D1/A2): a THROWN read — a transport error, a dead engine — is
124
+ // unreadable exactly like the returned non-2xx tuple below: one read, no wait, the level
125
+ // sent unverified with the note naming the error. Before this the rejection escaped
126
+ // sendPrompt and the leg died on it. Named mutant "THROWNREADFAILS"
127
+ // (tests/utils/engine-variants.test.js): drop the try/catch.
128
+ return { known: false, variants: {}, limitOutput: null, unreadable: `read threw: ${(err && err.message) || String(err)}` };
129
+ }
130
+ // #218 PR 4 whole-branch review (EP-3): the SDK returns a non-2xx as a VALUE ({error,
131
+ // response}, no data — opencode-client.js :: providerErrorReason keys on the same shape).
132
+ // A response with no providers array is UNREADABLE, not "model unknown": the wait must not
133
+ // burn 5 s on it and the note must not claim a read that never happened.
134
+ // Named mutant "UNREADABLEISCOLD" (tests/utils/engine-variants.test.js): `list = []` on that shape.
135
+ const list = (r && r.data && Array.isArray(r.data.providers)) ? r.data.providers : null;
136
+ if (list === null) {
137
+ const status = r && ((r.response && r.response.status) || (r.error && r.error.status));
138
+ return { known: false, variants: {}, limitOutput: null, unreadable: typeof status === 'number' ? `HTTP ${status}` : 'no providers array in the response' };
139
+ }
140
+ const p = list.find((x) => x && x.id === providerID);
141
+ const m = (p && p.models && Object.prototype.hasOwnProperty.call(p.models, modelID)) ? p.models[modelID] : null;
142
+ const limit = (m && m.limit && typeof m.limit === 'object') ? m.limit : {};
143
+ return {
144
+ known: engineSourced(modelID, m),
145
+ variants: (m && m.variants && typeof m.variants === 'object') ? m.variants : {},
146
+ limitOutput: positiveCount(limit.output),
147
+ unreadable: null,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * The ceiling amicus's own catalog knows for `model` — the number
153
+ * config.js :: buildProviderModels clamps a budget-derived descriptor to — or
154
+ * null when it knows none (the descriptor was then bare).
155
+ * @param {string} model executable id
156
+ * @param {Function} [readCache] test seam for model-catalog.js :: readCache
157
+ * @returns {number|null}
158
+ */
159
+ function catalogCeilingFor(model, readCache) {
160
+ try {
161
+ const { buildLimitLookup } = require('./model-output-limit');
162
+ const cache = (readCache || require('./model-catalog').readCache)();
163
+ const row = buildLimitLookup(cache && cache.models).get(model);
164
+ return row ? positiveCount(row.maxOutputTokens) : null;
165
+ } catch { return null; }
166
+ }
167
+
168
+ /**
169
+ * The engine's declaration for `model` ('provider/model', split at the FIRST
170
+ * slash — an OpenRouter id keeps its vendor path), waiting up to `waitMs` for
171
+ * the catalogue to know it. Named mutant "NOWAIT" (tests/utils/engine-variants.test.js).
172
+ * @param {object} client SDK client
173
+ * @param {string} model executable id
174
+ * @param {{waitMs?: number, pollMs?: number, sleep?: Function, now?: Function, catalogCeiling?: number|null, readCache?: Function, signal?: {aborted: boolean}, readTimeoutMs?: number}} [opts] test seams — `catalogCeiling` (explicit) wins over `readCache`. `readTimeoutMs` — each individual `/config/providers` read's own deadline (council #235 r2 A1).
175
+ * @returns {Promise<{known: boolean, variants: object, limitOutput: number|null, unreadable: string|null, ceiling: number|null, ceilingFrom: string, waitedMs: number}>} `ceiling` is what the fit judges against: the amicus catalog's ceiling when it knows the model, else `limitOutput`; `ceilingFrom` is `'catalog'` or `'engine'` accordingly, and checkVariant's OVER_BUDGET remedy reads it.
176
+ */
177
+ async function readModelDeclaration(client, model, opts = {}) {
178
+ const idx = typeof model === 'string' ? model.indexOf('/') : -1;
179
+ const providerID = idx > 0 ? model.slice(0, idx) : String(model);
180
+ const modelID = idx > 0 ? model.slice(idx + 1) : '';
181
+ const waitMs = opts.waitMs === undefined ? DECLARATION_WAIT_MS : opts.waitMs;
182
+ const pollMs = opts.pollMs === undefined ? DECLARATION_POLL_MS : opts.pollMs;
183
+ const sleep = opts.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
184
+ const now = opts.now || Date.now;
185
+ const catalogCeiling = opts.catalogCeiling !== undefined ? opts.catalogCeiling : catalogCeilingFor(model, opts.readCache);
186
+ const signal = opts.signal || null; // #218 PR 4 whole-branch review (EP-2): the caller's abandon signal
187
+ // council #235 r3 (C1/B1): the wait asks `known` and NOTHING about the budget. `known` is now
188
+ // "the engine's catalogue supplied this row" (engineSourced, M23), so the row that used to be
189
+ // indistinguishable from an echo — amicus's descriptor and nothing else — is positively
190
+ // identified and polled, and an engine row declaring no variants settles on the first read and
191
+ // is refused; both in EITHER budget state. The deleted `budgetInForce`/`couldBeEcho`/`ambiguous`
192
+ // machinery managed an ambiguity the response never had. Named mutant "COLDECHOKNOWN": stop polling once the dump reports any `limit.output` — the echo ends the wait on the first read again. Council #235 r4 (A2/C4): the bound is the WHOLE wait, not the number of reads. The condition is checked BEFORE the sleep+read that follows it, so an unclamped pair used to carry the wait to `waitMs + pollMs + readTimeoutMs` (7.5 s on the shipped 5000/500/2000; measured 7,361 ms against an endpoint answering in 1.9 s, which killed a leg under an `AMICUS_NO_OUTPUT_BACKSTOP_MS=6000` the docs present as safely above the wait). No read now starts without a poll interval left, and each in-loop read gets only the remaining budget. Named mutant "UNCLAMPEDPOLL": restore the plain `now() - start < waitMs` condition with an unclamped in-loop read.
193
+ const readTimeout = positiveCount(opts.readTimeoutMs) || DECLARATION_READ_TIMEOUT_MS;
194
+ const start = now();
195
+ let d = await readDeclarationOnce(client, providerID, modelID, { signal, readTimeoutMs: opts.readTimeoutMs });
196
+ while (!d.unreadable && !d.known && !(signal && signal.aborted) && waitMs - (now() - start) > pollMs) {
197
+ await sleep(pollMs);
198
+ const left = waitMs - (now() - start);
199
+ if (left < 1) { break; } // < 1, not <= 0: positiveCount FLOORS a sub-millisecond remainder to null, which would hand the read the full 2 s default back
200
+ const next = await readDeclarationOnce(client, providerID, modelID, { signal, readTimeoutMs: Math.min(readTimeout, left) });
201
+ if (next.unreadable && left < readTimeout) { break; } // council #235 r4 wave 5 repair: a read the WAIT'S OWN remainder truncated is not evidence the endpoint could not be read, and `unreadable` is what formatUnverifiedVariantNote turns into "could not be read (...; one read, no wait)". Measured: with the clamp above and no suppression here, an endpoint answering in 285 ms — the top of M12's warm range — ended 7 good reads and a full 5 s wait reported unreadable. The cold-catalogue outcome (`waitedMs`, no `unreadable`) is the true one; only a read that got its FULL deadline and still failed is a read failure. Named mutants "BUDGETABORTUNREADABLE" (drop this line) and "SUPPRESSALLINLOOP" (drop the `left < readTimeout` conjunct).
202
+ d = next;
203
+ }
204
+ // #218 PR 4 (found by probe row M20 in Task 2): /config/providers ECHOES the
205
+ // descriptor amicus wrote -- a budget-derived limit.output 24000 reads back as
206
+ // 24000 (M3's dump-after) -- so with a budget in force the dump cannot tell
207
+ // the model's ceiling. The fit judges against the ceiling amicus clamped that
208
+ // descriptor to (its own catalog's maxOutputTokens, the same number
209
+ // config.js :: buildProviderModels used) and against the dump's value only
210
+ // when the catalog has none: then the descriptor was bare and the dump is the
211
+ // engine's own ceiling (K5/K12). Named mutant "ECHOEDCEILING"
212
+ // (tests/utils/engine-variants.test.js): `ceiling: d.limitOutput` unconditionally.
213
+ // council #235 r2 (B1): `ceilingFrom` says whose number this is. 'engine' = a bare descriptor,
214
+ // so the dump IS the engine's ceiling (K5/K12) and the remedy below is provable. 'catalog' =
215
+ // the dump echoes what amicus wrote (M3), so the engine's real ceiling may be HIGHER and
216
+ // raising the budget to it can land inside the unrefused window. Named mutant "CEILINGPROVENANCE".
217
+ return { ...d, ceiling: catalogCeiling !== null ? catalogCeiling : d.limitOutput, ceilingFrom: catalogCeiling !== null ? 'catalog' : 'engine', waitedMs: now() - start };
218
+ }
219
+
220
+ /**
221
+ * Pure: send, refuse, or send unverified — in that order of tests.
222
+ * 1. unknown model -> {ok: true, verified: false} (mutant "UNKNOWNREFUSED");
223
+ * 2. known, undeclared -> VARIANT_UNDECLARED in one of TWO shapes (own properties only — mutant
224
+ * "PROTOLOOKUP"): the declared set named when the row lists one, else the empty-set reason,
225
+ * which says whose row it is and that a budget cannot change the verdict (mutants
226
+ * "EMPTYSETSILENT", "MESSAGEOVERCLAIMS");
227
+ * 3. declared, entry `thinking: {type: 'enabled', budgetTokens: N}`, a positive budget B, and either a ceiling C above it or NO ceiling at all -> VARIANT_OVER_BUDGET reserving min(B + N, C), or the unclamped B + N when nothing declares a C (council #235 r4 C7, mutant "NULLCEILINGSENDS"); mutants "ALWAYSREFUSE": drop `B < C`; "FITWITHOUTBUDGET": default a null budget to 32000; "ANTHROPICONLY": key on the provider id instead of the shape.
228
+ * The remedy has THREE shapes. With a C, keyed on `declaration.ceilingFrom` (council #235 r2 B1): an ENGINE-sourced ceiling gets "raise to at least C (the sum is then clamped, K4)", which the dump proves; a CATALOG-sourced one names C as amicus's own number and says to run `amicus models --refresh` first, because the engine's real ceiling may be higher and a budget in that gap is never re-checked (mutant "REMEDYALWAYSCATALOG"). With NO C it names the `limit` to declare and the only value that fits, B - N (M17), because declaring C = B silences the fit instead of shrinking the reservation;
229
+ * 4. otherwise {ok: true, verified: true, entry}.
230
+ * @param {{variant: string, model: string, declaration: object, outputBudget?: number|null}} a
231
+ * @returns {{ok: true, verified: boolean, entry?: object} | {ok: false, code: string, reason: string}}
232
+ */
233
+ function checkVariant({ variant, model, declaration, outputBudget }) {
234
+ if (!declaration || !declaration.known) { return { ok: true, verified: false }; }
235
+ const variants = (declaration.variants && typeof declaration.variants === 'object') ? declaration.variants : {};
236
+ if (!Object.prototype.hasOwnProperty.call(variants, variant)) {
237
+ const names = Object.keys(variants);
238
+ // council #235 r3 (C1/B1): the EMPTY set is its own answer and gets its own reason. The
239
+ // row reached this branch because `known` says the engine's own catalogue supplied it
240
+ // (engineSourced, M23), so "declares no variants" is an observation, not an unfinished
241
+ // read — and it says so without claiming WHICH cell carried the evidence, because the
242
+ // predicate is an OR (10 of 50 openai rows have `name === id`; 26 of 361 openrouter
243
+ // `:free` rows price at zero, and both are engine-sourced through other cells). Named
244
+ // mutants "EMPTYSETSILENT" (fall back to the listed wording) and "MESSAGEOVERCLAIMS"
245
+ // (assert the row carries its name, family, release date AND prices). Council #235 r3 wave 4 repair, three more ways one string misleads: the enumeration also names the DISPLAY NAME, since `name !== modelID` is a disjunct too ("NAMECELLUNNAMED"); the reason echoes the level the user typed, which docs/troubleshooting.md promises and a fanout needs ("MESSAGEDROPSLEVEL"); and the mirror hint says "sometimes", not "often" — measured during the wave-4 review on a live dump, 2 of 196 variant-less engine rows had a mirror that declares levels, and gpt-4o's own (openrouter/openai/gpt-4o-2024-08-06) does not ("MIRROROFTEN"). Council #235 r4 (C3), the message only: `/config/providers` serves the MERGED config-and-catalogue view, so metadata a user declares in their own opencode.json (name, release_date, cost, reasoning) satisfies `engineSourced` without the engine's catalogue knowing the model — the verdict is still honest there (a merged view declaring no variants makes the send a no-op either way), so the sentence stops asserting that the engine's own catalogue is the source and says the row reads as a declaration in that merged view. The second `config.get()` read that would subtract config-set cells is FILED, not built (BACKLOG, #218 PR 4). Named mutant "CATALOGUEPROVENANCE": restore "carries cells only the engine's own catalogue fills".
246
+ if (names.length === 0) {
247
+ return { ok: false, code: 'VARIANT_UNDECLARED', reason: `VARIANT_UNDECLARED: ${model} declares no variants at all, so '${variant}' is not among them — the row the engine returned for it (/config/providers, the engine's merged view of its own catalogue and your opencode config) carries catalogue-style metadata (a display name, family, release date, pricing or capabilities), so it reads as a declaration and not an unfinished read; an undeclared variant is a silent no-op on the wire (probe F3/M7), so nothing was sent. Omit --thinking to run at the provider's own default effort, or pick a route whose row declares levels (a gateway mirror of the same model sometimes does). Setting an outputBudget does not change this verdict (council #235 r3, C1/B1); on a first engine start the bundled catalogue can declare a smaller set than the live one, so the same level can be accepted on the next run` };
248
+ }
249
+ // council #235 r2 (B4): the names come from the engine's remote models.dev refresh, so
250
+ // they are defanged before they enter a message that reaches a log and a terminal.
251
+ // `model` is NOT defanged and must not be: it is amicus's own resolved config id, not
252
+ // engine-sourced text.
253
+ const listed = defang(names.join(', '));
254
+ return { ok: false, code: 'VARIANT_UNDECLARED', reason: `VARIANT_UNDECLARED: ${model} does not declare a '${variant}' variant — the engine's catalogue lists ${listed} for it (/config/providers); an undeclared variant is a silent no-op on the wire (probe F3/M7), so nothing was sent. Pick one of the listed levels, or omit --thinking to run at the provider's own default effort` };
255
+ }
256
+ const entry = variants[variant];
257
+ const thinking = (entry && entry.thinking && typeof entry.thinking === 'object') ? entry.thinking : null;
258
+ const budgetTokens = (thinking && thinking.type === 'enabled') ? positiveCount(thinking.budgetTokens) : null;
259
+ const budget = positiveCount(outputBudget);
260
+ const ceiling = declaration.ceiling;
261
+ if (budgetTokens !== null && budget !== null && (ceiling === null || budget < ceiling)) {
262
+ const sum = budget + budgetTokens;
263
+ const reservation = ceiling === null ? sum : Math.min(sum, ceiling);
264
+ const how = ceiling === null ? `${budget} + ${budgetTokens}, with no ceiling declared anywhere to clamp it` : (sum > ceiling ? `${budget} + ${budgetTokens}, clamped to the model's ${ceiling} ceiling` : `${budget} + ${budgetTokens}`);
265
+ // council #235 r2 (B1): the remedy must not walk the user into the one unrefused window.
266
+ // With a budget in force the dump ECHOES the descriptor amicus wrote (M3), so `ceiling` is
267
+ // amicus's own catalog number; if the engine's real ceiling is higher, a budget raised to
268
+ // exactly this number sits in [C_catalog, C_engine) — the fit falls silent there and the leg
269
+ // still reserves up to C_catalog + N on the wire. Named mutant "REMEDYALWAYSCATALOG".
270
+ // council #235 r4 (C7): a NULL ceiling is "no clamp anyone declared", not "no risk" — a row that declares the level with the `enabled + budgetTokens` shape and carries no `limit.output` anywhere (a model declared in the user's own opencode.json with a variants block and no limit, which amicus's catalog has no row for either) used to skip the fit entirely and send verified, while `budget + N` with N >= 1 always exceeds the budget. Its remedy must name the ceiling that is MISSING, never tell the user to raise the budget to a number nothing declares. Named mutant "NULLCEILINGSENDS": restore the `ceiling !== null` conjunct above. Wave 5 repair, that remedy's own two false notes: a declared `limit.output` is a DESCRIPTOR, not a clamp on the sum (K9 measured `limit.output 40000` + `max` reserving 63999; K3/K4/K10 record the sum clamped at the model's REAL ceiling "regardless of what the descriptor or the flag said"), and the obvious value to declare — the budget itself — makes `budget < ceiling` false, so the fit falls SILENT and the same over-budget leg sends verified (the r2 B1 window again). The remedy names the fitting value instead, `budget - N` (the exact fit, M17). Named mutants "DECLAREDLIMITCLAMPS" and "REMEDYWITHOUTFIT".
271
+ const raise = ceiling !== null ? (declaration.ceilingFrom === 'catalog' ? `Raise outputBudget to at least ${ceiling} — the ceiling amicus's own catalog carries for this model, which is what the fit can read once a budget is set (M3); if the engine's real ceiling is higher, a budget in that gap is not re-checked, so prefer \`amicus models --refresh\` first` : `Raise outputBudget to at least ${ceiling} (the sum is then clamped to the ceiling, K4)`) : `Declare a \`limit\` for this model in your opencode config — its \`output\` is the ceiling this fit judges against and has to leave room for the ${budgetTokens} the engine adds ON TOP (${budget > budgetTokens ? `at most ${budget - budgetTokens}, the value that lands the sum exactly on the budget — the exact fit, M17` : `no value fits — the engine adds ${budgetTokens} on top of whatever it reserves, more than this whole budget`}); a value at or above ${budget} silences this fit while the leg still reserves ${budgetTokens} over the budget, and declaring one does not clamp the sum — only the model's real ceiling does that (K3/K9/K10), and nothing declares one here — or clear outputBudget, which leaves the leg the engine's own default reservation with the ${budgetTokens} tokens added to it`;
272
+ return { ok: false, code: 'VARIANT_OVER_BUDGET', reason: `VARIANT_OVER_BUDGET: the '${variant}' variant on ${model} carries a ${budgetTokens}-token thinking budget that the engine adds ON TOP of the reservation on this route (probe M2: 24000 + 16000 = 40000; K2), so with outputBudget ${budget} this leg would reserve ${reservation} (${how}) — ${reservation - budget} over the budget; nothing was sent. ${raise}, route the model through OpenRouter (a variant leaves the reservation at the budget there — M1: 8000 stayed 8000 under 'low'; M9: 32000 with 'high' on both of the engine's catalogues), or use an adaptive-thinking model such as claude-sonnet-5 (M10b)` };
273
+ }
274
+ return { ok: true, verified: true, entry };
275
+ }
276
+
277
+ /**
278
+ * The log line for a variant sent to a model the catalogue did not know in time.
279
+ * TWO shapes with TWO tails (council #235 r5, J4/A1/B2): they used to share one, and the shared
280
+ * tail said the level applies once the engine LEARNS the model — true of a cold catalogue, false
281
+ * of a read that failed, where nothing was learned or unlearned. Both tails now also say that the
282
+ * over-budget fit did not run for this leg, because `known === false` entails an empty variants
283
+ * map (it is one of the disjuncts of `known`), so the fit had no budgetTokens to read and CANNOT
284
+ * be made to run here -- the refusal the seats asked for is refused; naming what did not run is
285
+ * the whole remedy for the first-run-versus-later-run inconsistency. Named mutant "NOTEHIDESFIT".
286
+ * @param {{model: string, variant: string, waitedMs: number, unreadable?: string|null}} a
287
+ * @returns {string}
288
+ */
289
+ function formatUnverifiedVariantNote({ model, variant, waitedMs, unreadable }) {
290
+ if (unreadable) {
291
+ // council #235 r2 (B4): `unreadable` carries an engine/transport error message -- defanged before it reaches a log line and a terminal. `model` stays raw: it is amicus's own id.
292
+ return `the engine's /config/providers could not be read (${defang(unreadable)}; one read, no wait), so '${variant}' was sent unverified: NEITHER check ran for this leg — not the declared-set check and not the over-budget fit — so the level applies if and only if ${model} declares it, and on the direct-Anthropic additive shape (a variant entry whose thinking type is 'enabled' with a budgetTokens N) the engine adds that N on top of the output budget (probe M2: 24000 + 16000 = 40000; K2) with nothing here to catch it.`;
293
+ }
294
+ // council #235 r3 (C1/B1): two shapes, not three. The `ambiguous` branch named a state the dump never had -- a row carrying only amicus's descriptor is positively identified now.
295
+ return `the engine's catalogue did not know ${model} within ${waitedMs} ms (its /config/providers entry carries nothing but the descriptor amicus registered, or the model is absent from the dump), so '${variant}' was sent unverified: it applies only if the engine learns the model before it builds the request (its startup models.dev refresh — probe M12 saw qwen3.8-max-0902 known on the first poll of a warm engine, 36 ms on one run, and unknown at the first read of a cold one, M0) and is a silent no-op otherwise (M7). The over-budget fit did not run for this leg either, so once the engine's catalogue knows the model the same command can refuse with VARIANT_OVER_BUDGET.`;
296
+ }
297
+
298
+ module.exports = { VARIANT_LEVELS, VariantRefusedError, readModelDeclaration, checkVariant, formatUnverifiedVariantNote };
@@ -0,0 +1,284 @@
1
+ /**
2
+ * @module http-get
3
+ * One HTTPS GET, always resolved, never rejected — the timer/destroy/failure
4
+ * core that model-fetcher.js :: fetchViaConfigDetailed carried inline (#209)
5
+ * and that the models.dev ceiling fetch (#218 P3) needs too. Extracted rather
6
+ * than copied so a second caller cannot drift from the first: the same failure
7
+ * reasons and the same {reason, status?, detail?} shape the catalog persists
8
+ * as providerFailures.
9
+ *
10
+ * The call shape is deliberately `https.get(url, { headers }, cb)` — the one
11
+ * tests/model-fetcher.test.js mocks — so that suite keeps intercepting after
12
+ * the extraction.
13
+ *
14
+ * REDIRECTS ARE OPT-IN, AND THEN FOLLOWED AT MOST TWICE (council #230 A2, made
15
+ * opt-in by #230 D4). Without `followRedirects: true` a 3xx is the terminal
16
+ * `http-status` failure it has always been — the keyed provider fetches in
17
+ * `model-fetcher.js` never hop, so a provider's redirect stays a visible
18
+ * failure rather than a silent behavioural change. With the option on, a
19
+ * 301/302/303/307/308 carrying a `Location` is resolved against the URL that
20
+ * produced it and re-issued under the SAME deadline — one timeout covers the
21
+ * whole chain, so a hop never buys the server more time. The target must be
22
+ * `https:`. A plain-http Location, a missing Location and a third redirect are
23
+ * each an `http-status` failure carrying the status plus a `detail` naming
24
+ * which, so a domain move that loops or downgrades stays visible on the
25
+ * caller's failure line (for the models.dev ceiling fetch, the `Ceilings:` line
26
+ * of `amicus models --refresh`) instead of being chased silently.
27
+ *
28
+ * ONLY AN ALLOWLIST OF HEADERS CROSSES AN ORIGIN (council #230 C3). `model-fetcher.js`
29
+ * hands this module `Authorization: Bearer <key>` (openrouter/openai/deepseek)
30
+ * or `x-api-key` (anthropic), so a 302 — or an open redirect — on a provider
31
+ * host would otherwise forward a live key to whatever host the `Location`
32
+ * named. A same-origin hop keeps every header; a CROSS-ORIGIN hop keeps ONLY
33
+ * `user-agent`, `accept` and `accept-language` (matched
34
+ * case-insensitively) and drops everything else. An allowlist rather than a
35
+ * deny-list because the deny-list has to be extended for every new vendor
36
+ * header — `x-goog-api-key` was not on it — and the one that is forgotten is
37
+ * the one that leaks. Once dropped, stay dropped: the stripped set is what the
38
+ * next hop carries, so a same-origin third hop cannot resurrect a credential.
39
+ * `accept-encoding` is NOT on the allowlist (council #230 C3): nothing here
40
+ * decodes a content-encoded body, so forwarding it would invite compressed
41
+ * bytes to be concatenated into the body as text.
42
+ *
43
+ * A REFUSED 3xx RELEASES THE CONNECTION (council #230 C1). Each refusal branch
44
+ * settles the promise first and then retires the response and destroys the live
45
+ * request: the chain's single deadline is cleared the moment the promise
46
+ * settles, so a refused redirect whose body never ends would otherwise hold the
47
+ * socket open with nothing left to close it.
48
+ *
49
+ * The body is capped at `maxBytes` (council #230 B3): a response that keeps
50
+ * coming is destroyed and reported as `too-large` rather than accumulated in a
51
+ * string until the process dies.
52
+ */
53
+
54
+ 'use strict';
55
+
56
+ const https = require('https');
57
+
58
+ const DEFAULT_TIMEOUT_MS = 5000;
59
+ /** 16 MiB. models.dev's api.json is ~4.5 MB, so this is headroom, not a budget. */
60
+ const DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
61
+ /** Two hops, then `redirect limit reached`. */
62
+ const MAX_REDIRECTS = 2;
63
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
64
+ /**
65
+ * The ONLY headers a cross-origin hop keeps. Lower-case: callers spell headers
66
+ * freely. `accept-encoding` is deliberately NOT here (council #230 C3): this
67
+ * module never decodes a content-encoded body, so forwarding it would invite a
68
+ * compressed response to be concatenated into a string as text.
69
+ */
70
+ const CROSS_ORIGIN_HEADERS = new Set(['user-agent', 'accept', 'accept-language']);
71
+
72
+ /**
73
+ * The headers the NEXT hop may carry. A same-origin hop keeps the caller's
74
+ * object untouched; a cross-origin hop gets a copy holding ONLY the allowlisted
75
+ * names. A current URL that will not parse counts as cross-origin — the safe
76
+ * direction, since the comparison cannot be made.
77
+ * @param {object} headers the headers this hop was issued with
78
+ * @param {string} from the URL that produced the redirect
79
+ * @param {URL} to the resolved target
80
+ * @returns {object} `headers` itself, or an allowlisted copy
81
+ */
82
+ function hopHeaders(headers, from, to) {
83
+ let fromOrigin = null;
84
+ try { fromOrigin = new URL(from).origin; } catch (err) { fromOrigin = null; }
85
+ if (fromOrigin !== null && fromOrigin === to.origin) { return headers; }
86
+ const kept = {};
87
+ for (const name of Object.keys(headers)) {
88
+ if (CROSS_ORIGIN_HEADERS.has(name.toLowerCase())) { kept[name] = headers[name]; }
89
+ }
90
+ return kept;
91
+ }
92
+
93
+ /**
94
+ * Retire a response the chain has finished with — either before the next hop starts, or on a
95
+ * refused redirect (`refuseRedirect`). Its `data`/`end`
96
+ * listeners and — the point of this — the chain's `error` listener are detached
97
+ * first, so an abrupt close on the abandoned socket can no longer settle a
98
+ * promise the live hop now owns. `error` is REPLACED by a swallow rather than
99
+ * simply removed: a destroyed stream still emits, and an 'error' with no
100
+ * listener at all throws as an uncaught exception.
101
+ * @param {object} res the http.IncomingMessage the chain has moved on from
102
+ */
103
+ function retire(res) {
104
+ res.removeAllListeners('data');
105
+ res.removeAllListeners('end');
106
+ res.removeAllListeners('error');
107
+ res.on('error', () => {});
108
+ if (typeof res.destroy === 'function') { res.destroy(); }
109
+ }
110
+
111
+ /**
112
+ * A REFUSED 3xx: settle with the failure, then release the connection it came
113
+ * on. Draining alone was not enough (council #230 C1) — settling clears the
114
+ * chain's only deadline, so after the refusal nothing would ever close a socket
115
+ * whose body never ends. The failure is raised FIRST and the teardown second,
116
+ * because destroying the request can emit `error` on it and `ctx.onError` is
117
+ * still attached; single-settle then makes that late event a no-op instead of
118
+ * rewriting the reason as `network-error`.
119
+ * @param {object} res the http.IncomingMessage
120
+ * @param {{fail: Function, destroy: Function}} ctx
121
+ * @param {{reason: string, status: number, detail: string}} failure
122
+ */
123
+ function refuseRedirect(res, ctx, failure) {
124
+ ctx.fail(failure);
125
+ retire(res);
126
+ ctx.destroy();
127
+ }
128
+
129
+ /**
130
+ * One hop's 3xx: resolve `Location` against the URL that produced it, retire
131
+ * this response, and hand the target plus its (possibly stripped) headers to
132
+ * `ctx.hop` — or fail with the status and a `detail` naming why the hop was
133
+ * refused. Every refusal goes through `refuseRedirect`, which releases the
134
+ * connection as well as failing.
135
+ * @param {object} res the http.IncomingMessage
136
+ * @param {{url: string, left: number, headers: object}} hop the hop it answered
137
+ * @param {{hop: Function, fail: Function, destroy: Function, retireRequest: Function}} ctx
138
+ */
139
+ function followRedirect(res, hop, ctx) {
140
+ const status = res.statusCode;
141
+ const loc = (res.headers && res.headers.location) || null;
142
+ if (loc === null) { refuseRedirect(res, ctx, { reason: 'http-status', status, detail: 'redirect without Location' }); return; }
143
+ if (hop.left <= 0) { refuseRedirect(res, ctx, { reason: 'http-status', status, detail: 'redirect limit reached' }); return; }
144
+ let next;
145
+ try {
146
+ next = new URL(loc, hop.url);
147
+ } catch (err) {
148
+ refuseRedirect(res, ctx, { reason: 'http-status', status, detail: `redirect to an unparseable Location: ${err.message}` });
149
+ return;
150
+ }
151
+ // https ONLY: a downgrade would re-send the caller's headers in clear text.
152
+ if (next.protocol !== 'https:') { refuseRedirect(res, ctx, { reason: 'http-status', status, detail: 'redirect to non-https location' }); return; }
153
+ const headers = hopHeaders(hop.headers, hop.url, next);
154
+ retire(res); // BEFORE the next hop exists, so the two can never race
155
+ ctx.retireRequest(); // and its REQUEST's 'error' listener with it
156
+ ctx.hop({ url: next.toString(), left: hop.left - 1, headers });
157
+ }
158
+
159
+ /**
160
+ * One hop's response: a 3xx goes to followRedirect WHEN the caller opted in,
161
+ * any other non-200 — an un-opted-in 3xx included — is an `http-status` failure
162
+ * once the body drains, a 200 accumulates under the byte cap.
163
+ * @param {object} res the http.IncomingMessage
164
+ * @param {{url: string, left: number, headers: object}} hop the hop it answered
165
+ * @param {{hop: Function, fail: Function, done: Function, onError: Function, destroy: Function,
166
+ * retireRequest: Function, maxBytes: number, followRedirects: boolean}} ctx
167
+ */
168
+ function readResponse(res, hop, ctx) {
169
+ // Decode once, at the stream: `chunks += chunk` decodes each Buffer on its
170
+ // own and mangles any multi-byte character split across a chunk boundary.
171
+ res.setEncoding('utf8');
172
+ // A mid-body stream error is emitted on `res`, NOT on `req`. With no listener
173
+ // here node rethrows it as an uncaught exception and the promise never
174
+ // settles — so it is attached once, ahead of the status check, and covers the
175
+ // redirect and non-200 drain branches too.
176
+ res.on('error', ctx.onError);
177
+ if (ctx.followRedirects && REDIRECT_STATUS.has(res.statusCode)) { followRedirect(res, hop, ctx); return; }
178
+ if (res.statusCode !== 200) {
179
+ // The deadline stays armed until `end`: a non-200 whose body never ends
180
+ // must still time out rather than leave the promise pending for ever.
181
+ res.on('data', () => {});
182
+ res.on('end', () => { ctx.fail({ reason: 'http-status', status: res.statusCode }); });
183
+ return;
184
+ }
185
+ let body = '';
186
+ let bytes = 0;
187
+ res.on('data', (chunk) => {
188
+ // Bytes, not `body.length`: the cap is named in bytes and the stream is
189
+ // already decoded, so a multi-byte body would otherwise pass an over-budget
190
+ // payload through under the character count.
191
+ bytes += Buffer.byteLength(chunk, 'utf8');
192
+ if (bytes > ctx.maxBytes) {
193
+ ctx.destroy();
194
+ ctx.fail({ reason: 'too-large', detail: `body exceeded ${ctx.maxBytes} bytes` });
195
+ return;
196
+ }
197
+ body += chunk;
198
+ });
199
+ res.on('end', () => { ctx.done({ ok: true, body }); });
200
+ }
201
+
202
+ /**
203
+ * GET `url`; resolve with the raw body on a 200. Caps the body at `maxBytes`.
204
+ * With `followRedirects: true` it follows at most two https redirects under a
205
+ * single deadline, keeping only the allowlisted headers on a cross-origin hop;
206
+ * without it a 3xx is a terminal `http-status` failure.
207
+ * @param {string} url
208
+ * @param {{headers?: object, timeoutMs?: number, maxBytes?: number, followRedirects?: boolean}} [opts]
209
+ * @returns {Promise<{ok: true, body: string}|{ok: false, failure: {reason: string, status?: number, detail?: string}}>}
210
+ */
211
+ function httpGetText(url, opts = {}) {
212
+ const headers = opts.headers || {};
213
+ const timeoutMs = opts.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : opts.timeoutMs;
214
+ const maxBytes = opts.maxBytes === undefined ? DEFAULT_MAX_BYTES : opts.maxBytes;
215
+ const followRedirects = opts.followRedirects === true;
216
+ return new Promise((resolve) => {
217
+ // CHAIN-SCOPED. The deadline and the size trip must destroy the LIVE hop, so
218
+ // every hop assigns this from inside itself BEFORE dispatching its response.
219
+ // An outer `req = https.get(...)` cannot: https.get may call back
220
+ // synchronously, and the next hop's assignment would then be undone by the
221
+ // outer one completing afterwards.
222
+ let current = null;
223
+ let timer = null;
224
+ let settled = false;
225
+ // Single-settle: a timeout, a stream error, a size trip and an `end` can all
226
+ // race, and across a redirect chain the losing hops are still live.
227
+ const done = (v) => { if (settled) { return; } settled = true; clearTimeout(timer); resolve(v); };
228
+ const fail = (failure) => { done({ ok: false, failure }); };
229
+ const onError = (err) => { fail({ reason: 'network-error', detail: err.message }); };
230
+ const destroy = () => { if (current) { current.destroy(); } };
231
+ // A superseded hop's REQUEST keeps `onError` attached, so a late socket
232
+ // error on the abandoned connection would settle a chain the live hop now
233
+ // owns. Swapped for a swallow rather than merely removed: 'error' with no
234
+ // listener at all throws as an uncaught exception. Not destroyed — the
235
+ // response side already is, and destroying the request would fire this.
236
+ const retireRequest = () => {
237
+ if (!current) { return; }
238
+ current.removeAllListeners('error');
239
+ current.on('error', () => {});
240
+ };
241
+ // ONE deadline for the WHOLE chain — armed before the first hop, never rearmed.
242
+ timer = setTimeout(() => { destroy(); fail({ reason: 'timeout', detail: `no response within ${timeoutMs}ms` }); }, timeoutMs);
243
+ const ctx = { fail, done, onError, maxBytes, destroy, retireRequest, followRedirects, hop: null };
244
+ ctx.hop = (hop) => {
245
+ let res = null;
246
+ let armed = false;
247
+ let request;
248
+ try {
249
+ request = https.get(hop.url, { headers: hop.headers }, (r) => { res = r; if (armed) { readResponse(r, hop, ctx); } });
250
+ } catch (err) {
251
+ // `https.get` throws SYNCHRONOUSLY on a malformed URL (and on a bad
252
+ // option object). "Always resolves, never rejects" has to hold for that.
253
+ onError(err);
254
+ return;
255
+ }
256
+ current = request;
257
+ request.on('error', onError);
258
+ armed = true;
259
+ // A callback that already fired SYNCHRONOUSLY is replayed here, now that
260
+ // `current` is this hop's request rather than the previous one's.
261
+ if (res) { readResponse(res, hop, ctx); }
262
+ };
263
+ ctx.hop({ url, left: MAX_REDIRECTS, headers });
264
+ });
265
+ }
266
+
267
+ /**
268
+ * GET + JSON.parse. A body that is not JSON is a 'parse-error' failure, so a
269
+ * caller sees the transport reasons plus exactly this one more.
270
+ * @param {string} url
271
+ * @param {{headers?: object, timeoutMs?: number, maxBytes?: number, followRedirects?: boolean}} [opts]
272
+ * @returns {Promise<{ok: true, json: any}|{ok: false, failure: {reason: string, status?: number, detail?: string}}>}
273
+ */
274
+ async function getJson(url, opts) {
275
+ const res = await httpGetText(url, opts);
276
+ if (!res.ok) { return res; }
277
+ try {
278
+ return { ok: true, json: JSON.parse(res.body) };
279
+ } catch (err) {
280
+ return { ok: false, failure: { reason: 'parse-error', detail: err.message } };
281
+ }
282
+ }
283
+
284
+ module.exports = { httpGetText, getJson, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_BYTES, MAX_REDIRECTS };