bareloop 0.40.0 → 0.42.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 (77) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +141 -128
  4. package/package.json +5 -4
  5. package/src/authorflow.js +346 -38
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +82 -20
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +39 -30
  10. package/src/authorscout.js +112 -4
  11. package/src/bundle.js +7 -4
  12. package/src/bundlerun.js +4 -1
  13. package/src/cli.js +1 -1
  14. package/src/config.js +129 -29
  15. package/src/declaredclose.js +23 -12
  16. package/src/draftspend.js +4 -4
  17. package/src/facts.js +317 -0
  18. package/src/factwords.js +129 -0
  19. package/src/index.js +2 -4
  20. package/src/inputlook.js +301 -0
  21. package/src/interviewrun.js +17 -25
  22. package/src/job.js +15 -8
  23. package/src/jobblock.js +7 -2
  24. package/src/judged.js +11 -43
  25. package/src/keysfile.js +15 -7
  26. package/src/kinds.js +86 -5
  27. package/src/labelscan.js +170 -0
  28. package/src/modellist.js +84 -0
  29. package/src/monthly.js +23 -43
  30. package/src/pagefetch.js +125 -0
  31. package/src/panel/authorroutes.js +34 -16
  32. package/src/panel/authorsession.js +161 -80
  33. package/src/panel/index.html +553 -152
  34. package/src/panel/jobplan.js +34 -11
  35. package/src/panel/runroutes.js +3 -0
  36. package/src/panel/server.js +54 -18
  37. package/src/panel/settingsroutes.js +232 -173
  38. package/src/planrun.js +5 -4
  39. package/src/providerrows.js +226 -141
  40. package/src/providers.js +58 -332
  41. package/src/providershapes.js +18 -0
  42. package/src/replay.js +1 -1
  43. package/src/rubricstep.js +804 -0
  44. package/src/source.js +166 -1
  45. package/src/thinking.js +130 -0
  46. package/src/u-readout.js +1 -1
  47. package/src/userrun.js +64 -75
  48. package/types/authorflow.d.ts +272 -18
  49. package/types/authoring.d.ts +29 -1
  50. package/types/authorjob.d.ts +9 -7
  51. package/types/authorreadout.d.ts +27 -2
  52. package/types/authorscout.d.ts +72 -2
  53. package/types/config.d.ts +23 -6
  54. package/types/declaredclose.d.ts +4 -2
  55. package/types/draftspend.d.ts +8 -8
  56. package/types/facts.d.ts +71 -0
  57. package/types/factwords.d.ts +42 -0
  58. package/types/index.d.ts +1 -1
  59. package/types/inputlook.d.ts +238 -0
  60. package/types/job.d.ts +5 -5
  61. package/types/jobblock.d.ts +8 -1
  62. package/types/judged.d.ts +5 -36
  63. package/types/keysfile.d.ts +15 -4
  64. package/types/labelscan.d.ts +38 -0
  65. package/types/modellist.d.ts +50 -0
  66. package/types/monthly.d.ts +4 -3
  67. package/types/pagefetch.d.ts +71 -0
  68. package/types/panel/authorsession.d.ts +20 -11
  69. package/types/panel/jobplan.d.ts +12 -4
  70. package/types/panel/server.d.ts +18 -6
  71. package/types/providerrows.d.ts +192 -99
  72. package/types/providers.d.ts +34 -159
  73. package/types/providershapes.d.ts +10 -0
  74. package/types/rubricstep.d.ts +286 -0
  75. package/types/source.d.ts +94 -0
  76. package/types/thinking.d.ts +90 -0
  77. package/types/userrun.d.ts +17 -1
@@ -1,37 +1,24 @@
1
- // PANEL-BUILD.md P4b — the Providers rows, driven by the keys file. NOTHING here is a
2
- // hardcoded provider list: one row exists per key in `~/.config/bareloop/.env` that has a
3
- // value; each row's Name / API shape / Base URL come from config.json `keys.<ENV NAME>`
4
- // (src/config.js), else the per-name defaults below. This is NOT a new provider table:
5
- // a row's API shape names an entry that already exists in `src/providers.js`
6
- // (`PROVIDER_TABLE`), and a row's Base URL is that provider's `baseUrl` option.
1
+ // rewritten-from: fwdloop src/provider.js@f9449e8 (`effectiveModels`, `tickedLines`)
2
+ // PANEL-BUILD.md P4b / P8 — the Providers rows, driven by the keys file. NOTHING here is a
3
+ // hardcoded provider list, model, address or price: one row exists per key in
4
+ // `~/.config/bareloop/.env` that has a value; each row's API shape / Base URL / old-max_tokens
5
+ // tick come from config.json `keys.<ENV NAME>` and its model lines from `models.<ENV NAME>`
6
+ // (src/config.js), else the plain defaults below (shape `openai-api`, no address, no lines).
7
+ // A row's API shape names a `src/providers.js` table entry (`PROVIDER_TABLE`); its Base URL is
8
+ // that provider's `baseUrl` option.
7
9
  //
