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.
Files changed (51) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bareloop.context.md +97 -124
  3. package/package.json +2 -2
  4. package/src/authorflow.js +9 -9
  5. package/src/authorjob.js +4 -4
  6. package/src/authorrun.js +25 -25
  7. package/src/authorscout.js +4 -4
  8. package/src/bundle.js +7 -4
  9. package/src/bundlerun.js +4 -1
  10. package/src/cli.js +1 -1
  11. package/src/config.js +129 -29
  12. package/src/draftspend.js +4 -4
  13. package/src/index.js +2 -4
  14. package/src/interviewrun.js +17 -25
  15. package/src/job.js +13 -7
  16. package/src/judged.js +11 -43
  17. package/src/keysfile.js +15 -7
  18. package/src/kinds.js +2 -2
  19. package/src/modellist.js +84 -0
  20. package/src/monthly.js +23 -43
  21. package/src/panel/authorroutes.js +19 -14
  22. package/src/panel/authorsession.js +62 -58
  23. package/src/panel/index.html +520 -145
  24. package/src/panel/runroutes.js +3 -0
  25. package/src/panel/server.js +35 -15
  26. package/src/panel/settingsroutes.js +232 -173
  27. package/src/planrun.js +2 -2
  28. package/src/providerrows.js +226 -141
  29. package/src/providers.js +58 -332
  30. package/src/providershapes.js +18 -0
  31. package/src/replay.js +1 -1
  32. package/src/thinking.js +130 -0
  33. package/src/userrun.js +50 -73
  34. package/types/authorflow.d.ts +5 -5
  35. package/types/authorjob.d.ts +2 -2
  36. package/types/authorscout.d.ts +2 -2
  37. package/types/config.d.ts +23 -6
  38. package/types/draftspend.d.ts +8 -8
  39. package/types/index.d.ts +1 -1
  40. package/types/job.d.ts +5 -5
  41. package/types/judged.d.ts +5 -36
  42. package/types/keysfile.d.ts +15 -4
  43. package/types/modellist.d.ts +50 -0
  44. package/types/monthly.d.ts +4 -3
  45. package/types/panel/authorsession.d.ts +2 -9
  46. package/types/panel/server.d.ts +14 -4
  47. package/types/providerrows.d.ts +192 -99
  48. package/types/providers.d.ts +34 -159
  49. package/types/providershapes.d.ts +10 -0
  50. package/types/thinking.d.ts +90 -0
  51. 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 provider model id (e.g. "claude-sonnet-5")');
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, defaultModel: string }} args
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
- * `defaultModel` — the runner's own default when neither spec nor flag says.
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, defaultModel }) {
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
- return { model: defaultModel, source: 'default' };
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, judgeCallOptions } from './providers.js';
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 `JUDGE_MODEL`. A caller that cannot
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, defaultModel: string}) => {model: string, source: string}} resolveWorkerModelFn `resolveWorkerModel` from `./job.js`
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
- const providerEntry = resolveProvider(providerName);
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
- * @param {{provider: any, system: string, rates?: {in: number, out: number}|null}} o
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, { ...judgeCallOptions(provider), ...opts });
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
- * Create the keys file with the five empty preset lines when — and only when — it does not
80
- * exist (mode 600). An existing file is never touched.
81
- * @param {readonly string[]} presetNames the names to seed, in order
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(presetNames, home) {
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, presetNames.map((n) => `${n}=\n`).join(''), { mode: 0o600, flag: 'wx' });
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
- // `JUDGE_MODEL` has always SAID a bump forces a full recalibration; this is the
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 to `JUDGE_MODEL` here — a run that
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.
@@ -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 endpoint it ran on and names
37
- // the groups it knows the vendor of. This is HISTORY labelling for spend already recorded
38
- // (an archived run keeps its provider whatever Settings holds today) — not the Providers
39
- // rows, which come from the keys file (src/providerrows.js).
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
- * (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
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 whose Name EXACTLY
313
- // equals that model. Two rows sharing the Name make it ambiguous, so it counts toward neither.
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.name === leg.model);
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) tokensByRow[served.envName] = (tokensByRow[served.envName] ?? 0) + leg.tokens;
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 = id === null && leg.provider === null;
324
- const key = id ?? (notRecorded ? `not-recorded:${leg.model ?? ''}` : `other:${leg.provider}${leg.baseUrl ? ` @ ${leg.baseUrl}` : ''}`);
325
- const label = id ? (MONEY_LABELS.find((r) => r.id === id)?.name ?? id)
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 { keyNameFor, modelChoiceFor, rowsForHome, chatModels, usableKey } from '../providerrows.js';
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 modelId = q.get('model') ?? '';
141
- const rows = rowsForHome(keysHome(opts.home));
142
- const choice = modelChoiceFor(rows, modelId);
143
- if (!choice) { send(400, { ok: false, error: `unknown model "${modelId}"` }); return true; }
144
- // the key of the Settings row this Name belongs to
145
- const keyName = keyNameFor(choice.provider, choice.baseUrl, rows, choice.name).name;
146
- const raw = envNow()[keyName];
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} */ (usableKey(raw)), model: choice.name,
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
- send(200, { ok: true, models: chatModels(rowsForHome(keysHome(opts.home))) });
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.