@latimer-woods-tech/llm 0.2.0 → 0.4.1

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/dist/index.mjs CHANGED
@@ -1,235 +1,760 @@
1
1
  // src/index.ts
2
2
  import {
3
- ErrorCodes,
4
3
  InternalError,
5
4
  RateLimitError,
6
- ValidationError
5
+ ValidationError,
6
+ toErrorResponse
7
7
  } from "@latimer-woods-tech/errors";
8
+ var MODELS = {
9
+ anthropic: {
10
+ fast: "claude-haiku-4-20250514",
11
+ balanced: "claude-sonnet-4-6",
12
+ smart: "claude-opus-4-7"
13
+ },
14
+ gemini: {
15
+ smart: "gemini-2.5-pro"
16
+ },
17
+ groq: {
18
+ verifier: "llama-4-maverick"
19
+ },
20
+ grok: {
21
+ fast: "grok-4.3"
22
+ },
23
+ deepseek: {
24
+ workbench: "deepseek-chat"
25
+ }
26
+ };
8
27
  var DEFAULT_MAX_TOKENS = 1024;
9
28
  var DEFAULT_TEMPERATURE = 0.7;
10
- var DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514";
11
- var DEFAULT_GROK_MODEL = "grok-3-fast";
12
- var DEFAULT_GROQ_MODEL = "llama-3.3-70b-versatile";
13
- function isFailover(status) {
14
- return status === 429 || status >= 500;
29
+ var DEFAULT_LONG_CONTEXT_THRESHOLD = 15e4;
30
+ var BACKOFF_BASE_MS = 500;
31
+ var BACKOFF_CAP_MS = 8e3;
32
+ var BACKOFF_JITTER_MAX_MS = 250;
33
+ var PER_PROVIDER_MAX_ATTEMPTS = 3;
34
+ var providerCooldownUntil = /* @__PURE__ */ new Map();
35
+ var PROVIDER_COOLDOWN_MS = 3e4;
36
+ function isProviderCoolingDown(provider, now = Date.now) {
37
+ const until = providerCooldownUntil.get(provider);
38
+ if (until === void 0) return false;
39
+ return now() < until;
40
+ }
41
+ function isoDate(nowMs) {
42
+ return new Date(nowMs).toISOString().slice(0, 10);
43
+ }
44
+ async function recordOrgCostUsage(kv, todayKey, monthKey, costUsd, opts) {
45
+ if (opts.dailyCapUsd !== void 0) {
46
+ const raw = await kv.get(todayKey).catch(() => null);
47
+ const spent = parseFloat(raw ?? "0");
48
+ await kv.put(todayKey, String(spent + costUsd), {
49
+ expirationTtl: 172800
50
+ /* 48 h */
51
+ }).catch(() => void 0);
52
+ }
53
+ if (opts.monthlyCapUsd !== void 0) {
54
+ const raw = await kv.get(monthKey).catch(() => null);
55
+ const spent = parseFloat(raw ?? "0");
56
+ await kv.put(monthKey, String(spent + costUsd), {
57
+ expirationTtl: 3456e3
58
+ /* 40 d */
59
+ }).catch(() => void 0);
60
+ }
61
+ }
62
+ var MODEL_PRICE_PER_1M = {
63
+ // Anthropic Haiku 4
64
+ "claude-haiku-4-20250514": { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
65
+ "claude-haiku-4-5-20251001": { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
66
+ // Anthropic Sonnet 4
67
+ "claude-sonnet-4-20250514": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
68
+ "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
69
+ // Anthropic Opus 4
70
+ "claude-opus-4-20250514": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
71
+ "claude-opus-4-7": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
72
+ // Gemini 2.5 Pro
73
+ "gemini-2.5-pro": { input: 1.25, output: 10, cacheRead: 0.31, cacheWrite: 4.5 },
74
+ // Groq Llama 4 Maverick
75
+ "llama-4-maverick": { input: 0.5, output: 0.77, cacheRead: 0.05, cacheWrite: 0.5 },
76
+ // Grok 4.3
77
+ "grok-4.3": { input: 1.25, output: 2.5, cacheRead: 0, cacheWrite: 0 },
78
+ // DeepSeek API pricing as of 2026-05: cache-write conservatively uses cache-miss input pricing.
79
+ "deepseek-chat": { input: 0.27, output: 1.1, cacheRead: 0.07, cacheWrite: 0.27 },
80
+ "deepseek-reasoner": { input: 0.55, output: 2.19, cacheRead: 0.14, cacheWrite: 0.55 },
81
+ // Deprecated aliases retained for historical ledger rows.
82
+ "grok-4-fast": { input: 1.25, output: 2.5, cacheRead: 0, cacheWrite: 0 },
83
+ "grok-3-mini-latest": { input: 1.25, output: 2.5, cacheRead: 0, cacheWrite: 0 }
84
+ };
85
+ var PRICE_FALLBACK = MODEL_PRICE_PER_1M["claude-opus-4-7"];
86
+ function estimateCostUsd(tokens, model) {
87
+ const price = MODEL_PRICE_PER_1M[model] ?? PRICE_FALLBACK;
88
+ return (tokens.input * price.input + tokens.output * price.output + (tokens.cacheRead ?? 0) * price.cacheRead + (tokens.cacheWrite ?? 0) * price.cacheWrite) / 1e6;
89
+ }
90
+ function isoMonth(nowMs) {
91
+ return new Date(nowMs).toISOString().slice(0, 7);
92
+ }
93
+ function markProviderCoolingDown(provider, now = Date.now) {
94
+ providerCooldownUntil.set(provider, now() + PROVIDER_COOLDOWN_MS);
95
+ }
96
+ function clearProviderCooldown(provider) {
97
+ providerCooldownUntil.delete(provider);
98
+ }
99
+ var BASE_BACKOFF_MS = 250;
100
+ function isRetryableForBackoff(status) {
101
+ return status === 429 || status >= 500 && status < 600;
102
+ }
103
+ function estimateTokens(messages, system) {
104
+ let chars = system?.length ?? 0;
105
+ for (const m of messages) chars += m.content.length;
106
+ return Math.ceil(chars / 4);
107
+ }
108
+ function sleep(ms, signal) {
109
+ return new Promise((resolve, reject) => {
110
+ const t = setTimeout(resolve, ms);
111
+ if (signal) {
112
+ const onAbort = () => {
113
+ clearTimeout(t);
114
+ reject(new DOMException("Aborted", "AbortError"));
115
+ };
116
+ if (signal.aborted) onAbort();
117
+ else signal.addEventListener("abort", onAbort, { once: true });
118
+ }
119
+ });
15
120
  }
16
- function buildAnthropicBody(messages, opts, system) {
121
+ function computeBackoffMs(attempt) {
122
+ const jitter = Math.floor(Math.random() * BACKOFF_JITTER_MAX_MS);
123
+ return Math.min(BACKOFF_BASE_MS * Math.pow(2, attempt) + jitter, BACKOFF_CAP_MS);
124
+ }
125
+ function buildAnthropicRequest(model, messages, opts, env, streaming = false) {
126
+ const sys = opts.system ?? messages.find((m) => m.role === "system")?.content;
17
127
  const filtered = messages.filter((m) => m.role !== "system");
18
- const sys = system ?? messages.find((m) => m.role === "system")?.content;
19
128
  const body = {
20
- model: opts.model ?? DEFAULT_ANTHROPIC_MODEL,
129
+ model,
21
130
  max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
22
131
  temperature: opts.temperature ?? DEFAULT_TEMPERATURE,
23
132
  messages: filtered.map((m) => ({ role: m.role, content: m.content }))
24
133
  };
134
+ if (streaming) {
135
+ body.stream = true;
136
+ }
25
137
  if (sys) {
26
- body.system = sys;
138
+ const cache = opts.promptCache ?? sys.length >= 4096;
139
+ body.system = cache ? [{ type: "text", text: sys, cache_control: { type: "ephemeral" } }] : sys;
27
140
  }
28
- return JSON.stringify(body);
141
+ return {
142
+ url: `${env.AI_GATEWAY_BASE_URL}/anthropic/v1/messages`,
143
+ headers: {
144
+ "content-type": "application/json",
145
+ "x-api-key": env.ANTHROPIC_API_KEY,
146
+ "anthropic-version": "2023-06-01",
147
+ "anthropic-beta": "prompt-caching-2024-07-31"
148
+ },
149
+ body: JSON.stringify(body)
150
+ };
29
151
  }
30
- function buildOpenAIBody(model, messages, opts, system) {
31
- const sys = system ?? messages.find((m) => m.role === "system")?.content;
32
- const merged = [];
152
+ function buildGeminiRequest(model, messages, opts, env) {
153
+ const sys = opts.system ?? messages.find((m) => m.role === "system")?.content;
154
+ const contents = messages.filter((m) => m.role !== "system").map((m) => ({
155
+ role: m.role === "assistant" ? "model" : "user",
156
+ parts: [{ text: m.content }]
157
+ }));
158
+ const body = {
159
+ contents,
160
+ generationConfig: {
161
+ maxOutputTokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
162
+ temperature: opts.temperature ?? DEFAULT_TEMPERATURE
163
+ }
164
+ };
33
165
  if (sys) {
34
- merged.push({ role: "system", content: sys });
166
+ body.systemInstruction = { parts: [{ text: sys }] };
35
167
  }
36
- for (const m of messages) {
37
- if (m.role !== "system") {
38
- merged.push(m);
39
- }
168
+ const path = `v1/projects/${env.VERTEX_PROJECT}/locations/${env.VERTEX_LOCATION}/publishers/google/models/${model}:generateContent`;
169
+ return {
170
+ url: `${env.AI_GATEWAY_BASE_URL}/google-vertex-ai/${path}`,
171
+ headers: {
172
+ "content-type": "application/json",
173
+ authorization: `Bearer ${env.VERTEX_ACCESS_TOKEN}`
174
+ },
175
+ body: JSON.stringify(body)
176
+ };
177
+ }
178
+ function buildGroqRequest(model, messages, opts, env) {
179
+ const sys = opts.system ?? messages.find((m) => m.role === "system")?.content;
180
+ const merged = [];
181
+ if (sys) merged.push({ role: "system", content: sys });
182
+ for (const m of messages) if (m.role !== "system") merged.push(m);
183
+ return {
184
+ url: `${env.AI_GATEWAY_BASE_URL}/groq/openai/v1/chat/completions`,
185
+ headers: {
186
+ "content-type": "application/json",
187
+ authorization: `Bearer ${env.GROQ_API_KEY}`
188
+ },
189
+ body: JSON.stringify({
190
+ model,
191
+ max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
192
+ temperature: opts.temperature ?? DEFAULT_TEMPERATURE,
193
+ messages: merged
194
+ })
195
+ };
196
+ }
197
+ function buildGrokRequest(model, messages, opts, env) {
198
+ if (!env.GROK_API_KEY) {
199
+ throw new ValidationError("GROK_API_KEY required for grok-* model override");
40
200
  }
41
- return JSON.stringify({
201
+ const sys = opts.system ?? messages.find((m) => m.role === "system")?.content;
202
+ const merged = [];
203
+ if (sys) merged.push({ role: "system", content: sys });
204
+ for (const m of messages) if (m.role !== "system") merged.push(m);
205
+ const body = {
42
206
  model,
43
207
  max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
44
208
  temperature: opts.temperature ?? DEFAULT_TEMPERATURE,
45
209
  messages: merged
46
- });
210
+ };
211
+ if (model === MODELS.grok.fast) {
212
+ body.reasoning_effort = opts.reasoningEffort ?? "none";
213
+ }
214
+ return {
215
+ url: `${env.AI_GATEWAY_BASE_URL}/grok/v1/chat/completions`,
216
+ headers: {
217
+ "content-type": "application/json",
218
+ authorization: `Bearer ${env.GROK_API_KEY}`
219
+ },
220
+ body: JSON.stringify(body)
221
+ };
222
+ }
223
+ function buildDeepSeekRequest(model, messages, opts, env) {
224
+ if (!env.DEEPSEEK_API_KEY) {
225
+ throw new ValidationError("DEEPSEEK_API_KEY required for workbench tier or deepseek-* model override");
226
+ }
227
+ const sys = opts.system ?? messages.find((m) => m.role === "system")?.content;
228
+ const merged = [];
229
+ if (sys) merged.push({ role: "system", content: sys });
230
+ for (const m of messages) if (m.role !== "system") merged.push(m);
231
+ return {
232
+ url: `${env.AI_GATEWAY_BASE_URL}/deepseek/chat/completions`,
233
+ headers: {
234
+ "content-type": "application/json",
235
+ authorization: `Bearer ${env.DEEPSEEK_API_KEY}`
236
+ },
237
+ body: JSON.stringify({
238
+ model,
239
+ max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
240
+ temperature: opts.temperature ?? DEFAULT_TEMPERATURE,
241
+ messages: merged
242
+ })
243
+ };
47
244
  }
48
245
  function parseAnthropic(json) {
49
246
  const r = json;
50
- const text = r.content?.find((c) => c.type === "text")?.text ?? "";
51
247
  return {
52
- content: text,
248
+ content: r.content?.find((c) => c.type === "text")?.text ?? "",
53
249
  input: r.usage?.input_tokens ?? 0,
54
- output: r.usage?.output_tokens ?? 0
250
+ output: r.usage?.output_tokens ?? 0,
251
+ cacheRead: r.usage?.cache_read_input_tokens ?? 0,
252
+ cacheWrite: r.usage?.cache_creation_input_tokens ?? 0,
253
+ model: r.model
55
254
  };
56
255
  }
57
- function parseOpenAI(json) {
256
+ function parseGemini(json) {
58
257
  const r = json;
59
- const text = r.choices?.[0]?.message?.content ?? "";
258
+ const text = r.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("") ?? "";
60
259
  return {
61
260
  content: text,
261
+ input: r.usageMetadata?.promptTokenCount ?? 0,
262
+ output: r.usageMetadata?.candidatesTokenCount ?? 0
263
+ };
264
+ }
265
+ function parseGroq(json) {
266
+ const r = json;
267
+ return {
268
+ content: r.choices?.[0]?.message?.content ?? "",
62
269
  input: r.usage?.prompt_tokens ?? 0,
63
- output: r.usage?.completion_tokens ?? 0
270
+ output: r.usage?.completion_tokens ?? 0,
271
+ model: r.model
64
272
  };
65
273
  }
66
- async function callProvider(provider, request, fetchImpl) {
67
- const response = await fetchImpl(request.url, {
68
- method: "POST",
69
- headers: request.headers,
70
- body: request.body
71
- });
72
- if (!response.ok) {
73
- const text = await response.text().catch(() => "");
74
- const err = {
75
- status: response.status,
76
- message: `${provider} request failed (${String(response.status)}): ${text.slice(0, 200)}`
77
- };
274
+ async function callWithBackoff(provider, request, fetchImpl, signal, logger, nowFn) {
275
+ function exhaustAndThrow(err) {
276
+ markProviderCoolingDown(provider, nowFn ?? Date.now);
78
277
  throw err;
79
278
  }
80
- return await response.json();
279
+ let lastErr;
280
+ for (let attempt = 1; attempt <= PER_PROVIDER_MAX_ATTEMPTS; attempt++) {
281
+ try {
282
+ const response = await fetchImpl(request.url, {
283
+ method: "POST",
284
+ headers: request.headers,
285
+ body: request.body,
286
+ signal
287
+ });
288
+ if (!response.ok) {
289
+ const text = await response.text().catch(() => "");
290
+ const retryable = isRetryableForBackoff(response.status);
291
+ const err = {
292
+ provider,
293
+ status: response.status,
294
+ retryable,
295
+ message: `${provider} ${String(response.status)}: ${text.slice(0, 300)}`
296
+ };
297
+ logger?.warn?.("llm.provider.error", { provider, status: response.status, attempt });
298
+ if (!err.retryable || attempt === PER_PROVIDER_MAX_ATTEMPTS) {
299
+ if (err.retryable) exhaustAndThrow(err);
300
+ throw err;
301
+ }
302
+ lastErr = err;
303
+ } else {
304
+ const gatewayRequestId = response.headers.get("cf-aig-request-id") ?? void 0;
305
+ clearProviderCooldown(provider);
306
+ return { json: await response.json(), gatewayRequestId, attempts: attempt };
307
+ }
308
+ } catch (e) {
309
+ if (e instanceof DOMException && e.name === "AbortError") throw e;
310
+ if (typeof e === "object" && e !== null && "retryable" in e) {
311
+ const err = e;
312
+ if (!err.retryable || attempt === PER_PROVIDER_MAX_ATTEMPTS) {
313
+ if (err.retryable) exhaustAndThrow(err);
314
+ throw err;
315
+ }
316
+ lastErr = err;
317
+ } else {
318
+ const err = {
319
+ provider,
320
+ status: 0,
321
+ retryable: true,
322
+ message: e instanceof Error ? e.message : String(e)
323
+ };
324
+ if (attempt === PER_PROVIDER_MAX_ATTEMPTS) exhaustAndThrow(err);
325
+ lastErr = err;
326
+ }
327
+ }
328
+ const backoffMs = computeBackoffMs(attempt - 1);
329
+ await sleep(backoffMs, signal);
330
+ }
331
+ markProviderCoolingDown(provider, nowFn ?? Date.now);
332
+ throw lastErr ?? { provider, status: 0, retryable: false, message: "exhausted" };
81
333
  }
82
334
  function isProviderError(err) {
83
- return typeof err === "object" && err !== null && typeof err.status === "number" && typeof err.message === "string";
335
+ return typeof err === "object" && err !== null && typeof err.status === "number" && typeof err.message === "string" && typeof err.provider === "string";
336
+ }
337
+ function plan(tier, opts, tokenEstimate) {
338
+ if (opts.model) {
339
+ const m = opts.model;
340
+ if (m.startsWith("claude")) return { primary: { provider: "anthropic", model: m } };
341
+ if (m.startsWith("gemini")) return { primary: { provider: "gemini", model: m } };
342
+ if (m.startsWith("grok")) return { primary: { provider: "grok", model: m } };
343
+ if (m.startsWith("deepseek")) return { primary: { provider: "deepseek", model: m } };
344
+ return { primary: { provider: "groq", model: m } };
345
+ }
346
+ const longContext = tokenEstimate >= (opts.longContextThreshold ?? DEFAULT_LONG_CONTEXT_THRESHOLD);
347
+ switch (tier) {
348
+ case "workbench":
349
+ return {
350
+ primary: { provider: "deepseek", model: MODELS.deepseek.workbench },
351
+ fallback: { provider: "groq", model: MODELS.groq.verifier }
352
+ };
353
+ case "verifier":
354
+ return { primary: { provider: "groq", model: MODELS.groq.verifier } };
355
+ case "smart":
356
+ return longContext ? {
357
+ primary: { provider: "gemini", model: MODELS.gemini.smart },
358
+ fallback: { provider: "anthropic", model: MODELS.anthropic.smart }
359
+ } : {
360
+ primary: { provider: "anthropic", model: MODELS.anthropic.smart },
361
+ fallback: { provider: "gemini", model: MODELS.gemini.smart }
362
+ };
363
+ case "fast":
364
+ return {
365
+ primary: { provider: "grok", model: MODELS.grok.fast },
366
+ fallback: { provider: "anthropic", model: MODELS.anthropic.fast }
367
+ };
368
+ case "balanced":
369
+ default:
370
+ return longContext ? {
371
+ primary: { provider: "gemini", model: MODELS.gemini.smart },
372
+ fallback: { provider: "anthropic", model: MODELS.anthropic.balanced }
373
+ } : {
374
+ primary: { provider: "anthropic", model: MODELS.anthropic.balanced },
375
+ fallback: { provider: "gemini", model: MODELS.gemini.smart }
376
+ };
377
+ }
378
+ }
379
+ function buildAigMetadata(opts) {
380
+ const meta = {};
381
+ if (opts.project) meta.project = opts.project;
382
+ if (opts.workload) meta.workload = opts.workload;
383
+ if (opts.actor) meta.actor = opts.actor;
384
+ if (opts.runId) meta.runId = opts.runId;
385
+ return Object.keys(meta).length > 0 ? JSON.stringify(meta) : void 0;
386
+ }
387
+ async function callOne(leg, messages, opts, env, fetchImpl, logger, nowFn) {
388
+ let req;
389
+ switch (leg.provider) {
390
+ case "anthropic":
391
+ req = buildAnthropicRequest(leg.model, messages, opts, env);
392
+ break;
393
+ case "gemini":
394
+ req = buildGeminiRequest(leg.model, messages, opts, env);
395
+ break;
396
+ case "groq":
397
+ req = buildGroqRequest(leg.model, messages, opts, env);
398
+ break;
399
+ case "grok":
400
+ req = buildGrokRequest(leg.model, messages, opts, env);
401
+ break;
402
+ case "deepseek":
403
+ req = buildDeepSeekRequest(leg.model, messages, opts, env);
404
+ break;
405
+ }
406
+ const aigMetadata = buildAigMetadata(opts);
407
+ if (aigMetadata) req.headers["cf-aig-metadata"] = aigMetadata;
408
+ const { json, gatewayRequestId, attempts } = await callWithBackoff(
409
+ leg.provider,
410
+ req,
411
+ fetchImpl,
412
+ opts.signal,
413
+ logger,
414
+ nowFn
415
+ );
416
+ switch (leg.provider) {
417
+ case "anthropic":
418
+ return { parsed: parseAnthropic(json), gatewayRequestId, attempts };
419
+ case "gemini":
420
+ return { parsed: parseGemini(json), gatewayRequestId, attempts };
421
+ case "groq":
422
+ return { parsed: parseGroq(json), gatewayRequestId, attempts };
423
+ case "grok":
424
+ return { parsed: parseGroq(json), gatewayRequestId, attempts };
425
+ case "deepseek":
426
+ return { parsed: parseGroq(json), gatewayRequestId, attempts };
427
+ }
84
428
  }
85
429
  async function complete(messages, env, opts = {}, deps = {}) {
86
430
  if (messages.length === 0) {
87
431
  throw new ValidationError("messages must not be empty");
88
432
  }
433
+ if (!env.AI_GATEWAY_BASE_URL) {
434
+ throw new ValidationError("AI_GATEWAY_BASE_URL is required in 0.3.0");
435
+ }
89
436
  const fetchImpl = deps.fetch ?? fetch;
90
437
  const now = deps.now ?? (() => Date.now());
91
438
  const logger = deps.logger;
92
439
  const startedAt = now();
93
- const attempts = [];
94
- try {
95
- const json = await callProvider(
96
- "anthropic",
97
- {
98
- url: "https://api.anthropic.com/v1/messages",
99
- headers: {
100
- "content-type": "application/json",
101
- "x-api-key": env.ANTHROPIC_API_KEY,
102
- "anthropic-version": "2023-06-01"
103
- },
104
- body: buildAnthropicBody(messages, opts, opts.system)
105
- },
106
- fetchImpl
107
- );
108
- const parsed = parseAnthropic(json);
109
- return {
110
- data: {
111
- content: parsed.content,
112
- provider: "anthropic",
113
- tokens: { input: parsed.input, output: parsed.output },
114
- latency: now() - startedAt
115
- },
116
- error: null
117
- };
118
- } catch (err) {
119
- const status = isProviderError(err) ? err.status : 0;
120
- const message = isProviderError(err) ? err.message : err.message;
121
- attempts.push({ provider: "anthropic", status, message });
122
- if (status !== 0 && !isFailover(status)) {
123
- return providerErrorResponse(attempts, status === 429);
440
+ const tier = opts.tier ?? "balanced";
441
+ const system = opts.system ?? messages.find((m) => m.role === "system")?.content;
442
+ const tokenEstimate = estimateTokens(messages, system);
443
+ const route = plan(tier, opts, tokenEstimate);
444
+ const kv = env.LLM_COST_KV;
445
+ const todayKey = `llm:daily-cost:${isoDate(now())}`;
446
+ const monthKey = `llm:monthly-cost:${isoMonth(now())}`;
447
+ if (kv) {
448
+ if (opts.dailyCapUsd !== void 0) {
449
+ const raw = await kv.get(todayKey).catch(() => null);
450
+ const spent = parseFloat(raw ?? "0");
451
+ if (spent >= opts.dailyCapUsd) {
452
+ return toErrorResponse(
453
+ new RateLimitError("LLM_DAILY_CAP_EXCEEDED", {
454
+ spentUsd: spent,
455
+ dailyCapUsd: opts.dailyCapUsd
456
+ })
457
+ );
458
+ }
459
+ }
460
+ if (opts.monthlyCapUsd !== void 0) {
461
+ const raw = await kv.get(monthKey).catch(() => null);
462
+ const spent = parseFloat(raw ?? "0");
463
+ if (spent >= opts.monthlyCapUsd) {
464
+ return toErrorResponse(
465
+ new RateLimitError("LLM_MONTHLY_CAP_EXCEEDED", {
466
+ spentUsd: spent,
467
+ monthlyCapUsd: opts.monthlyCapUsd
468
+ })
469
+ );
470
+ }
124
471
  }
125
- logger?.warn("llm.failover", { from: "anthropic", to: "grok", status, message });
126
- }
127
- try {
128
- const json = await callProvider(
129
- "grok",
130
- {
131
- url: "https://api.x.ai/v1/chat/completions",
132
- headers: {
133
- "content-type": "application/json",
134
- authorization: `Bearer ${env.GROK_API_KEY}`
135
- },
136
- body: buildOpenAIBody(opts.model ?? DEFAULT_GROK_MODEL, messages, opts, opts.system)
137
- },
138
- fetchImpl
139
- );
140
- const parsed = parseOpenAI(json);
141
- return {
142
- data: {
143
- content: parsed.content,
144
- provider: "grok",
145
- tokens: { input: parsed.input, output: parsed.output },
146
- latency: now() - startedAt
147
- },
148
- error: null
149
- };
150
- } catch (err) {
151
- const status = isProviderError(err) ? err.status : 0;
152
- const message = isProviderError(err) ? err.message : err.message;
153
- attempts.push({ provider: "grok", status, message });
154
- logger?.warn("llm.failover", { from: "grok", to: "groq", status, message });
155
472
  }
156
- try {
157
- const json = await callProvider(
158
- "groq",
159
- {
160
- url: "https://api.groq.com/openai/v1/chat/completions",
161
- headers: {
162
- "content-type": "application/json",
163
- authorization: `Bearer ${env.GROQ_API_KEY}`
473
+ const attemptLog = [];
474
+ const routeLegs = [route.primary, route.fallback].filter(Boolean);
475
+ for (const [legIndex, leg] of routeLegs.entries()) {
476
+ if (isProviderCoolingDown(leg.provider, now)) {
477
+ logger?.warn?.("llm.provider.coolingDown", { provider: leg.provider });
478
+ attemptLog.push({ provider: leg.provider, message: "skipped: cooling down" });
479
+ continue;
480
+ }
481
+ if (opts.signal?.aborted) {
482
+ return toErrorResponse(
483
+ new InternalError("llm call aborted", { provider: leg.provider, model: leg.model })
484
+ );
485
+ }
486
+ try {
487
+ const result = await callOne(leg, messages, opts, env, fetchImpl, logger, now);
488
+ if (!result.parsed.content) {
489
+ throw { provider: leg.provider, status: 200, retryable: false, message: "empty content" };
490
+ }
491
+ logger?.info?.("llm.complete", {
492
+ provider: leg.provider,
493
+ model: leg.model,
494
+ tier,
495
+ tokenEstimate,
496
+ attempts: result.attempts,
497
+ runId: opts.runId,
498
+ project: opts.project,
499
+ actor: opts.actor,
500
+ workload: opts.workload
501
+ });
502
+ const llmResult = {
503
+ content: result.parsed.content,
504
+ provider: leg.provider,
505
+ model: result.parsed.model ?? leg.model,
506
+ tier,
507
+ tokens: {
508
+ input: result.parsed.input,
509
+ output: result.parsed.output,
510
+ cacheRead: result.parsed.cacheRead,
511
+ cacheWrite: result.parsed.cacheWrite
164
512
  },
165
- body: buildOpenAIBody(opts.model ?? DEFAULT_GROQ_MODEL, messages, opts, opts.system)
166
- },
167
- fetchImpl
168
- );
169
- const parsed = parseOpenAI(json);
170
- return {
171
- data: {
172
- content: parsed.content,
173
- provider: "groq",
174
- tokens: { input: parsed.input, output: parsed.output },
175
- latency: now() - startedAt
176
- },
177
- error: null
178
- };
179
- } catch (err) {
180
- const status = isProviderError(err) ? err.status : 0;
181
- const message = isProviderError(err) ? err.message : err.message;
182
- attempts.push({ provider: "groq", status, message });
183
- logger?.error("llm.all_providers_failed", void 0, { attempts });
184
- }
185
- return providerErrorResponse(attempts, false);
186
- }
187
- function providerErrorResponse(attempts, rateLimited) {
188
- const base = rateLimited ? new RateLimitError("LLM provider rate limited", { code: ErrorCodes.LLM_RATE_LIMITED, attempts }) : new InternalError("All LLM providers failed", {
189
- code: ErrorCodes.LLM_ALL_PROVIDERS_FAILED,
190
- attempts
191
- });
192
- return {
193
- data: null,
194
- error: {
195
- code: rateLimited ? ErrorCodes.LLM_RATE_LIMITED : ErrorCodes.LLM_ALL_PROVIDERS_FAILED,
196
- message: base.message,
197
- status: base.status,
198
- retryable: base.retryable,
199
- context: base.context
513
+ latency: now() - startedAt,
514
+ attempts: result.attempts,
515
+ gatewayRequestId: result.gatewayRequestId
516
+ };
517
+ const costUsd = estimateCostUsd(llmResult.tokens, llmResult.model);
518
+ if (opts.maxCostUsd !== void 0 && costUsd > opts.maxCostUsd) {
519
+ if (kv && (opts.dailyCapUsd !== void 0 || opts.monthlyCapUsd !== void 0)) {
520
+ await recordOrgCostUsage(kv, todayKey, monthKey, costUsd, opts);
521
+ }
522
+ return toErrorResponse(
523
+ new RateLimitError("LLM_COST_CAP_EXCEEDED", {
524
+ costUsd,
525
+ maxCostUsd: opts.maxCostUsd,
526
+ model: llmResult.model,
527
+ tokens: llmResult.tokens
528
+ })
529
+ );
530
+ }
531
+ if (kv && (opts.dailyCapUsd !== void 0 || opts.monthlyCapUsd !== void 0)) {
532
+ await recordOrgCostUsage(kv, todayKey, monthKey, costUsd, opts);
533
+ }
534
+ if (deps.onRecord && opts.ledger) {
535
+ const row = {
536
+ ...opts.ledger,
537
+ model: llmResult.model,
538
+ provider: llmResult.provider,
539
+ tier: llmResult.tier,
540
+ inputTokens: llmResult.tokens.input,
541
+ outputTokens: llmResult.tokens.output,
542
+ cacheReadTokens: llmResult.tokens.cacheRead ?? 0,
543
+ cacheWriteTokens: llmResult.tokens.cacheWrite ?? 0,
544
+ latencyMs: llmResult.latency,
545
+ costUsd,
546
+ yyyyMm: isoMonth(now())
547
+ };
548
+ deps.onRecord(row).catch((e) => {
549
+ logger?.warn?.("llm.onRecord.error", { message: e instanceof Error ? e.message : String(e) });
550
+ });
551
+ }
552
+ return { data: llmResult, error: null };
553
+ } catch (e) {
554
+ if (e instanceof DOMException && e.name === "AbortError") {
555
+ return toErrorResponse(
556
+ new InternalError("llm call aborted", { provider: leg.provider, model: leg.model })
557
+ );
558
+ }
559
+ if (isProviderError(e)) {
560
+ attemptLog.push({ provider: e.provider, status: e.status, message: e.message });
561
+ if (e.status === 429 && legIndex === routeLegs.length - 1) {
562
+ return toErrorResponse(
563
+ new RateLimitError(`llm rate limited on ${e.provider}`, { attempts: attemptLog })
564
+ );
565
+ }
566
+ logger?.warn?.("llm.leg.failed", { provider: leg.provider, status: e.status });
567
+ continue;
568
+ }
569
+ attemptLog.push({ provider: leg.provider, message: e instanceof Error ? e.message : String(e) });
200
570
  }
201
- };
571
+ }
572
+ return toErrorResponse(
573
+ new InternalError("LLM_ALL_PROVIDERS_FAILED", { attempts: attemptLog, tier, tokenEstimate })
574
+ );
202
575
  }
203
- async function stream(messages, env, opts = {}, deps = {}) {
576
+ async function* completionStream(messages, env, opts = {}) {
204
577
  if (messages.length === 0) {
205
578
  throw new ValidationError("messages must not be empty");
206
579
  }
580
+ if (!env.AI_GATEWAY_BASE_URL) {
581
+ throw new ValidationError("AI_GATEWAY_BASE_URL is required in 0.3.0");
582
+ }
583
+ const deps = opts.deps ?? {};
207
584
  const fetchImpl = deps.fetch ?? fetch;
208
- const body = JSON.parse(buildAnthropicBody(messages, opts, opts.system));
209
- body.stream = true;
210
- const response = await fetchImpl("https://api.anthropic.com/v1/messages", {
211
- method: "POST",
212
- headers: {
213
- "content-type": "application/json",
214
- "x-api-key": env.ANTHROPIC_API_KEY,
215
- "anthropic-version": "2023-06-01"
216
- },
217
- body: JSON.stringify(body)
218
- });
219
- if (!response.ok || !response.body) {
220
- throw new InternalError("Anthropic stream failed", {
221
- code: ErrorCodes.LLM_ALL_PROVIDERS_FAILED,
222
- status: response.status
585
+ const now = deps.now ?? (() => Date.now());
586
+ const logger = deps.logger;
587
+ const startedAt = now();
588
+ const tier = opts.tier ?? "balanced";
589
+ const system = opts.system ?? messages.find((m) => m.role === "system")?.content;
590
+ const tokenEstimate = estimateTokens(messages, system);
591
+ const route = plan(tier, opts, tokenEstimate);
592
+ const streamLeg = route.primary.provider === "grok" && !env.GROK_API_KEY && route.fallback?.provider === "anthropic" ? route.fallback : route.primary;
593
+ if (streamLeg.provider !== "anthropic") {
594
+ const result = await complete(messages, env, opts, deps);
595
+ if (result.error !== null || result.data === null) {
596
+ throw new InternalError("LLM_ALL_PROVIDERS_FAILED", { error: result.error });
597
+ }
598
+ yield result.data.content;
599
+ return result.data;
600
+ }
601
+ if (isProviderCoolingDown(streamLeg.provider, now)) {
602
+ logger?.warn?.("llm.provider.coolingDown", { provider: streamLeg.provider });
603
+ const result = await complete(messages, env, opts, deps);
604
+ if (result.error !== null || result.data === null) {
605
+ throw new InternalError("LLM_ALL_PROVIDERS_FAILED", { error: result.error });
606
+ }
607
+ yield result.data.content;
608
+ return result.data;
609
+ }
610
+ const req = buildAnthropicRequest(streamLeg.model, messages, opts, env, true);
611
+ const streamAigMetadata = buildAigMetadata(opts);
612
+ if (streamAigMetadata) req.headers["cf-aig-metadata"] = streamAigMetadata;
613
+ let response;
614
+ try {
615
+ response = await fetchImpl(req.url, {
616
+ method: "POST",
617
+ headers: req.headers,
618
+ body: req.body,
619
+ // Fall back to a 60 s default when the caller provides no signal — prevents
620
+ // a hung provider connection from consuming the Worker's wall-clock budget.
621
+ signal: opts.signal ?? AbortSignal.timeout(6e4)
622
+ });
623
+ } catch (e) {
624
+ if (e instanceof DOMException && e.name === "AbortError") {
625
+ throw new InternalError("llm call aborted", {
626
+ provider: streamLeg.provider,
627
+ model: streamLeg.model
628
+ });
629
+ }
630
+ throw new InternalError("llm stream fetch failed", {
631
+ message: e instanceof Error ? e.message : String(e)
223
632
  });
224
633
  }
225
- return response.body;
634
+ if (!response.ok) {
635
+ const text = await response.text().catch(() => "");
636
+ const retryable = isRetryableForBackoff(response.status);
637
+ if (retryable && response.status === 429) {
638
+ markProviderCoolingDown(streamLeg.provider, now);
639
+ }
640
+ const result = await complete(messages, env, opts, deps);
641
+ if (result.error !== null || result.data === null) {
642
+ throw new InternalError("LLM_ALL_PROVIDERS_FAILED", {
643
+ streamError: `${streamLeg.provider} ${String(response.status)}: ${text.slice(0, 300)}`,
644
+ error: result.error
645
+ });
646
+ }
647
+ yield result.data.content;
648
+ return result.data;
649
+ }
650
+ if (!response.body) {
651
+ throw new InternalError("llm stream response body is null", {
652
+ provider: streamLeg.provider
653
+ });
654
+ }
655
+ const decoder = new TextDecoder();
656
+ let accumulatedText = "";
657
+ let inputTokens = 0;
658
+ let outputTokens = 0;
659
+ let cacheRead = 0;
660
+ let cacheWrite = 0;
661
+ let modelName;
662
+ const gatewayRequestId = response.headers.get("cf-aig-request-id") ?? void 0;
663
+ const reader = response.body.getReader();
664
+ let buffer = "";
665
+ try {
666
+ while (true) {
667
+ const { done, value } = await reader.read();
668
+ if (done) break;
669
+ buffer += decoder.decode(value, { stream: true });
670
+ const lines = buffer.split("\n");
671
+ buffer = lines.pop() ?? "";
672
+ for (const line of lines) {
673
+ if (!line.startsWith("data: ")) continue;
674
+ const data = line.slice(6).trim();
675
+ if (data === "[DONE]") break;
676
+ let event;
677
+ try {
678
+ event = JSON.parse(data);
679
+ } catch {
680
+ continue;
681
+ }
682
+ switch (event.type) {
683
+ case "message_start":
684
+ inputTokens = event.message?.usage?.input_tokens ?? 0;
685
+ cacheRead = event.message?.usage?.cache_read_input_tokens ?? 0;
686
+ cacheWrite = event.message?.usage?.cache_creation_input_tokens ?? 0;
687
+ modelName = event.message?.model;
688
+ break;
689
+ case "content_block_delta":
690
+ if (event.delta?.type === "text_delta" && typeof event.delta.text === "string") {
691
+ accumulatedText += event.delta.text;
692
+ yield event.delta.text;
693
+ }
694
+ break;
695
+ case "message_delta":
696
+ outputTokens = event.usage?.output_tokens ?? outputTokens;
697
+ break;
698
+ default:
699
+ break;
700
+ }
701
+ }
702
+ }
703
+ } finally {
704
+ reader.releaseLock();
705
+ }
706
+ clearProviderCooldown(streamLeg.provider);
707
+ logger?.info?.("llm.completionStream", {
708
+ provider: streamLeg.provider,
709
+ model: streamLeg.model,
710
+ tier,
711
+ tokenEstimate,
712
+ runId: opts.runId,
713
+ project: opts.project,
714
+ actor: opts.actor,
715
+ workload: opts.workload
716
+ });
717
+ return {
718
+ content: accumulatedText,
719
+ provider: streamLeg.provider,
720
+ model: modelName ?? streamLeg.model,
721
+ tier,
722
+ tokens: { input: inputTokens, output: outputTokens, cacheRead, cacheWrite },
723
+ latency: now() - startedAt,
724
+ attempts: 1,
725
+ gatewayRequestId
726
+ };
226
727
  }
227
- function withSystem(system) {
228
- return (messages, env, opts = {}, deps = {}) => complete(messages, env, { ...opts, system }, deps);
728
+ function assertGrounding(response, sources) {
729
+ if (sources.length === 0) return true;
730
+ const WINDOW = 5;
731
+ const responseTokens = response.split(/\s+/).filter((t) => t.length > 0);
732
+ if (responseTokens.length < WINDOW) return false;
733
+ const sourceNgrams = /* @__PURE__ */ new Set();
734
+ for (const source of sources) {
735
+ const tokens = source.split(/\s+/).filter((t) => t.length > 0);
736
+ for (let i = 0; i <= tokens.length - WINDOW; i++) {
737
+ const ngram = tokens.slice(i, i + WINDOW).join(" ");
738
+ sourceNgrams.add(ngram);
739
+ }
740
+ }
741
+ if (sourceNgrams.size === 0) return false;
742
+ for (let i = 0; i <= responseTokens.length - WINDOW; i++) {
743
+ const ngram = responseTokens.slice(i, i + WINDOW).join(" ");
744
+ if (sourceNgrams.has(ngram)) return true;
745
+ }
746
+ return false;
229
747
  }
230
748
  export {
749
+ BASE_BACKOFF_MS,
750
+ MODELS,
751
+ MODEL_PRICE_PER_1M,
752
+ PROVIDER_COOLDOWN_MS,
753
+ assertGrounding,
754
+ clearProviderCooldown,
231
755
  complete,
232
- stream,
233
- withSystem
756
+ completionStream,
757
+ isProviderCoolingDown,
758
+ markProviderCoolingDown
234
759
  };
235
760
  //# sourceMappingURL=index.mjs.map