bareloop 0.40.0 → 0.41.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 +33 -0
- package/bareloop.context.md +97 -124
- package/package.json +2 -2
- package/src/authorflow.js +9 -9
- package/src/authorjob.js +4 -4
- package/src/authorrun.js +25 -25
- package/src/authorscout.js +4 -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/draftspend.js +4 -4
- package/src/index.js +2 -4
- package/src/interviewrun.js +17 -25
- package/src/job.js +13 -7
- package/src/judged.js +11 -43
- package/src/keysfile.js +15 -7
- package/src/kinds.js +2 -2
- package/src/modellist.js +84 -0
- package/src/monthly.js +23 -43
- package/src/panel/authorroutes.js +19 -14
- package/src/panel/authorsession.js +62 -58
- package/src/panel/index.html +520 -145
- package/src/panel/runroutes.js +3 -0
- package/src/panel/server.js +35 -15
- package/src/panel/settingsroutes.js +232 -173
- package/src/planrun.js +2 -2
- 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/thinking.js +130 -0
- package/src/userrun.js +50 -73
- package/types/authorflow.d.ts +5 -5
- package/types/authorjob.d.ts +2 -2
- package/types/authorscout.d.ts +2 -2
- package/types/config.d.ts +23 -6
- package/types/draftspend.d.ts +8 -8
- package/types/index.d.ts +1 -1
- package/types/job.d.ts +5 -5
- package/types/judged.d.ts +5 -36
- package/types/keysfile.d.ts +15 -4
- package/types/modellist.d.ts +50 -0
- package/types/monthly.d.ts +4 -3
- package/types/panel/authorsession.d.ts +2 -9
- package/types/panel/server.d.ts +14 -4
- package/types/providerrows.d.ts +192 -99
- package/types/providers.d.ts +34 -159
- package/types/providershapes.d.ts +10 -0
- package/types/thinking.d.ts +90 -0
- package/types/userrun.d.ts +6 -1
package/src/panel/runroutes.js
CHANGED
|
@@ -175,6 +175,9 @@ export function createRunRoutes(opts) {
|
|
|
175
175
|
'--wait', 'systemd-inhibit', '--why=bareloop panel resume',
|
|
176
176
|
process.execPath, bareloopBin,
|
|
177
177
|
'run-u', '--spec', specPath, '--resume', m[1], '--approve', specHash,
|
|
178
|
+
// an older spec that names no model runs on the Settings line the person picks at resume (hash untouched, run-u's own rule)
|
|
179
|
+
...(typeof body?.model === 'string' && body.model !== '' ? ['--model', body.model] : []),
|
|
180
|
+
...(typeof body?.provider === 'string' && body.provider !== '' ? ['--provider', body.provider] : []),
|
|
178
181
|
// the drafting fold rides every leg of the chain (a leg that dropped it would widen the ceiling)
|
|
179
182
|
...(plan.draftSpentUsd !== null ? ['--draft-spent-usd', String(plan.draftSpentUsd), ...(plan.draftSpendComplete === false ? ['--draft-spend-incomplete'] : [])] : []),
|
|
180
183
|
];
|
package/src/panel/server.js
CHANGED
|
@@ -49,7 +49,8 @@ import { parseImportedRunId, safeSpinePath, spineStartedAt } from './importrun.j
|
|
|
49
49
|
import { checkBundleDeps, resolveBundleSpec } from '../bundle.js';
|
|
50
50
|
import { checkCloseByteSignature } from '../close-integrity.js';
|
|
51
51
|
import { keysHome } from '../keysfile.js';
|
|
52
|
-
import { rowsForHome, findRow, modelChoiceFor } from '../providerrows.js';
|
|
52
|
+
import { rowsForHome, findRow, modelChoiceFor, tickedLines, endpointOf } from '../providerrows.js';
|
|
53
|
+
import { SHAPES } from '../providershapes.js';
|
|
53
54
|
import { resolveProvider } from '../providers.js';
|
|
54
55
|
import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
|
|
55
56
|
import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
|
|
@@ -2374,28 +2375,38 @@ function signedSpecForRun(row, records) {
|
|
|
2374
2375
|
}
|
|
2375
2376
|
|
|
2376
2377
|
/**
|
|
2377
|
-
* The worker a signed spec ran: provider, baseUrl and model
|
|
2378
|
-
* when the spec names none (the same fallback `resolveWorkerModel` makes at run time).
|
|
2378
|
+
* The worker a signed spec ran: provider, baseUrl and model ('' when an older spec names none: bareloop keeps no default).
|
|
2379
2379
|
* @param {any} spec
|
|
2380
2380
|
* @returns {{provider: string, baseUrl?: string, model: string}}
|
|
2381
2381
|
*/
|
|
2382
2382
|
function workerOfSpec(spec) {
|
|
2383
2383
|
const provider = typeof spec?.provider === 'string' ? spec.provider : '';
|
|
2384
|
-
|
|
2385
|
-
if (!model) { try { model = resolveProvider(provider).tiers.sonnet; } catch { model = ''; } }
|
|
2384
|
+
const model = typeof spec?.model === 'string' ? spec.model : '';
|
|
2386
2385
|
return { provider, ...(typeof spec?.baseUrl === 'string' && spec.baseUrl ? { baseUrl: spec.baseUrl } : {}), model };
|
|
2387
2386
|
}
|
|
2388
2387
|
/** @param {{provider: string, baseUrl?: string, model: string}} w @returns {string} one spelling of "the same worker" */
|
|
2389
2388
|
const workerId = (w) => `${w.provider}\u0000${w.baseUrl ?? ''}\u0000${w.model}`;
|
|
2389
|
+
/**
|
|
2390
|
+
* The model id a reuse card's Model box shows for a row: the spec's own model when the row has a line for it, else the
|
|
2391
|
+
* row's first ticked line; `undefined` when there is no row or no such line.
|
|
2392
|
+
* @param {import('../providerrows.js').KeyRow|null} row
|
|
2393
|
+
* @param {string|null|undefined} specModel
|
|
2394
|
+
* @returns {string|undefined}
|
|
2395
|
+
*/
|
|
2396
|
+
function rowModelOf(row, specModel) {
|
|
2397
|
+
if (!row) return undefined;
|
|
2398
|
+
if (specModel && row.models.some((l) => l.model === specModel)) return specModel;
|
|
2399
|
+
return tickedLines([row])[0]?.line.model;
|
|
2400
|
+
}
|
|
2390
2401
|
/**
|
|
2391
2402
|
* The worker the reuse card's CURRENT Model box names: the Settings row's provider, baseUrl and Name. `null` when the
|
|
2392
2403
|
* box is blank or names no row.
|
|
2393
2404
|
* @param {string} model a Settings Name
|
|
2394
|
-
* @param {{ home?: string }} opts
|
|
2405
|
+
* @param {{ home?: string, provider?: string }} opts `provider` is the key's env name the card picked
|
|
2395
2406
|
* @returns {{provider: string, baseUrl?: string, model: string}|null}
|
|
2396
2407
|
*/
|
|
2397
2408
|
function workerOfChoice(model, opts) {
|
|
2398
|
-
const c = modelChoiceFor(rowsForHome(keysHome(opts.home)), model);
|
|
2409
|
+
const c = modelChoiceFor(rowsForHome(keysHome(opts.home)), { provider: opts.provider, model });
|
|
2399
2410
|
return c ? { provider: c.provider, ...(c.baseUrl ? { baseUrl: c.baseUrl } : {}), model: c.name } : null;
|
|
2400
2411
|
}
|
|
2401
2412
|
|
|
@@ -2478,10 +2489,11 @@ export function reuseLine(record) {
|
|
|
2478
2489
|
* $ cap and the Time cap — start at what the run had. `ok:false` when the run's signed job is not on disk (nothing
|
|
2479
2490
|
* to copy: change the job: Clear the card). `null` when the runid is not listed.
|
|
2480
2491
|
* @param {string} runid
|
|
2481
|
-
* @param {{ home?: string, model?: string }} [opts] `model` = the Model box's current Name (the estimate counts only runs on that worker); absent = the card's own Model
|
|
2492
|
+
* @param {{ home?: string, model?: string, provider?: string }} [opts] `model` = the Model box's current Name (the estimate counts only runs on that worker); absent = the card's own Model
|
|
2482
2493
|
* @returns {{ok: true, origin: {runid: string, job: string}, outcome: string|null, card: Record<string, any>, from: 'signed job',
|
|
2483
2494
|
* locked: readonly string[], open: readonly string[], note: string|null,
|
|
2484
|
-
* specHash: string, workflowKey: string, spec: any, specPath: string,
|
|
2495
|
+
* specHash: string, workflowKey: string, spec: any, specPath: string, specNamesModel: boolean,
|
|
2496
|
+
* specWorker: {provider: string, endpoint: string, label: string},
|
|
2485
2497
|
* trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
|
|
2486
2498
|
*/
|
|
2487
2499
|
export function getStartFrom(runid, opts = {}) {
|
|
@@ -2507,7 +2519,7 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2507
2519
|
const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
2508
2520
|
const card = reuseCardFromSpec(spec, {
|
|
2509
2521
|
model: typeof saved.model === 'string' && saved.model ? saved.model
|
|
2510
|
-
: (findRow(rowsForHome(keysHome(opts.home)), { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model })
|
|
2522
|
+
: (rowModelOf(findRow(rowsForHome(keysHome(opts.home)), { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model }), spec.model)
|
|
2511
2523
|
?? (typeof jobStart?.model === 'string' ? jobStart.model : '')),
|
|
2512
2524
|
source: typeof saved.source === 'string' && saved.source ? saved.source : (typeof manifest?.source === 'string' ? manifest.source : (typeof row.patient === 'string' ? row.patient : '')),
|
|
2513
2525
|
// a card saved by P7 or later keeps the Inputs text the person typed
|
|
@@ -2515,7 +2527,7 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2515
2527
|
jobName: row.job,
|
|
2516
2528
|
});
|
|
2517
2529
|
const key = workflowKey(spec);
|
|
2518
|
-
const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, opts) });
|
|
2530
|
+
const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, { ...opts, provider: opts.provider ?? card.provider }) });
|
|
2519
2531
|
return {
|
|
2520
2532
|
ok: true,
|
|
2521
2533
|
origin: { runid, job: row.job },
|
|
@@ -2530,6 +2542,14 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2530
2542
|
workflowKey: key,
|
|
2531
2543
|
spec,
|
|
2532
2544
|
specPath: signed.specPath,
|
|
2545
|
+
// an older spec signed before models were required names none: the Resume card then opens its Model box (run-u takes `--model`)
|
|
2546
|
+
specNamesModel: typeof spec.model === 'string' && spec.model !== '',
|
|
2547
|
+
// what resolveWorker will hold a picked line to: the spec's shape, and its endpoint when it names one ('' = any endpoint)
|
|
2548
|
+
specWorker: {
|
|
2549
|
+
provider: String(spec.provider),
|
|
2550
|
+
endpoint: typeof spec.baseUrl === 'string' && spec.baseUrl.trim() !== '' ? endpointOf(String(spec.provider), spec.baseUrl) : '',
|
|
2551
|
+
label: SHAPES.find((x) => x.id === spec.provider)?.label ?? String(spec.provider),
|
|
2552
|
+
},
|
|
2533
2553
|
trackRecord,
|
|
2534
2554
|
line: reuseLine(trackRecord),
|
|
2535
2555
|
};
|
|
@@ -2540,7 +2560,7 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2540
2560
|
* that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
|
|
2541
2561
|
* it to the existing start-from flow unchanged; `line` is `getStartFrom`'s own estimate line (never a second
|
|
2542
2562
|
* spelling). Local runs only — an imported job keeps its Reuse button in the Import view. $0, read-only.
|
|
2543
|
-
* @param {{ home?: string, model?: string }} [opts] `model` passes through to the estimate line, as start-from's does
|
|
2563
|
+
* @param {{ home?: string, model?: string, provider?: string }} [opts] `model` passes through to the estimate line, as start-from's does
|
|
2544
2564
|
* @returns {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]}
|
|
2545
2565
|
*/
|
|
2546
2566
|
export function listReuseJobs(opts = {}) {
|
|
@@ -2621,7 +2641,7 @@ function describeSpec(spec) {
|
|
|
2621
2641
|
* signs it on this machine at [Sign & run]. Same card, same locked/open boxes as a run's reuse. `null` when no such
|
|
2622
2642
|
* imported job.
|
|
2623
2643
|
* @param {string} id
|
|
2624
|
-
* @param {{ home?: string, model?: string }} [opts] `model` as for {@link getStartFrom}
|
|
2644
|
+
* @param {{ home?: string, model?: string, provider?: string }} [opts] `model` as for {@link getStartFrom}
|
|
2625
2645
|
* @returns {{ok: true, origin: {runid: null, job: string, importId: string}, card: Record<string, any>, from: 'imported job',
|
|
2626
2646
|
* locked: readonly string[], open: readonly string[], note: string|null, specHash: string, workflowKey: string, spec: any,
|
|
2627
2647
|
* specPath: null, trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
|
|
@@ -2640,10 +2660,10 @@ export function getStartFromImport(id, opts = {}) {
|
|
|
2640
2660
|
// the bundle's provider is no longer forced: Model is open on a reuse, so the person's chosen Settings row is the worker.
|
|
2641
2661
|
// The card opens on the row matching the bundle's own provider when there is one, else blank (the page's menu picks).
|
|
2642
2662
|
const rows = rowsForHome(keysHome(opts.home));
|
|
2643
|
-
const named = findRow(rows, { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model })
|
|
2663
|
+
const named = rowModelOf(findRow(rows, { provider: spec.provider, baseUrl: spec.baseUrl, model: spec.model }), spec.model) ?? '';
|
|
2644
2664
|
const card = reuseCardFromSpec(spec, { model: named, source: '', jobName: row.job });
|
|
2645
2665
|
const key = workflowKey(spec);
|
|
2646
|
-
const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, opts) });
|
|
2666
|
+
const trackRecord = trackRecordFor(key, { ...opts, worker: workerOfChoice(opts.model ?? card.model, { ...opts, provider: opts.provider ?? card.provider }) });
|
|
2647
2667
|
return {
|
|
2648
2668
|
ok: true,
|
|
2649
2669
|
origin: { runid: null, job: row.job, importId: id },
|
|
@@ -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
|
@@ -1685,7 +1685,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1685
1685
|
// ride, so `runJob`'s ONE ledger accounts a close's spend exactly as it accounts
|
|
1686
1686
|
// an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
|
|
1687
1687
|
// 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,
|
|
1688
|
+
judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgeRates, thinking: job.thinking === true }) : null,
|
|
1689
1689
|
// the seam's other half (PRD item 32.1) — WHICH model that loop drives. Passed
|
|
1690
1690
|
// through untouched: this module resolves no judge, exactly as it constructs no
|
|
1691
1691
|
// provider. `runJudgedFloor` stops as a wiring gap when it is absent rather than
|
|
@@ -2301,7 +2301,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2301
2301
|
// chain, node_modules/bareguard/src/gate.js) — i.e. only the residual
|
|
2302
2302
|
// case of an in-fence path that still carries one of these segments.
|
|
2303
2303
|
tools: { denyArgPatterns: { write: [FORBIDDEN_WRITE_SEGMENT_PATTERN], edit: [FORBIDDEN_WRITE_SEGMENT_PATTERN] } },
|
|
2304
|
-
budget: { maxCostUsd: Math.max(remainingUsd(),
|
|
2304
|
+
budget: { maxCostUsd: Math.max(remainingUsd(), 1e-4) },
|
|
2305
2305
|
limits: { maxTurns },
|
|
2306
2306
|
audit: { path: auditPath },
|
|
2307
2307
|
// The gate audit is the THIRD persistent, append-only channel (beside the
|