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/providers.js CHANGED
@@ -21,62 +21,12 @@
21
21
  // (`buildRunnerProviders`, below); it never prices a round (F113: rates
22
22
  // passthrough is dead), and never decides a budget.
23
23
 
24
+ import { listModels } from './modellist.js';
25
+ import { SHAPE_IDS } from './providershapes.js';
24
26
  import { createRequire } from 'node:module';
25
27
 
26
28
  const require = createRequire(import.meta.url);
27
- const { AnthropicProvider, OpenAIProvider, GeminiProvider, thinkingSupport } = require('bare-agent/providers');
28
-
29
- /**
30
- * Today's anthropic-api tier table — EXACTLY the `DEFAULT_TIER_MODELS` both
31
- * runners hardcoded before this factory existed. Any change to these two
32
- * values is a change to shipped anthropic-api behaviour, not a refactor.
33
- */
34
- export const ANTHROPIC_TIER_MODELS = Object.freeze({
35
- sonnet: 'claude-sonnet-5',
36
- haiku: 'claude-haiku-4-5-20251001',
37
- });
38
-
39
- /**
40
- * DeepSeek (F149/F150) is a SINGLE model, not a tiered family — hamr's
41
- * ruling above: one secondary provider, not a menu. Both tiers map to the
42
- * same model id ON PURPOSE: `resolveWorkerModel`/a runner's `providerFor
43
- * (tier)` only ever LOOK UP a tier's model id (src/job.js:818-827,
44
- * scripts/run-u.mjs's `providerFor`) — neither assumes the tiers name
45
- * different models — so repeating the one id is the honest representation
46
- * of a single-model provider, not a second model masquerading as `haiku`.
47
- * If DeepSeek (or a future openai-api entrant) ever ships a genuinely
48
- * cheaper tier model, this is where it would earn its own `haiku` id.
49
- *
50
- * MODEL SWAP (F171, item 34 L16, hamr 2026-09-13): `deepseek-chat` is no
51
- * longer served — a live `/models` call returned only `deepseek-flash` and
52
- * `deepseek-v4-pro`. Switched to `deepseek-flash` (DeepSeek V4.1). F149's
53
- * `legacyMaxTokens` measurement (asked 64 output tokens, got 665/608) was
54
- * taken on `deepseek-chat` and is UNVERIFIED on `deepseek-flash` — kept on
55
- * below as the fail-safe (over-capping direction is safe; see F171).
56
- */
57
- export const OPENAI_TIER_MODELS = Object.freeze({
58
- sonnet: 'deepseek-flash',
59
- haiku: 'deepseek-flash',
60
- });
61
-
62
- /**
63
- * Gemini (PRD item 31.3, hamr 2026-09-09: "anthropic, openai, gemini drop
64
- * ollama for now"). Two real tiers, unlike DeepSeek's single model: `flash` is
65
- * the cheap tier by design, so `haiku` maps to it honestly rather than
66
- * repeating one id.
67
- *
68
- * ADMITTED-PENDING-PROBE. The probe rule stands (PRD item 28 ruling (d),
69
- * 2026-09-09): no endpoint is in the menu without its own clean paid probe,
70
- * and `gemini-api` has ZERO runs. `anthropic-api` is the original (~167
71
- * archived runs) and `openai-api` was probed green through the shipped runner
72
- * (run mtu12vks, $1.64/$4, F157); gemini owes that fire and has not paid it.
73
- * `PROBE_STATUS` below is the machine-readable form of that debt — nothing in
74
- * here silently pretends the provider is proven.
75
- */
76
- export const GEMINI_TIER_MODELS = Object.freeze({
77
- sonnet: 'gemini-2.5-pro',
78
- haiku: 'gemini-2.5-flash',
79
- });
29
+ const { AnthropicProvider, OpenAIProvider, GeminiProvider } = require('bare-agent/providers');
80
30
 
