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/job.js
CHANGED
|
@@ -167,7 +167,7 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
|
|
|
167
167
|
// `steps` stays in the field list ONLY so a retired spec reds by name
|
|
168
168
|
// (`shape-retired`) instead of falling through to a generic unknown-field —
|
|
169
169
|
// the operator gets told what happened, not just that something is wrong.
|
|
170
|
-
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools', 'jobLines', 'inputs'];
|
|
170
|
+
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'thinking', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools', 'jobLines', 'inputs'];
|
|
171
171
|
/** the exact fields of a signed `judge` override — nested objects red unknown
|
|
172
172
|
* keys too, because no smuggling level exists in a signed spec */
|
|
173
173
|
const JUDGE_FIELDS = ['provider', 'model'];
|
|
@@ -329,7 +329,12 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
|
|
|
329
329
|
// on THAT model, not merely on the goal/close it runs against. The judge
|
|
330
330
|
// model stays library-pinned pending recalibration — out of scope here.
|
|
331
331
|
if (spec.model !== undefined && !isNonEmptyString(spec.model)) {
|
|
332
|
-
red('invalid-value', 'model', 'non-empty string — exact
|
|
332
|
+
red('invalid-value', 'model', 'non-empty string — the exact model id of a line ticked in Settings > Providers');
|
|
333
|
+
}
|
|
334
|
+
// THINKING (P8) — the job's "Thinking on" tick, OPTIONAL: absent means off (and an old spec keeps the hash it always had); present it is
|
|
335
|
+
// signed like `model`. It covers DRAFTING and the rubric JUDGE only — the worker is never handed a thinking option. A boolean, nothing else.
|
|
336
|
+
if (spec.thinking !== undefined && typeof spec.thinking !== 'boolean') {
|
|
337
|
+
red('invalid-value', 'thinking', 'true or false — the job\'s "Thinking on" tick (absent = off)');
|
|
333
338
|
}
|
|
334
339
|
|
|
335
340
|
// JUDGE — PRD item 32.1 (hamr, 2026-09-10: the judge "defaults to whatever
|
|
@@ -984,15 +989,15 @@ export function checkApproval(job, approvals) {
|
|
|
984
989
|
* mismatched flag is a mistake worth stopping on, never a silent swap); the
|
|
985
990
|
* same id from both is not a conflict. Extracted from scripts/run-u.mjs so
|
|
986
991
|
* the decision is testable without running the script or touching a provider.
|
|
987
|
-
* @param {{ specModel?: string, flagModel?: string,
|
|
992
|
+
* @param {{ specModel?: string, flagModel?: string, suppliedModel?: string }} args
|
|
988
993
|
* `specModel` — `job.model` if the signed spec carries one, else undefined.
|
|
989
994
|
* `flagModel` — the model id an explicit `--model <tier>` resolved to, else
|
|
990
995
|
* undefined (a flag that was never passed, not merely a default tier name).
|
|
991
|
-
* `
|
|
996
|
+
* `suppliedModel` — a model the CALLER supplies when neither spec nor flag says (bareloop keeps none of its own).
|
|
992
997
|
* @returns {{ model: string, source: 'spec'|'flag'|'default' }}
|
|
993
|
-
* @throws {Error} when the spec names a model and the flag names a different one
|
|
998
|
+
* @throws {Error} when the spec names a model and the flag names a different one, or when no model is named at all
|
|
994
999
|
*/
|
|
995
|
-
export function resolveWorkerModel({ specModel, flagModel,
|
|
1000
|
+
export function resolveWorkerModel({ specModel, flagModel, suppliedModel }) {
|
|
996
1001
|
if (specModel !== undefined) {
|
|
997
1002
|
if (flagModel !== undefined && flagModel !== specModel) {
|
|
998
1003
|
throw new Error(`spec names model "${specModel}" but --model resolved to "${flagModel}" — the signed spec wins; a mismatched --model flag is refused, never silently overridden. Drop --model or point it at the same model.`);
|
|
@@ -1000,5 +1005,6 @@ export function resolveWorkerModel({ specModel, flagModel, defaultModel }) {
|
|
|
1000
1005
|
return { model: specModel, source: 'spec' };
|
|
1001
1006
|
}
|
|
1002
1007
|
if (flagModel !== undefined) return { model: flagModel, source: 'flag' };
|
|
1003
|
-
|
|
1008
|
+
if (suppliedModel === undefined) throw new Error('no model is named — a job carries its own model, or the run is given --model <id> (one ticked in Settings > Providers with both prices)');
|
|
1009
|
+
return { model: suppliedModel, source: 'default' };
|
|
1004
1010
|
}
|
package/src/judged.js
CHANGED
|
@@ -66,43 +66,11 @@
|
|
|
66
66
|
import { quoteIn } from 'bareguard';
|
|
67
67
|
import { createRequire } from 'node:module';
|
|
68
68
|
import { extractArtifact, priceOf, scrubRaw, rateSourceFields } from './text.js';
|
|
69
|
-
import { resolveProvider
|
|
69
|
+
import { resolveProvider } from './providers.js';
|
|
70
|
+
import { thinkingCallOptions } from './thinking.js';
|
|
70
71
|
|
|
71
72
|
const require = createRequire(import.meta.url);
|
|
72
73
|
|
|
73
|
-
/**
|
|
74
|
-
* THE JUDGE'S HISTORICAL PIN. Until PRD item 32 (2026-09-10) this constant WAS
|
|
75
|
-
* the judge: every grading path imported it, so bareloop could run a worker on
|
|
76
|
-
* any provider and still judge only on Claude.
|
|
77
|
-
*
|
|
78
|
-
* It is no longer read by any grading path. The judge identity is now RESOLVED
|
|
79
|
-
* per job (`resolveJudge` below) and travels as data — `ctx.judgeModel` into
|
|
80
|
-
* `runJudgedFloor`, an explicit argument into the calibration gate, and
|
|
81
|
-
* `closeDecl.calibration.judgeModel` into the signed spec, exactly as before.
|
|
82
|
-
* Keeping the export is deliberate: it is part of the public API
|
|
83
|
-
* (`src/index.js`) and an adopter importing it should not break on a minor.
|
|
84
|
-
* What it means now is "the judge bareloop pinned before item 32", which is
|
|
85
|
-
* still the right DEFAULT for an anthropic-api job and still the only tier with
|
|
86
|
-
* established injection resistance upstream (BA-20's `INJECTION_BATTERY` is
|
|
87
|
-
* haiku-4.5 only).
|
|
88
|
-
*
|
|
89
|
-
* THE ENFORCEMENT IS UNCHANGED, and still lives in three wired places because a
|
|
90
|
-
* rule with no detector is prose (F45) — only the value being compared moved
|
|
91
|
-
* from this constant to the resolved identity:
|
|
92
|
-
* 1. the identity is STORED with the set it graded — `foldJudgedArtifacts`
|
|
93
|
-
* writes it as `closeDecl.calibration.judgeModel`, inside the spec, so
|
|
94
|
-
* `jobSpecHash` covers it by construction. Change the judge and every
|
|
95
|
-
* signed judged spec hashes differently: the signature dies, and re-signing
|
|
96
|
-
* is what re-runs the calibration gate.
|
|
97
|
-
* 2. `validateCloseDecl` REQUIRES it on any stored set — a set nobody can
|
|
98
|
-
* attribute to a judge is a floor nobody can tell apart from a moved one.
|
|
99
|
-
* 3. `runJudgedFloor` refuses to grade when the stored identity is not the one
|
|
100
|
-
* about to grade, naming both. It does not degrade in either direction:
|
|
101
|
-
* grading on the new judge judges against a floor nobody calibrated, and
|
|
102
|
-
* the old judge is not on offer.
|
|
103
|
-
*/
|
|
104
|
-
export const JUDGE_MODEL = 'claude-haiku-4-5';
|
|
105
|
-
|
|
106
74
|
/**
|
|
107
75
|
* RESOLVE WHICH MODEL GRADES THIS JOB (PRD item 32.1).
|
|
108
76
|
*
|
|
@@ -122,7 +90,7 @@ export const JUDGE_MODEL = 'claude-haiku-4-5';
|
|
|
122
90
|
* item 31.4 a green job runs on its own key alone, and this is what lets a
|
|
123
91
|
* JUDGED one do the same.
|
|
124
92
|
*
|
|
125
|
-
* There is deliberately NO third fallback to
|
|
93
|
+
* There is deliberately NO third fallback to a library-pinned model. A caller that cannot
|
|
126
94
|
* say which model will grade has not wired a judge, and the grading path stops
|
|
127
95
|
* as a wiring gap rather than grading under an identity nobody chose — the same
|
|
128
96
|
* answer an absent `judgeLoop` already gets. A silent default here would be the
|
|
@@ -198,15 +166,13 @@ export function resolveJudge({ specJudge, workerProvider, workerModel } = {}) {
|
|
|
198
166
|
*
|
|
199
167
|
* @param {{provider?: string, model?: string, judge?: any}} source draft or spec
|
|
200
168
|
* @param {string} fallbackProviderName used only when `source.provider` is absent
|
|
201
|
-
* @param {(o: {specModel?: string, flagModel?: string,
|
|
169
|
+
* @param {(o: {specModel?: string, flagModel?: string, suppliedModel?: string}) => {model: string, source: string}} resolveWorkerModelFn `resolveWorkerModel` from `./job.js`
|
|
202
170
|
* @returns {{provider: string, model: string}}
|
|
203
171
|
*/
|
|
204
172
|
export function resolveJobJudge(source, fallbackProviderName, resolveWorkerModelFn) {
|
|
205
173
|
const providerName = source?.provider ?? fallbackProviderName;
|
|
206
|
-
|
|
207
|
-
const { model } = resolveWorkerModelFn({
|
|
208
|
-
specModel: source?.model, flagModel: undefined, defaultModel: providerEntry.tiers.sonnet,
|
|
209
|
-
});
|
|
174
|
+
resolveProvider(providerName); // an unknown shape is the named throw, never a silent default
|
|
175
|
+
const { model } = resolveWorkerModelFn({ specModel: source?.model, flagModel: undefined });
|
|
210
176
|
return resolveJudge({ specJudge: source?.judge, workerProvider: providerName, workerModel: model });
|
|
211
177
|
}
|
|
212
178
|
|
|
@@ -1089,9 +1055,11 @@ function topLevelFunctionNames(artifactText) {
|
|
|
1089
1055
|
* so the priced seam is the only legal one.
|
|
1090
1056
|
* `rates` is the customer's own price for the judge's row (USD per 1K, `ratesFor`'s
|
|
1091
1057
|
* `rates`); null/absent = bare-agent's built-in guess, exactly as before.
|
|
1092
|
-
*
|
|
1058
|
+
* `thinking` is the job's SIGNED "Thinking on" tick (false/absent = off): the judge follows the signed value — so the calibration gate
|
|
1059
|
+
* (10/10) and the later grading run with the SAME tick — and bare-agent decides what each model can switch (src/thinking.js).
|
|
1060
|
+
* @param {{provider: any, system: string, rates?: {in: number, out: number}|null, thinking?: boolean}} o
|
|
1093
1061
|
* @returns {{run: (msgs: any[], tools: any[], opts?: any) => Promise<any>}} */
|
|
1094
|
-
export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
|
|
1062
|
+
export const defaultJudgeLoop = ({ provider, system, rates = null, thinking = false }) => {
|
|
1095
1063
|
const { Loop } = require('bare-agent');
|
|
1096
1064
|
// BA-21: the Loop's run RESULT carries no `rateSource` — only the per-call metering event
|
|
1097
1065
|
// does (`onLlmResult`). Capture it there and ride it on the result, so `runLocate` can
|
|
@@ -1107,7 +1075,7 @@ export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
|
|
|
1107
1075
|
return {
|
|
1108
1076
|
run: async (/** @type {any[]} */ msgs, /** @type {any[]} */ tools, /** @type {any} */ opts) => {
|
|
1109
1077
|
seen = null;
|
|
1110
|
-
const res = await loop.run(msgs, tools, { ...
|
|
1078
|
+
const res = await loop.run(msgs, tools, { ...thinkingCallOptions(provider, thinking === true ? 'on' : 'off'), ...opts });
|
|
1111
1079
|
return res && typeof res === 'object' && seen ? { ...res, rateSource: /** @type {any} */ (seen).rateSource } : res;
|
|
1112
1080
|
},
|
|
1113
1081
|
};
|
package/src/keysfile.js
CHANGED
|
@@ -76,18 +76,24 @@ export function filledKeyNames(home) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
|
|
79
|
+
* The two comment lines a fresh keys file is created with — no provider name, no preset key.
|
|
80
|
+
* Each key the person types becomes a row in Settings > Providers.
|
|
81
|
+
*/
|
|
82
|
+
export const KEYS_FILE_BODY = '# bareloop keys — type NAME=value by hand; this file is never shown or written by bareloop.\n'
|
|
83
|
+
+ '# One line per provider key, e.g. MY_PROVIDER_API_KEY=... ; each key becomes a row in Settings > Providers.\n';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create the keys file with {@link KEYS_FILE_BODY} when — and only when — it does not
|
|
87
|
+
* exist (mode 600, dir 700). An existing file is never touched.
|
|
82
88
|
* @param {string} [home]
|
|
83
89
|
* @returns {boolean} true if it was created
|
|
84
90
|
*/
|
|
85
|
-
export function ensureKeysFile(
|
|
91
|
+
export function ensureKeysFile(home) {
|
|
86
92
|
const file = keysFilePath(home);
|
|
87
93
|
if (existsSync(file)) return false;
|
|
88
|
-
mkdirSync(keysHome(home), { recursive: true });
|
|
94
|
+
mkdirSync(keysHome(home), { recursive: true, mode: 0o700 });
|
|
89
95
|
try {
|
|
90
|
-
writeFileSync(file,
|
|
96
|
+
writeFileSync(file, KEYS_FILE_BODY, { mode: 0o600, flag: 'wx' });
|
|
91
97
|
} catch (/** @type {any} */ e) {
|
|
92
98
|
if (e?.code === 'EEXIST') return false; // lost a race: the file is there, untouched
|
|
93
99
|
throw e;
|
|
@@ -101,6 +107,8 @@ export function ensureKeysFile(presetNames, home) {
|
|
|
101
107
|
* @property {string[]} names the NAMES the file defines (sorted; never values)
|
|
102
108
|
* @property {string|null} warning a plain-words warning (file readable by others, or
|
|
103
109
|
* unreadable), else null — a warning, never a refusal
|
|
110
|
+
* @property {boolean} [unreadable] true only when the file is there but could not be read (the Settings row then
|
|
111
|
+
* reads "refused — see above"); absent otherwise
|
|
104
112
|
* @property {Record<string,string|undefined>} env shell env with the file filling the
|
|
105
113
|
* names the shell does not set. THE ONLY place values leave this module.
|
|
106
114
|
*/
|
|
@@ -127,7 +135,7 @@ export function loadKeysEnv(opts = {}) {
|
|
|
127
135
|
try {
|
|
128
136
|
parsed = parseKeysText(readFileSync(file, 'utf8'));
|
|
129
137
|
} catch (/** @type {any} */ e) {
|
|
130
|
-
return { exists: true, names: [], warning: `${file} could not be read (${e?.code ?? 'error'})`, env: { ...base } };
|
|
138
|
+
return { exists: true, names: [], warning: `${file} could not be read (${e?.code ?? 'error'})`, unreadable: true, env: { ...base } };
|
|
131
139
|
}
|
|
132
140
|
/** @type {Record<string,string|undefined>} */
|
|
133
141
|
const env = { ...base };
|
package/src/kinds.js
CHANGED
|
@@ -1776,7 +1776,7 @@ async function runJudgedFloor(stage, ctx) {
|
|
|
1776
1776
|
+ `${MAX_JUDGED_PATHS} — a judged stage buys one call per artifact, and a runaway list is a bill`, {}, STOP_FAULTS.CRASHED);
|
|
1777
1777
|
}
|
|
1778
1778
|
// 1b. THE TIER THE SIGNED SET WAS GRADED BY, against the tier this run would buy.
|
|
1779
|
-
//
|
|
1779
|
+
// the judge model has always SAID a bump forces a full recalibration; this is the
|
|
1780
1780
|
// detector, because a rule with no wired detector is prose (F45). The stamp comes
|
|
1781
1781
|
// off the SIGNED calibration (`calibration.judgeModel`, carried across by
|
|
1782
1782
|
// `declaredStages`), so a mismatch means exactly one thing: the pin moved under a
|
|
@@ -1799,7 +1799,7 @@ async function runJudgedFloor(stage, ctx) {
|
|
|
1799
1799
|
// model). The IDENTITY is checked before the seam and before the stamp, because
|
|
1800
1800
|
// an unwired identity makes both of the messages below unwritable: neither
|
|
1801
1801
|
// "would grade with X" nor "the judge is X" can be said by a stage that cannot
|
|
1802
|
-
// name X. There is deliberately NO fall-back
|
|
1802
|
+
// name X. There is deliberately NO library-pinned fall-back here — a run that
|
|
1803
1803
|
// cannot say which model grades has not wired a judge, and grading under an
|
|
1804
1804
|
// identity nobody chose is exactly the silent-ignore class this repo refuses
|
|
1805
1805
|
// everywhere else.
|
package/src/modellist.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// rewritten-from: fwdloop src/panel/settings.js@f9449e8 (`authHeaders`, `getWithKey`, `idsOf`, `listModels`)
|
|
2
|
+
// PANEL-BUILD.md P8 — the ONE `<baseUrl>/models` GET. Settings > Providers' Test and Refresh and the
|
|
3
|
+
// New job card's model-readiness check (`checkProviderReachable`, src/providers.js) all call it; no
|
|
4
|
+
// second copy of the probe exists. It is a GET of a models LIST, never a completion, so it spends
|
|
5
|
+
// no token and writes no spend row. The key goes in a header only and is never returned or logged.
|
|
6
|
+
// A redirect is NOT followed (`redirect:'manual'`), so the key is never sent on to another host.
|
|
7
|
+
// There is no default host: a blank Base URL makes no call.
|
|
8
|
+
|
|
9
|
+
/** The default deadline for the one GET. */
|
|
10
|
+
export const LIST_TIMEOUT_MS = 4000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The key goes only in a header, in the form each shape expects.
|
|
14
|
+
* @param {string} shape an API shape id (src/providershapes.js)
|
|
15
|
+
* @param {string} key
|
|
16
|
+
* @returns {Record<string, string>}
|
|
17
|
+
*/
|
|
18
|
+
export function authHeaders(shape, key) {
|
|
19
|
+
if (shape === 'anthropic-api') return { 'x-api-key': key, 'anthropic-version': '2023-06-01' };
|
|
20
|
+
if (shape === 'gemini-api') return { 'x-goog-api-key': key };
|
|
21
|
+
return { Authorization: `Bearer ${key}` };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The model ids a provider's list answer holds, per API shape (`data[].id` for two shapes;
|
|
26
|
+
* `models[].name` minus its `models/` prefix for the third). Sorted, de-duplicated.
|
|
27
|
+
* @param {string} shape
|
|
28
|
+
* @param {any} body
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
export function idsOf(shape, body) {
|
|
32
|
+
const list = shape === 'gemini-api' ? body?.models : body?.data;
|
|
33
|
+
const key = shape === 'gemini-api' ? 'name' : 'id';
|
|
34
|
+
const ids = (Array.isArray(list) ? list : [])
|
|
35
|
+
.map((/** @type {any} */ m) => (typeof m?.[key] === 'string' ? m[key].replace(/^models\//, '') : ''))
|
|
36
|
+
.filter((id) => id !== '');
|
|
37
|
+
return [...new Set(ids)].sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {{ ok: true, models: string[], ms: number }
|
|
42
|
+
* | { ok: false, why: string, status: string, detail?: string }} ListResult
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One free GET of `<baseUrl>/models` with the key in the header only and a deadline.
|
|
47
|
+
* Failure is data, never a throw: `why` is a plain sentence, `status` a short machine word
|
|
48
|
+
* (`no-base-url`, `redirect`, `HTTP <n>`, `timeout`, `unreachable`) for callers that need it.
|
|
49
|
+
* @param {object} o
|
|
50
|
+
* @param {string} o.shape an API shape id
|
|
51
|
+
* @param {string} o.baseUrl the row's Base URL ('' = no call)
|
|
52
|
+
* @param {string} o.key the key value (header only)
|
|
53
|
+
* @param {typeof fetch} [o.fetchImpl] test seam; defaults to the global `fetch`
|
|
54
|
+
* @param {number} [o.timeoutMs]
|
|
55
|
+
* @returns {Promise<ListResult>}
|
|
56
|
+
*/
|
|
57
|
+
export async function listModels({ shape, baseUrl, key, fetchImpl = fetch, timeoutMs = LIST_TIMEOUT_MS }) {
|
|
58
|
+
const base = typeof baseUrl === 'string' ? baseUrl.trim().replace(/\/+$/, '') : '';
|
|
59
|
+
if (base === '') return { ok: false, why: 'Type a Base URL.', status: 'no-base-url' };
|
|
60
|
+
const url = `${base}/models`;
|
|
61
|
+
const started = Date.now();
|
|
62
|
+
const ctl = new AbortController();
|
|
63
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetchImpl(url, { method: 'GET', headers: authHeaders(shape, key), signal: ctl.signal, redirect: 'manual' });
|
|
66
|
+
if (res.status >= 300 && res.status < 400) {
|
|
67
|
+
return { ok: false, why: 'The provider answered with a redirect; the key was not sent on. Check the Base URL.', status: 'redirect' };
|
|
68
|
+
}
|
|
69
|
+
if (res.status === 401 || res.status === 403) return { ok: false, why: 'The provider refused the key.', status: `HTTP ${res.status}` };
|
|
70
|
+
if (!res.ok) return { ok: false, why: 'The provider answered with an error.', status: `HTTP ${res.status}` };
|
|
71
|
+
/** @type {any} */
|
|
72
|
+
let body = null;
|
|
73
|
+
try { body = await res.json(); } catch { body = null; }
|
|
74
|
+
return { ok: true, models: idsOf(shape, body), ms: Date.now() - started };
|
|
75
|
+
} catch (/** @type {any} */ e) {
|
|
76
|
+
if (e?.name === 'AbortError') return { ok: false, why: 'The provider did not answer in 4 seconds.', status: 'timeout' };
|
|
77
|
+
const cls = e?.code || e?.name || 'network-error';
|
|
78
|
+
// connection refused surfaces as fetch's bare TypeError ("fetch failed")
|
|
79
|
+
const refused = e?.name === 'TypeError' || e?.code === 'ECONNREFUSED' || e?.cause?.code === 'ECONNREFUSED';
|
|
80
|
+
return { ok: false, why: 'Could not reach the provider.', status: String(cls), detail: refused ? `nothing answering at ${url}` : `request failed: ${cls}` };
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/monthly.js
CHANGED
|
@@ -33,37 +33,10 @@ import { readOrphanDraftSpends } from './draftspend.js';
|
|
|
33
33
|
// the per-leg / per-run spend readers live with the ledger (one home, no import cycle through the replay reader)
|
|
34
34
|
export { legSpend, runSpend, chainSpend };
|
|
35
35
|
|
|
36
|
-
// The Money tab's per-provider breakdown groups a run by the
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
/** @type {readonly {id: string, name: string}[]} */
|
|
41
|
-
const MONEY_LABELS = Object.freeze([
|
|
42
|
-
{ id: 'anthropic', name: 'Anthropic' }, { id: 'openai', name: 'OpenAI' },
|
|
43
|
-
{ id: 'gemini', name: 'Gemini' }, { id: 'deepseek', name: 'DeepSeek' },
|
|
44
|
-
]);
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Which known vendor a run's `(provider, baseUrl)` belongs to. `openai-api` with no override
|
|
48
|
-
* is OpenAI; with DeepSeek's host it is DeepSeek; with any OTHER override it is none (null —
|
|
49
|
-
* never silently pooled into OpenAI's figures).
|
|
50
|
-
* @param {string|null|undefined} provider
|
|
51
|
-
* @param {string|null|undefined} baseUrl
|
|
52
|
-
* @returns {string|null} a MONEY_LABELS id, or null
|
|
53
|
-
*/
|
|
54
|
-
function rowIdFor(provider, baseUrl) {
|
|
55
|
-
if (provider === 'anthropic-api') return 'anthropic';
|
|
56
|
-
if (provider === 'gemini-api') return 'gemini';
|
|
57
|
-
if (provider === 'openai-api') {
|
|
58
|
-
if (!baseUrl) return 'openai';
|
|
59
|
-
try {
|
|
60
|
-
const host = new URL(baseUrl).hostname;
|
|
61
|
-
if (host === 'api.deepseek.com') return 'deepseek';
|
|
62
|
-
} catch { /* an unparseable override is no known vendor */ }
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
36
|
+
// The Money tab's per-provider breakdown groups a run by the Providers ROW that served it (its
|
|
37
|
+
// key variable name, `src/providerrows.js` `findRow`); a run no row claims lands under its own
|
|
38
|
+
// `<shape> @ <baseUrl>`. This is HISTORY labelling for spend already recorded (an archived run
|
|
39
|
+
// keeps its provider whatever Settings holds today). No vendor name or host is known here.
|
|
67
40
|
|
|
68
41
|
/**
|
|
69
42
|
* One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
|
|
@@ -289,11 +262,11 @@ export function monthSpend(opts = {}) {
|
|
|
289
262
|
|
|
290
263
|
/**
|
|
291
264
|
* The Money tab's figures: all-time and this-month spend, and per-provider breakdown
|
|
292
|
-
* (
|
|
293
|
-
* pooled into another row). Any leg with unknown spend makes the figure it belongs to
|
|
265
|
+
* (grouped by the matched Providers row's key variable name; a run on no row lands under its
|
|
266
|
+
* own provider name, never pooled into another row). Any leg with unknown spend makes the figure it belongs to
|
|
294
267
|
* an "at least".
|
|
295
268
|
* @param {{ home?: string, sessionsRoot?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
|
|
296
|
-
* @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
|
|
269
|
+
* @returns {{ tokensByRow: Record<string, number>, tokensByModel: Record<string, Record<string, number>>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
|
|
297
270
|
* byProvider: Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}> }}
|
|
298
271
|
*/
|
|
299
272
|
export function spendSummary(opts = {}) {
|
|
@@ -307,22 +280,29 @@ export function spendSummary(opts = {}) {
|
|
|
307
280
|
const wallSeen = {};
|
|
308
281
|
/** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
|
|
309
282
|
const tokensByRow = {};
|
|
283
|
+
/** tokens per model line of each row, from the spend rows that NAME the model; absent = no row names it (never 0) @type {Record<string, Record<string, number>>} */
|
|
284
|
+
const tokensByModel = {};
|
|
310
285
|
for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot })) {
|
|
311
286
|
let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
|
|
312
|
-
// an older spine records a model but no provider: it counts toward the row
|
|
313
|
-
//
|
|
287
|
+
// an older spine records a model but no provider: it counts toward the row with a model line EXACTLY
|
|
288
|
+
// named that model. Two rows naming it make it ambiguous, so it counts toward neither.
|
|
314
289
|
if (opts.rows && leg.provider === null && leg.model !== null) {
|
|
315
|
-
const named = opts.rows.filter((r) => r.
|
|
290
|
+
const named = opts.rows.filter((r) => r.models.some((l) => l.model === leg.model));
|
|
316
291
|
served = named.length === 1 ? named[0] : null;
|
|
317
292
|
}
|
|
318
|
-
if (served)
|
|
293
|
+
if (served) {
|
|
294
|
+
tokensByRow[served.envName] = (tokensByRow[served.envName] ?? 0) + leg.tokens;
|
|
295
|
+
if (typeof leg.model === 'string' && leg.model !== '') {
|
|
296
|
+
const own = (tokensByModel[served.envName] ??= {});
|
|
297
|
+
own[leg.model] = (own[leg.model] ?? 0) + leg.tokens;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
319
300
|
const inMonth = Number.isNaN(leg.at.getTime()) ? true : sameLocalMonth(leg.at, nowDate);
|
|
320
|
-
const id = rowIdFor(leg.provider, leg.baseUrl);
|
|
321
301
|
// an older spine carries a model but no provider: say so honestly, one row per model —
|
|
322
302
|
// never a provider guessed from the model's name
|
|
323
|
-
const notRecorded =
|
|
324
|
-
const key =
|
|
325
|
-
const label =
|
|
303
|
+
const notRecorded = !served && leg.provider === null;
|
|
304
|
+
const key = served ? served.envName : (notRecorded ? `not-recorded:${leg.model ?? ''}` : `other:${leg.provider}${leg.baseUrl ? ` @ ${leg.baseUrl}` : ''}`);
|
|
305
|
+
const label = served ? served.envName
|
|
326
306
|
: (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
|
|
327
307
|
const p = (byProvider[key] ??= { label, monthUsd: 0, monthAtLeast: false, totalUsd: 0, totalAtLeast: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, monthWallMs: 0, monthWallAtLeast: false, totalWallMs: 0, totalWallAtLeast: false });
|
|
328
308
|
const w = (wallSeen[key] ??= { monthRuns: 0, monthKnown: 0, totalKnown: 0 });
|
|
@@ -349,7 +329,7 @@ export function spendSummary(opts = {}) {
|
|
|
349
329
|
if (w.totalKnown === 0) p.totalWallMs = null;
|
|
350
330
|
if (w.monthRuns > 0 && w.monthKnown === 0) p.monthWallMs = null;
|
|
351
331
|
}
|
|
352
|
-
return { tokensByRow, total, month, byProvider };
|
|
332
|
+
return { tokensByRow, tokensByModel, total, month, byProvider };
|
|
353
333
|
}
|
|
354
334
|
|
|
355
335
|
/**
|
|
@@ -29,7 +29,8 @@ import { validateJob } from '../job.js';
|
|
|
29
29
|
import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
|
|
30
30
|
import { ConfigError } from '../config.js';
|
|
31
31
|
import { keysForDoor, keysHome } from '../keysfile.js';
|
|
32
|
-
import {
|
|
32
|
+
import { endpointOf, judgePick, keyValueOf, rowsForHome, tickedLines } from '../providerrows.js';
|
|
33
|
+
import { thinkingForLine } from '../thinking.js';
|
|
33
34
|
import { apiKeyProblem, checkProviderReachable } from '../providers.js';
|
|
34
35
|
|
|
35
36
|
/** @returns {string} a fresh per-process token — never persisted, never logged */
|
|
@@ -137,13 +138,13 @@ export function createAuthorRoutes(opts) {
|
|
|
137
138
|
if (pathname === '/api/author/model-check') {
|
|
138
139
|
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
139
140
|
const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// the key of the Settings row this
|
|
145
|
-
const keyName =
|
|
146
|
-
const raw = envNow()
|
|
141
|
+
const rows = rowsForHome(keysHome(opts.home), envNow());
|
|
142
|
+
const pick = judgePick(rows, { provider: q.get('provider'), model: q.get('model') });
|
|
143
|
+
if (!pick.ok) { send(400, { ok: false, error: pick.say }); return true; }
|
|
144
|
+
const choice = pick.choice;
|
|
145
|
+
// the key of the Settings row this line belongs to
|
|
146
|
+
const keyName = choice.envName;
|
|
147
|
+
const raw = keyValueOf(envNow(), keyName);
|
|
147
148
|
const problem = raw ? apiKeyProblem(raw) : null;
|
|
148
149
|
const keyStatus = !raw ? 'missing' : (problem ? 'bad-shape' : 'found');
|
|
149
150
|
if (keyStatus !== 'found') {
|
|
@@ -157,7 +158,7 @@ export function createAuthorRoutes(opts) {
|
|
|
157
158
|
// `checkProviderReachable`'s own doc) — a short timeout so a flaky
|
|
158
159
|
// endpoint never hangs the card.
|
|
159
160
|
checkProviderReachable({
|
|
160
|
-
providerName: choice.provider, apiKey: /** @type {string} */ (
|
|
161
|
+
providerName: choice.provider, apiKey: /** @type {string} */ (raw), model: choice.name,
|
|
161
162
|
baseUrl: choice.baseUrl, fetchImpl, timeoutMs: 4000,
|
|
162
163
|
}).then((r) => {
|
|
163
164
|
send(200, {
|
|
@@ -180,7 +181,11 @@ export function createAuthorRoutes(opts) {
|
|
|
180
181
|
// ── P4b: Chat's Model menu IS the Settings rows (a blank Name is not offered). ──
|
|
181
182
|
if (pathname === '/api/author/models') {
|
|
182
183
|
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
183
|
-
|
|
184
|
+
// every ticked-and-priced line of a row, labelled `<KEY> · <model>`; `thinking` is the server's answer for the "Thinking on" box
|
|
185
|
+
const lines = tickedLines(rowsForHome(keysHome(opts.home), envNow())).map(({ row, line }) => ({
|
|
186
|
+
slot: row.envName, model: line.model, label: `${row.envName} · ${line.model}`, shape: row.provider, endpoint: endpointOf(row.provider, row.baseUrl), thinking: thinkingForLine(row.provider, line.model, row.baseUrl),
|
|
187
|
+
}));
|
|
188
|
+
send(200, { ok: true, lines, say: lines.length === 0 ? 'Tick a model in Settings > Providers.' : '' });
|
|
184
189
|
return true;
|
|
185
190
|
}
|
|
186
191
|
|
|
@@ -207,14 +212,14 @@ export function createAuthorRoutes(opts) {
|
|
|
207
212
|
const sp = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
|
|
208
213
|
const runid = sp.get('runid') ?? '';
|
|
209
214
|
// `model` = the card's Model box right now: the estimate line counts only runs on that worker
|
|
210
|
-
const asModel = sp.has('model') ? { model: sp.get('model') ?? '' } : {};
|
|
215
|
+
const asModel = sp.has('model') ? { model: sp.get('model') ?? '', provider: sp.get('provider') ?? '' } : {};
|
|
211
216
|
const pre = sp.has('import') ? opts.startFrom.getImport(sp.get('import') ?? '', asModel) : opts.startFrom.get(runid, asModel);
|
|
212
217
|
if (pre === null) { send(404, { ok: false, error: 'no such run' }); return true; }
|
|
213
218
|
if (!pre.ok) { send(409, pre); return true; }
|
|
214
219
|
send(200, {
|
|
215
220
|
ok: true, origin: pre.origin, card: pre.card, from: pre.from, note: pre.note,
|
|
216
221
|
locked: pre.locked, open: pre.open, specHash: pre.specHash, workflowKey: pre.workflowKey, trackRecord: pre.trackRecord,
|
|
217
|
-
line: pre.line,
|
|
222
|
+
line: pre.line, specNamesModel: pre.specNamesModel, specWorker: pre.specWorker,
|
|
218
223
|
});
|
|
219
224
|
return true;
|
|
220
225
|
}
|
|
@@ -225,7 +230,7 @@ export function createAuthorRoutes(opts) {
|
|
|
225
230
|
if (!opts.startFrom || !opts.startFrom.listJobs) { send(404, { ok: false, error: 'not found' }); return true; }
|
|
226
231
|
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
227
232
|
const sp = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
|
|
228
|
-
const asModel = sp.has('model') ? { model: sp.get('model') ?? '' } : {};
|
|
233
|
+
const asModel = sp.has('model') ? { model: sp.get('model') ?? '', provider: sp.get('provider') ?? '' } : {};
|
|
229
234
|
send(200, { ok: true, jobs: opts.startFrom.listJobs(asModel) });
|
|
230
235
|
return true;
|
|
231
236
|
}
|
|
@@ -243,7 +248,7 @@ export function createAuthorRoutes(opts) {
|
|
|
243
248
|
if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
|
|
244
249
|
if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
|
|
245
250
|
const card = body ?? {};
|
|
246
|
-
const rows = rowsForHome(keysHome(opts.home));
|
|
251
|
+
const rows = rowsForHome(keysHome(opts.home), envNow());
|
|
247
252
|
// Reuse workflow — started from a green run or an imported job: only Source, Destination and the two caps may
|
|
248
253
|
// differ from the signed workflow. The SERVER refuses any other change (the page greys those boxes, but is
|
|
249
254
|
// never trusted); the spec it signs is a copy of the origin's with just those four set. Nothing is drafted.
|