amicus 4.9.3 → 4.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +324 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +8 -5
- package/docs/architecture-map.md +736 -0
- package/docs/configuration.md +165 -26
- package/docs/council.md +9 -0
- package/docs/doc-system.md +12 -9
- package/docs/testing.md +2 -1
- package/docs/troubleshooting.md +113 -0
- package/docs/usage.md +11 -6
- package/package.json +1 -1
- package/schemas/model-catalog.schema.json +2 -1
- package/schemas/run.schema.json +13 -0
- package/scripts/postinstall.js +4 -0
- package/skills/sidecar/SKILL.md +1 -8
- package/src/cli-handlers-doctor.js +3 -0
- package/src/cli-handlers-fanout.js +10 -1
- package/src/cli-handlers-resume-continue.js +25 -0
- package/src/cli.js +5 -8
- package/src/council/briefings-chair.js +4 -2
- package/src/council/run-assemble.js +7 -2
- package/src/council/run-retry-notes.js +21 -1
- package/src/council/run-stages.js +8 -1
- package/src/headless.js +125 -7
- package/src/mcp-server.js +26 -0
- package/src/mcp-tools.js +4 -4
- package/src/opencode-client.js +84 -8
- package/src/pack/pack-validate.js +3 -0
- package/src/session-manager.js +2 -2
- package/src/sidecar/continue.js +6 -1
- package/src/sidecar/conversation-mirror.js +35 -11
- package/src/sidecar/electron-install.js +81 -81
- package/src/sidecar/electron-provision.js +179 -0
- package/src/sidecar/electron-trust.js +299 -0
- package/src/sidecar/fanout-leg-fallback.js +1 -0
- package/src/sidecar/fanout-leg.js +10 -2
- package/src/sidecar/fanout.js +2 -2
- package/src/sidecar/interactive.js +31 -4
- package/src/sidecar/models-ceiling-line.js +72 -0
- package/src/sidecar/models.js +4 -2
- package/src/sidecar/reopen-notices.js +97 -0
- package/src/sidecar/reopen-spend.js +3 -2
- package/src/sidecar/resume.js +15 -2
- package/src/sidecar/session-finalize.js +4 -1
- package/src/sidecar/session-utils.js +5 -1
- package/src/sidecar/start-metadata.js +1 -1
- package/src/sidecar/start.js +10 -5
- package/src/sidecar/unzip.js +40 -0
- package/src/utils/config.js +33 -12
- package/src/utils/curated-models.js +8 -8
- package/src/utils/degrade.js +7 -0
- package/src/utils/doctor-output-budget-check.js +198 -0
- package/src/utils/engine-output-flag.js +105 -0
- package/src/utils/engine-variants.js +298 -0
- package/src/utils/http-get.js +284 -0
- package/src/utils/model-catalog.js +36 -4
- package/src/utils/model-ceilings-modelsdev.js +230 -0
- package/src/utils/model-fetcher.js +12 -36
- package/src/utils/model-output-limit.js +21 -13
- package/src/utils/output-length.js +90 -0
- package/src/utils/result-schema.js +7 -2
- package/src/utils/spend-ledger.js +5 -1
- package/src/utils/thinking-validators.js +27 -80
- package/src/utils/validators.js +2 -3
|
@@ -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 };
|
|
@@ -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
|
-
/**
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
8
|
+
const { httpGetText } = require('./http-get');
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Hardcoded Anthropic floor: the anthropic/ rows a KEYLESS user (or a
|
|
@@ -64,6 +64,9 @@ const PROVIDER_FETCH_CONFIG = {
|
|
|
64
64
|
id: `google/${m.name.replace('models/', '')}`,
|
|
65
65
|
name: m.displayName || m.name.replace('models/', ''),
|
|
66
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,
|
|
67
70
|
pricing: null
|
|
68
71
|
}));
|
|
69
72
|
}
|
|
@@ -148,43 +151,16 @@ function fetchModelsFromProvider(provider, key) {
|
|
|
148
151
|
* @param {string} key - API key
|
|
149
152
|
* @returns {Promise<{rows: Array, failure: {reason: string, status?: number, detail?: string}|null}>}
|
|
150
153
|
*/
|
|
151
|
-
function fetchViaConfigDetailed(provider, key) {
|
|
154
|
+
async function fetchViaConfigDetailed(provider, key) {
|
|
152
155
|
const config = PROVIDER_FETCH_CONFIG[provider];
|
|
153
156
|
const url = config.buildUrl ? config.buildUrl(key) : config.url;
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const timer = setTimeout(() => {
|
|
162
|
-
req.destroy();
|
|
163
|
-
fail({ reason: 'timeout', detail: `no response within ${FETCH_TIMEOUT_MS}ms` });
|
|
164
|
-
}, FETCH_TIMEOUT_MS);
|
|
165
|
-
|
|
166
|
-
const req = https.get(url, { headers }, (res) => {
|
|
167
|
-
if (res.statusCode !== 200) {
|
|
168
|
-
clearTimeout(timer);
|
|
169
|
-
res.on('data', () => {});
|
|
170
|
-
res.on('end', () => fail({ reason: 'http-status', status: res.statusCode }));
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
res.on('data', (chunk) => { chunks += chunk; });
|
|
174
|
-
res.on('end', () => {
|
|
175
|
-
clearTimeout(timer);
|
|
176
|
-
try {
|
|
177
|
-
ok(config.normalize(chunks));
|
|
178
|
-
} catch (err) {
|
|
179
|
-
fail({ reason: 'parse-error', detail: err.message });
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
req.on('error', (err) => {
|
|
184
|
-
clearTimeout(timer);
|
|
185
|
-
fail({ reason: 'network-error', detail: err.message });
|
|
186
|
-
});
|
|
187
|
-
});
|
|
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
|
+
}
|
|
188
164
|
}
|
|
189
165
|
|
|
190
166
|
/**
|