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 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.