81
31
  /**
82
32
  * Which admitted providers have paid for their own end-to-end probe, and which
@@ -91,146 +41,10 @@ export const PROBE_STATUS = Object.freeze({
91
41
  'gemini-api': Object.freeze({ probed: false, evidence: 'ZERO runs — admitted to the menu, owes its own paid probe (PRD item 31.3)' }),
92
42
  });
93
43
 
94
- /**
95
- * Per-MODEL request-key gating (F149, live-measured 2026-09-09): DeepSeek
96
- * silently IGNORES the modern `max_completion_tokens` key — asked for 64
97
- * output tokens, it returned 665/608 — and only honours the legacy
98
- * `max_tokens` key. `bare-agent`'s `OpenAIProvider` already exposes the
99
- * switch (`legacyMaxTokens`, BA-24; no model-name sniffing inside
100
- * bare-agent itself — "the caller declares the dialect"), so THIS table is
101
- * bareloop's routing of it, keyed per model, never a global default. An
102
- * output cap that does not bind is a money hazard: a reasoning model can
103
- * run to its own ceiling on every round, silently, so getting this wrong
104
- * for even one model defeats every budget that model's tier sets.
105
- *
106
- * A model with no entry here gets `{}` — bare-agent's own default
107
- * (`max_completion_tokens`, GPT-5-safe) applies, unmodified.
108
- *
109
- * F171 (2026-09-13): `deepseek-chat` was retired and swapped for
110
- * `deepseek-flash` (item 34 L16). This gating was measured on
111
- * `deepseek-chat` only and carried forward onto `deepseek-flash`
112
- * UNVERIFIED — re-verify before any paid DeepSeek run relies on it.
113
- */
114
- const OPENAI_MODEL_OPTIONS = Object.freeze({
115
- 'deepseek-flash': Object.freeze({ legacyMaxTokens: true }),
116
- });
117
-
118
- /**
119
- * THINKING (hamr 2026-10-11: no hardcoded per-model lists in bareloop — bare-agent >=0.51 owns the shapes). The drafter and the
120
- * rubric judge run thinking OFF by default (deepseek-flash thinks by default and bills the reasoning as output: F192 run mv117wde
121
- * truncated judge locates at the cap, and a drafter answer that runs to the output cap is a failed try). This module is the ONE
122
- * owner of what a call sends: it asks bare-agent's `thinkingSupport(kind, model)` (pure lookup, no network) and sends the
123
- * normalized per-call `thinkingMode: 'off'` ONLY when the model is measured switchable. A model measured NOT switchable
124
- * (claude-opus-5-5 …) gets nothing — asking would throw before send — and a model bare-agent has not measured ('unverified') also
125
- * gets nothing on this DEFAULT path, so a local/unknown model's drafting never breaks by default. PER-CALL, never constructor
126
- * options (the drafter's provider is the worker's instance; the worker is deliberately left as it was). The raw `thinking`
127
- * option is never sent (bare-agent throws if both are set).
128
- */
129
-
130
- /**
131
- * The thinking-ladder kind bare-agent's `thinkingSupport` is keyed by, from a constructed provider; null for anything that is
132
- * not one of the three admitted constructors (a test double, a clipipe) — such a provider is never sent the switch.
133
- * OpenAI-shaped providers are DeepSeek by model id `deepseek-*` or a `deepseek.com` baseUrl (bare-agent's documented rule,
134
- * provider-openai.js:80), else OpenAI.
135
- * @param {any} provider
136
- * @returns {'anthropic'|'openai'|'deepseek'|'gemini'|null}
137
- */
138
- function thinkingKind(provider) {
139
- if (provider instanceof AnthropicProvider) return 'anthropic';
140
- if (provider instanceof GeminiProvider) return 'gemini';
141
- if (provider instanceof OpenAIProvider) {
142
- return /^deepseek-/i.test(String(provider.model)) || /deepseek\.com/i.test(String(provider.baseUrl)) ? 'deepseek' : 'openai';
143
- }
144
- return null;
145
- }
146
-
147
- /**
148
- * What an 'off' request means for this provider instance, for the draft log and the panel row: `'off'` (measured switchable —
149
- * the switch is sent), `'not-switchable'` (measured NOT switchable — nothing sent, the model thinks as it would),
150
- * `'unverified'` (bare-agent has not measured this model — nothing sent on the default path).
151
- * @param {any} provider a constructed bare-agent provider
152
- * @returns {'off'|'not-switchable'|'unverified'}
153
- */
154
- function offOutcome(provider) {
155
- const kind = thinkingKind(provider);
156
- if (kind === null) return 'not-switchable';
157
- const support = thinkingSupport(kind, provider.model);
158
- if (support.switchable === true) return 'off';
159
- return support.switchable === 'unverified' ? 'unverified' : 'not-switchable';
160
- }
161
-
162
- /**
163
- * The per-call options the rubric JUDGE's model calls carry for this provider instance: `{ thinkingMode: 'off' }` when
164
- * bare-agent measures the model switchable, else `{}` (F192: judge runs thinking off; a judge on a model that cannot turn it
165
- * off must not throw).
166
- * @param {any} provider a constructed bare-agent provider
167
- * @returns {Record<string, any>}
168
- */
169
- export function judgeCallOptions(provider) {
170
- return offOutcome(provider) === 'off' ? { thinkingMode: 'off' } : {};
171
- }
172
-
173
- /**
174
- * The drafter's THINKING knob — an enumerated OPERATOR param, never model output and never read from env inside the library.
175
- * `'on'` sends nothing (the model's own thinking, byte-identical to a no-knob call); `'off'` sends `thinkingMode: 'off'` where
176
- * bare-agent measures the model switchable. The DEFAULT is `'off'` (hamr 2026-10-11, decided by the frozen ON/OFF contrast,
177
- * docs/logs/2026-10-10-drafter-thinking-contrast-prereg.md); `'on'` stays selectable for a later Settings checkbox.
178
- */
179
- export const DRAFT_THINKING_MODES = Object.freeze(['on', 'off']);
180
-
181
- /**
182
- * Throw unless `mode` is one of {@link DRAFT_THINKING_MODES} — an operator-param guard, so a typo never silently means a mode.
183
- * @param {unknown} mode
184
- * @returns {'on'|'off'}
185
- */
186
- export function assertDraftThinking(mode) {
187
- if (mode === 'on' || mode === 'off') return mode;
188
- throw new Error(`[providers] draftThinking must be one of ${DRAFT_THINKING_MODES.join(' | ')}, got ${JSON.stringify(mode)}`);
189
- }
190
-
191
- /**
192
- * What the drafting calls on this provider ACTUALLY carry for thinking, for the draft log and the panel row: `'on'` (mode on,
193
- * nothing sent), `'off'` (mode off and the switch is sent), `'not-switchable'` (mode off, model measured not switchable —
194
- * nothing sent), `'unverified'` (mode off, model not measured by bare-agent — nothing sent). Never re-derives a model list.
195
- * @param {any} provider a constructed bare-agent provider
196
- * @param {unknown} [mode] 'on' | 'off' (default 'off')
197
- * @returns {'on'|'off'|'not-switchable'|'unverified'}
198
- */
199
- export function draftThinkingSent(provider, mode = 'off') {
200
- if (assertDraftThinking(mode) === 'on') return 'on';
201
- return offOutcome(provider);
202
- }
203
-
204
- /**
205
- * {@link draftThinkingSent} for a model the caller has only NAMED (provider shape, model id, baseUrl) — no key, no network. For
206
- * the panel's first drafting row, which is shown before the key is read. Constructing the provider only makes the class and
207
- * the model id available to `thinkingSupport`; it sends nothing.
208
- * @param {{providerName: string, model: string, baseUrl?: string|null}} o
209
- * @param {unknown} [mode] 'on' | 'off' (default 'off')
210
- * @returns {'on'|'off'|'not-switchable'|'unverified'}
211
- */
212
- export function draftThinkingFor({ providerName, model, baseUrl }, mode = 'off') {
213
- return draftThinkingSent(makeProvider(providerName, { apiKey: 'unset', model, ...(baseUrl ? { baseUrl } : {}) }), mode);
214
- }
215
-
216
- /**
217
- * The ONE owner of the per-call options every DRAFTING model call carries (scout, confirm, author, revise, structure retries).
218
- * `{ thinkingMode: 'off' }` exactly when {@link draftThinkingSent} says `'off'`, else `{}`.
219
- * @param {any} provider a constructed bare-agent provider
220
- * @param {unknown} [mode] 'on' | 'off' (default 'off')
221
- * @returns {Record<string, any>}
222
- */
223
- export function draftCallOptions(provider, mode = 'off') {
224
- return draftThinkingSent(provider, mode) === 'off' ? { thinkingMode: 'off' } : {};
225
- }
226
-
227
44
  /**
228
45
  * @typedef {object} ProviderTableEntry
229
46
  * @property {new (options: any) => any} ctor the bare-agent provider class
230
- * @property {string} envKey the environment variable this provider reads its key from
231
- * @property {Readonly<Record<string, string>>} tiers tier name -> model id
232
47
  * @property {string} endpointKey the CONSTRUCTOR OPTION NAME this provider reads its endpoint from — NOT assumed to be `baseUrl` (see below)
233
- * @property {(model: string|undefined) => Record<string, any>} paramsFor per-model request-key/param gating, applied on TOP of {exposeErrorBody:true, apiKey, model, <endpointKey>?}
234
48
  */