8
10
  // This module is also THE one owner of "model -> key + shape + URL": Chat's Model menu,
9
11
  // the author doors and the run doors all resolve through `findRow` / `keyNameFor` /
10
12
  // `applyConfiguredKey` / `modelChoiceFor` below — no second lookup exists. Key VALUES
11
13
  // never pass through here — only names.
12
- import { resolveProvider } from './providers.js';
13
- import { readConfig, ConfigError } from './config.js';
14
+ import { readConfig } from './config.js';
14
15
  import { filledKeyNames } from './keysfile.js';
16
+ import { SHAPES } from './providershapes.js';
15
17
 
16
- /** DeepSeek's OpenAI-shaped endpoint (the same string the panel's Model menu used). */
17
- export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1';
18
+ export { SHAPES };
18
19
 
19
- /**
20
- * The API shapes the dropdown offers. `id` is the `src/providers.js` table name;
21
- * `defaultUrl` is the host a blank Base URL means ('' = the vendor's own, unshown).
22
- * @type {readonly {id: string, label: string, defaultUrl: string}[]}
23
- */
24
- export const SHAPES = Object.freeze([
25
- Object.freeze({ id: 'anthropic-api', label: 'Anthropic', defaultUrl: 'https://api.anthropic.com/v1' }),
26
- Object.freeze({ id: 'openai-api', label: 'OpenAI-compatible', defaultUrl: 'https://api.openai.com/v1' }),
27
- Object.freeze({ id: 'gemini-api', label: 'Gemini', defaultUrl: '' }),
28
- ]);
29
-
30
- /** The five empty lines a fresh keys file is created with, in this order. */
31
- export const PRESET_KEY_NAMES = Object.freeze(['ANTHROPIC_API_KEY', 'DEEPSEEK_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'LOCAL_API_KEY']);
32
-
33
- /** A local server's default endpoint: Ollama's OpenAI-compatible host (LM Studio / llama.cpp: change the URL). */
34
- export const LOCAL_BASE_URL = 'http://127.0.0.1:11434/v1';
20
+ /** What a row with nothing saved reads as: no vendor is assumed. */
21
+ const DEFAULT_SHAPE = 'openai-api';
35
22
 
36
23
  /**
37
24
  * A key line whose value is the word `null` is SET and means "no real key": the row exists and
@@ -52,32 +39,61 @@ export function usableKey(raw) {
52
39
  }
53
40
 
54
41
  /**
55
- * What a key with no saved entry shows. Any name not listed is OpenAI-compatible with a
56
- * blank URL and a blank Name.
57
- * @param {string} envName
58
- * @returns {{ name: string, shape: string, baseUrl: string }}
42
+ * @typedef {object} ModelLine one model line of a row (`models.<ENV>` in config.json)
43
+ * @property {string} model the model id
44
+ * @property {boolean} use ticked = offered in Chat (needs both prices)
45
+ * @property {number} [inPerM] the customer's own price, USD per 1M input tokens
46
+ * @property {number} [outPerM] the same, for output tokens
59
47
  */
60
- export function defaultsFor(envName) {
61
- switch (envName) {
62
- case 'ANTHROPIC_API_KEY': return { name: 'claude-sonnet-5', shape: 'anthropic-api', baseUrl: '' };
63
- case 'DEEPSEEK_API_KEY': return { name: 'deepseek-flash', shape: 'openai-api', baseUrl: DEEPSEEK_BASE_URL };
64
- case 'GEMINI_API_KEY': return { name: '', shape: 'gemini-api', baseUrl: '' };
65
- case 'LOCAL_API_KEY': return { name: '', shape: 'openai-api', baseUrl: LOCAL_BASE_URL };
66
- default: return { name: '', shape: 'openai-api', baseUrl: '' };
67
- }
68
- }
48
+
49
+ /** @typedef {{ rates: { in: number, out: number }, inPerM: number, outPerM: number, envName: string }|null} PriceResult */
69
50
 
70
51
  /**
71
52
  * @typedef {object} KeyRow
72
53
  * @property {string} envName the key variable NAME (read-only label; never a value)
73
- * @property {string} name the model id Chat uses ('' = not offered in Chat)
74
54
  * @property {string} provider the `src/providers.js` table entry (the API shape's id)
75
- * @property {string} baseUrl the saved Base URL ('' = the shape's default host)
76
- * @property {unknown} [priceInPerM] the customer's own price, USD per 1M input tokens — carried RAW
77
- * (present only when the config row names it); {@link ratesFor} is the one place that validates it
78
- * @property {unknown} [priceOutPerM] the same, for output tokens
55
+ * @property {string} baseUrl the saved Base URL ('' = none typed yet)
56
+ * @property {boolean} legacyMaxTokens the "old max_tokens" tick (only meaningful on `openai-api`)
57
+ * @property {ModelLine[]} models the row's model lines
79
58
  */
80
59
 
60
+ const isPrice = (/** @type {unknown} */ v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
61
+
62
+ /**
63
+ * The model lines of a row, pure (no I/O, no rewrite of the file). A row WITH `models.<ENV>` uses
64
+ * that list and ignores `keys.<ENV>.{name,priceInPerM,priceOutPerM}`. A row WITHOUT it whose
65
+ * `keys.<ENV>.name` is a non-empty saved string reads as ONE line: ticked when BOTH older prices
66
+ * are finite numbers >= 0 (the line carries them), else UNTICKED with no price (a ticked line
67
+ * needs both prices). A row with no saved name reads as no lines — nothing is carried from a
68
+ * built-in table.
69
+ * @param {string} envName
70
+ * @param {Record<string, any>} config
71
+ * @returns {ModelLine[]}
72
+ */
73
+ export function effectiveModels(envName, config) {
74
+ const models = config?.models;
75
+ if (models && typeof models === 'object' && Object.hasOwn(models, envName) && Array.isArray(models[envName])) {
76
+ return models[envName].map((/** @type {ModelLine} */ l) => ({ ...l }));
77
+ }
78
+ const s = config?.keys?.[envName];
79
+ if (!s || typeof s !== 'object' || typeof s.name !== 'string' || s.name === '') return [];
80
+ return isPrice(s.priceInPerM) && isPrice(s.priceOutPerM)
81
+ ? [{ model: s.name, use: true, inPerM: s.priceInPerM, outPerM: s.priceOutPerM }]
82
+ : [{ model: s.name, use: false }];
83
+ }
84
+
85
+ /** @param {ModelLine} l @returns {boolean} a line Chat may offer: ticked and priced */
86
+ export const isTickedLine = (l) => l.use === true && isPrice(l.inPerM) && isPrice(l.outPerM);
87
+
88
+ /**
89
+ * Every ticked-and-priced line of the rows, in keys-file order.
90
+ * @param {readonly KeyRow[]} rows
91
+ * @returns {{ row: KeyRow, line: ModelLine }[]}
92
+ */
93
+ export function tickedLines(rows) {
94
+ return rows.flatMap((row) => row.models.filter(isTickedLine).map((line) => ({ row, line })));
95
+ }
96
+
81
97
  /**
82
98
  * The rows: one per name in `filled`, saved entry over defaults.
83
99
  * @param {{ filled: readonly string[], config: Record<string, any> }} src
@@ -86,47 +102,71 @@ export function defaultsFor(envName) {
86
102
  export function keyRows({ filled, config }) {
87
103
  const saved = config?.keys && typeof config.keys === 'object' && !Array.isArray(config.keys) ? config.keys : {};
88
104
  return filled.map((envName) => {
89
- const d = defaultsFor(envName);
90
105
  const s = saved[envName] && typeof saved[envName] === 'object' ? saved[envName] : {};
106
+ const provider = SHAPES.some((x) => x.id === s.shape) ? s.shape : DEFAULT_SHAPE;
91
107
  return {
92
108
  envName,
93
- name: typeof s.name === 'string' ? s.name : d.name,
94
- provider: SHAPES.some((x) => x.id === s.shape) ? s.shape : d.shape,
95
- baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl : d.baseUrl,
96
- ...(Object.hasOwn(s, 'priceInPerM') ? { priceInPerM: s.priceInPerM } : {}),
97
- ...(Object.hasOwn(s, 'priceOutPerM') ? { priceOutPerM: s.priceOutPerM } : {}),
109
+ provider,
110
+ baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl : '',
111
+ legacyMaxTokens: s.legacyMaxTokens === true && provider === 'openai-api',
112
+ models: effectiveModels(envName, config),
98
113
  };
99
114
  });
100
115
  }
101
116
 
102
117
  /**
103
- * The rows for a keys home. `undefined` home (a door that skipped the keys file) = no
104
- * rows, so nothing resolves through the file and the provider's built-in variable stands.
118
+ * The key names that make rows: every key in the keys file with a value (file order), plus — when `env` is given — a key only the
119
+ * SHELL holds that config already names (`keys.<ENV>` or `models.<ENV>`), sorted after them. The page shows such a row as
120
+ * "found in shell"; the run path must resolve it the same way.
121
+ * @param {readonly string[]} filled the keys file's filled names
122
+ * @param {Record<string, any>} config
123
+ * @param {Record<string, string|undefined>} [env] the shell side
124
+ * @returns {string[]}
125
+ */
126
+ export function rowNamesOf(filled, config, env) {
127
+ if (!env) return [...filled];
128
+ const named = new Set([...Object.keys(config?.keys ?? {}), ...Object.keys(config?.models ?? {})]);
129
+ const shellOnly = [...named].filter((n) => !filled.includes(n) && typeof env[n] === 'string' && env[n] !== '').sort();
130
+ return [...filled, ...shellOnly];
131
+ }
132
+
133
+ /**
134
+ * The rows for a keys home. `undefined` home (a door that skipped the keys file) = no rows. With `env`, a shell-only key that
135
+ * config names is a row too ({@link rowNamesOf}).
105
136
  * @param {string|undefined} home
137
+ * @param {Record<string, string|undefined>} [env]
106
138
  * @returns {KeyRow[]}
107
139
  */
108
- export function rowsForHome(home) {
140
+ export function rowsForHome(home, env) {
109
141
  if (home === undefined) return [];
110
- return keyRows({ filled: filledKeyNames(home), config: readConfig({ home }).config });
142
+ const config = readConfig({ home }).config;
143
+ return keyRows({ filled: rowNamesOf(filledKeyNames(home), config, env), config });
111
144
  }
112
145
 
113
146
  /**
114
- * @param {string} provider
115
- * @returns {string} the shape's default host ('' = the vendor's own)
147
+ * EVERY row a config home names, keyed or not: the keys file's filled names plus every name `keys.<ENV>` / `models.<ENV>` carries. For
148
+ * a readout that must say which variable a model line WOULD read ("KEY is not set in this shell") before any key exists. Never for a run.
149
+ * @param {string|undefined} home
150
+ * @returns {KeyRow[]}
116
151
  */
117
- export function defaultUrlOf(provider) {
118
- return SHAPES.find((s) => s.id === provider)?.defaultUrl ?? '';
152
+ export function namedRowsForHome(home) {
153
+ if (home === undefined) return [];
154
+ const config = readConfig({ home }).config;
155
+ const named = [...new Set([...Object.keys(config?.keys ?? {}), ...Object.keys(config?.models ?? {})])];
156
+ const filled = filledKeyNames(home);
157
+ return keyRows({ filled: [...filled, ...named.filter((n) => !filled.includes(n))], config });
119
158
  }
120
159
 
121
160
  /**
122
- * An endpoint in comparable form: blank = the shape's default host; scheme + host lower-cased,
123
- * trailing slashes dropped.
161
+ * An endpoint in comparable form: scheme + host lower-cased, trailing slashes dropped. A blank
162
+ * Base URL is blank — there is no default host.
124
163
  * @param {string} provider
125
164
  * @param {string|null|undefined} baseUrl
126
165
  * @returns {string}
127
166
  */
128
167
  export function endpointOf(provider, baseUrl) {
129
- const raw = (typeof baseUrl === 'string' && baseUrl.trim() !== '' ? baseUrl : defaultUrlOf(provider)).trim();
168
+ void provider;
169
+ const raw = (typeof baseUrl === 'string' ? baseUrl : '').trim();
130
170
  if (raw === '') return '';
131
171
  try {
132
172
  const u = new URL(raw);
@@ -136,8 +176,7 @@ export function endpointOf(provider, baseUrl) {
136
176
 
137
177
  /**
138
178
  * A job's provider in plain words for a refusal line: the host its Base URL points at (else the
139
- * shape's own default host, else the shape's label), plus the model when the spec names one.
140
- * Never a key, a path or a status code.
179
+ * shape's label), plus the model when the spec names one. Never a key, a path or a status code.
141
180
  * @param {{provider?: string, baseUrl?: string|null, model?: string|null}} spec
142
181
  * @returns {string}
143
182
  */
@@ -151,18 +190,10 @@ export function providerInWords(spec) {
151
190
  return model !== '' ? `${host}, ${model}` : host;
152
191
  }
153
192
 
154
- /**
155
- * The URL a row talks to, for display ('' = the vendor's own host, which has no URL to show).
156
- * @param {KeyRow} row
157
- * @returns {string}
158
- */
159
- export function shownUrl(row) {
160
- return row.baseUrl.trim() !== '' ? row.baseUrl : defaultUrlOf(row.provider);
161
- }
162
-
163
193
  /**
164
194
  * Which row a `(provider, baseUrl[, model])` identity belongs to: the rows on that shape and
165
- * endpoint; when several share it, the one whose Name is the model, else the first.
195
+ * endpoint; when several share it, the one that HAS a ticked-and-priced line for `model`, else
196
+ * the first.
166
197
  * @param {readonly KeyRow[]} rows
167
198
  * @param {{ provider?: string|null, baseUrl?: string|null, model?: string|null }} id
168
199
  * @returns {KeyRow|null}
@@ -172,70 +203,110 @@ export function findRow(rows, { provider, baseUrl, model }) {
172
203
  const want = endpointOf(provider, baseUrl);
173
204
  const same = rows.filter((r) => r.provider === provider && endpointOf(r.provider, r.baseUrl) === want);
174
205
  if (same.length === 0) return null;
175
- return (model ? same.find((r) => r.name === model) : undefined) ?? same[0];
206
+ return (model ? same.find((r) => r.models.some((l) => l.model === model && isTickedLine(l))) : undefined) ?? same[0];
176
207
  }
177
208
 
178
209
  /**
179
- * What Chat's Model menu offers and how a pick resolves: the row whose Name is the model
180
- * (a blank Name is never offered). `baseUrl` is absent when blank, exactly as a spec omits it.
210
+ * How a Model-menu pick resolves: the row named by `provider` (its key's env name) with a ticked-and-priced line for `model`.
211
+ * With no `provider` (an older caller) the first row ticking the model. `baseUrl` is absent when blank, exactly as a spec omits it.
212
+ * A model no row ticks, or a row that does not tick it, is `null` — never offered, never run.
181
213
  * @param {readonly KeyRow[]} rows
182
- * @param {string} model
183
- * @returns {{ provider: string, baseUrl?: string, envName: string, name: string }|null}
214
+ * @param {{ provider?: string|null, model?: string|null }} pick `provider` is the KEY's env name
215
+ * @returns {{ provider: string, baseUrl?: string, envName: string, name: string, legacyMaxTokens: boolean }|null}
184
216
  */
185
- export function modelChoiceFor(rows, model) {
186
- const r = model === '' ? undefined : rows.find((x) => x.name === model);
187
- if (!r) return null;
188
- return { provider: r.provider, ...(r.baseUrl.trim() !== '' ? { baseUrl: r.baseUrl.trim() } : {}), envName: r.envName, name: r.name };
217
+ export function modelChoiceFor(rows, { provider, model }) {
218
+ if (typeof model !== 'string' || model === '') return null;
219
+ const hit = tickedLines(rows).find((x) => x.line.model === model && (typeof provider === 'string' && provider !== '' ? x.row.envName === provider : true));
220
+ if (!hit) return null;
221
+ const r = hit.row;
222
+ return { provider: r.provider, ...(r.baseUrl.trim() !== '' ? { baseUrl: r.baseUrl.trim() } : {}), envName: r.envName, name: hit.line.model, legacyMaxTokens: r.legacyMaxTokens };
189
223
  }
190
224
 
191
225
  /**
192
- * The models Chat offers, in keys-file order.
226
+ * The lines Chat's Model menu offers: every ticked-and-priced line of the rows, in keys-file order, labelled `<KEY> · <model>`.
193
227
  * @param {readonly KeyRow[]} rows
194
- * @returns {{ id: string, envName: string, shape: string }[]}
228
+ * @returns {{ slot: string, model: string, label: string }[]}
195
229
  */
196
- export function chatModels(rows) {
197
- return rows.filter((r) => r.name !== '').map((r) => ({
198
- id: r.name, envName: r.envName, shape: SHAPES.find((s) => s.id === r.provider)?.label ?? r.provider,
199
- }));
230
+ export function chatLines(rows) {
231
+ return tickedLines(rows).map(({ row, line }) => ({ slot: row.envName, model: line.model, label: `${row.envName} · ${line.model}` }));
200
232
  }
201
233
 
234
+ /** The plain sentence when a stale Chat pick names a key and a model no row ticks (fwd's wording, bareloop's seam). */
235
+ export const REFUSE_NO_TICK = 'Tick a model in Settings > Providers. Nothing was spent.';
236
+ export const REFUSE_NO_PICK = 'Pick a model. Nothing was spent.';
237
+ /** @param {string} key @returns {string} */
238
+ export const refuseNotSetUp = (key) => `${key} is not set up: put its key in your keys file and type its Base URL in Settings > Providers. Nothing was spent.`;
239
+
202
240
  /**
203
- * The key variable NAME a job on `(provider, baseUrl[, model])` reads: the matching row's
204
- * variable, else the provider's built-in one (byte-identical to before for anything no row
205
- * claims).
241
+ * Judge a Model-menu pick `{provider, model}` against the rows, for the doors that start a session. `ok:true` carries the choice;
242
+ * otherwise the plain refusal (one of the three sentences).
243
+ * @param {readonly KeyRow[]} rows
244
+ * @param {{ provider?: string|null, model?: string|null }} pick
245
+ * @returns {{ ok: true, choice: NonNullable<ReturnType<typeof modelChoiceFor>> } | { ok: false, say: string }}
246
+ */
247
+ export function judgePick(rows, pick) {
248
+ if (typeof pick.model !== 'string' || pick.model === '') return { ok: false, say: tickedLines(rows).length === 0 ? REFUSE_NO_TICK : REFUSE_NO_PICK };
249
+ const choice = modelChoiceFor(rows, pick);
250
+ if (choice) return { ok: true, choice };
251
+ const key = typeof pick.provider === 'string' ? pick.provider : '';
252
+ if (key !== '' && !rows.some((r) => r.envName === key)) return { ok: false, say: refuseNotSetUp(key) };
253
+ if (key !== '') return { ok: false, say: rows.find((r) => r.envName === key)?.models.some(isTickedLine) ? REFUSE_NO_PICK : REFUSE_NO_TICK };
254
+ return { ok: false, say: tickedLines(rows).length === 0 ? REFUSE_NO_TICK : REFUSE_NO_PICK };
255
+ }
256
+
257
+ /**
258
+ * The CLI sentence when a run names no model and the spec carries none (fwd's `pickTicked` sentence).
259
+ */
260
+ export const REFUSE_NAME_MODEL = 'Name the model with --model <id>: one ticked in Settings > Providers with both prices.';
261
+
262
+ /**
263
+ * Which Settings line a RUN works on. A spec's own `model` wins; a spec that names none (signed before models were required)
264
+ * takes `--model <id>`; a spec with no `baseUrl` (the same era) matches a row on any endpoint of its shape. `--provider <KEY>`
265
+ * narrows to one key when two keys tick the same id. Pure; the signed spec is never changed, so its hash is untouched.
266
+ * @param {readonly KeyRow[]} rows
267
+ * @param {{ provider?: string|null, baseUrl?: string|null, model?: string|null }} spec the signed spec's worker fields
268
+ * @param {{ model?: string|null, provider?: string|null }} [flags] the operator's `--model` / `--provider`
269
+ * @returns {{ ok: true, row: KeyRow, line: ModelLine, model: string, baseUrl: string|undefined } | { ok: false, say: string }}
270
+ */
271
+ export function resolveWorker(rows, spec, flags = {}) {
272
+ const model = typeof spec.model === 'string' && spec.model !== '' ? spec.model : (typeof flags.model === 'string' && flags.model !== '' ? flags.model : '');
273
+ if (model === '') return { ok: false, say: REFUSE_NAME_MODEL };
274
+ const want = typeof spec.baseUrl === 'string' && spec.baseUrl.trim() !== '' ? endpointOf(String(spec.provider), spec.baseUrl) : null;
275
+ const key = typeof flags.provider === 'string' && flags.provider !== '' ? flags.provider : null;
276
+ const hits = tickedLines(rows).filter(({ row, line }) => row.provider === spec.provider && line.model === model
277
+ && (want === null || endpointOf(row.provider, row.baseUrl) === want) && (key === null || row.envName === key));
278
+ if (hits.length === 0) return { ok: false, say: refuseNotSetUp(model) };
279
+ const names = [...new Set(hits.map((h) => h.row.envName))];
280
+ if (names.length > 1) return { ok: false, say: `${model} is ticked on more than one key (${names.join(', ')}): name one with --provider <KEY>. Nothing was spent.` };
281
+ const { row, line } = hits[0];
282
+ return { ok: true, row, line, model, baseUrl: row.baseUrl.trim() !== '' ? row.baseUrl.trim() : undefined };
283
+ }
284
+
285
+ /**
286
+ * The key variable NAME a job on `(provider, baseUrl[, model])` reads: the matching row's own env name. There is NO built-in
287
+ * name: no matching row = no key (`name: null`).
206
288
  * @param {string|null|undefined} provider
207
289
  * @param {string|null|undefined} baseUrl
208
290
  * @param {readonly KeyRow[]} rows
209
291
  * @param {string|null|undefined} [model]
210
- * @returns {{ name: string, builtIn: string, chosen: boolean }}
292
+ * @returns {{ name: string|null, chosen: boolean }}
211
293
  */
212
294
  export function keyNameFor(provider, baseUrl, rows, model) {
213
- const builtIn = resolveProvider(/** @type {string} */ (provider)).envKey;
214
295
  const row = findRow(rows, { provider, baseUrl, model });
215
- return row && row.envName !== builtIn
216
- ? { name: row.envName, builtIn, chosen: true }
217
- : { name: builtIn, builtIn, chosen: false };
296
+ return row ? { name: row.envName, chosen: true } : { name: null, chosen: false };
218
297
  }
219
298
 
220
299
  /**
221
- * The env a door hands to provider construction, with the matching row's key variable
222
- * standing in for the provider's built-in one: `env[builtIn] = env[row.envName]` (the `null`
223
- * no-key marker becomes {@link NO_KEY_PLACEHOLDER}). A copy — never mutates the input.
224
- * No matching row and no marker = the env unchanged.
300
+ * The key VALUE for a row's env name out of an env (the `null` no-key marker becomes {@link NO_KEY_PLACEHOLDER}); `undefined`
301
+ * when the row's variable is unset or empty.
225
302
  * @param {Record<string,string|undefined>} env
226
- * @param {string|null|undefined} provider
227
- * @param {string|null|undefined} baseUrl
228
- * @param {readonly KeyRow[]} rows
229
- * @param {string|null|undefined} [model]
230
- * @returns {Record<string,string|undefined>}
303
+ * @param {string|null|undefined} envName
304
+ * @returns {string|undefined}
231
305
  */
232
- export function applyConfiguredKey(env, provider, baseUrl, rows, model) {
233
- if (typeof provider !== 'string') return env;
234
- let k;
235
- try { k = keyNameFor(provider, baseUrl, rows, model); } catch { return env; } // unknown provider: resolveProvider's own named throw fires at its own door
236
- const value = env[k.name];
237
- if (!k.chosen && value !== 'null') return env;
238
- return { ...env, [k.builtIn]: usableKey(value) };
306
+ export function keyValueOf(env, envName) {
307
+ if (typeof envName !== 'string') return undefined;
308
+ const v = env[envName];
309
+ return v === undefined || v === '' ? undefined : usableKey(v);
239
310
  }
240
311
 
241
312
  /**
@@ -264,12 +335,12 @@ export function priceReadout(price) {
264
335
  }
265
336
 
266
337
  /**
267
- * The customer's own price for the row a `(provider, baseUrl[, model])` identity belongs to —
268
- * the SAME row match {@link keyNameFor} uses (`findRow`), so the price and the key always come
269
- * from one row. `null` = no price set (the run keeps bare-agent's built-in guess, byte-identical
270
- * to before). Both fields or neither, each a finite number >= 0, USD per 1M tokens; anything
271
- * else throws `ConfigError` naming the row and field — a bad price never silently falls back to
272
- * the guess. `rates` is bare-agent's `Loop({ rates })` shape (USD per 1K); `inPerM`/`outPerM`
338
+ * The customer's own price for the model LINE of the row a `(provider, baseUrl[, model])` identity
339
+ * belongs to — the SAME row match {@link keyNameFor} uses (`findRow`), so the price and the key
340
+ * always come from one row. With a `model`, the row's line of that name; without one, the row's
341
+ * first ticked line. `null` = no price set (the run keeps bare-agent's built-in guess). A line's
342
+ * price is a pair or nothing ({@link modelsProblem} guards the file), so a half price never
343
+ * reaches here. `rates` is bare-agent's `Loop({ rates })` shape (USD per 1K); `inPerM`/`outPerM`
273
344
  * are what a readout prints.
274
345
  * @param {string|null|undefined} provider
275
346
  * @param {string|null|undefined} baseUrl
@@ -280,31 +351,45 @@ export function priceReadout(price) {
280
351
  export function ratesFor(provider, baseUrl, rows, model) {
281
352
  const row = findRow(rows, { provider, baseUrl, model });
282
353
  if (!row) return null;
283
- const hasIn = row.priceInPerM !== undefined;
284
- const hasOut = row.priceOutPerM !== undefined;
285
- if (!hasIn && !hasOut) return null;
286
- if (hasIn !== hasOut) {
287
- throw new ConfigError(`keys.${row.envName} sets ${hasIn ? 'priceInPerM' : 'priceOutPerM'} but not ${hasIn ? 'priceOutPerM' : 'priceInPerM'} — set both prices (USD per 1M tokens) or neither`);
288
- }
289
- for (const field of /** @type {const} */ (['priceInPerM', 'priceOutPerM'])) {
290
- const v = row[field];
291
- if (!(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
292
- throw new ConfigError(`keys.${row.envName}.${field} must be a number, 0 or more (USD per 1M tokens) — fix it in config.json or remove both price lines`);
293
- }
294
- }
295
- const inPerM = /** @type {number} */ (row.priceInPerM);
296
- const outPerM = /** @type {number} */ (row.priceOutPerM);
297
- return { rates: { in: inPerM / 1000, out: outPerM / 1000 }, inPerM, outPerM, envName: row.envName };
354
+ const line = model ? row.models.find((l) => l.model === model) : row.models.find(isTickedLine);
355
+ const r = line ? ratesOfLine(line) : null;
356
+ return r ? { ...r, envName: row.envName } : null;
357
+ }
358
+
359
+ /**
360
+ * A model line's own price in bare-agent's shape, or `null` when the line carries none (a price is a pair or nothing).
361
+ * @param {ModelLine} line
362
+ * @returns {{ rates: { in: number, out: number }, inPerM: number, outPerM: number }|null}
363
+ */
364
+ export function ratesOfLine(line) {
365
+ if (!isPrice(line.inPerM) || !isPrice(line.outPerM)) return null;
366
+ const inPerM = /** @type {number} */ (line.inPerM);
367
+ const outPerM = /** @type {number} */ (line.outPerM);
368
+ return { rates: { in: inPerM / 1000, out: outPerM / 1000 }, inPerM, outPerM };
369
+ }
370
+
371
+ /**
372
+ * The row a judge's key and endpoint come from: the worker's own row when the judge shares the worker's API shape, else the judge
373
+ * shape's row that ticks the judge's model (else the first row of that shape). `null` when no row is on that shape.
374
+ * @param {readonly KeyRow[]} rows
375
+ * @param {{ provider: string, model?: string|null }} judge
376
+ * @param {string|null|undefined} workerProvider
377
+ * @param {KeyRow|null} workerRow
378
+ * @returns {KeyRow|null}
379
+ */
380
+ export function judgeRowFor(rows, judge, workerProvider, workerRow) {
381
+ if (judge.provider === workerProvider && workerRow) return workerRow;
382
+ const onShape = rows.filter((r) => r.provider === judge.provider);
383
+ return onShape.find((r) => r.models.some((l) => l.model === judge.model && isTickedLine(l))) ?? onShape[0] ?? null;
298
384
  }
299
385
 
300
386
  /**
301
- * The price a judge's rounds are booked at. A price belongs to the MODEL's row: when the judge is
302
- * the worker's own provider it calls the worker's endpoint, so a row on that endpoint NAMED for the
303
- * judge's model prices it ({@link ratesFor}); a judge model that is absent or names no row there
304
- * keeps the worker's price (the worker's row, or none = the guess). A different provider resolves
305
- * on its own default endpoint, as before. A bad price on the resolved row throws like `ratesFor`.
306
- * The judge's KEY is chosen elsewhere (`applyConfiguredKey`, by the worker's model), so the two
307
- * can come from different rows — same provider + endpoint, judge model naming another row.
387
+ * The price a judge's rounds are booked at. A price belongs to the MODEL's line: when the judge is
388
+ * the worker's own provider it calls the worker's endpoint, so a row on that endpoint with a line
389
+ * NAMED for the judge's model prices it ({@link ratesFor}); a judge model that is absent or names
390
+ * no line there keeps the worker's price (the worker's row, or none = the guess). A different
391
+ * provider resolves with no endpoint. The judge's KEY is chosen elsewhere (`applyConfiguredKey`,
392
+ * by the worker's model), so the two can come from different rows.
308
393
  * @param {{ provider: string, model?: string|null }} judge
309
394
  * @param {string|null|undefined} workerProvider
310
395
  * @param {string|null|undefined} workerBaseUrl
@@ -315,6 +400,6 @@ export function ratesFor(provider, baseUrl, rows, model) {
315
400
  export function judgeRatesFor(judge, workerProvider, workerBaseUrl, workerPrice, rows) {
316
401
  if (judge.provider !== workerProvider) return ratesFor(judge.provider, undefined, rows, judge.model);
317
402
  const want = endpointOf(judge.provider, workerBaseUrl);
318
- const named = judge.model ? rows.some((r) => r.provider === judge.provider && endpointOf(r.provider, r.baseUrl) === want && r.name === judge.model) : false;
403
+ const named = judge.model ? rows.some((r) => r.provider === judge.provider && endpointOf(r.provider, r.baseUrl) === want && r.models.some((l) => l.model === judge.model)) : false;
319
404
  return named ? ratesFor(judge.provider, workerBaseUrl, rows, judge.model) : workerPrice;
320
405
  }