bareloop 0.31.0 → 0.33.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 +151 -0
- package/README.md +4 -1
- package/bareloop.context.md +232 -85
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorflow.js +5 -3
- package/src/authorjob.js +3 -1
- package/src/authorrun.js +49 -11
- package/src/authorscout.js +10 -6
- package/src/bundlerun.js +266 -0
- package/src/cli.js +35 -357
- package/src/config.js +135 -0
- package/src/interviewrun.js +13 -4
- package/src/invoke.js +22 -0
- package/src/judged.js +5 -3
- package/src/keysfile.js +156 -0
- package/src/ledger.js +26 -0
- package/src/monthly.js +422 -0
- package/src/panel/authorroutes.js +61 -16
- package/src/panel/authorsession.js +46 -28
- package/src/panel/index.html +287 -13
- package/src/panel/server.js +58 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/planrun.js +7 -3
- package/src/providerrows.js +301 -0
- package/src/providers.js +3 -1
- package/src/run.js +7 -2
- package/src/runlist.js +92 -7
- package/src/source.js +8 -3
- package/src/u-watchdog.mjs +296 -0
- package/src/userrun.js +230 -54
- package/types/authorflow.d.ts +8 -2
- package/types/authorjob.d.ts +6 -1
- package/types/authorrun.d.ts +5 -0
- package/types/authorscout.d.ts +11 -2
- package/types/bundlerun.d.ts +26 -0
- package/types/cli.d.ts +2 -1
- package/types/config.d.ts +48 -0
- package/types/interviewrun.d.ts +5 -0
- package/types/invoke.d.ts +8 -0
- package/types/judged.d.ts +5 -1
- package/types/keysfile.d.ts +90 -0
- package/types/ledger.d.ts +14 -0
- package/types/monthly.d.ts +227 -0
- package/types/panel/authorroutes.d.ts +4 -2
- package/types/panel/authorsession.d.ts +7 -15
- package/types/panel/server.d.ts +10 -3
- package/types/panel/settingsroutes.d.ts +14 -0
- package/types/planrun.d.ts +13 -1
- package/types/providerrows.d.ts +229 -0
- package/types/run.d.ts +14 -1
- package/types/runlist.d.ts +66 -3
- package/types/source.d.ts +8 -3
- package/types/userrun.d.ts +54 -0
package/src/panel/server.js
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// `src/cli.js` calls — `src/replayio.js`'s read side and `src/runlist.js`'s
|
|
6
6
|
// run list — never a re-implementation of either.
|
|
7
7
|
//
|
|
8
|
-
// READ-ONLY, BY CONSTRUCTION: GET/HEAD only (anything else -> 405)
|
|
9
|
-
//
|
|
8
|
+
// READ-ONLY, BY CONSTRUCTION: GET/HEAD only (anything else -> 405) EXCEPT the two
|
|
9
|
+
// human-click-guarded route families in their own modules (`/api/author/*`,
|
|
10
|
+
// `/api/settings/*` — P3/P4a); no endpoint here runs a job, spends money, signs, or
|
|
11
|
+
// returns a key/.env value. Nothing
|
|
10
12
|
// here imports `src/providers.js` or touches `process.env` for a secret —
|
|
11
13
|
// grepped before writing this file, and the same discipline is kept here.
|
|
12
14
|
//
|
|
@@ -28,19 +30,23 @@ import {
|
|
|
28
30
|
dirname, join, basename, relative, isAbsolute, sep,
|
|
29
31
|
} from 'node:path';
|
|
30
32
|
import { fileURLToPath } from 'node:url';
|
|
31
|
-
import { readRunList } from '../runlist.js';
|
|
33
|
+
import { readRunList, DIED_MTIME_MS } from '../runlist.js';
|
|
32
34
|
import {
|
|
33
35
|
replayOne, parseJsonl, resolveSiblings, isSidecarByName,
|
|
34
36
|
} from '../replayio.js';
|
|
35
37
|
import { summarizeForAllLine, auditWindow } from '../replay.js';
|
|
36
38
|
import { runBehaviour } from '../behaviour.js';
|
|
37
|
-
import { SPEND_RECORD_TYPES } from '../ledger.js';
|
|
39
|
+
import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
|
|
38
40
|
import { jobSpecHash } from '../job.js';
|
|
39
41
|
import { confirmProtections } from '../authorflow.js';
|
|
40
42
|
import { createAuthorRoutes, mintToken } from './authorroutes.js';
|
|
43
|
+
import { createSettingsRoutes } from './settingsroutes.js';
|
|
41
44
|
|
|
42
45
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
43
46
|
|
|
47
|
+
/** Largest POST body the panel buffers (job cards are short prose); a bigger one is refused with 413. */
|
|
48
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
49
|
+
|
|
44
50
|
/**
|
|
45
51
|
* A runid, as it appears in a URL path segment — never a raw filesystem
|
|
46
52
|
* path. `~` is included so a backfill-disambiguated runid (F197:
|
|
@@ -131,17 +137,10 @@ export function checkTypeTitle(verdictType, atIso) {
|
|
|
131
137
|
return null;
|
|
132
138
|
}
|
|
133
139
|
|
|
134
|
-
// DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` never
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
// machine slept) and gets its own [?] glyph. The distinguishing signal is
|
|
139
|
-
// the spine FILE's own mtime, never wall-clock "now minus job-start" (a
|
|
140
|
-
// resumed/paused run can legitimately sit quiet for a long time without
|
|
141
|
-
// having died): still fresh (written to within this window) reads as the
|
|
142
|
-
// existing `running` [▶] state; older than this reads as died. Tighten-only,
|
|
143
|
-
// named so a future change is a deliberate, visible edit.
|
|
144
|
-
export const DIED_MTIME_MS = 10 * 60 * 1000;
|
|
140
|
+
// DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` is `died` [?], never [✗], once its
|
|
141
|
+
// spine file's mtime is older than DIED_MTIME_MS — the constant and its full comment live in
|
|
142
|
+
// src/runlist.js (the monthly limit reads the same rule); re-exported here for the panel's callers.
|
|
143
|
+
export { DIED_MTIME_MS };
|
|
145
144
|
|
|
146
145
|
/**
|
|
147
146
|
* Plain-words description of ONE spine record, for the died "why" sentence's
|
|
@@ -237,32 +236,6 @@ export function formatTimestamp(ts) {
|
|
|
237
236
|
* @param {string|null} outcome `replayRun`'s own `summary.outcome`
|
|
238
237
|
* @returns {{died: boolean, why: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
|
|
239
238
|
*/
|
|
240
|
-
/**
|
|
241
|
-
* The priced-rounds spend floor + first-record→last-record wall floor,
|
|
242
|
-
* derived straight off the spine's raw records — the same derivation a
|
|
243
|
-
* died run's "at least …" figures use, factored out so build item 6
|
|
244
|
-
* (2026-09-28) can hand a genuinely-still-running spine the identical
|
|
245
|
-
* floor instead of "unknown" on the Run tab, without a second copy of this
|
|
246
|
-
* math.
|
|
247
|
-
* @param {any[]} records raw parsed spine records
|
|
248
|
-
* @returns {{spendFloorUsd: number|null, wallFloorMs: number|null}}
|
|
249
|
-
*/
|
|
250
|
-
function floorsFromRecords(records) {
|
|
251
|
-
let spendSum = 0;
|
|
252
|
-
let pricedCount = 0;
|
|
253
|
-
for (const r of records) {
|
|
254
|
-
if (!r || typeof r !== 'object' || !SPEND_RECORD_TYPES.includes(r.type)) continue; // worker-result echoes excluded by construction — not a spend type
|
|
255
|
-
if (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd)) { spendSum += r.costUsd; pricedCount += 1; }
|
|
256
|
-
}
|
|
257
|
-
const spendFloorUsd = pricedCount > 0 ? spendSum : null;
|
|
258
|
-
|
|
259
|
-
const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
|
|
260
|
-
const firstMs = withTs.length ? Date.parse(withTs[0].ts) : NaN;
|
|
261
|
-
const lastMs = withTs.length ? Date.parse(withTs[withTs.length - 1].ts) : NaN;
|
|
262
|
-
const wallFloorMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? lastMs - firstMs : null;
|
|
263
|
-
return { spendFloorUsd, wallFloorMs };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
239
|
function deriveDeath(spinePath, records, outcome) {
|
|
267
240
|
const notDied = {
|
|
268
241
|
died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
|
|
@@ -1751,9 +1724,17 @@ function sendText(res, code, text) {
|
|
|
1751
1724
|
* only `createPanelServer` below always supplies one.
|
|
1752
1725
|
* @param {import('node:http').IncomingMessage} req
|
|
1753
1726
|
* @param {import('node:http').ServerResponse} res
|
|
1754
|
-
* @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes> }} opts
|
|
1727
|
+
* @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes> }} opts
|
|
1755
1728
|
*/
|
|
1756
1729
|
export function handleRequest(req, res, opts) {
|
|
1730
|
+
/** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
|
|
1731
|
+
* `/api/settings/*` (Settings, P4a): separate modules, each behind the human-click guard.
|
|
1732
|
+
* @param {any} o @param {string} p */
|
|
1733
|
+
function routesFor(o, p) {
|
|
1734
|
+
if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
|
|
1735
|
+
if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
|
|
1736
|
+
return null;
|
|
1737
|
+
}
|
|
1757
1738
|
const method = req.method ?? 'GET';
|
|
1758
1739
|
|
|
1759
1740
|
let url;
|
|
@@ -1766,22 +1747,39 @@ export function handleRequest(req, res, opts) {
|
|
|
1766
1747
|
const { pathname } = url;
|
|
1767
1748
|
|
|
1768
1749
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
1769
|
-
|
|
1750
|
+
const routes = routesFor(opts, pathname);
|
|
1751
|
+
if (routes) {
|
|
1770
1752
|
let raw = '';
|
|
1771
|
-
|
|
1753
|
+
let size = 0;
|
|
1754
|
+
let refused = false;
|
|
1755
|
+
req.on('data', (c) => {
|
|
1756
|
+
if (refused) return;
|
|
1757
|
+
size += c.length;
|
|
1758
|
+
if (size > MAX_BODY_BYTES) {
|
|
1759
|
+
refused = true;
|
|
1760
|
+
raw = '';
|
|
1761
|
+
res.once('finish', () => { req.destroy(); });
|
|
1762
|
+
res.setHeader('connection', 'close');
|
|
1763
|
+
sendText(res, 413, `request body over ${MAX_BODY_BYTES} bytes`);
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
raw += c;
|
|
1767
|
+
});
|
|
1772
1768
|
req.on('end', () => {
|
|
1769
|
+
if (refused) return;
|
|
1773
1770
|
/** @type {any} */
|
|
1774
1771
|
let body = null;
|
|
1775
1772
|
if (raw.length > 0) { try { body = JSON.parse(raw); } catch { body = null; } }
|
|
1776
|
-
|
|
1773
|
+
routes.handle(req, res, pathname, body);
|
|
1777
1774
|
});
|
|
1778
1775
|
return;
|
|
1779
1776
|
}
|
|
1780
|
-
sendText(res, 405, 'method not allowed — this panel is read-only outside /api/author (GET/HEAD only)');
|
|
1777
|
+
sendText(res, 405, 'method not allowed — this panel is read-only outside /api/author and /api/settings (GET/HEAD only)');
|
|
1781
1778
|
return;
|
|
1782
1779
|
}
|
|
1783
|
-
|
|
1784
|
-
|
|
1780
|
+
const getRoutes = routesFor(opts, pathname);
|
|
1781
|
+
if (getRoutes) {
|
|
1782
|
+
getRoutes.handle(req, res, pathname, null);
|
|
1785
1783
|
return;
|
|
1786
1784
|
}
|
|
1787
1785
|
|
|
@@ -1901,10 +1899,12 @@ export function createPanelServer(opts = {}) {
|
|
|
1901
1899
|
let boundPort = requestedPort;
|
|
1902
1900
|
/** @type {ReturnType<typeof createAuthorRoutes>|undefined} */
|
|
1903
1901
|
let authorRoutes;
|
|
1902
|
+
/** @type {ReturnType<typeof createSettingsRoutes>|undefined} */
|
|
1903
|
+
let settingsRoutes;
|
|
1904
1904
|
const server = createServer((req, res) => {
|
|
1905
1905
|
try {
|
|
1906
1906
|
handleRequest(req, res, {
|
|
1907
|
-
home, port: boundPort, token, authorRoutes,
|
|
1907
|
+
home, port: boundPort, token, authorRoutes, settingsRoutes,
|
|
1908
1908
|
});
|
|
1909
1909
|
} catch (e) {
|
|
1910
1910
|
sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
|
|
@@ -1926,6 +1926,10 @@ export function createPanelServer(opts = {}) {
|
|
|
1926
1926
|
spawnFn: opts.spawnFn,
|
|
1927
1927
|
bareloopBin: opts.bareloopBin,
|
|
1928
1928
|
fetchImpl: opts.fetchImpl,
|
|
1929
|
+
home,
|
|
1930
|
+
});
|
|
1931
|
+
settingsRoutes = createSettingsRoutes({
|
|
1932
|
+
port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
|
|
1929
1933
|
});
|
|
1930
1934
|
resolve({
|
|
1931
1935
|
server,
|
|
@@ -1942,7 +1946,10 @@ export function createPanelServer(opts = {}) {
|
|
|
1942
1946
|
* Never picks a different port on collision (see file header): prints a
|
|
1943
1947
|
* loud, named error to stderr and returns 1.
|
|
1944
1948
|
* @param {string[]} argv
|
|
1945
|
-
* @param {{ out: (s: string) => void, err: (s: string) => void, runlistHome?: string }} ctx
|
|
1949
|
+
* @param {{ out: (s: string) => void, err: (s: string) => void, runlistHome?: string, env?: Record<string,string|undefined> }} ctx
|
|
1950
|
+
* `env` is the RAW shell env (never the start-time merge with the keys file) — the routes
|
|
1951
|
+
* re-merge `~/.config/bareloop/.env` onto it per request so Reload keys is real;
|
|
1952
|
+
* undefined = the process env.
|
|
1946
1953
|
* @returns {Promise<number>}
|
|
1947
1954
|
*/
|
|
1948
1955
|
export async function panelMain(argv, ctx) {
|
|
@@ -1956,7 +1963,7 @@ export async function panelMain(argv, ctx) {
|
|
|
1956
1963
|
}
|
|
1957
1964
|
}
|
|
1958
1965
|
try {
|
|
1959
|
-
const { port: boundPort } = await createPanelServer({ port, home: ctx.runlistHome });
|
|
1966
|
+
const { port: boundPort } = await createPanelServer({ port, home: ctx.runlistHome, env: ctx.env });
|
|
1960
1967
|
ctx.out(`bareloop panel — read-only, http://127.0.0.1:${boundPort} (Ctrl-C to stop)`);
|
|
1961
1968
|
// never resolves on its own — the process stays up until killed, same
|
|
1962
1969
|
// shape any other long-running dev server takes.
|
|
@@ -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
|
@@ -762,6 +762,10 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
762
762
|
* `false` → the drafter has no tools, so a native session would report NO cost — return a
|
|
763
763
|
* metered claude-json TEXT provider (`--output-format json`, `parse:'claude-json'`) instead,
|
|
764
764
|
* so its spend is never invisible. The Loop path (`anthropic-api`) never touches this.
|
|
765
|
+
* @param {{in: number, out: number}|null} [opts.rates] the customer's own price for the worker's key row (USD per
|
|
766
|
+
* 1K tokens, `ratesFor`) — handed to the one Loop that drives worker, scout and planner rounds, so they read
|
|
767
|
+
* `rateSource:'caller'`; null = bare-agent's built-in guess. Not applied to a native (CLI) session, which reports its own cost.
|
|
768
|
+
* @param {{in: number, out: number}|null} [opts.judgeRates] the same, for the judge's Loop
|
|
765
769
|
* @param {string|null} [opts.judgeModel] softgreen — WHICH model that judge provider drives
|
|
766
770
|
* (PRD item 32.1). Resolved by the CALLER (`resolveJudge`, src/judged.js: the spec's signed
|
|
767
771
|
* `judge` override, else the job's own worker model), never pinned in this library. It
|
|
@@ -983,7 +987,7 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
983
987
|
* 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
|
|
984
988
|
* 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
|
|
985
989
|
*/
|
|
986
|
-
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
|
|
990
|
+
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
|
|
987
991
|
// MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
|
|
988
992
|
// every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
|
|
989
993
|
// worker) — one accumulator closed over by all of them, because the shim's ledger
|
|
@@ -1687,7 +1691,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1687
1691
|
// ride, so `runJob`'s ONE ledger accounts a close's spend exactly as it accounts
|
|
1688
1692
|
// an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
|
|
1689
1693
|
// the same F6 pricing halt rather than being laundered into $0.
|
|
1690
|
-
judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
|
|
1694
|
+
judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgeRates }) : null,
|
|
1691
1695
|
// the seam's other half (PRD item 32.1) — WHICH model that loop drives. Passed
|
|
1692
1696
|
// through untouched: this module resolves no judge, exactly as it constructs no
|
|
1693
1697
|
// provider. `runJudgedFloor` stops as a wiring gap when it is absent rather than
|
|
@@ -2750,7 +2754,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2750
2754
|
}
|
|
2751
2755
|
return onLlmResult(arg);
|
|
2752
2756
|
};
|
|
2753
|
-
self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
|
|
2757
|
+
self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome, ...(rates ? { rates } : {}) });
|
|
2754
2758
|
return self;
|
|
2755
2759
|
};
|
|
2756
2760
|
/** @param {string} prompt @param {typeof toolDefs} [defs] */
|