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/dist/index.js
CHANGED
|
@@ -2,11 +2,13 @@ import { randomUUID } from "crypto";
|
|
|
2
2
|
|
|
3
3
|
//#region src/types/errors.ts
|
|
4
4
|
var LLMError = class extends Error {
|
|
5
|
-
constructor(message, type, status, issues) {
|
|
5
|
+
constructor(message, type, status, issues, cause, retryAfterMs) {
|
|
6
6
|
super(message);
|
|
7
7
|
this.type = type;
|
|
8
8
|
this.status = status;
|
|
9
9
|
this.issues = issues;
|
|
10
|
+
this.cause = cause;
|
|
11
|
+
this.retryAfterMs = retryAfterMs;
|
|
10
12
|
this.name = "LLMError";
|
|
11
13
|
}
|
|
12
14
|
};
|
|
@@ -28,26 +30,43 @@ var CircuitBreaker = class {
|
|
|
28
30
|
openedAt = 0;
|
|
29
31
|
threshold;
|
|
30
32
|
cooldownMs;
|
|
33
|
+
/**
|
|
34
|
+
* True while a single half-open trial call is in flight. Guards against
|
|
35
|
+
* multiple concurrent callers all treating themselves as "the" trial once
|
|
36
|
+
* the cooldown elapses
|
|
37
|
+
*/
|
|
38
|
+
trialInFlight = false;
|
|
31
39
|
constructor(options = {}) {
|
|
32
40
|
this.threshold = options.threshold ?? 5;
|
|
33
41
|
this.cooldownMs = options.cooldownMs ?? 3e4;
|
|
34
42
|
}
|
|
35
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Throws if the circuit is open and the cooldown hasn't elapsed, or if
|
|
45
|
+
* the circuit is half-open and a trial call is already in flight.
|
|
46
|
+
* Otherwise, if the circuit just became eligible for a trial (cooldown
|
|
47
|
+
* elapsed, or half-open with no trial currently running), this call
|
|
48
|
+
* becomes that trial
|
|
49
|
+
*/
|
|
36
50
|
assertClosed() {
|
|
37
|
-
if (this.state
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
if (this.state === "closed") return;
|
|
52
|
+
if (this.state === "open") {
|
|
53
|
+
const elapsed = Date.now() - this.openedAt;
|
|
54
|
+
if (elapsed < this.cooldownMs) throw new LLMError(`Circuit open — provider has failed ${this.consecutiveFailures} times in a row. Retry in ${Math.ceil((this.cooldownMs - elapsed) / 1e3)}s.`, "circuit_open");
|
|
40
55
|
this.state = "half-open";
|
|
56
|
+
this.trialInFlight = true;
|
|
41
57
|
return;
|
|
42
58
|
}
|
|
43
|
-
throw new LLMError(
|
|
59
|
+
if (this.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open");
|
|
60
|
+
this.trialInFlight = true;
|
|
44
61
|
}
|
|
45
62
|
recordSuccess() {
|
|
46
63
|
this.consecutiveFailures = 0;
|
|
47
64
|
this.state = "closed";
|
|
65
|
+
this.trialInFlight = false;
|
|
48
66
|
}
|
|
49
67
|
recordFailure() {
|
|
50
68
|
this.consecutiveFailures += 1;
|
|
69
|
+
this.trialInFlight = false;
|
|
51
70
|
if (this.state === "half-open") {
|
|
52
71
|
this.state = "open";
|
|
53
72
|
this.openedAt = Date.now();
|
|
@@ -85,6 +104,32 @@ function extractStatus(err) {
|
|
|
85
104
|
if (typeof error.statusCode === "number") return error.statusCode;
|
|
86
105
|
return void 0;
|
|
87
106
|
}
|
|
107
|
+
function formatSafely(value) {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
110
|
+
} catch {
|
|
111
|
+
try {
|
|
112
|
+
return String(value);
|
|
113
|
+
} catch {
|
|
114
|
+
return "[unprintable error]";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Looks inside an unknown thrown value and pulls out a human-readable
|
|
120
|
+
* description of it. Checks the `error` field first (the provider's raw
|
|
121
|
+
* rejection body, JSON-stringified if possible) then falls back to the
|
|
122
|
+
* message` field. Always returns a safe string, even when the thrown value
|
|
123
|
+
* has hostile properties or cannot be serialized normally.
|
|
124
|
+
*/
|
|
125
|
+
function describeError(err) {
|
|
126
|
+
if (err && typeof err === "object") try {
|
|
127
|
+
const error = err;
|
|
128
|
+
if (error.error !== void 0) return formatSafely(error.error);
|
|
129
|
+
if (typeof error.message === "string") return error.message;
|
|
130
|
+
} catch {}
|
|
131
|
+
return formatSafely(err);
|
|
132
|
+
}
|
|
88
133
|
/**
|
|
89
134
|
* Runs an async function and cancels it if it takes longer than the given
|
|
90
135
|
* timeout. Creates an internal abort controller that fires after the
|
|
@@ -112,11 +157,40 @@ async function withTimeout(fn, timeoutMs, externalSignal) {
|
|
|
112
157
|
}
|
|
113
158
|
}
|
|
114
159
|
/**
|
|
160
|
+
* Default cap (ms) for both exponential backoff and honored Retry-After
|
|
161
|
+
* values, so a misbehaving/adversarial Retry-After can't stall a caller
|
|
162
|
+
* indefinitely
|
|
163
|
+
*/
|
|
164
|
+
const DEFAULT_MAX_DELAY_MS = 1e4;
|
|
165
|
+
/**
|
|
166
|
+
* Looks inside an unknown error value for a Retry-After header and
|
|
167
|
+
* converts it to milliseconds. Checks `.headers` first (fetch-style,
|
|
168
|
+
* Headers-like with `.get()`), then `.response.headers` (axios-style,
|
|
169
|
+
* plain object) since different client libraries surface headers
|
|
170
|
+
* differently. Supports both the delta-seconds form ("30") and the
|
|
171
|
+
* HTTP-date form ("Wed, 21 Oct 2015 07:28:00 GMT"). The result is capped
|
|
172
|
+
* at maxDelayMs. Returns undefined when no usable Retry-After is present
|
|
173
|
+
*/
|
|
174
|
+
function extractRetryAfterMs(err, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
|
|
175
|
+
if (!err || typeof err !== "object") return void 0;
|
|
176
|
+
const error = err;
|
|
177
|
+
const headers = error.headers ?? error.response?.headers;
|
|
178
|
+
if (!headers || typeof headers !== "object") return void 0;
|
|
179
|
+
const getter = headers;
|
|
180
|
+
const raw = typeof getter.get === "function" ? getter.get("Retry-After") : Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.at(1);
|
|
181
|
+
if (typeof raw !== "string" || raw.trim() === "") return void 0;
|
|
182
|
+
const trimmed = raw.trim();
|
|
183
|
+
if (/^\d+$/.test(trimmed)) return Math.max(0, Math.min(Number(trimmed) * 1e3, maxDelayMs));
|
|
184
|
+
const dateMs = Date.parse(trimmed);
|
|
185
|
+
if (!Number.isNaN(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), maxDelayMs));
|
|
186
|
+
return void 0;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
115
189
|
* Exponential backoff with jitter, capped at maxDelayMs.
|
|
116
190
|
* Jitter avoids thundering-herd retries when many callers back off in lockstep,
|
|
117
191
|
* the cap prevents unbounded delays when maxRetries is high
|
|
118
192
|
*/
|
|
119
|
-
function getBackoffDelay(baseDelayMs, attempt, maxDelayMs =
|
|
193
|
+
function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
|
|
120
194
|
const exp = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
121
195
|
return exp / 2 + Math.random() * (exp / 2);
|
|
122
196
|
}
|
|
@@ -234,6 +308,7 @@ var VernLLM = class {
|
|
|
234
308
|
defaultMaxTokens;
|
|
235
309
|
cache;
|
|
236
310
|
nonRetryableStatus;
|
|
311
|
+
inFlight = new Map();
|
|
237
312
|
parseJson;
|
|
238
313
|
onUsage;
|
|
239
314
|
logger;
|
|
@@ -316,7 +391,9 @@ var VernLLM = class {
|
|
|
316
391
|
return result;
|
|
317
392
|
} catch (error) {
|
|
318
393
|
this.breaker?.recordFailure();
|
|
319
|
-
|
|
394
|
+
const normalized = this.normalizeError(error, params.signal);
|
|
395
|
+
this.logger.debug(`[vern:${requestId}] error:\n${describeError(error)}`);
|
|
396
|
+
throw normalized;
|
|
320
397
|
}
|
|
321
398
|
}
|
|
322
399
|
/**
|
|
@@ -327,7 +404,7 @@ var VernLLM = class {
|
|
|
327
404
|
async retryWithBackoff(fn, requestId, signal) {
|
|
328
405
|
let lastError;
|
|
329
406
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
|
|
330
|
-
if (attempt > 0) await this.recoverDelay(requestId, attempt, signal);
|
|
407
|
+
if (attempt > 0) await this.recoverDelay(requestId, attempt, lastError, signal);
|
|
331
408
|
return await fn();
|
|
332
409
|
} catch (error) {
|
|
333
410
|
lastError = error;
|
|
@@ -345,8 +422,9 @@ var VernLLM = class {
|
|
|
345
422
|
if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
|
|
346
423
|
if (error instanceof LLMError) return error;
|
|
347
424
|
const status = extractStatus(error);
|
|
348
|
-
|
|
349
|
-
return new LLMError("LLM request failed", "
|
|
425
|
+
const retryAfterMs = extractRetryAfterMs(error);
|
|
426
|
+
if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs);
|
|
427
|
+
return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
|
|
350
428
|
}
|
|
351
429
|
/**
|
|
352
430
|
* Performs a single attempt: builds the request, dispatches it with a
|
|
@@ -391,6 +469,7 @@ var VernLLM = class {
|
|
|
391
469
|
buildRequestPayload(params) {
|
|
392
470
|
const { systemPrompt, userContent, history = [], temperature = .2, jsonMode = true, maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema } = params;
|
|
393
471
|
const useJson = jsonMode || Boolean(jsonSchema);
|
|
472
|
+
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");
|
|
394
473
|
const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
|
|
395
474
|
this.validateHistory(history);
|
|
396
475
|
const request = {
|
|
@@ -475,12 +554,16 @@ var VernLLM = class {
|
|
|
475
554
|
}
|
|
476
555
|
/**
|
|
477
556
|
* Waits out the backoff delay for a given retry attempt, logging the
|
|
478
|
-
* attempt for observability before the wait begins.
|
|
479
|
-
* the
|
|
557
|
+
* attempt for observability before the wait begins. Honors a
|
|
558
|
+
* Retry-After header on the failed attempt's error when present
|
|
559
|
+
* (capped at the same maxDelayMs as backoff), otherwise falls back to
|
|
560
|
+
* exponential backoff exactly as before. Rejects early if the signal
|
|
561
|
+
* aborts during the wait
|
|
480
562
|
*/
|
|
481
|
-
async recoverDelay(requestId, attempt, signal) {
|
|
482
|
-
const
|
|
483
|
-
|
|
563
|
+
async recoverDelay(requestId, attempt, error, signal) {
|
|
564
|
+
const retryAfterMs = extractRetryAfterMs(error);
|
|
565
|
+
const delay = retryAfterMs ?? getBackoffDelay(this.baseDelayMs, attempt);
|
|
566
|
+
this.logger.warn(`[vern:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterMs !== void 0 ? " (honoring Retry-After)" : ""));
|
|
484
567
|
await waitForRetry(delay, signal);
|
|
485
568
|
}
|
|
486
569
|
/**
|
|
@@ -509,26 +592,55 @@ var VernLLM = class {
|
|
|
509
592
|
await this.cache.delete(key);
|
|
510
593
|
}
|
|
511
594
|
/**
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
595
|
+
* Cache wrapper around caller-supplied logic. `params.fn` should invoke
|
|
596
|
+
* `this.call(...)` (see `cachedLLMCall`); retry/timeout handling is left
|
|
597
|
+
* to the caller.
|
|
598
|
+
*
|
|
599
|
+
* Concurrent misses for the same `cacheKey` share a single in-flight call,
|
|
600
|
+
* avoiding cache stampedes. Each caller still receives its own
|
|
601
|
+
* `reserveUsage`/`refundUsage` callbacks with coalescing metadata.
|
|
516
602
|
*/
|
|
517
603
|
async cachedCall(params) {
|
|
518
604
|
const cached = await this.cache.get(params.cacheKey);
|
|
519
605
|
if (cached.hit) return cached.value;
|
|
606
|
+
const existing = this.inFlight.get(params.cacheKey);
|
|
607
|
+
const coalesced = existing !== void 0;
|
|
608
|
+
const resultPromise = existing ?? this.registerTrigger(params, coalesced);
|
|
609
|
+
if (coalesced) return this.withRefundOnFailure(params, coalesced, async () => {
|
|
610
|
+
await params.reserveUsage?.({ coalesced });
|
|
611
|
+
return resultPromise;
|
|
612
|
+
});
|
|
613
|
+
return this.withRefundOnFailure(params, coalesced, () => resultPromise);
|
|
614
|
+
}
|
|
615
|
+
/** Starts the shared fn() call for a cache miss, reserving usage first, and registers it in the in-flight map until it settles */
|
|
616
|
+
registerTrigger(params, coalesced) {
|
|
617
|
+
const resultPromise = (async () => {
|
|
618
|
+
await params.reserveUsage?.({ coalesced });
|
|
619
|
+
return this.runAndCache(params);
|
|
620
|
+
})();
|
|
621
|
+
this.inFlight.set(params.cacheKey, resultPromise);
|
|
622
|
+
resultPromise.catch(() => {}).finally(() => {
|
|
623
|
+
this.inFlight.delete(params.cacheKey);
|
|
624
|
+
});
|
|
625
|
+
return resultPromise;
|
|
626
|
+
}
|
|
627
|
+
/** Runs `fn` and writes its result to the cache. Only ever called once per cacheKey per in-flight window, from registerTrigger */
|
|
628
|
+
async runAndCache(params) {
|
|
629
|
+
const result = await params.fn();
|
|
520
630
|
try {
|
|
521
|
-
await params.
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
631
|
+
await this.cache.set(params.cacheKey, result, params.ttl);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
|
|
634
|
+
}
|
|
635
|
+
return result;
|
|
636
|
+
}
|
|
637
|
+
/** Awaits `run`, calling this caller's own refundUsage (tagged with whether it was coalesced) if it rejects, then rethrows the original error */
|
|
638
|
+
async withRefundOnFailure(params, coalesced, run) {
|
|
639
|
+
try {
|
|
640
|
+
return await run();
|
|
529
641
|
} catch (error) {
|
|
530
642
|
try {
|
|
531
|
-
await params.refundUsage?.();
|
|
643
|
+
await params.refundUsage?.({ coalesced });
|
|
532
644
|
} catch (refundError) {
|
|
533
645
|
this.logger.error("[VernLLM] refundUsage failed", { message: refundError instanceof Error ? refundError.message : "unknown" });
|
|
534
646
|
}
|
|
@@ -556,9 +668,53 @@ var VernLLM = class {
|
|
|
556
668
|
}
|
|
557
669
|
};
|
|
558
670
|
|
|
671
|
+
//#endregion
|
|
672
|
+
//#region src/internal/imageFormat.ts
|
|
673
|
+
/**
|
|
674
|
+
* MIME types accepted for `ImageBlock.mimeType` across all adapters. This is
|
|
675
|
+
* the intersection of what Anthropic, Gemini, OpenAI-compatible, and Bedrock
|
|
676
|
+
* Converse all natively support, so a `ContentBlock[]` that validates for
|
|
677
|
+
* one provider validates for all of them.
|
|
678
|
+
*/
|
|
679
|
+
const SUPPORTED_IMAGE_MIME_TYPES = [
|
|
680
|
+
"image/png",
|
|
681
|
+
"image/jpeg",
|
|
682
|
+
"image/gif",
|
|
683
|
+
"image/webp"
|
|
684
|
+
];
|
|
685
|
+
/**
|
|
686
|
+
* Validates an `ImageBlock.mimeType` against the shared supported set.
|
|
687
|
+
* Throws a non-retryable `LLMError('validation')`, since an unsupported
|
|
688
|
+
* mimeType is a permanent failure, retrying the same input can't fix it,
|
|
689
|
+
* the same way a schema-validation or JSON-parse failure isn't retried.
|
|
690
|
+
*/
|
|
691
|
+
function assertSupportedImageMimeType(mimeType) {
|
|
692
|
+
if (SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType)) return mimeType;
|
|
693
|
+
throw new LLMError(`Unsupported image mimeType "${mimeType}": expected one of ${SUPPORTED_IMAGE_MIME_TYPES.join(", ")}`, "validation");
|
|
694
|
+
}
|
|
695
|
+
|
|
559
696
|
//#endregion
|
|
560
697
|
//#region src/adapters/anthropic.ts
|
|
561
698
|
/**
|
|
699
|
+
* Translates a VernLLM `ContentBlock[]` (our provider-agnostic multimodal
|
|
700
|
+
* shape) into Anthropic's native content-block array: text blocks pass
|
|
701
|
+
* through as-is, image blocks become `{ type: 'image', source: { type:
|
|
702
|
+
* 'base64', media_type, data } }`.
|
|
703
|
+
*/
|
|
704
|
+
function toAnthropicContent(blocks) {
|
|
705
|
+
return blocks.map((block) => block.type === "image" ? {
|
|
706
|
+
type: "image",
|
|
707
|
+
source: {
|
|
708
|
+
type: "base64",
|
|
709
|
+
media_type: assertSupportedImageMimeType(block.mimeType),
|
|
710
|
+
data: block.data
|
|
711
|
+
}
|
|
712
|
+
} : {
|
|
713
|
+
type: "text",
|
|
714
|
+
text: block.text
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
562
718
|
* Wraps an Anthropic SDK client so it satisfies the same `LLMClient`
|
|
563
719
|
* interface VernLLM uses for OpenAI/Groq.
|
|
564
720
|
*
|
|
@@ -596,7 +752,7 @@ function fromAnthropic(anthropicClient) {
|
|
|
596
752
|
system: system || void 0,
|
|
597
753
|
messages: conversationMessages.map((m) => ({
|
|
598
754
|
role: m.role,
|
|
599
|
-
content: m.content
|
|
755
|
+
content: Array.isArray(m.content) ? toAnthropicContent(m.content) : m.content
|
|
600
756
|
})),
|
|
601
757
|
...tools ? {
|
|
602
758
|
tools,
|
|
@@ -625,6 +781,18 @@ function fromAnthropic(anthropicClient) {
|
|
|
625
781
|
//#endregion
|
|
626
782
|
//#region src/adapters/gemini.ts
|
|
627
783
|
/**
|
|
784
|
+
* Translates a VernLLM `ContentBlock[]` into Gemini's native `parts` array:
|
|
785
|
+
* text blocks become `{ text }`, image blocks become inline data parts
|
|
786
|
+
* (`{ inlineData: { mimeType, data } }`), Geminis shape for embedding raw
|
|
787
|
+
* base64 image bytes directly in the request.
|
|
788
|
+
*/
|
|
789
|
+
function toGeminiParts(blocks) {
|
|
790
|
+
return blocks.map((block) => block.type === "image" ? { inlineData: {
|
|
791
|
+
mimeType: assertSupportedImageMimeType(block.mimeType),
|
|
792
|
+
data: block.data
|
|
793
|
+
} } : { text: block.text });
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
628
796
|
* Wraps a Gemini client so it satisfies the `LLMClient` interface VernLLM
|
|
629
797
|
* uses for OpenAI/Groq. Geminis shape differs on nearly every axis: a
|
|
630
798
|
* `contents` array instead of `messages`, a separate `systemInstruction`
|
|
@@ -650,7 +818,7 @@ function fromGemini(geminiClient) {
|
|
|
650
818
|
model: params.model,
|
|
651
819
|
contents: conversationMessages.map((m) => ({
|
|
652
820
|
role: m.role === "assistant" ? "model" : "user",
|
|
653
|
-
parts: [{ text: m.content }]
|
|
821
|
+
parts: Array.isArray(m.content) ? toGeminiParts(m.content) : [{ text: m.content }]
|
|
654
822
|
})),
|
|
655
823
|
systemInstruction: systemMessage ? { parts: [{ text: systemMessage.content }] } : void 0,
|
|
656
824
|
generationConfig
|
|
@@ -669,6 +837,36 @@ function fromGemini(geminiClient) {
|
|
|
669
837
|
|
|
670
838
|
//#endregion
|
|
671
839
|
//#region src/adapters/bedrock.ts
|
|
840
|
+
/** Maps a `ContentBlock` image MIME type, already validated, to Converse's `format` enum. */
|
|
841
|
+
function toBedrockImageFormat(mimeType) {
|
|
842
|
+
switch (assertSupportedImageMimeType(mimeType)) {
|
|
843
|
+
case "image/png": return "png";
|
|
844
|
+
case "image/jpeg": return "jpeg";
|
|
845
|
+
case "image/gif": return "gif";
|
|
846
|
+
case "image/webp": return "webp";
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Decodes base64 image data into the raw `Uint8Array` bytes Converse's
|
|
851
|
+
* `image.source.bytes` expects (unlike Anthropic/Gemini/OpenAI, which all
|
|
852
|
+
* take base64 strings directly). Uses `Buffer`, since this adapter, like
|
|
853
|
+
* the rest of the package, targets Node.
|
|
854
|
+
*/
|
|
855
|
+
function decodeBase64(data) {
|
|
856
|
+
return new Uint8Array(Buffer.from(data, "base64"));
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Translates a VernLLM `ContentBlock[]` into Converse's native content-block
|
|
860
|
+
* array: text blocks pass through as `{ text }`, image blocks become
|
|
861
|
+
* `{ image: { format, source: { bytes } } }` with the base64 payload decoded
|
|
862
|
+
* to raw bytes, since Converse doesn't accept base64 strings directly.
|
|
863
|
+
*/
|
|
864
|
+
function toBedrockContent(blocks) {
|
|
865
|
+
return blocks.map((block) => block.type === "image" ? { image: {
|
|
866
|
+
format: toBedrockImageFormat(block.mimeType),
|
|
867
|
+
source: { bytes: decodeBase64(block.data) }
|
|
868
|
+
} } : { text: block.text });
|
|
869
|
+
}
|
|
672
870
|
/**
|
|
673
871
|
* Wraps a Bedrock Converse-API client so it satisfies the `LLMClient`
|
|
674
872
|
* interface VernLLM uses for OpenAI/Groq. The Converse API is unified
|
|
@@ -710,7 +908,7 @@ function fromBedrock(bedrockClient) {
|
|
|
710
908
|
modelId: params.model,
|
|
711
909
|
messages: conversationMessages.map((m) => ({
|
|
712
910
|
role: m.role,
|
|
713
|
-
content: [{ text: m.content }]
|
|
911
|
+
content: Array.isArray(m.content) ? toBedrockContent(m.content) : [{ text: m.content }]
|
|
714
912
|
})),
|
|
715
913
|
system: systemParts.length ? systemParts.map((text$1) => ({ text: text$1 })) : void 0,
|
|
716
914
|
inferenceConfig: {
|
|
@@ -752,19 +950,23 @@ function fromFetch(config) {
|
|
|
752
950
|
return { chat: { completions: { async create(params, options) {
|
|
753
951
|
const url = typeof config.url === "function" ? config.url(params) : config.url;
|
|
754
952
|
const headers = typeof config.headers === "function" ? await config.headers() : config.headers;
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
953
|
+
const method = config.method ?? "POST";
|
|
954
|
+
const request = config.request ?? fetch;
|
|
955
|
+
const supportsBody = !["GET", "HEAD"].includes(method.toUpperCase());
|
|
956
|
+
const res = await request(url, {
|
|
957
|
+
method,
|
|
958
|
+
headers: supportsBody ? {
|
|
758
959
|
"Content-Type": "application/json",
|
|
759
960
|
...headers
|
|
760
|
-
},
|
|
761
|
-
body: JSON.stringify(config.mapRequest(params)),
|
|
961
|
+
} : { ...headers },
|
|
962
|
+
...supportsBody ? { body: JSON.stringify(config.mapRequest(params)) } : {},
|
|
762
963
|
signal: options.signal
|
|
763
964
|
});
|
|
764
965
|
if (!res.ok) {
|
|
765
966
|
const body = await res.text().catch(() => "");
|
|
766
967
|
const err = new Error(`Fetch adapter request failed (${res.status}): ${body.slice(0, 500)}`);
|
|
767
968
|
err.status = res.status;
|
|
969
|
+
err.headers = res.headers;
|
|
768
970
|
throw err;
|
|
769
971
|
}
|
|
770
972
|
const json = await res.json();
|
|
@@ -783,13 +985,36 @@ function fromFetch(config) {
|
|
|
783
985
|
//#endregion
|
|
784
986
|
//#region src/adapters/openaiCompatible.ts
|
|
785
987
|
/**
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
|
|
988
|
+
* Translates a VernLLM `ContentBlock[]` into OpenAI's wire-level content
|
|
989
|
+
* array. Text blocks become `{ type: 'text', text }`; image blocks become
|
|
990
|
+
* `{ type: 'image_url', image_url: { url } }` with the base64 payload
|
|
991
|
+
* inlined as a `data:` URL, since our `ContentBlock` shape (`{ type:
|
|
992
|
+
* 'image', data, mimeType }`) is provider-agnostic and doesn't itself match
|
|
993
|
+
* OpenAI's wire format.
|
|
994
|
+
*/
|
|
995
|
+
function toOpenAIContent(blocks) {
|
|
996
|
+
return blocks.map((block) => block.type === "image" ? {
|
|
997
|
+
type: "image_url",
|
|
998
|
+
image_url: { url: `data:${assertSupportedImageMimeType(block.mimeType)};base64,${block.data}` }
|
|
999
|
+
} : {
|
|
1000
|
+
type: "text",
|
|
1001
|
+
text: block.text
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Adapter for any SDK/client whose `chat.completions.create` already
|
|
1006
|
+
* matches the OpenAI wire format: this covers most hosted inference
|
|
1007
|
+
* providers, since "OpenAI-compatible" is a de facto standard for chat
|
|
1008
|
+
* completion APIs. Almost everything passes straight through untouched,
|
|
1009
|
+
* this exists purely so call sites read clearly (`fromMistral(client)` vs
|
|
1010
|
+
* handing a Mistral client to something typed for OpenAI) and so a real
|
|
1011
|
+
* transformation could be added later, per-provider, without a breaking
|
|
1012
|
+
* change.
|
|
1013
|
+
*
|
|
1014
|
+
* The one thing that isn't a pure passthrough: a `ContentBlock[]`
|
|
1015
|
+
* `userContent` is translated into OpenAI's native `image_url` content-part
|
|
1016
|
+
* shape, since VernLLM's `ContentBlock` is intentionally provider-agnostic
|
|
1017
|
+
* rather than a copy of any one provider's wire format.
|
|
793
1018
|
*
|
|
794
1019
|
* Not every SDKs own TypeScript types line up exactly with `LLMClient`
|
|
795
1020
|
* (extra fields, stricter unions, etc.), so this takes `unknown` and casts:
|
|
@@ -797,7 +1022,17 @@ function fromFetch(config) {
|
|
|
797
1022
|
* receives over the wire, not the SDKs TS types.
|
|
798
1023
|
*/
|
|
799
1024
|
function fromOpenAICompatible(client) {
|
|
800
|
-
|
|
1025
|
+
const raw = client;
|
|
1026
|
+
return { chat: { completions: { async create(params, options) {
|
|
1027
|
+
const messages = params.messages.map((m) => m.role === "user" && Array.isArray(m.content) ? {
|
|
1028
|
+
...m,
|
|
1029
|
+
content: toOpenAIContent(m.content)
|
|
1030
|
+
} : m);
|
|
1031
|
+
return raw.chat.completions.create({
|
|
1032
|
+
...params,
|
|
1033
|
+
messages
|
|
1034
|
+
}, options);
|
|
1035
|
+
} } } };
|
|
801
1036
|
}
|
|
802
1037
|
/** Groqs SDK matches the OpenAI wire format */
|
|
803
1038
|
const fromGroq = fromOpenAICompatible;
|