vern-llm 1.7.1 → 2.1.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/dist/index.js CHANGED
@@ -2,297 +2,23 @@ import { randomUUID } from "crypto";
2
2
 
3
3
  //#region src/types/errors.ts
4
4
  var LLMError = class extends Error {
5
- constructor(message, type, status, issues, cause, retryAfterMs) {
5
+ constructor(message, type, status, issues, cause, retryAfterMs, code) {
6
6
  super(message);
7
7
  this.type = type;
8
8
  this.status = status;
9
9
  this.issues = issues;
10
10
  this.cause = cause;
11
11
  this.retryAfterMs = retryAfterMs;
12
+ this.code = code;
12
13
  this.name = "LLMError";
13
14
  }
15
+ /** Every tool contract failure in one response, when there is more than one. */
16
+ toolIssues;
14
17
  };
15
18
  function isLLMError(err) {
16
19
  return err instanceof LLMError;
17
20
  }
18
21
 
19
- //#endregion
20
- //#region src/circuitBreaker.ts
21
- /**
22
- * Per retry VernLLM-instance circuit breaker. Tracks consecutive failures across
23
- * calls. Once the threshold is hit, short-circuits new calls with an
24
- * LLMError('circuit_open') instead of hitting the provider, until the
25
- * cooldown elapses and a single trial call is allowed through
26
- */
27
- var CircuitBreaker = class {
28
- state = "closed";
29
- consecutiveFailures = 0;
30
- openedAt = 0;
31
- threshold;
32
- cooldownMs;
33
- /**
34
- * True while a single half-open trial call is in flight. Guards against
35
- * multiple concurrent callers all treating themselves as "the" trial once
36
- * the cooldown elapses
37
- */
38
- trialInFlight = false;
39
- constructor(options = {}) {
40
- this.threshold = options.threshold ?? 5;
41
- this.cooldownMs = options.cooldownMs ?? 3e4;
42
- }
43
- /**
44
- * Throws if the circuit is open and the cooldown hasn't elapsed, or if
45
- * the circuit is half-open and a trial call is already in flight.
46
- * Otherwise, if the circuit just became eligible for a trial (cooldown
47
- * elapsed, or half-open with no trial currently running), this call
48
- * becomes that trial
49
- */
50
- assertClosed() {
51
- if (this.state === "closed") return;
52
- if (this.state === "open") {
53
- const elapsed = Date.now() - this.openedAt;
54
- if (elapsed < this.cooldownMs) throw new LLMError(`Circuit open — provider has failed ${this.consecutiveFailures} times in a row. Retry in ${Math.ceil((this.cooldownMs - elapsed) / 1e3)}s.`, "circuit_open");
55
- this.state = "half-open";
56
- this.trialInFlight = true;
57
- return;
58
- }
59
- if (this.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open");
60
- this.trialInFlight = true;
61
- }
62
- recordSuccess() {
63
- this.consecutiveFailures = 0;
64
- this.state = "closed";
65
- this.trialInFlight = false;
66
- }
67
- recordFailure() {
68
- this.consecutiveFailures += 1;
69
- this.trialInFlight = false;
70
- if (this.state === "half-open") {
71
- this.state = "open";
72
- this.openedAt = Date.now();
73
- return;
74
- }
75
- if (this.consecutiveFailures >= this.threshold) {
76
- this.state = "open";
77
- this.openedAt = Date.now();
78
- }
79
- }
80
- getState() {
81
- return this.state;
82
- }
83
- };
84
-
85
- //#endregion
86
- //#region src/internal/vernLLM.utils.ts
87
- function defaultParseJson(content) {
88
- try {
89
- return JSON.parse(content);
90
- } catch {
91
- return void 0;
92
- }
93
- }
94
- /**
95
- * Looks inside an unknown error value and pulls out an http status code
96
- * if one is present. Checks the status field first then the status code
97
- * field since different client libraries use different names for this.
98
- * Returns undefined when the error is not an object or carries no status
99
- */
100
- function extractStatus(err) {
101
- if (!err || typeof err !== "object") return void 0;
102
- const error = err;
103
- if (typeof error.status === "number") return error.status;
104
- if (typeof error.statusCode === "number") return error.statusCode;
105
- return void 0;
106
- }
107
- function formatSafely(value) {
108
- try {
109
- return JSON.stringify(value, null, 2) ?? String(value);
110
- } catch {
111
- try {
112
- return String(value);
113
- } catch {
114
- return "[unprintable error]";
115
- }
116
- }
117
- }
118
- /**
119
- * Looks inside an unknown thrown value and pulls out a human-readable
120
- * description of it. Checks the `error` field first (the provider's raw
121
- * rejection body, JSON-stringified if possible) then falls back to the
122
- * message` field. Always returns a safe string, even when the thrown value
123
- * has hostile properties or cannot be serialized normally.
124
- */
125
- function describeError(err) {
126
- if (err && typeof err === "object") try {
127
- const error = err;
128
- if (error.error !== void 0) return formatSafely(error.error);
129
- if (typeof error.message === "string") return error.message;
130
- } catch {}
131
- return formatSafely(err);
132
- }
133
- /**
134
- * Runs an async function and cancels it if it takes longer than the given
135
- * timeout. Creates an internal abort controller that fires after the
136
- * timeout elapses, and combines it with any external signal the caller
137
- * passed in so either one can cancel the underlying call. If the internal
138
- * timeout triggers and the underlying operation aborts, the error is
139
- * converted into an LLMError with type "timeout". External cancellations
140
- * continue to propagate as aborted errors. The internal timer is always
141
- * cleared afterward, whether the function succeeds, fails, or is aborted,
142
- * so nothing is left running in the background.
143
- */
144
- async function withTimeout(fn, timeoutMs, externalSignal) {
145
- const controller = new AbortController();
146
- const timer = setTimeout(() => {
147
- controller.abort();
148
- }, timeoutMs);
149
- const signal = externalSignal ? AbortSignal.any([externalSignal, controller.signal]) : controller.signal;
150
- try {
151
- return await fn(signal);
152
- } catch (err) {
153
- if (controller.signal.aborted && !externalSignal?.aborted && err instanceof DOMException && err.name === "AbortError") throw new LLMError("Request timed out", "timeout");
154
- throw err;
155
- } finally {
156
- clearTimeout(timer);
157
- }
158
- }
159
- /**
160
- * Default cap (ms) for both exponential backoff and honored Retry-After
161
- * values, so a misbehaving/adversarial Retry-After can't stall a caller
162
- * indefinitely
163
- */
164
- const DEFAULT_MAX_DELAY_MS = 1e4;
165
- /**
166
- * Looks inside an unknown error value for a Retry-After header and
167
- * converts it to milliseconds. Checks `.headers` first (fetch-style,
168
- * Headers-like with `.get()`), then `.response.headers` (axios-style,
169
- * plain object) since different client libraries surface headers
170
- * differently. Supports both the delta-seconds form ("30") and the
171
- * HTTP-date form ("Wed, 21 Oct 2015 07:28:00 GMT"). The result is capped
172
- * at maxDelayMs. Returns undefined when no usable Retry-After is present
173
- */
174
- function extractRetryAfterMs(err, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
175
- if (!err || typeof err !== "object") return void 0;
176
- const error = err;
177
- const headers = error.headers ?? error.response?.headers;
178
- if (!headers || typeof headers !== "object") return void 0;
179
- const getter = headers;
180
- const raw = typeof getter.get === "function" ? getter.get("Retry-After") : Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.at(1);
181
- if (typeof raw !== "string" || raw.trim() === "") return void 0;
182
- const trimmed = raw.trim();
183
- if (/^\d+$/.test(trimmed)) return Math.max(0, Math.min(Number(trimmed) * 1e3, maxDelayMs));
184
- const dateMs = Date.parse(trimmed);
185
- if (!Number.isNaN(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), maxDelayMs));
186
- return void 0;
187
- }
188
- /** Converts any thrown value into a well-typed LLMError. */
189
- function normalizeError(error, signal) {
190
- if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
191
- if (error instanceof LLMError) return error;
192
- const status = extractStatus(error);
193
- const retryAfterMs = extractRetryAfterMs(error);
194
- if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs);
195
- return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
196
- }
197
- /**
198
- * Exponential backoff with jitter, capped at maxDelayMs.
199
- * Jitter avoids thundering-herd retries when many callers back off in lockstep,
200
- * the cap prevents unbounded delays when maxRetries is high
201
- */
202
- function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
203
- const exp = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
204
- return exp / 2 + Math.random() * (exp / 2);
205
- }
206
- /**
207
- * Pauses execution for the given delay before a retry attempt. If an
208
- * abort signal is provided and it fires while waiting, the pending
209
- * timer is cancelled immediately and the wait rejects right away with
210
- * an aborted error instead of continuing to sit idle until the delay
211
- * would have finished on its own
212
- */
213
- async function waitForRetry(delay, signal) {
214
- await new Promise((resolve, reject) => {
215
- const onAbort = () => {
216
- clearTimeout(timer);
217
- reject(new LLMError("Operation aborted", "aborted"));
218
- };
219
- const timer = setTimeout(() => {
220
- signal?.removeEventListener("abort", onAbort);
221
- resolve();
222
- }, delay);
223
- signal?.addEventListener("abort", onAbort, { once: true });
224
- });
225
- }
226
- /**
227
- * Runs `getResult` after reserving usage, if a `reserveUsage` hook was
228
- * provided. `refundUsage` fires only if a reservation was actually made.
229
- * `onRefundError` is called (instead of throwing) whenever a refund attempt
230
- * itself fails, so a broken refund hook never masks the original error.
231
- */
232
- async function withReservedUsage(params, coalesced, getResult, signal, onRefundError) {
233
- if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
234
- let reserved = false;
235
- try {
236
- if (params.reserveUsage) {
237
- await params.reserveUsage({
238
- coalesced,
239
- signal
240
- });
241
- reserved = true;
242
- }
243
- } catch (error) {
244
- if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
245
- throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", void 0, void 0, error);
246
- }
247
- const refund = async (logMessage) => {
248
- try {
249
- await params.refundUsage?.({
250
- coalesced,
251
- signal
252
- });
253
- } catch (refundError) {
254
- onRefundError(logMessage, refundError);
255
- }
256
- };
257
- if (signal?.aborted) {
258
- if (reserved) await refund("[VernLLM] refundUsage failed after abort");
259
- throw new LLMError("LLM request aborted", "aborted");
260
- }
261
- let result;
262
- try {
263
- result = await getResult();
264
- } catch (error) {
265
- if (reserved) await refund("[VernLLM] refundUsage failed");
266
- throw error;
267
- }
268
- if (signal?.aborted) {
269
- if (reserved) await refund("[VernLLM] refundUsage failed after abort");
270
- throw new LLMError("LLM request aborted", "aborted");
271
- }
272
- return result;
273
- }
274
-
275
- //#endregion
276
- //#region src/logger.ts
277
- /**
278
- * Default logger. `debug` is gated by the `debug` option on VernLLM
279
- * warn/error always fire since they indicate real problems (retries, cache failures)
280
- */
281
- var ConsoleLogger = class {
282
- constructor(debugEnabled) {
283
- this.debugEnabled = debugEnabled;
284
- }
285
- debug(message) {
286
- if (this.debugEnabled) console.debug(message);
287
- }
288
- warn(message) {
289
- console.warn(message);
290
- }
291
- error(message, meta) {
292
- console.error(message, meta ?? "");
293
- }
294
- };
295
-
296
22
  //#endregion
297
23
  //#region src/types/cache.ts
298
24
  /**
@@ -404,91 +130,1495 @@ var TieredCacheAdapter = class {
404
130
  };
405
131
 
406
132
  //#endregion
407
- //#region src/vernLLM.ts
133
+ //#region src/types/tools.ts
408
134
  /**
409
- * A resilient layer around an LLM chat completions client, this is VernLLM!
410
- *
411
- * Adds retry with backoff/jitter, per-attempt timeouts, an optional circuit breaker,
412
- * JSON parsing with optional schema validation, usage tracking, and an
413
- * optional response cache, all configurable, all opt-in beyond sensible
414
- * defaults.
135
+ * Runtime-safe check for whether a `call()` result is a `tool_calls`
136
+ * result. Prefer this over relying on TypeScript's static narrowing
137
+ * whenever `params` passed to `call()` wasn't a literal with `tools`
138
+ * inlined (see the "note on the overload" in `VernLLM.call`'s docs), in
139
+ * that case TS may have typed the result as plain `T` even though it's
140
+ * actually a `CallWithToolsResult<T>` at runtime, and this check works
141
+ * either way.
415
142
  */
