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/LICENSE +21 -0
- package/README.md +265 -0
- package/bin/cli.js +262 -0
- package/do-better/SKILL.md +417 -0
- package/do-better/references/refute-charter.md +170 -0
- package/do-better/references/scoring.md +98 -0
- package/do-better/references/taxonomy.md +136 -0
- package/do-better/references/templates/charter-template.md +71 -0
- package/do-better/references/templates/finding-template.md +56 -0
- package/do-better/references/templates/rail-template.md +74 -0
- package/do-better/references/templates/roadmap-template.md +67 -0
- package/do-better/references/templates/ticket-template.md +78 -0
- package/do-better/references/verification.md +129 -0
- package/package.json +20 -0
- package/src/adlc.js +331 -0
- package/src/artifacts.js +580 -0
- package/src/charter.js +657 -0
- package/src/comprehend.js +553 -0
- package/src/identify.js +1119 -0
- package/src/llm.js +530 -0
- package/src/rail.js +533 -0
- package/src/refresh.js +256 -0
- package/src/roadmap.js +630 -0
- package/src/scan.js +313 -0
- package/src/state.js +260 -0
- package/src/utils.js +449 -0
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
|
+
}
|