bareloop 0.32.0 → 0.34.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 +95 -0
- package/README.md +4 -1
- package/bareloop.context.md +163 -82
- package/package.json +1 -1
- package/src/authorflow.js +5 -3
- package/src/authorjob.js +3 -1
- package/src/authorrun.js +36 -8
- package/src/authorscout.js +10 -6
- package/src/bundlerun.js +266 -0
- package/src/cli.js +24 -352
- package/src/interviewrun.js +6 -4
- package/src/invoke.js +22 -0
- package/src/job.js +2 -2
- package/src/judged.js +27 -5
- package/src/kinds.js +1 -0
- package/src/ledger.js +3 -4
- package/src/monthly.js +112 -35
- package/src/panel/authorroutes.js +12 -2
- package/src/panel/authorsession.js +24 -4
- package/src/panel/server.js +18 -13
- package/src/planrun.js +10 -8
- package/src/providerrows.js +87 -1
- package/src/providers.js +4 -4
- package/src/run.js +7 -2
- package/src/runlist.js +94 -7
- package/src/source.js +8 -3
- package/src/u-watchdog.mjs +296 -0
- package/src/userrun.js +207 -65
- package/types/authorflow.d.ts +8 -2
- package/types/authorjob.d.ts +6 -1
- package/types/authorscout.d.ts +11 -2
- package/types/bundlerun.d.ts +26 -0
- package/types/invoke.d.ts +8 -0
- package/types/judged.d.ts +10 -3
- package/types/monthly.d.ts +57 -10
- package/types/panel/authorroutes.d.ts +15 -0
- package/types/planrun.d.ts +13 -1
- package/types/providerrows.d.ts +66 -0
- package/types/providers.d.ts +1 -2
- package/types/run.d.ts +14 -1
- package/types/runlist.d.ts +79 -3
- package/types/source.d.ts +8 -3
- package/types/userrun.d.ts +49 -0
package/src/monthly.js
CHANGED
|
@@ -8,21 +8,22 @@
|
|
|
8
8
|
// The limit REFUSES, never warns; the page and the chat can never raise it (a person
|
|
9
9
|
// edits it by hand in Settings — src/config.js).
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
11
|
+
// HOLD UNTIL DONE (hamr 2026-09-30): a run CLAIMS its whole leg cap when it starts (`claimRun`: append
|
|
12
|
+
// the run's row FIRST, carrying its `pid` and `capUsd`, then read the list — only the claims ABOVE its row
|
|
13
|
+
// count, so whoever runs first holds) and holds it until it is done. While the row's pid is a live bareloop runner the
|
|
14
|
+
// run is counted at max(spend so far, capUsd) however quiet its spine; a run with a `job-end` counts
|
|
15
|
+
// its real spend; a claim whose process is gone counts its floor and is settled — with an attributed
|
|
16
|
+
// `settled` entry — by whichever later run finds it (the first note wins). An older row with no `pid` keeps the spine
|
|
17
|
+
// mtime rule (a fresh spine, written to within DIED_MTIME_MS, is in flight) — `runIsAlive`, src/runlist.js, the
|
|
18
|
+
// one "is this run alive" rule the panel's glyph shares. Only the refusal check
|
|
19
|
+
// reserves; the Money tab keeps showing real spend (`usd`).
|
|
18
20
|
//
|
|
19
21
|
// Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
|
|
20
22
|
// round, a missing spine file) makes the month total an "at least" figure — never a clean
|
|
21
23
|
// number. The refusal text stays exactly `Max $X (monthly limit)`; `atLeast` travels beside it.
|
|
22
|
-
import { existsSync
|
|
23
|
-
import { resolve } from 'node:path';
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
24
25
|
import { readConfig, configPath, ConfigError } from './config.js';
|
|
25
|
-
import { readRunList,
|
|
26
|
+
import { readRunList, appendRun, appendRunEvent, isLiveRunner, runIsAlive } from './runlist.js';
|
|
26
27
|
import { parseJsonl } from './replayio.js';
|
|
27
28
|
import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords } from './ledger.js';
|
|
28
29
|
import { findRow } from './providerrows.js';
|
|
@@ -140,46 +141,64 @@ export function legTokens(records) {
|
|
|
140
141
|
|
|
141
142
|
/**
|
|
142
143
|
* What the refusal check counts for one run: its spend, unless it is IN FLIGHT (`job-start`, no
|
|
143
|
-
* `job-end`,
|
|
144
|
+
* `job-end`, `runIsAlive` for a row with no pid — the pid-bearing rows are held by `readLegs`), when it is the larger of that
|
|
144
145
|
* spend and the leg's own cap — the signed `budgetUsd` on the spine's `job-start` less the fold
|
|
145
146
|
* a resumed leg inherited (`priorSpentUsd`), the same figure the run-start seam computes
|
|
146
147
|
* (`legCapUsd`, src/userrun.js). A spine that records no `budgetUsd` reserves nothing extra.
|
|
147
148
|
* @param {any[]} records
|
|
148
|
-
* @param {
|
|
149
|
+
* @param {import('./runlist.js').RunRow} row
|
|
149
150
|
* @param {number} nowMs
|
|
150
151
|
* @param {number} usd the leg's spend so far
|
|
151
152
|
* @returns {number}
|
|
152
153
|
*/
|
|
153
|
-
function inFlightCapUsd(records,
|
|
154
|
+
function inFlightCapUsd(records, row, nowMs, usd) {
|
|
154
155
|
if (records.some((r) => r && r.type === 'job-end')) return usd;
|
|
155
156
|
const start = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
156
157
|
if (!start || !(typeof start.budgetUsd === 'number' && Number.isFinite(start.budgetUsd))) return usd;
|
|
157
|
-
|
|
158
|
-
try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return usd; }
|
|
159
|
-
if (nowMs - mtimeMs > DIED_MTIME_MS) return usd; // stale — died, counts its floor
|
|
158
|
+
if (!runIsAlive(row, nowMs)) return usd; // stale — died, counts its floor
|
|
160
159
|
const fold = typeof start.priorSpentUsd === 'number' && Number.isFinite(start.priorSpentUsd) ? start.priorSpentUsd : 0;
|
|
161
160
|
return Math.max(usd, Math.max(0, start.budgetUsd - fold));
|
|
162
161
|
}
|
|
163
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The rows listed EARLIER in the run list than `runid`'s own row (file order is claim order). No
|
|
165
|
+
* `runid`, or a runid not in the list, = every row.
|
|
166
|
+
* @param {import('./runlist.js').RunRow[]} rows
|
|
167
|
+
* @param {string} [runid]
|
|
168
|
+
* @returns {import('./runlist.js').RunRow[]}
|
|
169
|
+
*/
|
|
170
|
+
function rowsAbove(rows, runid) {
|
|
171
|
+
if (runid === undefined) return rows;
|
|
172
|
+
const i = rows.findIndex((r) => r.runid === runid);
|
|
173
|
+
return i < 0 ? rows : rows.slice(0, i);
|
|
174
|
+
}
|
|
175
|
+
|
|
164
176
|
/**
|
|
165
177
|
* Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
|
|
166
178
|
* and the per-provider figures share.
|
|
167
|
-
* @param {{ home?: string, now?: () => number,
|
|
179
|
+
* @param {{ home?: string, now?: () => number, aboveRunid?: string, thisMonthOnly?: boolean }} [opts] `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below); `thisMonthOnly` = do not parse the spine of a run listed in another local month (the refusal check drops it anyway) unless its claim is still held
|
|
168
180
|
* @returns {Leg[]}
|
|
169
181
|
*/
|
|
170
182
|
export function readLegs(opts = {}) {
|
|
171
183
|
const nowMs = (opts.now ?? Date.now)();
|
|
172
|
-
const { rows } = readRunList({ home: opts.home });
|
|
173
|
-
const
|
|
184
|
+
const { rows: allRows, events } = readRunList({ home: opts.home });
|
|
185
|
+
const rows = rowsAbove(allRows, opts.aboveRunid);
|
|
186
|
+
const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
|
|
174
187
|
/** @type {Leg[]} */
|
|
175
188
|
const legs = [];
|
|
176
189
|
for (const row of rows) {
|
|
177
190
|
const at = new Date(row.at);
|
|
178
191
|
const base = { at, provider: null, baseUrl: null, model: null, wallMs: null, wallComplete: false, usd: 0, complete: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, reservedUsd: 0, unreadable: true };
|
|
179
|
-
|
|
192
|
+
// Rule 2 (hold-until-done): a row that carries its runner's `pid` is HELD at its cap while that
|
|
193
|
+
// pid is a live bareloop runner, however quiet its spine — killed = process gone, not silence.
|
|
194
|
+
// An older row with no `pid` keeps the spine-mtime rule (`inFlightCapUsd`), never guessed alive.
|
|
195
|
+
const hasPid = Number.isInteger(row.pid);
|
|
196
|
+
const heldCap = hasPid && !settled.has(row.runid) && typeof row.capUsd === 'number' && Number.isFinite(row.capUsd) && runIsAlive(row, nowMs) ? row.capUsd : null;
|
|
197
|
+
if (opts.thisMonthOnly && heldCap === null && !Number.isNaN(at.getTime()) && !sameLocalMonth(at, new Date(nowMs))) continue;
|
|
198
|
+
if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
|
|
180
199
|
/** @type {any[]} */
|
|
181
200
|
let records;
|
|
182
|
-
try { records = parseJsonl(row.spine).records; } catch { legs.push(base); continue; }
|
|
201
|
+
try { records = parseJsonl(row.spine).records; } catch { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
|
|
183
202
|
const start = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
184
203
|
const leg = legSpend(records);
|
|
185
204
|
const prov = spendProvenance(records);
|
|
@@ -196,7 +215,8 @@ export function readLegs(opts = {}) {
|
|
|
196
215
|
tokens: legTokens(records),
|
|
197
216
|
vouchedRounds: prov.vouched.rounds,
|
|
198
217
|
otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
|
|
199
|
-
reservedUsd:
|
|
218
|
+
reservedUsd: hasPid ? (records.some((r) => r && r.type === 'job-end') || heldCap === null ? leg.usd : Math.max(leg.usd, heldCap))
|
|
219
|
+
: inFlightCapUsd(records, row, nowMs, leg.usd),
|
|
200
220
|
unreadable: false,
|
|
201
221
|
});
|
|
202
222
|
}
|
|
@@ -211,8 +231,8 @@ export function readLegs(opts = {}) {
|
|
|
211
231
|
const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
|
|
212
232
|
|
|
213
233
|
/**
|
|
214
|
-
* @param {{ home?: string, now?: () => number,
|
|
215
|
-
* @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's)
|
|
234
|
+
* @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`: see {@link readLegs}
|
|
235
|
+
* @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number, heldUsd: number, heldRuns: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's); `heldUsd` = what in-progress runs set aside beyond what they have spent (`reservedUsd - usd`), over `heldRuns` runs
|
|
216
236
|
*/
|
|
217
237
|
export function monthSpend(opts = {}) {
|
|
218
238
|
const nowDate = new Date((opts.now ?? Date.now)());
|
|
@@ -220,17 +240,20 @@ export function monthSpend(opts = {}) {
|
|
|
220
240
|
let reservedUsd = 0;
|
|
221
241
|
let atLeast = false;
|
|
222
242
|
let runs = 0;
|
|
223
|
-
|
|
243
|
+
let heldUsd = 0;
|
|
244
|
+
let heldRuns = 0;
|
|
245
|
+
for (const leg of readLegs({ home: opts.home, now: opts.now, aboveRunid: opts.aboveRunid, thisMonthOnly: true })) {
|
|
224
246
|
// an unreadable date could belong to this month — unknown, never dropped
|
|
225
247
|
if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
|
|
226
248
|
if (!sameLocalMonth(leg.at, nowDate)) continue;
|
|
227
249
|
runs += 1;
|
|
228
|
-
if (leg.unreadable) { atLeast = true; continue; }
|
|
250
|
+
if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; if (leg.reservedUsd > 0) { heldUsd += leg.reservedUsd; heldRuns += 1; } continue; }
|
|
229
251
|
usd += leg.usd;
|
|
230
252
|
reservedUsd += leg.reservedUsd;
|
|
253
|
+
if (leg.reservedUsd > leg.usd) { heldUsd += leg.reservedUsd - leg.usd; heldRuns += 1; }
|
|
231
254
|
if (!leg.complete) atLeast = true;
|
|
232
255
|
}
|
|
233
|
-
return { usd, atLeast, runs, reservedUsd };
|
|
256
|
+
return { usd, atLeast, runs, reservedUsd, heldUsd, heldRuns };
|
|
234
257
|
}
|
|
235
258
|
|
|
236
259
|
/**
|
|
@@ -302,6 +325,8 @@ export function spendSummary(opts = {}) {
|
|
|
302
325
|
* @property {number|null} leftUsd what is left this month (null = no limit set)
|
|
303
326
|
* @property {number|null} limitUsd the person's monthly limit (null = none)
|
|
304
327
|
* @property {boolean} atLeast the month's spend is a floor, so `leftUsd` is a ceiling
|
|
328
|
+
* @property {number} [heldUsd] what run(s) in progress have set aside beyond their spend so far (present when a limit is set)
|
|
329
|
+
* @property {number} [heldRuns] how many runs hold it
|
|
305
330
|
*/
|
|
306
331
|
|
|
307
332
|
/**
|
|
@@ -321,32 +346,84 @@ export function monthlyLimitOf(config, home) {
|
|
|
321
346
|
}
|
|
322
347
|
|
|
323
348
|
/**
|
|
324
|
-
* Does a run with this $ cap fit in what is left this month (
|
|
349
|
+
* Does a run with this $ cap fit in what is left this month (a run still held counts at its full cap)? No limit set (key absent or null) = ok, no check; a limit that is present but not a
|
|
325
350
|
* number above 0 throws `ConfigError`.
|
|
326
351
|
* Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
|
|
327
352
|
* whose own instrument is broken refuses; it never silently runs with no limit.
|
|
328
|
-
* @param {{ capUsd: number, home?: string, now?: () => number,
|
|
353
|
+
* @param {{ capUsd: number, home?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
|
|
329
354
|
* @returns {MonthlyRoom}
|
|
330
355
|
*/
|
|
331
|
-
export function checkMonthlyRoom({ capUsd, home, now,
|
|
356
|
+
export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }) {
|
|
332
357
|
const cfg = readConfig({ home });
|
|
333
358
|
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
334
359
|
const limit = monthlyLimitOf(cfg.config, home);
|
|
335
360
|
if (limit === null) {
|
|
336
361
|
return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
|
|
337
362
|
}
|
|
338
|
-
const spent = monthSpend({ home, now,
|
|
363
|
+
const spent = monthSpend({ home, now, aboveRunid });
|
|
339
364
|
const leftCents = Math.max(0, Math.floor((limit - spent.reservedUsd) * 100 + 1e-6));
|
|
340
|
-
const capCents = Math.ceil(
|
|
341
|
-
return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast };
|
|
365
|
+
const capCents = Math.ceil(capUsd * 100 - 1e-6); // a non-finite cap never fits (NaN/Infinity compare false) — never read as $0
|
|
366
|
+
return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast, heldUsd: spent.heldUsd, heldRuns: spent.heldRuns };
|
|
342
367
|
}
|
|
343
368
|
|
|
344
369
|
/**
|
|
345
|
-
* The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal).
|
|
370
|
+
* The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal). When run(s) in progress hold
|
|
371
|
+
* part of the limit, the same line says how much.
|
|
346
372
|
* @param {MonthlyRoom} room
|
|
347
373
|
* @returns {string|null} null when the run fits
|
|
348
374
|
*/
|
|
349
375
|
export function monthlyRefusalText(room) {
|
|
350
376
|
if (room.ok || room.leftUsd === null) return null;
|
|
351
|
-
|
|
377
|
+
const max = `Max $${room.leftUsd.toFixed(2)}`;
|
|
378
|
+
if (!room.heldRuns || !room.heldUsd || room.heldUsd <= 0) return `${max} (monthly limit)`;
|
|
379
|
+
return `${max} (monthly limit, $${room.heldUsd.toFixed(2)} held by ${room.heldRuns > 1 ? 'runs' : 'a run'} in progress)`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Close every claim ABOVE `aboveRunid` (file order) whose process is gone: a pid-bearing run row with no
|
|
384
|
+
* `settled` entry whose pid is not a live bareloop runner gets `{ type: 'settled', by: <closer>, reason:
|
|
385
|
+
* 'process gone', spentUsd: <its spine's floor>, spendComplete: false }` appended — so every claim gets a
|
|
386
|
+
* write-back, attributed. Two runs may both close the same dead claim; readers take the FIRST note.
|
|
387
|
+
* @param {{ home?: string, by: string, aboveRunid?: string }} args `by` = the run doing the closing
|
|
388
|
+
* @returns {number} how many notes were appended
|
|
389
|
+
*/
|
|
390
|
+
export function settleDeadClaims({ home, by, aboveRunid }) {
|
|
391
|
+
const { rows, events } = readRunList({ home });
|
|
392
|
+
const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
|
|
393
|
+
let n = 0;
|
|
394
|
+
for (const row of rowsAbove(rows, aboveRunid)) {
|
|
395
|
+
if (!Number.isInteger(row.pid) || settled.has(row.runid) || isLiveRunner(/** @type {number} */ (row.pid))) continue;
|
|
396
|
+
let spentUsd = 0;
|
|
397
|
+
try { spentUsd = legSpend(parseJsonl(row.spine).records).usd; } catch { /* no spine: nothing was recorded spent */ }
|
|
398
|
+
appendRunEvent({ runid: row.runid, type: 'settled', by, reason: 'process gone', spentUsd, spendComplete: false, at: new Date().toISOString() }, { home });
|
|
399
|
+
n += 1;
|
|
400
|
+
}
|
|
401
|
+
return n;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The CLAIM (hamr 2026-09-30: whoever runs first claims the limit and holds it; no lock file, records
|
|
406
|
+
* only): the run APPENDS its row first (`row` carries its `pid` and `capUsd`), then reads the run list.
|
|
407
|
+
* Only the claims ABOVE its own row count — a live one is held at its cap (a dead one is closed with an
|
|
408
|
+
* attributed note and counts its real spend); rows below yield to it. If `capUsd` fits, it runs; if not
|
|
409
|
+
* it appends `{type:'released', reason:'refused'}` and reports the refusal, nothing spent. No limit set =
|
|
410
|
+
* no claim (`claimed: false`; the caller lists the run as it always did). An unreadable config, or a row
|
|
411
|
+
* that cannot be written, throws `ConfigError` — the gate refuses, never reads "no limit".
|
|
412
|
+
* @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number }} args
|
|
413
|
+
* @returns {{ room: MonthlyRoom, claimed: boolean }}
|
|
414
|
+
*/
|
|
415
|
+
export function claimRun({ row, capUsd, home, now }) {
|
|
416
|
+
const cfg = readConfig({ home });
|
|
417
|
+
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
418
|
+
if (monthlyLimitOf(cfg.config, home) === null) return { room: { ok: true, leftUsd: null, limitUsd: null, atLeast: false }, claimed: false };
|
|
419
|
+
try { appendRun(row, { home }); } catch (/** @type {any} */ e) {
|
|
420
|
+
throw new ConfigError(`cannot write this run's claim to the run list (${e.message})`);
|
|
421
|
+
}
|
|
422
|
+
settleDeadClaims({ home, by: row.runid, aboveRunid: row.runid });
|
|
423
|
+
const room = checkMonthlyRoom({ capUsd, home, now, aboveRunid: row.runid });
|
|
424
|
+
if (!room.ok) {
|
|
425
|
+
try { appendRunEvent({ runid: row.runid, type: 'released', by: row.runid, reason: 'refused', at: new Date().toISOString() }, { home }); } catch { /* the refusal stands; a stranded row of a dead process is closed by the next run */ }
|
|
426
|
+
return { room, claimed: false };
|
|
427
|
+
}
|
|
428
|
+
return { room, claimed: true };
|
|
352
429
|
}
|
|
@@ -34,6 +34,17 @@ export function mintToken() {
|
|
|
34
34
|
return randomBytes(24).toString('hex');
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The ONE Host check: the request names this panel's own bind address. A page on another
|
|
39
|
+
* origin reaching the panel through a rebound DNS name sends that name as Host, so it fails here.
|
|
40
|
+
* @param {import('node:http').IncomingMessage} req
|
|
41
|
+
* @param {{port: number}} o
|
|
42
|
+
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
43
|
+
*/
|
|
44
|
+
export function checkHostGuard(req, { port }) {
|
|
45
|
+
return req.headers.host === `127.0.0.1:${port}` ? { ok: true } : { ok: false, reason: 'wrong Host' };
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
/**
|
|
38
49
|
* `true` when a request may reach ANY route in this file — the token header
|
|
39
50
|
* plus an Origin/Host that names this exact bind address. `req.headers`
|
|
@@ -48,10 +59,9 @@ export function checkHumanGuard(req, { token, port }) {
|
|
|
48
59
|
if (got !== token) return { ok: false, reason: 'missing or wrong token' };
|
|
49
60
|
const want = `127.0.0.1:${port}`;
|
|
50
61
|
const origin = req.headers.origin;
|
|
51
|
-
const host = req.headers.host;
|
|
52
62
|
if (typeof origin === 'string' && origin.length > 0) {
|
|
53
63
|
if (origin !== `http://${want}` && origin !== `https://${want}`) return { ok: false, reason: 'wrong Origin' };
|
|
54
|
-
} else if (
|
|
64
|
+
} else if (!checkHostGuard(req, { port }).ok) {
|
|
55
65
|
return { ok: false, reason: 'wrong Host (and no Origin header)' };
|
|
56
66
|
}
|
|
57
67
|
return { ok: true };
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
// `src/panel/server.js`'s POST routes). Nothing here changes what a step
|
|
21
21
|
// means or what an answer does — only where the answer comes from.
|
|
22
22
|
|
|
23
|
-
import { applyConfiguredKey, keyNameFor, modelChoiceFor, rowsForHome } from '../providerrows.js';
|
|
23
|
+
import { applyConfiguredKey, judgeRatesFor, keyNameFor, modelChoiceFor, ratesFor, rowsForHome } from '../providerrows.js';
|
|
24
|
+
import { ConfigError } from '../config.js';
|
|
24
25
|
import {
|
|
25
26
|
mkdirSync, existsSync, writeFileSync, readdirSync,
|
|
26
27
|
} from 'node:fs';
|
|
@@ -310,6 +311,15 @@ export function createSession(card, deps = {}) {
|
|
|
310
311
|
const draftKeyName = keyNameFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name).name;
|
|
311
312
|
const apiKey = draftEnv[providerEntry.envKey];
|
|
312
313
|
if (!apiKey) { refuse(`${draftKeyName} not set. Stopped — nothing spent.`); return; }
|
|
314
|
+
// the customer's own price on this row (USD per 1M in config.json), resolved once here at $0 and
|
|
315
|
+
// handed to every model call this session makes; a bad price refuses, never falls back to the guess
|
|
316
|
+
/** @type {ReturnType<typeof ratesFor>} */
|
|
317
|
+
let draftPrice = null;
|
|
318
|
+
try { draftPrice = ratesFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name); } catch (e) {
|
|
319
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
320
|
+
refuse(`${e.message}. Stopped — nothing spent.`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
313
323
|
const keyProblem = apiKeyProblem(apiKey);
|
|
314
324
|
if (keyProblem) { refuse(`${draftKeyName} ${keyProblem}. Stopped — nothing spent.`); return; }
|
|
315
325
|
|
|
@@ -422,8 +432,8 @@ export function createSession(card, deps = {}) {
|
|
|
422
432
|
// `provider` for the model boundary) and/or `scout` (bypassing the real
|
|
423
433
|
// paid scout) so it can drive the REAL confirm turn/ask()/revise/hash
|
|
424
434
|
// machinery below with a deterministic fake, never a live provider call.
|
|
425
|
-
const generate = deps.generate ?? makeLoopGenerate(provider);
|
|
426
|
-
const confirmGenerate = deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM });
|
|
435
|
+
const generate = deps.generate ?? makeLoopGenerate(provider, { rates: draftPrice?.rates ?? null });
|
|
436
|
+
const confirmGenerate = deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM, rates: draftPrice?.rates ?? null });
|
|
427
437
|
|
|
428
438
|
state.phase = 'drafting';
|
|
429
439
|
state.progressLabel = 'drafting';
|
|
@@ -437,6 +447,7 @@ export function createSession(card, deps = {}) {
|
|
|
437
447
|
questions: questionsFor(verdictType),
|
|
438
448
|
writeScope, provider, generate,
|
|
439
449
|
ceilingUsd: card.capUsd,
|
|
450
|
+
rates: draftPrice?.rates ?? null,
|
|
440
451
|
onPhase, onCall,
|
|
441
452
|
ask, confirmGenerate, isRepo: true, langResult,
|
|
442
453
|
...(deps.scout ? { scout: deps.scout } : {}),
|
|
@@ -470,7 +481,16 @@ export function createSession(card, deps = {}) {
|
|
|
470
481
|
const judges = closeJudges(spec.closeDecl);
|
|
471
482
|
const judge = judges ? resolveJobJudge(spec, modelChoice.provider, resolveWorkerModel) : null;
|
|
472
483
|
let judgeProvider = null;
|
|
484
|
+
/** @type {ReturnType<typeof ratesFor>} */
|
|
485
|
+
let judgePrice = null;
|
|
473
486
|
if (judge) {
|
|
487
|
+
try {
|
|
488
|
+
judgePrice = judgeRatesFor(judge, modelChoice.provider, modelChoice.baseUrl, draftPrice, keyCfg);
|
|
489
|
+
} catch (e) {
|
|
490
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
491
|
+
refuse(`${e.message} — refusing before gate 4 spends anything`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
474
494
|
const judgeKeyValue = env.JUDGE_API_KEY ?? (judge.provider === modelChoice.provider ? draftEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model))[resolveProvider(judge.provider).envKey];
|
|
475
495
|
const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
|
|
476
496
|
if (!judgeKeyValue || judgeKeyProblem) {
|
|
@@ -486,7 +506,7 @@ export function createSession(card, deps = {}) {
|
|
|
486
506
|
const signing = await prepareSigningFn({
|
|
487
507
|
spec, workdir: prep.tree, seedRef: authored.seedRef, timeoutMs,
|
|
488
508
|
shellCapUsd: spec.budgetUsd, ceilingUsd: card.capUsd, priorCalls: [...metered],
|
|
489
|
-
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
|
|
509
|
+
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
|
|
490
510
|
judgeModel: judge?.model ?? null,
|
|
491
511
|
onJudgeCost: (c) => onCall({ label: `${c.label}:${c.id}`, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds }),
|
|
492
512
|
});
|
package/src/panel/server.js
CHANGED
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
// for every cap/threshold: a shell never widens what it was asked to do).
|
|
26
26
|
|
|
27
27
|
import { createServer } from 'node:http';
|
|
28
|
-
import { readFileSync, existsSync
|
|
28
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
29
29
|
import {
|
|
30
30
|
dirname, join, basename, relative, isAbsolute, sep,
|
|
31
31
|
} from 'node:path';
|
|
32
32
|
import { fileURLToPath } from 'node:url';
|
|
33
|
-
import { readRunList, DIED_MTIME_MS } from '../runlist.js';
|
|
33
|
+
import { readRunList, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
|
|
34
34
|
import {
|
|
35
35
|
replayOne, parseJsonl, resolveSiblings, isSidecarByName,
|
|
36
36
|
} from '../replayio.js';
|
|
@@ -39,7 +39,7 @@ import { runBehaviour } from '../behaviour.js';
|
|
|
39
39
|
import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
|
|
40
40
|
import { jobSpecHash } from '../job.js';
|
|
41
41
|
import { confirmProtections } from '../authorflow.js';
|
|
42
|
-
import { createAuthorRoutes, mintToken } from './authorroutes.js';
|
|
42
|
+
import { createAuthorRoutes, mintToken, checkHostGuard } from './authorroutes.js';
|
|
43
43
|
import { createSettingsRoutes } from './settingsroutes.js';
|
|
44
44
|
|
|
45
45
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -137,9 +137,9 @@ export function checkTypeTitle(verdictType, atIso) {
|
|
|
137
137
|
return null;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
// DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` is `died` [?], never [✗], once
|
|
141
|
-
//
|
|
142
|
-
//
|
|
140
|
+
// DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` is `died` [?], never [✗], once nothing is
|
|
141
|
+
// running it — `runIsAlive` (src/runlist.js: the row's pid first, the spine-mtime rule DIED_MTIME_MS only
|
|
142
|
+
// for a row with no pid; the monthly limit reads the same rule); the constant is re-exported here for the panel's callers.
|
|
143
143
|
export { DIED_MTIME_MS };
|
|
144
144
|
|
|
145
145
|
/**
|
|
@@ -231,12 +231,12 @@ export function formatTimestamp(ts) {
|
|
|
231
231
|
* fields, which assume a `job-end` exists) — the ONE owner of this
|
|
232
232
|
* derivation, used by every caller below (`/api/runs`, `/api/runs/:id`) so
|
|
233
233
|
* the two never drift apart.
|
|
234
|
-
* @param {
|
|
234
|
+
* @param {import('../runlist.js').RunRow} row the run's listed row (its pid, else its spine's mtime, says whether it is still running)
|
|
235
235
|
* @param {any[]} records raw parsed spine records (already read once by the caller)
|
|
236
236
|
* @param {string|null} outcome `replayRun`'s own `summary.outcome`
|
|
237
237
|
* @returns {{died: boolean, why: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
|
|
238
238
|
*/
|
|
239
|
-
function deriveDeath(
|
|
239
|
+
function deriveDeath(row, records, outcome) {
|
|
240
240
|
const notDied = {
|
|
241
241
|
died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
|
|
242
242
|
};
|
|
@@ -245,9 +245,7 @@ function deriveDeath(spinePath, records, outcome) {
|
|
|
245
245
|
// out to be a died run or one that is genuinely still running (build item
|
|
246
246
|
// 6): computed here, once, before the died/still-running branch below.
|
|
247
247
|
const floors = floorsFromRecords(records);
|
|
248
|
-
|
|
249
|
-
try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return { ...notDied, ...floors }; }
|
|
250
|
-
if (Date.now() - mtimeMs <= DIED_MTIME_MS) return { ...notDied, ...floors }; // still fresh — genuinely `running`, not died
|
|
248
|
+
if (runIsAlive(row)) return { ...notDied, ...floors }; // something is still running it — genuinely `running`, not died
|
|
251
249
|
|
|
252
250
|
const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
|
|
253
251
|
const last = withTs.length ? withTs[withTs.length - 1] : null;
|
|
@@ -300,7 +298,7 @@ function summarizeRow(row) {
|
|
|
300
298
|
};
|
|
301
299
|
}
|
|
302
300
|
const line = summarizeForAllLine(summary);
|
|
303
|
-
const death = deriveDeath(row
|
|
301
|
+
const death = deriveDeath(row, rawRecords, summary.outcome);
|
|
304
302
|
return {
|
|
305
303
|
runid: row.runid,
|
|
306
304
|
job: row.job,
|
|
@@ -395,7 +393,7 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
395
393
|
const auditPath = resolveAuditPathForRow(row, rawSpineRecords);
|
|
396
394
|
const summary = replayOne(row.spine, { auditPathOverride: auditPath });
|
|
397
395
|
const timelineKind = summary.timelineKind;
|
|
398
|
-
const death = deriveDeath(row
|
|
396
|
+
const death = deriveDeath(row, rawSpineRecords, summary.outcome);
|
|
399
397
|
// judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
|
|
400
398
|
// `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
|
|
401
399
|
// close's own paid judge seam, distinct from the worker `model` above.
|
|
@@ -1746,6 +1744,13 @@ export function handleRequest(req, res, opts) {
|
|
|
1746
1744
|
}
|
|
1747
1745
|
const { pathname } = url;
|
|
1748
1746
|
|
|
1747
|
+
// every read names the panel's own address as Host — no token needed to read, but another origin's
|
|
1748
|
+
// page (a rebound DNS name) must not be able to fetch the run list
|
|
1749
|
+
if ((method === 'GET' || method === 'HEAD') && !checkHostGuard(req, { port: opts.port }).ok) {
|
|
1750
|
+
sendText(res, 403, 'wrong Host — this panel answers only at its own 127.0.0.1 address');
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1749
1754
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
1750
1755
|
const routes = routesFor(opts, pathname);
|
|
1751
1756
|
if (routes) {
|
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
|
|
@@ -1704,11 +1708,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1704
1708
|
stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
|
|
1705
1709
|
costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
|
|
1706
1710
|
// BA-21 provenance, through the SAME helper the worker rounds use — this record
|
|
1707
|
-
// is on `spendProvenance`'s read list (src/ledger.js)
|
|
1708
|
-
//
|
|
1709
|
-
//
|
|
1710
|
-
// (src/kinds.js `onJudgeCost({...})`) does not carry `rateSource` today, so it
|
|
1711
|
-
// still reads UNKNOWN — correctly — until that payload forwards it too.
|
|
1711
|
+
// is on `spendProvenance`'s read list (src/ledger.js). The judge cost payload
|
|
1712
|
+
// (src/kinds.js `onJudgeCost`) forwards the field when the judge Loop carried it
|
|
1713
|
+
// (`defaultJudgeLoop`); a loop that metered no rate leaves the key absent (UNKNOWN).
|
|
1712
1714
|
...rateSourceFields(c),
|
|
1713
1715
|
}),
|
|
1714
1716
|
})
|
|
@@ -2750,7 +2752,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2750
2752
|
}
|
|
2751
2753
|
return onLlmResult(arg);
|
|
2752
2754
|
};
|
|
2753
|
-
self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
|
|
2755
|
+
self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome, ...(rates ? { rates } : {}) });
|
|
2754
2756
|
return self;
|
|
2755
2757
|
};
|
|
2756
2758
|
/** @param {string} prompt @param {typeof toolDefs} [defs] */
|
package/src/providerrows.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// `applyConfiguredKey` / `modelChoiceFor` below — no second lookup exists. Key VALUES
|
|
11
11
|
// never pass through here — only names.
|
|
12
12
|
import { resolveProvider } from './providers.js';
|
|
13
|
-
import { readConfig } from './config.js';
|
|
13
|
+
import { readConfig, ConfigError } from './config.js';
|
|
14
14
|
import { filledKeyNames } from './keysfile.js';
|
|
15
15
|
|
|
16
16
|
/** DeepSeek's OpenAI-shaped endpoint (the same string the panel's Model menu used). */
|
|
@@ -73,6 +73,9 @@ export function defaultsFor(envName) {
|
|
|
73
73
|
* @property {string} name the model id Chat uses ('' = not offered in Chat)
|
|
74
74
|
* @property {string} provider the `src/providers.js` table entry (the API shape's id)
|
|
75
75
|
* @property {string} baseUrl the saved Base URL ('' = the shape's default host)
|
|
76
|
+
* @property {unknown} [priceInPerM] the customer's own price, USD per 1M input tokens — carried RAW
|
|
77
|
+
* (present only when the config row names it); {@link ratesFor} is the one place that validates it
|
|
78
|
+
* @property {unknown} [priceOutPerM] the same, for output tokens
|
|
76
79
|
*/
|
|
77
80
|
|
|
78
81
|
/**
|
|
@@ -90,6 +93,8 @@ export function keyRows({ filled, config }) {
|
|
|
90
93
|
name: typeof s.name === 'string' ? s.name : d.name,
|
|
91
94
|
provider: SHAPES.some((x) => x.id === s.shape) ? s.shape : d.shape,
|
|
92
95
|
baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl : d.baseUrl,
|
|
96
|
+
...(Object.hasOwn(s, 'priceInPerM') ? { priceInPerM: s.priceInPerM } : {}),
|
|
97
|
+
...(Object.hasOwn(s, 'priceOutPerM') ? { priceOutPerM: s.priceOutPerM } : {}),
|
|
93
98
|
};
|
|
94
99
|
});
|
|
95
100
|
}
|
|
@@ -215,3 +220,84 @@ export function applyConfiguredKey(env, provider, baseUrl, rows, model) {
|
|
|
215
220
|
if (!k.chosen && value !== 'null') return env;
|
|
216
221
|
return { ...env, [k.builtIn]: usableKey(value) };
|
|
217
222
|
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A per-1M price for a readout: two decimals at least ($1.20), more when the number needs them so a
|
|
226
|
+
* tiny price never rounds to $0.00 or $0.01 ($0.006, $0.0004).
|
|
227
|
+
* @param {number} n USD per 1M tokens
|
|
228
|
+
* @returns {string}
|
|
229
|
+
*/
|
|
230
|
+
function perMoney(n) {
|
|
231
|
+
const shown = String(n);
|
|
232
|
+
if (!shown.includes('e') && (shown.split('.')[1] ?? '').length >= 2) return `$${shown}`;
|
|
233
|
+
if (shown.includes('e')) return `$${n.toFixed(12).replace(/0+$/, '').replace(/\.$/, '')}`;
|
|
234
|
+
return `$${n.toFixed(2)}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The one readout line for a resolved customer price, preview and run tail alike:
|
|
239
|
+
* `yours: in $0.006 / out $1.20 per 1M tokens (DEEPSEEK_API_KEY row)`. A 0 / 0 price is accepted (the price
|
|
240
|
+
* is the customer's) but the line says so: every round then books $0, so spend and limits read $0.
|
|
241
|
+
* @param {{ inPerM: number, outPerM: number, envName: string }} price a {@link ratesFor} result
|
|
242
|
+
* @returns {string}
|
|
243
|
+
*/
|
|
244
|
+
export function priceReadout(price) {
|
|
245
|
+
const zero = price.inPerM === 0 && price.outPerM === 0 ? ' — WARNING: this price is $0, so spend and limits read $0' : '';
|
|
246
|
+
return `yours: in ${perMoney(price.inPerM)} / out ${perMoney(price.outPerM)} per 1M tokens (${price.envName} row)${zero}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The customer's own price for the row a `(provider, baseUrl[, model])` identity belongs to —
|
|
251
|
+
* the SAME row match {@link keyNameFor} uses (`findRow`), so the price and the key always come
|
|
252
|
+
* from one row. `null` = no price set (the run keeps bare-agent's built-in guess, byte-identical
|
|
253
|
+
* to before). Both fields or neither, each a finite number >= 0, USD per 1M tokens; anything
|
|
254
|
+
* else throws `ConfigError` naming the row and field — a bad price never silently falls back to
|
|
255
|
+
* the guess. `rates` is bare-agent's `Loop({ rates })` shape (USD per 1K); `inPerM`/`outPerM`
|
|
256
|
+
* are what a readout prints.
|
|
257
|
+
* @param {string|null|undefined} provider
|
|
258
|
+
* @param {string|null|undefined} baseUrl
|
|
259
|
+
* @param {readonly KeyRow[]} rows
|
|
260
|
+
* @param {string|null|undefined} [model]
|
|
261
|
+
* @returns {{ rates: { in: number, out: number }, inPerM: number, outPerM: number, envName: string }|null}
|
|
262
|
+
*/
|
|
263
|
+
export function ratesFor(provider, baseUrl, rows, model) {
|
|
264
|
+
const row = findRow(rows, { provider, baseUrl, model });
|
|
265
|
+
if (!row) return null;
|
|
266
|
+
const hasIn = row.priceInPerM !== undefined;
|
|
267
|
+
const hasOut = row.priceOutPerM !== undefined;
|
|
268
|
+
if (!hasIn && !hasOut) return null;
|
|
269
|
+
if (hasIn !== hasOut) {
|
|
270
|
+
throw new ConfigError(`keys.${row.envName} sets ${hasIn ? 'priceInPerM' : 'priceOutPerM'} but not ${hasIn ? 'priceOutPerM' : 'priceInPerM'} — set both prices (USD per 1M tokens) or neither`);
|
|
271
|
+
}
|
|
272
|
+
for (const field of /** @type {const} */ (['priceInPerM', 'priceOutPerM'])) {
|
|
273
|
+
const v = row[field];
|
|
274
|
+
if (!(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
|
|
275
|
+
throw new ConfigError(`keys.${row.envName}.${field} must be a number, 0 or more (USD per 1M tokens) — fix it in config.json or remove both price lines`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const inPerM = /** @type {number} */ (row.priceInPerM);
|
|
279
|
+
const outPerM = /** @type {number} */ (row.priceOutPerM);
|
|
280
|
+
return { rates: { in: inPerM / 1000, out: outPerM / 1000 }, inPerM, outPerM, envName: row.envName };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The price a judge's rounds are booked at. A price belongs to the MODEL's row: when the judge is
|
|
285
|
+
* the worker's own provider it calls the worker's endpoint, so a row on that endpoint NAMED for the
|
|
286
|
+
* judge's model prices it ({@link ratesFor}); a judge model that is absent or names no row there
|
|
287
|
+
* keeps the worker's price (the worker's row, or none = the guess). A different provider resolves
|
|
288
|
+
* on its own default endpoint, as before. A bad price on the resolved row throws like `ratesFor`.
|
|
289
|
+
* The judge's KEY is chosen elsewhere (`applyConfiguredKey`, by the worker's model), so the two
|
|
290
|
+
* can come from different rows — same provider + endpoint, judge model naming another row.
|
|
291
|
+
* @param {{ provider: string, model?: string|null }} judge
|
|
292
|
+
* @param {string|null|undefined} workerProvider
|
|
293
|
+
* @param {string|null|undefined} workerBaseUrl
|
|
294
|
+
* @param {ReturnType<typeof ratesFor>} workerPrice what the worker's own rounds are booked at
|
|
295
|
+
* @param {readonly KeyRow[]} rows
|
|
296
|
+
* @returns {ReturnType<typeof ratesFor>}
|
|
297
|
+
*/
|
|
298
|
+
export function judgeRatesFor(judge, workerProvider, workerBaseUrl, workerPrice, rows) {
|
|
299
|
+
if (judge.provider !== workerProvider) return ratesFor(judge.provider, undefined, rows, judge.model);
|
|
300
|
+
const want = endpointOf(judge.provider, workerBaseUrl);
|
|
301
|
+
const named = judge.model ? rows.some((r) => r.provider === judge.provider && endpointOf(r.provider, r.baseUrl) === want && r.name === judge.model) : false;
|
|
302
|
+
return named ? ratesFor(judge.provider, workerBaseUrl, rows, judge.model) : workerPrice;
|
|
303
|
+
}
|