vern-llm 0.1.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/LICENSE.md +7 -0
- package/README.md +315 -0
- package/dist/index.cjs +742 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +608 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +608 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +701 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 LakBud
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# vern-llm
|
|
2
|
+
|
|
3
|
+
Retry + timeout + cache wrapper for OpenAI-compatible chat completion calls (OpenAI, Groq, Anthropic via adapter).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add vern-llm openai
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Basic usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import OpenAI from 'openai';
|
|
15
|
+
import { VernLLM } from 'vern-llm';
|
|
16
|
+
|
|
17
|
+
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
18
|
+
|
|
19
|
+
const llm = new VernLLM({
|
|
20
|
+
client: openai,
|
|
21
|
+
model: 'gpt-4o',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const parsed = await llm.call({
|
|
25
|
+
systemPrompt: 'Return JSON: { "skills": string[] }',
|
|
26
|
+
userContent: 'Extract skills from: ...',
|
|
27
|
+
});
|
|
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
|
+
```
|
|
104
|
+
|
|
105
|
+
`reasoningEffort` is dropped by the Anthropic adapter — Claude's extended thinking uses a token budget, not an effort tier, so there's no faithful 1:1 mapping.
|
|
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
|
|
130
|
+
|
|
131
|
+
`cachedCall` is a thin cache wrapper; it doesn't apply retry/timeout/circuit-breaker behavior on its own — that's up to whatever `fn` does. If `fn` is always going to be `() => llm.call(...)`, `cachedLLMCall` wires that up for you:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
const result = await llm.cachedLLMCall({
|
|
135
|
+
cacheKey: `cv:${cvId}`,
|
|
136
|
+
ttl: 3600,
|
|
137
|
+
call: { systemPrompt, userContent }, // same shape as call()'s params
|
|
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
|
+
async set(key: string, value: unknown, ttl: number) { /* ... */ }
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Token usage tracking
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const llm = new VernLLM({
|
|
158
|
+
client: openai,
|
|
159
|
+
model: 'gpt-4o',
|
|
160
|
+
onUsage: (usage) => {
|
|
161
|
+
// { promptTokens, completionTokens, totalTokens, requestId, model }
|
|
162
|
+
billing.record(usage);
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Circuit breaker
|
|
168
|
+
|
|
169
|
+
Stops hammering a provider that's down instead of retrying every call.
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
const llm = new VernLLM({
|
|
173
|
+
client: openai,
|
|
174
|
+
model: 'gpt-4o',
|
|
175
|
+
circuitBreaker: { threshold: 5, cooldownMs: 30_000 }, // or `true` for defaults
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
llm.getCircuitState(); // 'closed' | 'open' | 'half-open' | undefined
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
## Pluggable logger
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import type { Logger } from 'vern-llm';
|
|
187
|
+
|
|
188
|
+
const pinoLogger: Logger = {
|
|
189
|
+
debug: (msg) => logger.debug(msg),
|
|
190
|
+
warn: (msg) => logger.warn(msg),
|
|
191
|
+
error: (msg, meta) => logger.error(meta, msg),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const llm = new VernLLM({ client: openai, model: 'gpt-4o', logger: pinoLogger });
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Defaults to a console logger; `debug` logging is gated by the `debug` option (`NODE_ENV !== 'production'` by default), `warn`/`error` always fire.
|
|
198
|
+
|
|
199
|
+
## Multi-provider adapters
|
|
200
|
+
|
|
201
|
+
`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.
|
|
202
|
+
|
|
203
|
+
### Already OpenAI-wire-compatible — zero-transform passthrough
|
|
204
|
+
|
|
205
|
+
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:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { VernLLM, fromGroq, fromMistral, fromTogether } from 'vern-llm';
|
|
209
|
+
|
|
210
|
+
const llm = new VernLLM({
|
|
211
|
+
client: fromGroq(new Groq({ apiKey: process.env.GROQ_API_KEY })),
|
|
212
|
+
model: 'llama-3.3-70b-versatile',
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`fromOpenAICompatible()` is the underlying function if your provider isn't in the named list — same thing, generic name.
|
|
217
|
+
|
|
218
|
+
### Anthropic
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
222
|
+
import { VernLLM, fromAnthropic } from 'vern-llm';
|
|
223
|
+
|
|
224
|
+
const llm = new VernLLM({
|
|
225
|
+
client: fromAnthropic(new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })),
|
|
226
|
+
model: 'claude-sonnet-4-6',
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
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.
|
|
231
|
+
|
|
232
|
+
### Gemini
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
236
|
+
import { VernLLM, fromGemini } from 'vern-llm';
|
|
237
|
+
|
|
238
|
+
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
|
|
239
|
+
const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' });
|
|
240
|
+
|
|
241
|
+
const llm = new VernLLM({ client: fromGemini(model), model: 'gemini-2.5-flash' });
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
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).
|
|
245
|
+
|
|
246
|
+
### AWS Bedrock
|
|
247
|
+
|
|
248
|
+
Bedrock's SDK v3 exposes `.send(command)`, not a direct method, so wrap it:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime';
|
|
252
|
+
import { VernLLM, fromBedrock } from 'vern-llm';
|
|
253
|
+
|
|
254
|
+
const client = new BedrockRuntimeClient({ region: 'us-east-1' });
|
|
255
|
+
|
|
256
|
+
const llm = new VernLLM({
|
|
257
|
+
client: fromBedrock({
|
|
258
|
+
converse: (params, options) =>
|
|
259
|
+
client.send(new ConverseCommand(params), { abortSignal: options.signal }),
|
|
260
|
+
}),
|
|
261
|
+
model: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
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.
|
|
266
|
+
|
|
267
|
+
### Anything else — `fromFetch`
|
|
268
|
+
|
|
269
|
+
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:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
import { VernLLM, fromFetch } from 'vern-llm';
|
|
273
|
+
|
|
274
|
+
const llm = new VernLLM({
|
|
275
|
+
client: fromFetch({
|
|
276
|
+
url: 'https://api.example.com/v1/generate',
|
|
277
|
+
headers: () => ({ Authorization: `Bearer ${process.env.EXAMPLE_API_KEY}` }),
|
|
278
|
+
mapRequest: (params) => ({
|
|
279
|
+
model: params.model,
|
|
280
|
+
prompt: params.messages.map((m) => m.content).join('\n\n'),
|
|
281
|
+
max_tokens: params.max_tokens,
|
|
282
|
+
}),
|
|
283
|
+
mapResponse: (json) => ({
|
|
284
|
+
content: json.output,
|
|
285
|
+
usage: { promptTokens: json.usage?.input, completionTokens: json.usage?.output },
|
|
286
|
+
}),
|
|
287
|
+
}),
|
|
288
|
+
model: 'example-model-v1',
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Non-2xx responses throw with `.status` set, so `nonRetryableStatus` still fails fast on 401/403 as usual.
|
|
293
|
+
|
|
294
|
+
## Notes
|
|
295
|
+
|
|
296
|
+
- Requires Node 20+ (`AbortSignal.any`).
|
|
297
|
+
- 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.
|
|
298
|
+
- An already-aborted `signal` rejects immediately with `LLMError('aborted')` before any request is dispatched.
|
|
299
|
+
- `jsonMode: false` skips JSON parsing entirely and returns the raw string.
|
|
300
|
+
- `zod` and provider SDKs (`openai`, `groq-sdk`, `@anthropic-ai/sdk`) are not bundled — bring your own; this package only relies on their shapes structurally.
|
|
301
|
+
|
|
302
|
+
## Development
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
pnpm install
|
|
306
|
+
pnpm run build # tsdown → dist (ESM + CJS + types)
|
|
307
|
+
pnpm run typecheck # tsc --noEmit on src, since tsdown doesn't fully type-check
|
|
308
|
+
pnpm run typecheck:test # tsc --noEmit on src + test (separate tsconfig, no rootDir conflict)
|
|
309
|
+
pnpm run test # vitest run
|
|
310
|
+
pnpm run test:watch # vitest, watch mode
|
|
311
|
+
pnpm run test:coverage # vitest run --coverage (v8 provider)
|
|
312
|
+
pnpm run changeset # record a change for the next release
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Tests live in `tests/`, mirroring `src/`: `VernLLM.call.test.ts` and `VernLLM.schema.test.ts` cover retry/backoff/timeout/abort/schema/model-override/usage behavior, `circuitBreaker.test.ts` covers the breaker as a unit and its integration with `call()`, `cachedCall.test.ts` covers caching and usage reservation/refund, `logger.test.ts` covers the injectable logger, and `test/adapters/*.test.ts` cover each provider adapter's request/response translation against a fake client, no real API calls are made anywhere in the suite.
|