vern-llm 2.2.0 → 2.3.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.cjs CHANGED
@@ -25,23 +25,205 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
25
25
  const crypto = __toESM(require("crypto"));
26
26
 
27
27
  //#region src/types/errors.ts
28
+ /**
29
+ * Tool contract codes: a model or provider response defect, not a
30
+ * transient provider fault. Deterministic on the wire request, so
31
+ * retrying can't change the outcome and it shouldn't count toward the
32
+ * circuit breaker either. Shared by `LLMError.retryable` below and by
33
+ * `CallExecutor`'s own retry/breaker accounting, so the two can't drift
34
+ * apart.
35
+ */
36
+ const NON_RETRYABLE_TOOL_CONTRACT_CODES = new Set([
37
+ "unknown_tool",
38
+ "duplicate_tool_call_id",
39
+ "tool_choice_none_violated",
40
+ "unexpected_tool_calls"
41
+ ]);
42
+ /**
43
+ * Local rate-limit codes: the call never reached the provider, so it says
44
+ * nothing about the provider's health, and retrying either just requeues
45
+ * behind the same limit (the two queue codes) or can never succeed at all
46
+ * (`rate_limit_capacity_exceeded`). Shared for the same reason as
47
+ * {@link NON_RETRYABLE_TOOL_CONTRACT_CODES}.
48
+ */
49
+ const LOCAL_RATE_LIMIT_CODES = new Set([
50
+ "rate_limit_queue_full",
51
+ "rate_limit_queue_timeout",
52
+ "rate_limit_capacity_exceeded"
53
+ ]);
54
+ /**
55
+ * Types that are never worth retrying on their own: deterministic
56
+ * caller-input, model-response, or cancellation failures rather than a
57
+ * transient provider fault.
58
+ */
59
+ const NON_RETRYABLE_TYPES = new Set([
60
+ "parse",
61
+ "validation",
62
+ "invalid_params",
63
+ "aborted"
64
+ ]);
65
+ /**
66
+ * Shared retryability rule behind both `LLMError.retryable` and
67
+ * `LLMErrorSnapshot.retryable`. Pulled out so the two can't drift apart:
68
+ * a snapshot is a point-in-time copy of an error's fields, and this is
69
+ * one of them, so it has to be computed the same way in both places.
70
+ */
71
+ function computeRetryable(type, code) {
72
+ if (NON_RETRYABLE_TYPES.has(type)) return false;
73
+ if (code && NON_RETRYABLE_TOOL_CONTRACT_CODES.has(code)) return false;
74
+ if (code && LOCAL_RATE_LIMIT_CODES.has(code)) return false;
75
+ return true;
76
+ }
77
+ /**
78
+ * Returns `issues` unchanged when it can survive `JSON.stringify`.
79
+ * Most `issues` values are VernLLM's own structured shapes (see
80
+ * `LLMErrorIssuesByCode`) and always safe. The one exception is a
81
+ * schema validation failure, where `issues` is a caller supplied
82
+ * `SchemaLike` validator's own `error: unknown`, not controlled by
83
+ * VernLLM and not guaranteed to be circular free. Rather than silently
84
+ * dropping it in that case, this returns a marker string so a reader
85
+ * of serialized output can tell "no issues data" apart from "issues
86
+ * existed but could not be shown".
87
+ */
88
+ function safeIssues(issues) {
89
+ if (issues === void 0) return void 0;
90
+ try {
91
+ JSON.stringify(issues);
92
+ return issues;
93
+ } catch {
94
+ return "[Unserializable: issues contained a circular reference]";
95
+ }
96
+ }
97
+ /**
98
+ * Depth cap for `safeAttempts`, guarding against a pathological,
99
+ * self referential `attempts` array. `attempts` is a public
100
+ * `LLMErrorOptions` field, so a caller can construct one by hand; this
101
+ * keeps that path bounded the same way a circular `issues` value is
102
+ * bounded, rather than assuming well formed input.
103
+ */
104
+ const MAX_ATTEMPTS_DEPTH = 20;
105
+ /**
106
+ * Returns a copy of `attempts` with every nested snapshot's `issues`
107
+ * re-checked through `safeIssues`, recursively through each snapshot's
108
+ * own `attempts`. Needed for two reasons: `safeIssues` returns a safe
109
+ * `issues` value by reference, so a shared object can be mutated into a
110
+ * circular one after the snapshot was created, and `attempts` is a
111
+ * public constructor option, so a caller can hand build a `RetryAttempt`
112
+ * (or a whole `LLMErrorSnapshot`) with a circular `issues` and pass it
113
+ * in directly, never touching `toSnapshot()` at all. Extra fields on an
114
+ * attempt (e.g. `FallbackAttempt`'s `provider`/`model`) are preserved.
115
+ */
116
+ function safeAttempts(attempts, depth = 0) {
117
+ if (attempts === void 0) return void 0;
118
+ if (depth >= MAX_ATTEMPTS_DEPTH) return [];
119
+ return attempts.map((attempt) => ({
120
+ ...attempt,
121
+ error: {
122
+ ...attempt.error,
123
+ issues: safeIssues(attempt.error.issues),
124
+ attempts: safeAttempts(attempt.error.attempts, depth + 1)
125
+ }
126
+ }));
127
+ }
28
128
  var LLMError = class extends Error {
29
- constructor(message, type, status, issues, cause, retryAfterMs, code) {
129
+ status;
130
+ issues;
131
+ cause;
132
+ retryAfterMs;
133
+ /** Stable discriminator within `type`. Absent on errors predating it. */
134
+ code;
135
+ /** Every attempt made before this error was thrown, in order. Absent when nothing was retried. */
136
+ attempts;
137
+ constructor(message, type, options = {}) {
30
138
  super(message);
31
139
  this.type = type;
32
- this.status = status;
33
- this.issues = issues;
34
- this.cause = cause;
35
- this.retryAfterMs = retryAfterMs;
36
- this.code = code;
37
140
  this.name = "LLMError";
141
+ this.status = options.status;
142
+ this.issues = options.issues;
143
+ this.cause = options.cause;
144
+ this.retryAfterMs = options.retryAfterMs;
145
+ this.code = options.code;
146
+ this.attempts = options.attempts;
147
+ }
148
+ /**
149
+ * Computed purely from `type`/`code`, independent of any specific call's
150
+ * `nonRetryableStatus` list. False for `parse`/`validation`/
151
+ * `invalid_params`/`aborted` types (the caller's own input, the model's
152
+ * own response, or intentional cancellation, none of which are the
153
+ * provider being unhealthy), the tool contract codes, and the local
154
+ * rate limit codes. Subclasses (see `FallbackExhaustedError`) may
155
+ * override this when `type` alone carries no retry signal.
156
+ */
157
+ get retryable() {
158
+ return computeRetryable(this.type, this.code);
159
+ }
160
+ /**
161
+ * Copies this error's fields into an {@link LLMErrorSnapshot}, for
162
+ * recording as a `RetryAttempt`/`FallbackAttempt`. `retryable` is
163
+ * captured here since a snapshot has no getter of its own. `cause` is
164
+ * not copied, see `LLMErrorSnapshot`'s own doc. `issues` and every
165
+ * nested `attempts` entry's own `issues` go through `safeAttempts`,
166
+ * since a schema validation failure's `issues` is a caller supplied
167
+ * value, not controlled by VernLLM, and `attempts` is itself a public
168
+ * constructor option a caller can hand build.
169
+ */
170
+ toSnapshot() {
171
+ return {
172
+ message: this.message,
173
+ type: this.type,
174
+ status: this.status,
175
+ issues: safeIssues(this.issues),
176
+ retryAfterMs: this.retryAfterMs,
177
+ code: this.code,
178
+ retryable: this.retryable,
179
+ attempts: safeAttempts(this.attempts)
180
+ };
181
+ }
182
+ /**
183
+ * Controls what `JSON.stringify(err)` produces. Omits `cause` for the
184
+ * same reason `toSnapshot()` does: `cause` is `unknown` and never
185
+ * validated by VernLLM, and some SDK errors carry circular structures
186
+ * `JSON.stringify` cannot serialize at all. Read `err.cause` directly
187
+ * instead. `issues`, including every nested `attempts` entry's own
188
+ * `issues`, goes through `safeAttempts` for the same reason: a schema
189
+ * validation failure's `issues` is caller supplied and not guaranteed
190
+ * circular free. Also includes `message` and `retryable`, which a
191
+ * plain property walk would otherwise miss: `message` is
192
+ * non-enumerable on `Error`, and `retryable` is a getter, not an own
193
+ * property.
194
+ */
195
+ toJSON() {
196
+ return {
197
+ name: this.name,
198
+ message: this.message,
199
+ type: this.type,
200
+ status: this.status,
201
+ issues: safeIssues(this.issues),
202
+ retryAfterMs: this.retryAfterMs,
203
+ code: this.code,
204
+ retryable: this.retryable,
205
+ attempts: safeAttempts(this.attempts)
206
+ };
38
207
  }
39
- /** Every tool contract failure in one response, when there is more than one. */
40
- toolIssues;
41
208
  };
42
209
  function isLLMError(err) {
43
210
  return err instanceof LLMError;
44
211
  }
212
+ /**
213
+ * Narrows `err.issues` to the exact shape {@link LLMErrorIssuesByCode} maps
214
+ * `code` to, for any code listed there. `code` stays the only discriminator
215
+ * VernLLM uses; this just gives that existing check a typed return instead
216
+ * of requiring a manual cast of `issues`:
217
+ *
218
+ * ```ts
219
+ * if (isLLMError(err) && hasIssues(err, 'duplicate_tool_names')) {
220
+ * console.log(err.issues.names); // string[], no cast needed
221
+ * }
222
+ * ```
223
+ */
224
+ function hasIssues(err, code) {
225
+ return err.code === code && err.issues !== void 0;
226
+ }
45
227
 
46
228
  //#endregion
47
229
  //#region src/types/cache.ts
@@ -171,7 +353,12 @@ function isToolCallResult(result) {
171
353
  //#endregion
172
354
  //#region src/types/fallback.ts
173
355
  /** Tool contract failures are the model ignoring the request, not a sick provider: repeating it elsewhere can't help. */
174
- const TOOL_CONTRACT_CODES = new Set(["unknown_tool", "duplicate_tool_call_id"]);
356
+ const TOOL_CONTRACT_CODES = new Set([
357
+ "unknown_tool",
358
+ "duplicate_tool_call_id",
359
+ "tool_choice_none_violated",
360
+ "unexpected_tool_calls"
361
+ ]);
175
362
  /**
176
363
  * The default `fallbackOn` policy. Exported so a caller can wrap rather
177
364
  * than replace it, e.g. `fallbackOn: (e, ctx) => myCheck(e) ? 'stop' : defaultFallbackOn(e, ctx)`.
@@ -194,10 +381,30 @@ const defaultFallbackOn = (error) => {
194
381
  var FallbackExhaustedError = class extends LLMError {
195
382
  constructor(attempts) {
196
383
  const last = attempts[attempts.length - 1]?.error;
197
- 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, last?.retryAfterMs, "fallback_exhausted");
384
+ super(`${attempts.length} provider${attempts.length === 1 ? "" : "s"} attempted and failed: ${attempts.map((a) => `${a.provider}(${a.error.type})`).join(" then ")}`, "fallback_exhausted", {
385
+ status: last?.status,
386
+ cause: last,
387
+ retryAfterMs: last?.retryAfterMs,
388
+ code: "fallback_exhausted",
389
+ attempts
390
+ });
198
391
  this.attempts = attempts;
199
392
  }
393
+ /**
394
+ * `type: 'fallback_exhausted'` by itself says nothing about whether
395
+ * retrying could help; the reason the last target failed does. Defers to
396
+ * that attempt's own `retryable` instead of anything about this class's
397
+ * own type.
398
+ */
399
+ get retryable() {
400
+ const last = this.attempts[this.attempts.length - 1]?.error;
401
+ return last ? last.retryable : super.retryable;
402
+ }
200
403
  };
404
+ /** Narrows `err` to {@link FallbackExhaustedError}, for direct access to its `attempts` (`provider`/`model` per failed target) without a manual `instanceof` check. */
405
+ function isFallbackExhaustedError(err) {
406
+ return err instanceof FallbackExhaustedError;
407
+ }
201
408
 
202
409
  //#endregion
203
410
  //#region src/internal/execution/usage.utils.ts
@@ -219,7 +426,7 @@ async function reserve(params, coalesced, signal) {
219
426
  return true;
220
427
  } catch (error) {
221
428
  if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
222
- throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", void 0, void 0, error);
429
+ throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", { cause: error });
223
430
  }
224
431
  }
