vern-llm 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +244 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +252 -131
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +252 -131
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +244 -41
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -52,26 +52,43 @@ var CircuitBreaker = class {
|
|
|
52
52
|
openedAt = 0;
|
|
53
53
|
threshold;
|
|
54
54
|
cooldownMs;
|
|
55
|
+
/**
|
|
56
|
+
* True while a single half-open trial call is in flight. Guards against
|
|
57
|
+
* multiple concurrent callers all treating themselves as "the" trial once
|
|
58
|
+
* the cooldown elapses
|
|
59
|
+
*/
|
|
60
|
+
trialInFlight = false;
|
|
55
61
|
constructor(options = {}) {
|
|
56
62
|
this.threshold = options.threshold ?? 5;
|
|
57
63
|
this.cooldownMs = options.cooldownMs ?? 3e4;
|
|
58
64
|
}
|
|
59
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Throws if the circuit is open and the cooldown hasn't elapsed, or if
|
|
67
|
+
* the circuit is half-open and a trial call is already in flight.
|
|
68
|
+
* Otherwise, if the circuit just became eligible for a trial (cooldown
|
|
69
|
+
* elapsed, or half-open with no trial currently running), this call
|
|
70
|
+
* becomes that trial
|
|
71
|
+
*/
|
|
60
72
|
assertClosed() {
|
|
61
|
-
if (this.state
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
if (this.state === "closed") return;
|
|
74
|
+
if (this.state === "open") {
|
|
75
|
+
const elapsed = Date.now() - this.openedAt;
|
|
76
|
+
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
77
|
this.state = "half-open";
|
|
78
|
+
this.trialInFlight = true;
|
|
65
79
|
return;
|
|
66
80
|
}
|
|
67
|
-
throw new LLMError(
|
|
81
|
+
if (this.trialInFlight) throw new LLMError("Circuit half-open. A trial request is already in flight. Try again shortly.", "circuit_open");
|
|
82
|
+
this.trialInFlight = true;
|
|
68
83
|
}
|
|
69
84
|
recordSuccess() {
|
|
70
85
|
this.consecutiveFailures = 0;
|
|
71
86
|
this.state = "closed";
|
|
87
|
+
this.trialInFlight = false;
|
|
72
88
|
}
|
|
73
89
|
recordFailure() {
|
|
74
90
|
this.consecutiveFailures += 1;
|
|
91
|
+
this.trialInFlight = false;
|
|
75
92
|
if (this.state === "half-open") {
|
|
76
93
|
this.state = "open";
|
|
77
94
|
this.openedAt = Date.now();
|
|
@@ -136,11 +153,40 @@ async function withTimeout(fn, timeoutMs, externalSignal) {
|
|
|
136
153
|
}
|
|
137
154
|
}
|
|
138
155
|
/**
|
|
156
|
+
* Default cap (ms) for both exponential backoff and honored Retry-After
|
|
157
|
+
* values, so a misbehaving/adversarial Retry-After can't stall a caller
|
|
158
|
+
* indefinitely
|
|
159
|
+
*/
|
|
160
|
+
const DEFAULT_MAX_DELAY_MS = 1e4;
|
|
161
|
+
/**
|
|
162
|
+
* Looks inside an unknown error value for a Retry-After header and
|
|
163
|
+
* converts it to milliseconds. Checks `.headers` first (fetch-style,
|
|
164
|
+
* Headers-like with `.get()`), then `.response.headers` (axios-style,
|
|
165
|
+
* plain object) since different client libraries surface headers
|
|
166
|
+
* differently. Supports both the delta-seconds form ("30") and the
|
|
167
|
+
* HTTP-date form ("Wed, 21 Oct 2015 07:28:00 GMT"). The result is capped
|
|
168
|
+
* at maxDelayMs. Returns undefined when no usable Retry-After is present
|
|
169
|
+
*/
|
|
170
|
+
function extractRetryAfterMs(err, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
|
|
171
|
+
if (!err || typeof err !== "object") return void 0;
|
|
172
|
+
const error = err;
|
|
173
|
+
const headers = error.headers ?? error.response?.headers;
|
|
174
|
+
if (!headers || typeof headers !== "object") return void 0;
|
|
175
|
+
const getter = headers;
|
|
176
|
+
const raw = typeof getter.get === "function" ? getter.get("Retry-After") : Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.at(1);
|
|
177
|
+
if (typeof raw !== "string" || raw.trim() === "") return void 0;
|
|
178
|
+
const trimmed = raw.trim();
|
|
179
|
+
if (/^\d+$/.test(trimmed)) return Math.max(0, Math.min(Number(trimmed) * 1e3, maxDelayMs));
|
|
180
|
+
const dateMs = Date.parse(trimmed);
|
|
181
|
+
if (!Number.isNaN(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), maxDelayMs));
|
|
182
|
+
return void 0;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
139
185
|
* Exponential backoff with jitter, capped at maxDelayMs.
|
|
140
186
|
* Jitter avoids thundering-herd retries when many callers back off in lockstep,
|
|
141
187
|
* the cap prevents unbounded delays when maxRetries is high
|
|
142
188
|
*/
|
|
143
|
-
function getBackoffDelay(baseDelayMs, attempt, maxDelayMs =
|
|
189
|
+
function getBackoffDelay(baseDelayMs, attempt, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
|
|
144
190
|
const exp = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
145
191
|
return exp / 2 + Math.random() * (exp / 2);
|
|
146
192
|
}
|
|
@@ -258,6 +304,7 @@ var VernLLM = class {
|
|
|
258
304
|
defaultMaxTokens;
|
|
259
305
|
cache;
|
|
260
306
|
nonRetryableStatus;
|
|
307
|
+
inFlight = new Map();
|
|
261
308
|
parseJson;
|
|
262
309
|
onUsage;
|
|
263
310
|
logger;
|
|
@@ -351,7 +398,7 @@ var VernLLM = class {
|
|
|
351
398
|
async retryWithBackoff(fn, requestId, signal) {
|
|
352
399
|
let lastError;
|
|
353
400
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
|
|
354
|
-
if (attempt > 0) await this.recoverDelay(requestId, attempt, signal);
|
|
401
|
+
if (attempt > 0) await this.recoverDelay(requestId, attempt, lastError, signal);
|
|
355
402
|
return await fn();
|
|
356
403
|
} catch (error) {
|
|
357
404
|
lastError = error;
|
|
@@ -499,12 +546,16 @@ var VernLLM = class {
|
|
|
499
546
|
}
|
|
500
547
|
/**
|
|
501
548
|
* Waits out the backoff delay for a given retry attempt, logging the
|
|
502
|
-
* attempt for observability before the wait begins.
|
|
503
|
-
* the
|
|
549
|
+
* attempt for observability before the wait begins. Honors a
|
|
550
|
+
* Retry-After header on the failed attempt's error when present
|
|
551
|
+
* (capped at the same maxDelayMs as backoff), otherwise falls back to
|
|
552
|
+
* exponential backoff exactly as before. Rejects early if the signal
|
|
553
|
+
* aborts during the wait
|
|
504
554
|
*/
|
|
505
|
-
async recoverDelay(requestId, attempt, signal) {
|
|
506
|
-
const
|
|
507
|
-
|
|
555
|
+
async recoverDelay(requestId, attempt, error, signal) {
|
|
556
|
+
const retryAfterMs = extractRetryAfterMs(error);
|
|
557
|
+
const delay = retryAfterMs ?? getBackoffDelay(this.baseDelayMs, attempt);
|
|
558
|
+
this.logger.warn(`[vern:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterMs !== void 0 ? " (honoring Retry-After)" : ""));
|
|
508
559
|
await waitForRetry(delay, signal);
|
|
509
560
|
}
|
|
510
561
|
/**
|
|
@@ -533,26 +584,55 @@ var VernLLM = class {
|
|
|
533
584
|
await this.cache.delete(key);
|
|
534
585
|
}
|
|
535
586
|
/**
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
587
|
+
* Cache wrapper around caller-supplied logic. `params.fn` should invoke
|
|
588
|
+
* `this.call(...)` (see `cachedLLMCall`); retry/timeout handling is left
|
|
589
|
+
* to the caller.
|
|
590
|
+
*
|
|
591
|
+
* Concurrent misses for the same `cacheKey` share a single in-flight call,
|
|
592
|
+
* avoiding cache stampedes. Each caller still receives its own
|
|
593
|
+
* `reserveUsage`/`refundUsage` callbacks with coalescing metadata.
|
|
540
594
|
*/
|
|
541
595
|
async cachedCall(params) {
|
|
542
596
|
const cached = await this.cache.get(params.cacheKey);
|
|
543
597
|
if (cached.hit) return cached.value;
|
|
598
|
+
const existing = this.inFlight.get(params.cacheKey);
|
|
599
|
+
const coalesced = existing !== void 0;
|
|
600
|
+
const resultPromise = existing ?? this.registerTrigger(params, coalesced);
|
|
601
|
+
if (coalesced) return this.withRefundOnFailure(params, coalesced, async () => {
|
|
602
|
+
await params.reserveUsage?.({ coalesced });
|
|
603
|
+
return resultPromise;
|
|
604
|
+
});
|
|
605
|
+
return this.withRefundOnFailure(params, coalesced, () => resultPromise);
|
|
606
|
+
}
|
|
607
|
+
/** Starts the shared fn() call for a cache miss, reserving usage first, and registers it in the in-flight map until it settles */
|
|
608
|
+
registerTrigger(params, coalesced) {
|
|
609
|
+
const resultPromise = (async () => {
|
|
610
|
+
await params.reserveUsage?.({ coalesced });
|
|
611
|
+
return this.runAndCache(params);
|
|
612
|
+
})();
|
|
613
|
+
this.inFlight.set(params.cacheKey, resultPromise);
|
|
614
|
+
resultPromise.catch(() => {}).finally(() => {
|
|
615
|
+
this.inFlight.delete(params.cacheKey);
|
|
616
|
+
});
|
|
617
|
+
return resultPromise;
|
|
618
|
+
}
|
|
619
|
+
/** Runs `fn` and writes its result to the cache. Only ever called once per cacheKey per in-flight window, from registerTrigger */
|
|
620
|
+
async runAndCache(params) {
|
|
621
|
+
const result = await params.fn();
|
|
544
622
|
try {
|
|
545
|
-
await params.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
623
|
+
await this.cache.set(params.cacheKey, result, params.ttl);
|
|
624
|
+
} catch (error) {
|
|
625
|
+
this.logger.error("[VernLLM] cache write failed", { message: error instanceof Error ? error.message : "unknown" });
|
|
626
|
+
}
|
|
627
|
+
return result;
|
|
628
|
+
}
|
|
629
|
+
/** Awaits `run`, calling this caller's own refundUsage (tagged with whether it was coalesced) if it rejects, then rethrows the original error */
|
|
630
|
+
async withRefundOnFailure(params, coalesced, run) {
|
|
631
|
+
try {
|
|
632
|
+
return await run();
|
|
553
633
|
} catch (error) {
|
|
554
634
|
try {
|
|
555
|
-
await params.refundUsage?.();
|
|
635
|
+
await params.refundUsage?.({ coalesced });
|
|
556
636
|
} catch (refundError) {
|
|
557
637
|
this.logger.error("[VernLLM] refundUsage failed", { message: refundError instanceof Error ? refundError.message : "unknown" });
|
|
558
638
|
}
|
|
@@ -580,9 +660,53 @@ var VernLLM = class {
|
|
|
580
660
|
}
|
|
581
661
|
};
|
|
582
662
|
|
|
663
|
+
//#endregion
|
|
664
|
+
//#region src/internal/imageFormat.ts
|
|
665
|
+
/**
|
|
666
|
+
* MIME types accepted for `ImageBlock.mimeType` across all adapters. This is
|
|
667
|
+
* the intersection of what Anthropic, Gemini, OpenAI-compatible, and Bedrock
|
|
668
|
+
* Converse all natively support, so a `ContentBlock[]` that validates for
|
|
669
|
+
* one provider validates for all of them.
|
|
670
|
+
*/
|
|
671
|
+
const SUPPORTED_IMAGE_MIME_TYPES = [
|
|
672
|
+
"image/png",
|
|
673
|
+
"image/jpeg",
|
|
674
|
+
"image/gif",
|
|
675
|
+
"image/webp"
|
|
676
|
+
];
|
|
677
|
+
/**
|
|
678
|
+
* Validates an `ImageBlock.mimeType` against the shared supported set.
|
|
679
|
+
* Throws a non-retryable `LLMError('validation')`, since an unsupported
|
|
680
|
+
* mimeType is a permanent failure, retrying the same input can't fix it,
|
|
681
|
+
* the same way a schema-validation or JSON-parse failure isn't retried.
|
|
682
|
+
*/
|
|
683
|
+
function assertSupportedImageMimeType(mimeType) {
|
|
684
|
+
if (SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType)) return mimeType;
|
|
685
|
+
throw new LLMError(`Unsupported image mimeType "${mimeType}": expected one of ${SUPPORTED_IMAGE_MIME_TYPES.join(", ")}`, "validation");
|
|
686
|
+
}
|
|
687
|
+
|
|
583
688
|
//#endregion
|
|
584
689
|
//#region src/adapters/anthropic.ts
|
|
585
690
|
/**
|
|
691
|
+
* Translates a VernLLM `ContentBlock[]` (our provider-agnostic multimodal
|
|
692
|
+
* shape) into Anthropic's native content-block array: text blocks pass
|
|
693
|
+
* through as-is, image blocks become `{ type: 'image', source: { type:
|
|
694
|
+
* 'base64', media_type, data } }`.
|
|
695
|
+
*/
|
|
696
|
+
function toAnthropicContent(blocks) {
|
|
697
|
+
return blocks.map((block) => block.type === "image" ? {
|
|
698
|
+
type: "image",
|
|
699
|
+
source: {
|
|
700
|
+
type: "base64",
|
|
701
|
+
media_type: assertSupportedImageMimeType(block.mimeType),
|
|
702
|
+
data: block.data
|
|
703
|
+
}
|
|
704
|
+
} : {
|
|
705
|
+
type: "text",
|
|
706
|
+
text: block.text
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
586
710
|
* Wraps an Anthropic SDK client so it satisfies the same `LLMClient`
|
|
587
711
|
* interface VernLLM uses for OpenAI/Groq.
|
|
588
712
|
*
|
|
@@ -620,7 +744,7 @@ function fromAnthropic(anthropicClient) {
|
|
|
620
744
|
system: system || void 0,
|
|
621
745
|
messages: conversationMessages.map((m) => ({
|
|
622
746
|
role: m.role,
|
|
623
|
-
content: m.content
|
|
747
|
+
content: Array.isArray(m.content) ? toAnthropicContent(m.content) : m.content
|
|
624
748
|
})),
|
|
625
749
|
...tools ? {
|
|
626
750
|
tools,
|
|
@@ -649,6 +773,18 @@ function fromAnthropic(anthropicClient) {
|
|
|
649
773
|
//#endregion
|
|
650
774
|
//#region src/adapters/gemini.ts
|
|
651
775
|
/**
|
|
776
|
+
* Translates a VernLLM `ContentBlock[]` into Gemini's native `parts` array:
|
|
777
|
+
* text blocks become `{ text }`, image blocks become inline data parts
|
|
778
|
+
* (`{ inlineData: { mimeType, data } }`), Geminis shape for embedding raw
|
|
779
|
+
* base64 image bytes directly in the request.
|
|
780
|
+
*/
|
|
781
|
+
function toGeminiParts(blocks) {
|
|
782
|
+
return blocks.map((block) => block.type === "image" ? { inlineData: {
|
|
783
|
+
mimeType: assertSupportedImageMimeType(block.mimeType),
|
|
784
|
+
data: block.data
|
|
785
|
+
} } : { text: block.text });
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
652
788
|
* Wraps a Gemini client so it satisfies the `LLMClient` interface VernLLM
|
|
653
789
|
* uses for OpenAI/Groq. Geminis shape differs on nearly every axis: a
|
|
654
790
|
* `contents` array instead of `messages`, a separate `systemInstruction`
|
|
@@ -674,7 +810,7 @@ function fromGemini(geminiClient) {
|
|
|
674
810
|
model: params.model,
|
|
675
811
|
contents: conversationMessages.map((m) => ({
|
|
676
812
|
role: m.role === "assistant" ? "model" : "user",
|
|
677
|
-
parts: [{ text: m.content }]
|
|
813
|
+
parts: Array.isArray(m.content) ? toGeminiParts(m.content) : [{ text: m.content }]
|
|
678
814
|
})),
|
|
679
815
|
systemInstruction: systemMessage ? { parts: [{ text: systemMessage.content }] } : void 0,
|
|
680
816
|
generationConfig
|
|
@@ -693,6 +829,36 @@ function fromGemini(geminiClient) {
|
|
|
693
829
|
|
|
694
830
|
//#endregion
|
|
695
831
|
//#region src/adapters/bedrock.ts
|
|
832
|
+
/** Maps a `ContentBlock` image MIME type, already validated, to Converse's `format` enum. */
|
|
833
|
+
function toBedrockImageFormat(mimeType) {
|
|
834
|
+
switch (assertSupportedImageMimeType(mimeType)) {
|
|
835
|
+
case "image/png": return "png";
|
|
836
|
+
case "image/jpeg": return "jpeg";
|
|
837
|
+
case "image/gif": return "gif";
|
|
838
|
+
case "image/webp": return "webp";
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Decodes base64 image data into the raw `Uint8Array` bytes Converse's
|
|
843
|
+
* `image.source.bytes` expects (unlike Anthropic/Gemini/OpenAI, which all
|
|
844
|
+
* take base64 strings directly). Uses `Buffer`, since this adapter, like
|
|
845
|
+
* the rest of the package, targets Node.
|
|
846
|
+
*/
|
|
847
|
+
function decodeBase64(data) {
|
|
848
|
+
return new Uint8Array(Buffer.from(data, "base64"));
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Translates a VernLLM `ContentBlock[]` into Converse's native content-block
|
|
852
|
+
* array: text blocks pass through as `{ text }`, image blocks become
|
|
853
|
+
* `{ image: { format, source: { bytes } } }` with the base64 payload decoded
|
|
854
|
+
* to raw bytes, since Converse doesn't accept base64 strings directly.
|
|
855
|
+
*/
|
|
856
|
+
function toBedrockContent(blocks) {
|
|
857
|
+
return blocks.map((block) => block.type === "image" ? { image: {
|
|
858
|
+
format: toBedrockImageFormat(block.mimeType),
|
|
859
|
+
source: { bytes: decodeBase64(block.data) }
|
|
860
|
+
} } : { text: block.text });
|
|
861
|
+
}
|
|
696
862
|
/**
|
|
697
863
|
* Wraps a Bedrock Converse-API client so it satisfies the `LLMClient`
|
|
698
864
|
* interface VernLLM uses for OpenAI/Groq. The Converse API is unified
|
|
@@ -734,7 +900,7 @@ function fromBedrock(bedrockClient) {
|
|
|
734
900
|
modelId: params.model,
|
|
735
901
|
messages: conversationMessages.map((m) => ({
|
|
736
902
|
role: m.role,
|
|
737
|
-
content: [{ text: m.content }]
|
|
903
|
+
content: Array.isArray(m.content) ? toBedrockContent(m.content) : [{ text: m.content }]
|
|
738
904
|
})),
|
|
739
905
|
system: systemParts.length ? systemParts.map((text$1) => ({ text: text$1 })) : void 0,
|
|
740
906
|
inferenceConfig: {
|
|
@@ -776,19 +942,23 @@ function fromFetch(config) {
|
|
|
776
942
|
return { chat: { completions: { async create(params, options) {
|
|
777
943
|
const url = typeof config.url === "function" ? config.url(params) : config.url;
|
|
778
944
|
const headers = typeof config.headers === "function" ? await config.headers() : config.headers;
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
945
|
+
const method = config.method ?? "POST";
|
|
946
|
+
const request = config.request ?? fetch;
|
|
947
|
+
const supportsBody = !["GET", "HEAD"].includes(method.toUpperCase());
|
|
948
|
+
const res = await request(url, {
|
|
949
|
+
method,
|
|
950
|
+
headers: supportsBody ? {
|
|
782
951
|
"Content-Type": "application/json",
|
|
783
952
|
...headers
|
|
784
|
-
},
|
|
785
|
-
body: JSON.stringify(config.mapRequest(params)),
|
|
953
|
+
} : { ...headers },
|
|
954
|
+
...supportsBody ? { body: JSON.stringify(config.mapRequest(params)) } : {},
|
|
786
955
|
signal: options.signal
|
|
787
956
|
});
|
|
788
957
|
if (!res.ok) {
|
|
789
958
|
const body = await res.text().catch(() => "");
|
|
790
959
|
const err = new Error(`Fetch adapter request failed (${res.status}): ${body.slice(0, 500)}`);
|
|
791
960
|
err.status = res.status;
|
|
961
|
+
err.headers = res.headers;
|
|
792
962
|
throw err;
|
|
793
963
|
}
|
|
794
964
|
const json = await res.json();
|
|
@@ -807,13 +977,36 @@ function fromFetch(config) {
|
|
|
807
977
|
//#endregion
|
|
808
978
|
//#region src/adapters/openaiCompatible.ts
|
|
809
979
|
/**
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
|
|
980
|
+
* Translates a VernLLM `ContentBlock[]` into OpenAI's wire-level content
|
|
981
|
+
* array. Text blocks become `{ type: 'text', text }`; image blocks become
|
|
982
|
+
* `{ type: 'image_url', image_url: { url } }` with the base64 payload
|
|
983
|
+
* inlined as a `data:` URL, since our `ContentBlock` shape (`{ type:
|
|
984
|
+
* 'image', data, mimeType }`) is provider-agnostic and doesn't itself match
|
|
985
|
+
* OpenAI's wire format.
|
|
986
|
+
*/
|
|
987
|
+
function toOpenAIContent(blocks) {
|
|
988
|
+
return blocks.map((block) => block.type === "image" ? {
|
|
989
|
+
type: "image_url",
|
|
990
|
+
image_url: { url: `data:${assertSupportedImageMimeType(block.mimeType)};base64,${block.data}` }
|
|
991
|
+
} : {
|
|
992
|
+
type: "text",
|
|
993
|
+
text: block.text
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Adapter for any SDK/client whose `chat.completions.create` already
|
|
998
|
+
* matches the OpenAI wire format: this covers most hosted inference
|
|
999
|
+
* providers, since "OpenAI-compatible" is a de facto standard for chat
|
|
1000
|
+
* completion APIs. Almost everything passes straight through untouched,
|
|
1001
|
+
* this exists purely so call sites read clearly (`fromMistral(client)` vs
|
|
1002
|
+
* handing a Mistral client to something typed for OpenAI) and so a real
|
|
1003
|
+
* transformation could be added later, per-provider, without a breaking
|
|
1004
|
+
* change.
|
|
1005
|
+
*
|
|
1006
|
+
* The one thing that isn't a pure passthrough: a `ContentBlock[]`
|
|
1007
|
+
* `userContent` is translated into OpenAI's native `image_url` content-part
|
|
1008
|
+
* shape, since VernLLM's `ContentBlock` is intentionally provider-agnostic
|
|
1009
|
+
* rather than a copy of any one provider's wire format.
|
|
817
1010
|
*
|
|
818
1011
|
* Not every SDKs own TypeScript types line up exactly with `LLMClient`
|
|
819
1012
|
* (extra fields, stricter unions, etc.), so this takes `unknown` and casts:
|
|
@@ -821,7 +1014,17 @@ function fromFetch(config) {
|
|
|
821
1014
|
* receives over the wire, not the SDKs TS types.
|
|
822
1015
|
*/
|
|
823
1016
|
function fromOpenAICompatible(client) {
|
|
824
|
-
|
|
1017
|
+
const raw = client;
|
|
1018
|
+
return { chat: { completions: { async create(params, options) {
|
|
1019
|
+
const messages = params.messages.map((m) => m.role === "user" && Array.isArray(m.content) ? {
|
|
1020
|
+
...m,
|
|
1021
|
+
content: toOpenAIContent(m.content)
|
|
1022
|
+
} : m);
|
|
1023
|
+
return raw.chat.completions.create({
|
|
1024
|
+
...params,
|
|
1025
|
+
messages
|
|
1026
|
+
}, options);
|
|
1027
|
+
} } } };
|
|
825
1028
|
}
|
|
826
1029
|
/** Groqs SDK matches the OpenAI wire format */
|
|
827
1030
|
const fromGroq = fromOpenAICompatible;
|