vern-llm 1.3.0 → 1.5.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 -2
- package/dist/index.cjs +280 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +255 -133
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +255 -133
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +280 -45
- 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
|
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
|
};
|
|
@@ -52,26 +54,43 @@ var CircuitBreaker = class {
|
|
|
52
54
|
openedAt = 0;
|
|
53
55
|
threshold;
|
|
54
56
|
cooldownMs;
|
|
57
|
+
/**
|
|
58
|
+
* True while a single half-open trial call is in flight. Guards against
|
|
59
|
+
* multiple concurrent callers all treating themselves as "the" trial once
|
|
60
|
+
* the cooldown elapses
|
|
61
|
+
*/
|
|
62
|
+
trialInFlight = false;
|
|
55
63
|
constructor(options = {}) {
|
|
56
64
|
this.threshold = options.threshold ?? 5;
|
|
57
65
|
this.cooldownMs = options.cooldownMs ?? 3e4;
|
|
58
66
|
}
|
|
59
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Throws if the circuit is open and the cooldown hasn't elapsed, or if
|
|
69
|
+
* the circuit is half-open and a trial call is already in flight.
|
|
70
|
+
* Otherwise, if the circuit just became eligible for a trial (cooldown
|
|
71
|
+
* elapsed, or half-open with no trial currently running), this call
|
|
72
|
+
* becomes that trial
|
|
73
|
+
*/
|
|
60
74
|
assertClosed() {
|
|
61
|
-
if (this.state
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
if (this.state === "closed") return;
|
|
76
|
+
if (this.state === "open") {
|
|
77
|
+
const elapsed = Date.now() - this.openedAt;
|
|
78
|
+
if (elapsed < this.cooldownMs) throw new LLMError(`Circuit open — provider has failed ${this.consecutiveFailures} times in a row. Retry in ${Math.ceil((this.cooldownMs - elapsed) / 1e3)}s.`, "circuit_open");
|
|
64
79
|
this.state = "half-open";
|
|
80
|
+
this.trialInFlight = true;
|
|
65
81
|
return;
|
|
66
82
|
}
|
|
67
|
-
throw new LLMError(
|
|
83
|
+
if (this.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open");
|
|
84
|
+
this.trialInFlight = true;
|
|
68
85
|
}
|
|
69
86
|
recordSuccess() {
|
|
70
87
|
this.consecutiveFailures = 0;
|
|
71
88
|
this.state = "closed";
|
|
89
|
+
this.trialInFlight = false;
|
|
72
90
|
}
|
|
73
91
|
recordFailure() {
|
|
74
92
|
this.consecutiveFailures += 1;
|
|
93
|
+
this.trialInFlight = false;
|
|
75
94
|
if (this.state === "half-open") {
|
|
76
95
|
this.state = "open";
|
|
77
96
|
this.openedAt = Date.now();
|
|
@@ -109,6 +128,32 @@ function extractStatus(err) {
|
|
|
109
128
|
if (typeof error.statusCode === "number") return error.statusCode;
|
|
110
129
|
return void 0;
|
|
111
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
|
+
}
|
|
112
157
|
/**
|
|
113
158
|
* Runs an async function and cancels it if it takes longer than the given
|
|
114
159
|
* timeout. Creates an internal abort controller that fires after the
|
|
@@ -136,11 +181,40 @@ async function withTimeout(fn, timeoutMs, externalSignal) {
|
|
|
136
181
|
}
|
|
137
182
|
}
|
|
138
183
|
/**
|
|
184
|
+
* Default cap (ms) for both exponential backoff and honored Retry-After
|
|
185
|
+
* values, so a misbehaving/adversarial Retry-After can't stall a caller
|
|
186
|
+
* indefinitely
|
|
187
|
+
*/
|
|
188
|
+
const DEFAULT_MAX_DELAY_MS = 1e4;
|
|
189
|
+
/**
|
|
190
|
+
* Looks inside an unknown error value for a Retry-After header and
|
|
191
|
+
* converts it to milliseconds. Checks `.headers` first (fetch-style,
|
|
192
|
+
* Headers-like with `.get()`), then `.response.headers` (axios-style,
|
|
193
|
+
* plain object) since different client libraries surface headers
|
|
194
|
+
* differently. Supports both the delta-seconds form ("30") and the
|
|
195
|
+
* HTTP-date form ("Wed, 21 Oct 2015 07:28:00 GMT"). The result is capped
|
|
196
|
+
* at maxDelayMs. Returns undefined when no usable Retry-After is present
|
|
197
|
+
*/
|
|
198
|
+
function extractRetryAfterMs(err, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
|
|
199
|
+
if (!err || typeof err !== "object") return void 0;
|
|
200
|
+
const error = err;
|
|
201
|
+
const headers = error.headers ?? error.response?.headers;
|
|
202
|
+
if (!headers || typeof headers !== "object") return void 0;
|
|
203
|
+
const getter = headers;
|
|
204
|
+
const raw = typeof getter.get === "function" ? getter.get("Retry-After") : Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.at(1);
|
|
205
|
+
if (typeof raw !== "string" || raw.trim() === "") return void 0;
|
|
206
|
+
const trimmed = raw.trim();
|
|
207
|
+
if (/^\d+$/.test(trimmed)) return Math.max(0, Math.min(Number(trimmed) * 1e3, maxDelayMs));
|
|
208
|
+
const dateMs = Date.parse(trimmed);
|
|
209
|
+
if (!Number.isNaN(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), maxDelayMs));
|
|
210
|
+
return void 0;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
139
213
|
* Exponential backoff with jitter, capped at maxDelayMs.
|
|
140
214
|
* Jitter avoids thundering-herd retries when many callers back off in lockstep,
|
|
141
215
|
* the cap prevents unbounded delays when maxRetries is high
|
|
142
216
|
*/
|
|
143
|
-
function getBackoffDelay(baseDelayMs, attempt, maxDelayMs =
|
|
217
|
+
function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
|
|
144
218
|
const exp = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
145
219
|
return exp / 2 + Math.random() * (exp / 2);
|
|
146
220
|
}
|
|
@@ -258,6 +332,7 @@ var VernLLM = class {
|
|
|
258
332
|
defaultMaxTokens;
|
|
259
333
|
cache;
|
|
260
334
|
nonRetryableStatus;
|
|
335
|
+
inFlight = new Map();
|
|
261
336
|
parseJson;
|
|
262
337
|
onUsage;
|
|
263
338
|
logger;
|
|
@@ -340,7 +415,9 @@ var VernLLM = class {
|
|
|
340
415
|
return result;
|
|
341
416
|
} catch (error) {
|
|
342
417
|
this.breaker?.recordFailure();
|
|
343
|
-
|
|
418
|
+
const normalized = this.normalizeError(error, params.signal);
|
|
419
|
+
this.logger.debug(`[vern:${requestId}] error:\n${describeError(error)}`);
|
|
420
|
+
throw normalized;
|
|
344
421
|
}
|
|
345
422
|
}
|
|
346
423
|
/**
|
|
@@ -351,7 +428,7 @@ var VernLLM = class {
|
|
|
351
428
|
async retryWithBackoff(fn, requestId, signal) {
|
|
352
429
|
let lastError;
|
|
353
430
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
|
|
354
|
-
if (attempt > 0) await this.recoverDelay(requestId, attempt, signal);
|
|
431
|
+
if (attempt > 0) await this.recoverDelay(requestId, attempt, lastError, signal);
|
|
355
432
|
return await fn();
|
|
356
433
|
} catch (error) {
|
|
357
434
|
lastError = error;
|
|
@@ -369,8 +446,9 @@ var VernLLM = class {
|
|
|
369
446
|
if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
|
|
370
447
|
if (error instanceof LLMError) return error;
|
|
371
448
|
const status = extractStatus(error);
|
|
372
|
-
|
|
373
|
-
return new LLMError("LLM request failed", "
|
|
449
|
+
const retryAfterMs = extractRetryAfterMs(error);
|
|
450
|
+
if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs);
|
|
451
|
+
return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
|
|
374
452
|
}
|
|
375
453
|
/**
|
|
376
454
|
* Performs a single attempt: builds the request, dispatches it with a
|
|
@@ -415,6 +493,7 @@ var VernLLM = class {
|
|
|
415
493
|
buildRequestPayload(params) {
|
|
416
494
|
const { systemPrompt, userContent, history = [], temperature = .2, jsonMode = true, maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema } = params;
|
|
417
495
|
const useJson = jsonMode || Boolean(jsonSchema);
|
|
496
|
+
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");
|
|
418
497
|
const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
|
|
419
498
|
this.validateHistory(history);
|
|
420
499
|
const request = {
|
|
@@ -499,12 +578,16 @@ var VernLLM = class {
|
|
|
499
578
|
}
|
|
500
579
|
/**
|
|
501
580
|
* Waits out the backoff delay for a given retry attempt, logging the
|
|
502
|
-
* attempt for observability before the wait begins.
|
|
503
|
-
* the
|
|
581
|
+
* attempt for observability before the wait begins. Honors a
|
|
582
|
+
* Retry-After header on the failed attempt's error when present
|
|
583
|
+
* (capped at the same maxDelayMs as backoff), otherwise falls back to
|
|
584
|
+
* exponential backoff exactly as before. Rejects early if the signal
|
|
585
|
+
* aborts during the wait
|
|
504
586
|
*/
|
|
505
|
-
async recoverDelay(requestId, attempt, signal) {
|
|
506
|
-
const
|
|
507
|
-
|
|
587
|
+
async recoverDelay(requestId, attempt, error, signal) {
|
|
588
|
+
const retryAfterMs = extractRetryAfterMs(error);
|
|
589
|
+
const delay = retryAfterMs ?? getBackoffDelay(this.baseDelayMs, attempt);
|
|
590
|
+
this.logger.warn(`[vern:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterMs !== void 0 ? " (honoring Retry-After)" : ""));
|
|
508
591
|
await waitForRetry(delay, signal);
|
|
509
592
|
}
|
|
510
593
|
/**
|
|
@@ -533,26 +616,55 @@ var VernLLM = class {
|
|
|
533
616
|
await this.cache.delete(key);
|
|
534
617
|
}
|
|
535
618
|
/**
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
619
|
+
* Cache wrapper around caller-supplied logic. `params.fn` should invoke
|
|
620
|
+
* `this.call(...)` (see `cachedLLMCall`); retry/timeout handling is left
|
|
621
|
+
* to the caller.
|
|
622
|
+
*
|
|
623
|
+
* Concurrent misses for the same `cacheKey` share a single in-flight call,
|
|
624
|
+
* avoiding cache stampedes. Each caller still receives its own
|
|
625
|
+
* `reserveUsage`/`refundUsage` callbacks with coalescing metadata.
|
|
540
626
|
*/
|
|
541
627
|
async cachedCall(params) {
|
|
542
628
|
const cached = await this.cache.get(params.cacheKey);
|
|
543
629
|
if (cached.hit) return cached.value;
|
|
630
|
+
const existing = this.inFlight.get(params.cacheKey);
|
|
631
|
+
const coalesced = existing !== void 0;
|
|
632
|
+
const resultPromise = existing ?? this.registerTrigger(params, coalesced);
|
|
633
|
+
if (coalesced) return this.withRefundOnFailure(params, coalesced, async () => {
|
|
634
|
+
await params.reserveUsage?.({ coalesced });
|
|
635
|
+
return resultPromise;
|
|
636
|
+
});
|
|
637
|
+
return this.withRefundOnFailure(params, coalesced, () => resultPromise);
|
|
638
|
+
}
|
|
639
|
+
/** Starts the shared fn() call for a cache miss, reserving usage first, and registers it in the in-flight map until it settles */
|
|
640
|
+
registerTrigger(params, coalesced) {
|
|
641
|
+
const resultPromise = (async () => {
|
|
642
|
+
await params.reserveUsage?.({ coalesced });
|
|
643
|
+
return this.runAndCache(params);
|
|
644
|
+
})();
|
|
645
|
+
this.inFlight.set(params.cacheKey, resultPromise);
|
|
646
|
+
resultPromise.catch(() => {}).finally(() => {
|
|
647
|
+
this.inFlight.delete(params.cacheKey);
|
|
648
|
+
});
|
|
649
|
+
return resultPromise;
|
|
650
|
+
}
|
|
651
|
+
/** Runs `fn` and writes its result to the cache. Only ever called once per cacheKey per in-flight window, from registerTrigger */
|
|
652
|
+
async runAndCache(params) {
|
|
653
|
+
const result = await params.fn();
|
|
544
654
|
try {
|
|
545
|
-
await params.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
655
|
+
await this.cache.set(params.cacheKey, result, params.ttl);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
|
|
658
|
+
}
|
|
659
|
+
return result;
|
|
660
|
+
}
|
|
661
|
+
/** Awaits `run`, calling this caller's own refundUsage (tagged with whether it was coalesced) if it rejects, then rethrows the original error */
|
|
662
|
+
async withRefundOnFailure(params, coalesced, run) {
|
|
663
|
+
try {
|
|
664
|
+
return await run();
|
|
553
665
|
} catch (error) {
|
|
554
666
|
try {
|
|
555
|
-
await params.refundUsage?.();
|
|
667
|
+
await params.refundUsage?.({ coalesced });
|
|
556
668
|
} catch (refundError) {
|
|
557
669
|
this.logger.error("[VernLLM] refundUsage failed", { message: refundError instanceof Error ? refundError.message : "unknown" });
|
|
558
670
|
}
|
|
@@ -580,9 +692,53 @@ var VernLLM = class {
|
|
|
580
692
|
}
|
|
581
693
|
};
|
|
582
694
|
|
|
695
|
+
//#endregion
|
|
696
|
+
//#region src/internal/imageFormat.ts
|
|
697
|
+
/**
|
|
698
|
+
* MIME types accepted for `ImageBlock.mimeType` across all adapters. This is
|
|
699
|
+
* the intersection of what Anthropic, Gemini, OpenAI-compatible, and Bedrock
|
|
700
|
+
* Converse all natively support, so a `ContentBlock[]` that validates for
|
|
701
|
+
* one provider validates for all of them.
|
|
702
|
+
*/
|
|
703
|
+
const SUPPORTED_IMAGE_MIME_TYPES = [
|
|
704
|
+
"image/png",
|
|
705
|
+
"image/jpeg",
|
|
706
|
+
"image/gif",
|
|
707
|
+
"image/webp"
|
|
708
|
+
];
|
|
709
|
+
/**
|
|
710
|
+
* Validates an `ImageBlock.mimeType` against the shared supported set.
|
|
711
|
+
* Throws a non-retryable `LLMError('validation')`, since an unsupported
|
|
712
|
+
* mimeType is a permanent failure, retrying the same input can't fix it,
|
|
713
|
+
* the same way a schema-validation or JSON-parse failure isn't retried.
|
|
714
|
+
*/
|
|
715
|
+
function assertSupportedImageMimeType(mimeType) {
|
|
716
|
+
if (SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType)) return mimeType;
|
|
717
|
+
throw new LLMError(`Unsupported image mimeType "${mimeType}": expected one of ${SUPPORTED_IMAGE_MIME_TYPES.join(", ")}`, "validation");
|
|
718
|
+
}
|
|
719
|
+
|
|
583
720
|
//#endregion
|
|
584
721
|
//#region src/adapters/anthropic.ts
|
|
585
722
|
/**
|
|
723
|
+
* Translates a VernLLM `ContentBlock[]` (our provider-agnostic multimodal
|
|
724
|
+
* shape) into Anthropic's native content-block array: text blocks pass
|
|
725
|
+
* through as-is, image blocks become `{ type: 'image', source: { type:
|
|
726
|
+
* 'base64', media_type, data } }`.
|
|
727
|
+
*/
|
|
728
|
+
function toAnthropicContent(blocks) {
|
|
729
|
+
return blocks.map((block) => block.type === "image" ? {
|
|
730
|
+
type: "image",
|
|
731
|
+
source: {
|
|
732
|
+
type: "base64",
|
|
733
|
+
media_type: assertSupportedImageMimeType(block.mimeType),
|
|
734
|
+
data: block.data
|
|
735
|
+
}
|
|
736
|
+
} : {
|
|
737
|
+
type: "text",
|
|
738
|
+
text: block.text
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
586
742
|
* Wraps an Anthropic SDK client so it satisfies the same `LLMClient`
|
|
587
743
|
* interface VernLLM uses for OpenAI/Groq.
|
|
588
744
|
*
|
|
@@ -620,7 +776,7 @@ function fromAnthropic(anthropicClient) {
|
|
|
620
776
|
system: system || void 0,
|
|
621
777
|
messages: conversationMessages.map((m) => ({
|
|
622
778
|
role: m.role,
|
|
623
|
-
content: m.content
|
|
779
|
+
content: Array.isArray(m.content) ? toAnthropicContent(m.content) : m.content
|
|
624
780
|
})),
|
|
625
781
|
...tools ? {
|
|
626
782
|
tools,
|
|
@@ -649,6 +805,18 @@ function fromAnthropic(anthropicClient) {
|
|
|
649
805
|
//#endregion
|
|
650
806
|
//#region src/adapters/gemini.ts
|
|
651
807
|
/**
|
|
808
|
+
* Translates a VernLLM `ContentBlock[]` into Gemini's native `parts` array:
|
|
809
|
+
* text blocks become `{ text }`, image blocks become inline data parts
|
|
810
|
+
* (`{ inlineData: { mimeType, data } }`), Geminis shape for embedding raw
|
|
811
|
+
* base64 image bytes directly in the request.
|
|
812
|
+
*/
|
|
813
|
+
function toGeminiParts(blocks) {
|
|
814
|
+
return blocks.map((block) => block.type === "image" ? { inlineData: {
|
|
815
|
+
mimeType: assertSupportedImageMimeType(block.mimeType),
|
|
816
|
+
data: block.data
|
|
817
|
+
} } : { text: block.text });
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
652
820
|
* Wraps a Gemini client so it satisfies the `LLMClient` interface VernLLM
|
|
653
821
|
* uses for OpenAI/Groq. Geminis shape differs on nearly every axis: a
|
|
654
822
|
* `contents` array instead of `messages`, a separate `systemInstruction`
|
|
@@ -674,7 +842,7 @@ function fromGemini(geminiClient) {
|
|
|
674
842
|
model: params.model,
|
|
675
843
|
contents: conversationMessages.map((m) => ({
|
|
676
844
|
role: m.role === "assistant" ? "model" : "user",
|
|
677
|
-
parts: [{ text: m.content }]
|
|
845
|
+
parts: Array.isArray(m.content) ? toGeminiParts(m.content) : [{ text: m.content }]
|
|
678
846
|
})),
|
|
679
847
|
systemInstruction: systemMessage ? { parts: [{ text: systemMessage.content }] } : void 0,
|
|
680
848
|
generationConfig
|
|
@@ -693,6 +861,36 @@ function fromGemini(geminiClient) {
|
|
|
693
861
|
|
|
694
862
|
//#endregion
|
|
695
863
|
//#region src/adapters/bedrock.ts
|
|
864
|
+
/** Maps a `ContentBlock` image MIME type, already validated, to Converse's `format` enum. */
|
|
865
|
+
function toBedrockImageFormat(mimeType) {
|
|
866
|
+
switch (assertSupportedImageMimeType(mimeType)) {
|
|
867
|
+
case "image/png": return "png";
|
|
868
|
+
case "image/jpeg": return "jpeg";
|
|
869
|
+
case "image/gif": return "gif";
|
|
870
|
+
case "image/webp": return "webp";
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Decodes base64 image data into the raw `Uint8Array` bytes Converse's
|
|
875
|
+
* `image.source.bytes` expects (unlike Anthropic/Gemini/OpenAI, which all
|
|
876
|
+
* take base64 strings directly). Uses `Buffer`, since this adapter, like
|
|
877
|
+
* the rest of the package, targets Node.
|
|
878
|
+
*/
|
|
879
|
+
function decodeBase64(data) {
|
|
880
|
+
return new Uint8Array(Buffer.from(data, "base64"));
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Translates a VernLLM `ContentBlock[]` into Converse's native content-block
|
|
884
|
+
* array: text blocks pass through as `{ text }`, image blocks become
|
|
885
|
+
* `{ image: { format, source: { bytes } } }` with the base64 payload decoded
|
|
886
|
+
* to raw bytes, since Converse doesn't accept base64 strings directly.
|
|
887
|
+
*/
|
|
888
|
+
function toBedrockContent(blocks) {
|
|
889
|
+
return blocks.map((block) => block.type === "image" ? { image: {
|
|
890
|
+
format: toBedrockImageFormat(block.mimeType),
|
|
891
|
+
source: { bytes: decodeBase64(block.data) }
|
|
892
|
+
} } : { text: block.text });
|
|
893
|
+
}
|
|
696
894
|
/**
|
|
697
895
|
* Wraps a Bedrock Converse-API client so it satisfies the `LLMClient`
|
|
698
896
|
* interface VernLLM uses for OpenAI/Groq. The Converse API is unified
|
|
@@ -734,7 +932,7 @@ function fromBedrock(bedrockClient) {
|
|
|
734
932
|
modelId: params.model,
|
|
735
933
|
messages: conversationMessages.map((m) => ({
|
|
736
934
|
role: m.role,
|
|
737
|
-
content: [{ text: m.content }]
|
|
935
|
+
content: Array.isArray(m.content) ? toBedrockContent(m.content) : [{ text: m.content }]
|
|
738
936
|
})),
|
|
739
937
|
system: systemParts.length ? systemParts.map((text$1) => ({ text: text$1 })) : void 0,
|
|
740
938
|
inferenceConfig: {
|
|
@@ -776,19 +974,23 @@ function fromFetch(config) {
|
|
|
776
974
|
return { chat: { completions: { async create(params, options) {
|
|
777
975
|
const url = typeof config.url === "function" ? config.url(params) : config.url;
|
|
778
976
|
const headers = typeof config.headers === "function" ? await config.headers() : config.headers;
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
977
|
+
const method = config.method ?? "POST";
|
|
978
|
+
const request = config.request ?? fetch;
|
|
979
|
+
const supportsBody = !["GET", "HEAD"].includes(method.toUpperCase());
|
|
980
|
+
const res = await request(url, {
|
|
981
|
+
method,
|
|
982
|
+
headers: supportsBody ? {
|
|
782
983
|
"Content-Type": "application/json",
|
|
783
984
|
...headers
|
|
784
|
-
},
|
|
785
|
-
body: JSON.stringify(config.mapRequest(params)),
|
|
985
|
+
} : { ...headers },
|
|
986
|
+
...supportsBody ? { body: JSON.stringify(config.mapRequest(params)) } : {},
|
|
786
987
|
signal: options.signal
|
|
787
988
|
});
|
|
788
989
|
if (!res.ok) {
|
|
789
990
|
const body = await res.text().catch(() => "");
|
|
790
991
|
const err = new Error(`Fetch adapter request failed (${res.status}): ${body.slice(0, 500)}`);
|
|
791
992
|
err.status = res.status;
|
|
993
|
+
err.headers = res.headers;
|
|
792
994
|
throw err;
|
|
793
995
|
}
|
|
794
996
|
const json = await res.json();
|
|
@@ -807,13 +1009,36 @@ function fromFetch(config) {
|
|
|
807
1009
|
//#endregion
|
|
808
1010
|
//#region src/adapters/openaiCompatible.ts
|
|
809
1011
|
/**
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
|
|
1012
|
+
* Translates a VernLLM `ContentBlock[]` into OpenAI's wire-level content
|
|
1013
|
+
* array. Text blocks become `{ type: 'text', text }`; image blocks become
|
|
1014
|
+
* `{ type: 'image_url', image_url: { url } }` with the base64 payload
|
|
1015
|
+
* inlined as a `data:` URL, since our `ContentBlock` shape (`{ type:
|
|
1016
|
+
* 'image', data, mimeType }`) is provider-agnostic and doesn't itself match
|
|
1017
|
+
* OpenAI's wire format.
|
|
1018
|
+
*/
|
|
1019
|
+
function toOpenAIContent(blocks) {
|
|
1020
|
+
return blocks.map((block) => block.type === "image" ? {
|
|
1021
|
+
type: "image_url",
|
|
1022
|
+
image_url: { url: `data:${assertSupportedImageMimeType(block.mimeType)};base64,${block.data}` }
|
|
1023
|
+
} : {
|
|
1024
|
+
type: "text",
|
|
1025
|
+
text: block.text
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Adapter for any SDK/client whose `chat.completions.create` already
|
|
1030
|
+
* matches the OpenAI wire format: this covers most hosted inference
|
|
1031
|
+
* providers, since "OpenAI-compatible" is a de facto standard for chat
|
|
1032
|
+
* completion APIs. Almost everything passes straight through untouched,
|
|
1033
|
+
* this exists purely so call sites read clearly (`fromMistral(client)` vs
|
|
1034
|
+
* handing a Mistral client to something typed for OpenAI) and so a real
|
|
1035
|
+
* transformation could be added later, per-provider, without a breaking
|
|
1036
|
+
* change.
|
|
1037
|
+
*
|
|
1038
|
+
* The one thing that isn't a pure passthrough: a `ContentBlock[]`
|
|
1039
|
+
* `userContent` is translated into OpenAI's native `image_url` content-part
|
|
1040
|
+
* shape, since VernLLM's `ContentBlock` is intentionally provider-agnostic
|
|
1041
|
+
* rather than a copy of any one provider's wire format.
|
|
817
1042
|
*
|
|
818
1043
|
* Not every SDKs own TypeScript types line up exactly with `LLMClient`
|
|
819
1044
|
* (extra fields, stricter unions, etc.), so this takes `unknown` and casts:
|
|
@@ -821,7 +1046,17 @@ function fromFetch(config) {
|
|
|
821
1046
|
* receives over the wire, not the SDKs TS types.
|
|
822
1047
|
*/
|
|
823
1048
|
function fromOpenAICompatible(client) {
|
|
824
|
-
|
|
1049
|
+
const raw = client;
|
|
1050
|
+
return { chat: { completions: { async create(params, options) {
|
|
1051
|
+
const messages = params.messages.map((m) => m.role === "user" && Array.isArray(m.content) ? {
|
|
1052
|
+
...m,
|
|
1053
|
+
content: toOpenAIContent(m.content)
|
|
1054
|
+
} : m);
|
|
1055
|
+
return raw.chat.completions.create({
|
|
1056
|
+
...params,
|
|
1057
|
+
messages
|
|
1058
|
+
}, options);
|
|
1059
|
+
} } } };
|
|
825
1060
|
}
|
|
826
1061
|
/** Groqs SDK matches the OpenAI wire format */
|
|
827
1062
|
const fromGroq = fromOpenAICompatible;
|