225
432
  /**
@@ -419,13 +626,30 @@ var CacheOrchestrator = class {
419
626
  */
420
627
  async deleteCache(key) {
421
628
  if (!this.cache.delete) return;
422
- await this.cache.delete(await this.resolveCacheKey(key));
629
+ try {
630
+ await this.cache.delete(await this.resolveCacheKey(key));
631
+ } catch (error) {
632
+ this.logger.warn(`[VernLLM] cache delete failed: ${error instanceof Error ? error.message : "unknown"}`);
633
+ }
423
634
  }
424
635
  /** Logs a failed refundUsage attempt via the configured logger. */
425
636
  logRefundError(logMessage, error) {
426
637
  this.logger.error(logMessage, { message: error instanceof Error ? error.message : "unknown" });
427
638
  }
428
639
  /**
640
+ * Reads from the cache, treating a failed adapter read as a miss rather
641
+ * than letting it fail the call. The request still falls through to a
642
+ * real provider call, but that fallback is now logged instead of silent.
643
+ */
644
+ async getCached(key) {
645
+ try {
646
+ return await this.cache.get(key);
647
+ } catch (error) {
648
+ this.logger.warn(`[VernLLM] cache read failed: ${error instanceof Error ? error.message : "unknown"}`);
649
+ return { hit: false };
650
+ }
651
+ }
652
+ /**
429
653
  * Internal cache primitive around caller-supplied logic. Concurrent misses
430
654
  * for the same `cacheKey` share a single in-flight call, avoiding cache
431
655
  * stampedes.
@@ -445,7 +669,7 @@ var CacheOrchestrator = class {
445
669
  ...params,
446
670
  cacheKey: resolvedKey
447
671
  };
448
- const cached = await this.cache.get(resolvedKey);
672
+ const cached = await this.getCached(resolvedKey);
449
673
  if (cached.hit) return cached.value;
450
674
  const existing = this.inFlight.get(resolvedKey);
451
675
  if (existing) return withReservedUsage(resolvedParams, true, () => existing, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
@@ -466,7 +690,7 @@ var CacheOrchestrator = class {
466
690
  try {
467
691
  await this.cache.set(params.cacheKey, result, params.ttl);
468
692
  } catch (error) {
469
- this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
693
+ this.logger.warn(`[VernLLM] cache write failed: ${error instanceof Error ? error.message : "unknown"}`);
470
694
  }
471
695
  return result;
472
696
  }
@@ -495,7 +719,7 @@ var CacheOrchestrator = class {
495
719
  ...params,
496
720
  cacheKey: resolvedKey
497
721
  };
498
- const cached = await this.cache.get(resolvedKey);
722
+ const cached = await this.getCached(resolvedKey);
499
723
  if (cached.hit) {
500
724
  const value = cached.value;
501
725
  return {
@@ -544,7 +768,7 @@ var CacheOrchestrator = class {
544
768
  try {
545
769
  await this.cache.set(params.cacheKey, value, params.ttl);
546
770
  } catch (error) {
547
- this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
771
+ this.logger.warn(`[VernLLM] cache write failed: ${error instanceof Error ? error.message : "unknown"}`);
548
772
  }
549
773
  return value;
550
774
  }, (error) => {
@@ -586,6 +810,7 @@ var CircuitBreaker = class {
586
810
  threshold;
587
811
  cooldownMs;
588
812
  onStateChange;
813
+ /** Whether this breaker tracks failures per model instead of one shared circuit. Read by `CallExecutor`/`VernLLM` to report per-target in `getCircuitStates`. */
589
814
  isolateByModel;
590
815
  sharedBucket = newBucket();
591
816
  bucketsByModel = new Map();
