bareloop 0.40.0 → 0.42.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 +96 -1
- package/README.md +13 -21
- package/bareloop.context.md +141 -128
- package/package.json +5 -4
- package/src/authorflow.js +346 -38
- package/src/authoring.js +92 -7
- package/src/authorjob.js +82 -20
- package/src/authorreadout.js +89 -5
- package/src/authorrun.js +39 -30
- package/src/authorscout.js +112 -4
- package/src/bundle.js +7 -4
- package/src/bundlerun.js +4 -1
- package/src/cli.js +1 -1
- package/src/config.js +129 -29
- package/src/declaredclose.js +23 -12
- package/src/draftspend.js +4 -4
- package/src/facts.js +317 -0
- package/src/factwords.js +129 -0
- package/src/index.js +2 -4
- package/src/inputlook.js +301 -0
- package/src/interviewrun.js +17 -25
- package/src/job.js +15 -8
- package/src/jobblock.js +7 -2
- package/src/judged.js +11 -43
- package/src/keysfile.js +15 -7
- package/src/kinds.js +86 -5
- package/src/labelscan.js +170 -0
- package/src/modellist.js +84 -0
- package/src/monthly.js +23 -43
- package/src/pagefetch.js +125 -0
- package/src/panel/authorroutes.js +34 -16
- package/src/panel/authorsession.js +161 -80
- package/src/panel/index.html +553 -152
- package/src/panel/jobplan.js +34 -11
- package/src/panel/runroutes.js +3 -0
- package/src/panel/server.js +54 -18
- package/src/panel/settingsroutes.js +232 -173
- package/src/planrun.js +5 -4
- package/src/providerrows.js +226 -141
- package/src/providers.js +58 -332
- package/src/providershapes.js +18 -0
- package/src/replay.js +1 -1
- package/src/rubricstep.js +804 -0
- package/src/source.js +166 -1
- package/src/thinking.js +130 -0
- package/src/u-readout.js +1 -1
- package/src/userrun.js +64 -75
- package/types/authorflow.d.ts +272 -18
- package/types/authoring.d.ts +29 -1
- package/types/authorjob.d.ts +9 -7
- package/types/authorreadout.d.ts +27 -2
- package/types/authorscout.d.ts +72 -2
- package/types/config.d.ts +23 -6
- package/types/declaredclose.d.ts +4 -2
- package/types/draftspend.d.ts +8 -8
- package/types/facts.d.ts +71 -0
- package/types/factwords.d.ts +42 -0
- package/types/index.d.ts +1 -1
- package/types/inputlook.d.ts +238 -0
- package/types/job.d.ts +5 -5
- package/types/jobblock.d.ts +8 -1
- package/types/judged.d.ts +5 -36
- package/types/keysfile.d.ts +15 -4
- package/types/labelscan.d.ts +38 -0
- package/types/modellist.d.ts +50 -0
- package/types/monthly.d.ts +4 -3
- package/types/pagefetch.d.ts +71 -0
- package/types/panel/authorsession.d.ts +20 -11
- package/types/panel/jobplan.d.ts +12 -4
- package/types/panel/server.d.ts +18 -6
- package/types/providerrows.d.ts +192 -99
- package/types/providers.d.ts +34 -159
- package/types/providershapes.d.ts +10 -0
- package/types/rubricstep.d.ts +286 -0
- package/types/source.d.ts +94 -0
- package/types/thinking.d.ts +90 -0
- package/types/userrun.d.ts +17 -1
|
@@ -1,26 +1,35 @@
|
|
|
1
|
-
//
|
|
1
|
+
// rewritten-from: fwdloop src/panel/settings.js@f9449e8 (the Providers routes; the Money routes are bareloop's own)
|
|
2
|
+
// PANEL-BUILD.md P4a / P8 — Settings routes (`/api/settings/*`). A separate module from the
|
|
2
3
|
// authoring routes on purpose: the chat/authoring session can reach NO route here (it has
|
|
3
4
|
// no path to this file's handlers), and every route here is behind the same human-click
|
|
4
5
|
// guard (per-server-start token + Origin/Host, `checkHumanGuard`) — GET too, because the
|
|
5
6
|
// pages these serve name key variables and spend.
|
|
6
7
|
//
|
|
7
|
-
// Providers (
|
|
8
|
-
//
|
|
8
|
+
// Providers (P8): the rows are the keys file's filled lines (plus a shell-only key that config
|
|
9
|
+
// already names); API shape / Base URL / old-max_tokens save to config.json `keys.<ENV NAME>`
|
|
10
|
+
// and the model lines to `models.<ENV NAME>` (src/providerrows.js is the one owner of the rows).
|
|
11
|
+
// Test and Refresh are ONE free GET of `<Base URL>/models` (src/modellist.js), never a completion,
|
|
12
|
+
// and write no spend row. Every refusal is a plain sentence in `say`; the page shows `say`, never
|
|
13
|
+
// a code or an HTTP status.
|
|
9
14
|
//
|
|
10
15
|
// Every write goes to `~/.config/bareloop/config.json` through `src/config.js` (the one
|
|
11
16
|
// writer); the monthly limit is a person's own number, set by hand — nothing here (and
|
|
12
17
|
// nothing the agent can reach) raises it on their behalf. Key VALUES never appear in any
|
|
13
18
|
// response: names and found / not set only.
|
|
14
19
|
import { checkHumanGuard } from './authorroutes.js';
|
|
15
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
readConfig, updateConfig, ConfigError, providerFieldProblem, cleanBaseUrl, modelsProblem,
|
|
22
|
+
} from '../config.js';
|
|
16
23
|
import { spendSummary, monthlyLimitOf } from '../monthly.js';
|
|
17
24
|
import { spawn as realSpawn } from 'node:child_process';
|
|
18
25
|
import { loadKeysEnv, keysFilePath, keysHome, filledKeyNames, ensureKeysFile } from '../keysfile.js';
|
|
19
|
-
import { SHAPES
|
|
20
|
-
import {
|
|
26
|
+
import { SHAPES } from '../providershapes.js';
|
|
27
|
+
import { keyRows, effectiveModels, usableKey, rowNamesOf } from '../providerrows.js';
|
|
28
|
+
import { apiKeyProblem } from '../providers.js';
|
|
29
|
+
import { listModels } from '../modellist.js';
|
|
21
30
|
|
|
22
|
-
|
|
23
|
-
const
|
|
31
|
+
const BROKEN_CONFIG = 'Your settings file (config.json) cannot be read. Fix or remove it, then look again.';
|
|
32
|
+
const BAD_KEY = 'The key has a space or a line break in it. Fix it in your keys file, then reload keys.';
|
|
24
33
|
|
|
25
34
|
/**
|
|
26
35
|
* `openFolderSpawn` / `platform` are test seams for the Open keys folder button (a real caller passes
|
|
@@ -36,11 +45,209 @@ export function createSettingsRoutes(opts) {
|
|
|
36
45
|
* module; values never do.
|
|
37
46
|
* @returns {ReturnType<typeof loadKeysEnv>} */
|
|
38
47
|
const keys = () => loadKeysEnv({ env: opts.env, home: opts.home });
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The row names: every key in the keys file with a value, plus a key only the SHELL holds when
|
|
51
|
+
* config already names it (`keys.<ENV>` or `models.<ENV>`).
|
|
52
|
+
* @param {Record<string, any>} config
|
|
53
|
+
* @returns {{ filled: string[], shellOnly: string[], all: string[] }}
|
|
54
|
+
*/
|
|
55
|
+
function rowNames(config) {
|
|
56
|
+
const filled = filledKeyNames(opts.home);
|
|
57
|
+
const all = rowNamesOf(filled, config, opts.env ?? process.env);
|
|
58
|
+
return { filled, shellOnly: all.filter((n) => !filled.includes(n)), all };
|
|
59
|
+
}
|
|
60
|
+
/** config.json, or a plain-words problem (never read as "nothing set"). @returns {{config: Record<string, any>, problem: string|null}} */
|
|
61
|
+
function config() {
|
|
62
|
+
const c = readConfig({ home: opts.home });
|
|
63
|
+
return c.problem ? { config: {}, problem: BROKEN_CONFIG } : { config: c.config, problem: null };
|
|
64
|
+
}
|
|
65
|
+
/** @param {string} refused @param {string} say @param {number} [status] */
|
|
66
|
+
const refuse = (refused, say, status = 400) => ({ status, body: { ok: false, refused, say } });
|
|
67
|
+
/** @param {number} status @param {any} body */
|
|
68
|
+
const reply = (status, body) => ({ status, body });
|
|
69
|
+
/** The row a request names, or null: only a key that is a row is a provider. @param {unknown} slot @param {Record<string, any>} cfg */
|
|
70
|
+
const slotOf = (slot, cfg) => (typeof slot === 'string' && rowNames(cfg).all.includes(slot) ? slot : null);
|
|
71
|
+
|
|
72
|
+
/** One row's key for a header only; or `{why}` in plain words. @param {string} slot @param {ReturnType<typeof loadKeysEnv>} k @returns {{ key?: string, why?: string }} */
|
|
73
|
+
function keyFor(slot, k) {
|
|
74
|
+
if (k.unreadable) return { why: String(k.warning) };
|
|
75
|
+
const value = k.env[slot];
|
|
76
|
+
if (value === undefined || value === '') return { why: 'No key set for this provider.' };
|
|
77
|
+
if (value !== 'null' && apiKeyProblem(value)) return { why: BAD_KEY };
|
|
78
|
+
return { key: /** @type {string} */ (usableKey(value)) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Test and Refresh: the ONE free GET (src/modellist.js) for a row. `{unknown:true}` when the slot is not a row.
|
|
83
|
+
* @param {unknown} rawSlot
|
|
84
|
+
* @returns {Promise<{unknown: true}|{ok: true, models: string[], ms: number}|{ok: false, why: string}>}
|
|
85
|
+
*/
|
|
86
|
+
async function listFor(rawSlot) {
|
|
87
|
+
const c = config();
|
|
88
|
+
const k = keys();
|
|
89
|
+
const slot = slotOf(rawSlot, c.config);
|
|
90
|
+
if (!slot) return { unknown: true };
|
|
91
|
+
if (c.problem) return { ok: false, why: c.problem };
|
|
92
|
+
const row = keyRows({ filled: [slot], config: c.config })[0];
|
|
93
|
+
// the address comes first: a row with no address has nothing to send yet
|
|
94
|
+
if (row.baseUrl.trim() === '') return { ok: false, why: `Type a Base URL for ${slot} in Settings.` };
|
|
95
|
+
const kf = keyFor(slot, k);
|
|
96
|
+
if (kf.why) return { ok: false, why: kf.why };
|
|
97
|
+
const r = await listModels({ shape: row.provider, baseUrl: row.baseUrl, key: /** @type {string} */ (kf.key), fetchImpl });
|
|
98
|
+
return r.ok ? r : { ok: false, why: r.why };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The Providers GET. */
|
|
102
|
+
function providers() {
|
|
103
|
+
const k = keys();
|
|
104
|
+
const c = config();
|
|
105
|
+
const names = rowNames(c.config);
|
|
106
|
+
const rows = keyRows({ filled: names.all, config: c.config });
|
|
107
|
+
const spend = spendSummary({ home: opts.home, now: opts.now, rows });
|
|
108
|
+
return reply(200, {
|
|
109
|
+
ok: true,
|
|
110
|
+
keysFile: { path: keysFilePath(opts.home), exists: k.exists, refusal: k.warning },
|
|
111
|
+
configProblem: c.problem,
|
|
112
|
+
shapes: SHAPES.map((x) => ({ id: x.id, label: x.label })),
|
|
113
|
+
rows: rows.map((r) => {
|
|
114
|
+
const raw = k.env[r.envName];
|
|
115
|
+
let keyStatus = 'not set';
|
|
116
|
+
if (k.unreadable) keyStatus = 'refused — see above';
|
|
117
|
+
else if (raw && raw !== 'null' && apiKeyProblem(raw)) keyStatus = BAD_KEY;
|
|
118
|
+
else if (names.filled.includes(r.envName)) keyStatus = 'set in file';
|
|
119
|
+
else if (names.shellOnly.includes(r.envName)) keyStatus = 'found in shell';
|
|
120
|
+
const own = spend.tokensByModel[r.envName] ?? {};
|
|
121
|
+
return {
|
|
122
|
+
slot: r.envName, label: r.envName, envVar: r.envName, keyStatus,
|
|
123
|
+
shape: r.provider, baseUrl: r.baseUrl, legacyMaxTokens: r.legacyMaxTokens,
|
|
124
|
+
tokens: spend.tokensByRow[r.envName] ?? 0,
|
|
125
|
+
models: r.models.map(({ model, use, inPerM, outPerM }) => ({
|
|
126
|
+
model, use, ...(inPerM === undefined ? {} : { inPerM }), ...(outPerM === undefined ? {} : { outPerM }),
|
|
127
|
+
// this model's own tokens from the spend rows that name it; absent = no row names it (never 0)
|
|
128
|
+
...(Object.hasOwn(own, model) ? { tokens: own[model] } : {}),
|
|
129
|
+
})),
|
|
130
|
+
};
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Save one model line: {slot, model, use?, inPerM?, outPerM?}. A field left out keeps the line's value; a price
|
|
137
|
+
* `null` clears it. The WHOLE list is judged by `modelsProblem` (the one rule) before anything is written.
|
|
138
|
+
* @param {any} body
|
|
139
|
+
*/
|
|
140
|
+
function modelRoute(body) {
|
|
141
|
+
const c = config();
|
|
142
|
+
const k = keys();
|
|
143
|
+
const slot = slotOf(body?.slot, c.config);
|
|
144
|
+
if (!slot) return refuse('unknown-provider', 'That provider is not known.');
|
|
145
|
+
if (c.problem) return refuse('config-broken', `${BROKEN_CONFIG} Nothing was saved.`);
|
|
146
|
+
if (Object.hasOwn(body, 'cachedInPerM')) {
|
|
147
|
+
return refuse('bad-price', 'Cached input has no price of its own: it is priced as input. Nothing was saved.');
|
|
148
|
+
}
|
|
149
|
+
if (typeof body.model !== 'string') return refuse('bad-model', 'Pick a model line. Nothing was saved.');
|
|
150
|
+
const list = effectiveModels(slot, c.config).map((m) => ({ ...m }));
|
|
151
|
+
/** @type {Record<string, any>|undefined} */
|
|
152
|
+
let line = list.find((m) => m.model === body.model);
|
|
153
|
+
if (!line) { line = { model: body.model, use: false }; list.push(/** @type {any} */ (line)); }
|
|
154
|
+
if (Object.hasOwn(body, 'use')) line.use = body.use;
|
|
155
|
+
for (const f of /** @type {const} */ (['inPerM', 'outPerM'])) {
|
|
156
|
+
if (!Object.hasOwn(body, f)) continue;
|
|
157
|
+
if (body[f] === null) delete line[f];
|
|
158
|
+
else line[f] = body[f];
|
|
159
|
+
}
|
|
160
|
+
const problem = modelsProblem(list);
|
|
161
|
+
if (problem) return refuse('bad-model', problem);
|
|
162
|
+
try {
|
|
163
|
+
updateConfig({ models: { [slot]: list } }, { home: opts.home });
|
|
164
|
+
} catch (e) {
|
|
165
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
166
|
+
return refuse('config-broken', `${BROKEN_CONFIG} Nothing was saved.`);
|
|
167
|
+
}
|
|
168
|
+
return reply(200, { ok: true, slot });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Save a row's shape, Base URL and old-max_tokens tick. Every field is judged first; write only if all pass. @param {any} body */
|
|
172
|
+
function providerRoute(body) {
|
|
173
|
+
const c = config();
|
|
174
|
+
const slot = slotOf(body?.slot, c.config);
|
|
175
|
+
if (!slot) return refuse('unknown-provider', 'That provider is not known.');
|
|
176
|
+
if (c.problem) return refuse('config-broken', `${BROKEN_CONFIG} Nothing was saved.`);
|
|
177
|
+
/** @type {Record<string, string>} */
|
|
178
|
+
const say = { shape: 'Pick an API shape from the list.', baseUrl: 'The Base URL must start with http:// or https://.', legacyMaxTokens: 'The legacy max_tokens tick must be on or off.' };
|
|
179
|
+
/** @type {Record<string, string|boolean|null>} */
|
|
180
|
+
const patch = {};
|
|
181
|
+
for (const key of Object.keys(say)) {
|
|
182
|
+
if (!Object.hasOwn(body, key)) continue;
|
|
183
|
+
const v = body[key];
|
|
184
|
+
const clearUrl = key === 'baseUrl' && v === '';
|
|
185
|
+
if (!clearUrl && providerFieldProblem(key, v) !== null) return refuse('bad-provider', `${say[key]} Nothing was saved.`);
|
|
186
|
+
patch[key] = clearUrl ? null : (key === 'baseUrl' ? cleanBaseUrl(v) : v);
|
|
187
|
+
}
|
|
188
|
+
if (Object.keys(patch).length === 0) return refuse('bad-provider', 'Nothing to save.');
|
|
189
|
+
try {
|
|
190
|
+
updateConfig({ keys: { [slot]: patch } }, { home: opts.home });
|
|
191
|
+
} catch (e) {
|
|
192
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
193
|
+
return refuse('config-broken', `${BROKEN_CONFIG} Nothing was saved.`);
|
|
194
|
+
}
|
|
195
|
+
return reply(200, { ok: true, slot });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Open the FOLDER of the server's own keys file (never a file, never an editor, never a path from the request) in the OS file manager:
|
|
200
|
+
* argv array, no shell, detached. Another OS, a failed spawn, or a test process with no injected opener: `opened:false` + the path,
|
|
201
|
+
* which the page shows as text.
|
|
202
|
+
* @param {any} body
|
|
203
|
+
* @returns {Promise<{status: number, body: any}>}
|
|
204
|
+
*/
|
|
205
|
+
function openKeysFolder(body) {
|
|
206
|
+
if (body !== null && typeof body === 'object' && Object.keys(body).length > 0) {
|
|
207
|
+
return Promise.resolve(refuse('no-path-allowed', 'The keys folder is opened from the panel\'s own settings; a path cannot be sent. Nothing was opened.'));
|
|
208
|
+
}
|
|
209
|
+
const dir = keysHome(opts.home);
|
|
210
|
+
const cannot = () => reply(200, { ok: true, opened: false, path: dir });
|
|
211
|
+
const platform = opts.platform ?? process.platform;
|
|
212
|
+
const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
|
|
213
|
+
// under a test process (NODE_ENV=test or `node --test`) nothing is spawned unless a test injected an opener
|
|
214
|
+
const underTest = process.env.NODE_ENV === 'test' || process.env.NODE_TEST_CONTEXT !== undefined;
|
|
215
|
+
const opener = opts.openFolderSpawn ?? (underTest ? null : realSpawn);
|
|
216
|
+
if (!cmd || !opener) return Promise.resolve(cannot());
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
let child;
|
|
219
|
+
try { child = opener(cmd, [dir], { detached: true, stdio: 'ignore' }); } catch { resolve(cannot()); return; }
|
|
220
|
+
let settled = false;
|
|
221
|
+
child.once('error', () => { if (!settled) { settled = true; resolve(cannot()); } });
|
|
222
|
+
child.once('spawn', () => {
|
|
223
|
+
if (settled) return;
|
|
224
|
+
settled = true;
|
|
225
|
+
if (typeof child.unref === 'function') child.unref();
|
|
226
|
+
resolve(reply(200, { ok: true, opened: true, path: dir }));
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The POST routes that answer asynchronously.
|
|
233
|
+
* @param {string} pathname
|
|
234
|
+
* @param {any} body
|
|
235
|
+
* @returns {Promise<{status: number, body: any}>|{status: number, body: any}}
|
|
236
|
+
*/
|
|
237
|
+
function postRoute(pathname, body) {
|
|
238
|
+
if (pathname === '/api/settings/provider') return providerRoute(body);
|
|
239
|
+
if (pathname === '/api/settings/model') return modelRoute(body);
|
|
240
|
+
if (pathname === '/api/settings/open-keys-folder') return openKeysFolder(body);
|
|
241
|
+
return listFor(body?.slot).then((r) => {
|
|
242
|
+
if ('unknown' in r) return refuse('unknown-provider', 'That provider is not known.');
|
|
243
|
+
if (pathname === '/api/settings/test') return reply(200, r.ok ? { ok: true, ms: r.ms } : r);
|
|
244
|
+
// /api/settings/models
|
|
245
|
+
if (!r.ok) return reply(200, r);
|
|
246
|
+
const ids = r.models;
|
|
247
|
+
return reply(200, ids.length === 0 ? { ok: false, why: 'The provider did not list any models.' } : { ok: true, models: ids });
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
44
251
|
/**
|
|
45
252
|
* @param {import('node:http').IncomingMessage} req
|
|
46
253
|
* @param {import('node:http').ServerResponse} res
|
|
@@ -61,7 +268,8 @@ export function createSettingsRoutes(opts) {
|
|
|
61
268
|
if (pathname === '/api/settings/money') {
|
|
62
269
|
if (req.method === 'GET') {
|
|
63
270
|
const cfg = readConfig({ home: opts.home });
|
|
64
|
-
const
|
|
271
|
+
const rows = keyRows({ filled: rowNames(cfg.config).all, config: cfg.config });
|
|
272
|
+
const s = spendSummary({ home: opts.home, now: opts.now, rows });
|
|
65
273
|
/** @type {number|null} */
|
|
66
274
|
let limit = null;
|
|
67
275
|
let configProblem = cfg.problem;
|
|
@@ -109,171 +317,22 @@ export function createSettingsRoutes(opts) {
|
|
|
109
317
|
return true;
|
|
110
318
|
}
|
|
111
319
|
|
|
112
|
-
// ── Providers (
|
|
320
|
+
// ── Providers (P8) ──
|
|
113
321
|
if (pathname === '/api/settings/providers' && req.method === 'GET') {
|
|
114
|
-
// a missing keys file is created with
|
|
115
|
-
ensureKeysFile(
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
const rows = currentRows(cfg.config);
|
|
119
|
-
const s = spendSummary({ home: opts.home, now: opts.now, rows });
|
|
120
|
-
const note = cfg.config.anthropicBalanceNote;
|
|
121
|
-
// a preset key exported only in the shell gets no row (the rows are the keys file): NAMES the
|
|
122
|
-
// person can act on, read off the RAW shell env — never a value, never a length
|
|
123
|
-
const shellEnv = opts.env ?? process.env;
|
|
124
|
-
const filled = new Set(filledKeyNames(opts.home));
|
|
125
|
-
const shellOnly = PRESET_KEY_NAMES.filter((n) => typeof shellEnv[n] === 'string' && shellEnv[n] !== '' && !filled.has(n));
|
|
126
|
-
send(200, {
|
|
127
|
-
ok: true,
|
|
128
|
-
shellOnly,
|
|
129
|
-
keysFile: { path: keysFilePath(opts.home), exists: k.exists, names: k.names, warning: k.warning },
|
|
130
|
-
configProblem: cfg.problem,
|
|
131
|
-
shapes: SHAPES.map((x) => ({ id: x.id, label: x.label })),
|
|
132
|
-
rows: rows.map((r) => {
|
|
133
|
-
const raw = k.env[r.envName];
|
|
134
|
-
const problem = raw ? apiKeyProblem(raw) : null;
|
|
135
|
-
const isDeepseek = r.provider === 'openai-api' && hostOf(endpointOf(r.provider, r.baseUrl)) === 'api.deepseek.com';
|
|
136
|
-
return {
|
|
137
|
-
envName: r.envName, name: r.name, shape: r.provider, baseUrl: r.baseUrl,
|
|
138
|
-
placeholder: defaultUrlOf(r.provider),
|
|
139
|
-
keyStatus: !raw ? 'not set' : (raw === 'null' ? 'no key needed' : (problem ? `bad shape (${problem})` : 'found')),
|
|
140
|
-
canTest: r.provider !== 'gemini-api',
|
|
141
|
-
priceInPerM: typeof r.priceInPerM === 'number' ? r.priceInPerM : null,
|
|
142
|
-
priceOutPerM: typeof r.priceOutPerM === 'number' ? r.priceOutPerM : null,
|
|
143
|
-
tokens: s.tokensByRow[r.envName] ?? 0,
|
|
144
|
-
balance: r.provider === 'anthropic-api'
|
|
145
|
-
? { kind: 'note', usd: typeof note === 'number' && Number.isFinite(note) ? note : null }
|
|
146
|
-
: (isDeepseek ? { kind: 'fetch' } : { kind: 'none' }),
|
|
147
|
-
};
|
|
148
|
-
}),
|
|
149
|
-
});
|
|
150
|
-
return true;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (pathname === '/api/settings/providers/row' && req.method === 'POST') {
|
|
154
|
-
const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
|
|
155
|
-
if (!row) { send(400, { ok: false, error: 'unknown key — add NAME=key to your keys file and reload keys' }); return true; }
|
|
156
|
-
// only the three settings a row has; each is optional in the body, the whole triple is stored
|
|
157
|
-
/** @type {Record<string, string|number|null>} */
|
|
158
|
-
const patch = {
|
|
159
|
-
name: typeof body?.name === 'string' ? body.name.trim() : row.name,
|
|
160
|
-
shape: typeof body?.shape === 'string' ? body.shape : row.provider,
|
|
161
|
-
baseUrl: typeof body?.baseUrl === 'string' ? body.baseUrl.trim().replace(/\/+$/, '') : row.baseUrl,
|
|
162
|
-
};
|
|
163
|
-
// the two prices (USD per 1M tokens): absent = unchanged; blank / null = not set (the field is removed,
|
|
164
|
-
// never written as 0); otherwise a finite number >= 0 — anything else is refused and nothing is saved
|
|
165
|
-
for (const f of /** @type {const} */ (['priceInPerM', 'priceOutPerM'])) {
|
|
166
|
-
if (!Object.hasOwn(body ?? {}, f)) continue;
|
|
167
|
-
const v = body[f];
|
|
168
|
-
const label = f === 'priceInPerM' ? 'In' : 'Out';
|
|
169
|
-
if (v === null || (typeof v === 'string' && v.trim() === '')) { patch[f] = null; continue; }
|
|
170
|
-
const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+(\.\d+)?$/.test(v.trim()) ? Number(v.trim()) : NaN);
|
|
171
|
-
if (!(Number.isFinite(n) && n >= 0)) {
|
|
172
|
-
send(400, { ok: false, error: `the ${label} price must be a number, 0 or more (USD per 1M tokens), or blank for not set` });
|
|
173
|
-
return true;
|
|
174
|
-
}
|
|
175
|
-
patch[f] = n;
|
|
176
|
-
}
|
|
177
|
-
// a price is both fields or neither — ratesFor refuses a half-set one at run time, so never save half
|
|
178
|
-
const after = (/** @type {'priceInPerM'|'priceOutPerM'} */ f) => (Object.hasOwn(patch, f) ? patch[f] !== null : row[f] !== undefined);
|
|
179
|
-
if (after('priceInPerM') !== after('priceOutPerM')) {
|
|
180
|
-
send(400, { ok: false, error: 'set both prices (In and Out, USD per 1M tokens) or neither — a half-set price is refused' });
|
|
181
|
-
return true;
|
|
182
|
-
}
|
|
183
|
-
try {
|
|
184
|
-
updateConfig({ keys: { [row.envName]: patch } }, { home: opts.home });
|
|
185
|
-
} catch (e) {
|
|
186
|
-
if (!(e instanceof ConfigError)) throw e;
|
|
187
|
-
send(400, { ok: false, error: e.message });
|
|
188
|
-
return true;
|
|
189
|
-
}
|
|
190
|
-
send(200, { ok: true, envName: row.envName, ...patch });
|
|
191
|
-
return true;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (pathname === '/api/settings/open-keys-folder' && req.method === 'POST') {
|
|
195
|
-
// opens the FOLDER (never the file, never an editor) in the file manager; argv array, no shell, detached.
|
|
196
|
-
// Another OS, or a failed spawn: say so and hand back the path, which the page then shows as text.
|
|
197
|
-
const dir = keysHome(opts.home);
|
|
198
|
-
const platform = opts.platform ?? process.platform;
|
|
199
|
-
const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
|
|
200
|
-
const cannot = () => send(200, { ok: true, opened: false, path: dir });
|
|
201
|
-
if (!cmd) { cannot(); return true; }
|
|
202
|
-
let child;
|
|
203
|
-
try {
|
|
204
|
-
child = (opts.openFolderSpawn ?? realSpawn)(cmd, [dir], { detached: true, stdio: 'ignore' });
|
|
205
|
-
} catch { cannot(); return true; }
|
|
206
|
-
let settled = false;
|
|
207
|
-
child.once('error', () => { if (!settled) { settled = true; cannot(); } });
|
|
208
|
-
child.once('spawn', () => {
|
|
209
|
-
if (settled) return;
|
|
210
|
-
settled = true;
|
|
211
|
-
if (typeof child.unref === 'function') child.unref();
|
|
212
|
-
send(200, { ok: true, opened: true, path: dir });
|
|
213
|
-
});
|
|
214
|
-
return true;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (pathname === '/api/settings/providers/balance-note' && req.method === 'POST') {
|
|
218
|
-
// the Anthropic balance is a NOTE the person types; no check ever reads it
|
|
219
|
-
const raw = body?.usd;
|
|
220
|
-
const usd = raw === null || raw === '' || raw === undefined ? null : Number(raw);
|
|
221
|
-
if (usd !== null && !(Number.isFinite(usd) && usd >= 0)) { send(400, { ok: false, error: 'the balance must be a number, 0 or more' }); return true; }
|
|
222
|
-
try {
|
|
223
|
-
updateConfig({ anthropicBalanceNote: usd }, { home: opts.home });
|
|
224
|
-
} catch (e) {
|
|
225
|
-
if (!(e instanceof ConfigError)) throw e;
|
|
226
|
-
send(400, { ok: false, error: e.message });
|
|
227
|
-
return true;
|
|
228
|
-
}
|
|
229
|
-
send(200, { ok: true, usd });
|
|
322
|
+
// a missing keys file is created with its two comment lines (never edited if present)
|
|
323
|
+
ensureKeysFile(opts.home);
|
|
324
|
+
const r = providers();
|
|
325
|
+
send(r.status, r.body);
|
|
230
326
|
return true;
|
|
231
327
|
}
|
|
232
|
-
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
if (!row) { send(400, { ok: false, error: 'unknown key' }); return true; }
|
|
236
|
-
const raw = keys().env[row.envName];
|
|
237
|
-
if (!raw) { send(200, { ok: true, reachable: false, status: 'no key', note: `${row.envName} not set`, ms: null }); return true; }
|
|
238
|
-
const problem = apiKeyProblem(raw);
|
|
239
|
-
if (problem) { send(200, { ok: true, reachable: false, status: 'bad key', note: `${row.envName} ${problem}`, ms: null }); return true; }
|
|
240
|
-
const started = Date.now();
|
|
241
|
-
// $0: one GET of the models list with THIS row's shape + URL (never a completion), key only in a header
|
|
242
|
-
checkProviderReachable({
|
|
243
|
-
providerName: row.provider, apiKey: usableKey(raw), baseUrl: row.baseUrl.trim() !== '' ? row.baseUrl : undefined, fetchImpl, timeoutMs: 4000,
|
|
244
|
-
}).then((r) => {
|
|
245
|
-
send(200, { ok: true, reachable: r.reachable, status: r.status, note: r.note, ms: Date.now() - started });
|
|
246
|
-
});
|
|
247
|
-
return true;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (pathname === '/api/settings/providers/balance' && req.method === 'GET') {
|
|
251
|
-
// DeepSeek only: fetched server-side from the provider (key in a header, never returned)
|
|
252
|
-
const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
|
|
253
|
-
const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === q.get('env'));
|
|
254
|
-
if (!row || row.provider !== 'openai-api' || hostOf(endpointOf(row.provider, row.baseUrl)) !== 'api.deepseek.com') { send(404, { ok: false, error: 'not found' }); return true; }
|
|
255
|
-
const raw = keys().env[row.envName];
|
|
256
|
-
if (!raw || apiKeyProblem(raw)) { send(200, { ok: true, text: null, note: 'no usable key' }); return true; }
|
|
257
|
-
const controller = new AbortController();
|
|
258
|
-
const timer = setTimeout(() => controller.abort(), 4000);
|
|
259
|
-
fetchImpl(DEEPSEEK_BALANCE_URL, { method: 'GET', headers: { Authorization: `Bearer ${raw}` }, signal: controller.signal })
|
|
260
|
-
.then(async (res) => {
|
|
261
|
-
if (!res.ok) { send(200, { ok: true, text: null, note: `HTTP ${res.status}` }); return; }
|
|
262
|
-
/** @type {any} */
|
|
263
|
-
let j = null;
|
|
264
|
-
try { j = await res.json(); } catch { j = null; }
|
|
265
|
-
const infos = Array.isArray(j?.balance_infos) ? j.balance_infos : [];
|
|
266
|
-
const parts = infos
|
|
267
|
-
.filter((/** @type {any} */ b) => b && typeof b.currency === 'string' && b.total_balance !== undefined)
|
|
268
|
-
.map((/** @type {any} */ b) => `${String(b.total_balance)} ${b.currency}`);
|
|
269
|
-
send(200, { ok: true, text: parts.length ? parts.join(' + ') : null, note: parts.length ? null : 'balance not reported' });
|
|
270
|
-
})
|
|
271
|
-
.catch((/** @type {any} */ e) => { send(200, { ok: true, text: null, note: `request failed: ${e?.name === 'AbortError' ? 'timeout' : (e?.code || e?.name || 'error')}` }); })
|
|
272
|
-
.finally(() => clearTimeout(timer));
|
|
328
|
+
const postPaths = ['/api/settings/provider', '/api/settings/model', '/api/settings/models', '/api/settings/test', '/api/settings/open-keys-folder'];
|
|
329
|
+
if (req.method === 'POST' && postPaths.includes(pathname)) {
|
|
330
|
+
Promise.resolve(postRoute(pathname, body)).then((r) => send(r.status, r.body));
|
|
273
331
|
return true;
|
|
274
332
|
}
|
|
275
333
|
|
|
276
|
-
|
|
334
|
+
const r = refuse('no-such-settings-route', 'Settings cannot do that. Keys are typed into your keys file by hand.', 404);
|
|
335
|
+
send(r.status, r.body);
|
|
277
336
|
return true;
|
|
278
337
|
}
|
|
279
338
|
return { handle };
|
package/src/planrun.js
CHANGED
|
@@ -45,6 +45,7 @@ import { defaultJudgeLoop } from './judged.js';
|
|
|
45
45
|
export { rateSourceFields };
|
|
46
46
|
import { createClock, isWallTimeout } from './clock.js';
|
|
47
47
|
import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
|
|
48
|
+
import { inputLabelsOf } from './rubricstep.js';
|
|
48
49
|
import {
|
|
49
50
|
seedAtHead, seedListing, changedSet, GAP_TRIM_MARKER, GATE_AUDIT_FILE, ARBITER_BOOK_STORES,
|
|
50
51
|
HUMAN_KIND, HUMAN_DECISIONS, normalizeHumanRuling, resolveHumanRuling,
|
|
@@ -1685,7 +1686,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1685
1686
|
// ride, so `runJob`'s ONE ledger accounts a close's spend exactly as it accounts
|
|
1686
1687
|
// an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
|
|
1687
1688
|
// the same F6 pricing halt rather than being laundered into $0.
|
|
1688
|
-
judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgeRates }) : null,
|
|
1689
|
+
judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgeRates, thinking: job.thinking === true }) : null,
|
|
1689
1690
|
// the seam's other half (PRD item 32.1) — WHICH model that loop drives. Passed
|
|
1690
1691
|
// through untouched: this module resolves no judge, exactly as it constructs no
|
|
1691
1692
|
// provider. `runJudgedFloor` stops as a wiring gap when it is absent rather than
|
|
@@ -1886,7 +1887,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1886
1887
|
},
|
|
1887
1888
|
decisionReady: true,
|
|
1888
1889
|
decision: `The close minted ${outcome} and it STANDS. What happens to the work is yours: accept it, rerun it with your own words as the gap, or pause and come back.`
|
|
1889
|
-
+ (held ? ' This
|
|
1890
|
+
+ (held ? ' This is a rubric (soft-green) job, so its green earns no reuse until you accept it, whether or not a judge ran.' : ''),
|
|
1890
1891
|
options: [...HUMAN_DECISIONS],
|
|
1891
1892
|
meaning: 'not a verdict — the verdict is already minted and nothing an answer does can change it; this records a disposition',
|
|
1892
1893
|
});
|
|
@@ -2036,7 +2037,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2036
2037
|
}
|
|
2037
2038
|
const listed = await seedListing(workdir, s.seedRef);
|
|
2038
2039
|
const v = listed.stop === null
|
|
2039
|
-
? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType, writeScope: job.writeScope, jobLines: Array.isArray(job.jobLines) ? job.jobLines : null })
|
|
2040
|
+
? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType, writeScope: job.writeScope, jobLines: Array.isArray(job.jobLines) ? job.jobLines : null , inputLabels: inputLabelsOf(job.inputs) })
|
|
2040
2041
|
: { ok: false, grounded: false, reds: [{ code: 'listing-unreadable', path: 'closeDecl', detail: listed.stop }] };
|
|
2041
2042
|
emit('close-decl', {
|
|
2042
2043
|
genre: job.closeDecl.genre,
|
|
@@ -2301,7 +2302,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2301
2302
|
// chain, node_modules/bareguard/src/gate.js) — i.e. only the residual
|
|
2302
2303
|
// case of an in-fence path that still carries one of these segments.
|
|
2303
2304
|
tools: { denyArgPatterns: { write: [FORBIDDEN_WRITE_SEGMENT_PATTERN], edit: [FORBIDDEN_WRITE_SEGMENT_PATTERN] } },
|
|
2304
|
-
budget: { maxCostUsd: Math.max(remainingUsd(),
|
|
2305
|
+
budget: { maxCostUsd: Math.max(remainingUsd(), 1e-4) },
|
|
2305
2306
|
limits: { maxTurns },
|
|
2306
2307
|
audit: { path: auditPath },
|
|
2307
2308
|
// The gate audit is the THIRD persistent, append-only channel (beside the
|