416
- var VernLLM = class {
417
- client;
418
- model;
419
- maxRetries;
420
- timeoutMs;
421
- baseDelayMs;
422
- defaultMaxTokens;
423
- cache;
424
- nonRetryableStatus;
425
- inFlight = new Map();
426
- parseJson;
427
- onUsage;
428
- logger;
429
- breaker;
430
- /**
431
- * @param options - Client, model, and tunables. Notable defaults:
432
- * `maxRetries` 1, `timeoutMs` 25000, `baseDelayMs` 500 (exponential backoff
433
- * base), `defaultMaxTokens` 1000, `cache` an in-memory adapter,
434
- * `nonRetryableStatus` `[400, 401, 403, 404, 422]`, `debug` false.
435
- */
436
- constructor(options) {
437
- this.client = options.client;
438
- this.model = options.model;
439
- this.maxRetries = options.maxRetries ?? 1;
440
- this.timeoutMs = options.timeoutMs ?? 25e3;
441
- this.baseDelayMs = options.baseDelayMs ?? 500;
442
- this.defaultMaxTokens = options.defaultMaxTokens ?? 1e3;
443
- this.cache = options.cache ?? new InMemoryCacheAdapter();
444
- this.nonRetryableStatus = options.nonRetryableStatus ?? [
445
- 400,
446
- 401,
447
- 403,
448
- 404,
449
- 422
450
- ];
451
- this.parseJson = options.parseJson ?? defaultParseJson;
452
- this.onUsage = options.onUsage;
453
- this.logger = options.logger ?? new ConsoleLogger(options.debug ?? false);
454
- this.breaker = options.circuitBreaker ? new CircuitBreaker(options.circuitBreaker === true ? void 0 : options.circuitBreaker) : void 0;
455
- }
143
+ function isToolCallResult(result) {
144
+ return typeof result === "object" && result !== null && "type" in result && result.type === "tool_calls" && Array.isArray(result.toolCalls);
145
+ }
146
+
147
+ //#endregion
148
+ //#region src/types/fallback.ts
149
+ /** Tool contract failures are the model ignoring the request, not a sick provider: repeating it elsewhere can't help. */
150
+ const TOOL_CONTRACT_CODES = new Set(["unknown_tool", "duplicate_tool_call_id"]);
151
+ /**
152
+ * The default `fallbackOn` policy. Exported so a caller can wrap rather
153
+ * than replace it, e.g. `fallbackOn: (e, ctx) => myCheck(e) ? 'stop' : defaultFallbackOn(e, ctx)`.
154
+ */
155
+ const defaultFallbackOn = (error) => {
156
+ if (error.type === "parse" || error.type === "validation" || error.type === "aborted") return "stop";
157
+ if (error.type === "quota_exceeded") return "stop";
158
+ if (error.code && TOOL_CONTRACT_CODES.has(error.code)) return "stop";
159
+ return "next";
160
+ };
161
+ /**
162
+ * Thrown when the chain gives up, whether because the last target failed
163
+ * or `fallbackOn` chose to stop early. Carries each attempt in order so
164
+ * an outage across providers stays debuggable without reproducing it.
165
+ * Extends `LLMError` so `isLLMError` and any `instanceof LLMError` check
166
+ * still passes, inheriting the last failure's `type` so existing
167
+ * type-based handling keeps working on a fallback-exhausted error too.
168
+ */
169
+ var FallbackExhaustedError = class extends LLMError {
170
+ constructor(attempts) {
171
+ const last = attempts[attempts.length - 1]?.error;
172
+ super(`${attempts.length} provider${attempts.length === 1 ? "" : "s"} attempted and failed: ${attempts.map((a) => `${a.provider}(${a.error.type})`).join(" then ")}`, last?.type ?? "unknown", last?.status, void 0, last, void 0, "fallback_exhausted");
173
+ this.attempts = attempts;
174
+ }
175
+ };
176
+
177
+ //#endregion
178
+ //#region src/internal/execution/usage.utils.ts
179
+ /**
180
+ * Calls `params.reserveUsage`, if present, mapping any failure to a
181
+ * `quota_exceeded` LLMError (or an aborted error, if the signal fired
182
+ * during reservation). Returns whether a reservation was actually made,
183
+ * so callers know whether a later refund is needed. Shared by
184
+ * `withReservedUsage` and `withReservedUsageForStream`, which differ only
185
+ * in whether `coalesced` is caller-supplied or always `false`.
186
+ */
187
+ async function reserve(params, coalesced, signal) {
188
+ if (!params.reserveUsage) return false;
189
+ try {
190
+ await params.reserveUsage({
191
+ coalesced,
192
+ signal
193
+ });
194
+ return true;
195
+ } catch (error) {
196
+ if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
197
+ throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", void 0, void 0, error);
198
+ }
199
+ }
200
+ /**
201
+ * Builds a `(logMessage) => Promise<void>` refund function bound to the
202
+ * given hooks/coalesced/signal, reporting (instead of throwing) any error
203
+ * the refund hook itself raises, so a broken refund hook never masks the
204
+ * original error it was called to clean up after.
205
+ */
206
+ function makeRefund(params, coalesced, signal, onRefundError) {
207
+ return async (logMessage) => {
208
+ try {
209
+ await params.refundUsage?.({
210
+ coalesced,
211
+ signal
212
+ });
213
+ } catch (refundError) {
214
+ onRefundError(logMessage, refundError);
215
+ }
216
+ };
217
+ }
218
+ /**
219
+ * Runs `getResult` after reserving usage, if a `reserveUsage` hook was
220
+ * provided. `refundUsage` fires only if a reservation was actually made.
221
+ * `onRefundError` is called (instead of throwing) whenever a refund attempt
222
+ * itself fails, so a broken refund hook never masks the original error.
223
+ */
224
+ async function withReservedUsage(params, coalesced, getResult, signal, onRefundError) {
225
+ if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
226
+ const reserved = await reserve(params, coalesced, signal);
227
+ const refund = makeRefund(params, coalesced, signal, onRefundError);
228
+ if (signal?.aborted) {
229
+ if (reserved) await refund("[VernLLM] refundUsage failed after abort");
230
+ throw new LLMError("LLM request aborted", "aborted");
231
+ }
232
+ let result;
233
+ try {
234
+ result = await getResult();
235
+ } catch (error) {
236
+ if (reserved) await refund("[VernLLM] refundUsage failed");
237
+ throw error;
238
+ }
239
+ if (signal?.aborted) {
240
+ if (reserved) await refund("[VernLLM] refundUsage failed after abort");
241
+ throw new LLMError("LLM request aborted", "aborted");
242
+ }
243
+ return result;
244
+ }
245
+ /**
246
+ * Streaming counterpart to `withReservedUsage`. `withReservedUsage` assumes
247
+ * `getResult()` settling *is* the operation's final outcome, awaiting it
248
+ * synchronously before reserve/refund resolve. Streaming can't satisfy that:
249
+ * `call()` must return `{ chunks, finalResult }` as soon as the stream
250
+ * opens, well before the real outcome (validation, schema/tool-call checks)
251
+ * is known.
252
+ *
253
+ * Reserves usage before `openStream` runs, same failure mode as the
254
+ * non-streaming path if `reserveUsage` itself throws (mapped to
255
+ * `quota_exceeded`, nothing opened). If `openStream` itself throws (stream
256
+ * never opened), refunds synchronously and rethrows, exactly like
257
+ * `withReservedUsage` does today. If it succeeds, returns `{ chunks,
258
+ * finalResult }` immediately, refund/report is deferred onto
259
+ * `finalResult`'s continuation, since that's the only point the real
260
+ * outcome is known. This means `onUsageFailure` (and any refund) can fire
261
+ * well after this function itself has returned.
262
+ */
263
+ async function withReservedUsageForStream(params, openStream, signal, onRefundError) {
264
+ if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
265
+ const reserved = await reserve(params, false, signal);
266
+ const refund = makeRefund(params, false, signal, onRefundError);
267
+ if (signal?.aborted) {
268
+ if (reserved) await refund("[VernLLM] refundUsage failed after abort");
269
+ throw new LLMError("LLM request aborted", "aborted");
270
+ }
271
+ let opened;
272
+ try {
273
+ opened = await openStream();
274
+ } catch (error) {
275
+ if (reserved) await refund("[VernLLM] refundUsage failed after stream-open failure");
276
+ throw error;
277
+ }
278
+ const finalResult = opened.finalResult.then((value) => value, async (error) => {
279
+ if (reserved) await refund("[VernLLM] refundUsage failed after stream error");
280
+ throw error;
281
+ });
282
+ finalResult.catch(() => {});
283
+ return {
284
+ chunks: opened.chunks,
285
+ finalResult
286
+ };
287
+ }
288
+
289
+ //#endregion
290
+ //#region src/internal/cache/replay.utils.ts
291
+ /**
292
+ * Converts an already-known cache value back into a plausible "text" form
293
+ * for a one-shot replay chunk: passed through unchanged if it's already a
294
+ * string (the `jsonMode: false` case), otherwise `JSON.stringify`'d (the
295
+ * `jsonMode: true` case, where the cached value is the *parsed* result, not
296
+ * the original raw text). This is a reasonable reconstruction, not a
297
+ * byte-identical replay of whatever text the model originally streamed,
298
+ * good enough for `for await (const c of chunks)` call sites that don't
299
+ * branch on hit vs. miss, which is the only thing a cache-hit replay needs
300
+ * to support.
301
+ */
302
+ function toReplayText(value) {
303
+ return typeof value === "string" ? value : JSON.stringify(value) ?? "";
304
+ }
305
+ /**
306
+ * Builds a trivially-exhausted one-shot `chunks` iterable from an
307
+ * already-known value, used for a `cachedCall` cache hit, where there's no
308
+ * live generation to relay (see `VernLLM.cachedCall`'s docs). No `usage`
309
+ * chunk is emitted: a cache hit spent no real tokens, so there's nothing to
310
+ * report, matching how non-streaming `cachedCall` never calls `onUsage` on
311
+ * a hit either.
312
+ *
313
+ * `hasTools` must reflect whether the *original* call that produced this
314
+ * cached value had `tools` set, that's what determines whether `value` is
315
+ * `T` directly or a `CallWithToolsResult<T>` wrapper, and it isn't
316
+ * something that can be reliably guessed from the value's shape alone
317
+ * (a `schema`-validated `T` could coincidentally look like a
318
+ * `CallWithToolsResult`).
319
+ */
320
+ function buildReplayChunks(value, hasTools) {
321
+ const items = [];
322
+ if (hasTools) {
323
+ const result = value;
324
+ if (result.type === "tool_calls") {
325
+ result.toolCalls.forEach((toolCall, index) => {
326
+ items.push({
327
+ type: "tool_call_delta",
328
+ index,
329
+ id: toolCall.id,
330
+ name: toolCall.name,
331
+ argsDelta: JSON.stringify(toolCall.arguments ?? {}),
332
+ complete: true
333
+ });
334
+ });
335
+ if (result.content) items.push({
336
+ type: "text-delta",
337
+ delta: result.content
338
+ });
339
+ } else items.push({
340
+ type: "text-delta",
341
+ delta: toReplayText(result.content)
342
+ });
343
+ } else items.push({
344
+ type: "text-delta",
345
+ delta: toReplayText(value)
346
+ });
347
+ return { async *[Symbol.asyncIterator]() {
348
+ for (const item of items) yield item;
349
+ } };
350
+ }
351
+ /**
352
+ * Streaming counterpart to `buildReplayChunks` for a `cachedCall` that
353
+ * *joined* an already-in-flight call for the same key rather than
354
+ * triggering one itself (see `runCachedStream`'s in-flight-coalescing
355
+ * path): there's no live stream to relay (it isn't this call's stream to
356
+ * relay, see the joiner-path comment in `runCachedStream`), but there's
357
+ * also no value yet, only a pending promise for one. Waits for `promise`,
358
+ * then delegates to `buildReplayChunks`. If `promise` rejects, iterating
359
+ * `chunks` throws that same error, consistent with how a live stream's
360
+ * `chunks` throws on a mid-stream failure.
361
+ */
362
+ function buildReplayChunksFromPromise(promise, hasTools) {
363
+ return { async *[Symbol.asyncIterator]() {
364
+ const value = await promise;
365
+ yield* buildReplayChunks(value, hasTools);
366
+ } };
367
+ }
368
+
369
+ //#endregion
370
+ //#region src/internal/cache/cacheOrchestrator.ts
371
+ /**
372
+ * Owns cache key resolution, cache reads/writes, and in-flight coalescing
373
+ * for concurrent misses on the same key. Doesn't know about `CallExecutor`,
374
+ * retries, or providers at all: `fn`/`openStream` are opaque callbacks
375
+ * (`VernLLM.cachedCall` passes `() => this.call(...)`), so this class only
376
+ * needs the cache adapter and a logger. Extracted from `VernLLM` since
377
+ * caching and per-target call mechanics are independent concerns that
378
+ * happened to live on the same class.
379
+ */
380
+ var CacheOrchestrator = class {
381
+ inFlight = new Map();
382
+ constructor(cache, logger) {
383
+ this.cache = cache;
384
+ this.logger = logger;
385
+ }
456
386
  /** Resolves a cache key through the adapter when it supports normalization. */
457
387
  async resolveCacheKey(key) {
458
388
  return this.cache.resolveKey ? await this.cache.resolveKey(key) : key;
459
389
  }
460
390
  /**
461
- * Makes a single logical LLM call, retrying on failure per the configured
462
- * policy. Fails fast if the breaker is open or the signal is already
463
- * aborted. On exhausting retries, records a breaker failure and rejects
464
- * with a normalized LLMError.
391
+ * Removes a cached response by key when the configured cache adapter
392
+ * supports deletion. Cache invalidation is the caller's responsibility;
393
+ * only the application knows when cached data is stale.
394
+ */
395
+ async deleteCache(key) {
396
+ if (!this.cache.delete) return;
397
+ await this.cache.delete(await this.resolveCacheKey(key));
398
+ }
399
+ /** Logs a failed refundUsage attempt via the configured logger. */
400
+ logRefundError(logMessage, error) {
401
+ this.logger.error(logMessage, { message: error instanceof Error ? error.message : "unknown" });
402
+ }
403
+ /**
404
+ * Internal cache primitive around caller-supplied logic. Concurrent misses
405
+ * for the same `cacheKey` share a single in-flight call, avoiding cache
406
+ * stampedes.
407
+ *
408
+ * Backs the public `VernLLM.cachedCall()`, which always composes this
409
+ * with `call()` so cached results get the same retry/timeout/
410
+ * circuit-breaker guarantees as any other LLM call.
411
+ *
412
+ * @param params `cacheKey`, `ttl`, `fn` (the work to run on a cache
413
+ * miss, typically `() => this.call(...)`), and optional
414
+ * `reserveUsage`/`refundUsage`/`signal`. See `InternalCacheParams`.
415
+ * @returns The cached value on a hit, or the result of `fn()` on a miss.
416
+ */
417
+ async runCached(params) {
418
+ const resolvedKey = await this.resolveCacheKey(params.cacheKey);
419
+ const resolvedParams = resolvedKey === params.cacheKey ? params : {
420
+ ...params,
421
+ cacheKey: resolvedKey
422
+ };
423
+ const cached = await this.cache.get(resolvedKey);
424
+ if (cached.hit) return cached.value;
425
+ const existing = this.inFlight.get(resolvedKey);
426
+ if (existing) return withReservedUsage(resolvedParams, true, () => existing, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
427
+ return this.registerTrigger(resolvedParams);
428
+ }
429
+ /** Starts the shared fn() call for a cache miss and tracks it in the in-flight map until it settles. */
430
+ registerTrigger(params) {
431
+ const resultPromise = withReservedUsage(params, false, () => this.runAndCache(params), params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
432
+ this.inFlight.set(params.cacheKey, resultPromise);
433
+ resultPromise.catch(() => {}).finally(() => {
434
+ this.inFlight.delete(params.cacheKey);
435
+ });
436
+ return resultPromise;
437
+ }
438
+ /** Runs `fn` and writes its result to the cache. */
439
+ async runAndCache(params) {
440
+ const result = await params.fn();
441
+ try {
442
+ await this.cache.set(params.cacheKey, result, params.ttl);
443
+ } catch (error) {
444
+ this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
445
+ }
446
+ return result;
447
+ }
448
+ /**
449
+ * Streaming counterpart to `runCached`. Three cases:
450
+ *
451
+ * - Hit: no live generation to relay. Returns immediately with
452
+ * `finalResult` resolved to the cached value and a one-shot `chunks`
453
+ * replay built from it, so `for await (const c of chunks)` call sites
454
+ * work identically on a hit or a miss. No usage hooks fire, since
455
+ * nothing was actually spent.
456
+ * - Miss, nothing else in flight for this key: delegates to
457
+ * `registerStreamTrigger`, which opens the stream and relays its
458
+ * `chunks` live.
459
+ * - Miss, but another call for the same key is already in flight: this
460
+ * call has no live chunks of its own to relay, so it's treated like a
461
+ * delayed hit. `finalResult` shares the trigger's in-flight promise
462
+ * (the same in-flight map non-streaming `runCached` uses, so
463
+ * streaming and non-streaming calls for the same key coalesce
464
+ * against each other too), and `chunks` is a one-shot replay built
465
+ * once that promise resolves.
466
+ */
467
+ async runCachedStream(params, hasTools) {
468
+ const resolvedKey = await this.resolveCacheKey(params.cacheKey);
469
+ const resolvedParams = resolvedKey === params.cacheKey ? params : {
470
+ ...params,
471
+ cacheKey: resolvedKey
472
+ };
473
+ const cached = await this.cache.get(resolvedKey);
474
+ if (cached.hit) {
475
+ const value = cached.value;
476
+ return {
477
+ chunks: buildReplayChunks(value, hasTools),
478
+ finalResult: Promise.resolve(value)
479
+ };
480
+ }
481
+ const existing = this.inFlight.get(resolvedKey);
482
+ if (existing) {
483
+ const finalResult = withReservedUsage(resolvedParams, true, () => existing, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
484
+ finalResult.catch(() => {});
485
+ return {
486
+ chunks: buildReplayChunksFromPromise(finalResult, hasTools),
487
+ finalResult
488
+ };
489
+ }
490
+ return this.registerStreamTrigger(resolvedParams);
491
+ }
492
+ /**
493
+ * Opens the shared stream for a cache miss and tracks its settled value
494
+ * in the in-flight map until it resolves or rejects. Writes to the cache
495
+ * on success only, matching `runAndCache`.
496
+ *
497
+ * Registers the in-flight promise synchronously, before anything async
498
+ * runs, so a concurrent `cachedCall` for the same key always sees it in
499
+ * time to join instead of triggering its own stream. Settlement is
500
+ * wired onto the whole `withReservedUsageForStream` call rather than a
501
+ * line inside its callback, so any failure point (reserving usage,
502
+ * opening the stream, or the stream itself) reliably settles the
503
+ * in-flight entry instead of leaving it stuck.
504
+ */
505
+ registerStreamTrigger(params) {
506
+ let resolveInFlight;
507
+ let rejectInFlight;
508
+ const inFlightResult = new Promise((resolve, reject) => {
509
+ resolveInFlight = resolve;
510
+ rejectInFlight = reject;
511
+ });
512
+ this.inFlight.set(params.cacheKey, inFlightResult);
513
+ inFlightResult.catch(() => {}).finally(() => {
514
+ this.inFlight.delete(params.cacheKey);
515
+ });
516
+ const streamPromise = withReservedUsageForStream(params, async () => {
517
+ const opened = await params.openStream();
518
+ const trackedResult = opened.finalResult.then(async (value) => {
519
+ try {
520
+ await this.cache.set(params.cacheKey, value, params.ttl);
521
+ } catch (error) {
522
+ this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
523
+ }
524
+ return value;
525
+ }, (error) => {
526
+ throw error;
527
+ });
528
+ return {
529
+ chunks: opened.chunks,
530
+ finalResult: trackedResult
531
+ };
532
+ }, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
533
+ streamPromise.then((opened) => {
534
+ opened.finalResult.then(resolveInFlight, rejectInFlight);
535
+ }, (error) => {
536
+ rejectInFlight(error);
537
+ });
538
+ return streamPromise;
539
+ }
540
+ };
541
+
542
+ //#endregion
543
+ //#region src/circuitBreaker.ts
544
+ function newBucket() {
545
+ return {
546
+ state: "closed",
547
+ consecutiveFailures: 0,
548
+ openedAt: 0,
549
+ trialInFlight: false
550
+ };
551
+ }
552
+ /** Key a bucket lookup falls into when the call omitted `model` under `isolateByModel`. */
553
+ const UNLABELED_MODEL = "";
554
+ /**
555
+ * Per retry VernLLM-instance circuit breaker. Tracks consecutive failures across
556
+ * calls. Once the threshold is hit, short-circuits new calls with an
557
+ * LLMError('circuit_open') instead of hitting the provider, until the
558
+ * cooldown elapses and a single trial call is allowed through
559
+ */
560
+ var CircuitBreaker = class {
561
+ threshold;
562
+ cooldownMs;
563
+ onStateChange;
564
+ isolateByModel;
565
+ sharedBucket = newBucket();
566
+ bucketsByModel = new Map();
567
+ constructor(options = {}) {
568
+ this.threshold = options.threshold ?? 5;
569
+ this.cooldownMs = options.cooldownMs ?? 3e4;
570
+ this.onStateChange = options.onStateChange;
571
+ this.isolateByModel = options.isolateByModel ?? false;
572
+ }
573
+ /** Returns the bucket for a model if one already exists, without allocating. */
574
+ lookupBucket(model) {
575
+ if (!this.isolateByModel) return this.sharedBucket;
576
+ const key = model ?? UNLABELED_MODEL;
577
+ return this.bucketsByModel.get(key);
578
+ }
579
+ /** Creates and stores a bucket for a model when the first mutation needs one. */
580
+ ensureBucketFor(model) {
581
+ if (!this.isolateByModel) return this.sharedBucket;
582
+ const key = model ?? UNLABELED_MODEL;
583
+ let bucket = this.bucketsByModel.get(key);
584
+ if (!bucket) {
585
+ bucket = newBucket();
586
+ this.bucketsByModel.set(key, bucket);
587
+ }
588
+ return bucket;
589
+ }
590
+ /** Every state mutation routes through here, so `onStateChange` fires exactly once per real change. */
591
+ transition(bucket, to, model) {
592
+ if (to === bucket.state) return;
593
+ const from = bucket.state;
594
+ bucket.state = to;
595
+ this.onStateChange?.(from, to, bucket.consecutiveFailures, model);
596
+ }
597
+ /**
598
+ * Throws if the circuit is open and the cooldown hasn't elapsed, or if
599
+ * the circuit is half-open and a trial call is already in flight.
600
+ * Otherwise, if the circuit just became eligible for a trial (cooldown
601
+ * elapsed, or half-open with no trial currently running), this call
602
+ * becomes that trial
603
+ */
604
+ assertClosed(model) {
605
+ const bucket = this.ensureBucketFor(model);
606
+ if (bucket.state === "closed") return;
607
+ if (bucket.state === "open") {
608
+ const elapsed = Date.now() - bucket.openedAt;
609
+ if (elapsed < this.cooldownMs) throw new LLMError(`Circuit open, provider has failed ${bucket.consecutiveFailures} times in a row. Retry in ${Math.ceil((this.cooldownMs - elapsed) / 1e3)}s.`, "circuit_open");
610
+ bucket.trialInFlight = true;
611
+ this.transition(bucket, "half-open", model);
612
+ return;
613
+ }
614
+ if (bucket.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open");
615
+ bucket.trialInFlight = true;
616
+ }
617
+ recordSuccess(model) {
618
+ const bucket = this.lookupBucket(model);
619
+ if (!bucket) return;
620
+ bucket.consecutiveFailures = 0;
621
+ bucket.trialInFlight = false;
622
+ this.transition(bucket, "closed", model);
623
+ if (this.isolateByModel && bucket.state === "closed" && bucket.consecutiveFailures === 0) this.bucketsByModel.delete(model ?? UNLABELED_MODEL);
624
+ }
625
+ recordFailure(model) {
626
+ const bucket = this.ensureBucketFor(model);
627
+ bucket.consecutiveFailures += 1;
628
+ bucket.trialInFlight = false;
629
+ if (bucket.state === "half-open") {
630
+ bucket.openedAt = Date.now();
631
+ this.transition(bucket, "open", model);
632
+ return;
633
+ }
634
+ if (bucket.consecutiveFailures >= this.threshold) {
635
+ bucket.openedAt = Date.now();
636
+ this.transition(bucket, "open", model);
637
+ }
638
+ }
639
+ /**
640
+ * With `isolateByModel` off (the default), `model` is ignored and the
641
+ * one shared circuit's state is returned, unchanged from every version
642
+ * before this option existed. With `isolateByModel` on, returns that
643
+ * model's own state, `'closed'` for a model never seen yet, same as a
644
+ * fresh breaker.
645
+ */
646
+ getState(model) {
647
+ return this.lookupBucket(model)?.state ?? "closed";
648
+ }
649
+ };
650
+
651
+ //#endregion
652
+ //#region src/internal/circuitBreaker.utils.ts
653
+ /**
654
+ * Builds a `(event) => void` reporter that no-ops when `onEvent` is unset,
655
+ * and otherwise calls it, swallowing and logging any error the handler
656
+ * throws so a broken `onEvent` can't break the call that triggered it.
657
+ * Shared by `buildCircuitBreaker` (which needs to report before any
658
+ * executor exists) and `CallExecutor.reportEvent`, kept independent of the
659
+ * executor for that reason.
660
+ */
661
+ function makeEventReporter(onEvent, logger) {
662
+ return (event) => {
663
+ if (!onEvent) return;
664
+ try {
665
+ onEvent(event);
666
+ } catch (error) {
667
+ logger.error("[VernLLM] onEvent failed", { message: error instanceof Error ? error.message : "unknown" });
668
+ }
669
+ };
670
+ }
671
+ /**
672
+ * Builds the optional circuit breaker for one provider target, wiring its
673
+ * `onStateChange` to emit a `circuit_state` event and chain any
674
+ * caller-supplied `onStateChange`. Returns `undefined` when
675
+ * `circuitBreakerOption` is falsy, matching the option's own semantics.
676
+ *
677
+ * Lives outside `CallExecutor` (and outside `VernLLM`, once this were
678
+ * inlined) because the breaker has to exist *before* the executor it's
679
+ * passed into, so its construction can't be an executor concern.
680
+ * `onEvent` is called directly rather than through the executor for the
681
+ * same reason: nothing executor-shaped exists yet at this point.
682
+ *
683
+ * Takes the specific fields it needs (rather than a full `VernLLMOptions`)
684
+ * so it works identically for the primary target and for each fallback
685
+ * target, which carry their own `circuitBreaker` override alongside the
686
+ * shared `onEvent`.
687
+ */
688
+ function buildCircuitBreaker(circuitBreakerOption, providerName, defaultModel, onEvent, logger) {
689
+ if (!circuitBreakerOption) return void 0;
690
+ const breakerOptions = typeof circuitBreakerOption === "object" ? circuitBreakerOption : void 0;
691
+ const userOnStateChange = breakerOptions?.onStateChange;
692
+ const reportEvent = makeEventReporter(onEvent, logger);
693
+ return new CircuitBreaker({
694
+ ...breakerOptions,
695
+ onStateChange: (from, to, consecutiveFailures, model) => {
696
+ reportEvent({
697
+ kind: "circuit_state",
698
+ provider: providerName,
699
+ model: model ?? defaultModel,
700
+ from,
701
+ to,
702
+ consecutiveFailures
703
+ });
704
+ if (!userOnStateChange) return;
705
+ try {
706
+ userOnStateChange(from, to, consecutiveFailures, model);
707
+ } catch (error) {
708
+ logger.error("[VernLLM] circuitBreaker.onStateChange failed", { message: error instanceof Error ? error.message : "unknown" });
709
+ }
710
+ }
711
+ });
712
+ }
713
+
714
+ //#endregion
715
+ //#region src/internal/execution/retry.utils.ts
716
+ /**
717
+ * Default cap (ms) for both exponential backoff and honored Retry-After
718
+ * values, so a misbehaving/adversarial Retry-After can't stall a caller
719
+ * indefinitely
720
+ */
721
+ const DEFAULT_MAX_DELAY_MS = 1e4;
722
+ /**
723
+ * `setTimeout` silently clamps any delay above this (~24.8 days) or
724
+ * `Infinity` down to ~1ms instead of erroring, so a caller passing
725
+ * `Infinity` as "no timeout" gets the opposite of what they asked for.
726
+ * Both timeout helpers below guard against this explicitly.
727
+ */
728
+ const MAX_SETTIMEOUT_MS = 2147483647;
729
+ /**
730
+ * Resolves a timeout value to the number `setTimeout` should actually use,
731
+ * or `undefined` when the timeout should be treated as disabled (0,
732
+ * negative, or `Infinity`). Returning the resolved value directly, rather
733
+ * than a boolean, lets callers narrow `number | undefined` to `number`
734
+ * without an `as number` cast.
735
+ */
736
+ function resolveActiveTimeoutMs(ms) {
737
+ return !ms || ms <= 0 || ms === Infinity ? void 0 : ms;
738
+ }
739
+ /** Caps a timeout at the largest delay `setTimeout` actually honors. */
740
+ function clampTimeoutMs(ms) {
741
+ return Math.min(ms, MAX_SETTIMEOUT_MS);
742
+ }
743
+ /**
744
+ * Runs an async function and cancels it if it takes longer than the given
745
+ * timeout. Creates an internal abort controller that fires after the
746
+ * timeout elapses, and combines it with any external signal the caller
747
+ * passed in so either one can cancel the underlying call. If the internal
748
+ * timeout triggers and the underlying operation aborts, the error is
749
+ * converted into an LLMError with type "timeout". External cancellations
750
+ * continue to propagate as aborted errors. The internal timer is always
751
+ * cleared afterward, whether the function succeeds, fails, or is aborted,
752
+ * so nothing is left running in the background.
753
+ *
754
+ * `timeoutMs` of `Infinity` (or any value beyond what `setTimeout` can
755
+ * represent) disables the timeout rather than firing almost immediately.
756
+ */
757
+ async function withTimeout(fn, timeoutMs, externalSignal) {
758
+ const controller = new AbortController();
759
+ const activeTimeoutMs = resolveActiveTimeoutMs(timeoutMs);
760
+ const timer = activeTimeoutMs === void 0 ? void 0 : setTimeout(() => {
761
+ controller.abort();
762
+ }, clampTimeoutMs(activeTimeoutMs));
763
+ const signal = externalSignal ? AbortSignal.any([externalSignal, controller.signal]) : controller.signal;
764
+ try {
765
+ return await fn(signal);
766
+ } catch (err) {
767
+ if (controller.signal.aborted && !externalSignal?.aborted && err instanceof DOMException && err.name === "AbortError") throw new LLMError("Request timed out", "timeout");
768
+ throw err;
769
+ } finally {
770
+ clearTimeout(timer);
771
+ }
772
+ }
773
+ /**
774
+ * Races one `iterator.next()` call against a per-call idle timer, to
775
+ * bound the gap *between* chunks (unlike `withTimeout`, which only bounds
776
+ * opening the stream and its first chunk). Without this, a connection
777
+ * that streams one chunk then hangs would never fail.
778
+ *
779
+ * `timeoutMs` of 0/undefined/`Infinity` disables the check. Otherwise
780
+ * rejects with `LLMError('timeout')` if `next()` doesn't settle in time.
781
+ * The clock resets on every call, so the window is measured from the most
782
+ * recent chunk, not from stream start.
783
+ *
784
+ * `onIdle`, if given, is called the moment the timer fires (before the
785
+ * rejection), so callers can abort the underlying transport instead of
786
+ * just walking away from an unread promise. `logger`, if given, records a
787
+ * debug line if `next()` still settles *after* the idle timeout already
788
+ * rejected. `resolve`/`reject` on an already-settled promise is otherwise
789
+ * a silent no-op, so without this the late chunk (possibly the final
790
+ * usage chunk) would vanish with no trace.
791
+ */
792
+ function withChunkIdleTimeout(next, timeoutMs, onIdle, logger) {
793
+ const activeTimeoutMs = resolveActiveTimeoutMs(timeoutMs);
794
+ if (activeTimeoutMs === void 0) return next();
795
+ let settled = false;
796
+ return new Promise((resolve, reject) => {
797
+ const timer = setTimeout(() => {
798
+ settled = true;
799
+ onIdle?.();
800
+ reject(new LLMError(`No stream chunk received for ${activeTimeoutMs}ms (idle timeout)`, "timeout"));
801
+ }, clampTimeoutMs(activeTimeoutMs));
802
+ next().then((result) => {
803
+ clearTimeout(timer);
804
+ if (settled) {
805
+ logger?.debug("[VernLLM] chunk resolved after idle timeout already fired; discarding");
806
+ return;
807
+ }
808
+ settled = true;
809
+ resolve(result);
810
+ }, (error) => {
811
+ clearTimeout(timer);
812
+ if (settled) {
813
+ logger?.debug("[VernLLM] chunk rejection arrived after idle timeout already fired; discarding");
814
+ return;
815
+ }
816
+ settled = true;
817
+ reject(error);
818
+ });
819
+ });
820
+ }
821
+ /**
822
+ * Looks inside an unknown error value for a Retry-After header and
823
+ * converts it to milliseconds. Checks `.headers` first (fetch-style,
824
+ * Headers-like with `.get()`), then `.response.headers` (axios-style,
825
+ * plain object) since different client libraries surface headers
826
+ * differently. Supports both the delta-seconds form ("30") and the
827
+ * HTTP-date form ("Wed, 21 Oct 2015 07:28:00 GMT"). The result is capped
828
+ * at maxDelayMs. Returns undefined when no usable Retry-After is present
829
+ */
830
+ function extractRetryAfterMs(err, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
831
+ if (!err || typeof err !== "object") return void 0;
832
+ const error = err;
833
+ const headers = error.headers ?? error.response?.headers;
834
+ if (!headers || typeof headers !== "object") return void 0;
835
+ const getter = headers;
836
+ const raw = typeof getter.get === "function" ? getter.get("Retry-After") : Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.at(1);
837
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
838
+ const trimmed = raw.trim();
839
+ if (/^\d+$/.test(trimmed)) return Math.max(0, Math.min(Number(trimmed) * 1e3, maxDelayMs));
840
+ const dateMs = Date.parse(trimmed);
841
+ if (!Number.isNaN(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), maxDelayMs));
842
+ return void 0;
843
+ }
844
+ /**
845
+ * Exponential backoff with jitter, capped at maxDelayMs.
846
+ * Jitter avoids thundering-herd retries when many callers back off in lockstep,
847
+ * the cap prevents unbounded delays when maxRetries is high
848
+ */
849
+ function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
850
+ const exp = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
851
+ return exp / 2 + Math.random() * (exp / 2);
852
+ }
853
+ /**
854
+ * Pauses execution for the given delay before a retry attempt. If an
855
+ * abort signal is provided and it fires while waiting, the pending
856
+ * timer is cancelled immediately and the wait rejects right away with
857
+ * an aborted error instead of continuing to sit idle until the delay
858
+ * would have finished on its own
859
+ */
860
+ async function waitForRetry(delay, signal) {
861
+ if (signal?.aborted) throw new LLMError("Operation aborted", "aborted");
862
+ await new Promise((resolve, reject) => {
863
+ const onAbort = () => {
864
+ clearTimeout(timer);
865
+ reject(new LLMError("Operation aborted", "aborted"));
866
+ };
867
+ const timer = setTimeout(() => {
868
+ signal?.removeEventListener("abort", onAbort);
869
+ resolve();
870
+ }, delay);
871
+ signal?.addEventListener("abort", onAbort, { once: true });
872
+ });
873
+ }
874
+
875
+ //#endregion
876
+ //#region src/internal/execution/errors.utils.ts
877
+ /**
878
+ * Looks inside an unknown error value and pulls out an http status code
879
+ * if one is present. Checks the status field first then the status code
880
+ * field since different client libraries use different names for this,
881
+ * falling back to AWS SDK v3's `$metadata.httpStatusCode` (e.g. Bedrock's
882
+ * `ThrottlingException`), which doesn't set either of the other two.
883
+ * Returns undefined when the error is not an object or carries no status
884
+ */
885
+ function extractStatus(err) {
886
+ if (!err || typeof err !== "object") return void 0;
887
+ const error = err;
888
+ if (typeof error.status === "number") return error.status;
889
+ if (typeof error.statusCode === "number") return error.statusCode;
890
+ if (typeof error.$metadata?.httpStatusCode === "number") return error.$metadata.httpStatusCode;
891
+ return void 0;
892
+ }
893
+ function formatSafely(value) {
894
+ try {
895
+ return JSON.stringify(value, null, 2) ?? String(value);
896
+ } catch {
897
+ try {
898
+ return String(value);
899
+ } catch {
900
+ return "[unprintable error]";
901
+ }
902
+ }
903
+ }
904
+ /**
905
+ * Looks inside an unknown thrown value and pulls out a human-readable
906
+ * description of it. Checks the `error` field first (the provider's raw
907
+ * rejection body, JSON-stringified if possible) then falls back to the
908
+ * message` field. Always returns a safe string, even when the thrown value
909
+ * has hostile properties or cannot be serialized normally.
910
+ */
911
+ function describeError(err) {
912
+ if (err && typeof err === "object") try {
913
+ const error = err;
914
+ if (error.error !== void 0) return formatSafely(error.error);
915
+ if (typeof error.message === "string") return error.message;
916
+ } catch {}
917
+ return formatSafely(err);
918
+ }
919
+ /** Converts any thrown value into a well-typed LLMError. */
920
+ function normalizeError(error, signal) {
921
+ if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
922
+ if (error instanceof LLMError) {
923
+ if (error.status === 429 && error.code === void 0) error.code = "provider_rate_limited";
924
+ return error;
925
+ }
926
+ const status = extractStatus(error);
927
+ const retryAfterMs = extractRetryAfterMs(error);
928
+ if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs, status === 429 ? "provider_rate_limited" : void 0);
929
+ return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
930
+ }
931
+
932
+ //#endregion
933
+ //#region src/internal/execution/parse.utils.ts
934
+ /** Default `parseJson`: `JSON.parse` wrapped in try/catch, returning `undefined` on failure. */
935
+ function defaultParseJson(content) {
936
+ try {
937
+ return JSON.parse(content);
938
+ } catch {
939
+ return void 0;
940
+ }
941
+ }
942
+
943
+ //#endregion
944
+ //#region src/internal/execution/wire.utils.ts
945
+ /** Translates app-facing `ToolDefinition[]` into the OpenAI-shaped wire tools array. */
946
+ function toWireTools(tools) {
947
+ return tools.map((tool) => ({
948
+ type: "function",
949
+ function: {
950
+ name: tool.name,
951
+ description: tool.description,
952
+ parameters: tool.parameters
953
+ }
954
+ }));
955
+ }
956
+ /** Translates app-facing `ToolCall[]` (e.g. from a replayed assistant turn) into wire tool_calls. */
957
+ function toWireToolCalls(toolCalls) {
958
+ return toolCalls.map((tc) => ({
959
+ id: tc.id,
960
+ type: "function",
961
+ function: {
962
+ name: tc.name,
963
+ arguments: JSON.stringify(tc.arguments ?? {})
964
+ }
965
+ }));
966
+ }
967
+ /**
968
+ * Parses the provider's wire-shaped `tool_calls` back into VernLLM's
969
+ * `ToolCall[]`. Malformed argument JSON is a `'parse'` error, same
970
+ * convention as malformed JSON response bodies elsewhere in VernLLM.
971
+ */
972
+ function parseWireToolCalls(wireToolCalls) {
973
+ return wireToolCalls.map((wc) => {
974
+ let parsedArgs;
975
+ try {
976
+ parsedArgs = wc.function.arguments.trim() ? JSON.parse(wc.function.arguments) : {};
977
+ } catch {
978
+ throw new LLMError(`Invalid JSON arguments for tool call "${wc.function.name}"`, "parse");
979
+ }
980
+ return {
981
+ id: wc.id,
982
+ name: wc.function.name,
983
+ arguments: parsedArgs
984
+ };
985
+ });
986
+ }
987
+
988
+ //#endregion
989
+ //#region src/internal/execution/requestBuilder.ts
990
+ /**
991
+ * Builds the wire request object for one call, applying per-instance
992
+ * defaults (model, max tokens, temperature) and per-call overrides.
993
+ * Owns every validation that depends only on shape, not on execution:
994
+ * history alternation, duplicate/empty tool lists, `toolChoice` naming a
995
+ * real tool. Has no knowledge of retry, timeouts, or the breaker, only
996
+ * the three defaults a `FallbackTarget` can override per-target (see the
997
+ * `defaultMaxTokens`/`defaultTemperature` overrides in the fallback
998
+ * design), which is what keeps it separable from `CallExecutor`.
999
+ */
1000
+ var RequestBuilder = class {
1001
+ model;
1002
+ defaultMaxTokens;
1003
+ defaultTemperature;
1004
+ constructor(options) {
1005
+ this.model = options.model;
1006
+ this.defaultMaxTokens = options.defaultMaxTokens;
1007
+ this.defaultTemperature = options.defaultTemperature;
1008
+ }
1009
+ /** Applies per-call defaults and shapes params into the client's request object. */
1010
+ build(params) {
1011
+ const { systemPrompt, userContent, history = [], maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema, tools, toolChoice } = params;
1012
+ const temperature = params.temperature === void 0 ? this.defaultTemperature : params.temperature;
1013
+ if (tools && tools.length === 0) throw new LLMError("`tools` was an empty array. This is almost always a bug (e.g. a filtered tool list that ended up empty). An empty `tools` array still switches on tool-call mode (response shape, jsonMode default, wire format) with nothing for the model to call. Omit `tools` entirely for a normal call, or make sure the array is non-empty.", "validation");
1014
+ if (tools) {
1015
+ const seen = new Set();
1016
+ const duplicates = new Set();
1017
+ for (const tool of tools) {
1018
+ if (seen.has(tool.name)) duplicates.add(tool.name);
1019
+ seen.add(tool.name);
1020
+ }
1021
+ if (duplicates.size) throw new LLMError(`\`tools\` has duplicate name(s): [${[...duplicates].join(", ")}]. Tool names must be unique.`, "validation");
1022
+ }
1023
+ if (toolChoice && !tools) throw new LLMError("`toolChoice` was set without `tools`. There is nothing for it to choose between. Set `tools`, or remove `toolChoice`.", "validation");
1024
+ if (tools && typeof toolChoice === "object" && !tools.some((t) => t.name === toolChoice.name)) throw new LLMError(`toolChoice names "${toolChoice.name}", which is not in \`tools\` ([${tools.map((t) => t.name).join(", ")}]).`, "validation");
1025
+ const jsonMode = params.jsonMode ?? (tools ? false : true);
1026
+ const useJson = jsonMode || Boolean(jsonSchema);
1027
+ if (params.schema && !useJson) throw new LLMError("schema was provided but jsonMode: false disables JSON parsing, so nothing would validate it. Remove jsonMode: false, set jsonSchema, or remove schema.", "validation");
1028
+ const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
1029
+ this.validateHistory(history);
1030
+ const request = {
1031
+ model,
1032
+ ...temperature !== null ? { temperature } : {},
1033
+ max_tokens: maxTokens,
1034
+ ...responseFormat ? { response_format: responseFormat } : {},
1035
+ ...reasoningEffort ? { reasoning_effort: reasoningEffort } : {},
1036
+ ...tools ? { tools: toWireTools(tools) } : {},
1037
+ ...tools ? { tool_choice: this.buildWireToolChoice(toolChoice) } : {},
1038
+ messages: [
1039
+ ...systemPrompt ? [{
1040
+ role: "system",
1041
+ content: systemPrompt
1042
+ }] : [],
1043
+ ...history.flatMap((turn) => this.turnToWireMessages(turn)),
1044
+ {
1045
+ role: "user",
1046
+ content: userContent
1047
+ }
1048
+ ]
1049
+ };
1050
+ return {
1051
+ useJson,
1052
+ model,
1053
+ request
1054
+ };
1055
+ }
1056
+ /**
1057
+ * Validates `history` alternates user/assistant turns, since providers
1058
+ * like Anthropic/Gemini reject or mishandle consecutive same-role turns.
1059
+ */
1060
+ validateHistory(history) {
1061
+ let previousTurn;
1062
+ for (const [index, turn] of history.entries()) {
1063
+ if (turn.role === "tool") {
1064
+ if (previousTurn?.role !== "assistant" || !previousTurn.toolCalls?.length) throw new LLMError(`history[${index}] is a "tool" turn, but must immediately follow an "assistant" turn that requested tools`, "validation");
1065
+ if (!turn.toolResults?.length) throw new LLMError(`history[${index}] is a "tool" turn but has no toolResults`, "validation");
1066
+ const requestedIds = new Set(previousTurn.toolCalls.map((tc) => tc.id));
1067
+ const resultIds = turn.toolResults.map((tr) => tr.toolCallId);
1068
+ const unknownIds = resultIds.filter((id) => !requestedIds.has(id));
1069
+ if (unknownIds.length) throw new LLMError(`history[${index}].toolResults references unknown toolCallId(s) [${unknownIds.join(", ")}]`, "validation");
1070
+ const seenIds = new Set();
1071
+ const duplicateIds = new Set();
1072
+ for (const id of resultIds) {
1073
+ if (seenIds.has(id)) duplicateIds.add(id);
1074
+ seenIds.add(id);
1075
+ }
1076
+ if (duplicateIds.size) throw new LLMError(`history[${index}].toolResults has duplicate toolCallId(s) [${[...duplicateIds].join(", ")}]`, "validation");
1077
+ const missingIds = [...requestedIds].filter((id) => !resultIds.includes(id));
1078
+ if (missingIds.length) throw new LLMError(`history[${index}] is missing toolResults for toolCallId(s) [${missingIds.join(", ")}]`, "validation");
1079
+ } else {
1080
+ if (turn.role === previousTurn?.role) throw new LLMError(`history must alternate user/assistant turns: consecutive "${turn.role}" turns at history[${index - 1}] and history[${index}]`, "validation");
1081
+ if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError(`history[${index}] follows an assistant tool request without tool results`, "validation");
1082
+ }
1083
+ previousTurn = turn;
1084
+ }
1085
+ if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError("The last entry in history is an assistant tool request without tool results", "validation");
1086
+ if (previousTurn?.role === "user") throw new LLMError("The last entry in history is a \"user\" turn, which would collide with the current userContent turn.", "validation");
1087
+ }
1088
+ /** Maps VernLLM's app-facing `ToolChoice` onto the OpenAI-shaped wire `tool_choice`. */
1089
+ buildWireToolChoice(toolChoice) {
1090
+ if (!toolChoice || toolChoice === "auto") return "auto";
1091
+ if (toolChoice === "none" || toolChoice === "required") return toolChoice;
1092
+ return {
1093
+ type: "function",
1094
+ function: { name: toolChoice.name }
1095
+ };
1096
+ }
1097
+ /**
1098
+ * Expands one `ConversationTurn` into one or more wire messages. Plain
1099
+ * user/assistant turns map 1:1. An assistant turn with `toolCalls` maps
1100
+ * to an assistant message carrying wire-shaped `tool_calls`. A `'tool'`
1101
+ * turn expands into one wire `tool` message per `toolResult`, since
1102
+ * OpenAI-shaped wire format wants one message per tool_call_id.
1103
+ */
1104
+ turnToWireMessages(turn) {
1105
+ if (turn.role === "tool") return (turn.toolResults ?? []).map((tr) => ({
1106
+ role: "tool",
1107
+ tool_call_id: tr.toolCallId,
1108
+ content: typeof tr.content === "string" ? tr.content : JSON.stringify(tr.content ?? null),
1109
+ ...tr.isError ? { is_error: true } : {}
1110
+ }));
1111
+ if (turn.role === "assistant" && turn.toolCalls?.length) return [{
1112
+ role: "assistant",
1113
+ ...turn.content ? { content: turn.content } : {},
1114
+ tool_calls: toWireToolCalls(turn.toolCalls)
1115
+ }];
1116
+ return [{
1117
+ role: turn.role,
1118
+ content: turn.content ?? ""
1119
+ }];
1120
+ }
1121
+ /**
1122
+ * Chooses the response format: a provider-native `jsonSchema` takes
1123
+ * priority when supplied (constrains generation directly), otherwise
1124
+ * falls back to the looser `json_object` mode when JSON output is
1125
+ * requested, or no format at all for plain text responses.
1126
+ */
1127
+ buildResponseFormat(jsonSchema, useJson) {
1128
+ if (jsonSchema) return {
1129
+ type: "json_schema",
1130
+ json_schema: {
1131
+ name: jsonSchema.name,
1132
+ schema: jsonSchema.schema,
1133
+ strict: jsonSchema.strict ?? true,
1134
+ description: jsonSchema.description
1135
+ }
1136
+ };
1137
+ return useJson ? { type: "json_object" } : void 0;
1138
+ }
1139
+ };
1140
+
1141
+ //#endregion
1142
+ //#region src/internal/execution/streamAccumulator.ts
1143
+ /**
1144
+ * The streaming accumulator: wraps the raw `WireStreamChunk` iterator in
1145
+ * an async generator that yields translated `StreamChunk`s to the caller
1146
+ * live, as they arrive, with no per-chunk timeout and no bound on total
1147
+ * duration, and accumulates text/tool-call deltas internally so that
1148
+ * `finalize` can produce `finalResult` once the stream completes.
1149
+ *
1150
+ * Two separate try/catches: the iteration loop's catch handles errors
1151
+ * the transport itself throws, which aren't normalized yet, so that
1152
+ * happens here, alongside the one `onStreamFailure` call for them. The
1153
+ * second catch, around `finalize`, does not re-normalize or re-report,
1154
+ * since `finalize`'s caller (`finalizeResponse`) already does both
1155
+ * internally.
1156
+ */
1157
+ function buildStreamResult(iterator, first, options) {
1158
+ const { requestId, model, providerName, isFallback, chunkIdleTimeoutMs, streamController, logger, signal } = options;
1159
+ let resolveFinal;
1160
+ let rejectFinal;
1161
+ const finalResult = new Promise((resolve, reject) => {
1162
+ resolveFinal = resolve;
1163
+ rejectFinal = reject;
1164
+ });
1165
+ finalResult.catch(() => {});
1166
+ const MAX_BUFFERED_CHUNKS = 1e4;
1167
+ const buffered = [];
1168
+ const pending = [];
1169
+ let streamDone = false;
1170
+ let streamError;
1171
+ let hasLoggedEviction = false;
1172
+ const push = (chunk) => {
1173
+ const waiter = pending.shift();
1174
+ if (waiter) {
1175
+ waiter.resolve({
1176
+ done: false,
1177
+ value: chunk
1178
+ });
1179
+ return;
1180
+ }
1181
+ buffered.push(chunk);
1182
+ if (buffered.length > MAX_BUFFERED_CHUNKS * 2) {
1183
+ if (!hasLoggedEviction) {
1184
+ hasLoggedEviction = true;
1185
+ logger.warn(`[VernLLM] stream chunk buffer exceeded cap (${MAX_BUFFERED_CHUNKS}), evicting ${buffered.length - MAX_BUFFERED_CHUNKS} oldest chunk(s); buffered=${buffered.length}. The chunks iterable was never read (or fell far behind) for this stream.`);
1186
+ }
1187
+ buffered.splice(0, buffered.length - MAX_BUFFERED_CHUNKS);
1188
+ }
1189
+ };
1190
+ const finish = () => {
1191
+ streamDone = true;
1192
+ for (const waiter of pending.splice(0)) waiter.resolve({
1193
+ done: true,
1194
+ value: void 0
1195
+ });
1196
+ };
1197
+ const fail = (error) => {
1198
+ streamDone = true;
1199
+ streamError = error;
1200
+ for (const waiter of pending.splice(0)) waiter.reject(error);
1201
+ };
1202
+ const chunks = { [Symbol.asyncIterator]() {
1203
+ return { next() {
1204
+ if (buffered.length) return Promise.resolve({
1205
+ done: false,
1206
+ value: buffered.shift()
1207
+ });
1208
+ if (streamDone) return streamError ? Promise.reject(streamError) : Promise.resolve({
1209
+ done: true,
1210
+ value: void 0
1211
+ });
1212
+ return new Promise((resolve, reject) => {
1213
+ pending.push({
1214
+ resolve,
1215
+ reject
1216
+ });
1217
+ });
1218
+ } };
1219
+ } };
1220
+ const toolCallAcc = new Map();
1221
+ let textAcc = "";
1222
+ let usage;
1223
+ (async () => {
1224
+ try {
1225
+ let result = first;
1226
+ while (!result.done) {
1227
+ const wireChunk = result.value;
1228
+ if (wireChunk.type === "ping") {} else if (wireChunk.type === "text-delta") {
1229
+ textAcc += wireChunk.delta;
1230
+ push({
1231
+ type: "text-delta",
1232
+ delta: wireChunk.delta
1233
+ });
1234
+ } else if (wireChunk.type === "tool_call_delta") {
1235
+ const entry = toolCallAcc.get(wireChunk.index) ?? { args: "" };
1236
+ entry.id ??= wireChunk.id;
1237
+ entry.name ??= wireChunk.name;
1238
+ entry.args += wireChunk.argumentsDelta ?? "";
1239
+ toolCallAcc.set(wireChunk.index, entry);
1240
+ push({
1241
+ type: "tool_call_delta",
1242
+ index: wireChunk.index,
1243
+ id: wireChunk.id,
1244
+ name: wireChunk.name,
1245
+ argsDelta: wireChunk.argumentsDelta,
1246
+ complete: wireChunk.complete
1247
+ });
1248
+ } else if (wireChunk.type === "usage") {
1249
+ usage = {
1250
+ promptTokens: wireChunk.usage.prompt_tokens ?? 0,
1251
+ completionTokens: wireChunk.usage.completion_tokens ?? 0,
1252
+ totalTokens: wireChunk.usage.total_tokens ?? 0,
1253
+ requestId,
1254
+ model,
1255
+ provider: providerName,
1256
+ usedFallback: isFallback
1257
+ };
1258
+ push({
1259
+ type: "usage",
1260
+ usage
1261
+ });
1262
+ }
1263
+ result = await withChunkIdleTimeout(() => iterator.next(), chunkIdleTimeoutMs, () => streamController.abort(), logger);
1264
+ }
1265
+ } catch (error) {
1266
+ try {
1267
+ await iterator.return?.();
1268
+ } catch {}
1269
+ streamController.abort();
1270
+ const normalized = normalizeError(error, signal);
1271
+ try {
1272
+ options.onStreamFailure(normalized, usage);
1273
+ } catch {}
1274
+ fail(normalized);
1275
+ rejectFinal(normalized);
1276
+ return;
1277
+ }
1278
+ finish();
1279
+ try {
1280
+ options.onStreamSuccess(usage);
1281
+ } catch {}
1282
+ try {
1283
+ const wireToolCalls = toolCallAcc.size ? [...toolCallAcc.entries()].sort(([indexA], [indexB]) => indexA - indexB).map(([, entry]) => ({
1284
+ id: entry.id ?? "",
1285
+ type: "function",
1286
+ function: {
1287
+ name: entry.name ?? "",
1288
+ arguments: entry.args
1289
+ }
1290
+ })) : void 0;
1291
+ const finalized = options.finalize(textAcc, wireToolCalls, usage);
1292
+ resolveFinal(finalized);
1293
+ } catch (error) {
1294
+ rejectFinal(error);
1295
+ }
1296
+ })();
1297
+ return {
1298
+ chunks,
1299
+ finalResult
1300
+ };
1301
+ }
1302
+
1303
+ //#endregion
1304
+ //#region src/internal/execution/callExecutor.ts
1305
+ /**
1306
+ * Everything one provider target needs to attempt a call: request
1307
+ * building, retry with backoff, the per-target breaker, the per-target
1308
+ * limiter. Never exported publicly. `VernLLM` holds one per target and
1309
+ * owns the fallback loop and caching on top.
1310
+ */
1311
+ var CallExecutor = class {
1312
+ maxRetries;
1313
+ timeoutMs;
1314
+ chunkIdleTimeoutMs;
1315
+ baseDelayMs;
1316
+ nonRetryableStatus;
1317
+ parseJson;
1318
+ logger;
1319
+ redact;
1320
+ onUsage;
1321
+ onUsageFailure;
1322
+ reportEvent;
1323
+ breaker;
1324
+ limiter;
1325
+ isFallback;
1326
+ requestBuilder;
1327
+ constructor(providerName, client, model, options) {
1328
+ this.providerName = providerName;
1329
+ this.client = client;
1330
+ this.model = model;
1331
+ this.maxRetries = options.maxRetries;
1332
+ this.timeoutMs = options.timeoutMs;
1333
+ this.chunkIdleTimeoutMs = options.chunkIdleTimeoutMs;
1334
+ this.baseDelayMs = options.baseDelayMs;
1335
+ this.nonRetryableStatus = options.nonRetryableStatus;
1336
+ this.parseJson = options.parseJson ?? defaultParseJson;
1337
+ this.logger = options.logger;
1338
+ this.redact = options.redact;
1339
+ this.onUsage = options.onUsage;
1340
+ this.onUsageFailure = options.onUsageFailure;
1341
+ this.reportEvent = makeEventReporter(options.onEvent, this.logger);
1342
+ this.breaker = options.breaker;
1343
+ this.limiter = options.limiter;
1344
+ this.isFallback = options.isFallback ?? false;
1345
+ this.requestBuilder = new RequestBuilder({
1346
+ model,
1347
+ defaultMaxTokens: options.defaultMaxTokens,
1348
+ defaultTemperature: options.defaultTemperature
1349
+ });
1350
+ }
1351
+ getCircuitState(model) {
1352
+ return this.breaker?.getState(model);
1353
+ }
1354
+ /**
1355
+ * Throws if the breaker is open for this target/model, exactly like the
1356
+ * check `run`/`runStream` used to make internally. Exposed so `VernLLM`
1357
+ * can gate on it before reserving usage, avoiding a reserve-then-refund
1358
+ * round trip on a call that was never going to be attempted. `assertClosed`
1359
+ * has a stateful side effect (claiming a half-open trial slot), so it must
1360
+ * run exactly once per logical call: `run`/`runStream` no longer call it
1361
+ * themselves, this is now the only call site.
1362
+ */
1363
+ assertBreakerClosed(model) {
1364
+ this.breaker?.assertClosed(model ?? this.model);
1365
+ }
1366
+ /**
1367
+ * Runs a single logical call against this target: retry with backoff,
1368
+ * normalized error on exhaustion. Mirrors the old `VernLLM.call`'s
1369
+ * non-streaming branch, minus cache/usage-reservation and the breaker
1370
+ * check, which stay one layer up since they aren't per-target concerns
1371
+ * (see `assertBreakerClosed`).
1372
+ */
1373
+ async run(params, requestId, onAttempt) {
1374
+ const model = params.model ?? this.model;
1375
+ try {
1376
+ return await this.retryWithBackoff((attempt) => this.executeCall(params, requestId, attempt), requestId, model, params.signal, onAttempt);
1377
+ } catch (error) {
1378
+ const normalized = normalizeError(error, params.signal);
1379
+ if (this.countsTowardBreaker(normalized)) this.breaker?.recordFailure(model);
1380
+ this.logger.debug(`[VernLLM:${requestId}] error:\n${this.redactText(describeError(error))}`);
1381
+ throw normalized;
1382
+ }
1383
+ }
1384
+ /** Streaming counterpart to `run`. Mirrors the old streaming branch of `VernLLM.call`. */
1385
+ async runStream(params, requestId, onAttempt) {
1386
+ const model = params.model ?? this.model;
1387
+ try {
1388
+ return await this.retryWithBackoff((attempt) => this.executeStreamCall(params, requestId, attempt), requestId, model, params.signal, onAttempt);
1389
+ } catch (error) {
1390
+ const normalized = normalizeError(error, params.signal);
1391
+ if (this.countsTowardBreaker(normalized)) this.breaker?.recordFailure(model);
1392
+ this.logger.debug(`[VernLLM:${requestId}] stream-open error:\n${this.redactText(describeError(error))}`);
1393
+ throw normalized;
1394
+ }
1395
+ }
1396
+ /**
1397
+ * Performs a single attempt: builds the request (translating `tools` to
1398
+ * wire shape when present), dispatches it with a timeout, and shapes the
1399
+ * response into `T` or a `CallWithToolsResult<T>` when `params.tools` was
1400
+ * set. Throws on an empty response (no text and no tool_calls) so the
1401
+ * retry loop treats it like any other transient failure.
1402
+ */
1403
+ async executeCall(params, requestId, attempt) {
1404
+ const { useJson, model, request } = this.requestBuilder.build(params);
1405
+ let release;
1406
+ if (this.limiter) {
1407
+ const acquired = await this.limiter.acquire(this.limiter.estimate(request), params.signal);
1408
+ release = acquired.release;
1409
+ if (acquired.waitedMs > 0) this.reportEvent({
1410
+ kind: "rate_limited",
1411
+ requestId,
1412
+ provider: this.providerName,
1413
+ model,
1414
+ waitedMs: acquired.waitedMs,
1415
+ reason: acquired.reason ?? "rpm"
1416
+ });
1417
+ }
1418
+ try {
1419
+ const response = await withTimeout((attemptSignal) => this.client.chat.completions.create(request, { signal: attemptSignal }), this.timeoutMs, params.signal);
1420
+ const usage = this.extractUsage(response, requestId, model);
1421
+ release?.(this.actualTokensFor(usage));
1422
+ release = void 0;
1423
+ const rawContent = response.choices?.[0]?.message?.content;
1424
+ const wireToolCalls = response.choices?.[0]?.message?.tool_calls;
1425
+ return this.finalizeResponse(rawContent, wireToolCalls, params, useJson, model, usage, requestId, attempt);
1426
+ } finally {
1427
+ release?.();
1428
+ }
1429
+ }
1430
+ /** Applies `redact` (if configured); otherwise returns `text` unchanged. */
1431
+ redactText(text) {
1432
+ return this.redact ? this.redact(text) : text;
1433
+ }
1434
+ /**
1435
+ * Applies `redact` (if configured) to whatever the debug log is about
1436
+ * to show: real content when there is any, otherwise the tool-call
1437
+ * placeholder, which carries no user data and passes through
1438
+ * `redact` unchanged in practice but is included for a caller whose
1439
+ * `redact` does something structural (e.g. adding a marker) rather
1440
+ * than just scrubbing PII.
1441
+ */
1442
+ redactedOutput(content, wireToolCalls) {
1443
+ return this.redactText(content ?? `[${wireToolCalls?.length ?? 0} tool call(s)]`);
1444
+ }
1445
+ /**
1446
+ * Shapes a fully-arrived response (content and/or tool_calls, already
1447
+ * extracted from the provider's payload) into `T` or a
1448
+ * `CallWithToolsResult<T>`. Reused by the streaming path once it has
1449
+ * buffered the full text/tool-call deltas, so there's no separate
1450
+ * parsing/validation logic for streaming.
465
1451
  *
466
- * @param params - System/user content plus per-call overrides (model,
467
- * temperature, jsonMode, schema, signal, etc). See `CallParams`.
468
- * @returns The parsed (and optionally schema-validated) response, or the
469
- * raw string content when `jsonMode` is false and no `jsonSchema` is set.
1452
+ * Normalizes and reports usage failure on error itself, so every caller
1453
+ * gets identical error handling without duplicating it.
470
1454
  */
471
- async call(params) {
472
- this.breaker?.assertClosed();
473
- if (params.signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
474
- const requestId = params.requestId ?? randomUUID();
475
- return withReservedUsage(params, false, async () => {
476
- try {
477
- return await this.retryWithBackoff(() => this.executeCall(params, requestId), requestId, params.signal);
478
- } catch (error) {
479
- const normalized = normalizeError(error, params.signal);
480
- if (normalized.type !== "validation" && normalized.type !== "parse" && normalized.type !== "aborted") this.breaker?.recordFailure();
481
- this.logger.debug(`[vern:${requestId}] error:\n${describeError(error)}`);
482
- throw normalized;
1455
+ finalizeResponse(rawContent, wireToolCalls, params, useJson, model, usage, requestId, attempt) {
1456
+ try {
1457
+ const content = rawContent?.trim();
1458
+ if (!content && !wireToolCalls?.length) throw new LLMError("Empty LLM response", "api");
1459
+ this.logger.debug(`[VernLLM:${requestId}] output:\n${this.redactedOutput(content, wireToolCalls).slice(0, 800)}`);
1460
+ if (wireToolCalls?.length) {
1461
+ if (!params.tools) throw new LLMError("Provider returned tool_calls but no `tools` were sent with this call.", "api");
1462
+ const toolCalls = parseWireToolCalls(wireToolCalls);
1463
+ this.validateToolCallArguments(toolCalls, params.tools);
1464
+ this.breaker?.recordSuccess(model);
1465
+ this.reportUsage(usage);
1466
+ return {
1467
+ type: "tool_calls",
1468
+ toolCalls,
1469
+ ...content ? { content } : {}
1470
+ };
483
1471
  }
484
- }, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
1472
+ const textContent = content ?? "";
1473
+ if (!useJson) {
1474
+ this.breaker?.recordSuccess(model);
1475
+ this.reportUsage(usage);
1476
+ return params.tools ? {
1477
+ type: "content",
1478
+ content: textContent
1479
+ } : textContent;
1480
+ }
1481
+ const result = this.parseAndValidate(textContent, params.schema);
1482
+ this.breaker?.recordSuccess(model);
1483
+ this.reportUsage(usage);
1484
+ return params.tools ? {
1485
+ type: "content",
1486
+ content: result
1487
+ } : result;
1488
+ } catch (error) {
1489
+ const normalized = normalizeError(error, params.signal);
1490
+ if (usage && normalized.type !== "aborted") this.reportUsageFailure(usage, normalized, attempt);
1491
+ throw normalized;
1492
+ }
1493
+ }
1494
+ /**
1495
+ * Opens a stream for a single attempt: builds the request exactly like
1496
+ * `executeCall`, then requires `createStream` on the client (a clear
1497
+ * `validation` error if the adapter doesn't support it). The timeout
1498
+ * wraps stream construction and the first `.next()` together, not just
1499
+ * construction: calling an `async function*` returns an iterator
1500
+ * synchronously without running its body until `.next()` is first
1501
+ * invoked, so timing only construction would time an operation that's
1502
+ * always instant, not the actual connection. Both are folded into a
1503
+ * single `withTimeout` so the same abort signal reaches whatever the
1504
+ * adapter's `createStream` uses internally for its first network
1505
+ * round-trip.
1506
+ *
1507
+ * Circuit-breaker success is recorded once the stream fully completes,
1508
+ * not on the first chunk arriving, so a connection that opens but then
1509
+ * dies mid-stream isn't masked as a success (see `buildStreamResult`).
1510
+ */
1511
+ async executeStreamCall(params, requestId, attempt) {
1512
+ const { useJson, model, request } = this.requestBuilder.build(params);
1513
+ const completions = this.client.chat.completions;
1514
+ if (!completions.createStream) throw new LLMError("stream: true requires a client/adapter with createStream", "validation");
1515
+ const createStream = completions.createStream.bind(completions);
1516
+ let release;
1517
+ if (this.limiter) {
1518
+ const acquired = await this.limiter.acquire(this.limiter.estimate(request), params.signal);
1519
+ release = acquired.release;
1520
+ if (acquired.waitedMs > 0) this.reportEvent({
1521
+ kind: "rate_limited",
1522
+ requestId,
1523
+ provider: this.providerName,
1524
+ model,
1525
+ waitedMs: acquired.waitedMs,
1526
+ reason: acquired.reason ?? "rpm"
1527
+ });
1528
+ }
1529
+ const streamController = new AbortController();
1530
+ const combinedExternal = params.signal ? AbortSignal.any([params.signal, streamController.signal]) : streamController.signal;
1531
+ try {
1532
+ const { iterator, first } = await withTimeout(async (attemptSignal) => {
1533
+ const streamIterator = createStream(request, { signal: attemptSignal })[Symbol.asyncIterator]();
1534
+ const firstResult = await streamIterator.next();
1535
+ return {
1536
+ iterator: streamIterator,
1537
+ first: firstResult
1538
+ };
1539
+ }, this.timeoutMs, combinedExternal);
1540
+ if (first.done) throw new LLMError("Empty LLM response", "api");
1541
+ const releaseAtOpen = release;
1542
+ const result = buildStreamResult(iterator, first, {
1543
+ requestId,
1544
+ model,
1545
+ providerName: this.providerName,
1546
+ isFallback: this.isFallback,
1547
+ chunkIdleTimeoutMs: params.chunkIdleTimeoutMs ?? this.chunkIdleTimeoutMs,
1548
+ streamController,
1549
+ logger: this.logger,
1550
+ signal: params.signal,
1551
+ onStreamSuccess: (usage) => {
1552
+ this.breaker?.recordSuccess(model);
1553
+ releaseAtOpen?.(this.actualTokensFor(usage));
1554
+ },
1555
+ onStreamFailure: (normalized, usage) => {
1556
+ if (normalized.type === "timeout") this.breaker?.recordFailure(model);
1557
+ if (usage && normalized.type !== "aborted") this.reportUsageFailure(usage, normalized, attempt, true);
1558
+ releaseAtOpen?.(this.actualTokensFor(usage));
1559
+ },
1560
+ finalize: (textAcc, wireToolCalls, usage) => this.finalizeResponse(textAcc, wireToolCalls, params, useJson, model, usage, requestId, attempt)
1561
+ });
1562
+ release = void 0;
1563
+ return result;
1564
+ } finally {
1565
+ release?.();
1566
+ }
1567
+ }
1568
+ /**
1569
+ * Checks every `ToolCall` against the `tools` that were offered, catching
1570
+ * a hallucinated tool name and a duplicate call id before either reaches
1571
+ * the application's dispatch table, then runs each tool's
1572
+ * `argumentsSchema`, if present.
1573
+ *
1574
+ * Contract failures (unknown name, duplicate id) are collected across
1575
+ * every call and thrown together, since retrying a request that already
1576
+ * has these errors cannot help (`shouldRetry` excludes them by `code`)
1577
+ * and a caller fixing them wants to see every one, not just the first.
1578
+ * Schema failures keep the original single-error, `type: 'validation'`
1579
+ * shape rather than being folded into the aggregate, since they're a
1580
+ * distinct failure kind from the contract failures above (also excluded
1581
+ * from retry, by `type` rather than `code`; see `shouldRetry`).
1582
+ */
1583
+ validateToolCallArguments(toolCalls, tools) {
1584
+ const known = new Map(tools.map((t) => [t.name, t]));
1585
+ const seenIds = new Set();
1586
+ const toolIssues = [];
1587
+ for (const call of toolCalls) {
1588
+ if (seenIds.has(call.id)) toolIssues.push({
1589
+ name: call.name,
1590
+ toolCallId: call.id,
1591
+ code: "duplicate_tool_call_id"
1592
+ });
1593
+ seenIds.add(call.id);
1594
+ if (!known.has(call.name)) toolIssues.push({
1595
+ name: call.name,
1596
+ toolCallId: call.id,
1597
+ code: "unknown_tool"
1598
+ });
1599
+ }
1600
+ if (toolIssues.length > 0) {
1601
+ const unknownTool = toolIssues.find((i) => i.code === "unknown_tool");
1602
+ const primary = unknownTool ? `Model requested tool "${unknownTool.name}", which was not in the tools offered ([${[...known.keys()].join(", ")}]).` : `Duplicate tool call id "${toolIssues[0].toolCallId}" in the model's response.`;
1603
+ const message = toolIssues.length > 1 ? `${primary} (${toolIssues.length} tool call issues total, see toolIssues.)` : primary;
1604
+ const error = new LLMError(message, "api", void 0, void 0, void 0, void 0, unknownTool ? "unknown_tool" : "duplicate_tool_call_id");
1605
+ error.toolIssues = toolIssues;
1606
+ throw error;
1607
+ }
1608
+ for (const call of toolCalls) {
1609
+ const definition = known.get(call.name);
1610
+ if (!definition?.argumentsSchema) continue;
1611
+ const result = definition.argumentsSchema.safeParse(call.arguments);
1612
+ if (!result.success) throw new LLMError(`Arguments for tool call "${call.name}" failed validation`, "validation", void 0, result.error);
1613
+ }
485
1614
  }
486
1615
  /** Runs `fn`, retrying with backoff according to `shouldRetry`. */
487
- async retryWithBackoff(fn, requestId, signal) {
1616
+ async retryWithBackoff(fn, requestId, model, signal, onAttempt) {
488
1617
  let lastError;
489
1618
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
490
- if (attempt > 0) await this.recoverDelay(requestId, attempt, lastError, signal);
491
- return await fn();
1619
+ if (attempt > 0) await this.recoverDelay(requestId, model, attempt, lastError, signal);
1620
+ onAttempt?.();
1621
+ return await fn(attempt);
492
1622
  } catch (error) {
493
1623
  lastError = error;
494
1624
  if (!this.shouldRetry(error, signal)) break;
@@ -496,105 +1626,60 @@ var VernLLM = class {
496
1626
  throw lastError;
497
1627
  }
498
1628
  /**
499
- * Performs a single attempt: builds the request, dispatches it with a
500
- * timeout, and shapes the response. Throws on an empty response so the
501
- * retry loop treats it like any other transient failure.
502
- */
503
- async executeCall(params, requestId) {
504
- const { useJson, model, request } = this.buildRequestPayload(params);
505
- const response = await withTimeout((attemptSignal) => this.client.chat.completions.create(request, { signal: attemptSignal }), this.timeoutMs, params.signal);
506
- const content = response.choices?.[0]?.message?.content?.trim();
507
- if (!content) throw new LLMError("Empty LLM response", "api");
508
- this.logger.debug(`[vern:${requestId}] output:\n${content.slice(0, 800)}`);
509
- this.recordUsage(response, requestId, model);
510
- if (!useJson) {
511
- this.breaker?.recordSuccess();
512
- return content;
513
- }
514
- const result = this.parseAndValidate(content, params.schema);
515
- this.breaker?.recordSuccess();
516
- return result;
517
- }
518
- /**
519
- * Validates `history` alternates user/assistant turns, since providers
520
- * like Anthropic/Gemini reject or mishandle consecutive same-role turns.
1629
+ * Pulls `TokenUsage` out of a raw response, if the provider reported it.
1630
+ * Extraction doesn't depend on what happens to the response afterward, so
1631
+ * a malformed body can still yield usage if the provider's usage block
1632
+ * itself came through intact.
521
1633
  */
522
- validateHistory(history) {
523
- let previousRole;
524
- for (const [index, turn] of history.entries()) {
525
- if (turn.role !== "user" && turn.role !== "assistant") throw new LLMError(`Invalid history[${index}].role "${turn.role}": must be "user" or "assistant"`, "validation");
526
- if (turn.role === previousRole) throw new LLMError(`history must alternate user/assistant turns: consecutive "${turn.role}" turns at history[${index - 1}] and history[${index}]`, "validation");
527
- previousRole = turn.role;
528
- }
529
- if (previousRole === "user") throw new LLMError("The last entry in history is a \"user\" turn, which would collide with the current userContent turn. history must end with an \"assistant\" turn (or be empty).", "validation");
530
- }
531
- /** Applies per-call defaults and shapes params into the client's request object. */
532
- buildRequestPayload(params) {
533
- const { systemPrompt, userContent, history = [], temperature = .2, jsonMode = true, maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema } = params;
534
- const useJson = jsonMode || Boolean(jsonSchema);
535
- if (params.schema && !useJson) throw new LLMError("schema was provided but jsonMode: false disables JSON parsing, so nothing would validate it. Remove jsonMode: false, set jsonSchema, or remove schema.", "validation");
536
- const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
537
- this.validateHistory(history);
538
- const request = {
539
- model,
540
- temperature,
541
- max_tokens: maxTokens,
542
- ...responseFormat ? { response_format: responseFormat } : {},
543
- ...reasoningEffort ? { reasoning_effort: reasoningEffort } : {},
544
- messages: [
545
- ...systemPrompt ? [{
546
- role: "system",
547
- content: systemPrompt
548
- }] : [],
549
- ...history.map((turn) => ({
550
- role: turn.role,
551
- content: turn.content
552
- })),
553
- {
554
- role: "user",
555
- content: userContent
556
- }
557
- ]
558
- };
1634
+ extractUsage(response, requestId, model) {
1635
+ if (!response.usage) return void 0;
559
1636
  return {
560
- useJson,
1637
+ promptTokens: response.usage.prompt_tokens ?? 0,
1638
+ completionTokens: response.usage.completion_tokens ?? 0,
1639
+ totalTokens: response.usage.total_tokens ?? 0,
1640
+ requestId,
561
1641
  model,
562
- request
1642
+ provider: this.providerName,
1643
+ usedFallback: this.isFallback
563
1644
  };
564
1645
  }
565
1646
  /**
566
- * Chooses the response format: a provider-native `jsonSchema` takes
567
- * priority when supplied (constrains generation directly), otherwise
568
- * falls back to the looser `json_object` mode when JSON output is
569
- * requested, or no format at all for plain text responses.
1647
+ * The token count to reconcile the rate limiter against for a finished
1648
+ * attempt: `totalTokens` when reported, otherwise the sum of prompt and
1649
+ * completion tokens, matching `reportUsageFailure`'s own fallback below
1650
+ * for a hand-rolled client that reports the parts but omits the total.
570
1651
  */
571
- buildResponseFormat(jsonSchema, useJson) {
572
- if (jsonSchema) return {
573
- type: "json_schema",
574
- json_schema: {
575
- name: jsonSchema.name,
576
- schema: jsonSchema.schema,
577
- strict: jsonSchema.strict ?? true,
578
- description: jsonSchema.description
579
- }
580
- };
581
- return useJson ? { type: "json_object" } : void 0;
1652
+ actualTokensFor(usage) {
1653
+ if (!usage) return void 0;
1654
+ return usage.totalTokens || usage.promptTokens + usage.completionTokens;
582
1655
  }
583
- /** Reports token usage to `onUsage`, swallowing and logging any error it throws. */
584
- recordUsage(response, requestId, model) {
585
- if (!response.usage || !this.onUsage) return;
1656
+ /** Reports token usage for a successful call, swallowing and logging any error `onUsage` throws. */
1657
+ reportUsage(usage) {
1658
+ if (!usage || !this.onUsage) return;
586
1659
  try {
587
- this.onUsage({
588
- promptTokens: response.usage.prompt_tokens ?? 0,
589
- completionTokens: response.usage.completion_tokens ?? 0,
590
- totalTokens: response.usage.total_tokens ?? 0,
591
- requestId,
592
- model
593
- });
1660
+ this.onUsage(usage);
594
1661
  } catch (error) {
595
1662
  this.logger.error("[VernLLM] onUsage failed", { message: error instanceof Error ? error.message : "unknown" });
596
1663
  }
597
1664
  }
1665
+ /**
1666
+ * Reports token usage spent on an attempt that then failed, so it isn't
1667
+ * dropped alongside the error. Covers any error thrown after usage
1668
+ * extraction, since all of them happen only after a response (real
1669
+ * spend) already arrived. Swallows and logs any error `onUsageFailure`
1670
+ * itself throws.
1671
+ */
1672
+ reportUsageFailure(usage, error, attempt, terminal = false) {
1673
+ const displayTokens = usage.totalTokens || usage.promptTokens + usage.completionTokens;
1674
+ const attemptText = terminal ? "mid-stream failure (terminal, no further attempts)" : `attempt ${attempt + 1}/${this.maxRetries + 1}`;
1675
+ this.logger.warn(`[VernLLM:${usage.requestId}] usage failure, ${attemptText}: type=${error.type} tokens=${displayTokens}`);
1676
+ if (!this.onUsageFailure) return;
1677
+ try {
1678
+ this.onUsageFailure(usage, error);
1679
+ } catch (hookError) {
1680
+ this.logger.error("[VernLLM] onUsageFailure failed", { message: hookError instanceof Error ? hookError.message : "unknown" });
1681
+ }
1682
+ }
598
1683
  /** Parses response content as JSON and validates it against `schema` when supplied. */
599
1684
  parseAndValidate(content, schema) {
600
1685
  let parsed;
@@ -613,106 +1698,681 @@ var VernLLM = class {
613
1698
  * Waits out the backoff delay for a retry attempt, honoring a
614
1699
  * Retry-After header on the failed attempt's error when present.
615
1700
  * Both Retry-After and plain exponential backoff are capped at the same
616
- * max delay (see `DEFAULT_MAX_DELAY_MS` in `vernLLM.utils.ts`).
1701
+ * max delay (see `DEFAULT_MAX_DELAY_MS` in `retry.utils.ts`).
617
1702
  */
618
- async recoverDelay(requestId, attempt, error, signal) {
1703
+ async recoverDelay(requestId, model, attempt, error, signal) {
619
1704
  const retryAfterMs = extractRetryAfterMs(error);
620
1705
  const delay = retryAfterMs ?? getBackoffDelay(this.baseDelayMs, attempt);
621
- this.logger.warn(`[vern:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterMs !== void 0 ? " (honoring Retry-After)" : ""));
1706
+ const retryAfterHonored = retryAfterMs !== void 0;
1707
+ this.logger.warn(`[VernLLM:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterHonored ? " (honoring Retry-After)" : ""));
1708
+ this.reportEvent({
1709
+ kind: "retry",
1710
+ requestId,
1711
+ provider: this.providerName,
1712
+ model,
1713
+ attempt,
1714
+ maxRetries: this.maxRetries,
1715
+ delayMs: delay,
1716
+ retryAfterHonored,
1717
+ error: normalizeError(error, signal)
1718
+ });
622
1719
  await waitForRetry(delay, signal);
623
1720
  }
1721
+ isNonRetryableToolContractError(error) {
1722
+ return error instanceof LLMError && (error.code === "unknown_tool" || error.code === "duplicate_tool_call_id");
1723
+ }
624
1724
  /** Decides whether a failed attempt is worth retrying. */
625
1725
  shouldRetry(error, signal) {
626
1726
  if (signal?.aborted) return false;
627
1727
  if (error instanceof LLMError && (error.type === "parse" || error.type === "validation")) return false;
1728
+ if (error instanceof LLMError && error.code === "local_rate_limit") return false;
1729
+ if (this.isNonRetryableToolContractError(error)) return false;
628
1730
  const status = extractStatus(error);
629
1731
  return !(status !== void 0 && this.nonRetryableStatus.includes(status));
630
1732
  }
631
1733
  /**
1734
+ * Decides whether a failed attempt should count toward the circuit
1735
+ * breaker's failure threshold. A model hallucinating a tool name or
1736
+ * reusing a call id isn't the provider being unhealthy, it's a model
1737
+ * response defect that will very likely recur regardless of provider
1738
+ * health, so it shouldn't push a healthy provider's circuit toward
1739
+ * opening. Mirrors the same reasoning `shouldRetry` already applies to
1740
+ * `parse`/`validation`/these same tool-contract codes.
1741
+ */
1742
+ countsTowardBreaker(error) {
1743
+ if (error.type === "validation" || error.type === "parse" || error.type === "aborted" || error.code === "local_rate_limit" || this.isNonRetryableToolContractError(error)) return false;
1744
+ return true;
1745
+ }
1746
+ };
1747
+
1748
+ //#endregion
1749
+ //#region src/logger.ts
1750
+ /**
1751
+ * Default logger. `debug` is gated by the `debug` option on VernLLM
1752
+ * warn/error always fire since they indicate real problems (retries, cache failures)
1753
+ */
1754
+ var ConsoleLogger = class {
1755
+ constructor(debugEnabled) {
1756
+ this.debugEnabled = debugEnabled;
1757
+ }
1758
+ debug(message) {
1759
+ if (this.debugEnabled) console.debug(message);
1760
+ }
1761
+ warn(message) {
1762
+ console.warn(message);
1763
+ }
1764
+ error(message, meta) {
1765
+ console.error(message, meta ?? "");
1766
+ }
1767
+ };
1768
+
1769
+ //#endregion
1770
+ //#region src/rateLimit.ts
1771
+ /** Default `estimateTokens`: chars/4 over every message's content, plus the requested `max_tokens`. */
1772
+ function defaultEstimateTokens(request) {
1773
+ const messagesChars = request.messages.reduce((sum, message) => {
1774
+ const content = message.content;
1775
+ if (typeof content === "string") return sum + content.length;
1776
+ if (content === void 0 || content === null) return sum;
1777
+ try {
1778
+ return sum + JSON.stringify(content).length;
1779
+ } catch {
1780
+ return sum;
1781
+ }
1782
+ }, 0);
1783
+ return Math.ceil(messagesChars / 4) + (request.max_tokens ?? 0);
1784
+ }
1785
+ /**
1786
+ * A capacity that refills continuously. Used for requests per minute and
1787
+ * tokens per minute, where `refillPerMs` is `capacity / 60000`, and for
1788
+ * concurrency, where `refillPerMs` is 0 and every release calls
1789
+ * `give(1)` instead of relying on the clock.
1790
+ */
1791
+ var TokenBucket = class {
1792
+ available;
1793
+ lastRefill = Date.now();
1794
+ constructor(capacity, refillPerMs) {
1795
+ this.capacity = capacity;
1796
+ this.refillPerMs = refillPerMs;
1797
+ this.available = capacity;
1798
+ }
1799
+ refill() {
1800
+ if (this.refillPerMs === 0) return;
1801
+ const now = Date.now();
1802
+ this.available = Math.min(this.capacity, this.available + (now - this.lastRefill) * this.refillPerMs);
1803
+ this.lastRefill = now;
1804
+ }
1805
+ /** Refills, then takes `amount` if available. Leaves the bucket untouched if it can't. */
1806
+ tryTake(amount) {
1807
+ this.refill();
1808
+ if (this.available < amount) return false;
1809
+ this.available -= amount;
1810
+ return true;
1811
+ }
1812
+ /**
1813
+ * Refills, then reports how many ms until this bucket could supply
1814
+ * `amount`, assuming nothing else takes from it meanwhile. Returns 0 if
1815
+ * it already can, `Infinity` if it never will on its own (a
1816
+ * concurrency bucket, `refillPerMs === 0`, only frees via `give`).
1817
+ */
1818
+ msUntilAvailable(amount) {
1819
+ this.refill();
1820
+ if (this.available >= amount) return 0;
1821
+ if (this.refillPerMs === 0) return Infinity;
1822
+ return (amount - this.available) / this.refillPerMs;
1823
+ }
1824
+ /**
1825
+ * Gives capacity back. Not floored at 0: a bad token-usage estimate can
1826
+ * push `available` negative, and it self-corrects on the next refill
1827
+ * rather than being clamped away immediately. Only ceilinged at
1828
+ * `capacity`, so a give can never overfill the bucket.
1829
+ */
1830
+ give(amount) {
1831
+ this.available = Math.min(this.capacity, this.available + amount);
1832
+ }
1833
+ /** The bucket's ceiling, e.g. so a request that could never fit can fail fast instead of queueing forever. */
1834
+ getCapacity() {
1835
+ return this.capacity;
1836
+ }
1837
+ };
1838
+ /**
1839
+ * `setTimeout` silently clamps any delay above this (~24.8 days) instead
1840
+ * of erroring, so an uncapped delay derived from a very small
1841
+ * `requestsPerMinute`/`tokensPerMinute` could wrap around to firing
1842
+ * almost immediately instead of waiting. Mirrors the same guard in
1843
+ * `withTimeout`/`withChunkIdleTimeout`.
1844
+ */
1845
+ const MAX_WAKE_DELAY_MS = 2147483647;
1846
+ /**
1847
+ * Per-target rate limiter. Up to three buckets (requests/min, tokens/min,
1848
+ * concurrency) behind one FIFO queue, so a large call isn't starved by a
1849
+ * stream of small ones. Any bucket omitted from `options` has infinite
1850
+ * capacity and never blocks.
1851
+ */
1852
+ var RateLimiter = class {
1853
+ requests;
1854
+ tokens;
1855
+ concurrency;
1856
+ maxQueueMs;
1857
+ maxQueueSize;
1858
+ estimateTokensFn;
1859
+ queue = [];
1860
+ /**
1861
+ * A single scheduled re-check for the head of the queue when it's
1862
+ * blocked on a bucket that refills on its own clock (rpm/tpm), so a
1863
+ * queue that nobody calls `acquire`/`release` on again isn't stuck
1864
+ * forever waiting for an external trigger to re-drain it. Not needed
1865
+ * for a concurrency block, which only clears via `release`.
1866
+ */
1867
+ wakeTimer;
1868
+ constructor(options) {
1869
+ if (options.requestsPerMinute) this.requests = new TokenBucket(options.requestsPerMinute, options.requestsPerMinute / 6e4);
1870
+ if (options.tokensPerMinute) this.tokens = new TokenBucket(options.tokensPerMinute, options.tokensPerMinute / 6e4);
1871
+ if (options.maxConcurrent) this.concurrency = new TokenBucket(options.maxConcurrent, 0);
1872
+ this.maxQueueMs = options.maxQueueMs ?? 3e4;
1873
+ this.maxQueueSize = options.maxQueueSize ?? 0;
1874
+ this.estimateTokensFn = options.estimateTokens ?? defaultEstimateTokens;
1875
+ }
1876
+ /** Pre-flight token estimate for a request, per the configured (or default) heuristic. */
1877
+ estimate(request) {
1878
+ return this.estimateTokensFn(request);
1879
+ }
1880
+ /**
1881
+ * Waits for capacity in every configured bucket, then takes from each.
1882
+ * The returned `release` gives the concurrency slot back and reconciles
1883
+ * the token bucket against real usage; it must run in a `finally` block.
1884
+ */
1885
+ async acquire(estimatedTokens, signal) {
1886
+ if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
1887
+ if (!Number.isFinite(estimatedTokens) || estimatedTokens < 0) throw new LLMError(`estimatedTokens must be a finite, non-negative number, got ${String(estimatedTokens)}`, "validation");
1888
+ if (this.tokens && estimatedTokens > this.tokens.getCapacity()) throw new LLMError(`estimatedTokens (${estimatedTokens}) exceeds the configured tokensPerMinute capacity (${this.tokens.getCapacity()}); this call could never acquire capacity.`, "quota_exceeded", void 0, void 0, void 0, void 0, "local_rate_limit");
1889
+ if (this.queue.length === 0) {
1890
+ const attempt = this.tryAcquireBuckets(estimatedTokens);
1891
+ if (attempt.ok) return {
1892
+ release: this.makeRelease(estimatedTokens),
1893
+ waitedMs: 0
1894
+ };
1895
+ if (this.maxQueueSize > 0 && this.queue.length >= this.maxQueueSize) throw this.queueFullError();
1896
+ return this.enqueue(estimatedTokens, attempt.reason, signal);
1897
+ }
1898
+ if (this.maxQueueSize > 0 && this.queue.length >= this.maxQueueSize) throw this.queueFullError();
1899
+ return this.enqueue(estimatedTokens, void 0, signal);
1900
+ }
1901
+ queueFullError() {
1902
+ return new LLMError("Rate limit queue is full", "quota_exceeded", void 0, void 0, void 0, void 0, "local_rate_limit");
1903
+ }
1904
+ enqueue(estimatedTokens, initialReason, signal) {
1905
+ return new Promise((resolvePromise, rejectPromise) => {
1906
+ const waiter = {
1907
+ estimatedTokens,
1908
+ enqueuedAt: Date.now(),
1909
+ lastReason: initialReason,
1910
+ resolve: (result) => {
1911
+ cleanup();
1912
+ resolvePromise(result);
1913
+ },
1914
+ reject: (error) => {
1915
+ cleanup();
1916
+ if (this.wakeTimer) {
1917
+ clearTimeout(this.wakeTimer);
1918
+ this.wakeTimer = void 0;
1919
+ }
1920
+ this.drain();
1921
+ rejectPromise(error);
1922
+ }
1923
+ };
1924
+ let queueTimer;
1925
+ const onAbort = () => {
1926
+ waiter.reject(new LLMError("LLM request aborted", "aborted"));
1927
+ };
1928
+ const cleanup = () => {
1929
+ if (queueTimer) clearTimeout(queueTimer);
1930
+ signal?.removeEventListener("abort", onAbort);
1931
+ const index = this.queue.indexOf(waiter);
1932
+ if (index !== -1) this.queue.splice(index, 1);
1933
+ };
1934
+ if (this.maxQueueMs > 0) queueTimer = setTimeout(() => {
1935
+ waiter.reject(new LLMError("Rate limit queue timed out before capacity was available", "quota_exceeded", void 0, void 0, void 0, void 0, "local_rate_limit"));
1936
+ }, this.maxQueueMs);
1937
+ signal?.addEventListener("abort", onAbort, { once: true });
1938
+ this.queue.push(waiter);
1939
+ this.drain();
1940
+ });
1941
+ }
1942
+ /**
1943
+ * Checks and takes from every configured bucket as one atomic unit: if
1944
+ * any bucket lacks capacity, whatever was already taken from the
1945
+ * earlier ones in this attempt is rolled back before reporting which
1946
+ * bucket blocked.
1947
+ */
1948
+ tryAcquireBuckets(estimatedTokens) {
1949
+ const taken = [];
1950
+ const take = (bucket, amount) => {
1951
+ if (!bucket) return true;
1952
+ if (!bucket.tryTake(amount)) return false;
1953
+ taken.push({
1954
+ bucket,
1955
+ amount
1956
+ });
1957
+ return true;
1958
+ };
1959
+ if (!take(this.concurrency, 1)) return {
1960
+ ok: false,
1961
+ reason: "concurrency"
1962
+ };
1963
+ if (!take(this.requests, 1)) {
1964
+ for (const entry of taken) entry.bucket.give(entry.amount);
1965
+ return {
1966
+ ok: false,
1967
+ reason: "rpm"
1968
+ };
1969
+ }
1970
+ if (!take(this.tokens, estimatedTokens)) {
1971
+ for (const entry of taken) entry.bucket.give(entry.amount);
1972
+ return {
1973
+ ok: false,
1974
+ reason: "tpm"
1975
+ };
1976
+ }
1977
+ return { ok: true };
1978
+ }
1979
+ /** Drains the queue head first. Stops at the first waiter that still can't proceed, so no one is starved out of turn. */
1980
+ drain() {
1981
+ while (this.queue.length > 0) {
1982
+ const waiter = this.queue[0];
1983
+ const attempt = this.tryAcquireBuckets(waiter.estimatedTokens);
1984
+ if (!attempt.ok) {
1985
+ waiter.lastReason = attempt.reason;
1986
+ this.scheduleWake(attempt.reason, waiter.estimatedTokens);
1987
+ return;
1988
+ }
1989
+ const waitedMs = Date.now() - waiter.enqueuedAt;
1990
+ waiter.resolve({
1991
+ release: this.makeRelease(waiter.estimatedTokens),
1992
+ waitedMs,
1993
+ reason: waiter.lastReason
1994
+ });
1995
+ }
1996
+ }
1997
+ /**
1998
+ * Schedules a one-shot re-check of the queue for whenever the bucket
1999
+ * that's currently blocking the head waiter should next have enough
2000
+ * capacity. A no-op for a concurrency block (only `release` can clear
2001
+ * that) or while a wake is already pending.
2002
+ */
2003
+ scheduleWake(reason, estimatedTokens) {
2004
+ if (this.wakeTimer) return;
2005
+ const ms = reason === "rpm" ? this.requests?.msUntilAvailable(1) : reason === "tpm" ? this.tokens?.msUntilAvailable(estimatedTokens) : void 0;
2006
+ if (ms === void 0 || !Number.isFinite(ms)) return;
2007
+ const delay = Math.min(Math.max(1, Math.ceil(ms)), MAX_WAKE_DELAY_MS);
2008
+ this.wakeTimer = setTimeout(() => {
2009
+ this.wakeTimer = void 0;
2010
+ this.drain();
2011
+ }, delay);
2012
+ }
2013
+ /**
2014
+ * Builds the one-shot release closure for an acquired slot. Only the
2015
+ * concurrency bucket is given back on release; the requests-per-minute
2016
+ * bucket is a real spend that only recovers via its own refill, and the
2017
+ * tokens bucket is reconciled against `actualTokens` rather than fully
2018
+ * refunded, since real tokens really were spent.
2019
+ */
2020
+ makeRelease(estimatedTokens) {
2021
+ let released = false;
2022
+ return (actualTokens) => {
2023
+ if (released) return;
2024
+ released = true;
2025
+ this.concurrency?.give(1);
2026
+ if (this.tokens && actualTokens !== void 0 && Number.isFinite(actualTokens)) this.tokens.give(estimatedTokens - actualTokens);
2027
+ this.drain();
2028
+ };
2029
+ }
2030
+ };
2031
+
2032
+ //#endregion
2033
+ //#region src/vernLLM.ts
2034
+ /**
2035
+ * A resilient layer around an LLM chat completions client. This is VernLLM!
2036
+ *
2037
+ * Adds retry with backoff and jitter, per-attempt timeouts, an optional
2038
+ * circuit breaker, JSON parsing with optional schema validation, usage
2039
+ * tracking, and an optional response cache. All configurable, all opt-in
2040
+ * beyond sensible defaults.
2041
+ */
2042
+ var VernLLM = class {
2043
+ logger;
2044
+ /**
2045
+ * One `CallExecutor` per provider target: index 0 is the primary,
2046
+ * everything after it is a `fallback` target, in the order declared.
2047
+ * Each owns its own request building, retry/timeout, circuit breaker,
2048
+ * and rate limiter. `call()` walks this array in `runFallbackChain`,
2049
+ * moving to the next entry only when `fallbackOn` says to.
2050
+ */
2051
+ executors;
2052
+ /** Decides whether a failed target is followed by the next one or the chain stops. See `VernLLMOptions['fallbackOn']`. */
2053
+ fallbackOn;
2054
+ /** Reports a `'fallback'` event when the chain moves to the next target. Shared `onEvent` plumbing, same as every executor's. */
2055
+ reportEvent;
2056
+ /**
2057
+ * Owns cache key resolution, cache reads/writes, and in-flight
2058
+ * coalescing for `cachedCall()`. Independent of `executor`: it only
2059
+ * ever calls back into `this.call()` as an opaque function.
2060
+ */
2061
+ cacheOrchestrator;
2062
+ /**
2063
+ * @param options Client, model, and tunables. Defaults: `maxRetries` 1,
2064
+ * `timeoutMs` 25000, `baseDelayMs` 500, `defaultMaxTokens` 1000,
2065
+ * `defaultTemperature` 0.2, `cache` an in-memory adapter,
2066
+ * `nonRetryableStatus` `[400, 401, 403, 404, 422]`, `debug` false.
2067
+ */
2068
+ constructor(options) {
2069
+ this.logger = options.logger ?? new ConsoleLogger(options.debug ?? false);
2070
+ const providerName = options.name ?? "primary";
2071
+ this.cacheOrchestrator = new CacheOrchestrator(options.cache ?? new InMemoryCacheAdapter(), this.logger);
2072
+ this.fallbackOn = options.fallbackOn ?? defaultFallbackOn;
2073
+ this.reportEvent = makeEventReporter(options.onEvent, this.logger);
2074
+ const primaryDefaultTemperature = options.defaultTemperature === void 0 ? .2 : options.defaultTemperature;
2075
+ const primaryTarget = {
2076
+ client: options.client,
2077
+ model: options.model,
2078
+ name: providerName,
2079
+ maxRetries: options.maxRetries,
2080
+ timeoutMs: options.timeoutMs,
2081
+ chunkIdleTimeoutMs: options.chunkIdleTimeoutMs,
2082
+ baseDelayMs: options.baseDelayMs,
2083
+ defaultMaxTokens: options.defaultMaxTokens,
2084
+ defaultTemperature: primaryDefaultTemperature,
2085
+ nonRetryableStatus: options.nonRetryableStatus,
2086
+ circuitBreaker: options.circuitBreaker,
2087
+ rateLimit: options.rateLimit
2088
+ };
2089
+ const declaredFallbacks = Array.isArray(options.fallback) ? options.fallback : options.fallback ? [options.fallback] : [];
2090
+ const targets = [primaryTarget, ...declaredFallbacks];
2091
+ this.executors = targets.map((target, i) => {
2092
+ const isFallback = i > 0;
2093
+ const name = target.name ?? (isFallback ? `fallback[${i - 1}]` : providerName);
2094
+ const breaker = buildCircuitBreaker(target.circuitBreaker, name, target.model, options.onEvent, this.logger);
2095
+ return new CallExecutor(name, target.client, target.model, {
2096
+ maxRetries: target.maxRetries ?? options.maxRetries ?? 1,
2097
+ timeoutMs: target.timeoutMs ?? options.timeoutMs ?? 25e3,
2098
+ chunkIdleTimeoutMs: target.chunkIdleTimeoutMs ?? options.chunkIdleTimeoutMs ?? 3e4,
2099
+ baseDelayMs: target.baseDelayMs ?? options.baseDelayMs ?? 500,
2100
+ defaultMaxTokens: target.defaultMaxTokens ?? options.defaultMaxTokens ?? 1e3,
2101
+ defaultTemperature: target.defaultTemperature === void 0 ? primaryDefaultTemperature : target.defaultTemperature,
2102
+ nonRetryableStatus: target.nonRetryableStatus ?? options.nonRetryableStatus ?? [
2103
+ 400,
2104
+ 401,
2105
+ 403,
2106
+ 404,
2107
+ 422
2108
+ ],
2109
+ parseJson: options.parseJson,
2110
+ logger: this.logger,
2111
+ redact: options.redact,
2112
+ onUsage: options.onUsage,
2113
+ onUsageFailure: options.onUsageFailure,
2114
+ onEvent: options.onEvent,
2115
+ breaker,
2116
+ limiter: target.rateLimit ? new RateLimiter(target.rateLimit) : void 0,
2117
+ isFallback
2118
+ });
2119
+ });
2120
+ }
2121
+ /** Logs a failed refundUsage attempt via the configured logger. */
2122
+ logRefundError(logMessage, error) {
2123
+ this.logger.error(logMessage, { message: error instanceof Error ? error.message : "unknown" });
2124
+ }
2125
+ /**
2126
+ * Walks `this.executors` in order, running `attempt` against each until
2127
+ * one succeeds or every target has failed. `run` on a lone target
2128
+ * (no `fallback` configured) throws exactly what it throws today: the
2129
+ * loop's single iteration path is unchanged from pre-fallback behavior.
2130
+ *
2131
+ * For streaming, `attempt` is `executor.runStream`, whose own retries
2132
+ * only cover *opening* the stream (see `CallExecutor.runStream`). A
2133
+ * mid-stream failure surfaces through `finalResult` after this function
2134
+ * has already returned, so it's never seen here and never falls over,
2135
+ * per the streaming limitation: splicing a second model's output into a
2136
+ * response the consumer has already partially rendered would corrupt
2137
+ * it.
2138
+ */
2139
+ async runFallbackChain(params, requestId, attempt, skipBreakerCheckForFirst = false) {
2140
+ const attempts = [];
2141
+ for (let i = 0; i < this.executors.length; i++) {
2142
+ const executor = this.executors[i];
2143
+ const startedAt = Date.now();
2144
+ let attemptCount = 0;
2145
+ try {
2146
+ if (!(i === 0 && skipBreakerCheckForFirst)) executor.assertBreakerClosed(params.model);
2147
+ const result = await attempt(executor, () => {
2148
+ attemptCount += 1;
2149
+ });
2150
+ return {
2151
+ result,
2152
+ executor,
2153
+ index: i,
2154
+ attemptCount
2155
+ };
2156
+ } catch (error) {
2157
+ const normalized = normalizeError(error, params.signal);
2158
+ attempts.push({
2159
+ index: i - 1,
2160
+ provider: executor.providerName,
2161
+ model: params.model ?? executor.model,
2162
+ error: normalized
2163
+ });
2164
+ const isLast = i === this.executors.length - 1;
2165
+ const policyDecision = this.fallbackOn(normalized, { isLastTarget: isLast });
2166
+ const decision = isLast ? "stop" : policyDecision;
2167
+ if (decision === "stop") throw attempts.length > 1 ? new FallbackExhaustedError(attempts) : normalized;
2168
+ const next = this.executors[i + 1];
2169
+ this.reportEvent({
2170
+ kind: "fallback",
2171
+ requestId,
2172
+ from: executor.providerName,
2173
+ to: next.providerName,
2174
+ fromIndex: i - 1,
2175
+ toIndex: i,
2176
+ error: normalized,
2177
+ elapsedMs: Date.now() - startedAt
2178
+ });
2179
+ }
2180
+ }
2181
+ throw new LLMError("No provider targets configured", "unknown");
2182
+ }
2183
+ async call(params) {
2184
+ if (params.signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
2185
+ const requestId = params.requestId ?? randomUUID();
2186
+ const soleTarget = this.executors.length === 1;
2187
+ if (soleTarget) this.executors[0].assertBreakerClosed(params.model);
2188
+ if (params.stream) return withReservedUsageForStream(params, async () => {
2189
+ const { result } = await this.runFallbackChain(params, requestId, (executor, onAttempt) => executor.runStream(params, requestId, onAttempt), soleTarget);
2190
+ return result;
2191
+ }, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
2192
+ return withReservedUsage(params, false, async () => {
2193
+ const { result, executor, index, attemptCount } = await this.runFallbackChain(params, requestId, (target, onAttempt) => target.run(params, requestId, onAttempt), soleTarget);
2194
+ if (params.meta) params.meta.current = {
2195
+ provider: executor.providerName,
2196
+ model: params.model ?? executor.model,
2197
+ fallbackIndex: index - 1,
2198
+ usedFallback: index > 0,
2199
+ attempts: attemptCount
2200
+ };
2201
+ return result;
2202
+ }, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
2203
+ }
2204
+ /**
2205
+ * Thin delegator kept private on `VernLLM` (rather than only existing on
2206
+ * `CacheOrchestrator`) since it's the one caching primitive exercised
2207
+ * directly by white-box tests, independent of the public `cachedCall()`
2208
+ * surface.
2209
+ */
2210
+ runCached(params) {
2211
+ return this.cacheOrchestrator.runCached(params);
2212
+ }
2213
+ /**
632
2214
  * Removes a cached response by key when the configured cache adapter
633
2215
  * supports deletion. Cache invalidation is the caller's responsibility;
634
2216
  * only the application knows when cached data is stale.
635
2217
  *
636
- * @param key - The raw cache key (resolved through the adapter's
2218
+ * @param key The raw cache key (resolved through the adapter's
637
2219
  * `resolveKey`, if any, before deletion).
638
2220
  */
639
2221
  async deleteCache(key) {
640
- if (!this.cache.delete) return;
641
- await this.cache.delete(await this.resolveCacheKey(key));
2222
+ await this.cacheOrchestrator.deleteCache(key);
642
2223
  }
643
- /**
644
- * Cache wrapper around caller-supplied logic. Concurrent misses for the
645
- * same `cacheKey` share a single in-flight call, avoiding cache stampedes.
646
- *
647
- * @param params - `cacheKey`, `ttl`, `fn` (the work to run on a cache
648
- * miss, typically `() => this.call(...)`), and optional
649
- * `reserveUsage`/`refundUsage`/`signal`. See `CachedCallParams`.
650
- * @returns The cached value on a hit, or the result of `fn()` on a miss.
651
- */
652
2224
  async cachedCall(params) {
653
- const resolvedKey = await this.resolveCacheKey(params.cacheKey);
654
- const resolvedParams = resolvedKey === params.cacheKey ? params : {
655
- ...params,
656
- cacheKey: resolvedKey
657
- };
658
- const cached = await this.cache.get(resolvedKey);
659
- if (cached.hit) return cached.value;
660
- const existing = this.inFlight.get(resolvedKey);
661
- if (existing) return withReservedUsage(resolvedParams, true, () => existing, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
662
- return this.registerTrigger(resolvedParams, false);
663
- }
664
- /** Starts the shared fn() call for a cache miss and tracks it in the in-flight map until it settles. */
665
- registerTrigger(params, coalesced) {
666
- const resultPromise = withReservedUsage(params, coalesced, () => this.runAndCache(params), params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
667
- this.inFlight.set(params.cacheKey, resultPromise);
668
- resultPromise.catch(() => {}).finally(() => {
669
- this.inFlight.delete(params.cacheKey);
670
- });
671
- return resultPromise;
672
- }
673
- /** Runs `fn` and writes its result to the cache. */
674
- async runAndCache(params) {
675
- const result = await params.fn();
676
- try {
677
- await this.cache.set(params.cacheKey, result, params.ttl);
678
- } catch (error) {
679
- this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
680
- }
681
- return result;
682
- }
683
- /** Logs a failed refundUsage attempt via the configured logger. */
684
- logRefundError(logMessage, error) {
685
- this.logger.error(logMessage, { message: error instanceof Error ? error.message : "unknown" });
686
- }
687
- /**
688
- * Convenience wrapper composing `call` + `cachedCall`, so cached LLM calls
689
- * automatically get retry/timeout/circuit-breaker behavior. `reserveUsage`/
690
- * `refundUsage` are read from the top-level params only.
691
- *
692
- * @param params - `cachedCall` params (`cacheKey`, `ttl`, etc, minus `fn`)
693
- * plus `call`, the `CallParams` to pass through to `this.call(...)`.
694
- * @returns The cached value on a hit, or the freshly-called result on a miss.
695
- */
696
- async cachedLLMCall(params) {
697
2225
  const { call: callParams,...cacheParams } = params;
698
- const { reserveUsage: innerReserveUsage, refundUsage: innerRefundUsage,...restCallParams } = callParams;
699
- if (innerReserveUsage || innerRefundUsage) this.logger.warn("[VernLLM] reserveUsage/refundUsage on `call` are ignored by cachedLLMCall; set them at the top level instead.");
700
- return this.cachedCall({
2226
+ const { reserveUsage, refundUsage,...restCallParams } = callParams;
2227
+ if (reserveUsage || refundUsage) this.logger.warn("[VernLLM] reserveUsage/refundUsage on `call` are ignored by cachedCall; set them at the top level instead.");
2228
+ if (restCallParams.stream) {
2229
+ const streamParams = restCallParams;
2230
+ return this.cacheOrchestrator.runCachedStream({
2231
+ ...cacheParams,
2232
+ openStream: () => this.call(streamParams)
2233
+ }, Boolean(restCallParams.tools));
2234
+ }
2235
+ return this.runCached({
701
2236
  ...cacheParams,
702
2237
  fn: () => this.call(restCallParams)
703
2238
  });
704
2239
  }
705
2240
  /**
2241
+ * @param model With `circuitBreaker.isolateByModel` on, returns that
2242
+ * model's own circuit state instead of the shared one. Ignored
2243
+ * otherwise. Omit for the shared circuit (the default) or, under
2244
+ * isolation, the state of calls that didn't resolve a model.
706
2245
  * @returns The current circuit breaker state (`'closed' | 'open' |
707
2246
  * 'half-open'`), or undefined if no circuit breaker was configured.
708
2247
  */
709
- getCircuitState() {
710
- return this.breaker?.getState();
2248
+ getCircuitState(model) {
2249
+ return this.executors[0].getCircuitState(model);
2250
+ }
2251
+ /**
2252
+ * @param model With `circuitBreaker.isolateByModel` on, returns each
2253
+ * target's circuit state for that model instead of its shared state.
2254
+ * Ignored otherwise. Omit for the shared circuit (the default) or, under
2255
+ * isolation, the state of calls that didn't resolve a model.
2256
+ * @returns The current circuit state for every target in declaration
2257
+ * order, including the primary and all fallback targets. Each entry
2258
+ * includes the target's provider name, chain index, whether it is a
2259
+ * fallback, and its circuit state, or undefined if that target has no
2260
+ * circuit breaker configured.
2261
+ */
2262
+ getCircuitStates(model) {
2263
+ return this.executors.map((executor, index) => ({
2264
+ provider: executor.providerName,
2265
+ index,
2266
+ isFallback: index > 0,
2267
+ state: executor.getCircuitState(model)
2268
+ }));
711
2269
  }
712
2270
  };
713
2271
 
714
2272
  //#endregion
715
- //#region src/internal/imageFormat.ts
2273
+ //#region src/adapters/internal/sse.ts
2274
+ /**
2275
+ * Parses a Server-Sent-Events byte/text stream into the JSON payload of
2276
+ * each `data:` frame, in arrival order. Generic over transport: works with
2277
+ * anything that hands back progressively-arriving `Uint8Array` or `string`
2278
+ * chunks via async iteration: native `fetch`'s `response.body` (wrapped
2279
+ * to be iterable, see `webStreamToAsyncIterable` in `fetch.ts`), axios's
2280
+ * Node `Readable` (already async-iterable, no wrapping needed), etc, so
2281
+ * this framing layer doesn't care which transport produced the bytes.
2282
+ *
2283
+ * Follows the SSE spec's frame-delimiting rules closely enough for LLM
2284
+ * streaming responses: frames are separated by a blank line, each frame
2285
+ * may carry one or more `data:` lines (joined with `\n` per spec when
2286
+ * there's more than one), `:`-prefixed lines are comments and ignored, and
2287
+ * other SSE fields (`event:`, `id:`, `retry:`) are ignored since VernLLM
2288
+ * only needs the payload. A frame whose data is exactly `[DONE]` (the
2289
+ * sentinel several providers, notably OpenAI, send to mark stream end)
2290
+ * ends iteration without yielding it.
2291
+ *
2292
+ * Line endings: `\r\n` and bare `\r` (both legal per the SSE spec, alongside `\n`) are normalized
2293
+ * to `\n` before frame splitting. A `\r` at the very end of the currently-buffered text is left
2294
+ * alone until either more text arrives (in case it's the first half of a split `\r\n` pair) or the
2295
+ * stream ends, so a `\r\n` pair split across two transport chunks is never misread as two blank
2296
+ * lines.
2297
+ *
2298
+ * Malformed JSON in a frame throws `LLMError('parse')`, consistent with
2299
+ * how malformed JSON is handled elsewhere in VernLLM.
2300
+ */
2301
+ async function* parseSseStream(source) {
2302
+ const decoder = new TextDecoder("utf-8", { fatal: true });
2303
+ let buffer = "";
2304
+ for await (const chunk of source) {
2305
+ let text;
2306
+ try {
2307
+ text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
2308
+ } catch (cause) {
2309
+ throw new LLMError("Invalid UTF-8 in SSE stream", "parse", void 0, void 0, cause);
2310
+ }
2311
+ buffer = (buffer + text).replace(/\r\n/g, "\n").replace(/\r(?!$)/g, "\n");
2312
+ let boundary$1 = buffer.indexOf("\n\n");
2313
+ while (boundary$1 !== -1) {
2314
+ const frame = buffer.slice(0, boundary$1);
2315
+ buffer = buffer.slice(boundary$1 + 2);
2316
+ const event = parseSseFrame(frame);
2317
+ if (event === DONE) return;
2318
+ if (event !== NO_DATA) yield event;
2319
+ boundary$1 = buffer.indexOf("\n\n");
2320
+ }
2321
+ }
2322
+ try {
2323
+ buffer += decoder.decode();
2324
+ } catch (cause) {
2325
+ throw new LLMError("Invalid UTF-8 in SSE stream", "parse", void 0, void 0, cause);
2326
+ }
2327
+ buffer = buffer.replace(/\r$/, "\n");
2328
+ let boundary = buffer.indexOf("\n\n");
2329
+ while (boundary !== -1) {
2330
+ const frame = buffer.slice(0, boundary);
2331
+ buffer = buffer.slice(boundary + 2);
2332
+ const event = parseSseFrame(frame);
2333
+ if (event === DONE) return;
2334
+ if (event !== NO_DATA) yield event;
2335
+ boundary = buffer.indexOf("\n\n");
2336
+ }
2337
+ const trailing = buffer.trim();
2338
+ if (trailing) {
2339
+ const event = parseSseFrame(trailing);
2340
+ if (event !== DONE && event !== NO_DATA) yield event;
2341
+ }
2342
+ }
2343
+ const DONE = Symbol("sse-stream-done");
2344
+ const NO_DATA = Symbol("sse-frame-no-data");
2345
+ /**
2346
+ * Sentinel yielded by `parseSseStream` for a comment-only frame (no
2347
+ * `data:` payload), the mechanism providers use for SSE keep-alive
2348
+ * pings. Exported so a consumer (e.g. `fromFetch`) can react to "still
2349
+ * alive" separately from a genuinely empty frame (`NO_DATA`, kept internal).
2350
+ */
2351
+ const SSE_PING = Symbol("sse-frame-ping");
2352
+ /** Extracts and JSON-parses the `data:` payload of one SSE frame (the text between two blank lines). */
2353
+ function parseSseFrame(frame) {
2354
+ const dataLines = [];
2355
+ let sawComment = false;
2356
+ for (const line of frame.split("\n")) {
2357
+ if (line.startsWith(":")) {
2358
+ sawComment = true;
2359
+ continue;
2360
+ }
2361
+ if (!line.startsWith("data:")) continue;
2362
+ dataLines.push(line.startsWith("data: ") ? line.slice(6) : line.slice(5));
2363
+ }
2364
+ if (!dataLines.length) return sawComment ? SSE_PING : NO_DATA;
2365
+ const data = dataLines.join("\n");
2366
+ if (data === "[DONE]") return DONE;
2367
+ try {
2368
+ return JSON.parse(data);
2369
+ } catch (cause) {
2370
+ throw new LLMError(`Invalid JSON in SSE frame: ${data.slice(0, 200)}`, "parse", void 0, void 0, cause);
2371
+ }
2372
+ }
2373
+
2374
+ //#endregion
2375
+ //#region src/adapters/internal/imageFormat.ts
716
2376
  /**
717
2377
  * MIME types accepted for `ImageBlock.mimeType` across all adapters. This is
718
2378
  * the intersection of what Anthropic, Gemini, OpenAI-compatible, and Bedrock
@@ -736,6 +2396,14 @@ function assertSupportedImageMimeType(mimeType) {
736
2396
  throw new LLMError(`Unsupported image mimeType "${mimeType}": expected one of ${SUPPORTED_IMAGE_MIME_TYPES.join(", ")}`, "validation");
737
2397
  }
738
2398
 
2399
+ //#endregion
2400
+ //#region src/adapters/internal/nativeStructuredOutput.ts
2401
+ /** Resolves whether `model` is covered by a caller-supplied allow-list/predicate. */
2402
+ function supportsNativeStructuredOutput(model, override) {
2403
+ if (!override) return false;
2404
+ return Array.isArray(override) ? override.includes(model) : override(model);
2405
+ }
2406
+
739
2407
  //#endregion
740
2408
  //#region src/adapters/anthropic.ts
741
2409
  /**
@@ -758,67 +2426,305 @@ function toAnthropicContent(blocks) {
758
2426
  });
759
2427
  }
760
2428
  /**
2429
+ * Asserts a caller-supplied JSON Schema is an object schema before it's
2430
+ * used as Anthropic's `Tool.input_schema`, which (like every other
2431
+ * provider's function-calling API) requires `type: 'object'`. VernLLM's own
2432
+ * public `tools`/`jsonSchema` APIs accept freeform `Record<string,
2433
+ * unknown>` JSON Schema, so nothing upstream guarantees this at compile
2434
+ * time; this is the runtime check that stands in for that, so a schema
2435
+ * missing (or mistyping) `type: 'object'` fails loudly and immediately
2436
+ * instead of being silently forwarded to Anthropic malformed.
2437
+ */
2438
+ function assertObjectSchema(schema, toolName) {
2439
+ if (schema.type !== "object") throw new LLMError(`Tool "${toolName}"'s schema must have "type": "object" (Anthropic requires object-shaped tool parameters).`, "validation");
2440
+ return schema;
2441
+ }
2442
+ /**
2443
+ * Translates VernLLM's OpenAI-shaped wire `tool_choice` into Anthropic's
2444
+ * `{ type: 'auto' | 'any' | 'none' | 'tool', name? }` shape. `'required'`
2445
+ * maps to `'any'` (Anthropic's "must call some tool" equivalent).
2446
+ */
2447
+ function toAnthropicToolChoice(toolChoice) {
2448
+ if (!toolChoice || toolChoice === "auto") return { type: "auto" };
2449
+ if (toolChoice === "none") return { type: "none" };
2450
+ if (toolChoice === "required") return { type: "any" };
2451
+ return {
2452
+ type: "tool",
2453
+ name: toolChoice.function.name
2454
+ };
2455
+ }
2456
+ /**
2457
+ * Maps VernLLM's OpenAI-shaped wire `tools`/`tool_choice` into Anthropic's
2458
+ * `tools`/`tool_choice` shape. Shared by the two call sites that build real
2459
+ * (non-schema-forced) tool definitions: the plain tools-only branch, and
2460
+ * the native-structured-output branch, which sends real tools alongside
2461
+ * `output_config` rather than instead of it.
2462
+ */
2463
+ function buildAnthropicTools(tools, toolChoiceParam) {
2464
+ return {
2465
+ tools: tools.map((t) => ({
2466
+ name: t.function.name,
2467
+ description: t.function.description,
2468
+ input_schema: assertObjectSchema(t.function.parameters, t.function.name)
2469
+ })),
2470
+ toolChoice: toAnthropicToolChoice(toolChoiceParam)
2471
+ };
2472
+ }
2473
+ /**
2474
+ * Builds the Anthropic-shaped request body from VernLLM's wire params,
2475
+ * shared between `create` and `createStream` so both go through identical
2476
+ * translation (system prompt, message shaping, and the jsonSchema →
2477
+ * forced-single-tool mapping all happen exactly once, not once per entry
2478
+ * point).
2479
+ *
2480
+ * Returns `toolName` alongside the body: when set, the model was forced to
2481
+ * call a single synthetic tool standing in for `jsonSchema` output (the
2482
+ * legacy path, for models without native structured-output support), and
2483
+ * both `create` and `createStream` need to know this so they can unwrap
2484
+ * that tool call back into plain text content instead of treating it like
2485
+ * a real tool call. On the native path (model supports `output_config`),
2486
+ * `toolName` is `undefined`: the schema-conforming JSON already arrives as
2487
+ * ordinary text content, nothing to unwrap, and any real tool calls in
2488
+ * `params.tools` are left for the normal, non-forced tool-call handling
2489
+ * both `create` and `createStream` already do when `toolName` is unset.
2490
+ */
2491
+ function buildAnthropicRequestBody(params, nativeStructuredOutputModels) {
2492
+ const systemMessage = params.messages.find((m) => m.role === "system");
2493
+ const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "tool");
2494
+ const jsonSchema = params.response_format?.type === "json_schema" ? params.response_format.json_schema : void 0;
2495
+ const schemaName = jsonSchema?.name.trim();
2496
+ if (jsonSchema && !schemaName) throw new LLMError("json_schema.name must not be empty.", "validation");
2497
+ const isNative = Boolean(jsonSchema) && supportsNativeStructuredOutput(params.model, nativeStructuredOutputModels);
2498
+ if (jsonSchema && params.tools?.length && !isNative) throw new LLMError(`Anthropic model "${params.model}" is not covered by nativeStructuredOutputModels, so \`jsonSchema\` is emulated as a forced single tool call there, which collides with the \`tools\` you also provided. Either drop \`tools\` or \`jsonSchema\` for this call, or pass this model in fromAnthropic's \`nativeStructuredOutputModels\` option once you've confirmed it supports Anthropic's \`output_config.format\`.`, "validation");
2499
+ let toolName;
2500
+ let jsonInstruction;
2501
+ let outputFormat;
2502
+ let tools;
2503
+ let toolChoice;
2504
+ if (jsonSchema && isNative) {
2505
+ outputFormat = {
2506
+ type: "json_schema",
2507
+ schema: jsonSchema.schema
2508
+ };
2509
+ if (params.tools?.length) ({tools, toolChoice} = buildAnthropicTools(params.tools, params.tool_choice));
2510
+ } else if (jsonSchema && schemaName) {
2511
+ const { schema, description, strict } = jsonSchema;
2512
+ toolName = schemaName;
2513
+ tools = [{
2514
+ name: toolName,
2515
+ description,
2516
+ input_schema: assertObjectSchema(schema, toolName),
2517
+ strict
2518
+ }];
2519
+ toolChoice = {
2520
+ type: "tool",
2521
+ name: toolName
2522
+ };
2523
+ } else if (params.response_format?.type === "json_object") jsonInstruction = "Respond with valid JSON only, no prose or markdown fences.";
2524
+ if (!jsonSchema && params.tools?.length) ({tools, toolChoice} = buildAnthropicTools(params.tools, params.tool_choice));
2525
+ const system = [systemMessage?.content, jsonInstruction].filter(Boolean).join("\n\n");
2526
+ const body = {
2527
+ model: params.model,
2528
+ max_tokens: params.max_tokens,
2529
+ ...params.temperature !== void 0 ? { temperature: params.temperature } : {},
2530
+ system: system || void 0,
2531
+ messages: mergeConsecutiveToolResults$1(conversationMessages.map((m) => toAnthropicMessage(m))),
2532
+ ...tools ? {
2533
+ tools,
2534
+ tool_choice: toolChoice
2535
+ } : {},
2536
+ ...outputFormat ? { output_config: { format: outputFormat } } : {}
2537
+ };
2538
+ return {
2539
+ body,
2540
+ toolName
2541
+ };
2542
+ }
2543
+ /**
761
2544
  * Wraps an Anthropic SDK client so it satisfies the same `LLMClient`
762
2545
  * interface VernLLM uses for OpenAI/Groq.
763
2546
  *
764
- * `response_format: json_schema` is mapped to Anthropic's forced tool-use:
765
- * a single tool is defined with `input_schema` set to the caller's schema,
766
- * `description` forwarded when provided, and `strict` forwarded when set.
767
- * `tool_choice` forces the model to call it. Provider-constrained schema
768
- * matching applies only when `strict: true` is forwarded and supported.
2547
+ * `response_format: json_schema`, on a model covered by
2548
+ * `options.nativeStructuredOutputModels`, is sent as `output_config.format`,
2549
+ * its own request field, independent of `tools`/`tool_choice`, so it can be
2550
+ * combined with real, caller-supplied `tools` in the same request. Only
2551
+ * `type` and `schema` are sent on this path, the real Anthropic API's
2552
+ * `output_config.format` has no `name`/`description`/`strict` fields.
2553
+ *
2554
+ * On any other model (the default, since `nativeStructuredOutputModels` is
2555
+ * opt-in), `response_format: json_schema` is mapped to Anthropic's forced
2556
+ * tool-use instead: a single tool is defined with `input_schema` set to
2557
+ * the caller's schema, `description` forwarded when provided, and `strict`
2558
+ * forwarded when set, and `tool_choice` forces the model to call it. This
2559
+ * legacy path cannot be combined with real `tools` (both would need the
2560
+ * same `tools`/`tool_choice` field), and a call that tries throws
2561
+ * `LLMError('validation')` before reaching the API. Provider-constrained
2562
+ * schema matching applies only when `strict: true` is forwarded and
2563
+ * supported.
769
2564
  *
770
2565
  * `response_format: json_object` (no schema to build a tool from) falls
771
2566
  * back to a system-prompt instruction, since there's nothing to constrain
772
- * generation against.
773
- */
774
- function fromAnthropic(anthropicClient) {
775
- return { chat: { completions: { async create(params, options) {
776
- const systemMessage = params.messages.find((m) => m.role === "system");
777
- const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant");
778
- const toolName = params.response_format?.type === "json_schema" ? params.response_format.json_schema.name : void 0;
779
- let jsonInstruction;
780
- let tools;
781
- if (params.response_format?.type === "json_schema" && toolName) {
782
- const { schema, description, strict } = params.response_format.json_schema;
783
- tools = [{
784
- name: toolName,
785
- description,
786
- input_schema: schema,
787
- strict
788
- }];
789
- } else if (params.response_format?.type === "json_object") jsonInstruction = "Respond with valid JSON only, no prose or markdown fences.";
790
- const system = [systemMessage?.content, jsonInstruction].filter(Boolean).join("\n\n");
791
- const response = await anthropicClient.messages.create({
792
- model: params.model,
793
- max_tokens: params.max_tokens,
794
- temperature: params.temperature,
795
- system: system || void 0,
796
- messages: conversationMessages.map((m) => ({
797
- role: m.role,
798
- content: Array.isArray(m.content) ? toAnthropicContent(m.content) : m.content
799
- })),
800
- ...tools ? {
801
- tools,
802
- tool_choice: {
803
- type: "tool",
804
- name: toolName
2567
+ * generation against. Unlike `jsonSchema`, this combines with real `tools`
2568
+ * freely on every model: it's a prompt nudge, not a request field, so
2569
+ * there's nothing for it to collide with.
2570
+ */
2571
+ function fromAnthropic(anthropicClient, options) {
2572
+ const nativeStructuredOutputModels = options?.nativeStructuredOutputModels;
2573
+ const rawMessagesCreate = anthropicClient.messages.create.bind(anthropicClient.messages);
2574
+ return { chat: { completions: {
2575
+ async create(params, options$1) {
2576
+ const { body, toolName } = buildAnthropicRequestBody(params, nativeStructuredOutputModels);
2577
+ const response = await anthropicClient.messages.create(body, options$1);
2578
+ let text;
2579
+ let wireToolCalls;
2580
+ if (toolName) {
2581
+ const toolUse = response.content.find((block) => block.type === "tool_use" && block.name === toolName);
2582
+ if (!toolUse) throw new LLMError(`Anthropic did not return the required structured output tool "${toolName}".`, "validation");
2583
+ if (!toolUse.input || typeof toolUse.input !== "object" || Array.isArray(toolUse.input)) throw new LLMError(`Anthropic returned invalid structured output for tool "${toolName}". Expected an object.`, "validation");
2584
+ text = JSON.stringify(toolUse.input);
2585
+ } else {
2586
+ text = response.content.filter((block) => block.type === "text").map((block) => block.text ?? "").join("");
2587
+ const toolUses = response.content.filter((block) => block.type === "tool_use");
2588
+ if (toolUses.length) wireToolCalls = toolUses.map((block) => ({
2589
+ id: block.id,
2590
+ type: "function",
2591
+ function: {
2592
+ name: block.name,
2593
+ arguments: JSON.stringify(block.input ?? {})
2594
+ }
2595
+ }));
2596
+ }
2597
+ return {
2598
+ choices: [{ message: {
2599
+ content: text,
2600
+ ...wireToolCalls ? { tool_calls: wireToolCalls } : {}
2601
+ } }],
2602
+ usage: {
2603
+ prompt_tokens: response.usage?.input_tokens,
2604
+ completion_tokens: response.usage?.output_tokens,
2605
+ total_tokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0)
805
2606
  }
806
- } : {}
807
- }, options);
808
- let text;
809
- if (toolName) {
810
- const toolUse = response.content.find((block) => block.type === "tool_use" && block.name === toolName);
811
- text = toolUse ? JSON.stringify(toolUse.input) : "";
812
- } else text = response.content.find((block) => block.type === "text")?.text ?? "";
813
- return {
814
- choices: [{ message: { content: text } }],
815
- usage: {
816
- prompt_tokens: response.usage?.input_tokens,
817
- completion_tokens: response.usage?.output_tokens,
818
- total_tokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0)
2607
+ };
2608
+ },
2609
+ async *createStream(params, options$1) {
2610
+ const { body, toolName } = buildAnthropicRequestBody(params, nativeStructuredOutputModels);
2611
+ const stream = await rawMessagesCreate({
2612
+ ...body,
2613
+ stream: true
2614
+ }, options$1);
2615
+ const blockKinds = new Map();
2616
+ let inputTokens = 0;
2617
+ let sawJsonTool = false;
2618
+ for await (const event of stream) if (event.type === "message_start") inputTokens = event.message.usage?.input_tokens ?? 0;
2619
+ else if (event.type === "content_block_start") if (event.content_block.type === "tool_use") {
2620
+ const kind = event.content_block.name === toolName ? "json-tool" : "tool_use";
2621
+ blockKinds.set(event.index, kind);
2622
+ if (kind === "json-tool") sawJsonTool = true;
2623
+ else if (!toolName) yield {
2624
+ type: "tool_call_delta",
2625
+ index: event.index,
2626
+ id: event.content_block.id,
2627
+ name: event.content_block.name
2628
+ };
2629
+ } else blockKinds.set(event.index, "text");
2630
+ else if (event.type === "content_block_delta") {
2631
+ if (event.delta.type === "text_delta") {
2632
+ if (!toolName) yield {
2633
+ type: "text-delta",
2634
+ delta: event.delta.text
2635
+ };
2636
+ } else if (event.delta.type === "input_json_delta") {
2637
+ const kind = blockKinds.get(event.index);
2638
+ if (kind === "json-tool") yield {
2639
+ type: "text-delta",
2640
+ delta: event.delta.partial_json
2641
+ };
2642
+ else if (!toolName) yield {
2643
+ type: "tool_call_delta",
2644
+ index: event.index,
2645
+ argumentsDelta: event.delta.partial_json
2646
+ };
2647
+ }
2648
+ } else if (event.type === "message_delta") {
2649
+ const outputTokens = event.usage?.output_tokens ?? 0;
2650
+ yield {
2651
+ type: "usage",
2652
+ usage: {
2653
+ prompt_tokens: inputTokens,
2654
+ completion_tokens: outputTokens,
2655
+ total_tokens: inputTokens + outputTokens
2656
+ }
2657
+ };
2658
+ } else if (event.type === "ping") yield { type: "ping" };
2659
+ if (toolName && !sawJsonTool) throw new LLMError(`Anthropic did not return the required structured output tool "${toolName}".`, "validation");
2660
+ }
2661
+ } } };
2662
+ }
2663
+ /**
2664
+ * Anthropic requires strict role alternation, so the per-wire-message
2665
+ * mapping above (one `{role:'user', content:[tool_result]}` per VernLLM
2666
+ * wire tool message) needs merging back together when an assistant turn
2667
+ * requested more than one tool: multiple consecutive user turns would
2668
+ * violate that alternation, and Anthropic's API rejects it outright. This
2669
+ * merges any run of tool-result-only user messages into one, with all
2670
+ * their tool_result blocks combined, the shape Anthropic expects for "here
2671
+ * are the results of everything you just asked for."
2672
+ */
2673
+ function mergeConsecutiveToolResults$1(messages) {
2674
+ const isToolResultOnly = (m) => m.role === "user" && Array.isArray(m.content) && m.content.length > 0 && m.content.every((b) => b.type === "tool_result");
2675
+ const merged = [];
2676
+ for (const m of messages) {
2677
+ const prev = merged.at(-1);
2678
+ if (isToolResultOnly(m) && prev && isToolResultOnly(prev)) prev.content.push(...m.content);
2679
+ else merged.push(m);
2680
+ }
2681
+ return merged;
2682
+ }
2683
+ /**
2684
+ * Translates one VernLLM wire message (OpenAI-shaped: plain user/assistant
2685
+ * turns, an assistant turn with `tool_calls`, or a `tool` turn) into
2686
+ * Anthropic's `{ role: 'user' | 'assistant', content }` shape.
2687
+ */
2688
+ function toAnthropicMessage(m) {
2689
+ if (m.role === "tool") return {
2690
+ role: "user",
2691
+ content: [{
2692
+ type: "tool_result",
2693
+ tool_use_id: m.tool_call_id,
2694
+ content: m.content,
2695
+ ...m.is_error ? { is_error: true } : {}
2696
+ }]
2697
+ };
2698
+ if (m.role === "assistant" && m.tool_calls?.length) {
2699
+ const blocks = [];
2700
+ if (m.content) blocks.push({
2701
+ type: "text",
2702
+ text: m.content
2703
+ });
2704
+ for (const tc of m.tool_calls) {
2705
+ let input;
2706
+ try {
2707
+ input = tc.function.arguments.trim() ? JSON.parse(tc.function.arguments) : {};
2708
+ } catch (cause) {
2709
+ throw new LLMError(`Assistant tool call "${tc.function.name}" (${tc.id}) has arguments that are not valid JSON.`, "validation", void 0, void 0, cause);
819
2710
  }
2711
+ if (input === null || Array.isArray(input) || typeof input !== "object") throw new LLMError(`Assistant tool call "${tc.function.name}" (${tc.id}) arguments must be a JSON object.`, "validation");
2712
+ blocks.push({
2713
+ type: "tool_use",
2714
+ id: tc.id,
2715
+ name: tc.function.name,
2716
+ input
2717
+ });
2718
+ }
2719
+ return {
2720
+ role: "assistant",
2721
+ content: blocks
820
2722
  };
821
- } } } };
2723
+ }
2724
+ return {
2725
+ role: m.role,
2726
+ content: Array.isArray(m.content) ? toAnthropicContent(m.content) : m.content ?? ""
2727
+ };
822
2728
  }
823
2729
 
824
2730
  //#endregion
@@ -835,6 +2741,122 @@ function toGeminiParts(blocks) {
835
2741
  data: block.data
836
2742
  } } : { text: block.text });
837
2743
  }
2744
+ /** Maps VernLLM's OpenAI-shaped wire `tool_choice` onto Gemini's `functionCallingConfig`. */
2745
+ function toGeminiToolConfig(toolChoice) {
2746
+ if (!toolChoice || toolChoice === "auto") return { functionCallingConfig: { mode: "AUTO" } };
2747
+ if (toolChoice === "none") return { functionCallingConfig: { mode: "NONE" } };
2748
+ if (toolChoice === "required") return { functionCallingConfig: { mode: "ANY" } };
2749
+ return { functionCallingConfig: {
2750
+ mode: "ANY",
2751
+ allowedFunctionNames: [toolChoice.function.name]
2752
+ } };
2753
+ }
2754
+ /**
2755
+ * Translates one VernLLM wire message into a Gemini `contents` entry.
2756
+ * Gemini has no separate 'tool' role: a prior assistant tool request
2757
+ * becomes a `'model'` turn with `functionCall` parts, and its result
2758
+ * becomes a `'user'` turn with `functionResponse` parts.
2759
+ */
2760
+ function toGeminiContent(m) {
2761
+ if (m.role === "tool") return {
2762
+ role: "user",
2763
+ parts: [{ functionResponse: {
2764
+ name: m.tool_call_id,
2765
+ response: parseToolResult(m.content)
2766
+ } }]
2767
+ };
2768
+ if (m.role === "assistant" && m.tool_calls?.length) {
2769
+ const parts = [];
2770
+ if (typeof m.content === "string" && m.content) parts.push({ text: m.content });
2771
+ parts.push(...m.tool_calls.map((tc) => ({ functionCall: {
2772
+ name: tc.function.name,
2773
+ args: parseToolArguments(tc.function.arguments, tc.function.name)
2774
+ } })));
2775
+ return {
2776
+ role: "model",
2777
+ parts
2778
+ };
2779
+ }
2780
+ return {
2781
+ role: m.role === "assistant" ? "model" : "user",
2782
+ parts: Array.isArray(m.content) ? toGeminiParts(m.content) : [{ text: m.content ?? "" }]
2783
+ };
2784
+ }
2785
+ function parseToolArguments(text, toolName) {
2786
+ let parsed;
2787
+ try {
2788
+ parsed = text.trim() ? JSON.parse(text) : {};
2789
+ } catch (cause) {
2790
+ throw new LLMError(`Tool call "${toolName}" arguments are not valid JSON.`, "validation", void 0, void 0, cause);
2791
+ }
2792
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new LLMError(`Tool call "${toolName}" arguments must be a JSON object.`, "validation");
2793
+ return parsed;
2794
+ }
2795
+ function parseToolResult(text) {
2796
+ try {
2797
+ return text.trim() ? JSON.parse(text) : "";
2798
+ } catch {
2799
+ return text;
2800
+ }
2801
+ }
2802
+ /**
2803
+ * Gemini expects the results of everything the model asked for in one turn
2804
+ * to arrive together as multiple `functionResponse` parts on a single
2805
+ * `'user'` entry, not as separate consecutive `'user'` entries. The
2806
+ * per-wire-message mapping above produces one `'user'` entry per VernLLM
2807
+ * wire tool message, so when an assistant turn requested more than one
2808
+ * tool, this merges the resulting run of functionResponse-only `'user'`
2809
+ * entries back into one.
2810
+ */
2811
+ function mergeConsecutiveFunctionResponses(contents) {
2812
+ const isFunctionResponseOnly = (c) => c.role === "user" && c.parts.length > 0 && c.parts.every((p) => "functionResponse" in p);
2813
+ const merged = [];
2814
+ for (const c of contents) {
2815
+ const prev = merged.at(-1);
2816
+ if (isFunctionResponseOnly(c) && prev && isFunctionResponseOnly(prev)) prev.parts.push(...c.parts);
2817
+ else merged.push(c);
2818
+ }
2819
+ return merged;
2820
+ }
2821
+ /**
2822
+ * Builds the Gemini-shaped request from VernLLM's wire params, shared
2823
+ * between `create` and `createStream` so both go through identical
2824
+ * translation (contents shaping, `responseSchema`/`responseMimeType`
2825
+ * mapping, and tool/toolConfig translation all happen exactly once).
2826
+ * `abortSignal` is folded into `config` by the caller (`create`/
2827
+ * `createStream`), once the request options are available.
2828
+ */
2829
+ function buildGeminiRequest(params) {
2830
+ const systemMessage = params.messages.find((m) => m.role === "system");
2831
+ const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "tool");
2832
+ const wantsJson = Boolean(params.response_format);
2833
+ const config = {
2834
+ ...params.temperature !== void 0 ? { temperature: params.temperature } : {},
2835
+ maxOutputTokens: params.max_tokens,
2836
+ ...systemMessage ? { systemInstruction: { parts: [{ text: systemMessage.content }] } } : {}
2837
+ };
2838
+ if (wantsJson) config.responseMimeType = "application/json";
2839
+ if (params.response_format?.type === "json_schema") {
2840
+ const { schema, description } = params.response_format.json_schema;
2841
+ config.responseSchema = {
2842
+ ...schema,
2843
+ ...description ? { description } : {}
2844
+ };
2845
+ }
2846
+ if (params.tools?.length) {
2847
+ config.tools = [{ functionDeclarations: params.tools.map((t) => ({
2848
+ name: t.function.name,
2849
+ description: t.function.description,
2850
+ parameters: t.function.parameters
2851
+ })) }];
2852
+ config.toolConfig = toGeminiToolConfig(params.tool_choice);
2853
+ }
2854
+ return {
2855
+ model: params.model,
2856
+ contents: mergeConsecutiveFunctionResponses(conversationMessages.map((m) => toGeminiContent(m))),
2857
+ config
2858
+ };
2859
+ }
838
2860
  /**
839
2861
  * Wraps a Gemini client so it satisfies the `LLMClient` interface VernLLM
840
2862
  * uses for OpenAI-compatible APIs. Gemini's shape differs on nearly every
@@ -845,43 +2867,100 @@ function toGeminiParts(blocks) {
845
2867
  * `responseSchema`. `reasoning_effort` has no equivalent. Gemini's thinking
846
2868
  * models use a token budget, not an effort tier, so it's dropped, same as
847
2869
  * Anthropic.
2870
+ *
2871
+ * `tools` maps to Gemini's native `functionDeclarations`/`functionCall`;
2872
+ * `tool_choice` maps to `toolConfig.functionCallingConfig`. Gemini accepts
2873
+ * `responseSchema` and `tools` in the same request natively, so both are
2874
+ * set independently here and no special-casing is needed for the
2875
+ * combination, unlike `fromAnthropic`/`fromBedrock`.
2876
+ *
2877
+ * `createStream` calls `generateContentStream` (optional on `GeminiClient`
2878
+ *, required only if the caller sets `stream: true`) and translates each
2879
+ * partial response into `WireStreamChunk`s. Unlike OpenAI/Anthropic,
2880
+ * Gemini's own function-calling API doesn't stream tool-call arguments
2881
+ * incrementally: a `functionCall` part always arrives whole in one chunk,
2882
+ * so each one is emitted as a single, complete `tool_call_delta` (a
2883
+ * one-shot "delta" containing the full arguments) rather than accumulated
2884
+ * fragments, that's a real difference in the underlying API, not
2885
+ * something this adapter can smooth over. `usageMetadata` is (per Gemini's
2886
+ * own behavior) only reliably present on the last chunk, so the `usage`
2887
+ * `WireStreamChunk` is emitted once, after the stream completes, from
2888
+ * whichever chunk's `usageMetadata` was seen last.
848
2889
  */
849
2890
  function fromGemini(geminiClient) {
850
- return { chat: { completions: { async create(params, options) {
851
- const systemMessage = params.messages.find((m) => m.role === "system");
852
- const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant");
853
- const wantsJson = Boolean(params.response_format);
854
- const generationConfig = {
855
- temperature: params.temperature,
856
- maxOutputTokens: params.max_tokens
857
- };
858
- if (wantsJson) generationConfig.responseMimeType = "application/json";
859
- if (params.response_format?.type === "json_schema") {
860
- const { schema, description } = params.response_format.json_schema;
861
- generationConfig.responseSchema = {
862
- ...schema,
863
- ...description ? { description } : {}
2891
+ return { chat: { completions: {
2892
+ async create(params, options) {
2893
+ const request = buildGeminiRequest(params);
2894
+ request.config = {
2895
+ ...request.config,
2896
+ abortSignal: options.signal
864
2897
  };
865
- }
866
- const response = await geminiClient.generateContent({
867
- model: params.model,
868
- contents: conversationMessages.map((m) => ({
869
- role: m.role === "assistant" ? "model" : "user",
870
- parts: Array.isArray(m.content) ? toGeminiParts(m.content) : [{ text: m.content }]
871
- })),
872
- systemInstruction: systemMessage ? { parts: [{ text: systemMessage.content }] } : void 0,
873
- generationConfig
874
- }, options);
875
- const text = response.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("") ?? "";
876
- return {
877
- choices: [{ message: { content: text } }],
878
- usage: {
879
- prompt_tokens: response.usageMetadata?.promptTokenCount,
880
- completion_tokens: response.usageMetadata?.candidatesTokenCount,
881
- total_tokens: response.usageMetadata?.totalTokenCount
2898
+ const response = await geminiClient.generateContent(request);
2899
+ const parts = response.candidates?.[0]?.content?.parts ?? [];
2900
+ const text = parts.map((p) => p.text ?? "").join("");
2901
+ const functionCalls = parts.filter((p) => p.functionCall);
2902
+ let wireToolCalls;
2903
+ if (functionCalls.length) wireToolCalls = functionCalls.map((p) => ({
2904
+ id: p.functionCall.name,
2905
+ type: "function",
2906
+ function: {
2907
+ name: p.functionCall.name,
2908
+ arguments: JSON.stringify(p.functionCall.args ?? {})
2909
+ }
2910
+ }));
2911
+ return {
2912
+ choices: [{ message: {
2913
+ content: text,
2914
+ ...wireToolCalls ? { tool_calls: wireToolCalls } : {}
2915
+ } }],
2916
+ usage: {
2917
+ prompt_tokens: response.usageMetadata?.promptTokenCount,
2918
+ completion_tokens: response.usageMetadata?.candidatesTokenCount,
2919
+ total_tokens: response.usageMetadata?.totalTokenCount
2920
+ }
2921
+ };
2922
+ },
2923
+ async *createStream(params, options) {
2924
+ if (!geminiClient.generateContentStream) throw new LLMError("stream: true requires a Gemini client with generateContentStream", "validation");
2925
+ const request = buildGeminiRequest(params);
2926
+ request.config = {
2927
+ ...request.config,
2928
+ abortSignal: options.signal
2929
+ };
2930
+ const stream = await geminiClient.generateContentStream(request);
2931
+ let toolCallIndex = 0;
2932
+ let lastUsage;
2933
+ for await (const chunk of stream) {
2934
+ const parts = chunk.candidates?.[0]?.content?.parts ?? [];
2935
+ for (const part of parts) {
2936
+ if (part.text) yield {
2937
+ type: "text-delta",
2938
+ delta: part.text
2939
+ };
2940
+ if (part.functionCall) {
2941
+ yield {
2942
+ type: "tool_call_delta",
2943
+ index: toolCallIndex,
2944
+ id: part.functionCall.name,
2945
+ name: part.functionCall.name,
2946
+ argumentsDelta: JSON.stringify(part.functionCall.args ?? {}),
2947
+ complete: true
2948
+ };
2949
+ toolCallIndex++;
2950
+ }
2951
+ }
2952
+ if (chunk.usageMetadata) lastUsage = chunk.usageMetadata;
882
2953
  }
883
- };
884
- } } } };
2954
+ if (lastUsage) yield {
2955
+ type: "usage",
2956
+ usage: {
2957
+ prompt_tokens: lastUsage.promptTokenCount,
2958
+ completion_tokens: lastUsage.candidatesTokenCount,
2959
+ total_tokens: lastUsage.totalTokenCount
2960
+ }
2961
+ };
2962
+ }
2963
+ } } };
885
2964
  }
886
2965
 
887
2966
  //#endregion
@@ -917,6 +2996,99 @@ function toBedrockContent(blocks) {
917
2996
  } } : { text: block.text });
918
2997
  }
919
2998
  /**
2999
+ * Maps VernLLM's OpenAI-shaped wire `tools`/`tool_choice` into Converse's
3000
+ * `toolConfig` shape. Shared by the two call sites that build real
3001
+ * (non-schema-forced) tool definitions: the plain tools-only branch, and
3002
+ * the native-structured-output branch, which sends real tools alongside
3003
+ * `outputConfig` rather than instead of it.
3004
+ */
3005
+ function buildBedrockToolConfig(tools, toolChoiceParam) {
3006
+ return {
3007
+ tools: tools.map((t) => ({ toolSpec: {
3008
+ name: t.function.name,
3009
+ description: t.function.description,
3010
+ inputSchema: { json: t.function.parameters }
3011
+ } })),
3012
+ toolChoice: toBedrockToolChoice(toolChoiceParam)
3013
+ };
3014
+ }
3015
+ /**
3016
+ * Builds the Converse-shaped request from VernLLM's wire params, shared
3017
+ * between `create` and `createStream` so both go through identical
3018
+ * translation (system prompt, message shaping, the jsonSchema →
3019
+ * forced-single-tool mapping, and the `toolUseSupportedModels` preflight
3020
+ * check all happen exactly once).
3021
+ *
3022
+ * Returns `toolName` alongside the request: when set, the model was forced
3023
+ * to call a single synthetic tool standing in for `jsonSchema` output (the
3024
+ * legacy path, for models not covered by `nativeStructuredOutputModels`),
3025
+ * and both `create` and `createStream` need to know this so they can
3026
+ * unwrap that tool call back into plain text content instead of treating
3027
+ * it like a real tool call. On the native path (model covered by
3028
+ * `nativeStructuredOutputModels`), `toolName` is `undefined`: the
3029
+ * schema-conforming JSON already arrives as ordinary text content, nothing
3030
+ * to unwrap, and any real tool calls in `params.tools` are left for the
3031
+ * normal, non-forced tool-call handling both `create` and `createStream`
3032
+ * already do when `toolName` is unset.
3033
+ */
3034
+ function buildBedrockRequest(params, toolUseSupportedModels, nativeStructuredOutputModels) {
3035
+ const systemMessage = params.messages.find((m) => m.role === "system");
3036
+ const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "tool");
3037
+ const jsonSchema = params.response_format?.type === "json_schema" ? params.response_format.json_schema : void 0;
3038
+ const schemaName = jsonSchema?.name.trim();
3039
+ if (jsonSchema && !schemaName) throw new LLMError("json_schema.name must not be empty.", "validation");
3040
+ const isNative = Boolean(jsonSchema) && supportsNativeStructuredOutput(params.model, nativeStructuredOutputModels);
3041
+ if (jsonSchema && params.tools?.length && !isNative) throw new LLMError(`Bedrock model "${params.model}" is not covered by nativeStructuredOutputModels, so \`jsonSchema\` is emulated as a forced single tool call there (via \`toolConfig\`), which collides with the \`tools\` you also provided. Either drop \`tools\` or \`jsonSchema\` for this call, or pass this model in fromBedrock's \`nativeStructuredOutputModels\` option once you've confirmed it supports Converse's \`outputConfig.textFormat\`.`, "validation");
3042
+ let toolName;
3043
+ let jsonInstruction;
3044
+ let toolConfig;
3045
+ let outputConfig;
3046
+ if (jsonSchema && isNative) {
3047
+ const { schema, description } = jsonSchema;
3048
+ outputConfig = { textFormat: {
3049
+ type: "json_schema",
3050
+ structure: { jsonSchema: {
3051
+ schema: JSON.stringify(schema),
3052
+ name: schemaName,
3053
+ description
3054
+ } }
3055
+ } };
3056
+ } else if (jsonSchema && schemaName) {
3057
+ const { schema, description, strict } = jsonSchema;
3058
+ toolName = schemaName;
3059
+ toolConfig = {
3060
+ tools: [{ toolSpec: {
3061
+ name: toolName,
3062
+ description,
3063
+ inputSchema: { json: schema },
3064
+ strict
3065
+ } }],
3066
+ toolChoice: { tool: { name: toolName } }
3067
+ };
3068
+ } else if (params.response_format?.type === "json_object") jsonInstruction = "Respond with valid JSON only, no prose or markdown fences.";
3069
+ if (params.tools?.length && !toolName) toolConfig = buildBedrockToolConfig(params.tools, params.tool_choice);
3070
+ if (jsonSchema && toolConfig && toolUseSupportedModels) {
3071
+ const isSupported = Array.isArray(toolUseSupportedModels) ? toolUseSupportedModels.includes(params.model) : toolUseSupportedModels(params.model);
3072
+ if (!isSupported) throw new LLMError(`Bedrock model "${params.model}" is not listed in toolUseSupportedModels, but this call requires Converse tool use (either jsonSchema emulated as a forced tool call, or real \`tools\` sent alongside native structured output).`, "validation");
3073
+ }
3074
+ const systemParts = [systemMessage?.content, jsonInstruction].filter((s) => Boolean(s));
3075
+ const request = {
3076
+ modelId: params.model,
3077
+ messages: mergeConsecutiveToolResults(conversationMessages.map((m) => toBedrockMessage(m))),
3078
+ system: systemParts.length ? systemParts.map((text) => ({ text })) : void 0,
3079
+ inferenceConfig: {
3080
+ ...params.temperature !== void 0 ? { temperature: params.temperature } : {},
3081
+ maxTokens: params.max_tokens
3082
+ },
3083
+ ...toolConfig ? { toolConfig } : {},
3084
+ ...outputConfig ? { outputConfig } : {}
3085
+ };
3086
+ return {
3087
+ request,
3088
+ toolName
3089
+ };
3090
+ }
3091
+ /**
920
3092
  * Wraps a Bedrock Converse-API client so it satisfies the `LLMClient`
921
3093
  * interface VernLLM uses for OpenAI/Groq. The Converse API is unified
922
3094
  * across Bedrock's model families (Anthropic, Titan, Llama, Mistral, etc.),
@@ -924,78 +3096,264 @@ function toBedrockContent(blocks) {
924
3096
  * regardless of which underlying model `modelId` points at, as long as
925
3097
  * that model supports Converse (most current-generation ones do)
926
3098
  *
927
- * `response_format: json_schema` is mapped to Converse's `toolConfig`: a
928
- * single tool is defined from the schema, description, and strictness settings,
929
- * and `toolChoice` forces the model to call it. Provider-constrained schema
930
- * matching applies only when `strict: true` is forwarded and supported.
931
- * Native tool support varies by model family; pass
932
- * `toolUseSupportedModels` to preflight-check it (see
3099
+ * `response_format: json_schema`, on a model covered by
3100
+ * `options.nativeStructuredOutputModels` (opt-in, unset by default), is
3101
+ * sent as `outputConfig.textFormat`, its own request field, independent of
3102
+ * `toolConfig`, so it can be combined with real, caller-supplied `tools`
3103
+ * in the same request. Matches the real Converse API's shape exactly: the
3104
+ * schema is nested under `structure.jsonSchema` and JSON-encoded as a
3105
+ * string, not the parsed object `toolConfig`'s tool schemas use, and there
3106
+ * is no `strict` field on this path.
3107
+ *
3108
+ * On any other model (the default), `response_format: json_schema` is
3109
+ * mapped to Converse's `toolConfig` instead: a single tool is defined from
3110
+ * the schema, description, and strictness settings, and `toolChoice`
3111
+ * forces the model to call it. This legacy path cannot be combined with
3112
+ * real `tools` (both would need the same `toolConfig`), and a call that
3113
+ * tries throws `LLMError('validation')` before reaching the API.
3114
+ * Provider-constrained schema matching applies only when `strict: true` is
3115
+ * forwarded and supported. Native tool support varies by model family;
3116
+ * pass `toolUseSupportedModels` to preflight-check it (see
933
3117
  * `BedrockAdapterOptions`), otherwise a `jsonSchema` call to an
934
3118
  * unsupported model surfaces Bedrock's raw error unchanged.
935
3119
  *
936
3120
  * `response_format: json_object` (no schema to build a tool from) and
937
3121
  * `reasoning_effort` (no Converse equivalent) fall back to a system-prompt
938
- * instruction and are dropped respectively.
3122
+ * instruction and are dropped respectively. Unlike `jsonSchema`,
3123
+ * `json_object` combines with real `tools` freely on every model: it's a
3124
+ * prompt nudge, not a request field, so there's nothing for it to collide
3125
+ * with.
3126
+ *
3127
+ * `tools` alone maps to Converse's native `toolConfig`/`toolUse`/
3128
+ * `toolResult`; `tool_choice` maps to `toolConfig.toolChoice`.
3129
+ *
3130
+ * `createStream` calls `converseStream` (optional on `BedrockConverseClient`
3131
+ *, required only if the caller sets `stream: true`) and translates its
3132
+ * `contentBlockStart`/`contentBlockDelta`/`metadata` events into
3133
+ * `WireStreamChunk`s. Content blocks are tracked by `contentBlockIndex`,
3134
+ * same as `fromAnthropic`'s block-index tracking (Converse's streaming
3135
+ * shape is structurally close to Anthropic's own, both being tool-use-aware
3136
+ * content-block streams), including the same `json-tool` unwrapping: a
3137
+ * `jsonSchema`-forced tool's `toolUse.input` deltas are re-emitted as
3138
+ * `text-delta`, not `tool_call_delta`, so the accumulated result lands in
3139
+ * `finalizeResponse`'s `content` path exactly like the non-streaming
3140
+ * `create` branch above unwraps it.
939
3141
  */
940
3142
  function fromBedrock(bedrockClient, options) {
941
3143
  const toolUseSupportedModels = options?.toolUseSupportedModels;
942
- return { chat: { completions: { async create(params, requestOptions) {
943
- const systemMessage = params.messages.find((m) => m.role === "system");
944
- const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant");
945
- const jsonSchema = params.response_format?.type === "json_schema" ? params.response_format.json_schema : void 0;
946
- const toolName = jsonSchema?.name.trim();
947
- if (jsonSchema && !toolName) throw new LLMError("json_schema.name must not be empty.", "validation");
948
- let jsonInstruction;
949
- let toolConfig;
950
- if (jsonSchema) {
951
- const { schema, description, strict } = jsonSchema;
952
- toolConfig = {
953
- tools: [{ toolSpec: {
954
- name: toolName,
955
- description,
956
- inputSchema: { json: schema },
957
- strict
3144
+ const nativeStructuredOutputModels = options?.nativeStructuredOutputModels;
3145
+ return { chat: { completions: {
3146
+ async create(params, requestOptions) {
3147
+ const { request, toolName } = buildBedrockRequest(params, toolUseSupportedModels, nativeStructuredOutputModels);
3148
+ const response = await bedrockClient.converse(request, requestOptions);
3149
+ let text;
3150
+ let wireToolCalls;
3151
+ if (toolName) {
3152
+ const toolUseBlock = response.output?.message?.content?.find((block) => block.toolUse?.name === toolName);
3153
+ text = toolUseBlock?.toolUse ? JSON.stringify(toolUseBlock.toolUse.input) : "";
3154
+ } else {
3155
+ const blocks = response.output?.message?.content ?? [];
3156
+ text = blocks.map((c) => c.text ?? "").join("");
3157
+ const toolUses = blocks.filter((block) => Boolean(block.toolUse));
3158
+ if (toolUses.length) wireToolCalls = toolUses.map((block, i) => {
3159
+ const toolUse = block.toolUse;
3160
+ if (!toolUse.name) throw new LLMError(`Bedrock returned a toolUse block without a name at index ${i}.`, "validation");
3161
+ return {
3162
+ id: toolUse.toolUseId ?? `${toolUse.name}_${i}`,
3163
+ type: "function",
3164
+ function: {
3165
+ name: toolUse.name,
3166
+ arguments: JSON.stringify(toolUse.input ?? {})
3167
+ }
3168
+ };
3169
+ });
3170
+ }
3171
+ return {
3172
+ choices: [{ message: {
3173
+ content: text,
3174
+ ...wireToolCalls ? { tool_calls: wireToolCalls } : {}
958
3175
  } }],
959
- toolChoice: { tool: { name: toolName } }
3176
+ usage: {
3177
+ prompt_tokens: response.usage?.inputTokens,
3178
+ completion_tokens: response.usage?.outputTokens,
3179
+ total_tokens: response.usage?.totalTokens
3180
+ }
3181
+ };
3182
+ },
3183
+ async *createStream(params, requestOptions) {
3184
+ if (!bedrockClient.converseStream) throw new LLMError("stream: true requires a Bedrock client with converseStream", "validation");
3185
+ const { request, toolName } = buildBedrockRequest(params, toolUseSupportedModels, nativeStructuredOutputModels);
3186
+ const { stream } = await bedrockClient.converseStream(request, requestOptions);
3187
+ const blockKinds = new Map();
3188
+ for await (const event of stream) if ("contentBlockStart" in event) {
3189
+ const { contentBlockIndex, start } = event.contentBlockStart;
3190
+ if (start?.toolUse) {
3191
+ const kind = start.toolUse.name === toolName ? "json-tool" : "tool_use";
3192
+ blockKinds.set(contentBlockIndex, kind);
3193
+ if (kind === "tool_use" && !toolName) yield {
3194
+ type: "tool_call_delta",
3195
+ index: contentBlockIndex,
3196
+ id: start.toolUse.toolUseId,
3197
+ name: start.toolUse.name
3198
+ };
3199
+ } else blockKinds.set(contentBlockIndex, "text");
3200
+ } else if ("contentBlockDelta" in event) {
3201
+ const { contentBlockIndex, delta } = event.contentBlockDelta;
3202
+ if (delta && "text" in delta && delta.text !== void 0 && !toolName) yield {
3203
+ type: "text-delta",
3204
+ delta: delta.text
3205
+ };
3206
+ else if (delta && "toolUse" in delta && delta.toolUse?.input !== void 0) {
3207
+ const kind = blockKinds.get(contentBlockIndex);
3208
+ if (kind === "json-tool") yield {
3209
+ type: "text-delta",
3210
+ delta: delta.toolUse.input
3211
+ };
3212
+ else if (!toolName) yield {
3213
+ type: "tool_call_delta",
3214
+ index: contentBlockIndex,
3215
+ argumentsDelta: delta.toolUse.input
3216
+ };
3217
+ }
3218
+ } else if ("metadata" in event && event.metadata.usage) yield {
3219
+ type: "usage",
3220
+ usage: {
3221
+ prompt_tokens: event.metadata.usage.inputTokens,
3222
+ completion_tokens: event.metadata.usage.outputTokens,
3223
+ total_tokens: event.metadata.usage.totalTokens
3224
+ }
960
3225
  };
961
- } else if (params.response_format?.type === "json_object") jsonInstruction = "Respond with valid JSON only, no prose or markdown fences.";
962
- if (jsonSchema && toolUseSupportedModels) {
963
- const isSupported = Array.isArray(toolUseSupportedModels) ? toolUseSupportedModels.includes(params.model) : toolUseSupportedModels(params.model);
964
- if (!isSupported) throw new LLMError(`Bedrock model "${params.model}" is not listed in toolUseSupportedModels, but jsonSchema structured output requires Converse tool use.`, "validation");
3226
+ else if ("throttlingException" in event) throw new LLMError(event.throttlingException.message ?? "Bedrock throttled the request mid-stream", "api", 429);
3227
+ else if ("validationException" in event) throw new LLMError(event.validationException.message ?? "Bedrock rejected the request mid-stream", "validation");
3228
+ else if ("internalServerException" in event || "serviceUnavailableException" in event || "modelStreamErrorException" in event) {
3229
+ const detail = "internalServerException" in event && event.internalServerException.message || "serviceUnavailableException" in event && event.serviceUnavailableException.message || "modelStreamErrorException" in event && event.modelStreamErrorException.message || "Bedrock reported a mid-stream error";
3230
+ const status = "modelStreamErrorException" in event && event.modelStreamErrorException.originalStatusCode || "serviceUnavailableException" in event && 503 || 500;
3231
+ throw new LLMError(detail, "api", status);
3232
+ }
965
3233
  }
966
- const systemParts = [systemMessage?.content, jsonInstruction].filter((s) => Boolean(s));
967
- const response = await bedrockClient.converse({
968
- modelId: params.model,
969
- messages: conversationMessages.map((m) => ({
970
- role: m.role,
971
- content: Array.isArray(m.content) ? toBedrockContent(m.content) : [{ text: m.content }]
972
- })),
973
- system: systemParts.length ? systemParts.map((text$1) => ({ text: text$1 })) : void 0,
974
- inferenceConfig: {
975
- temperature: params.temperature,
976
- maxTokens: params.max_tokens
977
- },
978
- ...toolConfig ? { toolConfig } : {}
979
- }, requestOptions);
980
- let text;
981
- if (toolName) {
982
- const toolUseBlock = response.output?.message?.content?.find((block) => block.toolUse?.name === toolName);
983
- text = toolUseBlock?.toolUse ? JSON.stringify(toolUseBlock.toolUse.input) : "";
984
- } else text = response.output?.message?.content?.map((c) => c.text ?? "").join("") ?? "";
985
- return {
986
- choices: [{ message: { content: text } }],
987
- usage: {
988
- prompt_tokens: response.usage?.inputTokens,
989
- completion_tokens: response.usage?.outputTokens,
990
- total_tokens: response.usage?.totalTokens
3234
+ } } };
3235
+ }
3236
+ /** Maps VernLLM's OpenAI-shaped wire `tool_choice` onto Converse's `toolChoice`. */
3237
+ function toBedrockToolChoice(toolChoice) {
3238
+ if (!toolChoice || toolChoice === "auto") return { auto: {} };
3239
+ if (toolChoice === "required") return { any: {} };
3240
+ if (toolChoice === "none") throw new LLMError("'none' is not supported by fromBedrock: Bedrock Converse has no `tool_choice` equivalent to forbidding tool use while tools are still offered. Omit `tools` entirely for this call instead.", "validation");
3241
+ return { tool: { name: toolChoice.function.name } };
3242
+ }
3243
+ /**
3244
+ * Translates one VernLLM wire message into Converse's
3245
+ * `{ role: 'user' | 'assistant', content }` shape.
3246
+ */
3247
+ function toBedrockMessage(m) {
3248
+ if (m.role === "tool") return {
3249
+ role: "user",
3250
+ content: [{ toolResult: {
3251
+ toolUseId: m.tool_call_id,
3252
+ content: [{ text: m.content }],
3253
+ status: m.is_error ? "error" : "success"
3254
+ } }]
3255
+ };
3256
+ if (m.role === "assistant" && m.tool_calls?.length) {
3257
+ const blocks = [];
3258
+ if (m.content) blocks.push({ text: m.content });
3259
+ for (const tc of m.tool_calls) {
3260
+ let input;
3261
+ if (!tc.function.arguments.trim()) input = {};
3262
+ else try {
3263
+ input = JSON.parse(tc.function.arguments);
3264
+ } catch (cause) {
3265
+ throw new LLMError(`Assistant tool call "${tc.function.name}" (${tc.id}) has arguments that are not valid JSON.`, "validation", void 0, void 0, cause);
991
3266
  }
3267
+ blocks.push({ toolUse: {
3268
+ toolUseId: tc.id,
3269
+ name: tc.function.name,
3270
+ input
3271
+ } });
3272
+ }
3273
+ return {
3274
+ role: "assistant",
3275
+ content: blocks
992
3276
  };
993
- } } } };
3277
+ }
3278
+ return {
3279
+ role: m.role,
3280
+ content: Array.isArray(m.content) ? toBedrockContent(m.content) : [{ text: m.content ?? "" }]
3281
+ };
3282
+ }
3283
+ /**
3284
+ * Converse expects the results of everything the model asked for in one
3285
+ * turn to arrive together as multiple `toolResult` content blocks on a
3286
+ * single `'user'` message, not as separate consecutive `'user'` messages.
3287
+ * The per-wire-message mapping above produces one `'user'` message per
3288
+ * VernLLM wire tool message, so when an assistant turn requested more than
3289
+ * one tool, this merges the resulting run of toolResult-only `'user'`
3290
+ * messages back into one.
3291
+ */
3292
+ function mergeConsecutiveToolResults(messages) {
3293
+ const isToolResultOnly = (m) => m.role === "user" && m.content.length > 0 && m.content.every((b) => "toolResult" in b);
3294
+ const merged = [];
3295
+ for (const m of messages) {
3296
+ const prev = merged.at(-1);
3297
+ if (isToolResultOnly(m) && prev && isToolResultOnly(prev)) prev.content.push(...m.content);
3298
+ else merged.push(m);
3299
+ }
3300
+ return merged;
994
3301
  }
995
3302
 
996
3303
  //#endregion
997
3304
  //#region src/adapters/fetch.ts
998
3305
  /**
3306
+ * Wraps a WHATWG `ReadableStream` (what `response.body` is) so it can be
3307
+ * consumed with `for await`. Implemented via `getReader()` rather than
3308
+ * relying on `ReadableStream` having a native `Symbol.asyncIterator`,
3309
+ * that support varies across runtimes/versions, and this works everywhere
3310
+ * a `ReadableStream` does.
3311
+ */
3312
+ async function* webStreamToAsyncIterable(stream) {
3313
+ const reader = stream.getReader();
3314
+ try {
3315
+ for (;;) {
3316
+ const { done, value } = await reader.read();
3317
+ if (done) return;
3318
+ if (value) yield value;
3319
+ }
3320
+ } finally {
3321
+ try {
3322
+ await reader.cancel();
3323
+ } catch {}
3324
+ reader.releaseLock();
3325
+ }
3326
+ }
3327
+ /** Default `requestStream`: native `fetch`, with the same error/`.status` contract non-streaming errors get. */
3328
+ async function defaultRequestStream(url, init) {
3329
+ const res = await fetch(url, init);
3330
+ if (!res.ok) {
3331
+ const body = await res.text().catch(() => "");
3332
+ const err = new Error(`Fetch adapter stream request failed (${res.status}): ${body.slice(0, 500)}`);
3333
+ err.status = res.status;
3334
+ err.headers = res.headers;
3335
+ throw err;
3336
+ }
3337
+ if (!res.body) throw new Error("Fetch adapter stream request received a response with no body.");
3338
+ return webStreamToAsyncIterable(res.body);
3339
+ }
3340
+ /** Builds the shared `{ method, headers, body? }` request-init for both `create` and `createStream`. */
3341
+ async function buildRequestInit(config, params, requestBody) {
3342
+ const url = typeof config.url === "function" ? config.url(params) : config.url;
3343
+ const headers = typeof config.headers === "function" ? await config.headers() : config.headers;
3344
+ const method = config.method ?? "POST";
3345
+ const supportsBody = !["GET", "HEAD"].includes(method.toUpperCase());
3346
+ return {
3347
+ url,
3348
+ method,
3349
+ headers: supportsBody ? {
3350
+ "Content-Type": "application/json",
3351
+ ...headers
3352
+ } : { ...headers },
3353
+ ...supportsBody ? { body: JSON.stringify(requestBody) } : {}
3354
+ };
3355
+ }
3356
+ /**
999
3357
  * A fetch-based escape hatch for providers with no SDK, or where pulling one
1000
3358
  * in isnt worth it. You supply the URL, headers, and two small mapping
1001
3359
  * functions; this handles the HTTP call and slots the result into the same
@@ -1005,41 +3363,99 @@ function fromBedrock(bedrockClient, options) {
1005
3363
  * Non-2xx responses throw an error with `.status` set to the HTTP status
1006
3364
  * code, so VernLLMs `nonRetryableStatus` handling (e.g. failing fast on
1007
3365
  * 401/403) applies here too
3366
+ *
3367
+ * Tool calling works the same way as every other adapter: `mapRequest`
3368
+ * receives the full `ChatRequest`, including `tools`/`toolChoice`, so it can
3369
+ * translate them into whatever shape the provider's wire format expects
3370
+ * (typically an OpenAI-`function`-wrapped `tools` array plus a `tool_choice`
3371
+ * field). On the way back, `mapResponse` may return a `toolCalls` array
3372
+ * (id/name/JSON-encoded-arguments-string per call) alongside or instead of
3373
+ * `content`; VernLLM parses and (if `argumentsSchema` was set) validates
3374
+ * those arguments the same way it does for every other adapter. For
3375
+ * `stream: true`, tool-call deltas go through the existing
3376
+ * `mapStreamEvent` seam via `WireStreamChunk`'s `tool_call_delta` variant,
3377
+ * no separate config is needed for streaming vs non-streaming tool calls.
3378
+ *
3379
+
3380
+ * `createStream` requires `mapStreamEvent` (there's no non-streaming
3381
+ * response to fall back on, unlike the other three optional streaming
3382
+ * seams). It opens the request via `requestStream` (defaults to native
3383
+ * `fetch`), splits the raw bytes into individual events via
3384
+ * `parseStreamFrames` (defaults to SSE framing, see `parseSseStream`),
3385
+ * and translates each event into `WireStreamChunk`(s) via
3386
+ * `mapStreamEvent`. Both seams are overridable per-config for providers
3387
+ * that don't fit the SSE-over-fetch default. If a custom `request`
3388
+ * transport is configured, `requestStream` must be configured too,
3389
+ * `requestStream` never silently falls back to `request` (see
3390
+ * `createStream`'s own comment for why), so a `stream: true` call with
3391
+ * `request` set but no `requestStream` throws a clear
3392
+ * `LLMError('validation')` instead of quietly using unrelated native
3393
+ * `fetch`.
1008
3394
  */
1009
3395
  function fromFetch(config) {
1010
- return { chat: { completions: { async create(params, options) {
1011
- const url = typeof config.url === "function" ? config.url(params) : config.url;
1012
- const headers = typeof config.headers === "function" ? await config.headers() : config.headers;
1013
- const method = config.method ?? "POST";
1014
- const request = config.request ?? fetch;
1015
- const supportsBody = !["GET", "HEAD"].includes(method.toUpperCase());
1016
- const res = await request(url, {
1017
- method,
1018
- headers: supportsBody ? {
1019
- "Content-Type": "application/json",
1020
- ...headers
1021
- } : { ...headers },
1022
- ...supportsBody ? { body: JSON.stringify(config.mapRequest(params)) } : {},
1023
- signal: options.signal
1024
- });
1025
- if (!res.ok) {
1026
- const body = await res.text().catch(() => "");
1027
- const err = new Error(`Fetch adapter request failed (${res.status}): ${body.slice(0, 500)}`);
1028
- err.status = res.status;
1029
- err.headers = res.headers;
1030
- throw err;
3396
+ return { chat: { completions: {
3397
+ async create(params, options) {
3398
+ const { url, method, headers, body } = await buildRequestInit(config, params, config.mapRequest(params));
3399
+ const request = config.request ?? fetch;
3400
+ const res = await request(url, {
3401
+ method,
3402
+ headers,
3403
+ body,
3404
+ signal: options.signal
3405
+ });
3406
+ if (!res.ok) {
3407
+ const responseBody = await res.text().catch(() => "");
3408
+ const err = new Error(`Fetch adapter request failed (${res.status}): ${responseBody.slice(0, 500)}`);
3409
+ err.status = res.status;
3410
+ err.headers = res.headers;
3411
+ throw err;
3412
+ }
3413
+ const json = await res.json();
3414
+ const { content, usage, toolCalls } = config.mapResponse(json);
3415
+ const wireToolCalls = toolCalls?.length ? toolCalls.map((tc) => ({
3416
+ id: tc.id,
3417
+ type: "function",
3418
+ function: {
3419
+ name: tc.name,
3420
+ arguments: tc.arguments
3421
+ }
3422
+ })) : void 0;
3423
+ return {
3424
+ choices: [{ message: {
3425
+ content,
3426
+ ...wireToolCalls ? { tool_calls: wireToolCalls } : {}
3427
+ } }],
3428
+ usage: usage ? {
3429
+ prompt_tokens: usage.promptTokens,
3430
+ completion_tokens: usage.completionTokens,
3431
+ total_tokens: usage.totalTokens
3432
+ } : void 0
3433
+ };
3434
+ },
3435
+ async *createStream(params, options) {
3436
+ if (!config.mapStreamEvent) throw new LLMError("stream: true requires mapStreamEvent to be configured on fromFetch", "validation");
3437
+ if (config.request && !config.requestStream) throw new LLMError("`stream: true` requires `requestStream` to be configured on fromFetch when a custom `request` transport is set. `requestStream` does not fall back to `request` (it needs an async-iterable byte stream, which `RequestLike`'s buffered `ResponseLike` has no way to provide), without it, `stream: true` would silently use plain native `fetch` instead of your configured transport. Add a `requestStream` that opens the same connection your `request` does, or omit `request` if native `fetch` is fine for both.", "validation");
3438
+ const { url, method, headers, body } = await buildRequestInit(config, params, config.mapRequest(params));
3439
+ const requestStream = config.requestStream ?? defaultRequestStream;
3440
+ const parseFrames = config.parseStreamFrames ?? parseSseStream;
3441
+ const byteStream = await requestStream(url, {
3442
+ method,
3443
+ headers,
3444
+ body,
3445
+ signal: options.signal
3446
+ });
3447
+ for await (const event of parseFrames(byteStream)) {
3448
+ if (event === SSE_PING) {
3449
+ yield { type: "ping" };
3450
+ continue;
3451
+ }
3452
+ const wireChunks = config.mapStreamEvent(event);
3453
+ if (!wireChunks) continue;
3454
+ if (Array.isArray(wireChunks)) yield* wireChunks;
3455
+ else yield wireChunks;
3456
+ }
1031
3457
  }
1032
- const json = await res.json();
1033
- const { content, usage } = config.mapResponse(json);
1034
- return {
1035
- choices: [{ message: { content } }],
1036
- usage: usage ? {
1037
- prompt_tokens: usage.promptTokens,
1038
- completion_tokens: usage.completionTokens,
1039
- total_tokens: usage.totalTokens
1040
- } : void 0
1041
- };
1042
- } } } };
3458
+ } } };
1043
3459
  }
1044
3460
 
1045
3461
  //#endregion
@@ -1062,41 +3478,84 @@ function toOpenAIContent(blocks) {
1062
3478
  });
1063
3479
  }
1064
3480
  /**
1065
- * Adapter for any SDK/client whose `chat.completions.create` already
1066
- * matches the OpenAI wire format: this covers most hosted inference
1067
- * providers, since "OpenAI-compatible" is a de facto standard for chat
1068
- * completion APIs. Almost everything passes straight through untouched,
1069
- * this exists purely so call sites read clearly (`fromMistral(client)` vs
1070
- * handing a Mistral client to something typed for OpenAI) and so a real
1071
- * transformation could be added later, per-provider, without a breaking
1072
- * change.
1073
- *
1074
- * The one thing that isn't a pure passthrough: a `ContentBlock[]`
1075
- * `userContent` is translated into OpenAI's native `image_url` content-part
1076
- * shape, since VernLLM's `ContentBlock` is intentionally provider-agnostic
1077
- * rather than a copy of any one provider's wire format.
1078
- *
1079
- * Not every SDKs own TypeScript types line up exactly with `LLMClient`
1080
- * (extra fields, stricter unions, etc.), so this takes `unknown` and casts:
1081
- * the actual compatibility contract is the JSON each provider sends and
1082
- * receives over the wire, not the SDKs TS types.
3481
+ * Translates VernLLM's provider-agnostic `messages` (the one part of a
3482
+ * request that isn't a pure passthrough for OpenAI-compatible clients) into
3483
+ * OpenAI's native wire shape. Shared between `create` and `createStream` so
3484
+ * both go through identical message translation.
1083
3485
  */
1084
- function fromOpenAICompatible(client) {
1085
- const raw = client;
1086
- return { chat: { completions: { async create(params, options) {
1087
- const messages = params.messages.map((m) => m.role === "user" && Array.isArray(m.content) ? {
3486
+ function toOpenAIMessages(params) {
3487
+ return params.messages.map((m) => {
3488
+ if (m.role === "user" && Array.isArray(m.content)) return {
1088
3489
  ...m,
1089
3490
  content: toOpenAIContent(m.content)
1090
- } : m);
1091
- return raw.chat.completions.create({
1092
- ...params,
1093
- messages
1094
- }, options);
1095
- } } } };
3491
+ };
3492
+ if (m.role === "tool") {
3493
+ const { is_error: _isError,...openAIToolMessage } = m;
3494
+ return openAIToolMessage;
3495
+ }
3496
+ return m;
3497
+ });
3498
+ }
3499
+ /**
3500
+ * Translates one OpenAI-shaped SSE chunk into zero or more `WireStreamChunk`s.
3501
+ * A single chunk can carry a text delta, one or more tool-call argument
3502
+ * deltas (each keyed by `index`, OpenAI's own convention for streaming
3503
+ * parallel tool calls, mirrored directly by VernLLM's `tool_call_delta`
3504
+ * shape so accumulation composes without translation), and/or a final
3505
+ * usage block (present only when `stream_options.include_usage` is set,
3506
+ * which this adapter always sets).
3507
+ */
3508
+ function* toWireStreamChunks(chunk) {
3509
+ const delta = chunk.choices?.[0]?.delta;
3510
+ if (delta?.content) yield {
3511
+ type: "text-delta",
3512
+ delta: delta.content
3513
+ };
3514
+ if (delta?.tool_calls?.length) for (const toolCall of delta.tool_calls) yield {
3515
+ type: "tool_call_delta",
3516
+ index: toolCall.index,
3517
+ id: toolCall.id,
3518
+ name: toolCall.function?.name,
3519
+ argumentsDelta: toolCall.function?.arguments
3520
+ };
3521
+ if (chunk.usage) yield {
3522
+ type: "usage",
3523
+ usage: chunk.usage
3524
+ };
3525
+ }
3526
+ function fromOpenAICompatible(client, options = {}) {
3527
+ const raw = client;
3528
+ const { supportsStreamUsage = true } = options;
3529
+ const rawCreate = raw.chat.completions.create.bind(raw.chat.completions);
3530
+ return { chat: { completions: {
3531
+ async create(params, options$1) {
3532
+ const messages = toOpenAIMessages(params);
3533
+ return raw.chat.completions.create({
3534
+ ...params,
3535
+ messages
3536
+ }, options$1);
3537
+ },
3538
+ async *createStream(params, options$1) {
3539
+ const messages = toOpenAIMessages(params);
3540
+ const stream = await rawCreate({
3541
+ ...params,
3542
+ messages,
3543
+ stream: true,
3544
+ ...supportsStreamUsage ? { stream_options: { include_usage: true } } : {}
3545
+ }, options$1);
3546
+ for await (const chunk of stream) yield* toWireStreamChunks(chunk);
3547
+ }
3548
+ } } };
1096
3549
  }
1097
3550
  /** Groqs SDK matches the OpenAI wire format */
1098
3551
  const fromGroq = fromOpenAICompatible;
1099
- /** Mistrals `chat.completions`-shaped client (or their OpenAI-compat endpoint) */
3552
+ /**
3553
+ * Mistrals `chat.completions`-shaped client (or their OpenAI-compat
3554
+ * endpoint). Mistral supports `stream_options.include_usage` (added after
3555
+ * an earlier period where it returned a 422 for unrecognized fields, per
3556
+ * Mistral's changelog and streaming docs), so this is a plain alias like
3557
+ * the others, `supportsStreamUsage` defaults to `true`.
3558
+ */
1100
3559
  const fromMistral = fromOpenAICompatible;
1101
3560
  /** DeepSeeks API is OpenAI-compatible */
1102
3561
  const fromDeepSeek = fromOpenAICompatible;
@@ -1185,5 +3644,5 @@ const fromAtlasCloud = fromOpenAICompatible;
1185
3644
  const from01AI = fromOpenAICompatible;
1186
3645
 
1187
3646
  //#endregion
1188
- export { CircuitBreaker, ConsoleLogger, InMemoryCacheAdapter, LLMError, NormalizedCacheAdapter, TieredCacheAdapter, VernLLM, from01AI, fromAnthropic, fromAnyscale, fromAtlasCloud, fromBaseten, fromBedrock, fromCerebras, fromCloudflareWorkersAI, fromDeepInfra, fromDeepSeek, fromFeatherless, fromFetch, fromFireworks, fromFriendli, fromGemini, fromGitHubModels, fromGroq, fromHyperbolic, fromInferenceNet, fromInfermatic, fromKlusterAI, fromLMStudio, fromLambdaLabs, fromLepton, fromMiniMax, fromMistral, fromMoonshot, fromNebius, fromNovita, fromNvidiaNIM, fromOllama, fromOpenAICompatible, fromOpenRouter, fromParasail, fromPerplexity, fromSambaNova, fromSiliconFlow, fromSnowflakeCortex, fromStepFun, fromTogether, fromVLLM, fromVercelAIGateway, fromXAI, fromZhipu, isLLMError };
3647
+ export { CircuitBreaker, ConsoleLogger, FallbackExhaustedError, InMemoryCacheAdapter, LLMError, NormalizedCacheAdapter, RateLimiter, SSE_PING, TieredCacheAdapter, VernLLM, defaultEstimateTokens, defaultFallbackOn, from01AI, fromAnthropic, fromAnyscale, fromAtlasCloud, fromBaseten, fromBedrock, fromCerebras, fromCloudflareWorkersAI, fromDeepInfra, fromDeepSeek, fromFeatherless, fromFetch, fromFireworks, fromFriendli, fromGemini, fromGitHubModels, fromGroq, fromHyperbolic, fromInferenceNet, fromInfermatic, fromKlusterAI, fromLMStudio, fromLambdaLabs, fromLepton, fromMiniMax, fromMistral, fromMoonshot, fromNebius, fromNovita, fromNvidiaNIM, fromOllama, fromOpenAICompatible, fromOpenRouter, fromParasail, fromPerplexity, fromSambaNova, fromSiliconFlow, fromSnowflakeCortex, fromStepFun, fromTogether, fromVLLM, fromVercelAIGateway, fromXAI, fromZhipu, isLLMError, isToolCallResult, parseSseStream };
1189
3648
  //# sourceMappingURL=index.js.map