bareloop 0.30.0 → 0.32.0
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/CHANGELOG.md +154 -0
- package/bareloop.context.md +205 -10
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorflow.js +22 -42
- package/src/authorjob.js +5 -8
- package/src/authorrun.js +42 -16
- package/src/cli.js +12 -6
- package/src/config.js +135 -0
- package/src/interviewrun.js +10 -2
- package/src/keysfile.js +156 -0
- package/src/ledger.js +26 -0
- package/src/monthly.js +352 -0
- package/src/panel/authorroutes.js +328 -0
- package/src/panel/authorsession.js +555 -0
- package/src/panel/index.html +1247 -50
- package/src/panel/server.js +449 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/planrun.js +61 -5
- package/src/promptregisters.js +3 -0
- package/src/providerrows.js +217 -0
- package/src/providers.js +84 -0
- package/src/replay.js +80 -2
- package/src/replayio.js +14 -3
- package/src/run.js +48 -2
- package/src/runlist.js +13 -0
- package/src/userrun.js +134 -13
- package/types/authorflow.d.ts +4 -21
- package/types/authorrun.d.ts +5 -0
- package/types/cli.d.ts +2 -1
- package/types/config.d.ts +48 -0
- package/types/interviewrun.d.ts +5 -0
- package/types/keysfile.d.ts +90 -0
- package/types/ledger.d.ts +14 -0
- package/types/monthly.d.ts +192 -0
- package/types/panel/authorroutes.d.ts +67 -0
- package/types/panel/authorsession.d.ts +53 -0
- package/types/panel/server.d.ts +42 -5
- package/types/panel/settingsroutes.d.ts +14 -0
- package/types/providerrows.d.ts +164 -0
- package/types/providers.d.ts +34 -0
- package/types/replay.d.ts +31 -0
- package/types/replayio.d.ts +12 -1
- package/types/run.d.ts +26 -1
- package/types/runlist.d.ts +1 -0
- package/types/userrun.d.ts +22 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// PANEL-BUILD.md P4a — Settings routes (`/api/settings/*`). A separate module from the
|
|
2
|
+
// authoring routes on purpose: the chat/authoring session can reach NO route here (it has
|
|
3
|
+
// no path to this file's handlers), and every route here is behind the same human-click
|
|
4
|
+
// guard (per-server-start token + Origin/Host, `checkHumanGuard`) — GET too, because the
|
|
5
|
+
// pages these serve name key variables and spend.
|
|
6
|
+
//
|
|
7
|
+
// Providers (P4b): the rows are the keys file's filled lines; Name / API shape / Base URL save to
|
|
8
|
+
// config.json `keys.<ENV NAME>` (src/providerrows.js is the one owner of the rows).
|
|
9
|
+
//
|
|
10
|
+
// Every write goes to `~/.config/bareloop/config.json` through `src/config.js` (the one
|
|
11
|
+
// writer); the monthly limit is a person's own number, set by hand — nothing here (and
|
|
12
|
+
// nothing the agent can reach) raises it on their behalf. Key VALUES never appear in any
|
|
13
|
+
// response: names and found / not set only.
|
|
14
|
+
import { checkHumanGuard } from './authorroutes.js';
|
|
15
|
+
import { readConfig, updateConfig, ConfigError } from '../config.js';
|
|
16
|
+
import { spendSummary, monthlyLimitOf } from '../monthly.js';
|
|
17
|
+
import { loadKeysEnv, keysFilePath, filledKeyNames, ensureKeysFile } from '../keysfile.js';
|
|
18
|
+
import { SHAPES, PRESET_KEY_NAMES, keyRows, endpointOf, defaultUrlOf, usableKey } from '../providerrows.js';
|
|
19
|
+
import { apiKeyProblem, checkProviderReachable } from '../providers.js';
|
|
20
|
+
|
|
21
|
+
/** DeepSeek's own balance endpoint (same host as its models list). */
|
|
22
|
+
const DEEPSEEK_BALANCE_URL = 'https://api.deepseek.com/user/balance';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ port: number, token: string, home?: string, env?: Record<string,string|undefined>,
|
|
26
|
+
* fetchImpl?: typeof fetch, now?: () => number }} opts
|
|
27
|
+
*/
|
|
28
|
+
export function createSettingsRoutes(opts) {
|
|
29
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
30
|
+
/** the env keys are looked up in: the door's env (shell + keys file), re-read against the
|
|
31
|
+
* keys file on every call so "Reload keys" is just another request. NAMES leave this
|
|
32
|
+
* module; values never do.
|
|
33
|
+
* @returns {ReturnType<typeof loadKeysEnv>} */
|
|
34
|
+
const keys = () => loadKeysEnv({ env: opts.env, home: opts.home });
|
|
35
|
+
/** the Providers rows: keys with a value in the file, over the saved settings
|
|
36
|
+
* @param {Record<string, any>} config */
|
|
37
|
+
const currentRows = (config) => keyRows({ filled: filledKeyNames(opts.home), config });
|
|
38
|
+
/** @param {string} u @returns {string} */
|
|
39
|
+
const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
|
|
40
|
+
/**
|
|
41
|
+
* @param {import('node:http').IncomingMessage} req
|
|
42
|
+
* @param {import('node:http').ServerResponse} res
|
|
43
|
+
* @param {string} pathname
|
|
44
|
+
* @param {any} body already-parsed JSON body (or `null`)
|
|
45
|
+
* @returns {boolean} true if this module handled the request
|
|
46
|
+
*/
|
|
47
|
+
function handle(req, res, pathname, body) {
|
|
48
|
+
if (!pathname.startsWith('/api/settings')) return false;
|
|
49
|
+
const send = (/** @type {number} */ code, /** @type {any} */ obj) => {
|
|
50
|
+
const text = JSON.stringify(obj);
|
|
51
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
|
|
52
|
+
res.end(text);
|
|
53
|
+
};
|
|
54
|
+
const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
|
|
55
|
+
if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
|
|
56
|
+
|
|
57
|
+
if (pathname === '/api/settings/money') {
|
|
58
|
+
if (req.method === 'GET') {
|
|
59
|
+
const cfg = readConfig({ home: opts.home });
|
|
60
|
+
const s = spendSummary({ home: opts.home, now: opts.now });
|
|
61
|
+
/** @type {number|null} */
|
|
62
|
+
let limit = null;
|
|
63
|
+
let configProblem = cfg.problem;
|
|
64
|
+
// a limit the file names but that is not a number above 0 is a problem, never "no limit"
|
|
65
|
+
try { limit = monthlyLimitOf(cfg.config, opts.home); } catch (e) {
|
|
66
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
67
|
+
configProblem = e.message;
|
|
68
|
+
}
|
|
69
|
+
send(200, {
|
|
70
|
+
ok: true,
|
|
71
|
+
configProblem,
|
|
72
|
+
totalUsd: s.total.usd, totalAtLeast: s.total.atLeast,
|
|
73
|
+
monthUsd: s.month.usd, monthAtLeast: s.month.atLeast,
|
|
74
|
+
totalTokens: s.total.tokens, monthTokens: s.month.tokens,
|
|
75
|
+
monthlyLimitUsd: limit,
|
|
76
|
+
byProvider: Object.values(s.byProvider).sort((a, b) => b.totalUsd - a.totalUsd),
|
|
77
|
+
});
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (req.method === 'POST') {
|
|
81
|
+
// an empty / unparseable body, or a JSON body without the key, is NOT "clear the limit" — only
|
|
82
|
+
// an explicit null / blank clears it (that is how the auto-saving field clears it)
|
|
83
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body) || !Object.hasOwn(body, 'monthlyLimitUsd')) {
|
|
84
|
+
send(400, { ok: false, error: 'send {"monthlyLimitUsd": <number above 0>} to set the limit, or null to clear it' });
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
// a blank / null limit removes it (no limit); anything else must be a number > 0
|
|
88
|
+
const raw = body.monthlyLimitUsd;
|
|
89
|
+
const limit = raw === null || raw === '' ? null : Number(raw);
|
|
90
|
+
if (limit !== null && !(Number.isFinite(limit) && limit > 0)) {
|
|
91
|
+
send(400, { ok: false, error: 'the monthly limit must be a number above 0 (leave it blank for no limit)' });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
updateConfig({ monthlyLimitUsd: limit }, { home: opts.home });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
98
|
+
send(400, { ok: false, error: e.message });
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
send(200, { ok: true, monthlyLimitUsd: limit });
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
send(405, { ok: false, error: 'GET or POST only' });
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Providers (P4b): one row per key in the keys file that has a value. ──
|
|
109
|
+
if (pathname === '/api/settings/providers' && req.method === 'GET') {
|
|
110
|
+
// a missing keys file is created with the five empty preset lines (never edited if present)
|
|
111
|
+
ensureKeysFile(PRESET_KEY_NAMES, opts.home);
|
|
112
|
+
const cfg = readConfig({ home: opts.home });
|
|
113
|
+
const k = keys();
|
|
114
|
+
const rows = currentRows(cfg.config);
|
|
115
|
+
const s = spendSummary({ home: opts.home, now: opts.now, rows });
|
|
116
|
+
const note = cfg.config.anthropicBalanceNote;
|
|
117
|
+
// a preset key exported only in the shell gets no row (the rows are the keys file): NAMES the
|
|
118
|
+
// person can act on, read off the RAW shell env — never a value, never a length
|
|
119
|
+
const shellEnv = opts.env ?? process.env;
|
|
120
|
+
const filled = new Set(filledKeyNames(opts.home));
|
|
121
|
+
const shellOnly = PRESET_KEY_NAMES.filter((n) => typeof shellEnv[n] === 'string' && shellEnv[n] !== '' && !filled.has(n));
|
|
122
|
+
send(200, {
|
|
123
|
+
ok: true,
|
|
124
|
+
shellOnly,
|
|
125
|
+
keysFile: { path: keysFilePath(opts.home), exists: k.exists, names: k.names, warning: k.warning },
|
|
126
|
+
configProblem: cfg.problem,
|
|
127
|
+
shapes: SHAPES.map((x) => ({ id: x.id, label: x.label })),
|
|
128
|
+
rows: rows.map((r) => {
|
|
129
|
+
const raw = k.env[r.envName];
|
|
130
|
+
const problem = raw ? apiKeyProblem(raw) : null;
|
|
131
|
+
const isDeepseek = r.provider === 'openai-api' && hostOf(endpointOf(r.provider, r.baseUrl)) === 'api.deepseek.com';
|
|
132
|
+
return {
|
|
133
|
+
envName: r.envName, name: r.name, shape: r.provider, baseUrl: r.baseUrl,
|
|
134
|
+
placeholder: defaultUrlOf(r.provider),
|
|
135
|
+
keyStatus: !raw ? 'not set' : (raw === 'null' ? 'no key needed' : (problem ? `bad shape (${problem})` : 'found')),
|
|
136
|
+
canTest: r.provider !== 'gemini-api',
|
|
137
|
+
tokens: s.tokensByRow[r.envName] ?? 0,
|
|
138
|
+
balance: r.provider === 'anthropic-api'
|
|
139
|
+
? { kind: 'note', usd: typeof note === 'number' && Number.isFinite(note) ? note : null }
|
|
140
|
+
: (isDeepseek ? { kind: 'fetch' } : { kind: 'none' }),
|
|
141
|
+
};
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (pathname === '/api/settings/providers/row' && req.method === 'POST') {
|
|
148
|
+
const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
|
|
149
|
+
if (!row) { send(400, { ok: false, error: 'unknown key — add NAME=key to your keys file and reload keys' }); return true; }
|
|
150
|
+
// only the three settings a row has; each is optional in the body, the whole triple is stored
|
|
151
|
+
const patch = {
|
|
152
|
+
name: typeof body?.name === 'string' ? body.name.trim() : row.name,
|
|
153
|
+
shape: typeof body?.shape === 'string' ? body.shape : row.provider,
|
|
154
|
+
baseUrl: typeof body?.baseUrl === 'string' ? body.baseUrl.trim().replace(/\/+$/, '') : row.baseUrl,
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
updateConfig({ keys: { [row.envName]: patch } }, { home: opts.home });
|
|
158
|
+
} catch (e) {
|
|
159
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
160
|
+
send(400, { ok: false, error: e.message });
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
send(200, { ok: true, envName: row.envName, ...patch });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (pathname === '/api/settings/providers/balance-note' && req.method === 'POST') {
|
|
168
|
+
// the Anthropic balance is a NOTE the person types; no check ever reads it
|
|
169
|
+
const raw = body?.usd;
|
|
170
|
+
const usd = raw === null || raw === '' || raw === undefined ? null : Number(raw);
|
|
171
|
+
if (usd !== null && !(Number.isFinite(usd) && usd >= 0)) { send(400, { ok: false, error: 'the balance must be a number, 0 or more' }); return true; }
|
|
172
|
+
try {
|
|
173
|
+
updateConfig({ anthropicBalanceNote: usd }, { home: opts.home });
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
176
|
+
send(400, { ok: false, error: e.message });
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
send(200, { ok: true, usd });
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (pathname === '/api/settings/providers/test' && req.method === 'POST') {
|
|
184
|
+
const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
|
|
185
|
+
if (!row) { send(400, { ok: false, error: 'unknown key' }); return true; }
|
|
186
|
+
const raw = keys().env[row.envName];
|
|
187
|
+
if (!raw) { send(200, { ok: true, reachable: false, status: 'no key', note: `${row.envName} not set`, ms: null }); return true; }
|
|
188
|
+
const problem = apiKeyProblem(raw);
|
|
189
|
+
if (problem) { send(200, { ok: true, reachable: false, status: 'bad key', note: `${row.envName} ${problem}`, ms: null }); return true; }
|
|
190
|
+
const started = Date.now();
|
|
191
|
+
// $0: one GET of the models list with THIS row's shape + URL (never a completion), key only in a header
|
|
192
|
+
checkProviderReachable({
|
|
193
|
+
providerName: row.provider, apiKey: usableKey(raw), baseUrl: row.baseUrl.trim() !== '' ? row.baseUrl : undefined, fetchImpl, timeoutMs: 4000,
|
|
194
|
+
}).then((r) => {
|
|
195
|
+
send(200, { ok: true, reachable: r.reachable, status: r.status, note: r.note, ms: Date.now() - started });
|
|
196
|
+
});
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (pathname === '/api/settings/providers/balance' && req.method === 'GET') {
|
|
201
|
+
// DeepSeek only: fetched server-side from the provider (key in a header, never returned)
|
|
202
|
+
const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
|
|
203
|
+
const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === q.get('env'));
|
|
204
|
+
if (!row || row.provider !== 'openai-api' || hostOf(endpointOf(row.provider, row.baseUrl)) !== 'api.deepseek.com') { send(404, { ok: false, error: 'not found' }); return true; }
|
|
205
|
+
const raw = keys().env[row.envName];
|
|
206
|
+
if (!raw || apiKeyProblem(raw)) { send(200, { ok: true, text: null, note: 'no usable key' }); return true; }
|
|
207
|
+
const controller = new AbortController();
|
|
208
|
+
const timer = setTimeout(() => controller.abort(), 4000);
|
|
209
|
+
fetchImpl(DEEPSEEK_BALANCE_URL, { method: 'GET', headers: { Authorization: `Bearer ${raw}` }, signal: controller.signal })
|
|
210
|
+
.then(async (res) => {
|
|
211
|
+
if (!res.ok) { send(200, { ok: true, text: null, note: `HTTP ${res.status}` }); return; }
|
|
212
|
+
/** @type {any} */
|
|
213
|
+
let j = null;
|
|
214
|
+
try { j = await res.json(); } catch { j = null; }
|
|
215
|
+
const infos = Array.isArray(j?.balance_infos) ? j.balance_infos : [];
|
|
216
|
+
const parts = infos
|
|
217
|
+
.filter((/** @type {any} */ b) => b && typeof b.currency === 'string' && b.total_balance !== undefined)
|
|
218
|
+
.map((/** @type {any} */ b) => `${String(b.total_balance)} ${b.currency}`);
|
|
219
|
+
send(200, { ok: true, text: parts.length ? parts.join(' + ') : null, note: parts.length ? null : 'balance not reported' });
|
|
220
|
+
})
|
|
221
|
+
.catch((/** @type {any} */ e) => { send(200, { ok: true, text: null, note: `request failed: ${e?.name === 'AbortError' ? 'timeout' : (e?.code || e?.name || 'error')}` }); })
|
|
222
|
+
.finally(() => clearTimeout(timer));
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
send(404, { ok: false, error: 'not found' });
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
return { handle };
|
|
230
|
+
}
|
package/src/planrun.js
CHANGED
|
@@ -3864,7 +3864,41 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
3864
3864
|
blindCap: capRuns,
|
|
3865
3865
|
directions: stageDirections,
|
|
3866
3866
|
});
|
|
3867
|
-
|
|
3867
|
+
// F198: feed the STRUCTURED {stage, value} a declared close already produced
|
|
3868
|
+
// (`closeGrade`, src/declaredclose.js) rather than re-parsing `post.gap` through
|
|
3869
|
+
// `readGrade`'s `\bred\b` scan — a declared close's own gap prose never carries
|
|
3870
|
+
// that word, so the text path read every declared grade as an uncomparable
|
|
3871
|
+
// null. `post` IS `lastCloseVerdict` here (this is the opening grade, straight
|
|
3872
|
+
// off `judgeClose()`, before any crash-message substitution can occur), so
|
|
3873
|
+
// `closeGrade(post)` is exact. `readGrade` stays the fallback INSIDE
|
|
3874
|
+
// `trend.record` for a command close, where `closeGrade` returns `{gap}` only.
|
|
3875
|
+
fixTrend.record(closeGrade(post));
|
|
3876
|
+
// F198 item 2 — TRACE: before this, `middle` (below) handed the fix worker
|
|
3877
|
+
// ONLY the current gap (this iteration's raw close output, or the previous
|
|
3878
|
+
// attempt's — planrun.js's `await w.ask([...])` at the end of `middle`) and
|
|
3879
|
+
// never any history of prior grades. Nothing else about the close's own
|
|
3880
|
+
// numbers travelled between tries.
|
|
3881
|
+
//
|
|
3882
|
+
// The fix: a FACTS-ONLY number history, one entry per grade fixTrend has
|
|
3883
|
+
// already read (never advice, never model-generated text — a prompt
|
|
3884
|
+
// register change, `src/promptregisters.js`'s inventory, `src/planrun.js`
|
|
3885
|
+
// already listed there). `checksHistory` is the "N passed of M declared"
|
|
3886
|
+
// count per grade: N counts the satisfied stages on each declared verdict's
|
|
3887
|
+
// own `stages` list (first-red-wins, so that list holds only the stages that
|
|
3888
|
+
// RAN), and M is the DECLARED stage count (`stagedClose.length`, the same
|
|
3889
|
+
// one source `createTrend`'s stageOrder reads) — never the ran count, or a
|
|
3890
|
+
// 7-stage close red at stage 2 would read "1/2" instead of "1/7". Nothing a
|
|
3891
|
+
// command close's verdict carries, so it stays empty there and the line is
|
|
3892
|
+
// simply omitted. The per-stage number history rides on `fixTrend.report()`
|
|
3893
|
+
// already, so nothing new is stored for that half.
|
|
3894
|
+
/** @type {string[]} */
|
|
3895
|
+
const checksHistory = [];
|
|
3896
|
+
const pushChecks = (/** @type {any} */ v) => {
|
|
3897
|
+
if (Array.isArray(v?.stages)) {
|
|
3898
|
+
checksHistory.push(`${v.stages.filter((/** @type {any} */ s) => s.verdict === 'satisfied').length}/${stagedClose.length}`);
|
|
3899
|
+
}
|
|
3900
|
+
};
|
|
3901
|
+
pushChecks(post);
|
|
3868
3902
|
/** ralph's `ladder` seam, filled by the trend reader instead of the step
|
|
3869
3903
|
* ladder's repeat/write pair. ONE exhaustion terminal, two triggers (ralph's own
|
|
3870
3904
|
* rule): the category stays `cap-halt` and the outcome stays `escalated`, so the
|
|
@@ -3883,7 +3917,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
3883
3917
|
if (lastCloseVerdict?.verdict === HUMAN_PAUSE) {
|
|
3884
3918
|
return { governor: 'close-trend', trend: 'unknown', reading: 'the close is waiting on a person — not a grade', iteration: o.iteration, paused: true };
|
|
3885
3919
|
}
|
|
3886
|
-
|
|
3920
|
+
// Same structured-first read as the seed above. `lastCloseVerdict` is THIS
|
|
3921
|
+
// iteration's verdict (judge() ran immediately before ralph calls this
|
|
3922
|
+
// record) UNLESS a worker-crash overwrote `gap` with a synthetic message
|
|
3923
|
+
// (src/ralph.js) that no longer matches `lastCloseVerdict.gap` — guarded by
|
|
3924
|
+
// the equality check so a crash still reads through the text fallback on
|
|
3925
|
+
// its own synthetic gap, exactly as before.
|
|
3926
|
+
const graded = lastCloseVerdict && lastCloseVerdict.gap === o.gap ? closeGrade(lastCloseVerdict) : { gap: o.gap };
|
|
3927
|
+
if (lastCloseVerdict && lastCloseVerdict.gap === o.gap) pushChecks(lastCloseVerdict);
|
|
3928
|
+
return { governor: 'close-trend', ...fixTrend.record(graded), iteration: o.iteration };
|
|
3887
3929
|
},
|
|
3888
3930
|
struckOut: fixTrend.struckOut,
|
|
3889
3931
|
report: fixTrend.report,
|
|
@@ -3897,9 +3939,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
3897
3939
|
: `${rep.strikes}/${rep.limit} strikes — the fix loop stopped making progress against the close's own numbers (${t.reading}). Continue, change approach, or stop?`,
|
|
3898
3940
|
options: [
|
|
3899
3941
|
'revise the goal/spec so the work is reachable (a spec edit, so the new hash needs re-approval)',
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3942
|
+
// F197/mul5fofw — this terminal is ONE exhaustion terminal, and ralph.js
|
|
3943
|
+
// hardcodes its outcome as `escalated` (a GRADED RED, src/reuse.js's
|
|
3944
|
+
// `REUSE_GRADED_RED`: the close rendered a verdict and it was red).
|
|
3945
|
+
// `escalated` is a graded red, never a checkpoint (F197) — `--resume`
|
|
3946
|
+
// refuses it, so the sentence states that plainly.
|
|
3947
|
+
'this cannot be resumed — the close already rendered its verdict against the tree; revise the goal/spec and rerun fresh (a new hash needs re-approval)',
|
|
3903
3948
|
'abandon the task',
|
|
3904
3949
|
],
|
|
3905
3950
|
};
|
|
@@ -3996,12 +4041,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
3996
4041
|
})
|
|
3997
4042
|
: null;
|
|
3998
4043
|
if (rootInj) emit('root-injected', { phase: 'fix', ...rootInj.event });
|
|
4044
|
+
// F198 item 2 — the facts-only history: JOINED numbers only, no prose the
|
|
4045
|
+
// model didn't already earn by writing the close's own output. `values`
|
|
4046
|
+
// joins with `→` (fixTrend's own vocabulary), stages join with `·`; a
|
|
4047
|
+
// stage this leg has read only once still shows its one number (no arrow),
|
|
4048
|
+
// which is still a fact the worker did not otherwise have.
|
|
4049
|
+
const checksFact = checksHistory.length > 0 ? `checks passed ${checksHistory.join(' → ')}` : null;
|
|
4050
|
+
const stageFacts = fixTrend.report().stages
|
|
4051
|
+
.filter((s) => s.values.length > 0)
|
|
4052
|
+
.map((s) => `${s.stage} ${s.values.join(' → ')}`);
|
|
4053
|
+
const factsLine = [checksFact, ...stageFacts].filter(Boolean).join(' · ');
|
|
3999
4054
|
await w.ask([
|
|
4000
4055
|
'The job\'s final verification is failing. Fix the repository so it passes.',
|
|
4001
4056
|
`Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
|
|
4002
4057
|
artifacts.length > 0 && `Working context (read-only) — the plan's steps produced:\n${artifacts.map((a) => `[${a.id}] ${a.text}`).join('\n\n')}`,
|
|
4003
4058
|
!gap && post.gap && `The verification's output on the tree as it stands (not an attempt of yours):\n${post.gap}`,
|
|
4004
4059
|
gap && `Previous attempt failed the verification:\n${gap}`,
|
|
4060
|
+
factsLine && `The close's own numbers so far, oldest first (facts only — no advice):\n${factsLine}`,
|
|
4005
4061
|
rootInj && rootInj.note,
|
|
4006
4062
|
].filter(Boolean).join('\n\n'));
|
|
4007
4063
|
};
|
package/src/promptregisters.js
CHANGED
|
@@ -76,6 +76,9 @@ export const PROMPT_REGISTERS = Object.freeze([
|
|
|
76
76
|
|
|
77
77
|
// src/planrun.js — the native-surface read-truncation strategy line
|
|
78
78
|
Object.freeze({ file: 'src/planrun.js', name: 'NATIVE_READ_STRATEGY' }),
|
|
79
|
+
// src/planrun.js — the fix loop's facts-only close-grade history line
|
|
80
|
+
// (F198 item 2), built inline inside `middle` and appended to `w.ask([...])`
|
|
81
|
+
Object.freeze({ file: 'src/planrun.js', name: 'middle()\'s facts-only "The close\'s own numbers so far" block (inline template, not a const)' }),
|
|
79
82
|
|
|
80
83
|
// src/authoring.js — the types-genre close template, interpolated verbatim
|
|
81
84
|
// into the authoring prompt (src/authorflow.js's assembled system message,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// PANEL-BUILD.md P4b — the Providers rows, driven by the keys file. NOTHING here is a
|
|
2
|
+
// hardcoded provider list: one row exists per key in `~/.config/bareloop/.env` that has a
|
|
3
|
+
// value; each row's Name / API shape / Base URL come from config.json `keys.<ENV NAME>`
|
|
4
|
+
// (src/config.js), else the per-name defaults below. This is NOT a new provider table:
|
|
5
|
+
// a row's API shape names an entry that already exists in `src/providers.js`
|
|
6
|
+
// (`PROVIDER_TABLE`), and a row's Base URL is that provider's `baseUrl` option.
|
|
7
|
+
//
|
|
8
|
+
// This module is also THE one owner of "model -> key + shape + URL": Chat's Model menu,
|
|
9
|
+
// the author doors and the run doors all resolve through `findRow` / `keyNameFor` /
|
|
10
|
+
// `applyConfiguredKey` / `modelChoiceFor` below — no second lookup exists. Key VALUES
|
|
11
|
+
// never pass through here — only names.
|
|
12
|
+
import { resolveProvider } from './providers.js';
|
|
13
|
+
import { readConfig } from './config.js';
|
|
14
|
+
import { filledKeyNames } from './keysfile.js';
|
|
15
|
+
|
|
16
|
+
/** DeepSeek's OpenAI-shaped endpoint (the same string the panel's Model menu used). */
|
|
17
|
+
export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The API shapes the dropdown offers. `id` is the `src/providers.js` table name;
|
|
21
|
+
* `defaultUrl` is the host a blank Base URL means ('' = the vendor's own, unshown).
|
|
22
|
+
* @type {readonly {id: string, label: string, defaultUrl: string}[]}
|
|
23
|
+
*/
|
|
24
|
+
export const SHAPES = Object.freeze([
|
|
25
|
+
Object.freeze({ id: 'anthropic-api', label: 'Anthropic', defaultUrl: 'https://api.anthropic.com/v1' }),
|
|
26
|
+
Object.freeze({ id: 'openai-api', label: 'OpenAI-compatible', defaultUrl: 'https://api.openai.com/v1' }),
|
|
27
|
+
Object.freeze({ id: 'gemini-api', label: 'Gemini', defaultUrl: '' }),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** The five empty lines a fresh keys file is created with, in this order. */
|
|
31
|
+
export const PRESET_KEY_NAMES = Object.freeze(['ANTHROPIC_API_KEY', 'DEEPSEEK_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'LOCAL_API_KEY']);
|
|
32
|
+
|
|
33
|
+
/** A local server's default endpoint: Ollama's OpenAI-compatible host (LM Studio / llama.cpp: change the URL). */
|
|
34
|
+
export const LOCAL_BASE_URL = 'http://127.0.0.1:11434/v1';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A key line whose value is the word `null` is SET and means "no real key": the row exists and
|
|
38
|
+
* the provider is handed this harmless stand-in (local servers ignore auth). It is not a
|
|
39
|
+
* secret and is never shown or logged as a key.
|
|
40
|
+
*/
|
|
41
|
+
export const NO_KEY_PLACEHOLDER = 'no-key-needed';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The key value to hand a provider: the `null` marker becomes the placeholder, anything else
|
|
45
|
+
* passes through unchanged.
|
|
46
|
+
* @template {string|undefined} T
|
|
47
|
+
* @param {T} raw
|
|
48
|
+
* @returns {T|string}
|
|
49
|
+
*/
|
|
50
|
+
export function usableKey(raw) {
|
|
51
|
+
return raw === 'null' ? NO_KEY_PLACEHOLDER : raw;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What a key with no saved entry shows. Any name not listed is OpenAI-compatible with a
|
|
56
|
+
* blank URL and a blank Name.
|
|
57
|
+
* @param {string} envName
|
|
58
|
+
* @returns {{ name: string, shape: string, baseUrl: string }}
|
|
59
|
+
*/
|
|
60
|
+
export function defaultsFor(envName) {
|
|
61
|
+
switch (envName) {
|
|
62
|
+
case 'ANTHROPIC_API_KEY': return { name: 'claude-sonnet-5', shape: 'anthropic-api', baseUrl: '' };
|
|
63
|
+
case 'DEEPSEEK_API_KEY': return { name: 'deepseek-flash', shape: 'openai-api', baseUrl: DEEPSEEK_BASE_URL };
|
|
64
|
+
case 'GEMINI_API_KEY': return { name: '', shape: 'gemini-api', baseUrl: '' };
|
|
65
|
+
case 'LOCAL_API_KEY': return { name: '', shape: 'openai-api', baseUrl: LOCAL_BASE_URL };
|
|
66
|
+
default: return { name: '', shape: 'openai-api', baseUrl: '' };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {object} KeyRow
|
|
72
|
+
* @property {string} envName the key variable NAME (read-only label; never a value)
|
|
73
|
+
* @property {string} name the model id Chat uses ('' = not offered in Chat)
|
|
74
|
+
* @property {string} provider the `src/providers.js` table entry (the API shape's id)
|
|
75
|
+
* @property {string} baseUrl the saved Base URL ('' = the shape's default host)
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The rows: one per name in `filled`, saved entry over defaults.
|
|
80
|
+
* @param {{ filled: readonly string[], config: Record<string, any> }} src
|
|
81
|
+
* @returns {KeyRow[]}
|
|
82
|
+
*/
|
|
83
|
+
export function keyRows({ filled, config }) {
|
|
84
|
+
const saved = config?.keys && typeof config.keys === 'object' && !Array.isArray(config.keys) ? config.keys : {};
|
|
85
|
+
return filled.map((envName) => {
|
|
86
|
+
const d = defaultsFor(envName);
|
|
87
|
+
const s = saved[envName] && typeof saved[envName] === 'object' ? saved[envName] : {};
|
|
88
|
+
return {
|
|
89
|
+
envName,
|
|
90
|
+
name: typeof s.name === 'string' ? s.name : d.name,
|
|
91
|
+
provider: SHAPES.some((x) => x.id === s.shape) ? s.shape : d.shape,
|
|
92
|
+
baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl : d.baseUrl,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The rows for a keys home. `undefined` home (a door that skipped the keys file) = no
|
|
99
|
+
* rows, so nothing resolves through the file and the provider's built-in variable stands.
|
|
100
|
+
* @param {string|undefined} home
|
|
101
|
+
* @returns {KeyRow[]}
|
|
102
|
+
*/
|
|
103
|
+
export function rowsForHome(home) {
|
|
104
|
+
if (home === undefined) return [];
|
|
105
|
+
return keyRows({ filled: filledKeyNames(home), config: readConfig({ home }).config });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} provider
|
|
110
|
+
* @returns {string} the shape's default host ('' = the vendor's own)
|
|
111
|
+
*/
|
|
112
|
+
export function defaultUrlOf(provider) {
|
|
113
|
+
return SHAPES.find((s) => s.id === provider)?.defaultUrl ?? '';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* An endpoint in comparable form: blank = the shape's default host; scheme + host lower-cased,
|
|
118
|
+
* trailing slashes dropped.
|
|
119
|
+
* @param {string} provider
|
|
120
|
+
* @param {string|null|undefined} baseUrl
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
export function endpointOf(provider, baseUrl) {
|
|
124
|
+
const raw = (typeof baseUrl === 'string' && baseUrl.trim() !== '' ? baseUrl : defaultUrlOf(provider)).trim();
|
|
125
|
+
if (raw === '') return '';
|
|
126
|
+
try {
|
|
127
|
+
const u = new URL(raw);
|
|
128
|
+
return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, '')}`;
|
|
129
|
+
} catch { return raw.replace(/\/+$/, ''); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The URL a row talks to, for display ('' = the vendor's own host, which has no URL to show).
|
|
134
|
+
* @param {KeyRow} row
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function shownUrl(row) {
|
|
138
|
+
return row.baseUrl.trim() !== '' ? row.baseUrl : defaultUrlOf(row.provider);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Which row a `(provider, baseUrl[, model])` identity belongs to: the rows on that shape and
|
|
143
|
+
* endpoint; when several share it, the one whose Name is the model, else the first.
|
|
144
|
+
* @param {readonly KeyRow[]} rows
|
|
145
|
+
* @param {{ provider?: string|null, baseUrl?: string|null, model?: string|null }} id
|
|
146
|
+
* @returns {KeyRow|null}
|
|
147
|
+
*/
|
|
148
|
+
export function findRow(rows, { provider, baseUrl, model }) {
|
|
149
|
+
if (typeof provider !== 'string') return null;
|
|
150
|
+
const want = endpointOf(provider, baseUrl);
|
|
151
|
+
const same = rows.filter((r) => r.provider === provider && endpointOf(r.provider, r.baseUrl) === want);
|
|
152
|
+
if (same.length === 0) return null;
|
|
153
|
+
return (model ? same.find((r) => r.name === model) : undefined) ?? same[0];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* What Chat's Model menu offers and how a pick resolves: the row whose Name is the model
|
|
158
|
+
* (a blank Name is never offered). `baseUrl` is absent when blank, exactly as a spec omits it.
|
|
159
|
+
* @param {readonly KeyRow[]} rows
|
|
160
|
+
* @param {string} model
|
|
161
|
+
* @returns {{ provider: string, baseUrl?: string, envName: string, name: string }|null}
|
|
162
|
+
*/
|
|
163
|
+
export function modelChoiceFor(rows, model) {
|
|
164
|
+
const r = model === '' ? undefined : rows.find((x) => x.name === model);
|
|
165
|
+
if (!r) return null;
|
|
166
|
+
return { provider: r.provider, ...(r.baseUrl.trim() !== '' ? { baseUrl: r.baseUrl.trim() } : {}), envName: r.envName, name: r.name };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The models Chat offers, in keys-file order.
|
|
171
|
+
* @param {readonly KeyRow[]} rows
|
|
172
|
+
* @returns {{ id: string, envName: string, shape: string }[]}
|
|
173
|
+
*/
|
|
174
|
+
export function chatModels(rows) {
|
|
175
|
+
return rows.filter((r) => r.name !== '').map((r) => ({
|
|
176
|
+
id: r.name, envName: r.envName, shape: SHAPES.find((s) => s.id === r.provider)?.label ?? r.provider,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The key variable NAME a job on `(provider, baseUrl[, model])` reads: the matching row's
|
|
182
|
+
* variable, else the provider's built-in one (byte-identical to before for anything no row
|
|
183
|
+
* claims).
|
|
184
|
+
* @param {string|null|undefined} provider
|
|
185
|
+
* @param {string|null|undefined} baseUrl
|
|
186
|
+
* @param {readonly KeyRow[]} rows
|
|
187
|
+
* @param {string|null|undefined} [model]
|
|
188
|
+
* @returns {{ name: string, builtIn: string, chosen: boolean }}
|
|
189
|
+
*/
|
|
190
|
+
export function keyNameFor(provider, baseUrl, rows, model) {
|
|
191
|
+
const builtIn = resolveProvider(/** @type {string} */ (provider)).envKey;
|
|
192
|
+
const row = findRow(rows, { provider, baseUrl, model });
|
|
193
|
+
return row && row.envName !== builtIn
|
|
194
|
+
? { name: row.envName, builtIn, chosen: true }
|
|
195
|
+
: { name: builtIn, builtIn, chosen: false };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The env a door hands to provider construction, with the matching row's key variable
|
|
200
|
+
* standing in for the provider's built-in one: `env[builtIn] = env[row.envName]` (the `null`
|
|
201
|
+
* no-key marker becomes {@link NO_KEY_PLACEHOLDER}). A copy — never mutates the input.
|
|
202
|
+
* No matching row and no marker = the env unchanged.
|
|
203
|
+
* @param {Record<string,string|undefined>} env
|
|
204
|
+
* @param {string|null|undefined} provider
|
|
205
|
+
* @param {string|null|undefined} baseUrl
|
|
206
|
+
* @param {readonly KeyRow[]} rows
|
|
207
|
+
* @param {string|null|undefined} [model]
|
|
208
|
+
* @returns {Record<string,string|undefined>}
|
|
209
|
+
*/
|
|
210
|
+
export function applyConfiguredKey(env, provider, baseUrl, rows, model) {
|
|
211
|
+
if (typeof provider !== 'string') return env;
|
|
212
|
+
let k;
|
|
213
|
+
try { k = keyNameFor(provider, baseUrl, rows, model); } catch { return env; } // unknown provider: resolveProvider's own named throw fires at its own door
|
|
214
|
+
const value = env[k.name];
|
|
215
|
+
if (!k.chosen && value !== 'null') return env;
|
|
216
|
+
return { ...env, [k.builtIn]: usableKey(value) };
|
|
217
|
+
}
|
package/src/providers.js
CHANGED
|
@@ -320,6 +320,90 @@ export function buildRunnerProviders({
|
|
|
320
320
|
return { provider, providerFor, judgeProvider };
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
/**
|
|
324
|
+
* A `providerName`'s own models-list GET endpoint — the shape this
|
|
325
|
+
* function's ONE caller (`checkProviderReachable`, below) needs and nothing
|
|
326
|
+
* else reads: `anthropic-api` and `openai-api` (the panel's Model select,
|
|
327
|
+
* PANEL-BUILD.md P3 build item 7) are the only two shapes this build knows
|
|
328
|
+
* how to ask, both a plain unauthenticated-body GET with the key in a
|
|
329
|
+
* header — never a completion/chat endpoint, so this never spends a token.
|
|
330
|
+
* @param {string} providerName
|
|
331
|
+
* @param {string} [baseUrl]
|
|
332
|
+
* @returns {{url: string, headers: Record<string,string>}|null} `null` when
|
|
333
|
+
* this provider's models-list shape is not known (never guessed at)
|
|
334
|
+
*/
|
|
335
|
+
function modelsListRequest(providerName, baseUrl) {
|
|
336
|
+
if (providerName === 'anthropic-api') {
|
|
337
|
+
return {
|
|
338
|
+
url: `${baseUrl || 'https://api.anthropic.com/v1'}/models`,
|
|
339
|
+
headers: { 'x-api-key': '', 'anthropic-version': '2023-06-01' },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (providerName === 'openai-api') {
|
|
343
|
+
return { url: `${baseUrl || 'https://api.openai.com/v1'}/models`, headers: {} };
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* $0 reachability check: a GET against `providerName`'s own models-list
|
|
350
|
+
* endpoint, server-side, with the key in a header — NEVER a completion
|
|
351
|
+
* request, so it never spends a token (PANEL-BUILD.md P3 build item 7: "the
|
|
352
|
+
* person must get feedback BEFORE drafting if the chosen model can't
|
|
353
|
+
* work"). Reused, unmodified, by the panel's model-readiness route today
|
|
354
|
+
* (`src/panel/authorroutes.js`) and by the future Settings providers
|
|
355
|
+
* table's own "test" column (PANEL-BUILD.md P4) — ONE function, not two
|
|
356
|
+
* hand-typed copies of the same probe.
|
|
357
|
+
*
|
|
358
|
+
* NEVER returns the key, never logs it — the key only ever appears in a
|
|
359
|
+
* request header sent straight to the provider's own host.
|
|
360
|
+
* @param {object} o
|
|
361
|
+
* @param {string} o.providerName
|
|
362
|
+
* @param {string} o.apiKey
|
|
363
|
+
* @param {string} [o.model] when given, the response is also checked for this model id
|
|
364
|
+
* @param {string} [o.baseUrl]
|
|
365
|
+
* @param {typeof fetch} [o.fetchImpl] TEST SEAM — defaults to the real global `fetch`
|
|
366
|
+
* @param {number} [o.timeoutMs] defaults to 4000 — a flaky endpoint must never hang the caller
|
|
367
|
+
* @returns {Promise<{reachable: boolean, modelListed: boolean|null, status: string, note: string|null}>}
|
|
368
|
+
*/
|
|
369
|
+
export async function checkProviderReachable({
|
|
370
|
+
providerName, apiKey, model, baseUrl, fetchImpl = fetch, timeoutMs = 4000,
|
|
371
|
+
}) {
|
|
372
|
+
const req = modelsListRequest(providerName, baseUrl);
|
|
373
|
+
if (!req) {
|
|
374
|
+
return {
|
|
375
|
+
reachable: false, modelListed: null, status: 'unsupported-provider',
|
|
376
|
+
note: `no models-list check known for "${providerName}"`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const headers = { ...req.headers };
|
|
380
|
+
if (providerName === 'anthropic-api') headers['x-api-key'] = apiKey;
|
|
381
|
+
else headers.Authorization = `Bearer ${apiKey}`;
|
|
382
|
+
|
|
383
|
+
const controller = new AbortController();
|
|
384
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
385
|
+
try {
|
|
386
|
+
// GET ONLY — no body, no completion/chat call, no tokens spent.
|
|
387
|
+
const res = await fetchImpl(req.url, { method: 'GET', headers, signal: controller.signal });
|
|
388
|
+
if (!res.ok) {
|
|
389
|
+
return { reachable: false, modelListed: null, status: `HTTP ${res.status}`, note: `models list returned HTTP ${res.status}` };
|
|
390
|
+
}
|
|
391
|
+
/** @type {any} */
|
|
392
|
+
let body = null;
|
|
393
|
+
try { body = await res.json(); } catch { body = null; }
|
|
394
|
+
const ids = Array.isArray(body?.data) ? body.data.map((/** @type {any} */ m) => m?.id).filter(Boolean) : [];
|
|
395
|
+
const modelListed = model ? ids.includes(model) : null;
|
|
396
|
+
return { reachable: true, modelListed, status: 'ok', note: null };
|
|
397
|
+
} catch (/** @type {any} */ e) {
|
|
398
|
+
const cls = e?.name === 'AbortError' ? 'timeout' : (e?.code || e?.name || 'network-error');
|
|
399
|
+
// connection refused surfaces as fetch's bare TypeError ("fetch failed") — say it plainly
|
|
400
|
+
const refused = e?.name === 'TypeError' || e?.code === 'ECONNREFUSED' || e?.cause?.code === 'ECONNREFUSED';
|
|
401
|
+
return { reachable: false, modelListed: null, status: String(cls), note: refused ? `nothing answering at ${req.url}` : `request failed: ${cls}` };
|
|
402
|
+
} finally {
|
|
403
|
+
clearTimeout(timer);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
323
407
|
/**
|
|
324
408
|
* The launch-time warning lines for an ADMITTED-PENDING-PROBE provider, or
|
|
325
409
|
* `null` when the provider has paid for its own end-to-end probe (and for an
|