vern-llm 0.2.1 → 0.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/README.md +44 -283
- package/dist/index.cjs +113 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +81 -16
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +81 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +113 -39
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/LakBud/vernLLM/main/apps/docs/public/logo.png" alt="vern-llm logo" width="96" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h1 align="center">vern-llm</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://github.com/LakBud/vernLLM">GitHub</a> ·
|
|
9
|
+
<a href="https://vernllm.vercel.app">Documentation</a> ·
|
|
10
|
+
<a href="https://www.npmjs.com/package/vern-llm">npm</a>
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<a href="https://www.npmjs.com/package/vern-llm"><img src="https://img.shields.io/npm/v/vern-llm.svg" alt="npm version" /></a>
|
|
15
|
+
<a href="https://www.npmjs.com/package/vern-llm"><img src="https://img.shields.io/npm/dm/vern-llm.svg" alt="npm downloads" /></a>
|
|
16
|
+
<a href="https://bundlephobia.com/package/vern-llm"><img src="https://img.shields.io/bundlephobia/minzip/vern-llm.svg" alt="bundle size" /></a>
|
|
17
|
+
<a href="https://github.com/LakBud/vernLLM/actions/workflows/test.yml"><img src="https://github.com/LakBud/vernLLM/actions/workflows/test.yml/badge.svg" alt="test status" /></a>
|
|
18
|
+
<a href="https://github.com/LakBud/vernLLM/blob/main/LICENSE.md"><img src="https://img.shields.io/npm/l/vern-llm.svg" alt="license" /></a>
|
|
19
|
+
<img src="https://img.shields.io/node/v/vern-llm.svg" alt="node version" />
|
|
20
|
+
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
Production-ready resilience for LLM calls; Retries, timeouts, caching, and circuit breaking for any OpenAI-compatible chat completion API (OpenAI, Groq, Anthropic, Gemini, Bedrock, and more via adapters).
|
|
24
|
+
|
|
25
|
+
**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.
|
|
4
26
|
|
|
5
27
|
## Install
|
|
6
28
|
|
|
@@ -8,300 +30,37 @@ Retry + timeout + cache wrapper for OpenAI-compatible chat completion calls (Ope
|
|
|
8
30
|
pnpm add vern-llm openai
|
|
9
31
|
```
|
|
10
32
|
|
|
11
|
-
##
|
|
33
|
+
## Quick start
|
|
12
34
|
|
|
13
35
|
```ts
|
|
14
36
|
import OpenAI from 'openai';
|
|
15
37
|
import { VernLLM } from 'vern-llm';
|
|
16
38
|
|
|
17
|
-
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
18
|
-
|
|
19
39
|
const llm = new VernLLM({
|
|
20
|
-
client:
|
|
40
|
+
client: new OpenAI({ apiKey: process.env.OPENAI_API_KEY }),
|
|
21
41
|
model: 'gpt-4o',
|
|
42
|
+
maxRetries: 3,
|
|
43
|
+
timeoutMs: 10_000,
|
|
44
|
+
circuitBreaker: true,
|
|
22
45
|
});
|
|
23
46
|
|
|
24
|
-
const
|
|
47
|
+
const result = await llm.call({
|
|
25
48
|
systemPrompt: 'Return JSON: { "skills": string[] }',
|
|
26
49
|
userContent: 'Extract skills from: ...',
|
|
27
50
|
});
|
|
28
|
-
|
|
29
|
-
// jsonMode: false returns the raw string, no parsing
|
|
30
|
-
const text = await llm.call({
|
|
31
|
-
systemPrompt: 'Summarize this.',
|
|
32
|
-
userContent: '...',
|
|
33
|
-
jsonMode: false,
|
|
34
|
-
});
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
`VernLLM` is also exported as `RetryLLM` — same class, same behavior, pick whichever name reads better in your codebase:
|
|
38
|
-
|
|
39
|
-
```ts
|
|
40
|
-
import { RetryLLM } from 'vern-llm';
|
|
41
|
-
|
|
42
|
-
const llm = new RetryLLM({ client: openai, model: 'gpt-4o' });
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
## Structured output with Zod
|
|
46
|
-
|
|
47
|
-
Pass a `schema` and get a typed, validated result. Works with any validator exposing `safeParse` (Zod v3/v4).
|
|
48
|
-
|
|
49
|
-
```ts
|
|
50
|
-
import { z } from 'zod';
|
|
51
|
-
|
|
52
|
-
const CandidateSchema = z.object({
|
|
53
|
-
name: z.string(),
|
|
54
|
-
skills: z.array(z.string()),
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
// result is typed as z.infer<typeof CandidateSchema>
|
|
58
|
-
const result = await llm.call({
|
|
59
|
-
systemPrompt: 'Extract the candidate name and skills as JSON.',
|
|
60
|
-
userContent: resumeText,
|
|
61
|
-
schema: CandidateSchema,
|
|
62
|
-
});
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
On a schema mismatch, `call` throws `LLMError('validation')` with `.issues` set to the validator's error object, without burning a retry (validation failures are deterministic).
|
|
66
|
-
|
|
67
|
-
## Provider-native JSON Schema mode
|
|
68
|
-
|
|
69
|
-
`schema` above only validates client-side, after the model has already responded. `jsonSchema` sends the schema to the provider (OpenAI/Groq `response_format: { type: 'json_schema' }`), constraining generation itself — the model can't produce a shape that violates it. Combine both for provider-level constraint plus typed client-side inference:
|
|
70
|
-
|
|
71
|
-
```ts
|
|
72
|
-
const result = await llm.call({
|
|
73
|
-
systemPrompt: 'Extract the candidate name and skills.',
|
|
74
|
-
userContent: resumeText,
|
|
75
|
-
jsonSchema: {
|
|
76
|
-
name: 'Candidate',
|
|
77
|
-
schema: {
|
|
78
|
-
type: 'object',
|
|
79
|
-
properties: {
|
|
80
|
-
name: { type: 'string' },
|
|
81
|
-
skills: { type: 'array', items: { type: 'string' } },
|
|
82
|
-
},
|
|
83
|
-
required: ['name', 'skills'],
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
schema: CandidateSchema, // optional client-side type/validation on top
|
|
87
|
-
});
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
`jsonSchema` implies JSON mode. The Anthropic adapter has no native structured-output equivalent, so it embeds the schema in the system prompt as an instruction instead of provider-enforcing it.
|
|
91
|
-
|
|
92
|
-
## Per-call model override and reasoning effort
|
|
93
|
-
|
|
94
|
-
```ts
|
|
95
|
-
const llm = new VernLLM({ client: openai, model: 'gpt-4o-mini' }); // default model
|
|
96
|
-
|
|
97
|
-
await llm.call({
|
|
98
|
-
systemPrompt: '...',
|
|
99
|
-
userContent: '...',
|
|
100
|
-
model: 'o3', // overrides the instance default for this call only
|
|
101
|
-
reasoningEffort: 'high', // passed through as `reasoning_effort`; ignored by models that don't support it
|
|
102
|
-
});
|
|
103
51
|
```
|
|
104
52
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
## Caching + usage metering
|
|
108
|
-
|
|
109
|
-
```ts
|
|
110
|
-
import { InMemoryCacheAdapter } from 'vern-llm';
|
|
111
|
-
|
|
112
|
-
const llm = new VernLLM({
|
|
113
|
-
client: openai,
|
|
114
|
-
model: 'gpt-4o',
|
|
115
|
-
cache: new InMemoryCacheAdapter(), // swap for your own Redis/Upstash adapter
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const result = await llm.cachedCall({
|
|
119
|
-
cacheKey: `cv:${cvId}`,
|
|
120
|
-
ttl: 3600,
|
|
121
|
-
fn: () => llm.call({ systemPrompt, userContent }),
|
|
122
|
-
reserveUsage: () => quota.reserve(userId),
|
|
123
|
-
refundUsage: () => quota.refund(userId),
|
|
124
|
-
});
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
If `refundUsage` itself throws, that failure is logged and swallowed — the original error from `fn` (or the cache write) is what propagates, so a broken refund path never masks the real failure.
|
|
128
|
-
|
|
129
|
-
### `cachedLLMCall` — cached + retried in one call
|
|
53
|
+
## Why vern-llm?
|
|
130
54
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
reserveUsage: () => quota.reserve(userId),
|
|
139
|
-
refundUsage: () => quota.refund(userId),
|
|
140
|
-
});
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
### Custom cache adapter
|
|
144
|
-
|
|
145
|
-
```ts
|
|
146
|
-
import type { CacheAdapter } from 'vern-llm';
|
|
147
|
-
|
|
148
|
-
class UpstashCacheAdapter implements CacheAdapter {
|
|
149
|
-
async get(key: string) {
|
|
150
|
-
/* ... */
|
|
151
|
-
}
|
|
152
|
-
async set(key: string, value: unknown, ttl: number) {
|
|
153
|
-
/* ... */
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
## Token usage tracking
|
|
159
|
-
|
|
160
|
-
```ts
|
|
161
|
-
const llm = new VernLLM({
|
|
162
|
-
client: openai,
|
|
163
|
-
model: 'gpt-4o',
|
|
164
|
-
onUsage: (usage) => {
|
|
165
|
-
// { promptTokens, completionTokens, totalTokens, requestId, model }
|
|
166
|
-
billing.record(usage);
|
|
167
|
-
},
|
|
168
|
-
});
|
|
169
|
-
```
|
|
55
|
+
- **Retries with backoff**: transient failures retry automatically; validation errors and non-retryable status codes fail fast instead
|
|
56
|
+
- **Structured output**: pass a Zod schema, get a typed, validated result back
|
|
57
|
+
- **Provider-native JSON Schema mode**: constrain generation itself, not just validate after the fact
|
|
58
|
+
- **Caching**: wrap any call with `cachedCall`/`cachedLLMCall`, bring your own cache adapter
|
|
59
|
+
- **Circuit breaker**: trips after repeated failures, recovers automatically once the provider's back
|
|
60
|
+
- **One interface, every provider**: OpenAI, Groq, Mistral, DeepSeek, Cerebras, Together, Fireworks, Ollama, Anthropic, Gemini, Bedrock, or raw HTTP via `fromFetch`
|
|
61
|
+
- **Zero bundled deps**: `zod` and provider SDKs are peer dependencies; this package only relies on their shapes structurally
|
|
170
62
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
Stops hammering a provider that's down instead of retrying every call.
|
|
174
|
-
|
|
175
|
-
```ts
|
|
176
|
-
const llm = new VernLLM({
|
|
177
|
-
client: openai,
|
|
178
|
-
model: 'gpt-4o',
|
|
179
|
-
circuitBreaker: { threshold: 5, cooldownMs: 30_000 }, // or `true` for defaults
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
llm.getCircuitState(); // 'closed' | 'open' | 'half-open' | undefined
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
Once `threshold` consecutive failures occur, further calls fail immediately with `LLMError('circuit_open')` until `cooldownMs` elapses, at which point one trial call is allowed through (half-open). A successful trial closes the circuit; a failed one reopens it.
|
|
186
|
-
|
|
187
|
-
## Pluggable logger
|
|
188
|
-
|
|
189
|
-
```ts
|
|
190
|
-
import type { Logger } from 'vern-llm';
|
|
191
|
-
|
|
192
|
-
const pinoLogger: Logger = {
|
|
193
|
-
debug: (msg) => logger.debug(msg),
|
|
194
|
-
warn: (msg) => logger.warn(msg),
|
|
195
|
-
error: (msg, meta) => logger.error(meta, msg),
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
const llm = new VernLLM({ client: openai, model: 'gpt-4o', logger: pinoLogger });
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
Defaults to a console logger; `debug` logging is gated by the `debug` option (`NODE_ENV !== 'production'` by default), `warn`/`error` always fire.
|
|
202
|
-
|
|
203
|
-
## Multi-provider adapters
|
|
204
|
-
|
|
205
|
-
`VernLLM` expects a client shaped like OpenAI's `chat.completions.create`. Some providers already match that shape and need no adapter; others need one that translates their native API into it.
|
|
206
|
-
|
|
207
|
-
### Already OpenAI-wire-compatible — zero-transform passthrough
|
|
208
|
-
|
|
209
|
-
Groq, Mistral, DeepSeek, Cerebras, Together AI, Fireworks AI, and Ollama (via its `/v1/chat/completions` endpoint) all speak the same wire format as OpenAI. These are thin named wrappers around the same passthrough — pick whichever name matches your provider for readability:
|
|
210
|
-
|
|
211
|
-
```ts
|
|
212
|
-
import { VernLLM, fromGroq, fromMistral, fromTogether } from 'vern-llm';
|
|
213
|
-
|
|
214
|
-
const llm = new VernLLM({
|
|
215
|
-
client: fromGroq(new Groq({ apiKey: process.env.GROQ_API_KEY })),
|
|
216
|
-
model: 'llama-3.3-70b-versatile',
|
|
217
|
-
});
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
`fromOpenAICompatible()` is the underlying function if your provider isn't in the named list — same thing, generic name.
|
|
221
|
-
|
|
222
|
-
### Anthropic
|
|
223
|
-
|
|
224
|
-
```ts
|
|
225
|
-
import Anthropic from '@anthropic-ai/sdk';
|
|
226
|
-
import { VernLLM, fromAnthropic } from 'vern-llm';
|
|
227
|
-
|
|
228
|
-
const llm = new VernLLM({
|
|
229
|
-
client: fromAnthropic(new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })),
|
|
230
|
-
model: 'claude-sonnet-4-6',
|
|
231
|
-
});
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
No native `response_format` or `reasoning_effort` equivalent — JSON mode/schema are emulated via a system-prompt instruction (schema text embedded, not provider-enforced); `reasoning_effort` is dropped.
|
|
235
|
-
|
|
236
|
-
### Gemini
|
|
237
|
-
|
|
238
|
-
```ts
|
|
239
|
-
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
240
|
-
import { VernLLM, fromGemini } from 'vern-llm';
|
|
241
|
-
|
|
242
|
-
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
|
|
243
|
-
const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' });
|
|
244
|
-
|
|
245
|
-
const llm = new VernLLM({ client: fromGemini(model), model: 'gemini-2.5-flash' });
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
Unlike Anthropic, `jsonSchema` maps to Gemini's native `responseSchema` + `responseMimeType: 'application/json'`, so it's actually provider-enforced here. `reasoning_effort` is dropped (Gemini's thinking models use a token budget instead).
|
|
249
|
-
|
|
250
|
-
### AWS Bedrock
|
|
251
|
-
|
|
252
|
-
Bedrock's SDK v3 exposes `.send(command)`, not a direct method, so wrap it:
|
|
253
|
-
|
|
254
|
-
```ts
|
|
255
|
-
import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime';
|
|
256
|
-
import { VernLLM, fromBedrock } from 'vern-llm';
|
|
257
|
-
|
|
258
|
-
const client = new BedrockRuntimeClient({ region: 'us-east-1' });
|
|
259
|
-
|
|
260
|
-
const llm = new VernLLM({
|
|
261
|
-
client: fromBedrock({
|
|
262
|
-
converse: (params, options) =>
|
|
263
|
-
client.send(new ConverseCommand(params), { abortSignal: options.signal }),
|
|
264
|
-
}),
|
|
265
|
-
model: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
|
266
|
-
});
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
Uses Bedrock's Converse API, which is unified across model families (Anthropic, Titan, Llama, Mistral, etc. all speak the same request/response shape), so this one adapter works regardless of which `model` you point it at. JSON mode is emulated via system prompt, same as Anthropic; `reasoning_effort` is dropped.
|
|
270
|
-
|
|
271
|
-
### Anything else — `fromFetch`
|
|
272
|
-
|
|
273
|
-
For a provider with no SDK, or where pulling one in isn't worth it, `fromFetch` is a raw HTTP escape hatch — supply the URL, headers, and two small mapping functions, and retries/timeouts/circuit-breaker/JSON handling all still apply:
|
|
274
|
-
|
|
275
|
-
```ts
|
|
276
|
-
import { VernLLM, fromFetch } from 'vern-llm';
|
|
277
|
-
|
|
278
|
-
const llm = new VernLLM({
|
|
279
|
-
client: fromFetch({
|
|
280
|
-
url: 'https://api.example.com/v1/generate',
|
|
281
|
-
headers: () => ({ Authorization: `Bearer ${process.env.EXAMPLE_API_KEY}` }),
|
|
282
|
-
mapRequest: (params) => ({
|
|
283
|
-
model: params.model,
|
|
284
|
-
prompt: params.messages.map((m) => m.content).join('\n\n'),
|
|
285
|
-
max_tokens: params.max_tokens,
|
|
286
|
-
}),
|
|
287
|
-
mapResponse: (json) => ({
|
|
288
|
-
content: json.output,
|
|
289
|
-
usage: { promptTokens: json.usage?.input, completionTokens: json.usage?.output },
|
|
290
|
-
}),
|
|
291
|
-
}),
|
|
292
|
-
model: 'example-model-v1',
|
|
293
|
-
});
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
Non-2xx responses throw with `.status` set, so `nonRetryableStatus` still fails fast on 401/403 as usual.
|
|
297
|
-
|
|
298
|
-
## Notes
|
|
299
|
-
|
|
300
|
-
- Requires Node 20+ (`AbortSignal.any`).
|
|
301
|
-
- Non-retryable status codes (default `400, 401, 403`) fail fast instead of burning a retry, and are reported as `LLMError('api', status)` rather than a generic unknown error.
|
|
302
|
-
- An already-aborted `signal` rejects immediately with `LLMError('aborted')` before any request is dispatched.
|
|
303
|
-
- `jsonMode: false` skips JSON parsing entirely and returns the raw string.
|
|
304
|
-
- `zod` and provider SDKs (`openai`, `groq-sdk`, `@anthropic-ai/sdk`) are not bundled — bring your own; this package only relies on their shapes structurally.
|
|
63
|
+
See the [docs](https://vernllm.vercel.app) for adapter setup, caching, the circuit breaker, and structured output in depth.
|
|
305
64
|
|
|
306
65
|
## Development
|
|
307
66
|
|
|
@@ -309,11 +68,13 @@ Non-2xx responses throw with `.status` set, so `nonRetryableStatus` still fails
|
|
|
309
68
|
pnpm install
|
|
310
69
|
pnpm run build # tsdown → dist (ESM + CJS + types)
|
|
311
70
|
pnpm run typecheck # tsc --noEmit on src, since tsdown doesn't fully type-check
|
|
312
|
-
pnpm run typecheck:test # tsc --noEmit on src + test (separate tsconfig, no rootDir conflict)
|
|
313
71
|
pnpm run test # vitest run
|
|
314
|
-
pnpm run test:watch # vitest, watch mode
|
|
315
72
|
pnpm run test:coverage # vitest run --coverage (v8 provider)
|
|
316
73
|
pnpm run changeset # record a change for the next release
|
|
317
74
|
```
|
|
318
75
|
|
|
319
|
-
Tests live in `tests/`, mirroring `src
|
|
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
|
+
## License
|
|
79
|
+
|
|
80
|
+
[MIT](https://github.com/LakBud/vernLLM/blob/main/LICENSE.md) © LakBud
|
package/dist/index.cjs
CHANGED
|
@@ -135,9 +135,12 @@ function extractStatus(err) {
|
|
|
135
135
|
* Runs an async function and cancels it if it takes longer than the given
|
|
136
136
|
* timeout. Creates an internal abort controller that fires after the
|
|
137
137
|
* timeout elapses, and combines it with any external signal the caller
|
|
138
|
-
* passed in so either one can cancel the underlying call.
|
|
139
|
-
*
|
|
140
|
-
*
|
|
138
|
+
* passed in so either one can cancel the underlying call. If the internal
|
|
139
|
+
* timeout triggers and the underlying operation aborts, the error is
|
|
140
|
+
* converted into an LLMError with type "timeout". External cancellations
|
|
141
|
+
* continue to propagate as aborted errors. The internal timer is always
|
|
142
|
+
* cleared afterward, whether the function succeeds, fails, or is aborted,
|
|
143
|
+
* so nothing is left running in the background.
|
|
141
144
|
*/
|
|
142
145
|
async function withTimeout(fn, timeoutMs, externalSignal) {
|
|
143
146
|
const controller = new AbortController();
|
|
@@ -147,6 +150,9 @@ async function withTimeout(fn, timeoutMs, externalSignal) {
|
|
|
147
150
|
const signal = externalSignal ? AbortSignal.any([externalSignal, controller.signal]) : controller.signal;
|
|
148
151
|
try {
|
|
149
152
|
return await fn(signal);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (controller.signal.aborted && !externalSignal?.aborted && err instanceof DOMException && err.name === "AbortError") throw new LLMError("Request timed out", "timeout");
|
|
155
|
+
throw err;
|
|
150
156
|
} finally {
|
|
151
157
|
clearTimeout(timer);
|
|
152
158
|
}
|
|
@@ -351,28 +357,53 @@ var VernLLM = class {
|
|
|
351
357
|
return this.parseAndValidate(content, params.schema);
|
|
352
358
|
}
|
|
353
359
|
/**
|
|
360
|
+
* Anthropic and Gemini both require strict user/assistant alternation
|
|
361
|
+
* (and reject or silently mishandle two consecutive same-role turns), so
|
|
362
|
+
* this validates `history` up front rather than letting a malformed
|
|
363
|
+
* request surface as a confusing provider-side error. Thrown as a
|
|
364
|
+
* validation LLMError, which `shouldRetry` never retries, since retrying
|
|
365
|
+
* the same malformed input can't succeed
|
|
366
|
+
*/
|
|
367
|
+
validateHistory(history) {
|
|
368
|
+
let previousRole;
|
|
369
|
+
for (const [index, turn] of history.entries()) {
|
|
370
|
+
if (turn.role !== "user" && turn.role !== "assistant") throw new LLMError(`Invalid history[${index}].role "${turn.role}": must be "user" or "assistant"`, "validation");
|
|
371
|
+
if (turn.role === previousRole) throw new LLMError(`history must alternate user/assistant turns: consecutive "${turn.role}" turns at history[${index - 1}] and history[${index}]`, "validation");
|
|
372
|
+
previousRole = turn.role;
|
|
373
|
+
}
|
|
374
|
+
if (previousRole === "user") throw new LLMError("The last entry in history is a \"user\" turn, which would collide with the current userContent turn. history must end with an \"assistant\" turn (or be empty).", "validation");
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
354
377
|
* Applies per call defaults and shapes the params into the request
|
|
355
378
|
* object expected by the underlying client, including the resolved
|
|
356
379
|
* response format. Also returns whether JSON parsing should be
|
|
357
380
|
* applied to the response and which model was ultimately used
|
|
358
381
|
*/
|
|
359
382
|
buildRequestPayload(params) {
|
|
360
|
-
const { systemPrompt, userContent, temperature = .2, jsonMode = true, maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema } = params;
|
|
383
|
+
const { systemPrompt, userContent, history = [], temperature = .2, jsonMode = true, maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema } = params;
|
|
361
384
|
const useJson = jsonMode || Boolean(jsonSchema);
|
|
362
385
|
const responseFormat = this.buildResponseFormat(jsonSchema, useJson);
|
|
386
|
+
this.validateHistory(history);
|
|
363
387
|
const request = {
|
|
364
388
|
model,
|
|
365
389
|
temperature,
|
|
366
390
|
max_tokens: maxTokens,
|
|
367
391
|
...responseFormat ? { response_format: responseFormat } : {},
|
|
368
392
|
...reasoningEffort ? { reasoning_effort: reasoningEffort } : {},
|
|
369
|
-
messages: [
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
393
|
+
messages: [
|
|
394
|
+
{
|
|
395
|
+
role: "system",
|
|
396
|
+
content: systemPrompt
|
|
397
|
+
},
|
|
398
|
+
...history.map((turn) => ({
|
|
399
|
+
role: turn.role,
|
|
400
|
+
content: turn.content
|
|
401
|
+
})),
|
|
402
|
+
{
|
|
403
|
+
role: "user",
|
|
404
|
+
content: userContent
|
|
405
|
+
}
|
|
406
|
+
]
|
|
376
407
|
};
|
|
377
408
|
return {
|
|
378
409
|
useJson,
|
|
@@ -509,19 +540,33 @@ var VernLLM = class {
|
|
|
509
540
|
//#region src/adapters/anthropic.ts
|
|
510
541
|
/**
|
|
511
542
|
* Wraps an Anthropic SDK client so it satisfies the same `LLMClient`
|
|
512
|
-
* interface VernLLM uses for OpenAI/Groq.
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
543
|
+
* interface VernLLM uses for OpenAI/Groq.
|
|
544
|
+
*
|
|
545
|
+
* `response_format: json_schema` is mapped to Anthropics forced tool-use:
|
|
546
|
+
* a single tool is defined with `input_schema` set to the caller's schema,
|
|
547
|
+
* and `tool_choice` forces the model to call it, so the output is
|
|
548
|
+
* provider-constrained to match the schema rather than merely instructed
|
|
549
|
+
* to via prompt text (the same guarantee OpenAIs native `json_schema` mode
|
|
550
|
+
* gives, built on Anthropics tool-calling primitive instead).
|
|
551
|
+
*
|
|
552
|
+
* `response_format: json_object` (no schema to build a tool from) falls
|
|
553
|
+
* back to a system-prompt instruction, since theres nothing to constrain
|
|
554
|
+
* generation against.
|
|
516
555
|
*/
|
|
517
556
|
function fromAnthropic(anthropicClient) {
|
|
518
557
|
return { chat: { completions: { async create(params, options) {
|
|
519
558
|
const systemMessage = params.messages.find((m) => m.role === "system");
|
|
520
|
-
const
|
|
559
|
+
const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant");
|
|
560
|
+
const toolName = params.response_format?.type === "json_schema" ? params.response_format.json_schema.name : void 0;
|
|
521
561
|
let jsonInstruction;
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
562
|
+
let tools;
|
|
563
|
+
if (params.response_format?.type === "json_schema" && toolName) {
|
|
564
|
+
const { schema, description } = params.response_format.json_schema;
|
|
565
|
+
tools = [{
|
|
566
|
+
name: toolName,
|
|
567
|
+
description,
|
|
568
|
+
input_schema: schema
|
|
569
|
+
}];
|
|
525
570
|
} else if (params.response_format?.type === "json_object") jsonInstruction = "Respond with valid JSON only, no prose or markdown fences.";
|
|
526
571
|
const system = [systemMessage?.content, jsonInstruction].filter(Boolean).join("\n\n");
|
|
527
572
|
const response = await anthropicClient.messages.create({
|
|
@@ -529,12 +574,23 @@ function fromAnthropic(anthropicClient) {
|
|
|
529
574
|
max_tokens: params.max_tokens,
|
|
530
575
|
temperature: params.temperature,
|
|
531
576
|
system: system || void 0,
|
|
532
|
-
messages:
|
|
533
|
-
role:
|
|
577
|
+
messages: conversationMessages.map((m) => ({
|
|
578
|
+
role: m.role,
|
|
534
579
|
content: m.content
|
|
535
|
-
}))
|
|
580
|
+
})),
|
|
581
|
+
...tools ? {
|
|
582
|
+
tools,
|
|
583
|
+
tool_choice: {
|
|
584
|
+
type: "tool",
|
|
585
|
+
name: toolName
|
|
586
|
+
}
|
|
587
|
+
} : {}
|
|
536
588
|
}, options);
|
|
537
|
-
|
|
589
|
+
let text;
|
|
590
|
+
if (toolName) {
|
|
591
|
+
const toolUse = response.content.find((block) => block.type === "tool_use" && block.name === toolName);
|
|
592
|
+
text = toolUse ? JSON.stringify(toolUse.input) : "";
|
|
593
|
+
} else text = response.content.find((block) => block.type === "text")?.text ?? "";
|
|
538
594
|
return {
|
|
539
595
|
choices: [{ message: { content: text } }],
|
|
540
596
|
usage: {
|
|
@@ -562,7 +618,7 @@ function fromAnthropic(anthropicClient) {
|
|
|
562
618
|
function fromGemini(geminiClient) {
|
|
563
619
|
return { chat: { completions: { async create(params, options) {
|
|
564
620
|
const systemMessage = params.messages.find((m) => m.role === "system");
|
|
565
|
-
const
|
|
621
|
+
const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant");
|
|
566
622
|
const wantsJson = Boolean(params.response_format);
|
|
567
623
|
const generationConfig = {
|
|
568
624
|
temperature: params.temperature,
|
|
@@ -572,8 +628,8 @@ function fromGemini(geminiClient) {
|
|
|
572
628
|
if (params.response_format?.type === "json_schema") generationConfig.responseSchema = params.response_format.json_schema.schema;
|
|
573
629
|
const response = await geminiClient.generateContent({
|
|
574
630
|
model: params.model,
|
|
575
|
-
contents:
|
|
576
|
-
role: "user",
|
|
631
|
+
contents: conversationMessages.map((m) => ({
|
|
632
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
577
633
|
parts: [{ text: m.content }]
|
|
578
634
|
})),
|
|
579
635
|
systemInstruction: systemMessage ? { parts: [{ text: systemMessage.content }] } : void 0,
|
|
@@ -601,35 +657,53 @@ function fromGemini(geminiClient) {
|
|
|
601
657
|
* regardless of which underlying model `modelId` points at, as long as
|
|
602
658
|
* that model supports Converse (most current-generation ones do)
|
|
603
659
|
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
660
|
+
* `response_format: json_schema` is mapped to Converses `toolConfig`: a
|
|
661
|
+
* single tool is defined from the schema and `toolChoice` forces the model
|
|
662
|
+
* to call it, constraining output at generation time rather than merely
|
|
663
|
+
* instructing for it via prompt text. Native tool support varies by model
|
|
664
|
+
* family (most current-generation ones support it via Converse; check your
|
|
665
|
+
* specific `modelId` if a call fails with an unsupported-parameter error).
|
|
666
|
+
* `response_format: json_object` (no schema to build a tool from) and
|
|
667
|
+
* `reasoning_effort` (no Converse equivalent) fall back to a system-prompt
|
|
668
|
+
* instruction and are dropped respectively.
|
|
609
669
|
*/
|
|
610
670
|
function fromBedrock(bedrockClient) {
|
|
611
671
|
return { chat: { completions: { async create(params, options) {
|
|
612
672
|
const systemMessage = params.messages.find((m) => m.role === "system");
|
|
613
|
-
const
|
|
673
|
+
const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant");
|
|
674
|
+
const toolName = params.response_format?.type === "json_schema" ? params.response_format.json_schema.name : void 0;
|
|
614
675
|
let jsonInstruction;
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
676
|
+
let toolConfig;
|
|
677
|
+
if (params.response_format?.type === "json_schema" && toolName) {
|
|
678
|
+
const { schema, description } = params.response_format.json_schema;
|
|
679
|
+
toolConfig = {
|
|
680
|
+
tools: [{ toolSpec: {
|
|
681
|
+
name: toolName,
|
|
682
|
+
description,
|
|
683
|
+
inputSchema: { json: schema }
|
|
684
|
+
} }],
|
|
685
|
+
toolChoice: { tool: { name: toolName } }
|
|
686
|
+
};
|
|
618
687
|
} else if (params.response_format?.type === "json_object") jsonInstruction = "Respond with valid JSON only, no prose or markdown fences.";
|
|
619
688
|
const systemParts = [systemMessage?.content, jsonInstruction].filter((s) => Boolean(s));
|
|
620
689
|
const response = await bedrockClient.converse({
|
|
621
690
|
modelId: params.model,
|
|
622
|
-
messages:
|
|
623
|
-
role:
|
|
691
|
+
messages: conversationMessages.map((m) => ({
|
|
692
|
+
role: m.role,
|
|
624
693
|
content: [{ text: m.content }]
|
|
625
694
|
})),
|
|
626
695
|
system: systemParts.length ? systemParts.map((text$1) => ({ text: text$1 })) : void 0,
|
|
627
696
|
inferenceConfig: {
|
|
628
697
|
temperature: params.temperature,
|
|
629
698
|
maxTokens: params.max_tokens
|
|
630
|
-
}
|
|
699
|
+
},
|
|
700
|
+
...toolConfig ? { toolConfig } : {}
|
|
631
701
|
}, options);
|
|
632
|
-
|
|
702
|
+
let text;
|
|
703
|
+
if (toolName) {
|
|
704
|
+
const toolUseBlock = response.output?.message?.content?.find((block) => block.toolUse?.name === toolName);
|
|
705
|
+
text = toolUseBlock?.toolUse ? JSON.stringify(toolUseBlock.toolUse.input) : "";
|
|
706
|
+
} else text = response.output?.message?.content?.map((c) => c.text ?? "").join("") ?? "";
|
|
633
707
|
return {
|
|
634
708
|
choices: [{ message: { content: text } }],
|
|
635
709
|
usage: {
|