235
49
 
236
50
  // ── WHY `endpointKey` IS A FIELD AND NOT THE LITERAL `baseUrl` ──────────────
@@ -260,35 +74,16 @@ export function draftCallOptions(provider, mode = 'off') {
260
74
  // the menu holds only http-shaped API providers, and the FIRST thing that
261
75
  // changes when Ollama is admitted is the one line nobody would think to look at.
262
76
 
263
- /** @type {Readonly<Record<string, ProviderTableEntry>>} */
77
+ /**
78
+ * The three client classes bare-agent exports, chosen by shape id. NO key name, model id or per-model gating lives here: a row's
79
+ * key is its own env name (src/providerrows.js), a job carries its own model, and the old max_tokens spelling is the ROW's tick
80
+ * (`legacyMaxTokens`, `makeProvider`).
81
+ * @type {Readonly<Record<string, ProviderTableEntry>>}
82
+ */
264
83
  const PROVIDER_TABLE = Object.freeze({
265
- 'anthropic-api': Object.freeze({
266
- ctor: AnthropicProvider,
267
- envKey: 'ANTHROPIC_API_KEY',
268
- tiers: ANTHROPIC_TIER_MODELS,
269
- endpointKey: 'baseUrl',
270
- // Anthropic has only ever had one request key for the output cap
271
- // (`max_tokens`) — nothing to gate here, ever (unlike OpenAI-shaped
272
- // backends, which fork on `max_tokens` vs `max_completion_tokens`).
273
- paramsFor: () => ({}),
274
- }),
275
- 'openai-api': Object.freeze({
276
- ctor: OpenAIProvider,
277
- envKey: 'OPENAI_API_KEY',
278
- tiers: OPENAI_TIER_MODELS,
279
- endpointKey: 'baseUrl',
280
- paramsFor: (model) => OPENAI_MODEL_OPTIONS[/** @type {string} */ (model)] ?? {},
281
- }),
282
- 'gemini-api': Object.freeze({
283
- ctor: GeminiProvider,
284
- envKey: 'GEMINI_API_KEY',
285
- tiers: GEMINI_TIER_MODELS,
286
- endpointKey: 'baseUrl',
287
- // Gemini carries no `max_tokens`/`max_completion_tokens` fork of its own —
288
- // bare-agent's GeminiProvider maps the cap internally. Nothing to gate, and
289
- // an empty object is the honest statement of that, not an omission.
290
- paramsFor: () => ({}),
291
- }),
84
+ 'anthropic-api': Object.freeze({ ctor: AnthropicProvider, endpointKey: 'baseUrl' }),
85
+ 'openai-api': Object.freeze({ ctor: OpenAIProvider, endpointKey: 'baseUrl' }),
86
+ 'gemini-api': Object.freeze({ ctor: GeminiProvider, endpointKey: 'baseUrl' }),
292
87
  });
293
88
 
294
89
  /**
@@ -348,15 +143,15 @@ export function apiKeyProblem(value) {
348
143
  }
349
144
 
350
145
  /**
351
- * Construct ONE provider instance for `providerName`/`model`, applying
352
- * `exposeErrorBody: true` (F153 — every constructed provider, unconditionally)
353
- * and the model's own request-key gating (`paramsFor`). `baseUrl` (PRD item
354
- * 28, ruling (d), 2026-09-09: admitted in v1) is forwarded only when given —
355
- * every provider constructor here defaults it on its own when absent.
146
+ * Construct ONE provider instance for `providerName`/`model`, applying `exposeErrorBody: true` (F153 — every constructed
147
+ * provider, unconditionally). `baseUrl` (PRD item 28, ruling (d), 2026-09-09: admitted in v1) is forwarded only when given.
148
+ * `legacyMaxTokens` is the Settings ROW's "old max_tokens" tick: a server that ignores `max_completion_tokens` (F149) does not
149
+ * bind the output cap until it is on. It is forwarded only for `openai-api` and only when `true` — nothing here sniffs a host
150
+ * or a model id to decide it.
356
151
  * @param {string} providerName
357
- * @param {{apiKey?: string, model?: string, baseUrl?: string}} [options]
152
+ * @param {{apiKey?: string, model?: string, baseUrl?: string, legacyMaxTokens?: boolean}} [options]
358
153
  */
359
- export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
154
+ export function makeProvider(providerName, { apiKey, model, baseUrl, legacyMaxTokens } = {}) {
360
155
  const entry = resolveProvider(providerName);
361
156
  return new entry.ctor({
362
157
  exposeErrorBody: true,
@@ -369,105 +164,59 @@ export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
369
164
  // is spelled differently would otherwise IGNORE the endpoint silently and
370
165
  // run against its own default; see the note above the table.
371
166
  ...(baseUrl !== undefined ? { [entry.endpointKey]: baseUrl } : {}),
372
- ...entry.paramsFor(model),
167
+ ...(providerName === 'openai-api' && legacyMaxTokens === true ? { legacyMaxTokens: true } : {}),
373
168
  });
374
169
  }
375
170
 
376
171
  /**
377
- * Build the `{provider, providerFor, judgeProvider}` triple both runners
378
- * need (`src/authorrun.js` and `src/panel/authorsession.js` call it) — ONE seam instead of two copies of the same construction and
379
- * tier-memoization logic. `model`/`tierModels` are already-RESOLVED inputs
380
- * (the caller runs `resolveWorkerModel` itself, exactly as both runners did
381
- * before this factory existed — that decision stays where each runner's own
382
- * `--model` flag/spec precedence lives, not duplicated in here).
383
- *
384
- * THE JUDGE IS NO LONGER PINNED TO ANTHROPIC (PRD item 32.2, hamr 2026-09-10:
385
- * *"what i care about is that judge becomes llm agnostic and not set to one
386
- * model or provider"*). It is constructed through this same factory, from a
387
- * `judgeProviderName`/`judgeModel` pair the CALLER resolved (`resolveJudge`,
388
- * src/judged.js — the spec's signed `judge` override, else the job's own worker
389
- * provider and model). Item 28's pin, and item 28 part (2)'s deferral of a
390
- * signed `judge:{provider,model}` field, both close here.
172
+ * Build the `{provider, providerFor, judgeProvider}` triple both authoring runners need (`src/authorrun.js` and
173
+ * `src/panel/authorsession.js` call it) — ONE seam instead of two copies of the same construction. `model` is the
174
+ * already-RESOLVED worker model id (the caller picked the Settings line).
391
175
  *
392
- * `judgeApiKey` stays a SEPARATE argument rather than being derived: the judge
393
- * may legitimately be a different provider from the worker, and reading its key
394
- * for it would put a second env-var reader in a module whose whole job is to
395
- * have exactly one. The caller reads the resolved judge provider's own `envKey`
396
- * and hands the key in — the same contract the worker key already has here.
176
+ * THE JUDGE IS NOT PINNED (PRD item 32.2): it is constructed through this same factory from a `judgeProviderName`/`judgeModel`
177
+ * pair the CALLER resolved. `judgeApiKey` stays a SEPARATE argument: the judge may be a different provider from the worker,
178
+ * and each key is read by the caller from its own row.
397
179
  * @param {object} args
398
- * @param {string} args.providerName spec.provider (or the anthropic-api default)
399
- * @param {string|undefined} args.apiKey the WORKER's key, read from the provider's own `envKey`
180
+ * @param {string} args.providerName the worker's API shape id
181
+ * @param {string|undefined} args.apiKey the WORKER's key, read from its row's env name
400
182
  * @param {string} args.model the already-resolved worker model id
401
- * @param {Readonly<Record<string, string>>} args.tierModels the already-resolved tier -> model map (spec override folded in by the caller)
402
- * @param {string|undefined} [args.baseUrl] spec.baseUrl, if any
403
- * @param {string|undefined} args.judgeApiKey the judge's own key, read by the caller from the RESOLVED judge provider's `envKey`
183
+ * @param {string|undefined} [args.baseUrl] the worker row's Base URL
184
+ * @param {boolean} [args.legacyMaxTokens] the worker row's old max_tokens tick
185
+ * @param {string|undefined} args.judgeApiKey the judge's own key
404
186
  * @param {string} args.judgeModel the resolved judge model id
405
- * @param {string} [args.judgeProviderName] the resolved judge provider name; defaults to `providerName` (the job's own worker provider), which is `resolveJudge`'s own default
406
- * @param {string|undefined} [args.judgeBaseUrl] the judge provider's endpoint, when it takes one
187
+ * @param {string} [args.judgeProviderName] the resolved judge shape; defaults to `providerName`
188
+ * @param {string|undefined} [args.judgeBaseUrl] the judge row's endpoint
189
+ * @param {boolean} [args.judgeLegacyMaxTokens] the judge row's old max_tokens tick (defaults to the worker's when the judge shares its provider)
407
190
  */
408
191
  export function buildRunnerProviders({
409
- providerName, apiKey, model, tierModels, baseUrl, judgeApiKey, judgeModel,
410
- judgeProviderName = providerName, judgeBaseUrl = undefined,
192
+ providerName, apiKey, model, baseUrl, legacyMaxTokens = false, judgeApiKey, judgeModel,
193
+ judgeProviderName = providerName, judgeBaseUrl = undefined, judgeLegacyMaxTokens = undefined,
411
194
  }) {
412
- const provider = makeProvider(providerName, { apiKey, model, baseUrl });
413
- /** @type {Record<string, any>} */
414
- const tierCache = {};
415
- const providerFor = (/** @type {string} */ tier) => (tierCache[tier] ??= (
416
- tierModels[tier] === model
417
- ? provider
418
- : makeProvider(providerName, { apiKey, model: tierModels[tier], baseUrl })
419
- ));
420
- // the judge goes through the SAME table as the worker — one place names a
421
- // provider identity, which is the entire reason this factory exists. Reusing
422
- // the worker instance when the identity matches exactly is not an optimisation
423
- // for its own sake: a second instance against the same endpoint/model is a
424
- // second prompt-cache prefix, and cache writes are ~41% of the bill.
195
+ const provider = makeProvider(providerName, { apiKey, model, baseUrl, legacyMaxTokens });
196
+ // a plan step's `model` is the one schema token `sonnet` (STEP_MODELS, src/plan.js): it means THE JOB'S worker model
197
+ const providerFor = (/** @type {string} */ _tier) => provider;
198
+ // the judge goes through the SAME table as the worker. Reusing the worker instance when the identity matches exactly is not an
199
+ // optimisation for its own sake: a second instance against the same endpoint/model is a second prompt-cache prefix, and cache
200
+ // writes are ~41% of the bill.
201
+ const judgeLegacy = judgeLegacyMaxTokens ?? (judgeProviderName === providerName ? legacyMaxTokens : false);
425
202
  const judgeProvider = (judgeProviderName === providerName && judgeModel === model && judgeApiKey === apiKey
426
- && judgeBaseUrl === baseUrl)
203
+ && judgeBaseUrl === baseUrl && judgeLegacy === legacyMaxTokens)
427
204
  ? provider
428
- : makeProvider(judgeProviderName, { apiKey: judgeApiKey, model: judgeModel, baseUrl: judgeBaseUrl });
205
+ : makeProvider(judgeProviderName, { apiKey: judgeApiKey, model: judgeModel, baseUrl: judgeBaseUrl, legacyMaxTokens: judgeLegacy });
429
206
  return { provider, providerFor, judgeProvider };
430
207
  }