@@ -631,12 +856,12 @@ var CircuitBreaker = class {
631
856
  if (bucket.state === "closed") return;
632
857
  if (bucket.state === "open") {
633
858
  const elapsed = Date.now() - bucket.openedAt;
634
- 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");
859
+ 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", { code: "circuit_cooling_down" });
635
860
  bucket.trialInFlight = true;
636
861
  this.transition(bucket, "half-open", model);
637
862
  return;
638
863
  }
639
- if (bucket.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open");
864
+ if (bucket.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open", { code: "circuit_trial_in_flight" });
640
865
  bucket.trialInFlight = true;
641
866
  }
642
867
  recordSuccess(model) {
@@ -671,6 +896,33 @@ var CircuitBreaker = class {
671
896
  getState(model) {
672
897
  return this.lookupBucket(model)?.state ?? "closed";
673
898
  }
899
+ /**
900
+ * Manually opens the circuit, as if `threshold` consecutive failures had
901
+ * just happened, e.g. to pull a provider out of rotation ahead of known
902
+ * maintenance. Resets the cooldown window from now, same as a real
903
+ * threshold-crossing failure would, and clears any in-flight half-open
904
+ * trial since it no longer applies once the circuit is (re)opened.
905
+ */
906
+ open(model) {
907
+ const bucket = this.ensureBucketFor(model);
908
+ bucket.openedAt = Date.now();
909
+ bucket.trialInFlight = false;
910
+ this.transition(bucket, "open", model);
911
+ }
912
+ /**
913
+ * Manually closes the circuit and resets its failure count, e.g. once a
914
+ * provider is confirmed healthy again without waiting out the cooldown.
915
+ * Mirrors `recordSuccess`'s bookkeeping (including dropping the
916
+ * per-model bucket under `isolateByModel`, once idle) but without
917
+ * requiring an actual successful call first.
918
+ */
919
+ close(model) {
920
+ const bucket = this.ensureBucketFor(model);
921
+ bucket.consecutiveFailures = 0;
922
+ bucket.trialInFlight = false;
923
+ this.transition(bucket, "closed", model);
924
+ if (this.isolateByModel && bucket.state === "closed" && bucket.consecutiveFailures === 0) this.bucketsByModel.delete(model ?? UNLABELED_MODEL);
925
+ }
674
926
  };
675
927
 
676
928
  //#endregion
@@ -789,7 +1041,7 @@ async function withTimeout(fn, timeoutMs, externalSignal) {
789
1041
  try {
790
1042
  return await fn(signal);
791
1043
  } catch (err) {
792
- if (controller.signal.aborted && !externalSignal?.aborted && err instanceof DOMException && err.name === "AbortError") throw new LLMError("Request timed out", "timeout");
1044
+ if (controller.signal.aborted && !externalSignal?.aborted && err instanceof DOMException && err.name === "AbortError") throw new LLMError("Request timed out", "timeout", { code: "request_timeout" });
793
1045
  throw err;
794
1046
  } finally {
795
1047
  clearTimeout(timer);
@@ -822,7 +1074,7 @@ function withChunkIdleTimeout(next, timeoutMs, onIdle, logger) {
822
1074
  const timer = setTimeout(() => {
823
1075
  settled = true;
824
1076
  onIdle?.();
825
- reject(new LLMError(`No stream chunk received for ${activeTimeoutMs}ms (idle timeout)`, "timeout"));
1077
+ reject(new LLMError(`No stream chunk received for ${activeTimeoutMs}ms (idle timeout)`, "timeout", { code: "idle_timeout" }));
826
1078
  }, clampTimeoutMs(activeTimeoutMs));
827
1079
  next().then((result) => {
828
1080
  clearTimeout(timer);
@@ -987,20 +1239,58 @@ function describeError(err) {
987
1239
  } catch {}
988
1240
  return formatSafely(err);
989
1241
  }
990
- /** Converts any thrown value into a well-typed LLMError. */
991
- function normalizeError(error, signal) {
992
- if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
1242
+ /**
1243
+ * Maps an HTTP status to its corresponding `LLMErrorCode`, derived purely
1244
+ * from the status itself so it applies the same way regardless of which
1245
+ * adapter or client raised the error. Used both when building a fresh
1246
+ * `LLMError` and when filling in a `code` on an already-normalized one
1247
+ * that doesn't have one yet, so the two paths can't drift apart.
1248
+ */
1249
+ function codeForStatus(status) {
1250
+ switch (status) {
1251
+ case 429: return "provider_rate_limited";
1252
+ case 401: return "authentication";
1253
+ case 403: return "authorization";
1254
+ case 404: return "not_found";
1255
+ case 413: return "payload_too_large";
1256
+ default: return status >= 500 ? "server_error" : void 0;
1257
+ }
1258
+ }
1259
+ /**
1260
+ * Converts any thrown value into a well-typed LLMError. `attempts`, when
1261
+ * given, is the accumulated record of every attempt made before `error`
1262
+ * was thrown; it's passed straight into the constructed error's options
1263
+ * rather than assigned onto the error afterward, so `attempts` is always
1264
+ * settled once, through the constructor, like every other field on
1265
+ * `LLMError`.
1266
+ */
1267
+ function normalizeError(error, signal, attempts) {
1268
+ if (signal?.aborted) return new LLMError("LLM request aborted", "aborted", { attempts });
993
1269
  if (error instanceof LLMError) {
994
- if (error.code === void 0) {
995
- if (error.status === 429) error.code = "provider_rate_limited";
996
- else if (error.status === 401 || error.status === 403) error.code = "invalid_credentials";
997
- }
1270
+ if (error.code === void 0 && error.status !== void 0) error.code = codeForStatus(error.status);
1271
+ if (error.attempts === void 0 && attempts !== void 0) error.attempts = attempts;
998
1272
  return error;
999
1273
  }
1000
1274
  const status = extractStatus(error);
1001
1275
  const retryAfterMs = extractRetryAfterMs(error);
1002
- if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs, status === 429 ? "provider_rate_limited" : status === 401 || status === 403 ? "invalid_credentials" : void 0);
1003
- return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs, isNetworkError(error) ? "connection_failed" : void 0);
1276
+ if (status !== void 0) return new LLMError("LLM request failed", "api", {
1277
+ status,
1278
+ cause: error,
1279
+ retryAfterMs,
1280
+ code: codeForStatus(status),
1281
+ attempts
1282
+ });
1283
+ if (isNetworkError(error)) return new LLMError("LLM request failed", "network", {
1284
+ cause: error,
1285
+ retryAfterMs,
1286
+ code: "connection_failed",
1287
+ attempts
1288
+ });
1289
+ return new LLMError("LLM request failed", "unknown", {
1290
+ cause: error,
1291
+ retryAfterMs,
1292
+ attempts
1293
+ });
1004
1294
  }
1005
1295
 
1006
1296
  //#endregion
@@ -1049,7 +1339,7 @@ function parseWireToolCalls(wireToolCalls) {
1049
1339
  try {
1050
1340
  parsedArgs = wc.function.arguments.trim() ? JSON.parse(wc.function.arguments) : {};
1051
1341
  } catch {
1052
- throw new LLMError(`Invalid JSON arguments for tool call "${wc.function.name}"`, "parse");
1342
+ throw new LLMError(`Invalid JSON arguments for tool call "${wc.function.name}"`, "parse", { code: "tool_arguments_parse_failed" });
1053
1343
  }
1054
1344
  return {
1055
1345
  id: wc.id,
@@ -1064,9 +1354,13 @@ function parseWireToolCalls(wireToolCalls) {
1064
1354
  /**
1065
1355
  * Builds the wire request object for one call, applying per-instance
1066
1356
  * defaults (model, max tokens, temperature) and per-call overrides.
1067
- * Owns every validation that depends only on shape, not on execution:
1068
- * history alternation, duplicate/empty tool lists, `toolChoice` naming a
1069
- * real tool. Has no knowledge of retry, timeouts, or the breaker, only
1357
+ * Owns every check that depends only on the caller's own input shape, not
1358
+ * on execution: history alternation, duplicate/empty tool lists,
1359
+ * `toolChoice` naming a real tool. All deterministic on the call site's
1360
+ * own input and never touch the network, so every throw here is
1361
+ * `type: 'invalid_params'`, not `'validation'` (which is reserved for the
1362
+ * model/provider's own response failing a contract check). Has no
1363
+ * knowledge of retry, timeouts, or the breaker, only
1070
1364
  * the three defaults a `FallbackTarget` can override per-target (see the
1071
1365
  * `defaultMaxTokens`/`defaultTemperature` overrides in the fallback
1072
1366
  * design), which is what keeps it separable from `CallExecutor`.
@@ -1084,7 +1378,7 @@ var RequestBuilder = class {
1084
1378
  build(params) {
1085
1379
  const { systemPrompt, userContent, history = [], maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema, tools, toolChoice } = params;
1086
1380
  const temperature = params.temperature === void 0 ? this.defaultTemperature : params.temperature;
1087
- 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");
1381
+ 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.", "invalid_params");
1088
1382
  if (tools) {
1089
1383
  const seen = new Set();
1090
1384
  const duplicates = new Set();
@@ -1092,13 +1386,22 @@ var RequestBuilder = class {
1092
1386
  if (seen.has(tool.name)) duplicates.add(tool.name);
1093
1387
  seen.add(tool.name);
1094
1388
  }
1095
- if (duplicates.size) throw new LLMError(`\`tools\` has duplicate name(s): [${[...duplicates].join(", ")}]. Tool names must be unique.`, "validation");
1389
+ if (duplicates.size) throw new LLMError(`\`tools\` has duplicate name(s): [${[...duplicates].join(", ")}]. Tool names must be unique.`, "invalid_params", {
1390
+ code: "duplicate_tool_names",
1391
+ issues: { names: [...duplicates] }
1392
+ });
1096
1393
  }
1097
- 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");
1098
- 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");
1394
+ if (toolChoice && !tools) throw new LLMError("`toolChoice` was set without `tools`. There is nothing for it to choose between. Set `tools`, or remove `toolChoice`.", "invalid_params");
1395
+ 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(", ")}]).`, "invalid_params", {
1396
+ code: "unknown_tool_choice",
1397
+ issues: {
1398
+ requested: toolChoice.name,
1399
+ available: tools.map((t) => t.name)
1400
+ }
1401
+ });
1099
1402
  const jsonMode = params.jsonMode ?? (tools ? false : true);
1100
1403
  const useJson = jsonMode || Boolean(jsonSchema);
1101
- 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");
1404
+ 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.", "invalid_params");
1102
1405
  const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
1103
1406
  this.validateHistory(history);
1104
1407
  const request = {
@@ -1135,29 +1438,47 @@ var RequestBuilder = class {
1135
1438
  let previousTurn;
1136
1439
  for (const [index, turn] of history.entries()) {
1137
1440
  if (turn.role === "tool") {
1138
- 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");
1139
- if (!turn.toolResults?.length) throw new LLMError(`history[${index}] is a "tool" turn but has no toolResults`, "validation");
1441
+ 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`, "invalid_params");
1442
+ if (!turn.toolResults?.length) throw new LLMError(`history[${index}] is a "tool" turn but has no toolResults`, "invalid_params");
1140
1443
  const requestedIds = new Set(previousTurn.toolCalls.map((tc) => tc.id));
1141
1444
  const resultIds = turn.toolResults.map((tr) => tr.toolCallId);
1142
1445
  const unknownIds = resultIds.filter((id) => !requestedIds.has(id));
1143
- if (unknownIds.length) throw new LLMError(`history[${index}].toolResults references unknown toolCallId(s) [${unknownIds.join(", ")}]`, "validation");
1446
+ if (unknownIds.length) throw new LLMError(`history[${index}].toolResults references unknown toolCallId(s) [${unknownIds.join(", ")}]`, "invalid_params", {
1447
+ code: "unknown_tool_result_ids",
1448
+ issues: {
1449
+ historyIndex: index,
1450
+ ids: unknownIds
1451
+ }
1452
+ });
1144
1453
  const seenIds = new Set();
1145
1454
  const duplicateIds = new Set();
1146
1455
  for (const id of resultIds) {
1147
1456
  if (seenIds.has(id)) duplicateIds.add(id);
1148
1457
  seenIds.add(id);
1149
1458
  }
1150
- if (duplicateIds.size) throw new LLMError(`history[${index}].toolResults has duplicate toolCallId(s) [${[...duplicateIds].join(", ")}]`, "validation");
1459
+ if (duplicateIds.size) throw new LLMError(`history[${index}].toolResults has duplicate toolCallId(s) [${[...duplicateIds].join(", ")}]`, "invalid_params", {
1460
+ code: "duplicate_tool_result_ids",
1461
+ issues: {
1462
+ historyIndex: index,
1463
+ ids: [...duplicateIds]
1464
+ }
1465
+ });
1151
1466
  const missingIds = [...requestedIds].filter((id) => !resultIds.includes(id));
1152
- if (missingIds.length) throw new LLMError(`history[${index}] is missing toolResults for toolCallId(s) [${missingIds.join(", ")}]`, "validation");
1467
+ if (missingIds.length) throw new LLMError(`history[${index}] is missing toolResults for toolCallId(s) [${missingIds.join(", ")}]`, "invalid_params", {
1468
+ code: "missing_tool_results",
1469
+ issues: {
1470
+ historyIndex: index,
1471
+ ids: missingIds
1472
+ }
1473
+ });
1153
1474
  } else {
1154
- 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");
1155
- if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError(`history[${index}] follows an assistant tool request without tool results`, "validation");
1475
+ 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}]`, "invalid_params");
1476
+ if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError(`history[${index}] follows an assistant tool request without tool results`, "invalid_params");
1156
1477
  }
1157
1478
  previousTurn = turn;
1158
1479
  }
1159
- if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError("The last entry in history is an assistant tool request without tool results", "validation");
1160
- 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");
1480
+ if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError("The last entry in history is an assistant tool request without tool results", "invalid_params");
1481
+ if (previousTurn?.role === "user") throw new LLMError("The last entry in history is a \"user\" turn, which would collide with the current userContent turn.", "invalid_params");
1161
1482
  }
1162
1483
  /** Maps VernLLM's app-facing `ToolChoice` onto the OpenAI-shaped wire `tool_choice`. */
1163
1484
  buildWireToolChoice(toolChoice) {
@@ -1425,6 +1746,18 @@ var CallExecutor = class {
1425
1746
  getCircuitState(model) {
1426
1747
  return this.breaker?.getState(model);
1427
1748
  }
1749
+ /** Whether this target's breaker tracks failures per model. `false` if no breaker is configured. */
1750
+ get isolateByModel() {
1751
+ return this.breaker?.isolateByModel ?? false;
1752
+ }
1753
+ /** Manually opens this target's circuit breaker, if one is configured. No-op otherwise. */
1754
+ openCircuit(model) {
1755
+ this.breaker?.open(model);
1756
+ }
1757
+ /** Manually closes this target's circuit breaker, if one is configured. No-op otherwise. */
1758
+ closeCircuit(model) {
1759
+ this.breaker?.close(model);
1760
+ }
1428
1761
  /**
1429
1762
  * Throws if the breaker is open for this target/model, exactly like the
1430
1763
  * check `run`/`runStream` used to make internally. Exposed so `VernLLM`
@@ -1446,10 +1779,11 @@ var CallExecutor = class {
1446
1779
  */
1447
1780
  async run(params, requestId, onAttempt) {
1448
1781
  const model = params.model ?? this.model;
1782
+ const attempts = [];
1449
1783
  try {
1450
- return await this.retryWithBackoff((attempt) => this.executeCall(params, requestId, attempt), requestId, model, params.signal, onAttempt);
1784
+ return await this.retryWithBackoff((attempt) => this.executeCall(params, requestId, attempt), requestId, model, params.signal, onAttempt, attempts);
1451
1785
  } catch (error) {
1452
- const normalized = normalizeError(error, params.signal);
1786
+ const normalized = normalizeError(error, params.signal, attempts.length > 0 ? attempts : void 0);
1453
1787
  if (this.countsTowardBreaker(normalized)) this.breaker?.recordFailure(model);
1454
1788
  this.logger.debug(`[VernLLM:${requestId}] error:\n${this.redactText(describeError(error))}`);
1455
1789
  throw normalized;
@@ -1458,10 +1792,11 @@ var CallExecutor = class {
1458
1792
  /** Streaming counterpart to `run`. Mirrors the old streaming branch of `VernLLM.call`. */
1459
1793
  async runStream(params, requestId, onAttempt) {
1460
1794
  const model = params.model ?? this.model;
1795
+ const attempts = [];
1461
1796
  try {
1462
- return await this.retryWithBackoff((attempt) => this.executeStreamCall(params, requestId, attempt), requestId, model, params.signal, onAttempt);
1797
+ return await this.retryWithBackoff((attempt) => this.executeStreamCall(params, requestId, attempt), requestId, model, params.signal, onAttempt, attempts);
1463
1798
  } catch (error) {
1464
- const normalized = normalizeError(error, params.signal);
1799
+ const normalized = normalizeError(error, params.signal, attempts.length > 0 ? attempts : void 0);
1465
1800
  if (this.countsTowardBreaker(normalized)) this.breaker?.recordFailure(model);
1466
1801
  this.logger.debug(`[VernLLM:${requestId}] stream-open error:\n${this.redactText(describeError(error))}`);
1467
1802
  throw normalized;
@@ -1529,11 +1864,11 @@ var CallExecutor = class {
1529
1864
  finalizeResponse(rawContent, wireToolCalls, params, useJson, model, usage, requestId, attempt) {
1530
1865
  try {
1531
1866
  const content = rawContent?.trim();
1532
- if (!content && !wireToolCalls?.length) throw new LLMError("Empty LLM response", "api");
1867
+ if (!content && !wireToolCalls?.length) throw new LLMError("Empty LLM response", "api", { code: "empty_response" });
1533
1868
  this.logger.debug(`[VernLLM:${requestId}] output:\n${this.redactedOutput(content, wireToolCalls).slice(0, 800)}`);
1534
1869
  if (wireToolCalls?.length) {
1535
- if (!params.tools) throw new LLMError("Provider returned tool_calls but no `tools` were sent with this call.", "api");
1536
- if (params.toolChoice === "none") throw new LLMError("Provider returned tool_calls despite toolChoice: 'none'.", "api", void 0, void 0, void 0, void 0, "tool_choice_none_violated");
1870
+ if (!params.tools) throw new LLMError("Provider returned tool_calls but no `tools` were sent with this call.", "validation", { code: "unexpected_tool_calls" });
1871
+ if (params.toolChoice === "none") throw new LLMError("Provider returned tool_calls despite toolChoice: 'none'.", "validation", { code: "tool_choice_none_violated" });
1537
1872
  const toolCalls = parseWireToolCalls(wireToolCalls);
1538
1873
  this.validateToolCallArguments(toolCalls, params.tools);
1539
1874
  this.breaker?.recordSuccess(model);
@@ -1586,7 +1921,10 @@ var CallExecutor = class {
1586
1921
  async executeStreamCall(params, requestId, attempt) {
1587
1922
  const { useJson, model, request } = this.requestBuilder.build(params);
1588
1923
  const completions = this.client.chat.completions;
1589
- if (!completions.createStream) throw new LLMError("stream: true requires a client/adapter with createStream", "validation");
1924
+ if (!completions.createStream) throw new LLMError("stream: true requires a client/adapter with createStream", "invalid_params", {
1925
+ code: "unsupported_capability",
1926
+ issues: { capability: "createStream" }
1927
+ });
1590
1928
  const createStream = completions.createStream.bind(completions);
1591
1929
  let release;
1592
1930
  if (this.limiter) {
@@ -1647,13 +1985,13 @@ var CallExecutor = class {
1647
1985
  * `argumentsSchema`, if present.
1648
1986
  *
1649
1987
  * Contract failures (unknown name, duplicate id) are collected across
1650
- * every call and thrown together, since retrying a request that already
1651
- * has these errors cannot help (`shouldRetry` excludes them by `code`)
1652
- * and a caller fixing them wants to see every one, not just the first.
1653
- * Schema failures keep the original single-error, `type: 'validation'`
1654
- * shape rather than being folded into the aggregate, since they're a
1655
- * distinct failure kind from the contract failures above (also excluded
1656
- * from retry, by `type` rather than `code`; see `shouldRetry`).
1988
+ * every call and thrown together as one `type: 'validation'` error with
1989
+ * `issues: ToolIssue[]`, since retrying a request that already has these
1990
+ * errors cannot help (excluded from retry by `type`) and a caller fixing
1991
+ * them wants to see every one, not just the first. Schema failures keep
1992
+ * the original single-error, `type: 'validation'` shape rather than being
1993
+ * folded into the aggregate, since they're a distinct failure kind from
1994
+ * the contract failures above.
1657
1995
  */
1658
1996
  validateToolCallArguments(toolCalls, tools) {
1659
1997
  const known = new Map(tools.map((t) => [t.name, t]));
@@ -1675,20 +2013,31 @@ var CallExecutor = class {
1675
2013
  if (toolIssues.length > 0) {
1676
2014
  const unknownTool = toolIssues.find((i) => i.code === "unknown_tool");
1677
2015
  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.`;
1678
- const message = toolIssues.length > 1 ? `${primary} (${toolIssues.length} tool call issues total, see toolIssues.)` : primary;
1679
- const error = new LLMError(message, "api", void 0, void 0, void 0, void 0, unknownTool ? "unknown_tool" : "duplicate_tool_call_id");
1680
- error.toolIssues = toolIssues;
1681
- throw error;
2016
+ const message = toolIssues.length > 1 ? `${primary} (${toolIssues.length} tool call issues total, see error.issues.)` : primary;
2017
+ throw new LLMError(message, "validation", {
2018
+ code: unknownTool ? "unknown_tool" : "duplicate_tool_call_id",
2019
+ issues: toolIssues
2020
+ });
1682
2021
  }
1683
2022
  for (const call of toolCalls) {
1684
2023
  const definition = known.get(call.name);
1685
2024
  if (!definition?.argumentsSchema) continue;
1686
2025
  const result = definition.argumentsSchema.safeParse(call.arguments);
1687
- if (!result.success) throw new LLMError(`Arguments for tool call "${call.name}" failed validation`, "validation", void 0, result.error);
2026
+ if (!result.success) throw new LLMError(`Arguments for tool call "${call.name}" failed validation`, "validation", { issues: result.error });
1688
2027
  }
1689
2028
  }
1690
- /** Runs `fn`, retrying with backoff according to `shouldRetry`. */
1691
- async retryWithBackoff(fn, requestId, model, signal, onAttempt) {
2029
+ /**
2030
+ * Runs `fn`, retrying with backoff according to `shouldRetry`. When
2031
+ * `attempts` is given, every failed attempt that is actually followed by
2032
+ * a retry is recorded, in order. This mirrors `LLMError.attempts`'s
2033
+ * contract: every attempt made before this error was thrown. The
2034
+ * terminal failure is never pushed since it isn't a prior attempt, it
2035
+ * is the error being thrown. `attempts` stays empty when nothing was
2036
+ * retried, so no separate bookkeeping is needed at the call sites.
2037
+ * Each failure is recorded as a snapshot (`LLMError.toSnapshot()`),
2038
+ * not the live `LLMError`, per `RetryAttempt`'s contract.
2039
+ */
2040
+ async retryWithBackoff(fn, requestId, model, signal, onAttempt, attempts) {
1692
2041
  let lastError;
1693
2042
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
1694
2043
  if (attempt > 0) await this.recoverDelay(requestId, model, attempt, lastError, signal);
@@ -1696,7 +2045,12 @@ var CallExecutor = class {
1696
2045
  return await fn(attempt);
1697
2046
  } catch (error) {
1698
2047
  lastError = error;
1699
- if (!this.shouldRetry(error, signal)) break;
2048
+ const willRetry = attempt < this.maxRetries && this.shouldRetry(error, signal);
2049
+ if (!willRetry) break;
2050
+ attempts?.push({
2051
+ index: attempt,
2052
+ error: normalizeError(error, signal).toSnapshot()
2053
+ });
1700
2054
  }
1701
2055
  throw lastError;
1702
2056
  }
@@ -1766,7 +2120,7 @@ var CallExecutor = class {
1766
2120
  if (parsed === null || parsed === void 0) throw new LLMError("Invalid JSON response", "parse");
1767
2121
  if (!schema) return parsed;
1768
2122
  const result = schema.safeParse(parsed);
1769
- if (!result.success) throw new LLMError("Schema validation failed", "validation", void 0, result.error);
2123
+ if (!result.success) throw new LLMError("Schema validation failed", "validation", { issues: result.error });
1770
2124
  return result.data;
1771
2125
  }
1772
2126
  /**
@@ -1779,7 +2133,7 @@ var CallExecutor = class {
1779
2133
  const retryAfterMs = extractRetryAfterMs(error);
1780
2134
  const delay = retryAfterMs ?? getBackoffDelay(this.baseDelayMs, attempt);
1781
2135
  const retryAfterHonored = retryAfterMs !== void 0;
1782
- this.logger.warn(`[VernLLM:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterHonored ? " (honoring Retry-After)" : ""));
2136
+ this.logger.warn(`[VernLLM:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${Math.ceil(delay)}ms` + (retryAfterHonored ? " (honoring Retry-After)" : ""));
1783
2137
  this.reportEvent({
1784
2138
  kind: "retry",
1785
2139
  requestId,
@@ -1793,15 +2147,10 @@ var CallExecutor = class {
1793
2147
  });
1794
2148
  await waitForRetry(delay, signal);
1795
2149
  }
1796
- isNonRetryableToolContractError(error) {
1797
- return error instanceof LLMError && (error.code === "unknown_tool" || error.code === "duplicate_tool_call_id" || error.code === "tool_choice_none_violated");
1798
- }
1799
2150
  /** Decides whether a failed attempt is worth retrying. */
1800
2151
  shouldRetry(error, signal) {
1801
2152
  if (signal?.aborted) return false;
1802
- if (error instanceof LLMError && (error.type === "parse" || error.type === "validation")) return false;
1803
- if (error instanceof LLMError && error.code === "local_rate_limit") return false;
1804
- if (this.isNonRetryableToolContractError(error)) return false;
2153
+ if (error instanceof LLMError && !error.retryable) return false;
1805
2154
  const status = extractStatus(error);
1806
2155
  return !(status !== void 0 && this.nonRetryableStatus.includes(status));
1807
2156
  }
@@ -1811,16 +2160,48 @@ var CallExecutor = class {
1811
2160
  * reusing a call id, or a provider ignoring `toolChoice: 'none'` isn't
1812
2161
  * the provider being unhealthy, it's a model/provider response defect
1813
2162
  * that will very likely recur regardless of provider health, so it
1814
- * shouldn't push a healthy provider's circuit toward opening. Mirrors
1815
- * the same reasoning `shouldRetry` already applies to
1816
- * `parse`/`validation`/these same tool-contract codes.
2163
+ * shouldn't push a healthy provider's circuit toward opening. Same for
2164
+ * a caller-input bug or a local rate-limit rejection: neither ever
2165
+ * reached the provider at all. This is exactly what `LLMError.retryable`
2166
+ * already excludes, so this defers to it directly.
1817
2167
  */
1818
2168
  countsTowardBreaker(error) {
1819
- if (error.type === "validation" || error.type === "parse" || error.type === "aborted" || error.code === "local_rate_limit" || this.isNonRetryableToolContractError(error)) return false;
1820
- return true;
2169
+ return error.retryable;
1821
2170
  }
1822
2171
  };
1823
2172
 
2173
+ //#endregion
2174
+ //#region src/internal/logger.utils.ts
2175
+ /**
2176
+ * Wraps a `Logger` so a throwing implementation can never break the call
2177
+ * it's trying to describe. `logger` is user-supplied (`VernLLMOptions.logger`),
2178
+ * so a custom logger that ships to a file, Datadog, etc. can throw for
2179
+ * reasons unrelated to VernLLM. Wrap once at construction so every
2180
+ * downstream `this.logger.warn(...)` call stays as-is and is safe by
2181
+ * construction, instead of guarding each call site individually.
2182
+ */
2183
+ function createSafeLogger(logger) {
2184
+ return {
2185
+ debug: safe(logger, "debug"),
2186
+ warn: safe(logger, "warn"),
2187
+ error: safe(logger, "error")
2188
+ };
2189
+ }
2190
+ function safe(logger, method) {
2191
+ const fn = logger[method].bind(logger);
2192
+ return (...args) => {
2193
+ try {
2194
+ swallowRejection(fn(...args));
2195
+ } catch {}
2196
+ };
2197
+ }
2198
+ function isPromiseLike(value) {
2199
+ return typeof value?.then === "function";
2200
+ }
2201
+ function swallowRejection(result) {
2202
+ if (isPromiseLike(result)) Promise.resolve(result).catch(() => {});
2203
+ }
2204
+
1824
2205
  //#endregion
1825
2206
  //#region src/logger.ts
1826
2207
  /**
@@ -1961,8 +2342,8 @@ var RateLimiter = class {
1961
2342
  */
1962
2343
  async acquire(estimatedTokens, signal) {
1963
2344
  if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
1964
- if (!Number.isFinite(estimatedTokens) || estimatedTokens < 0) throw new LLMError(`estimatedTokens must be a finite, non-negative number, got ${String(estimatedTokens)}`, "validation");
1965
- 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");
2345
+ if (!Number.isFinite(estimatedTokens) || estimatedTokens < 0) throw new LLMError(`estimatedTokens must be a finite, non-negative number, got ${String(estimatedTokens)}`, "invalid_params");
2346
+ 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.`, "rate_limited", { code: "rate_limit_capacity_exceeded" });
1966
2347
  if (this.queue.length === 0) {
1967
2348
  const attempt = this.tryAcquireBuckets(estimatedTokens);
1968
2349
  if (attempt.ok) return {
@@ -1975,7 +2356,7 @@ var RateLimiter = class {
1975
2356
  return this.enqueue(estimatedTokens, void 0, signal);
1976
2357
  }
1977
2358
  queueFullError() {
1978
- return new LLMError("Rate limit queue is full", "quota_exceeded", void 0, void 0, void 0, void 0, "local_rate_limit");
2359
+ return new LLMError("Rate limit queue is full", "rate_limited", { code: "rate_limit_queue_full" });
1979
2360
  }
1980
2361
  enqueue(estimatedTokens, initialReason, signal) {
1981
2362
  return new Promise((resolvePromise, rejectPromise) => {
@@ -2008,7 +2389,7 @@ var RateLimiter = class {
2008
2389
  if (index !== -1) this.queue.splice(index, 1);
2009
2390
  };
2010
2391
  if (this.maxQueueMs > 0) queueTimer = setTimeout(() => {
2011
- 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"));
2392
+ waiter.reject(new LLMError("Rate limit queue timed out before capacity was available", "rate_limited", { code: "rate_limit_queue_timeout" }));
2012
2393
  }, this.maxQueueMs);
2013
2394
  signal?.addEventListener("abort", onAbort, { once: true });
2014
2395
  this.queue.push(waiter);
@@ -2142,7 +2523,7 @@ var VernLLM = class {
2142
2523
  * `nonRetryableStatus` `[400, 401, 403, 404, 422]`, `debug` false.
2143
2524
  */
2144
2525
  constructor(options) {
2145
- this.logger = options.logger ?? new ConsoleLogger(options.debug ?? false);
2526
+ this.logger = createSafeLogger(options.logger ?? new ConsoleLogger(options.debug ?? false));
2146
2527
  const providerName = options.name ?? "primary";
2147
2528
  this.cacheOrchestrator = new CacheOrchestrator(options.cache ?? new InMemoryCacheAdapter(), this.logger);
2148
2529
  this.fallbackOn = options.fallbackOn ?? defaultFallbackOn;
@@ -2235,7 +2616,7 @@ var VernLLM = class {
2235
2616
  index: i - 1,
2236
2617
  provider: executor.providerName,
2237
2618
  model: params.model ?? executor.model,
2238
- error: normalized
2619
+ error: normalized.toSnapshot()
2239
2620
  });
2240
2621
  const isLast = i === this.executors.length - 1;
2241
2622
  const policyDecision = this.fallbackOn(normalized, { isLastTarget: isLast });
@@ -2254,7 +2635,7 @@ var VernLLM = class {
2254
2635
  });
2255
2636
  }
2256
2637
  }
2257
- throw new LLMError("No provider targets configured", "unknown");
2638
+ throw new LLMError("No provider targets configured", "invalid_params");
2258
2639
  }
2259
2640
  async call(params) {
2260
2641
  if (params.signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
@@ -2300,7 +2681,7 @@ var VernLLM = class {
2300
2681
  async cachedCall(params) {
2301
2682
  const { call: callParams,...cacheParams } = params;
2302
2683
  const restCallParams = callParams;
2303
- if (restCallParams.reserveUsage || restCallParams.refundUsage) throw new LLMError("`reserveUsage`/`refundUsage` were set inside `call`, where cachedCall ignores them. Move them to the top level of the cachedCall() params, alongside cacheKey/ttl, instead.", "validation");
2684
+ if (restCallParams.reserveUsage || restCallParams.refundUsage) throw new LLMError("`reserveUsage`/`refundUsage` were set inside `call`, where cachedCall ignores them. Move them to the top level of the cachedCall() params, alongside cacheKey/ttl, instead.", "invalid_params");
2304
2685
  if (restCallParams.stream) {
2305
2686
  const streamParams = restCallParams;
2306
2687
  return this.cacheOrchestrator.runCachedStream({
@@ -2314,35 +2695,67 @@ var VernLLM = class {
2314
2695
  });
2315
2696
  }
2316
2697
  /**
2317
- * @param model With `circuitBreaker.isolateByModel` on, returns that
2318
- * model's own circuit state instead of the shared one. Ignored
2319
- * otherwise. Omit for the shared circuit (the default) or, under
2320
- * isolation, the state of calls that didn't resolve a model.
2321
- * @returns The current circuit breaker state (`'closed' | 'open' |
2322
- * 'half-open'`), or undefined if no circuit breaker was configured.
2698
+ * @param target.index Which target to read. Defaults to the primary.
2699
+ * @param target.model Which model bucket to read, if the target isolates by model.
2700
+ * @returns The breaker state, or `undefined` if that target has no breaker.
2701
+ * @throws {RangeError} If `target.index` names no target. Lets a real
2702
+ * target with no breaker (`undefined`) stay distinguishable from a
2703
+ * target that doesn't exist.
2323
2704
  */
2324
- getCircuitState(model) {
2325
- return this.executors[0].getCircuitState(model);
2705
+ getCircuitState(target) {
2706
+ const executor = this.resolveExecutor(target?.index ?? 0, "getCircuitState");
2707
+ this.warnIfModelUnsupported(executor.isolateByModel, target?.model, "getCircuitState");
2708
+ return executor.getCircuitState(target?.model ?? executor.model);
2326
2709
  }
2327
2710
  /**
2328
- * @param model With `circuitBreaker.isolateByModel` on, returns each
2329
- * target's circuit state for that model instead of its shared state.
2330
- * Ignored otherwise. Omit for the shared circuit (the default) or, under
2331
- * isolation, the state of calls that didn't resolve a model.
2332
- * @returns The current circuit state for every target in declaration
2333
- * order, including the primary and all fallback targets. Each entry
2334
- * includes the target's provider name, chain index, whether it is a
2335
- * fallback, and its circuit state, or undefined if that target has no
2336
- * circuit breaker configured.
2711
+ * @param model Which model bucket to read, for targets that isolate by model.
2712
+ * @returns Every target's state, in chain order.
2337
2713
  */
2338
2714
  getCircuitStates(model) {
2339
2715
  return this.executors.map((executor, index) => ({
2340
2716
  provider: executor.providerName,
2341
2717
  index,
2342
2718
  isFallback: index > 0,
2343
- state: executor.getCircuitState(model)
2719
+ isolateByModel: executor.isolateByModel,
2720
+ state: executor.getCircuitState(model ?? executor.model)
2344
2721
  }));
2345
2722
  }
2723
+ /**
2724
+ * Manually opens a target's breaker, e.g. to pull a provider out of
2725
+ * rotation ahead of known maintenance instead of waiting for it to fail.
2726
+ *
2727
+ * @param target.index Which target to open. Defaults to the primary.
2728
+ * @param target.model Which model bucket to open, if the target isolates by model.
2729
+ * @throws {RangeError} If `target.index` names no target.
2730
+ */
2731
+ openCircuit(target) {
2732
+ const executor = this.resolveExecutor(target?.index ?? 0, "openCircuit");
2733
+ this.warnIfModelUnsupported(executor.isolateByModel, target?.model, "openCircuit");
2734
+ executor.openCircuit(target?.model ?? executor.model);
2735
+ }
2736
+ /**
2737
+ * Manually closes a target's breaker, e.g. once a provider is confirmed
2738
+ * healthy again without waiting out the cooldown.
2739
+ *
2740
+ * @param target.index Which target to close. Defaults to the primary.
2741
+ * @param target.model Which model bucket to close, if the target isolates by model.
2742
+ * @throws {RangeError} If `target.index` names no target.
2743
+ */
2744
+ closeCircuit(target) {
2745
+ const executor = this.resolveExecutor(target?.index ?? 0, "closeCircuit");
2746
+ this.warnIfModelUnsupported(executor.isolateByModel, target?.model, "closeCircuit");
2747
+ executor.closeCircuit(target?.model ?? executor.model);
2748
+ }
2749
+ /** Resolves a target index so every circuit-breaker method agrees on what counts as valid. */
2750
+ resolveExecutor(index, caller) {
2751
+ const executor = this.executors[index];
2752
+ if (!executor) throw new RangeError(`${caller}: no target at index ${index} (chain has ${this.executors.length} target${this.executors.length === 1 ? "" : "s"})`);
2753
+ return executor;
2754
+ }
2755
+ /** Warns when `model` can't do anything on this target, so it's never silently ignored. */
2756
+ warnIfModelUnsupported(isolateByModel, model, caller) {
2757
+ if (model !== void 0 && !isolateByModel) this.logger.warn(`[VernLLM] ${caller}: \`model: '${model}'\` has no effect here. This target's circuitBreaker doesn't have isolateByModel on, so it only tracks one shared circuit regardless of \`model\`. Omit \`model\`, or set \`circuitBreaker.isolateByModel: true\` on this target if per-model tracking is what you want.`);
2758
+ }
2346
2759
  };
2347
2760
 
2348
2761
  //#endregion
@@ -2382,7 +2795,7 @@ async function* parseSseStream(source) {
2382
2795
  try {
2383
2796
  text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
2384
2797
  } catch (cause) {
2385
- throw new LLMError("Invalid UTF-8 in SSE stream", "parse", void 0, void 0, cause);
2798
+ throw new LLMError("Invalid UTF-8 in SSE stream", "parse", { cause });
2386
2799
  }
2387
2800
  buffer = (buffer + text).replace(/\r\n/g, "\n").replace(/\r(?!$)/g, "\n");
2388
2801
  let boundary$1 = buffer.indexOf("\n\n");
@@ -2398,7 +2811,7 @@ async function* parseSseStream(source) {
2398
2811
  try {
2399
2812
  buffer += decoder.decode();
2400
2813
  } catch (cause) {
2401
- throw new LLMError("Invalid UTF-8 in SSE stream", "parse", void 0, void 0, cause);
2814
+ throw new LLMError("Invalid UTF-8 in SSE stream", "parse", { cause });
2402
2815
  }
2403
2816
  buffer = buffer.replace(/\r$/, "\n");
2404
2817
  let boundary = buffer.indexOf("\n\n");
@@ -2443,7 +2856,10 @@ function parseSseFrame(frame) {
2443
2856
  try {
2444
2857
  return JSON.parse(data);
2445
2858
  } catch (cause) {
2446
- throw new LLMError(`Invalid JSON in SSE frame: ${data.slice(0, 200)}`, "parse", void 0, void 0, cause);
2859
+ throw new LLMError(`Invalid JSON in SSE frame: ${data.slice(0, 200)}`, "parse", {
2860
+ cause,
2861
+ code: "stream_frame_invalid"
2862
+ });
2447
2863
  }
2448
2864
  }
2449
2865
 
@@ -2463,13 +2879,14 @@ const SUPPORTED_IMAGE_MIME_TYPES = [
2463
2879
  ];
2464
2880
  /**
2465
2881
  * Validates an `ImageBlock.mimeType` against the shared supported set.
2466
- * Throws a non-retryable `LLMError('validation')`, since an unsupported
2467
- * mimeType is a permanent failure, retrying the same input can't fix it,
2468
- * the same way a schema-validation or JSON-parse failure isn't retried.
2882
+ * Throws a non-retryable `LLMError('invalid_params')`, since an unsupported
2883
+ * mimeType is a bug in the caller's own input, deterministic before any
2884
+ * request is built, the same class of failure as every other check in
2885
+ * `RequestBuilder`.
2469
2886
  */
2470
2887
  function assertSupportedImageMimeType(mimeType) {
2471
2888
  if (SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType)) return mimeType;
2472
- throw new LLMError(`Unsupported image mimeType "${mimeType}": expected one of ${SUPPORTED_IMAGE_MIME_TYPES.join(", ")}`, "validation");
2889
+ throw new LLMError(`Unsupported image mimeType "${mimeType}": expected one of ${SUPPORTED_IMAGE_MIME_TYPES.join(", ")}`, "invalid_params");
2473
2890
  }
2474
2891
 
2475
2892
  //#endregion
@@ -2782,7 +3199,7 @@ function toAnthropicMessage(m) {
2782
3199
  try {
2783
3200
  input = tc.function.arguments.trim() ? JSON.parse(tc.function.arguments) : {};
2784
3201
  } catch (cause) {
2785
- throw new LLMError(`Assistant tool call "${tc.function.name}" (${tc.id}) has arguments that are not valid JSON.`, "validation", void 0, void 0, cause);
3202
+ throw new LLMError(`Assistant tool call "${tc.function.name}" (${tc.id}) has arguments that are not valid JSON.`, "validation", { cause });
2786
3203
  }
2787
3204
  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");
2788
3205
  blocks.push({
@@ -2863,7 +3280,10 @@ function parseToolArguments(text, toolName) {
2863
3280
  try {
2864
3281
  parsed = text.trim() ? JSON.parse(text) : {};
2865
3282
  } catch (cause) {
2866
- throw new LLMError(`Tool call "${toolName}" arguments are not valid JSON.`, "validation", void 0, void 0, cause);
3283
+ throw new LLMError(`Tool call "${toolName}" arguments are not valid JSON.`, "parse", {
3284
+ cause,
3285
+ code: "tool_arguments_parse_failed"
3286
+ });
2867
3287
  }
2868
3288
  if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new LLMError(`Tool call "${toolName}" arguments must be a JSON object.`, "validation");
2869
3289
  return parsed;
@@ -2997,7 +3417,10 @@ function fromGemini(geminiClient) {
2997
3417
  };
2998
3418
  },
2999
3419
  async *createStream(params, options) {
3000
- if (!geminiClient.generateContentStream) throw new LLMError("stream: true requires a Gemini client with generateContentStream", "validation");
3420
+ if (!geminiClient.generateContentStream) throw new LLMError("stream: true requires a Gemini client with generateContentStream", "invalid_params", {
3421
+ code: "unsupported_capability",
3422
+ issues: { capability: "generateContentStream" }
3423
+ });
3001
3424
  const request = buildGeminiRequest(params);
3002
3425
  request.config = {
3003
3426
  ...request.config,
@@ -3145,7 +3568,10 @@ function buildBedrockRequest(params, toolUseSupportedModels, nativeStructuredOut
3145
3568
  if (params.tools?.length && !toolName) toolConfig = buildBedrockToolConfig(params.tools, params.tool_choice);
3146
3569
  if (jsonSchema && toolConfig && toolUseSupportedModels) {
3147
3570
  const isSupported = Array.isArray(toolUseSupportedModels) ? toolUseSupportedModels.includes(params.model) : toolUseSupportedModels(params.model);
3148
- 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");
3571
+ 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).`, "invalid_params", {
3572
+ code: "unsupported_capability",
3573
+ issues: { capability: "toolUseSupportedModels" }
3574
+ });
3149
3575
  }
3150
3576
  const systemParts = [systemMessage?.content, jsonInstruction].filter((s) => Boolean(s));
3151
3577
  const request = {
@@ -3257,7 +3683,10 @@ function fromBedrock(bedrockClient, options) {
3257
3683
  };
3258
3684
  },
3259
3685
  async *createStream(params, requestOptions) {
3260
- if (!bedrockClient.converseStream) throw new LLMError("stream: true requires a Bedrock client with converseStream", "validation");
3686
+ if (!bedrockClient.converseStream) throw new LLMError("stream: true requires a Bedrock client with converseStream", "invalid_params", {
3687
+ code: "unsupported_capability",
3688
+ issues: { capability: "converseStream" }
3689
+ });
3261
3690
  const { request, toolName } = buildBedrockRequest(params, toolUseSupportedModels, nativeStructuredOutputModels);
3262
3691
  const { stream } = await bedrockClient.converseStream(request, requestOptions);
3263
3692
  const blockKinds = new Map();
@@ -3299,12 +3728,18 @@ function fromBedrock(bedrockClient, options) {
3299
3728
  total_tokens: event.metadata.usage.totalTokens
3300
3729
  }
3301
3730
  };
3302
- else if ("throttlingException" in event) throw new LLMError(event.throttlingException.message ?? "Bedrock throttled the request mid-stream", "api", 429);
3731
+ else if ("throttlingException" in event) throw new LLMError(event.throttlingException.message ?? "Bedrock throttled the request mid-stream", "api", {
3732
+ status: 429,
3733
+ code: "provider_rate_limited"
3734
+ });
3303
3735
  else if ("validationException" in event) throw new LLMError(event.validationException.message ?? "Bedrock rejected the request mid-stream", "validation");
3304
3736
  else if ("internalServerException" in event || "serviceUnavailableException" in event || "modelStreamErrorException" in event) {
3305
3737
  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";
3306
3738
  const status = "modelStreamErrorException" in event && event.modelStreamErrorException.originalStatusCode || "serviceUnavailableException" in event && 503 || 500;
3307
- throw new LLMError(detail, "api", status);
3739
+ throw new LLMError(detail, "api", {
3740
+ status,
3741
+ code: status >= 500 ? "server_error" : void 0
3742
+ });
3308
3743
  }
3309
3744
  }
3310
3745
  } } };
@@ -3313,7 +3748,10 @@ function fromBedrock(bedrockClient, options) {
3313
3748
  function toBedrockToolChoice(toolChoice) {
3314
3749
  if (!toolChoice || toolChoice === "auto") return { auto: {} };
3315
3750
  if (toolChoice === "required") return { any: {} };
3316
- 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");
3751
+ 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.", "invalid_params", {
3752
+ code: "unsupported_capability",
3753
+ issues: { capability: "toolChoice: 'none'" }
3754
+ });
3317
3755
  return { tool: { name: toolChoice.function.name } };
3318
3756
  }
3319
3757
  /**
@@ -3338,7 +3776,7 @@ function toBedrockMessage(m) {
3338
3776
  else try {
3339
3777
  input = JSON.parse(tc.function.arguments);
3340
3778
  } catch (cause) {
3341
- throw new LLMError(`Assistant tool call "${tc.function.name}" (${tc.id}) has arguments that are not valid JSON.`, "validation", void 0, void 0, cause);
3779
+ throw new LLMError(`Assistant tool call "${tc.function.name}" (${tc.id}) has arguments that are not valid JSON.`, "validation", { cause });
3342
3780
  }
3343
3781
  blocks.push({ toolUse: {
3344
3782
  toolUseId: tc.id,
@@ -3509,8 +3947,14 @@ function fromFetch(config) {
3509
3947
  };
3510
3948
  },
3511
3949
  async *createStream(params, options) {
3512
- if (!config.mapStreamEvent) throw new LLMError("stream: true requires mapStreamEvent to be configured on fromFetch", "validation");
3513
- 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");
3950
+ if (!config.mapStreamEvent) throw new LLMError("stream: true requires mapStreamEvent to be configured on fromFetch", "invalid_params", {
3951
+ code: "unsupported_capability",
3952
+ issues: { capability: "mapStreamEvent" }
3953
+ });
3954
+ 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.", "invalid_params", {
3955
+ code: "unsupported_capability",
3956
+ issues: { capability: "requestStream" }
3957
+ });
3514
3958
  const { url, method, headers, body } = await buildRequestInit(config, params, config.mapRequest(params));
3515
3959
  const requestStream = config.requestStream ?? defaultRequestStream;
3516
3960
  const parseFrames = config.parseStreamFrames ?? parseSseStream;
@@ -3785,6 +4229,8 @@ exports.fromVLLM = fromVLLM
3785
4229
  exports.fromVercelAIGateway = fromVercelAIGateway
3786
4230
  exports.fromXAI = fromXAI
3787
4231
  exports.fromZhipu = fromZhipu
4232
+ exports.hasIssues = hasIssues
4233
+ exports.isFallbackExhaustedError = isFallbackExhaustedError
3788
4234
  exports.isLLMError = isLLMError
3789
4235
  exports.isToolCallResult = isToolCallResult
3790
4236
  exports.parseSseStream = parseSseStream