vern-llm 1.4.0 → 1.6.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 +4 -15
- package/dist/index.cjs +116 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -36
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +50 -36
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +116 -36
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,14 +20,16 @@
|
|
|
20
20
|
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
|
-
Production-ready resilience for LLM calls
|
|
23
|
+
<p align="center">Production-ready resilience for LLM calls</p>
|
|
24
|
+
|
|
25
|
+
Retries, timeouts, caching, and circuit breaking behind one typed interface, with adapters for OpenAI-compatible APIs (OpenAI, Groq, and more), Anthropic, Gemini, and Bedrock.
|
|
24
26
|
|
|
25
27
|
**Full documentation: [vernllm.vercel.app](https://vernllm.vercel.app)** — installation, structured output, caching, circuit breaker, every adapter, and the complete API reference all live there and are kept up to date. This README is a quick pitch, not the manual.
|
|
26
28
|
|
|
27
29
|
## Install
|
|
28
30
|
|
|
29
31
|
```bash
|
|
30
|
-
pnpm add vern-llm
|
|
32
|
+
pnpm add vern-llm
|
|
31
33
|
```
|
|
32
34
|
|
|
33
35
|
## Quick start
|
|
@@ -62,19 +64,6 @@ const result = await llm.call({
|
|
|
62
64
|
|
|
63
65
|
See the [docs](https://vernllm.vercel.app) for adapter setup, caching, the circuit breaker, and structured output in depth.
|
|
64
66
|
|
|
65
|
-
## Development
|
|
66
|
-
|
|
67
|
-
```bash
|
|
68
|
-
pnpm install
|
|
69
|
-
pnpm run build # tsdown → dist (ESM + CJS + types)
|
|
70
|
-
pnpm run typecheck # tsc --noEmit on src, since tsdown doesn't fully type-check
|
|
71
|
-
pnpm run test # vitest run
|
|
72
|
-
pnpm run test:coverage # vitest run --coverage (v8 provider)
|
|
73
|
-
pnpm run changeset # record a change for the next release
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Tests live in `tests/`, mirroring `src/`, and cover retry/backoff/timeout/abort/schema/model-override/usage behavior, the circuit breaker (unit + integration), caching, the injectable logger, and every provider adapter's request/response translation against a fake client — no real API calls anywhere in the suite.
|
|
77
|
-
|
|
78
67
|
## License
|
|
79
68
|
|
|
80
69
|
[MIT](https://github.com/LakBud/vernLLM/blob/main/LICENSE.md) © LakBud
|
package/dist/index.cjs
CHANGED
|
@@ -26,11 +26,13 @@ const crypto = __toESM(require("crypto"));
|
|
|
26
26
|
|
|
27
27
|
//#region src/types/errors.ts
|
|
28
28
|
var LLMError = class extends Error {
|
|
29
|
-
constructor(message, type, status, issues) {
|
|
29
|
+
constructor(message, type, status, issues, cause, retryAfterMs) {
|
|
30
30
|
super(message);
|
|
31
31
|
this.type = type;
|
|
32
32
|
this.status = status;
|
|
33
33
|
this.issues = issues;
|
|
34
|
+
this.cause = cause;
|
|
35
|
+
this.retryAfterMs = retryAfterMs;
|
|
34
36
|
this.name = "LLMError";
|
|
35
37
|
}
|
|
36
38
|
};
|
|
@@ -126,6 +128,32 @@ function extractStatus(err) {
|
|
|
126
128
|
if (typeof error.statusCode === "number") return error.statusCode;
|
|
127
129
|
return void 0;
|
|
128
130
|
}
|
|
131
|
+
function formatSafely(value) {
|
|
132
|
+
try {
|
|
133
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
134
|
+
} catch {
|
|
135
|
+
try {
|
|
136
|
+
return String(value);
|
|
137
|
+
} catch {
|
|
138
|
+
return "[unprintable error]";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Looks inside an unknown thrown value and pulls out a human-readable
|
|
144
|
+
* description of it. Checks the `error` field first (the provider's raw
|
|
145
|
+
* rejection body, JSON-stringified if possible) then falls back to the
|
|
146
|
+
* message` field. Always returns a safe string, even when the thrown value
|
|
147
|
+
* has hostile properties or cannot be serialized normally.
|
|
148
|
+
*/
|
|
149
|
+
function describeError(err) {
|
|
150
|
+
if (err && typeof err === "object") try {
|
|
151
|
+
const error = err;
|
|
152
|
+
if (error.error !== void 0) return formatSafely(error.error);
|
|
153
|
+
if (typeof error.message === "string") return error.message;
|
|
154
|
+
} catch {}
|
|
155
|
+
return formatSafely(err);
|
|
156
|
+
}
|
|
129
157
|
/**
|
|
130
158
|
* Runs an async function and cancels it if it takes longer than the given
|
|
131
159
|
* timeout. Creates an internal abort controller that fires after the
|
|
@@ -382,13 +410,16 @@ var VernLLM = class {
|
|
|
382
410
|
this.breaker?.assertClosed();
|
|
383
411
|
if (params.signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
|
|
384
412
|
const requestId = params.requestId ?? (0, crypto.randomUUID)();
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
413
|
+
return this.withReservedUsage(params, false, async () => {
|
|
414
|
+
try {
|
|
415
|
+
return await this.retryWithBackoff(() => this.executeCall(params, requestId), requestId, params.signal);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
const normalized = this.normalizeError(error, params.signal);
|
|
418
|
+
if (normalized.type !== "validation" && normalized.type !== "parse" && normalized.type !== "aborted") this.breaker?.recordFailure();
|
|
419
|
+
this.logger.debug(`[vern:${requestId}] error:\n${describeError(error)}`);
|
|
420
|
+
throw normalized;
|
|
421
|
+
}
|
|
422
|
+
}, params.signal);
|
|
392
423
|
}
|
|
393
424
|
/**
|
|
394
425
|
* Runs `fn`, retrying with backoff according to `shouldRetry` policy
|
|
@@ -416,8 +447,9 @@ var VernLLM = class {
|
|
|
416
447
|
if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
|
|
417
448
|
if (error instanceof LLMError) return error;
|
|
418
449
|
const status = extractStatus(error);
|
|
419
|
-
|
|
420
|
-
return new LLMError("LLM request failed", "
|
|
450
|
+
const retryAfterMs = extractRetryAfterMs(error);
|
|
451
|
+
if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs);
|
|
452
|
+
return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
|
|
421
453
|
}
|
|
422
454
|
/**
|
|
423
455
|
* Performs a single attempt: builds the request, dispatches it with a
|
|
@@ -432,9 +464,13 @@ var VernLLM = class {
|
|
|
432
464
|
if (!content) throw new LLMError("Empty LLM response", "api");
|
|
433
465
|
this.logger.debug(`[vern:${requestId}] output:\n${content.slice(0, 800)}`);
|
|
434
466
|
this.recordUsage(response, requestId, model);
|
|
467
|
+
if (!useJson) {
|
|
468
|
+
this.breaker?.recordSuccess();
|
|
469
|
+
return content;
|
|
470
|
+
}
|
|
471
|
+
const result = this.parseAndValidate(content, params.schema);
|
|
435
472
|
this.breaker?.recordSuccess();
|
|
436
|
-
|
|
437
|
-
return this.parseAndValidate(content, params.schema);
|
|
473
|
+
return result;
|
|
438
474
|
}
|
|
439
475
|
/**
|
|
440
476
|
* Anthropic and Gemini both require strict user/assistant alternation
|
|
@@ -462,6 +498,7 @@ var VernLLM = class {
|
|
|
462
498
|
buildRequestPayload(params) {
|
|
463
499
|
const { systemPrompt, userContent, history = [], temperature = .2, jsonMode = true, maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema } = params;
|
|
464
500
|
const useJson = jsonMode || Boolean(jsonSchema);
|
|
501
|
+
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");
|
|
465
502
|
const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
|
|
466
503
|
this.validateHistory(history);
|
|
467
504
|
const request = {
|
|
@@ -514,16 +551,24 @@ var VernLLM = class {
|
|
|
514
551
|
* Reports token usage to the caller supplied onUsage callback, when
|
|
515
552
|
* both a callback was configured and the provider actually returned
|
|
516
553
|
* usage data on this response. A no op otherwise.
|
|
554
|
+
*
|
|
555
|
+
* A throwing onUsage callback is logged and swallowed rather than
|
|
556
|
+
* propagated, so a broken billing/metrics hook can't fail or retrigger
|
|
557
|
+
* retries on an otherwise-successful call.
|
|
517
558
|
*/
|
|
518
559
|
recordUsage(response, requestId, model) {
|
|
519
560
|
if (!response.usage || !this.onUsage) return;
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
561
|
+
try {
|
|
562
|
+
this.onUsage({
|
|
563
|
+
promptTokens: response.usage.prompt_tokens ?? 0,
|
|
564
|
+
completionTokens: response.usage.completion_tokens ?? 0,
|
|
565
|
+
totalTokens: response.usage.total_tokens ?? 0,
|
|
566
|
+
requestId,
|
|
567
|
+
model
|
|
568
|
+
});
|
|
569
|
+
} catch (error) {
|
|
570
|
+
this.logger.error("[VernLLM] onUsage failed", { message: error instanceof Error ? error.message : "unknown" });
|
|
571
|
+
}
|
|
527
572
|
}
|
|
528
573
|
/**
|
|
529
574
|
* Parses the raw response content as JSON and, when a schema is
|
|
@@ -597,19 +642,12 @@ var VernLLM = class {
|
|
|
597
642
|
if (cached.hit) return cached.value;
|
|
598
643
|
const existing = this.inFlight.get(params.cacheKey);
|
|
599
644
|
const coalesced = existing !== void 0;
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
await params.reserveUsage?.({ coalesced });
|
|
603
|
-
return resultPromise;
|
|
604
|
-
});
|
|
605
|
-
return this.withRefundOnFailure(params, coalesced, () => resultPromise);
|
|
645
|
+
if (coalesced) return this.withReservedUsage(params, coalesced, () => existing, params.signal);
|
|
646
|
+
return this.registerTrigger(params, coalesced);
|
|
606
647
|
}
|
|
607
648
|
/** Starts the shared fn() call for a cache miss, reserving usage first, and registers it in the in-flight map until it settles */
|
|
608
649
|
registerTrigger(params, coalesced) {
|
|
609
|
-
const resultPromise = (
|
|
610
|
-
await params.reserveUsage?.({ coalesced });
|
|
611
|
-
return this.runAndCache(params);
|
|
612
|
-
})();
|
|
650
|
+
const resultPromise = this.withReservedUsage(params, coalesced, () => this.runAndCache(params), params.signal);
|
|
613
651
|
this.inFlight.set(params.cacheKey, resultPromise);
|
|
614
652
|
resultPromise.catch(() => {}).finally(() => {
|
|
615
653
|
this.inFlight.delete(params.cacheKey);
|
|
@@ -626,13 +664,48 @@ var VernLLM = class {
|
|
|
626
664
|
}
|
|
627
665
|
return result;
|
|
628
666
|
}
|
|
629
|
-
/**
|
|
630
|
-
|
|
667
|
+
/**
|
|
668
|
+
* Runs `getResult` after reserving usage, if a `reserveUsage` hook was
|
|
669
|
+
* provided. `refundUsage` fires only if a reservation was actually made,
|
|
670
|
+
* i.e. `reserveUsage` was provided and it resolved successfully. If
|
|
671
|
+
* `reserveUsage` is omitted entirely, or if it throws, there is nothing to
|
|
672
|
+
* refund, so `refundUsage` is not invoked in either case.
|
|
673
|
+
*
|
|
674
|
+
* Shared by `cachedCall()`/`registerTrigger()` and `call()`, so it only
|
|
675
|
+
* depends on the usage hooks rather than LLM request concerns.
|
|
676
|
+
*/
|
|
677
|
+
async withReservedUsage(params, coalesced, getResult, signal) {
|
|
678
|
+
let reserved = false;
|
|
631
679
|
try {
|
|
632
|
-
|
|
680
|
+
if (params.reserveUsage) {
|
|
681
|
+
await params.reserveUsage({
|
|
682
|
+
coalesced,
|
|
683
|
+
signal
|
|
684
|
+
});
|
|
685
|
+
reserved = true;
|
|
686
|
+
}
|
|
633
687
|
} catch (error) {
|
|
634
|
-
|
|
635
|
-
|
|
688
|
+
throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", void 0, void 0, error);
|
|
689
|
+
}
|
|
690
|
+
if (signal?.aborted) {
|
|
691
|
+
if (reserved) try {
|
|
692
|
+
await params.refundUsage?.({
|
|
693
|
+
coalesced,
|
|
694
|
+
signal
|
|
695
|
+
});
|
|
696
|
+
} catch (refundError) {
|
|
697
|
+
this.logger.error("[VernLLM] refundUsage failed after abort", { message: refundError instanceof Error ? refundError.message : "unknown" });
|
|
698
|
+
}
|
|
699
|
+
throw new LLMError("LLM request aborted", "aborted");
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
return await getResult();
|
|
703
|
+
} catch (error) {
|
|
704
|
+
if (reserved) try {
|
|
705
|
+
await params.refundUsage?.({
|
|
706
|
+
coalesced,
|
|
707
|
+
signal
|
|
708
|
+
});
|
|
636
709
|
} catch (refundError) {
|
|
637
710
|
this.logger.error("[VernLLM] refundUsage failed", { message: refundError instanceof Error ? refundError.message : "unknown" });
|
|
638
711
|
}
|
|
@@ -642,13 +715,20 @@ var VernLLM = class {
|
|
|
642
715
|
/**
|
|
643
716
|
* Convenience wrapper composing `call` + `cachedCall`, so cached LLM calls
|
|
644
717
|
* automatically get retry/timeout/circuit-breaker behavior without callers
|
|
645
|
-
* having to remember to wire `fn: () => this.call(...)` themselves
|
|
718
|
+
* having to remember to wire `fn: () => this.call(...)` themselves.
|
|
719
|
+
*
|
|
720
|
+
* `reserveUsage`/`refundUsage` are read from the top-level params only — if
|
|
721
|
+
* `call` also sets them, they're ignored, since `call()` now performs its
|
|
722
|
+
* own reservation. Honoring both would reserve/refund twice per logical
|
|
723
|
+
* cachedLLMCall (once via cachedCall's wrapping, once via the inner call()).
|
|
646
724
|
*/
|
|
647
725
|
async cachedLLMCall(params) {
|
|
648
726
|
const { call: callParams,...cacheParams } = params;
|
|
727
|
+
const { reserveUsage: _innerReserveUsage, refundUsage: _innerRefundUsage,...restCallParams } = callParams;
|
|
728
|
+
if (_innerReserveUsage || _innerRefundUsage) this.logger.warn("[VernLLM] reserveUsage/refundUsage on `call` are ignored by cachedLLMCall; set them at the top level instead.");
|
|
649
729
|
return this.cachedCall({
|
|
650
730
|
...cacheParams,
|
|
651
|
-
fn: () => this.call(
|
|
731
|
+
fn: () => this.call(restCallParams)
|
|
652
732
|
});
|
|
653
733
|
}
|
|
654
734
|
/**
|