431
208
 
432
209
  /**
433
- * A `providerName`'s own models-list GET endpoint — the shape this
434
- * function's ONE caller (`checkProviderReachable`, below) needs and nothing
435
- * else reads: `anthropic-api` and `openai-api` (the panel's Model select,
436
- * PANEL-BUILD.md P3 build item 7) are the only two shapes this build knows
437
- * how to ask, both a plain unauthenticated-body GET with the key in a
438
- * header — never a completion/chat endpoint, so this never spends a token.
439
- * @param {string} providerName
440
- * @param {string} [baseUrl]
441
- * @returns {{url: string, headers: Record<string,string>}|null} `null` when
442
- * this provider's models-list shape is not known (never guessed at)
443
- */
444
- function modelsListRequest(providerName, baseUrl) {
445
- if (providerName === 'anthropic-api') {
446
- return {
447
- url: `${baseUrl || 'https://api.anthropic.com/v1'}/models`,
448
- headers: { 'x-api-key': '', 'anthropic-version': '2023-06-01' },
449
- };
450
- }
451
- if (providerName === 'openai-api') {
452
- return { url: `${baseUrl || 'https://api.openai.com/v1'}/models`, headers: {} };
453
- }
454
- return null;
455
- }
456
-
457
- /**
458
- * $0 reachability check: a GET against `providerName`'s own models-list
459
- * endpoint, server-side, with the key in a header — NEVER a completion
460
- * request, so it never spends a token (PANEL-BUILD.md P3 build item 7: "the
461
- * person must get feedback BEFORE drafting if the chosen model can't
462
- * work"). Reused, unmodified, by the panel's model-readiness route today
463
- * (`src/panel/authorroutes.js`) and by the future Settings providers
464
- * table's own "test" column (PANEL-BUILD.md P4) — ONE function, not two
465
- * hand-typed copies of the same probe.
210
+ * $0 reachability check: ONE GET of the provider's models list (`src/modellist.js`, the one owner of
211
+ * that call), server-side, with the key in a header — NEVER a completion request, so it never spends
212
+ * a token (PANEL-BUILD.md P3 build item 7: "the person must get feedback BEFORE drafting if the
213
+ * chosen model can't work"). Used by the panel's model-readiness route (`src/panel/authorroutes.js`);
214
+ * Settings > Providers' Test and Refresh call `listModels` directly. A blank `baseUrl` makes no call.
466
215
  *
467
- * NEVER returns the key, never logs it — the key only ever appears in a
468
- * request header sent straight to the provider's own host.
216
+ * NEVER returns the key, never logs it — the key only ever appears in a request header sent
217
+ * straight to the provider's own host.
469
218
  * @param {object} o
470
- * @param {string} o.providerName
219
+ * @param {string} o.providerName an API shape id
471
220
  * @param {string} o.apiKey
472
221
  * @param {string} [o.model] when given, the response is also checked for this model id
473
222
  * @param {string} [o.baseUrl]
@@ -478,39 +227,16 @@ function modelsListRequest(providerName, baseUrl) {
478
227
  export async function checkProviderReachable({
479
228
  providerName, apiKey, model, baseUrl, fetchImpl = fetch, timeoutMs = 4000,
480
229
  }) {
481
- const req = modelsListRequest(providerName, baseUrl);
482
- if (!req) {
230
+ if (!SHAPE_IDS.includes(providerName)) {
483
231
  return {
484
232
  reachable: false, modelListed: null, status: 'unsupported-provider',
485
233
  note: `no models-list check known for "${providerName}"`,
486
234
  };
487
235
  }
488
- const headers = { ...req.headers };
489
- if (providerName === 'anthropic-api') headers['x-api-key'] = apiKey;
490
- else headers.Authorization = `Bearer ${apiKey}`;
491
-
492
- const controller = new AbortController();
493
- const timer = setTimeout(() => controller.abort(), timeoutMs);
494
- try {
495
- // GET ONLY — no body, no completion/chat call, no tokens spent.
496
- const res = await fetchImpl(req.url, { method: 'GET', headers, signal: controller.signal });
497
- if (!res.ok) {
498
- return { reachable: false, modelListed: null, status: `HTTP ${res.status}`, note: `models list returned HTTP ${res.status}` };
499
- }
500
- /** @type {any} */
501
- let body = null;
502
- try { body = await res.json(); } catch { body = null; }
503
- const ids = Array.isArray(body?.data) ? body.data.map((/** @type {any} */ m) => m?.id).filter(Boolean) : [];
504
- const modelListed = model ? ids.includes(model) : null;
505
- return { reachable: true, modelListed, status: 'ok', note: null };
506
- } catch (/** @type {any} */ e) {
507
- const cls = e?.name === 'AbortError' ? 'timeout' : (e?.code || e?.name || 'network-error');
508
- // connection refused surfaces as fetch's bare TypeError ("fetch failed") — say it plainly
509
- const refused = e?.name === 'TypeError' || e?.code === 'ECONNREFUSED' || e?.cause?.code === 'ECONNREFUSED';
510
- return { reachable: false, modelListed: null, status: String(cls), note: refused ? `nothing answering at ${req.url}` : `request failed: ${cls}` };
511
- } finally {
512
- clearTimeout(timer);
513
- }
236
+ const r = await listModels({ shape: providerName, baseUrl: baseUrl ?? '', key: apiKey, fetchImpl, timeoutMs });
237
+ if (r.ok) return { reachable: true, modelListed: model ? r.models.includes(model) : null, status: 'ok', note: null };
238
+ const note = r.status.startsWith('HTTP ') ? `models list returned ${r.status}` : (r.detail ?? r.why);
239
+ return { reachable: false, modelListed: null, status: r.status, note };
514
240
  }
