do-better 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/llm.js ADDED
@@ -0,0 +1,530 @@
1
+ // src/llm.js — provider-pluggable LLM service layer (SPEC D2, §6 tiering, D10 budget).
2
+ //
3
+ // Providers: anthropic (default) | gemini | openai | local (any OpenAI-compatible
4
+ // endpoint via DOBETTER_LOCAL_BASE_URL), via builtin fetch — no SDKs.
5
+ // Tiers (spec §6): cheap / mid / frontier. Budget is enforced before EVERY call.
6
+ // Offline (--offline) constructs a service whose calls throw OfflineError; phase
7
+ // modules route around it with withFallback(). Test seam: DOBETTER_FAKE_LLM.
8
+
9
+ import path from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import {
12
+ OpError,
13
+ BudgetError,
14
+ OfflineError,
15
+ assertSafeModelName,
16
+ log,
17
+ } from "./utils.js";
18
+
19
+ export const TIERS = ["cheap", "mid", "frontier"];
20
+
21
+ export const DEFAULT_MODELS = {
22
+ anthropic: { cheap: "claude-haiku-4-5", mid: "claude-sonnet-4-6", frontier: "claude-opus-4-8" },
23
+ openai: { cheap: "gpt-4o-mini", mid: "gpt-4o", frontier: "gpt-4o" },
24
+ gemini: { cheap: "gemini-2.5-flash", mid: "gemini-2.5-pro", frontier: "gemini-2.5-pro" },
25
+ };
26
+
27
+ // USD per 1M tokens { in, out }. Unknown model → its tier's default-model price
28
+ // for that provider; truly unknown → conservative fallback below.
29
+ export const PRICES = {
30
+ "claude-haiku-4-5": { in: 1.0, out: 5.0 },
31
+ "claude-sonnet-4-6": { in: 3.0, out: 15.0 },
32
+ "claude-opus-4-8": { in: 5.0, out: 25.0 },
33
+ "gpt-4o-mini": { in: 0.15, out: 0.6 },
34
+ "gpt-4o": { in: 2.5, out: 10.0 },
35
+ "gemini-2.5-flash": { in: 0.3, out: 2.5 },
36
+ "gemini-2.5-pro": { in: 1.25, out: 10.0 },
37
+ };
38
+
39
+ export const MAX_OUTPUT_TOKENS = 16000;
40
+
41
+ const FALLBACK_PRICE = { in: 5, out: 25 };
42
+ const KEY_VARS = {
43
+ anthropic: "ANTHROPIC_API_KEY",
44
+ gemini: "GEMINI_API_KEY",
45
+ openai: "OPENAI_API_KEY",
46
+ };
47
+ // Local provider (SPEC D2: "Anthropic default; Gemini/OpenAI/local"): any
48
+ // OpenAI-compatible endpoint (Ollama, llama.cpp, vLLM, LM Studio, …) for
49
+ // customer data-governance constraints where code may not leave the network.
50
+ const LOCAL_BASE_URL_VAR = "DOBETTER_LOCAL_BASE_URL";
51
+ const LOCAL_API_KEY_VAR = "DOBETTER_LOCAL_API_KEY";
52
+ const LOCAL_MODEL_VAR = "DOBETTER_LOCAL_MODEL";
53
+ // Autodetect order is anthropic-first by contract; local is detected last.
54
+ const AUTODETECT_ORDER = ["anthropic", "gemini", "openai"];
55
+ const RETRY_ATTEMPTS = 3;
56
+ const RETRY_BASE_DELAY_MS = 1000;
57
+
58
+ // Robustly extract JSON from a model response even when wrapped in prose or
59
+ // markdown code fences (skill-mining pattern).
60
+ export function cleanJsonResponse(text) {
61
+ let cleaned = String(text ?? "").trim();
62
+ const firstBrace = cleaned.indexOf("{");
63
+ const firstBracket = cleaned.indexOf("[");
64
+ let startIdx = -1;
65
+ let endIdx = -1;
66
+ if (firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket)) {
67
+ startIdx = firstBrace;
68
+ endIdx = cleaned.lastIndexOf("}");
69
+ } else if (firstBracket !== -1) {
70
+ startIdx = firstBracket;
71
+ endIdx = cleaned.lastIndexOf("]");
72
+ }
73
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
74
+ cleaned = cleaned.substring(startIdx, endIdx + 1);
75
+ }
76
+ if (cleaned.startsWith("```json")) cleaned = cleaned.slice(7);
77
+ else if (cleaned.startsWith("```")) cleaned = cleaned.slice(3);
78
+ if (cleaned.endsWith("```")) cleaned = cleaned.slice(0, -3);
79
+ return cleaned.trim();
80
+ }
81
+
82
+ function resolveModels(provider, flags) {
83
+ // Validate overrides only when actually provided (blueprint §4: validate
84
+ // model OVERRIDES; absent overrides resolve to the provider defaults).
85
+ if (flags.modelCheap != null) assertSafeModelName(flags.modelCheap, "--model-cheap");
86
+ if (flags.modelMid != null) assertSafeModelName(flags.modelMid, "--model-mid");
87
+ if (flags.modelFrontier != null) assertSafeModelName(flags.modelFrontier, "--model-frontier");
88
+ const defaults = DEFAULT_MODELS[provider] ?? DEFAULT_MODELS.anthropic;
89
+ return {
90
+ cheap: flags.modelCheap || defaults.cheap,
91
+ mid: flags.modelMid || defaults.mid,
92
+ frontier: flags.modelFrontier || defaults.frontier,
93
+ };
94
+ }
95
+
96
+ // Local provider config: OpenAI-compatible base URL + model names from env
97
+ // (per-tier --model-* flags override the DOBETTER_LOCAL_MODEL default).
98
+ function configureLocal(flags, env) {
99
+ const baseUrl = String(env[LOCAL_BASE_URL_VAR] ?? "").trim().replace(/\/+$/, "");
100
+ if (!baseUrl) {
101
+ throw new OpError(
102
+ `Provider "local" requested but ${LOCAL_BASE_URL_VAR} is not set. Point it at an ` +
103
+ `OpenAI-compatible endpoint (e.g. http://localhost:11434/v1 for Ollama).`
104
+ );
105
+ }
106
+ if (!/^https?:\/\//.test(baseUrl)) {
107
+ throw new OpError(`${LOCAL_BASE_URL_VAR} must be an http(s) URL, got "${baseUrl}".`);
108
+ }
109
+ const defaultModel = env[LOCAL_MODEL_VAR] || null;
110
+ if (defaultModel != null) assertSafeModelName(defaultModel, LOCAL_MODEL_VAR);
111
+ if (flags.modelCheap != null) assertSafeModelName(flags.modelCheap, "--model-cheap");
112
+ if (flags.modelMid != null) assertSafeModelName(flags.modelMid, "--model-mid");
113
+ if (flags.modelFrontier != null) assertSafeModelName(flags.modelFrontier, "--model-frontier");
114
+ const models = {
115
+ cheap: flags.modelCheap || defaultModel,
116
+ mid: flags.modelMid || defaultModel,
117
+ frontier: flags.modelFrontier || defaultModel,
118
+ };
119
+ const missing = TIERS.filter((t) => !models[t]);
120
+ if (missing.length) {
121
+ throw new OpError(
122
+ `Provider "local" requires model names for tier(s) ${missing.join(", ")}: set ${LOCAL_MODEL_VAR} ` +
123
+ `or pass --model-cheap/--model-mid/--model-frontier.`
124
+ );
125
+ }
126
+ return {
127
+ provider: "local",
128
+ apiKey: env[LOCAL_API_KEY_VAR] || "local",
129
+ baseUrl,
130
+ models,
131
+ offline: Boolean(flags.offline),
132
+ };
133
+ }
134
+
135
+ // Resolve the provider configuration from flags + env (skill-mining pattern).
136
+ // Providers: anthropic|gemini|openai hosted APIs, plus "local" = any
137
+ // OpenAI-compatible endpoint (D2 data-governance escape hatch). Arbitrary
138
+ // local CLI agents remain unsupported in v1.
139
+ export function configureProvider(flags = {}, env = process.env) {
140
+ const offline = Boolean(flags.offline);
141
+
142
+ if (flags.provider != null) {
143
+ const provider = flags.provider;
144
+ if (provider === "local") return configureLocal(flags, env);
145
+ if (!Object.hasOwn(KEY_VARS, provider)) {
146
+ throw new OpError(
147
+ `Unsupported provider "${provider}". do-better supports --provider anthropic|gemini|openai|local ` +
148
+ `(local = an OpenAI-compatible endpoint via ${LOCAL_BASE_URL_VAR}; ` +
149
+ `arbitrary local CLI providers are not supported in v1).`
150
+ );
151
+ }
152
+ const keyVar = KEY_VARS[provider];
153
+ if (!env[keyVar]) {
154
+ throw new OpError(`Provider "${provider}" requested but ${keyVar} is not set.`);
155
+ }
156
+ return { provider, apiKey: env[keyVar], models: resolveModels(provider, flags), offline };
157
+ }
158
+
159
+ for (const provider of AUTODETECT_ORDER) {
160
+ if (env[KEY_VARS[provider]]) {
161
+ return { provider, apiKey: env[KEY_VARS[provider]], models: resolveModels(provider, flags), offline };
162
+ }
163
+ }
164
+
165
+ // Local endpoint detected last: hosted keys win, but a configured local
166
+ // server keeps fully air-gapped engagements LLM-capable (SPEC D2).
167
+ if (env[LOCAL_BASE_URL_VAR]) return configureLocal(flags, env);
168
+
169
+ if (offline) {
170
+ // Offline still constructs a config so spend/budget plumbing exists; the
171
+ // anthropic defaults are placeholders (no call ever leaves the process).
172
+ return { provider: "offline", apiKey: null, models: resolveModels("anthropic", flags), offline: true };
173
+ }
174
+
175
+ throw new OpError(
176
+ "No LLM provider configured. Set ANTHROPIC_API_KEY, GEMINI_API_KEY, or OPENAI_API_KEY " +
177
+ `(or pass --provider with its key), set ${LOCAL_BASE_URL_VAR} for a local OpenAI-compatible ` +
178
+ "endpoint (--provider local), or run with --offline to degrade to static analysis."
179
+ );
180
+ }
181
+
182
+ function buildRequest(provider, { model, prompt, system, jsonMode, apiKey, baseUrl }) {
183
+ if (provider === "openai" || provider === "local") {
184
+ return {
185
+ url: provider === "local" ? `${baseUrl}/chat/completions` : "https://api.openai.com/v1/chat/completions",
186
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
187
+ body: {
188
+ model,
189
+ messages: [
190
+ ...(system ? [{ role: "system", content: system }] : []),
191
+ { role: "user", content: prompt },
192
+ ],
193
+ max_tokens: MAX_OUTPUT_TOKENS,
194
+ response_format: jsonMode ? { type: "json_object" } : undefined,
195
+ },
196
+ parse(data) {
197
+ const text = data?.choices?.[0]?.message?.content;
198
+ if (typeof text !== "string") {
199
+ throw new Error(`Invalid response format from ${provider === "local" ? "local OpenAI-compatible" : "OpenAI"} API: ${JSON.stringify(data).slice(0, 300)}`);
200
+ }
201
+ return { text, tokensIn: intOrNull(data?.usage?.prompt_tokens), tokensOut: intOrNull(data?.usage?.completion_tokens) };
202
+ },
203
+ };
204
+ }
205
+ if (provider === "anthropic") {
206
+ return {
207
+ url: "https://api.anthropic.com/v1/messages",
208
+ headers: {
209
+ "Content-Type": "application/json",
210
+ "x-api-key": apiKey,
211
+ "anthropic-version": "2023-06-01",
212
+ },
213
+ body: {
214
+ model,
215
+ messages: [{ role: "user", content: prompt }],
216
+ system: system || undefined,
217
+ max_tokens: MAX_OUTPUT_TOKENS,
218
+ },
219
+ parse(data) {
220
+ const text = data?.content?.[0]?.text;
221
+ if (typeof text !== "string") {
222
+ throw new Error(`Invalid response format from Anthropic API: ${JSON.stringify(data).slice(0, 300)}`);
223
+ }
224
+ return { text, tokensIn: intOrNull(data?.usage?.input_tokens), tokensOut: intOrNull(data?.usage?.output_tokens) };
225
+ },
226
+ };
227
+ }
228
+ if (provider === "gemini") {
229
+ return {
230
+ url: `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
231
+ headers: { "Content-Type": "application/json" },
232
+ body: {
233
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
234
+ systemInstruction: system ? { parts: [{ text: system }] } : undefined,
235
+ generationConfig: {
236
+ maxOutputTokens: MAX_OUTPUT_TOKENS,
237
+ ...(jsonMode ? { responseMimeType: "application/json" } : {}),
238
+ },
239
+ },
240
+ parse(data) {
241
+ const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
242
+ if (typeof text !== "string") {
243
+ throw new Error(`Invalid response format from Gemini API: ${JSON.stringify(data).slice(0, 300)}`);
244
+ }
245
+ return {
246
+ text,
247
+ tokensIn: intOrNull(data?.usageMetadata?.promptTokenCount),
248
+ tokensOut: intOrNull(data?.usageMetadata?.candidatesTokenCount),
249
+ };
250
+ },
251
+ };
252
+ }
253
+ throw new OpError(`No request builder for provider "${provider}".`);
254
+ }
255
+
256
+ function intOrNull(v) {
257
+ return Number.isFinite(v) ? Math.max(0, Math.round(v)) : null;
258
+ }
259
+
260
+ function sleep(ms) {
261
+ return new Promise((resolve) => setTimeout(resolve, ms));
262
+ }
263
+
264
+ // Retry only transient failures (H3): network errors (no HTTP status), request
265
+ // timeout (408), rate limit (429), and 5xx. A permanent 4xx (400 bad request,
266
+ // 401/403 auth, 404 model, 422 unprocessable) or a response-parse failure is
267
+ // not worth re-issuing — it fails the same way and only burns backoff sleeps
268
+ // (and, for a billed-but-unparseable body, re-bills). `err.status` is attached
269
+ // at the non-ok throw site; its absence means a network/transport error.
270
+ function isRetryable(err) {
271
+ const s = err?.status;
272
+ if (typeof s !== "number") return true; // network / transport — worth retrying
273
+ return s === 408 || s === 429 || s >= 500;
274
+ }
275
+
276
+ // Create the LLM service object — the ONE sanctioned mutable accumulator
277
+ // (spend). Never returns null; offline still constructs.
278
+ export function createLLM({ flags = {}, state = null, env = process.env, fetchImpl = globalThis.fetch, sleepImpl = sleep } = {}) {
279
+ const fakePath = env.DOBETTER_FAKE_LLM || null;
280
+ // Test seam: when DOBETTER_FAKE_LLM is set, providers/keys are ignored
281
+ // entirely and no network code path is reachable.
282
+ const config = fakePath
283
+ ? { provider: "fake", apiKey: null, models: { ...DEFAULT_MODELS.anthropic }, offline: Boolean(flags.offline) }
284
+ : configureProvider(flags, env);
285
+ const offline = config.offline || config.provider === "offline";
286
+
287
+ let limit = null;
288
+ if (flags.budget != null) {
289
+ if (typeof flags.budget !== "number" || !Number.isFinite(flags.budget) || flags.budget <= 0) {
290
+ throw new OpError(`Invalid --budget: expected a positive number of USD, got "${flags.budget}".`);
291
+ }
292
+ limit = flags.budget;
293
+ } else if (typeof state?.budget?.limitUSD === "number" && Number.isFinite(state.budget.limitUSD) && state.budget.limitUSD > 0) {
294
+ limit = state.budget.limitUSD;
295
+ }
296
+ // Running total of spend already reconciled out of the accumulator. Seeded
297
+ // from state.budget.spentUSD and ADVANCED by drainSpend() on every phase
298
+ // boundary — one llm instance spans all phases of `do-better run`, so without
299
+ // this promotion each drain would make later phases' budget checks forget all
300
+ // prior spend and silently blow the hard --budget ceiling (D10).
301
+ let spentBase = Number.isFinite(state?.budget?.spentUSD) ? state.budget.spentUSD : 0;
302
+
303
+ const accumulated = { calls: 0, tokensIn: 0, tokensOut: 0, costUSD: 0 };
304
+ // In-flight budget reservations (H2): checkBudget adds a call's estimated cost
305
+ // here BEFORE the network round-trip and call() releases it in a finally after
306
+ // the actual spend is recorded. Because checkBudget is check-then-record,
307
+ // concurrent callers (H8 pooled finders / D1 readers; the coldstart repair
308
+ // Promise.all) would otherwise each pass the same spend snapshot and blow the
309
+ // hard --budget ceiling collectively. Always 0 at check time for a sequential
310
+ // caller, so this is a provable no-op outside concurrency.
311
+ let reserved = 0;
312
+ let fakeFnPromise = null;
313
+
314
+ function estimateTokens(text) {
315
+ return Math.ceil(String(text ?? "").length / 4);
316
+ }
317
+
318
+ // Fake mode prices by the anthropic tier table (contract: budget logic stays
319
+ // testable offline); provider mode prices by the resolved model.
320
+ function priceFor(tier) {
321
+ // Local inference has no per-token API cost; spend stays accounted at $0.
322
+ if (config.provider === "local") return { in: 0, out: 0 };
323
+ const priceProvider = config.provider === "fake" || config.provider === "offline" ? "anthropic" : config.provider;
324
+ const model = config.provider === "fake" ? DEFAULT_MODELS.anthropic[tier] : config.models[tier];
325
+ if (PRICES[model]) return PRICES[model];
326
+ const tierDefault = DEFAULT_MODELS[priceProvider]?.[tier];
327
+ if (tierDefault && PRICES[tierDefault]) return PRICES[tierDefault];
328
+ return FALLBACK_PRICE;
329
+ }
330
+
331
+ // Reserves the call's estimated cost against the cap and returns the amount
332
+ // reserved (0 when no limit), which call() releases in a finally. Counts
333
+ // in-flight reservations so overlapping calls cannot each pass the same
334
+ // snapshot (H2). Throws BudgetError without reserving when the cap is blown.
335
+ function checkBudget(prompt, system, tier) {
336
+ if (limit === null) return 0;
337
+ const price = priceFor(tier);
338
+ const spent = spentBase + accumulated.costUSD + reserved;
339
+ const estCost =
340
+ (estimateTokens(prompt + system) / 1e6) * price.in + (MAX_OUTPUT_TOKENS / 1e6) * price.out;
341
+ if (spent + estCost > limit) {
342
+ throw new BudgetError(
343
+ `Budget $${limit} would be exceeded (spent $${spent.toFixed(4)}, next call ≈ $${estCost.toFixed(4)}). ` +
344
+ `Re-run with a higher --budget to resume; state.json preserves completed work.`
345
+ );
346
+ }
347
+ reserved += estCost;
348
+ return estCost;
349
+ }
350
+
351
+ function recordSpend(tokensIn, tokensOut, tier) {
352
+ const price = priceFor(tier);
353
+ accumulated.calls += 1;
354
+ accumulated.tokensIn += tokensIn;
355
+ accumulated.tokensOut += tokensOut;
356
+ accumulated.costUSD += (tokensIn / 1e6) * price.in + (tokensOut / 1e6) * price.out;
357
+ }
358
+
359
+ function loadFake() {
360
+ if (!fakeFnPromise) {
361
+ fakeFnPromise = import(pathToFileURL(path.resolve(fakePath)).href).then((mod) => {
362
+ if (typeof mod.default !== "function") {
363
+ throw new OpError(`DOBETTER_FAKE_LLM module must default-export an async function: ${fakePath}`);
364
+ }
365
+ return mod.default;
366
+ });
367
+ }
368
+ return fakeFnPromise;
369
+ }
370
+
371
+ async function callProvider(prompt, system, tier, jsonMode, label) {
372
+ const model = config.models[tier];
373
+ let delay = RETRY_BASE_DELAY_MS;
374
+ let lastErr = null;
375
+ for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
376
+ try {
377
+ const req = buildRequest(config.provider, { model, prompt, system, jsonMode, apiKey: config.apiKey, baseUrl: config.baseUrl });
378
+ const res = await fetchImpl(req.url, {
379
+ method: "POST",
380
+ headers: req.headers,
381
+ body: JSON.stringify(req.body),
382
+ });
383
+ if (!res.ok) {
384
+ const errText = await res.text();
385
+ const httpErr = new Error(`${config.provider} API error (${res.status}): ${errText}`);
386
+ httpErr.status = res.status; // lets isRetryable classify 4xx vs 5xx
387
+ throw httpErr;
388
+ }
389
+ const data = await res.json();
390
+ let parsed;
391
+ try {
392
+ parsed = req.parse(data);
393
+ } catch (parseErr) {
394
+ // Billed-but-unparseable (H3): the provider returned 200 and charged
395
+ // for the tokens, but the body has no usable text (e.g. a
396
+ // safety-blocked Gemini candidate, an OpenAI refusal shape). Record
397
+ // the billed spend BEFORE failing so it never escapes the budget
398
+ // counter, and mark the error non-retryable (the same body reparses
399
+ // the same way — retrying only re-bills).
400
+ recordSpend(intOrNull(data?.usage?.input_tokens ?? data?.usage?.prompt_tokens ?? data?.usageMetadata?.promptTokenCount) ?? estimateTokens(prompt + system), 0, tier);
401
+ parseErr.status = 200;
402
+ throw parseErr;
403
+ }
404
+ const { text, tokensIn, tokensOut } = parsed;
405
+ recordSpend(tokensIn ?? estimateTokens(prompt + system), tokensOut ?? estimateTokens(text), tier);
406
+ return text;
407
+ } catch (err) {
408
+ lastErr = err;
409
+ if (attempt < RETRY_ATTEMPTS && isRetryable(err)) {
410
+ log.warn(`${label}: LLM call failed (attempt ${attempt}/${RETRY_ATTEMPTS}): ${err.message} — retrying in ${delay}ms`);
411
+ await sleepImpl(delay);
412
+ delay *= 2;
413
+ } else if (!isRetryable(err)) {
414
+ // Permanent failure — stop immediately, do not burn further attempts.
415
+ break;
416
+ }
417
+ }
418
+ }
419
+ throw new OpError(
420
+ `${label}: LLM call failed after ${RETRY_ATTEMPTS} attempts (${config.provider}/${model}): ${lastErr?.message ?? "unknown error"}`
421
+ );
422
+ }
423
+
424
+ async function call(prompt, { system = "", tier = "mid", label = "LLM", jsonMode = false } = {}) {
425
+ if (typeof prompt !== "string" || prompt.length === 0) {
426
+ throw new OpError(`${label}: LLM prompt must be a non-empty string.`);
427
+ }
428
+ if (typeof system !== "string") {
429
+ throw new OpError(`${label}: LLM system instruction must be a string.`);
430
+ }
431
+ if (!TIERS.includes(tier)) {
432
+ throw new OpError(`${label}: unknown LLM tier "${tier}" (expected one of: ${TIERS.join(", ")}).`);
433
+ }
434
+ if (offline) {
435
+ throw new OfflineError(
436
+ `${label}: LLM call attempted in --offline mode with no static fallback. ` +
437
+ `Wrap call sites with withFallback() or remove --offline.`
438
+ );
439
+ }
440
+ const reservedCost = checkBudget(prompt, system, tier);
441
+ try {
442
+ if (config.provider === "fake") {
443
+ const fake = await loadFake();
444
+ const text = await fake({ prompt, system, tier, label, jsonMode });
445
+ if (typeof text !== "string") {
446
+ throw new OpError(`${label}: DOBETTER_FAKE_LLM module returned a non-string response.`);
447
+ }
448
+ recordSpend(estimateTokens(prompt + system), estimateTokens(text), tier);
449
+ return text;
450
+ }
451
+ return await callProvider(prompt, system, tier, jsonMode, label);
452
+ } finally {
453
+ // Release this call's reservation once it settles (success or throw). The
454
+ // actual spend is already in `accumulated` via recordSpend, so releasing
455
+ // the estimate avoids double-counting; a leak here would only err
456
+ // conservative, but the finally guarantees none.
457
+ reserved -= reservedCost;
458
+ }
459
+ }
460
+
461
+ async function callJson(prompt, { system = "", tier = "mid", label = "LLM" } = {}) {
462
+ let lastErr = null;
463
+ for (let attempt = 0; attempt < 2; attempt++) {
464
+ const suffix =
465
+ attempt === 0
466
+ ? ""
467
+ : "\n\nYour previous response was not valid JSON. Return ONLY valid JSON — no prose, no code fences.";
468
+ const response = await call(prompt + suffix, { system, tier, label, jsonMode: true });
469
+ try {
470
+ return JSON.parse(cleanJsonResponse(response));
471
+ } catch (err) {
472
+ lastErr = err;
473
+ log.warn(`${label}: response was not parseable JSON (attempt ${attempt + 1}/2)`);
474
+ }
475
+ }
476
+ throw new OpError(`${label}: returned unparseable JSON after retry (${lastErr?.message ?? "unknown"}).`);
477
+ }
478
+
479
+ function drainSpend() {
480
+ const out = { ...accumulated };
481
+ // Promote drained cost into the running base BEFORE zeroing, so the budget
482
+ // ceiling keeps counting cumulative spend across phase boundaries.
483
+ spentBase += accumulated.costUSD;
484
+ accumulated.calls = 0;
485
+ accumulated.tokensIn = 0;
486
+ accumulated.tokensOut = 0;
487
+ accumulated.costUSD = 0;
488
+ return out;
489
+ }
490
+
491
+ // Non-destructive read of cumulative USD spent so far (base + not-yet-drained
492
+ // accumulator) — for progress display (H16). Unlike drainSpend() this does
493
+ // NOT zero the accumulator, so it is safe to call mid-phase.
494
+ function spentSoFar() {
495
+ return spentBase + accumulated.costUSD;
496
+ }
497
+
498
+ return {
499
+ offline,
500
+ provider: config.provider,
501
+ models: { ...config.models },
502
+ call,
503
+ callJson,
504
+ drainSpend,
505
+ spentSoFar,
506
+ estimateTokens,
507
+ };
508
+ }
509
+
510
+ // Deterministic-fallback wrapper for every LLM call site (D2/§4 --offline).
511
+ // Offline → fallbackFn(). Online → the LLM path; ONLY an OfflineError is
512
+ // downgraded to fallbackFn() — network/parse/budget failures rethrow (fail
513
+ // closed, never silently degrade). callArgs: { prompt, system?, tier?, label?,
514
+ // jsonMode?, json? } — json:true routes through callJson().
515
+ export async function withFallback(llm, callArgs, fallbackFn) {
516
+ if (!llm || typeof llm.call !== "function") {
517
+ throw new OpError("withFallback requires an LLM service object.");
518
+ }
519
+ if (typeof fallbackFn !== "function") {
520
+ throw new OpError("withFallback requires a fallback function.");
521
+ }
522
+ const { prompt, json = false, ...opts } = callArgs ?? {};
523
+ if (llm.offline) return fallbackFn();
524
+ try {
525
+ return json ? await llm.callJson(prompt, opts) : await llm.call(prompt, opts);
526
+ } catch (err) {
527
+ if (err instanceof OfflineError) return fallbackFn();
528
+ throw err;
529
+ }
530
+ }