vern-llm 2.1.1 → 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/README.md +5 -1
- package/dist/index.cjs +629 -137
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +390 -132
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +390 -132
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +628 -136
- package/dist/index.js.map +1 -1
- package/package.json +16 -2
package/dist/index.js
CHANGED
|
@@ -1,23 +1,205 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
|
|
3
3
|
//#region src/types/errors.ts
|
|
4
|
+
/**
|
|
5
|
+
* Tool contract codes: a model or provider response defect, not a
|
|
6
|
+
* transient provider fault. Deterministic on the wire request, so
|
|
7
|
+
* retrying can't change the outcome and it shouldn't count toward the
|
|
8
|
+
* circuit breaker either. Shared by `LLMError.retryable` below and by
|
|
9
|
+
* `CallExecutor`'s own retry/breaker accounting, so the two can't drift
|
|
10
|
+
* apart.
|
|
11
|
+
*/
|
|
12
|
+
const NON_RETRYABLE_TOOL_CONTRACT_CODES = new Set([
|
|
13
|
+
"unknown_tool",
|
|
14
|
+
"duplicate_tool_call_id",
|
|
15
|
+
"tool_choice_none_violated",
|
|
16
|
+
"unexpected_tool_calls"
|
|
17
|
+
]);
|
|
18
|
+
/**
|
|
19
|
+
* Local rate-limit codes: the call never reached the provider, so it says
|
|
20
|
+
* nothing about the provider's health, and retrying either just requeues
|
|
21
|
+
* behind the same limit (the two queue codes) or can never succeed at all
|
|
22
|
+
* (`rate_limit_capacity_exceeded`). Shared for the same reason as
|
|
23
|
+
* {@link NON_RETRYABLE_TOOL_CONTRACT_CODES}.
|
|
24
|
+
*/
|
|
25
|
+
const LOCAL_RATE_LIMIT_CODES = new Set([
|
|
26
|
+
"rate_limit_queue_full",
|
|
27
|
+
"rate_limit_queue_timeout",
|
|
28
|
+
"rate_limit_capacity_exceeded"
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Types that are never worth retrying on their own: deterministic
|
|
32
|
+
* caller-input, model-response, or cancellation failures rather than a
|
|
33
|
+
* transient provider fault.
|
|
34
|
+
*/
|
|
35
|
+
const NON_RETRYABLE_TYPES = new Set([
|
|
36
|
+
"parse",
|
|
37
|
+
"validation",
|
|
38
|
+
"invalid_params",
|
|
39
|
+
"aborted"
|
|
40
|
+
]);
|
|
41
|
+
/**
|
|
42
|
+
* Shared retryability rule behind both `LLMError.retryable` and
|
|
43
|
+
* `LLMErrorSnapshot.retryable`. Pulled out so the two can't drift apart:
|
|
44
|
+
* a snapshot is a point-in-time copy of an error's fields, and this is
|
|
45
|
+
* one of them, so it has to be computed the same way in both places.
|
|
46
|
+
*/
|
|
47
|
+
function computeRetryable(type, code) {
|
|
48
|
+
if (NON_RETRYABLE_TYPES.has(type)) return false;
|
|
49
|
+
if (code && NON_RETRYABLE_TOOL_CONTRACT_CODES.has(code)) return false;
|
|
50
|
+
if (code && LOCAL_RATE_LIMIT_CODES.has(code)) return false;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Returns `issues` unchanged when it can survive `JSON.stringify`.
|
|
55
|
+
* Most `issues` values are VernLLM's own structured shapes (see
|
|
56
|
+
* `LLMErrorIssuesByCode`) and always safe. The one exception is a
|
|
57
|
+
* schema validation failure, where `issues` is a caller supplied
|
|
58
|
+
* `SchemaLike` validator's own `error: unknown`, not controlled by
|
|
59
|
+
* VernLLM and not guaranteed to be circular free. Rather than silently
|
|
60
|
+
* dropping it in that case, this returns a marker string so a reader
|
|
61
|
+
* of serialized output can tell "no issues data" apart from "issues
|
|
62
|
+
* existed but could not be shown".
|
|
63
|
+
*/
|
|
64
|
+
function safeIssues(issues) {
|
|
65
|
+
if (issues === void 0) return void 0;
|
|
66
|
+
try {
|
|
67
|
+
JSON.stringify(issues);
|
|
68
|
+
return issues;
|
|
69
|
+
} catch {
|
|
70
|
+
return "[Unserializable: issues contained a circular reference]";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Depth cap for `safeAttempts`, guarding against a pathological,
|
|
75
|
+
* self referential `attempts` array. `attempts` is a public
|
|
76
|
+
* `LLMErrorOptions` field, so a caller can construct one by hand; this
|
|
77
|
+
* keeps that path bounded the same way a circular `issues` value is
|
|
78
|
+
* bounded, rather than assuming well formed input.
|
|
79
|
+
*/
|
|
80
|
+
const MAX_ATTEMPTS_DEPTH = 20;
|
|
81
|
+
/**
|
|
82
|
+
* Returns a copy of `attempts` with every nested snapshot's `issues`
|
|
83
|
+
* re-checked through `safeIssues`, recursively through each snapshot's
|
|
84
|
+
* own `attempts`. Needed for two reasons: `safeIssues` returns a safe
|
|
85
|
+
* `issues` value by reference, so a shared object can be mutated into a
|
|
86
|
+
* circular one after the snapshot was created, and `attempts` is a
|
|
87
|
+
* public constructor option, so a caller can hand build a `RetryAttempt`
|
|
88
|
+
* (or a whole `LLMErrorSnapshot`) with a circular `issues` and pass it
|
|
89
|
+
* in directly, never touching `toSnapshot()` at all. Extra fields on an
|
|
90
|
+
* attempt (e.g. `FallbackAttempt`'s `provider`/`model`) are preserved.
|
|
91
|
+
*/
|
|
92
|
+
function safeAttempts(attempts, depth = 0) {
|
|
93
|
+
if (attempts === void 0) return void 0;
|
|
94
|
+
if (depth >= MAX_ATTEMPTS_DEPTH) return [];
|
|
95
|
+
return attempts.map((attempt) => ({
|
|
96
|
+
...attempt,
|
|
97
|
+
error: {
|
|
98
|
+
...attempt.error,
|
|
99
|
+
issues: safeIssues(attempt.error.issues),
|
|
100
|
+
attempts: safeAttempts(attempt.error.attempts, depth + 1)
|
|
101
|
+
}
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
4
104
|
var LLMError = class extends Error {
|
|
5
|
-
|
|
105
|
+
status;
|
|
106
|
+
issues;
|
|
107
|
+
cause;
|
|
108
|
+
retryAfterMs;
|
|
109
|
+
/** Stable discriminator within `type`. Absent on errors predating it. */
|
|
110
|
+
code;
|
|
111
|
+
/** Every attempt made before this error was thrown, in order. Absent when nothing was retried. */
|
|
112
|
+
attempts;
|
|
113
|
+
constructor(message, type, options = {}) {
|
|
6
114
|
super(message);
|
|
7
115
|
this.type = type;
|
|
8
|
-
this.status = status;
|
|
9
|
-
this.issues = issues;
|
|
10
|
-
this.cause = cause;
|
|
11
|
-
this.retryAfterMs = retryAfterMs;
|
|
12
|
-
this.code = code;
|
|
13
116
|
this.name = "LLMError";
|
|
117
|
+
this.status = options.status;
|
|
118
|
+
this.issues = options.issues;
|
|
119
|
+
this.cause = options.cause;
|
|
120
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
121
|
+
this.code = options.code;
|
|
122
|
+
this.attempts = options.attempts;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Computed purely from `type`/`code`, independent of any specific call's
|
|
126
|
+
* `nonRetryableStatus` list. False for `parse`/`validation`/
|
|
127
|
+
* `invalid_params`/`aborted` types (the caller's own input, the model's
|
|
128
|
+
* own response, or intentional cancellation, none of which are the
|
|
129
|
+
* provider being unhealthy), the tool contract codes, and the local
|
|
130
|
+
* rate limit codes. Subclasses (see `FallbackExhaustedError`) may
|
|
131
|
+
* override this when `type` alone carries no retry signal.
|
|
132
|
+
*/
|
|
133
|
+
get retryable() {
|
|
134
|
+
return computeRetryable(this.type, this.code);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Copies this error's fields into an {@link LLMErrorSnapshot}, for
|
|
138
|
+
* recording as a `RetryAttempt`/`FallbackAttempt`. `retryable` is
|
|
139
|
+
* captured here since a snapshot has no getter of its own. `cause` is
|
|
140
|
+
* not copied, see `LLMErrorSnapshot`'s own doc. `issues` and every
|
|
141
|
+
* nested `attempts` entry's own `issues` go through `safeAttempts`,
|
|
142
|
+
* since a schema validation failure's `issues` is a caller supplied
|
|
143
|
+
* value, not controlled by VernLLM, and `attempts` is itself a public
|
|
144
|
+
* constructor option a caller can hand build.
|
|
145
|
+
*/
|
|
146
|
+
toSnapshot() {
|
|
147
|
+
return {
|
|
148
|
+
message: this.message,
|
|
149
|
+
type: this.type,
|
|
150
|
+
status: this.status,
|
|
151
|
+
issues: safeIssues(this.issues),
|
|
152
|
+
retryAfterMs: this.retryAfterMs,
|
|
153
|
+
code: this.code,
|
|
154
|
+
retryable: this.retryable,
|
|
155
|
+
attempts: safeAttempts(this.attempts)
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Controls what `JSON.stringify(err)` produces. Omits `cause` for the
|
|
160
|
+
* same reason `toSnapshot()` does: `cause` is `unknown` and never
|
|
161
|
+
* validated by VernLLM, and some SDK errors carry circular structures
|
|
162
|
+
* `JSON.stringify` cannot serialize at all. Read `err.cause` directly
|
|
163
|
+
* instead. `issues`, including every nested `attempts` entry's own
|
|
164
|
+
* `issues`, goes through `safeAttempts` for the same reason: a schema
|
|
165
|
+
* validation failure's `issues` is caller supplied and not guaranteed
|
|
166
|
+
* circular free. Also includes `message` and `retryable`, which a
|
|
167
|
+
* plain property walk would otherwise miss: `message` is
|
|
168
|
+
* non-enumerable on `Error`, and `retryable` is a getter, not an own
|
|
169
|
+
* property.
|
|
170
|
+
*/
|
|
171
|
+
toJSON() {
|
|
172
|
+
return {
|
|
173
|
+
name: this.name,
|
|
174
|
+
message: this.message,
|
|
175
|
+
type: this.type,
|
|
176
|
+
status: this.status,
|
|
177
|
+
issues: safeIssues(this.issues),
|
|
178
|
+
retryAfterMs: this.retryAfterMs,
|
|
179
|
+
code: this.code,
|
|
180
|
+
retryable: this.retryable,
|
|
181
|
+
attempts: safeAttempts(this.attempts)
|
|
182
|
+
};
|
|
14
183
|
}
|
|
15
|
-
/** Every tool contract failure in one response, when there is more than one. */
|
|
16
|
-
toolIssues;
|
|
17
184
|
};
|
|
18
185
|
function isLLMError(err) {
|
|
19
186
|
return err instanceof LLMError;
|
|
20
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Narrows `err.issues` to the exact shape {@link LLMErrorIssuesByCode} maps
|
|
190
|
+
* `code` to, for any code listed there. `code` stays the only discriminator
|
|
191
|
+
* VernLLM uses; this just gives that existing check a typed return instead
|
|
192
|
+
* of requiring a manual cast of `issues`:
|
|
193
|
+
*
|
|
194
|
+
* ```ts
|
|
195
|
+
* if (isLLMError(err) && hasIssues(err, 'duplicate_tool_names')) {
|
|
196
|
+
* console.log(err.issues.names); // string[], no cast needed
|
|
197
|
+
* }
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
function hasIssues(err, code) {
|
|
201
|
+
return err.code === code && err.issues !== void 0;
|
|
202
|
+
}
|
|
21
203
|
|
|
22
204
|
//#endregion
|
|
23
205
|
//#region src/types/cache.ts
|
|
@@ -147,7 +329,12 @@ function isToolCallResult(result) {
|
|
|
147
329
|
//#endregion
|
|
148
330
|
//#region src/types/fallback.ts
|
|
149
331
|
/** 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([
|
|
332
|
+
const TOOL_CONTRACT_CODES = new Set([
|
|
333
|
+
"unknown_tool",
|
|
334
|
+
"duplicate_tool_call_id",
|
|
335
|
+
"tool_choice_none_violated",
|
|
336
|
+
"unexpected_tool_calls"
|
|
337
|
+
]);
|
|
151
338
|
/**
|
|
152
339
|
* The default `fallbackOn` policy. Exported so a caller can wrap rather
|
|
153
340
|
* than replace it, e.g. `fallbackOn: (e, ctx) => myCheck(e) ? 'stop' : defaultFallbackOn(e, ctx)`.
|
|
@@ -163,16 +350,37 @@ const defaultFallbackOn = (error) => {
|
|
|
163
350
|
* or `fallbackOn` chose to stop early. Carries each attempt in order so
|
|
164
351
|
* an outage across providers stays debuggable without reproducing it.
|
|
165
352
|
* Extends `LLMError` so `isLLMError` and any `instanceof LLMError` check
|
|
166
|
-
* still passes, inheriting the last failure's `type`
|
|
167
|
-
* type-based handling
|
|
353
|
+
* still passes, inheriting the last failure's `type`/`status`/`retryAfterMs`
|
|
354
|
+
* so existing type-based handling, including reading `retryAfterMs` on an
|
|
355
|
+
* `'api'`-typed error, keeps working on a fallback-exhausted error too.
|
|
168
356
|
*/
|
|
169
357
|
var FallbackExhaustedError = class extends LLMError {
|
|
170
358
|
constructor(attempts) {
|
|
171
359
|
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 ")}`,
|
|
360
|
+
super(`${attempts.length} provider${attempts.length === 1 ? "" : "s"} attempted and failed: ${attempts.map((a) => `${a.provider}(${a.error.type})`).join(" then ")}`, "fallback_exhausted", {
|
|
361
|
+
status: last?.status,
|
|
362
|
+
cause: last,
|
|
363
|
+
retryAfterMs: last?.retryAfterMs,
|
|
364
|
+
code: "fallback_exhausted",
|
|
365
|
+
attempts
|
|
366
|
+
});
|
|
173
367
|
this.attempts = attempts;
|
|
174
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* `type: 'fallback_exhausted'` by itself says nothing about whether
|
|
371
|
+
* retrying could help; the reason the last target failed does. Defers to
|
|
372
|
+
* that attempt's own `retryable` instead of anything about this class's
|
|
373
|
+
* own type.
|
|
374
|
+
*/
|
|
375
|
+
get retryable() {
|
|
376
|
+
const last = this.attempts[this.attempts.length - 1]?.error;
|
|
377
|
+
return last ? last.retryable : super.retryable;
|
|
378
|
+
}
|
|
175
379
|
};
|
|
380
|
+
/** Narrows `err` to {@link FallbackExhaustedError}, for direct access to its `attempts` (`provider`/`model` per failed target) without a manual `instanceof` check. */
|
|
381
|
+
function isFallbackExhaustedError(err) {
|
|
382
|
+
return err instanceof FallbackExhaustedError;
|
|
383
|
+
}
|
|
176
384
|
|
|
177
385
|
//#endregion
|
|
178
386
|
//#region src/internal/execution/usage.utils.ts
|
|
@@ -194,7 +402,7 @@ async function reserve(params, coalesced, signal) {
|
|
|
194
402
|
return true;
|
|
195
403
|
} catch (error) {
|
|
196
404
|
if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
|
|
197
|
-
throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded",
|
|
405
|
+
throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", { cause: error });
|
|
198
406
|
}
|
|
199
407
|
}
|
|
200
408
|
/**
|
|
@@ -394,13 +602,30 @@ var CacheOrchestrator = class {
|
|
|
394
602
|
*/
|
|
395
603
|
async deleteCache(key) {
|
|
396
604
|
if (!this.cache.delete) return;
|
|
397
|
-
|
|
605
|
+
try {
|
|
606
|
+
await this.cache.delete(await this.resolveCacheKey(key));
|
|
607
|
+
} catch (error) {
|
|
608
|
+
this.logger.warn(`[VernLLM] cache delete failed: ${error instanceof Error ? error.message : "unknown"}`);
|
|
609
|
+
}
|
|
398
610
|
}
|
|
399
611
|
/** Logs a failed refundUsage attempt via the configured logger. */
|
|
400
612
|
logRefundError(logMessage, error) {
|
|
401
613
|
this.logger.error(logMessage, { message: error instanceof Error ? error.message : "unknown" });
|
|
402
614
|
}
|
|
403
615
|
/**
|
|
616
|
+
* Reads from the cache, treating a failed adapter read as a miss rather
|
|
617
|
+
* than letting it fail the call. The request still falls through to a
|
|
618
|
+
* real provider call, but that fallback is now logged instead of silent.
|
|
619
|
+
*/
|
|
620
|
+
async getCached(key) {
|
|
621
|
+
try {
|
|
622
|
+
return await this.cache.get(key);
|
|
623
|
+
} catch (error) {
|
|
624
|
+
this.logger.warn(`[VernLLM] cache read failed: ${error instanceof Error ? error.message : "unknown"}`);
|
|
625
|
+
return { hit: false };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
404
629
|
* Internal cache primitive around caller-supplied logic. Concurrent misses
|
|
405
630
|
* for the same `cacheKey` share a single in-flight call, avoiding cache
|
|
406
631
|
* stampedes.
|
|
@@ -420,7 +645,7 @@ var CacheOrchestrator = class {
|
|
|
420
645
|
...params,
|
|
421
646
|
cacheKey: resolvedKey
|
|
422
647
|
};
|
|
423
|
-
const cached = await this.
|
|
648
|
+
const cached = await this.getCached(resolvedKey);
|
|
424
649
|
if (cached.hit) return cached.value;
|
|
425
650
|
const existing = this.inFlight.get(resolvedKey);
|
|
426
651
|
if (existing) return withReservedUsage(resolvedParams, true, () => existing, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
|
|
@@ -441,7 +666,7 @@ var CacheOrchestrator = class {
|
|
|
441
666
|
try {
|
|
442
667
|
await this.cache.set(params.cacheKey, result, params.ttl);
|
|
443
668
|
} catch (error) {
|
|
444
|
-
this.logger.
|
|
669
|
+
this.logger.warn(`[VernLLM] cache write failed: ${error instanceof Error ? error.message : "unknown"}`);
|
|
445
670
|
}
|
|
446
671
|
return result;
|
|
447
672
|
}
|
|
@@ -470,7 +695,7 @@ var CacheOrchestrator = class {
|
|
|
470
695
|
...params,
|
|
471
696
|
cacheKey: resolvedKey
|
|
472
697
|
};
|
|
473
|
-
const cached = await this.
|
|
698
|
+
const cached = await this.getCached(resolvedKey);
|
|
474
699
|
if (cached.hit) {
|
|
475
700
|
const value = cached.value;
|
|
476
701
|
return {
|
|
@@ -519,7 +744,7 @@ var CacheOrchestrator = class {
|
|
|
519
744
|
try {
|
|
520
745
|
await this.cache.set(params.cacheKey, value, params.ttl);
|
|
521
746
|
} catch (error) {
|
|
522
|
-
this.logger.
|
|
747
|
+
this.logger.warn(`[VernLLM] cache write failed: ${error instanceof Error ? error.message : "unknown"}`);
|
|
523
748
|
}
|
|
524
749
|
return value;
|
|
525
750
|
}, (error) => {
|
|
@@ -561,6 +786,7 @@ var CircuitBreaker = class {
|
|
|
561
786
|
threshold;
|
|
562
787
|
cooldownMs;
|
|
563
788
|
onStateChange;
|
|
789
|
+
/** Whether this breaker tracks failures per model instead of one shared circuit. Read by `CallExecutor`/`VernLLM` to report per-target in `getCircuitStates`. */
|
|
564
790
|
isolateByModel;
|
|
565
791
|
sharedBucket = newBucket();
|
|
566
792
|
bucketsByModel = new Map();
|
|
@@ -606,12 +832,12 @@ var CircuitBreaker = class {
|
|
|
606
832
|
if (bucket.state === "closed") return;
|
|
607
833
|
if (bucket.state === "open") {
|
|
608
834
|
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");
|
|
835
|
+
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" });
|
|
610
836
|
bucket.trialInFlight = true;
|
|
611
837
|
this.transition(bucket, "half-open", model);
|
|
612
838
|
return;
|
|
613
839
|
}
|
|
614
|
-
if (bucket.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open");
|
|
840
|
+
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" });
|
|
615
841
|
bucket.trialInFlight = true;
|
|
616
842
|
}
|
|
617
843
|
recordSuccess(model) {
|
|
@@ -646,6 +872,33 @@ var CircuitBreaker = class {
|
|
|
646
872
|
getState(model) {
|
|
647
873
|
return this.lookupBucket(model)?.state ?? "closed";
|
|
648
874
|
}
|
|
875
|
+
/**
|
|
876
|
+
* Manually opens the circuit, as if `threshold` consecutive failures had
|
|
877
|
+
* just happened, e.g. to pull a provider out of rotation ahead of known
|
|
878
|
+
* maintenance. Resets the cooldown window from now, same as a real
|
|
879
|
+
* threshold-crossing failure would, and clears any in-flight half-open
|
|
880
|
+
* trial since it no longer applies once the circuit is (re)opened.
|
|
881
|
+
*/
|
|
882
|
+
open(model) {
|
|
883
|
+
const bucket = this.ensureBucketFor(model);
|
|
884
|
+
bucket.openedAt = Date.now();
|
|
885
|
+
bucket.trialInFlight = false;
|
|
886
|
+
this.transition(bucket, "open", model);
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Manually closes the circuit and resets its failure count, e.g. once a
|
|
890
|
+
* provider is confirmed healthy again without waiting out the cooldown.
|
|
891
|
+
* Mirrors `recordSuccess`'s bookkeeping (including dropping the
|
|
892
|
+
* per-model bucket under `isolateByModel`, once idle) but without
|
|
893
|
+
* requiring an actual successful call first.
|
|
894
|
+
*/
|
|
895
|
+
close(model) {
|
|
896
|
+
const bucket = this.ensureBucketFor(model);
|
|
897
|
+
bucket.consecutiveFailures = 0;
|
|
898
|
+
bucket.trialInFlight = false;
|
|
899
|
+
this.transition(bucket, "closed", model);
|
|
900
|
+
if (this.isolateByModel && bucket.state === "closed" && bucket.consecutiveFailures === 0) this.bucketsByModel.delete(model ?? UNLABELED_MODEL);
|
|
901
|
+
}
|
|
649
902
|
};
|
|
650
903
|
|
|
651
904
|
//#endregion
|
|
@@ -764,7 +1017,7 @@ async function withTimeout(fn, timeoutMs, externalSignal) {
|
|
|
764
1017
|
try {
|
|
765
1018
|
return await fn(signal);
|
|
766
1019
|
} catch (err) {
|
|
767
|
-
if (controller.signal.aborted && !externalSignal?.aborted && err instanceof DOMException && err.name === "AbortError") throw new LLMError("Request timed out", "timeout");
|
|
1020
|
+
if (controller.signal.aborted && !externalSignal?.aborted && err instanceof DOMException && err.name === "AbortError") throw new LLMError("Request timed out", "timeout", { code: "request_timeout" });
|
|
768
1021
|
throw err;
|
|
769
1022
|
} finally {
|
|
770
1023
|
clearTimeout(timer);
|
|
@@ -797,7 +1050,7 @@ function withChunkIdleTimeout(next, timeoutMs, onIdle, logger) {
|
|
|
797
1050
|
const timer = setTimeout(() => {
|
|
798
1051
|
settled = true;
|
|
799
1052
|
onIdle?.();
|
|
800
|
-
reject(new LLMError(`No stream chunk received for ${activeTimeoutMs}ms (idle timeout)`, "timeout"));
|
|
1053
|
+
reject(new LLMError(`No stream chunk received for ${activeTimeoutMs}ms (idle timeout)`, "timeout", { code: "idle_timeout" }));
|
|
801
1054
|
}, clampTimeoutMs(activeTimeoutMs));
|
|
802
1055
|
next().then((result) => {
|
|
803
1056
|
clearTimeout(timer);
|
|
@@ -890,6 +1143,52 @@ function extractStatus(err) {
|
|
|
890
1143
|
if (typeof error.$metadata?.httpStatusCode === "number") return error.$metadata.httpStatusCode;
|
|
891
1144
|
return void 0;
|
|
892
1145
|
}
|
|
1146
|
+
/**
|
|
1147
|
+
* POSIX/libuv error codes libuv (and so Node's `fetch`/undici) attaches to
|
|
1148
|
+
* genuine transport-level failures: connection refused, DNS lookup
|
|
1149
|
+
* failure, connection reset mid-request, a connect that never completed,
|
|
1150
|
+
* DNS server unreachable, broken pipe, or host/network unreachable.
|
|
1151
|
+
* Deliberately narrow: only codes that can only mean "the connection
|
|
1152
|
+
* itself failed," not anything that could also indicate an application
|
|
1153
|
+
* error.
|
|
1154
|
+
*/
|
|
1155
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
1156
|
+
"ECONNREFUSED",
|
|
1157
|
+
"ENOTFOUND",
|
|
1158
|
+
"ECONNRESET",
|
|
1159
|
+
"ETIMEDOUT",
|
|
1160
|
+
"EAI_AGAIN",
|
|
1161
|
+
"EPIPE",
|
|
1162
|
+
"ECONNABORTED",
|
|
1163
|
+
"EHOSTUNREACH",
|
|
1164
|
+
"ENETUNREACH"
|
|
1165
|
+
]);
|
|
1166
|
+
/** `fetch`'s own wording for a transport-level failure, across runtimes/browsers. */
|
|
1167
|
+
const NETWORK_ERROR_MESSAGES = new Set([
|
|
1168
|
+
"fetch failed",
|
|
1169
|
+
"failed to fetch",
|
|
1170
|
+
"load failed",
|
|
1171
|
+
"networkerror when attempting to fetch resource."
|
|
1172
|
+
]);
|
|
1173
|
+
/**
|
|
1174
|
+
* Whether `error` is, with reasonable confidence, a transport-level
|
|
1175
|
+
* failure (never reached the provider, as opposed to the provider itself
|
|
1176
|
+
* responding with an error) rather than some other unexpected exception.
|
|
1177
|
+
* Checked via explicit, well-known signals only, so a genuinely unknown
|
|
1178
|
+
* error never gets misclassified as a connection failure just because it
|
|
1179
|
+
* also lacked an HTTP status.
|
|
1180
|
+
*/
|
|
1181
|
+
function isNetworkError(error) {
|
|
1182
|
+
if (!error || typeof error !== "object") return false;
|
|
1183
|
+
const err = error;
|
|
1184
|
+
if (typeof err.code === "string" && NETWORK_ERROR_CODES.has(err.code)) return true;
|
|
1185
|
+
if (typeof err.message === "string" && NETWORK_ERROR_MESSAGES.has(err.message.toLowerCase())) return true;
|
|
1186
|
+
if (err.cause && typeof err.cause === "object") {
|
|
1187
|
+
const cause = err.cause;
|
|
1188
|
+
if (typeof cause.code === "string" && NETWORK_ERROR_CODES.has(cause.code)) return true;
|
|
1189
|
+
}
|
|
1190
|
+
return false;
|
|
1191
|
+
}
|
|
893
1192
|
function formatSafely(value) {
|
|
894
1193
|
try {
|
|
895
1194
|
return JSON.stringify(value, null, 2) ?? String(value);
|
|
@@ -916,17 +1215,58 @@ function describeError(err) {
|
|
|
916
1215
|
} catch {}
|
|
917
1216
|
return formatSafely(err);
|
|
918
1217
|
}
|
|
919
|
-
/**
|
|
920
|
-
|
|
921
|
-
|
|
1218
|
+
/**
|
|
1219
|
+
* Maps an HTTP status to its corresponding `LLMErrorCode`, derived purely
|
|
1220
|
+
* from the status itself so it applies the same way regardless of which
|
|
1221
|
+
* adapter or client raised the error. Used both when building a fresh
|
|
1222
|
+
* `LLMError` and when filling in a `code` on an already-normalized one
|
|
1223
|
+
* that doesn't have one yet, so the two paths can't drift apart.
|
|
1224
|
+
*/
|
|
1225
|
+
function codeForStatus(status) {
|
|
1226
|
+
switch (status) {
|
|
1227
|
+
case 429: return "provider_rate_limited";
|
|
1228
|
+
case 401: return "authentication";
|
|
1229
|
+
case 403: return "authorization";
|
|
1230
|
+
case 404: return "not_found";
|
|
1231
|
+
case 413: return "payload_too_large";
|
|
1232
|
+
default: return status >= 500 ? "server_error" : void 0;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Converts any thrown value into a well-typed LLMError. `attempts`, when
|
|
1237
|
+
* given, is the accumulated record of every attempt made before `error`
|
|
1238
|
+
* was thrown; it's passed straight into the constructed error's options
|
|
1239
|
+
* rather than assigned onto the error afterward, so `attempts` is always
|
|
1240
|
+
* settled once, through the constructor, like every other field on
|
|
1241
|
+
* `LLMError`.
|
|
1242
|
+
*/
|
|
1243
|
+
function normalizeError(error, signal, attempts) {
|
|
1244
|
+
if (signal?.aborted) return new LLMError("LLM request aborted", "aborted", { attempts });
|
|
922
1245
|
if (error instanceof LLMError) {
|
|
923
|
-
if (error.
|
|
1246
|
+
if (error.code === void 0 && error.status !== void 0) error.code = codeForStatus(error.status);
|
|
1247
|
+
if (error.attempts === void 0 && attempts !== void 0) error.attempts = attempts;
|
|
924
1248
|
return error;
|
|
925
1249
|
}
|
|
926
1250
|
const status = extractStatus(error);
|
|
927
1251
|
const retryAfterMs = extractRetryAfterMs(error);
|
|
928
|
-
if (status !== void 0) return new LLMError("LLM request failed", "api",
|
|
929
|
-
|
|
1252
|
+
if (status !== void 0) return new LLMError("LLM request failed", "api", {
|
|
1253
|
+
status,
|
|
1254
|
+
cause: error,
|
|
1255
|
+
retryAfterMs,
|
|
1256
|
+
code: codeForStatus(status),
|
|
1257
|
+
attempts
|
|
1258
|
+
});
|
|
1259
|
+
if (isNetworkError(error)) return new LLMError("LLM request failed", "network", {
|
|
1260
|
+
cause: error,
|
|
1261
|
+
retryAfterMs,
|
|
1262
|
+
code: "connection_failed",
|
|
1263
|
+
attempts
|
|
1264
|
+
});
|
|
1265
|
+
return new LLMError("LLM request failed", "unknown", {
|
|
1266
|
+
cause: error,
|
|
1267
|
+
retryAfterMs,
|
|
1268
|
+
attempts
|
|
1269
|
+
});
|
|
930
1270
|
}
|
|
931
1271
|
|
|
932
1272
|
//#endregion
|
|
@@ -975,7 +1315,7 @@ function parseWireToolCalls(wireToolCalls) {
|
|
|
975
1315
|
try {
|
|
976
1316
|
parsedArgs = wc.function.arguments.trim() ? JSON.parse(wc.function.arguments) : {};
|
|
977
1317
|
} catch {
|
|
978
|
-
throw new LLMError(`Invalid JSON arguments for tool call "${wc.function.name}"`, "parse");
|
|
1318
|
+
throw new LLMError(`Invalid JSON arguments for tool call "${wc.function.name}"`, "parse", { code: "tool_arguments_parse_failed" });
|
|
979
1319
|
}
|
|
980
1320
|
return {
|
|
981
1321
|
id: wc.id,
|
|
@@ -990,9 +1330,13 @@ function parseWireToolCalls(wireToolCalls) {
|
|
|
990
1330
|
/**
|
|
991
1331
|
* Builds the wire request object for one call, applying per-instance
|
|
992
1332
|
* defaults (model, max tokens, temperature) and per-call overrides.
|
|
993
|
-
* Owns every
|
|
994
|
-
* history alternation, duplicate/empty tool lists,
|
|
995
|
-
* real tool.
|
|
1333
|
+
* Owns every check that depends only on the caller's own input shape, not
|
|
1334
|
+
* on execution: history alternation, duplicate/empty tool lists,
|
|
1335
|
+
* `toolChoice` naming a real tool. All deterministic on the call site's
|
|
1336
|
+
* own input and never touch the network, so every throw here is
|
|
1337
|
+
* `type: 'invalid_params'`, not `'validation'` (which is reserved for the
|
|
1338
|
+
* model/provider's own response failing a contract check). Has no
|
|
1339
|
+
* knowledge of retry, timeouts, or the breaker, only
|
|
996
1340
|
* the three defaults a `FallbackTarget` can override per-target (see the
|
|
997
1341
|
* `defaultMaxTokens`/`defaultTemperature` overrides in the fallback
|
|
998
1342
|
* design), which is what keeps it separable from `CallExecutor`.
|
|
@@ -1010,7 +1354,7 @@ var RequestBuilder = class {
|
|
|
1010
1354
|
build(params) {
|
|
1011
1355
|
const { systemPrompt, userContent, history = [], maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema, tools, toolChoice } = params;
|
|
1012
1356
|
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.", "
|
|
1357
|
+
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");
|
|
1014
1358
|
if (tools) {
|
|
1015
1359
|
const seen = new Set();
|
|
1016
1360
|
const duplicates = new Set();
|
|
@@ -1018,13 +1362,22 @@ var RequestBuilder = class {
|
|
|
1018
1362
|
if (seen.has(tool.name)) duplicates.add(tool.name);
|
|
1019
1363
|
seen.add(tool.name);
|
|
1020
1364
|
}
|
|
1021
|
-
if (duplicates.size) throw new LLMError(`\`tools\` has duplicate name(s): [${[...duplicates].join(", ")}]. Tool names must be unique.`, "
|
|
1365
|
+
if (duplicates.size) throw new LLMError(`\`tools\` has duplicate name(s): [${[...duplicates].join(", ")}]. Tool names must be unique.`, "invalid_params", {
|
|
1366
|
+
code: "duplicate_tool_names",
|
|
1367
|
+
issues: { names: [...duplicates] }
|
|
1368
|
+
});
|
|
1022
1369
|
}
|
|
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`.", "
|
|
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(", ")}]).`, "
|
|
1370
|
+
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");
|
|
1371
|
+
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", {
|
|
1372
|
+
code: "unknown_tool_choice",
|
|
1373
|
+
issues: {
|
|
1374
|
+
requested: toolChoice.name,
|
|
1375
|
+
available: tools.map((t) => t.name)
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1025
1378
|
const jsonMode = params.jsonMode ?? (tools ? false : true);
|
|
1026
1379
|
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.", "
|
|
1380
|
+
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");
|
|
1028
1381
|
const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
|
|
1029
1382
|
this.validateHistory(history);
|
|
1030
1383
|
const request = {
|
|
@@ -1061,29 +1414,47 @@ var RequestBuilder = class {
|
|
|
1061
1414
|
let previousTurn;
|
|
1062
1415
|
for (const [index, turn] of history.entries()) {
|
|
1063
1416
|
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`, "
|
|
1065
|
-
if (!turn.toolResults?.length) throw new LLMError(`history[${index}] is a "tool" turn but has no toolResults`, "
|
|
1417
|
+
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");
|
|
1418
|
+
if (!turn.toolResults?.length) throw new LLMError(`history[${index}] is a "tool" turn but has no toolResults`, "invalid_params");
|
|
1066
1419
|
const requestedIds = new Set(previousTurn.toolCalls.map((tc) => tc.id));
|
|
1067
1420
|
const resultIds = turn.toolResults.map((tr) => tr.toolCallId);
|
|
1068
1421
|
const unknownIds = resultIds.filter((id) => !requestedIds.has(id));
|
|
1069
|
-
if (unknownIds.length) throw new LLMError(`history[${index}].toolResults references unknown toolCallId(s) [${unknownIds.join(", ")}]`, "
|
|
1422
|
+
if (unknownIds.length) throw new LLMError(`history[${index}].toolResults references unknown toolCallId(s) [${unknownIds.join(", ")}]`, "invalid_params", {
|
|
1423
|
+
code: "unknown_tool_result_ids",
|
|
1424
|
+
issues: {
|
|
1425
|
+
historyIndex: index,
|
|
1426
|
+
ids: unknownIds
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1070
1429
|
const seenIds = new Set();
|
|
1071
1430
|
const duplicateIds = new Set();
|
|
1072
1431
|
for (const id of resultIds) {
|
|
1073
1432
|
if (seenIds.has(id)) duplicateIds.add(id);
|
|
1074
1433
|
seenIds.add(id);
|
|
1075
1434
|
}
|
|
1076
|
-
if (duplicateIds.size) throw new LLMError(`history[${index}].toolResults has duplicate toolCallId(s) [${[...duplicateIds].join(", ")}]`, "
|
|
1435
|
+
if (duplicateIds.size) throw new LLMError(`history[${index}].toolResults has duplicate toolCallId(s) [${[...duplicateIds].join(", ")}]`, "invalid_params", {
|
|
1436
|
+
code: "duplicate_tool_result_ids",
|
|
1437
|
+
issues: {
|
|
1438
|
+
historyIndex: index,
|
|
1439
|
+
ids: [...duplicateIds]
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1077
1442
|
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(", ")}]`, "
|
|
1443
|
+
if (missingIds.length) throw new LLMError(`history[${index}] is missing toolResults for toolCallId(s) [${missingIds.join(", ")}]`, "invalid_params", {
|
|
1444
|
+
code: "missing_tool_results",
|
|
1445
|
+
issues: {
|
|
1446
|
+
historyIndex: index,
|
|
1447
|
+
ids: missingIds
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1079
1450
|
} 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}]`, "
|
|
1081
|
-
if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError(`history[${index}] follows an assistant tool request without tool results`, "
|
|
1451
|
+
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");
|
|
1452
|
+
if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError(`history[${index}] follows an assistant tool request without tool results`, "invalid_params");
|
|
1082
1453
|
}
|
|
1083
1454
|
previousTurn = turn;
|
|
1084
1455
|
}
|
|
1085
|
-
if (previousTurn?.role === "assistant" && previousTurn.toolCalls?.length) throw new LLMError("The last entry in history is an assistant tool request without tool results", "
|
|
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.", "
|
|
1456
|
+
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");
|
|
1457
|
+
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");
|
|
1087
1458
|
}
|
|
1088
1459
|
/** Maps VernLLM's app-facing `ToolChoice` onto the OpenAI-shaped wire `tool_choice`. */
|
|
1089
1460
|
buildWireToolChoice(toolChoice) {
|
|
@@ -1351,6 +1722,18 @@ var CallExecutor = class {
|
|
|
1351
1722
|
getCircuitState(model) {
|
|
1352
1723
|
return this.breaker?.getState(model);
|
|
1353
1724
|
}
|
|
1725
|
+
/** Whether this target's breaker tracks failures per model. `false` if no breaker is configured. */
|
|
1726
|
+
get isolateByModel() {
|
|
1727
|
+
return this.breaker?.isolateByModel ?? false;
|
|
1728
|
+
}
|
|
1729
|
+
/** Manually opens this target's circuit breaker, if one is configured. No-op otherwise. */
|
|
1730
|
+
openCircuit(model) {
|
|
1731
|
+
this.breaker?.open(model);
|
|
1732
|
+
}
|
|
1733
|
+
/** Manually closes this target's circuit breaker, if one is configured. No-op otherwise. */
|
|
1734
|
+
closeCircuit(model) {
|
|
1735
|
+
this.breaker?.close(model);
|
|
1736
|
+
}
|
|
1354
1737
|
/**
|
|
1355
1738
|
* Throws if the breaker is open for this target/model, exactly like the
|
|
1356
1739
|
* check `run`/`runStream` used to make internally. Exposed so `VernLLM`
|
|
@@ -1372,10 +1755,11 @@ var CallExecutor = class {
|
|
|
1372
1755
|
*/
|
|
1373
1756
|
async run(params, requestId, onAttempt) {
|
|
1374
1757
|
const model = params.model ?? this.model;
|
|
1758
|
+
const attempts = [];
|
|
1375
1759
|
try {
|
|
1376
|
-
return await this.retryWithBackoff((attempt) => this.executeCall(params, requestId, attempt), requestId, model, params.signal, onAttempt);
|
|
1760
|
+
return await this.retryWithBackoff((attempt) => this.executeCall(params, requestId, attempt), requestId, model, params.signal, onAttempt, attempts);
|
|
1377
1761
|
} catch (error) {
|
|
1378
|
-
const normalized = normalizeError(error, params.signal);
|
|
1762
|
+
const normalized = normalizeError(error, params.signal, attempts.length > 0 ? attempts : void 0);
|
|
1379
1763
|
if (this.countsTowardBreaker(normalized)) this.breaker?.recordFailure(model);
|
|
1380
1764
|
this.logger.debug(`[VernLLM:${requestId}] error:\n${this.redactText(describeError(error))}`);
|
|
1381
1765
|
throw normalized;
|
|
@@ -1384,10 +1768,11 @@ var CallExecutor = class {
|
|
|
1384
1768
|
/** Streaming counterpart to `run`. Mirrors the old streaming branch of `VernLLM.call`. */
|
|
1385
1769
|
async runStream(params, requestId, onAttempt) {
|
|
1386
1770
|
const model = params.model ?? this.model;
|
|
1771
|
+
const attempts = [];
|
|
1387
1772
|
try {
|
|
1388
|
-
return await this.retryWithBackoff((attempt) => this.executeStreamCall(params, requestId, attempt), requestId, model, params.signal, onAttempt);
|
|
1773
|
+
return await this.retryWithBackoff((attempt) => this.executeStreamCall(params, requestId, attempt), requestId, model, params.signal, onAttempt, attempts);
|
|
1389
1774
|
} catch (error) {
|
|
1390
|
-
const normalized = normalizeError(error, params.signal);
|
|
1775
|
+
const normalized = normalizeError(error, params.signal, attempts.length > 0 ? attempts : void 0);
|
|
1391
1776
|
if (this.countsTowardBreaker(normalized)) this.breaker?.recordFailure(model);
|
|
1392
1777
|
this.logger.debug(`[VernLLM:${requestId}] stream-open error:\n${this.redactText(describeError(error))}`);
|
|
1393
1778
|
throw normalized;
|
|
@@ -1455,10 +1840,11 @@ var CallExecutor = class {
|
|
|
1455
1840
|
finalizeResponse(rawContent, wireToolCalls, params, useJson, model, usage, requestId, attempt) {
|
|
1456
1841
|
try {
|
|
1457
1842
|
const content = rawContent?.trim();
|
|
1458
|
-
if (!content && !wireToolCalls?.length) throw new LLMError("Empty LLM response", "api");
|
|
1843
|
+
if (!content && !wireToolCalls?.length) throw new LLMError("Empty LLM response", "api", { code: "empty_response" });
|
|
1459
1844
|
this.logger.debug(`[VernLLM:${requestId}] output:\n${this.redactedOutput(content, wireToolCalls).slice(0, 800)}`);
|
|
1460
1845
|
if (wireToolCalls?.length) {
|
|
1461
|
-
if (!params.tools) throw new LLMError("Provider returned tool_calls but no `tools` were sent with this call.", "
|
|
1846
|
+
if (!params.tools) throw new LLMError("Provider returned tool_calls but no `tools` were sent with this call.", "validation", { code: "unexpected_tool_calls" });
|
|
1847
|
+
if (params.toolChoice === "none") throw new LLMError("Provider returned tool_calls despite toolChoice: 'none'.", "validation", { code: "tool_choice_none_violated" });
|
|
1462
1848
|
const toolCalls = parseWireToolCalls(wireToolCalls);
|
|
1463
1849
|
this.validateToolCallArguments(toolCalls, params.tools);
|
|
1464
1850
|
this.breaker?.recordSuccess(model);
|
|
@@ -1511,7 +1897,10 @@ var CallExecutor = class {
|
|
|
1511
1897
|
async executeStreamCall(params, requestId, attempt) {
|
|
1512
1898
|
const { useJson, model, request } = this.requestBuilder.build(params);
|
|
1513
1899
|
const completions = this.client.chat.completions;
|
|
1514
|
-
if (!completions.createStream) throw new LLMError("stream: true requires a client/adapter with createStream", "
|
|
1900
|
+
if (!completions.createStream) throw new LLMError("stream: true requires a client/adapter with createStream", "invalid_params", {
|
|
1901
|
+
code: "unsupported_capability",
|
|
1902
|
+
issues: { capability: "createStream" }
|
|
1903
|
+
});
|
|
1515
1904
|
const createStream = completions.createStream.bind(completions);
|
|
1516
1905
|
let release;
|
|
1517
1906
|
if (this.limiter) {
|
|
@@ -1572,13 +1961,13 @@ var CallExecutor = class {
|
|
|
1572
1961
|
* `argumentsSchema`, if present.
|
|
1573
1962
|
*
|
|
1574
1963
|
* Contract failures (unknown name, duplicate id) are collected across
|
|
1575
|
-
* every call and thrown together
|
|
1576
|
-
*
|
|
1577
|
-
*
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
1581
|
-
*
|
|
1964
|
+
* every call and thrown together as one `type: 'validation'` error with
|
|
1965
|
+
* `issues: ToolIssue[]`, since retrying a request that already has these
|
|
1966
|
+
* errors cannot help (excluded from retry by `type`) and a caller fixing
|
|
1967
|
+
* them wants to see every one, not just the first. Schema failures keep
|
|
1968
|
+
* the original single-error, `type: 'validation'` shape rather than being
|
|
1969
|
+
* folded into the aggregate, since they're a distinct failure kind from
|
|
1970
|
+
* the contract failures above.
|
|
1582
1971
|
*/
|
|
1583
1972
|
validateToolCallArguments(toolCalls, tools) {
|
|
1584
1973
|
const known = new Map(tools.map((t) => [t.name, t]));
|
|
@@ -1600,20 +1989,31 @@ var CallExecutor = class {
|
|
|
1600
1989
|
if (toolIssues.length > 0) {
|
|
1601
1990
|
const unknownTool = toolIssues.find((i) => i.code === "unknown_tool");
|
|
1602
1991
|
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
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1992
|
+
const message = toolIssues.length > 1 ? `${primary} (${toolIssues.length} tool call issues total, see error.issues.)` : primary;
|
|
1993
|
+
throw new LLMError(message, "validation", {
|
|
1994
|
+
code: unknownTool ? "unknown_tool" : "duplicate_tool_call_id",
|
|
1995
|
+
issues: toolIssues
|
|
1996
|
+
});
|
|
1607
1997
|
}
|
|
1608
1998
|
for (const call of toolCalls) {
|
|
1609
1999
|
const definition = known.get(call.name);
|
|
1610
2000
|
if (!definition?.argumentsSchema) continue;
|
|
1611
2001
|
const result = definition.argumentsSchema.safeParse(call.arguments);
|
|
1612
|
-
if (!result.success) throw new LLMError(`Arguments for tool call "${call.name}" failed validation`, "validation",
|
|
2002
|
+
if (!result.success) throw new LLMError(`Arguments for tool call "${call.name}" failed validation`, "validation", { issues: result.error });
|
|
1613
2003
|
}
|
|
1614
2004
|
}
|
|
1615
|
-
/**
|
|
1616
|
-
|
|
2005
|
+
/**
|
|
2006
|
+
* Runs `fn`, retrying with backoff according to `shouldRetry`. When
|
|
2007
|
+
* `attempts` is given, every failed attempt that is actually followed by
|
|
2008
|
+
* a retry is recorded, in order. This mirrors `LLMError.attempts`'s
|
|
2009
|
+
* contract: every attempt made before this error was thrown. The
|
|
2010
|
+
* terminal failure is never pushed since it isn't a prior attempt, it
|
|
2011
|
+
* is the error being thrown. `attempts` stays empty when nothing was
|
|
2012
|
+
* retried, so no separate bookkeeping is needed at the call sites.
|
|
2013
|
+
* Each failure is recorded as a snapshot (`LLMError.toSnapshot()`),
|
|
2014
|
+
* not the live `LLMError`, per `RetryAttempt`'s contract.
|
|
2015
|
+
*/
|
|
2016
|
+
async retryWithBackoff(fn, requestId, model, signal, onAttempt, attempts) {
|
|
1617
2017
|
let lastError;
|
|
1618
2018
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
|
|
1619
2019
|
if (attempt > 0) await this.recoverDelay(requestId, model, attempt, lastError, signal);
|
|
@@ -1621,7 +2021,12 @@ var CallExecutor = class {
|
|
|
1621
2021
|
return await fn(attempt);
|
|
1622
2022
|
} catch (error) {
|
|
1623
2023
|
lastError = error;
|
|
1624
|
-
|
|
2024
|
+
const willRetry = attempt < this.maxRetries && this.shouldRetry(error, signal);
|
|
2025
|
+
if (!willRetry) break;
|
|
2026
|
+
attempts?.push({
|
|
2027
|
+
index: attempt,
|
|
2028
|
+
error: normalizeError(error, signal).toSnapshot()
|
|
2029
|
+
});
|
|
1625
2030
|
}
|
|
1626
2031
|
throw lastError;
|
|
1627
2032
|
}
|
|
@@ -1691,7 +2096,7 @@ var CallExecutor = class {
|
|
|
1691
2096
|
if (parsed === null || parsed === void 0) throw new LLMError("Invalid JSON response", "parse");
|
|
1692
2097
|
if (!schema) return parsed;
|
|
1693
2098
|
const result = schema.safeParse(parsed);
|
|
1694
|
-
if (!result.success) throw new LLMError("Schema validation failed", "validation",
|
|
2099
|
+
if (!result.success) throw new LLMError("Schema validation failed", "validation", { issues: result.error });
|
|
1695
2100
|
return result.data;
|
|
1696
2101
|
}
|
|
1697
2102
|
/**
|
|
@@ -1704,7 +2109,7 @@ var CallExecutor = class {
|
|
|
1704
2109
|
const retryAfterMs = extractRetryAfterMs(error);
|
|
1705
2110
|
const delay = retryAfterMs ?? getBackoffDelay(this.baseDelayMs, attempt);
|
|
1706
2111
|
const retryAfterHonored = retryAfterMs !== void 0;
|
|
1707
|
-
this.logger.warn(`[VernLLM:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterHonored ? " (honoring Retry-After)" : ""));
|
|
2112
|
+
this.logger.warn(`[VernLLM:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${Math.ceil(delay)}ms` + (retryAfterHonored ? " (honoring Retry-After)" : ""));
|
|
1708
2113
|
this.reportEvent({
|
|
1709
2114
|
kind: "retry",
|
|
1710
2115
|
requestId,
|
|
@@ -1718,33 +2123,61 @@ var CallExecutor = class {
|
|
|
1718
2123
|
});
|
|
1719
2124
|
await waitForRetry(delay, signal);
|
|
1720
2125
|
}
|
|
1721
|
-
isNonRetryableToolContractError(error) {
|
|
1722
|
-
return error instanceof LLMError && (error.code === "unknown_tool" || error.code === "duplicate_tool_call_id");
|
|
1723
|
-
}
|
|
1724
2126
|
/** Decides whether a failed attempt is worth retrying. */
|
|
1725
2127
|
shouldRetry(error, signal) {
|
|
1726
2128
|
if (signal?.aborted) return false;
|
|
1727
|
-
if (error instanceof LLMError &&
|
|
1728
|
-
if (error instanceof LLMError && error.code === "local_rate_limit") return false;
|
|
1729
|
-
if (this.isNonRetryableToolContractError(error)) return false;
|
|
2129
|
+
if (error instanceof LLMError && !error.retryable) return false;
|
|
1730
2130
|
const status = extractStatus(error);
|
|
1731
2131
|
return !(status !== void 0 && this.nonRetryableStatus.includes(status));
|
|
1732
2132
|
}
|
|
1733
2133
|
/**
|
|
1734
2134
|
* Decides whether a failed attempt should count toward the circuit
|
|
1735
|
-
* breaker's failure threshold. A model hallucinating a tool name
|
|
1736
|
-
* reusing a call id
|
|
1737
|
-
*
|
|
1738
|
-
*
|
|
1739
|
-
*
|
|
1740
|
-
*
|
|
2135
|
+
* breaker's failure threshold. A model hallucinating a tool name,
|
|
2136
|
+
* reusing a call id, or a provider ignoring `toolChoice: 'none'` isn't
|
|
2137
|
+
* the provider being unhealthy, it's a model/provider response defect
|
|
2138
|
+
* that will very likely recur regardless of provider health, so it
|
|
2139
|
+
* shouldn't push a healthy provider's circuit toward opening. Same for
|
|
2140
|
+
* a caller-input bug or a local rate-limit rejection: neither ever
|
|
2141
|
+
* reached the provider at all. This is exactly what `LLMError.retryable`
|
|
2142
|
+
* already excludes, so this defers to it directly.
|
|
1741
2143
|
*/
|
|
1742
2144
|
countsTowardBreaker(error) {
|
|
1743
|
-
|
|
1744
|
-
return true;
|
|
2145
|
+
return error.retryable;
|
|
1745
2146
|
}
|
|
1746
2147
|
};
|
|
1747
2148
|
|
|
2149
|
+
//#endregion
|
|
2150
|
+
//#region src/internal/logger.utils.ts
|
|
2151
|
+
/**
|
|
2152
|
+
* Wraps a `Logger` so a throwing implementation can never break the call
|
|
2153
|
+
* it's trying to describe. `logger` is user-supplied (`VernLLMOptions.logger`),
|
|
2154
|
+
* so a custom logger that ships to a file, Datadog, etc. can throw for
|
|
2155
|
+
* reasons unrelated to VernLLM. Wrap once at construction so every
|
|
2156
|
+
* downstream `this.logger.warn(...)` call stays as-is and is safe by
|
|
2157
|
+
* construction, instead of guarding each call site individually.
|
|
2158
|
+
*/
|
|
2159
|
+
function createSafeLogger(logger) {
|
|
2160
|
+
return {
|
|
2161
|
+
debug: safe(logger, "debug"),
|
|
2162
|
+
warn: safe(logger, "warn"),
|
|
2163
|
+
error: safe(logger, "error")
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
function safe(logger, method) {
|
|
2167
|
+
const fn = logger[method].bind(logger);
|
|
2168
|
+
return (...args) => {
|
|
2169
|
+
try {
|
|
2170
|
+
swallowRejection(fn(...args));
|
|
2171
|
+
} catch {}
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
function isPromiseLike(value) {
|
|
2175
|
+
return typeof value?.then === "function";
|
|
2176
|
+
}
|
|
2177
|
+
function swallowRejection(result) {
|
|
2178
|
+
if (isPromiseLike(result)) Promise.resolve(result).catch(() => {});
|
|
2179
|
+
}
|
|
2180
|
+
|
|
1748
2181
|
//#endregion
|
|
1749
2182
|
//#region src/logger.ts
|
|
1750
2183
|
/**
|
|
@@ -1799,7 +2232,8 @@ var TokenBucket = class {
|
|
|
1799
2232
|
refill() {
|
|
1800
2233
|
if (this.refillPerMs === 0) return;
|
|
1801
2234
|
const now = Date.now();
|
|
1802
|
-
|
|
2235
|
+
const elapsedMs = now - this.lastRefill;
|
|
2236
|
+
this.available = Math.min(this.capacity, this.available + Math.max(0, elapsedMs) * this.refillPerMs);
|
|
1803
2237
|
this.lastRefill = now;
|
|
1804
2238
|
}
|
|
1805
2239
|
/** Refills, then takes `amount` if available. Leaves the bucket untouched if it can't. */
|
|
@@ -1884,22 +2318,21 @@ var RateLimiter = class {
|
|
|
1884
2318
|
*/
|
|
1885
2319
|
async acquire(estimatedTokens, signal) {
|
|
1886
2320
|
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)}`, "
|
|
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.`, "
|
|
2321
|
+
if (!Number.isFinite(estimatedTokens) || estimatedTokens < 0) throw new LLMError(`estimatedTokens must be a finite, non-negative number, got ${String(estimatedTokens)}`, "invalid_params");
|
|
2322
|
+
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" });
|
|
1889
2323
|
if (this.queue.length === 0) {
|
|
1890
2324
|
const attempt = this.tryAcquireBuckets(estimatedTokens);
|
|
1891
2325
|
if (attempt.ok) return {
|
|
1892
2326
|
release: this.makeRelease(estimatedTokens),
|
|
1893
2327
|
waitedMs: 0
|
|
1894
2328
|
};
|
|
1895
|
-
if (this.maxQueueSize > 0 && this.queue.length >= this.maxQueueSize) throw this.queueFullError();
|
|
1896
2329
|
return this.enqueue(estimatedTokens, attempt.reason, signal);
|
|
1897
2330
|
}
|
|
1898
2331
|
if (this.maxQueueSize > 0 && this.queue.length >= this.maxQueueSize) throw this.queueFullError();
|
|
1899
2332
|
return this.enqueue(estimatedTokens, void 0, signal);
|
|
1900
2333
|
}
|
|
1901
2334
|
queueFullError() {
|
|
1902
|
-
return new LLMError("Rate limit queue is full", "
|
|
2335
|
+
return new LLMError("Rate limit queue is full", "rate_limited", { code: "rate_limit_queue_full" });
|
|
1903
2336
|
}
|
|
1904
2337
|
enqueue(estimatedTokens, initialReason, signal) {
|
|
1905
2338
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
@@ -1932,7 +2365,7 @@ var RateLimiter = class {
|
|
|
1932
2365
|
if (index !== -1) this.queue.splice(index, 1);
|
|
1933
2366
|
};
|
|
1934
2367
|
if (this.maxQueueMs > 0) queueTimer = setTimeout(() => {
|
|
1935
|
-
waiter.reject(new LLMError("Rate limit queue timed out before capacity was available", "
|
|
2368
|
+
waiter.reject(new LLMError("Rate limit queue timed out before capacity was available", "rate_limited", { code: "rate_limit_queue_timeout" }));
|
|
1936
2369
|
}, this.maxQueueMs);
|
|
1937
2370
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1938
2371
|
this.queue.push(waiter);
|
|
@@ -2066,7 +2499,7 @@ var VernLLM = class {
|
|
|
2066
2499
|
* `nonRetryableStatus` `[400, 401, 403, 404, 422]`, `debug` false.
|
|
2067
2500
|
*/
|
|
2068
2501
|
constructor(options) {
|
|
2069
|
-
this.logger = options.logger ?? new ConsoleLogger(options.debug ?? false);
|
|
2502
|
+
this.logger = createSafeLogger(options.logger ?? new ConsoleLogger(options.debug ?? false));
|
|
2070
2503
|
const providerName = options.name ?? "primary";
|
|
2071
2504
|
this.cacheOrchestrator = new CacheOrchestrator(options.cache ?? new InMemoryCacheAdapter(), this.logger);
|
|
2072
2505
|
this.fallbackOn = options.fallbackOn ?? defaultFallbackOn;
|
|
@@ -2159,7 +2592,7 @@ var VernLLM = class {
|
|
|
2159
2592
|
index: i - 1,
|
|
2160
2593
|
provider: executor.providerName,
|
|
2161
2594
|
model: params.model ?? executor.model,
|
|
2162
|
-
error: normalized
|
|
2595
|
+
error: normalized.toSnapshot()
|
|
2163
2596
|
});
|
|
2164
2597
|
const isLast = i === this.executors.length - 1;
|
|
2165
2598
|
const policyDecision = this.fallbackOn(normalized, { isLastTarget: isLast });
|
|
@@ -2178,7 +2611,7 @@ var VernLLM = class {
|
|
|
2178
2611
|
});
|
|
2179
2612
|
}
|
|
2180
2613
|
}
|
|
2181
|
-
throw new LLMError("No provider targets configured", "
|
|
2614
|
+
throw new LLMError("No provider targets configured", "invalid_params");
|
|
2182
2615
|
}
|
|
2183
2616
|
async call(params) {
|
|
2184
2617
|
if (params.signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
|
|
@@ -2223,8 +2656,8 @@ var VernLLM = class {
|
|
|
2223
2656
|
}
|
|
2224
2657
|
async cachedCall(params) {
|
|
2225
2658
|
const { call: callParams,...cacheParams } = params;
|
|
2226
|
-
const
|
|
2227
|
-
if (reserveUsage || refundUsage)
|
|
2659
|
+
const restCallParams = callParams;
|
|
2660
|
+
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");
|
|
2228
2661
|
if (restCallParams.stream) {
|
|
2229
2662
|
const streamParams = restCallParams;
|
|
2230
2663
|
return this.cacheOrchestrator.runCachedStream({
|
|
@@ -2238,35 +2671,67 @@ var VernLLM = class {
|
|
|
2238
2671
|
});
|
|
2239
2672
|
}
|
|
2240
2673
|
/**
|
|
2241
|
-
* @param
|
|
2242
|
-
* model
|
|
2243
|
-
*
|
|
2244
|
-
*
|
|
2245
|
-
*
|
|
2246
|
-
*
|
|
2674
|
+
* @param target.index Which target to read. Defaults to the primary.
|
|
2675
|
+
* @param target.model Which model bucket to read, if the target isolates by model.
|
|
2676
|
+
* @returns The breaker state, or `undefined` if that target has no breaker.
|
|
2677
|
+
* @throws {RangeError} If `target.index` names no target. Lets a real
|
|
2678
|
+
* target with no breaker (`undefined`) stay distinguishable from a
|
|
2679
|
+
* target that doesn't exist.
|
|
2247
2680
|
*/
|
|
2248
|
-
getCircuitState(
|
|
2249
|
-
|
|
2681
|
+
getCircuitState(target) {
|
|
2682
|
+
const executor = this.resolveExecutor(target?.index ?? 0, "getCircuitState");
|
|
2683
|
+
this.warnIfModelUnsupported(executor.isolateByModel, target?.model, "getCircuitState");
|
|
2684
|
+
return executor.getCircuitState(target?.model ?? executor.model);
|
|
2250
2685
|
}
|
|
2251
2686
|
/**
|
|
2252
|
-
* @param model
|
|
2253
|
-
* target's
|
|
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.
|
|
2687
|
+
* @param model Which model bucket to read, for targets that isolate by model.
|
|
2688
|
+
* @returns Every target's state, in chain order.
|
|
2261
2689
|
*/
|
|
2262
2690
|
getCircuitStates(model) {
|
|
2263
2691
|
return this.executors.map((executor, index) => ({
|
|
2264
2692
|
provider: executor.providerName,
|
|
2265
2693
|
index,
|
|
2266
2694
|
isFallback: index > 0,
|
|
2267
|
-
|
|
2695
|
+
isolateByModel: executor.isolateByModel,
|
|
2696
|
+
state: executor.getCircuitState(model ?? executor.model)
|
|
2268
2697
|
}));
|
|
2269
2698
|
}
|
|
2699
|
+
/**
|
|
2700
|
+
* Manually opens a target's breaker, e.g. to pull a provider out of
|
|
2701
|
+
* rotation ahead of known maintenance instead of waiting for it to fail.
|
|
2702
|
+
*
|
|
2703
|
+
* @param target.index Which target to open. Defaults to the primary.
|
|
2704
|
+
* @param target.model Which model bucket to open, if the target isolates by model.
|
|
2705
|
+
* @throws {RangeError} If `target.index` names no target.
|
|
2706
|
+
*/
|
|
2707
|
+
openCircuit(target) {
|
|
2708
|
+
const executor = this.resolveExecutor(target?.index ?? 0, "openCircuit");
|
|
2709
|
+
this.warnIfModelUnsupported(executor.isolateByModel, target?.model, "openCircuit");
|
|
2710
|
+
executor.openCircuit(target?.model ?? executor.model);
|
|
2711
|
+
}
|
|
2712
|
+
/**
|
|
2713
|
+
* Manually closes a target's breaker, e.g. once a provider is confirmed
|
|
2714
|
+
* healthy again without waiting out the cooldown.
|
|
2715
|
+
*
|
|
2716
|
+
* @param target.index Which target to close. Defaults to the primary.
|
|
2717
|
+
* @param target.model Which model bucket to close, if the target isolates by model.
|
|
2718
|
+
* @throws {RangeError} If `target.index` names no target.
|
|
2719
|
+
*/
|
|
2720
|
+
closeCircuit(target) {
|
|
2721
|
+
const executor = this.resolveExecutor(target?.index ?? 0, "closeCircuit");
|
|
2722
|
+
this.warnIfModelUnsupported(executor.isolateByModel, target?.model, "closeCircuit");
|
|
2723
|
+
executor.closeCircuit(target?.model ?? executor.model);
|
|
2724
|
+
}
|
|
2725
|
+
/** Resolves a target index so every circuit-breaker method agrees on what counts as valid. */
|
|
2726
|
+
resolveExecutor(index, caller) {
|
|
2727
|
+
const executor = this.executors[index];
|
|
2728
|
+
if (!executor) throw new RangeError(`${caller}: no target at index ${index} (chain has ${this.executors.length} target${this.executors.length === 1 ? "" : "s"})`);
|
|
2729
|
+
return executor;
|
|
2730
|
+
}
|
|
2731
|
+
/** Warns when `model` can't do anything on this target, so it's never silently ignored. */
|
|
2732
|
+
warnIfModelUnsupported(isolateByModel, model, caller) {
|
|
2733
|
+
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.`);
|
|
2734
|
+
}
|
|
2270
2735
|
};
|
|
2271
2736
|
|
|
2272
2737
|
//#endregion
|
|
@@ -2306,7 +2771,7 @@ async function* parseSseStream(source) {
|
|
|
2306
2771
|
try {
|
|
2307
2772
|
text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
2308
2773
|
} catch (cause) {
|
|
2309
|
-
throw new LLMError("Invalid UTF-8 in SSE stream", "parse",
|
|
2774
|
+
throw new LLMError("Invalid UTF-8 in SSE stream", "parse", { cause });
|
|
2310
2775
|
}
|
|
2311
2776
|
buffer = (buffer + text).replace(/\r\n/g, "\n").replace(/\r(?!$)/g, "\n");
|
|
2312
2777
|
let boundary$1 = buffer.indexOf("\n\n");
|
|
@@ -2322,7 +2787,7 @@ async function* parseSseStream(source) {
|
|
|
2322
2787
|
try {
|
|
2323
2788
|
buffer += decoder.decode();
|
|
2324
2789
|
} catch (cause) {
|
|
2325
|
-
throw new LLMError("Invalid UTF-8 in SSE stream", "parse",
|
|
2790
|
+
throw new LLMError("Invalid UTF-8 in SSE stream", "parse", { cause });
|
|
2326
2791
|
}
|
|
2327
2792
|
buffer = buffer.replace(/\r$/, "\n");
|
|
2328
2793
|
let boundary = buffer.indexOf("\n\n");
|
|
@@ -2367,7 +2832,10 @@ function parseSseFrame(frame) {
|
|
|
2367
2832
|
try {
|
|
2368
2833
|
return JSON.parse(data);
|
|
2369
2834
|
} catch (cause) {
|
|
2370
|
-
throw new LLMError(`Invalid JSON in SSE frame: ${data.slice(0, 200)}`, "parse",
|
|
2835
|
+
throw new LLMError(`Invalid JSON in SSE frame: ${data.slice(0, 200)}`, "parse", {
|
|
2836
|
+
cause,
|
|
2837
|
+
code: "stream_frame_invalid"
|
|
2838
|
+
});
|
|
2371
2839
|
}
|
|
2372
2840
|
}
|
|
2373
2841
|
|
|
@@ -2387,13 +2855,14 @@ const SUPPORTED_IMAGE_MIME_TYPES = [
|
|
|
2387
2855
|
];
|
|
2388
2856
|
/**
|
|
2389
2857
|
* Validates an `ImageBlock.mimeType` against the shared supported set.
|
|
2390
|
-
* Throws a non-retryable `LLMError('
|
|
2391
|
-
* mimeType is a
|
|
2392
|
-
* the same
|
|
2858
|
+
* Throws a non-retryable `LLMError('invalid_params')`, since an unsupported
|
|
2859
|
+
* mimeType is a bug in the caller's own input, deterministic before any
|
|
2860
|
+
* request is built, the same class of failure as every other check in
|
|
2861
|
+
* `RequestBuilder`.
|
|
2393
2862
|
*/
|
|
2394
2863
|
function assertSupportedImageMimeType(mimeType) {
|
|
2395
2864
|
if (SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType)) return mimeType;
|
|
2396
|
-
throw new LLMError(`Unsupported image mimeType "${mimeType}": expected one of ${SUPPORTED_IMAGE_MIME_TYPES.join(", ")}`, "
|
|
2865
|
+
throw new LLMError(`Unsupported image mimeType "${mimeType}": expected one of ${SUPPORTED_IMAGE_MIME_TYPES.join(", ")}`, "invalid_params");
|
|
2397
2866
|
}
|
|
2398
2867
|
|
|
2399
2868
|
//#endregion
|
|
@@ -2706,7 +3175,7 @@ function toAnthropicMessage(m) {
|
|
|
2706
3175
|
try {
|
|
2707
3176
|
input = tc.function.arguments.trim() ? JSON.parse(tc.function.arguments) : {};
|
|
2708
3177
|
} catch (cause) {
|
|
2709
|
-
throw new LLMError(`Assistant tool call "${tc.function.name}" (${tc.id}) has arguments that are not valid JSON.`, "validation",
|
|
3178
|
+
throw new LLMError(`Assistant tool call "${tc.function.name}" (${tc.id}) has arguments that are not valid JSON.`, "validation", { cause });
|
|
2710
3179
|
}
|
|
2711
3180
|
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
3181
|
blocks.push({
|
|
@@ -2787,7 +3256,10 @@ function parseToolArguments(text, toolName) {
|
|
|
2787
3256
|
try {
|
|
2788
3257
|
parsed = text.trim() ? JSON.parse(text) : {};
|
|
2789
3258
|
} catch (cause) {
|
|
2790
|
-
throw new LLMError(`Tool call "${toolName}" arguments are not valid JSON.`, "
|
|
3259
|
+
throw new LLMError(`Tool call "${toolName}" arguments are not valid JSON.`, "parse", {
|
|
3260
|
+
cause,
|
|
3261
|
+
code: "tool_arguments_parse_failed"
|
|
3262
|
+
});
|
|
2791
3263
|
}
|
|
2792
3264
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new LLMError(`Tool call "${toolName}" arguments must be a JSON object.`, "validation");
|
|
2793
3265
|
return parsed;
|
|
@@ -2921,7 +3393,10 @@ function fromGemini(geminiClient) {
|
|
|
2921
3393
|
};
|
|
2922
3394
|
},
|
|
2923
3395
|
async *createStream(params, options) {
|
|
2924
|
-
if (!geminiClient.generateContentStream) throw new LLMError("stream: true requires a Gemini client with generateContentStream", "
|
|
3396
|
+
if (!geminiClient.generateContentStream) throw new LLMError("stream: true requires a Gemini client with generateContentStream", "invalid_params", {
|
|
3397
|
+
code: "unsupported_capability",
|
|
3398
|
+
issues: { capability: "generateContentStream" }
|
|
3399
|
+
});
|
|
2925
3400
|
const request = buildGeminiRequest(params);
|
|
2926
3401
|
request.config = {
|
|
2927
3402
|
...request.config,
|
|
@@ -3069,7 +3544,10 @@ function buildBedrockRequest(params, toolUseSupportedModels, nativeStructuredOut
|
|
|
3069
3544
|
if (params.tools?.length && !toolName) toolConfig = buildBedrockToolConfig(params.tools, params.tool_choice);
|
|
3070
3545
|
if (jsonSchema && toolConfig && toolUseSupportedModels) {
|
|
3071
3546
|
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).`, "
|
|
3547
|
+
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", {
|
|
3548
|
+
code: "unsupported_capability",
|
|
3549
|
+
issues: { capability: "toolUseSupportedModels" }
|
|
3550
|
+
});
|
|
3073
3551
|
}
|
|
3074
3552
|
const systemParts = [systemMessage?.content, jsonInstruction].filter((s) => Boolean(s));
|
|
3075
3553
|
const request = {
|
|
@@ -3181,7 +3659,10 @@ function fromBedrock(bedrockClient, options) {
|
|
|
3181
3659
|
};
|
|
3182
3660
|
},
|
|
3183
3661
|
async *createStream(params, requestOptions) {
|
|
3184
|
-
if (!bedrockClient.converseStream) throw new LLMError("stream: true requires a Bedrock client with converseStream", "
|
|
3662
|
+
if (!bedrockClient.converseStream) throw new LLMError("stream: true requires a Bedrock client with converseStream", "invalid_params", {
|
|
3663
|
+
code: "unsupported_capability",
|
|
3664
|
+
issues: { capability: "converseStream" }
|
|
3665
|
+
});
|
|
3185
3666
|
const { request, toolName } = buildBedrockRequest(params, toolUseSupportedModels, nativeStructuredOutputModels);
|
|
3186
3667
|
const { stream } = await bedrockClient.converseStream(request, requestOptions);
|
|
3187
3668
|
const blockKinds = new Map();
|
|
@@ -3223,12 +3704,18 @@ function fromBedrock(bedrockClient, options) {
|
|
|
3223
3704
|
total_tokens: event.metadata.usage.totalTokens
|
|
3224
3705
|
}
|
|
3225
3706
|
};
|
|
3226
|
-
else if ("throttlingException" in event) throw new LLMError(event.throttlingException.message ?? "Bedrock throttled the request mid-stream", "api",
|
|
3707
|
+
else if ("throttlingException" in event) throw new LLMError(event.throttlingException.message ?? "Bedrock throttled the request mid-stream", "api", {
|
|
3708
|
+
status: 429,
|
|
3709
|
+
code: "provider_rate_limited"
|
|
3710
|
+
});
|
|
3227
3711
|
else if ("validationException" in event) throw new LLMError(event.validationException.message ?? "Bedrock rejected the request mid-stream", "validation");
|
|
3228
3712
|
else if ("internalServerException" in event || "serviceUnavailableException" in event || "modelStreamErrorException" in event) {
|
|
3229
3713
|
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
3714
|
const status = "modelStreamErrorException" in event && event.modelStreamErrorException.originalStatusCode || "serviceUnavailableException" in event && 503 || 500;
|
|
3231
|
-
throw new LLMError(detail, "api",
|
|
3715
|
+
throw new LLMError(detail, "api", {
|
|
3716
|
+
status,
|
|
3717
|
+
code: status >= 500 ? "server_error" : void 0
|
|
3718
|
+
});
|
|
3232
3719
|
}
|
|
3233
3720
|
}
|
|
3234
3721
|
} } };
|
|
@@ -3237,7 +3724,10 @@ function fromBedrock(bedrockClient, options) {
|
|
|
3237
3724
|
function toBedrockToolChoice(toolChoice) {
|
|
3238
3725
|
if (!toolChoice || toolChoice === "auto") return { auto: {} };
|
|
3239
3726
|
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.", "
|
|
3727
|
+
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", {
|
|
3728
|
+
code: "unsupported_capability",
|
|
3729
|
+
issues: { capability: "toolChoice: 'none'" }
|
|
3730
|
+
});
|
|
3241
3731
|
return { tool: { name: toolChoice.function.name } };
|
|
3242
3732
|
}
|
|
3243
3733
|
/**
|
|
@@ -3262,7 +3752,7 @@ function toBedrockMessage(m) {
|
|
|
3262
3752
|
else try {
|
|
3263
3753
|
input = JSON.parse(tc.function.arguments);
|
|
3264
3754
|
} catch (cause) {
|
|
3265
|
-
throw new LLMError(`Assistant tool call "${tc.function.name}" (${tc.id}) has arguments that are not valid JSON.`, "validation",
|
|
3755
|
+
throw new LLMError(`Assistant tool call "${tc.function.name}" (${tc.id}) has arguments that are not valid JSON.`, "validation", { cause });
|
|
3266
3756
|
}
|
|
3267
3757
|
blocks.push({ toolUse: {
|
|
3268
3758
|
toolUseId: tc.id,
|
|
@@ -3433,8 +3923,14 @@ function fromFetch(config) {
|
|
|
3433
3923
|
};
|
|
3434
3924
|
},
|
|
3435
3925
|
async *createStream(params, options) {
|
|
3436
|
-
if (!config.mapStreamEvent) throw new LLMError("stream: true requires mapStreamEvent to be configured on fromFetch", "
|
|
3437
|
-
|
|
3926
|
+
if (!config.mapStreamEvent) throw new LLMError("stream: true requires mapStreamEvent to be configured on fromFetch", "invalid_params", {
|
|
3927
|
+
code: "unsupported_capability",
|
|
3928
|
+
issues: { capability: "mapStreamEvent" }
|
|
3929
|
+
});
|
|
3930
|
+
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", {
|
|
3931
|
+
code: "unsupported_capability",
|
|
3932
|
+
issues: { capability: "requestStream" }
|
|
3933
|
+
});
|
|
3438
3934
|
const { url, method, headers, body } = await buildRequestInit(config, params, config.mapRequest(params));
|
|
3439
3935
|
const requestStream = config.requestStream ?? defaultRequestStream;
|
|
3440
3936
|
const parseFrames = config.parseStreamFrames ?? parseSseStream;
|
|
@@ -3618,8 +4114,6 @@ const fromNvidiaNIM = fromOpenAICompatible;
|
|
|
3618
4114
|
const fromVercelAIGateway = fromOpenAICompatible;
|
|
3619
4115
|
/** Cloudflare Workers AI exposes an OpenAI-compatible endpoint */
|
|
3620
4116
|
const fromCloudflareWorkersAI = fromOpenAICompatible;
|
|
3621
|
-
/** GitHub Models is OpenAI-compatible */
|
|
3622
|
-
const fromGitHubModels = fromOpenAICompatible;
|
|
3623
4117
|
/** Nebius AI Studio is OpenAI-compatible */
|
|
3624
4118
|
const fromNebius = fromOpenAICompatible;
|
|
3625
4119
|
/** SambaNova Cloud's API is OpenAI-compatible */
|
|
@@ -3646,8 +4140,6 @@ const fromSnowflakeCortex = fromOpenAICompatible;
|
|
|
3646
4140
|
const fromAnyscale = fromOpenAICompatible;
|
|
3647
4141
|
/** Lepton AI's inference API is OpenAI-compatible */
|
|
3648
4142
|
const fromLepton = fromOpenAICompatible;
|
|
3649
|
-
/** kluster.ai's inference API is OpenAI-compatible */
|
|
3650
|
-
const fromKlusterAI = fromOpenAICompatible;
|
|
3651
4143
|
/** Inference.net's API is OpenAI-compatible */
|
|
3652
4144
|
const fromInferenceNet = fromOpenAICompatible;
|
|
3653
4145
|
/** Infermatic's API is OpenAI-compatible */
|
|
@@ -3658,5 +4150,5 @@ const fromAtlasCloud = fromOpenAICompatible;
|
|
|
3658
4150
|
const from01AI = fromOpenAICompatible;
|
|
3659
4151
|
|
|
3660
4152
|
//#endregion
|
|
3661
|
-
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,
|
|
4153
|
+
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, fromGroq, fromHyperbolic, fromInferenceNet, fromInfermatic, fromLMStudio, fromLambdaLabs, fromLepton, fromMiniMax, fromMistral, fromMoonshot, fromNebius, fromNovita, fromNvidiaNIM, fromOllama, fromOpenAI, fromOpenAICompatible, fromOpenRouter, fromParasail, fromPerplexity, fromSambaNova, fromSiliconFlow, fromSnowflakeCortex, fromStepFun, fromTogether, fromVLLM, fromVercelAIGateway, fromXAI, fromZhipu, hasIssues, isFallbackExhaustedError, isLLMError, isToolCallResult, parseSseStream };
|
|
3662
4154
|
//# sourceMappingURL=index.js.map
|