515
241
 
516
242
  /**
@@ -0,0 +1,18 @@
1
+ // rewritten-from: fwdloop src/providershapes.js@f9449e8
2
+ // PANEL-BUILD.md P8 — the API shapes the Settings > Providers dropdown offers, ONE owner.
3
+ // A shape is a wire protocol, never a place: no web address lives here (each row's Base URL
4
+ // is typed in Settings). Only a shape `makeProvider` (src/providers.js) can really build is listed.
5
+ // Kept in its own file so config.js can validate a shape without importing providers.js.
6
+
7
+ /**
8
+ * `id` is the value saved in config.json (and the signed spec's `provider`).
9
+ * @type {readonly {id: string, label: string}[]}
10
+ */
11
+ export const SHAPES = Object.freeze([
12
+ Object.freeze({ id: 'anthropic-api', label: 'Anthropic' }),
13
+ Object.freeze({ id: 'openai-api', label: 'OpenAI-compatible' }),
14
+ Object.freeze({ id: 'gemini-api', label: 'Gemini' }),
15
+ ]);
16
+
17
+ /** @type {readonly string[]} */
18
+ export const SHAPE_IDS = Object.freeze(SHAPES.map((s) => s.id));
package/src/replay.js CHANGED
@@ -1520,7 +1520,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1520
1520
  // never hidden. `null` when there is nothing to compare (a resumed run,
1521
1521
  // where a gap is expected and already explained; or `thisFileSpend`
1522
1522
  // itself unknown) or when the two already agree within $0.0001.
1523
- spendMismatch: (!resumed && spentUsd !== null && thisFileSpend.value !== null && Math.abs(spentUsd - thisFileSpend.value) > 0.0001)
1523
+ spendMismatch: (!resumed && spentUsd !== null && thisFileSpend.value !== null && Math.abs(spentUsd - thisFileSpend.value) > 1e-4)
1524
1524
  ? { spentUsd, thisFileUsd: thisFileSpend.value, diffUsd: spentUsd - thisFileSpend.value }
1525
1525
  : null,
1526
1526
  timelineKind,
@@ -0,0 +1,130 @@
1
+ // rewritten-from: fwdloop src/thinking.js@f9449e8
2
+ // PANEL-BUILD.md P8 batch 5 — THE one owner of what a drafting or judging model call sends for thinking. bareloop keeps NO per-model
3
+ // thinking list and NO vendor regex: bare-agent (>=0.52) owns which request shape switches thinking for which model, and
4
+ // `thinkingSupport(kind, model, {baseUrl})` is its own pure lookup (no network): `true` (measured switchable), `false` (measured
5
+ // NOT switchable), `'unverified'` (not measured — the shape ladder is tried at runtime; if every shape fails with a client
6
+ // validation error ONE plain request decides, and the call still runs, reported `thinkingStatus: 'unsupported'`).
7
+ //
8
+ // The tick is the person's, per job ("Thinking on", the signed optional `thinking` field; absent = off). It covers DRAFTING and the
9
+ // rubric JUDGE; the WORKER is never handed a thinking option. PER-CALL options, never constructor options (the drafter's provider is
10
+ // the worker's instance, deliberately left as it was). The raw `thinking` option is never sent (bare-agent throws if both are set).
11
+ import { createRequire } from 'node:module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const { thinkingSupport: baseSupport, AnthropicProvider, OpenAIProvider, GeminiProvider } = require('bare-agent/providers');
15
+
16
+ /** The words under a greyed "Thinking on" box (the server's `thinking.canChange` decides; the page decides nothing). */
17
+ export const THINKING_FIXED_SAY = "can't change this for this model";
18
+
19
+ /** API shape id -> bare-agent's thinking-ladder kind. A missing shape (clipipe, a test double) has no kind. */
20
+ const KIND_OF_SHAPE = Object.freeze({ 'anthropic-api': 'anthropic', 'openai-api': 'openai', 'gemini-api': 'gemini' });
21
+
22
+ /**
23
+ * What bare-agent knows about switching this model's thinking. A missing kind, a non-function `impl`, or a throw all read
24
+ * `'unverified'` — never a guess in either direction.
25
+ * @param {string} shape an API shape id (src/providershapes.js)
26
+ * @param {string} model
27
+ * @param {{ baseUrl?: string|null, impl?: Function }} [o] `impl` is a test seam; production uses bare-agent's own
28
+ * @returns {true|false|'unverified'}
29
+ */
30
+ export function thinkingSupport(shape, model, { baseUrl, impl = baseSupport } = {}) {
31
+ const kind = /** @type {Record<string, string>} */ (KIND_OF_SHAPE)[shape];
32
+ if (kind === undefined || typeof impl !== 'function') return 'unverified';
33
+ try {
34
+ const r = impl(kind, model, baseUrl ? { baseUrl } : undefined);
35
+ return r?.switchable === true ? true : (r?.switchable === false ? false : 'unverified');
36
+ } catch { return 'unverified'; }
37
+ }
38
+
39
+ /**
40
+ * The per-call options for one support answer and one tick (`true` = "Thinking on"). The table (PANEL-BUILD.md P8, ruling Q1):
41
+ * tick on : true -> on | false -> {} (the box is greyed, cannot be on) | 'unverified' -> on
42
+ * tick off: true -> off | false -> {} (the model thinks as it would) | 'unverified' -> off (bare-agent decides; plain request on failure)
43
+ * @param {true|false|'unverified'} support
44
+ * @param {boolean} tick
45
+ * @returns {{ thinkingMode: 'on'|'off' } | {}}
46
+ */
47
+ export function thinkingOptions(support, tick) {
48
+ if (support === false) return {};
49
+ return { thinkingMode: tick ? 'on' : 'off' };
50
+ }
51
+
52
+ /** The enumerated operator knob a drafting call is built with: 'on' | 'off' (the tick, spelled as a word). */
53
+ export const THINKING_MODES = Object.freeze(['on', 'off']);
54
+
55
+ /**
56
+ * Throw unless `mode` is one of {@link THINKING_MODES} — an operator-param guard, so a typo never silently means a mode.
57
+ * @param {unknown} mode
58
+ * @returns {'on'|'off'}
59
+ */
60
+ export function assertThinkingMode(mode) {
61
+ if (mode === 'on' || mode === 'off') return mode;
62
+ throw new Error(`[thinking] draftThinking must be one of ${THINKING_MODES.join(' | ')}, got ${JSON.stringify(mode)}`);
63
+ }
64
+
65
+ /**
66
+ * The API shape of a constructed bare-agent provider (the three client classes only); `null` for a test double or a clipipe.
67
+ * @param {any} provider
68
+ * @returns {string|null}
69
+ */
70
+ export function shapeOfProvider(provider) {
71
+ if (provider instanceof AnthropicProvider) return 'anthropic-api';
72
+ if (provider instanceof OpenAIProvider) return 'openai-api';
73
+ if (provider instanceof GeminiProvider) return 'gemini-api';
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * The per-call options every DRAFTING and JUDGING model call on this provider instance carries for this mode. A provider that is
79
+ * not one of the three client classes (a test double, a clipipe) is never sent the switch.
80
+ * @param {any} provider a constructed bare-agent provider
81
+ * @param {unknown} [mode] 'on' | 'off' (default 'off')
82
+ * @returns {{ thinkingMode: 'on'|'off' } | {}}
83
+ */
84
+ export function thinkingCallOptions(provider, mode = 'off') {
85
+ const m = assertThinkingMode(mode);
86
+ const shape = shapeOfProvider(provider);
87
+ if (shape === null) return {};
88
+ return thinkingOptions(thinkingSupport(shape, String(provider.model), { baseUrl: provider.baseUrl }), m === 'on');
89
+ }
90
+
91
+ /**
92
+ * What the drafting calls will carry for a model the caller has only NAMED (shape, model id, Base URL) — no key, no network. The
93
+ * first drafting row is shown from this, before the key is read.
94
+ * @param {{providerName: string, model: string, baseUrl?: string|null}} o
95
+ * @param {unknown} [mode] 'on' | 'off'
96
+ * @returns {{ support: true|false|'unverified', mode: 'on'|'off', options: {thinkingMode?: 'on'|'off'} }}
97
+ */
98
+ export function thinkingPlanFor({ providerName, model, baseUrl }, mode = 'off') {
99
+ const m = assertThinkingMode(mode);
100
+ const support = thinkingSupport(providerName, model, { baseUrl });
101
+ return { support, mode: m, options: thinkingOptions(support, m === 'on') };
102
+ }
103
+
104
+ /**
105
+ * The server's answer for one Model-menu line: whether the "Thinking on" box can change and the words under it. `false` greys and
106
+ * unticks the box (the note reads {@link THINKING_FIXED_SAY}); `'unverified'` leaves it enabled with no note.
107
+ * @param {string} shape @param {string} model @param {string|null|undefined} baseUrl
108
+ * @returns {{ support: true|false|'unverified', canChange: boolean, say: string }}
109
+ */
110
+ export function thinkingForLine(shape, model, baseUrl) {
111
+ const support = thinkingSupport(shape, model, { baseUrl });
112
+ return { support, canChange: support !== false, say: support === false ? THINKING_FIXED_SAY : '' };
113
+ }
114
+
115
+ /**
116
+ * The first drafting row's words — code-owned, ALWAYS starting "thinking on" or "thinking off". `status` is bare-agent's own
117
+ * `thinkingStatus` for a call that has run ('applied' | 'unsupported' | 'ignored' | 'unknown'); undefined = before any call, read from
118
+ * the plan (`support`, `mode`).
119
+ * @param {{ mode: 'on'|'off', support?: true|false|'unverified', status?: string }} s
120
+ * @returns {string}
121
+ */
122
+ export function thinkingRowText({ mode, support, status }) {
123
+ if (status === 'applied') return `thinking ${mode}`;
124
+ if (status === 'unsupported') return "thinking on (this model can't change it)";
125
+ if (status === 'ignored') return 'thinking on (the model thought anyway)';
126
+ if (status === 'unknown') return `thinking ${mode} (not confirmed)`;
127
+ if (support === false) return "thinking on (this model can't change it)";
128
+ if (support === 'unverified') return `thinking ${mode} (not verified for this model)`;
129
+ return `thinking ${mode}`;
130
+ }