llm-fns 1.0.2

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tobias Anhalt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "llm-fns",
3
+ "version": "1.0.2",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "keywords": [],
7
+ "author": "",
8
+ "license": "MIT",
9
+ "dependencies": {
10
+ "openai": "^6.9.1",
11
+ "zod": "^4.1.13"
12
+ },
13
+ "devDependencies": {
14
+ "@keyv/sqlite": "^4.0.6",
15
+ "@types/node": "^20.11.0",
16
+ "cache-manager": "^7.2.5",
17
+ "dotenv": "^16.6.1",
18
+ "p-queue": "^9.0.1",
19
+ "typescript": "^5.9.3",
20
+ "vitest": "^1.2.1"
21
+ },
22
+ "scripts": {
23
+ "test": "vitest run",
24
+ "release": "scripts/release.sh",
25
+ "build": "tsc"
26
+ }
27
+ }
package/readme.md ADDED
@@ -0,0 +1,299 @@
1
+ # LLM Client Wrapper for OpenAI
2
+
3
+ A generic, type-safe wrapper around the OpenAI API. It abstracts away the boilerplate (parsing, retries, caching, logging) while allowing raw access when needed.
4
+
5
+ Designed for power users who need to switch between simple string prompts and complex, resilient agentic workflows.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install openai zod cache-manager p-queue
11
+ ```
12
+
13
+ ## Quick Start (Factory)
14
+
15
+ The `createLlm` factory bundles all functionality (Basic, Retry, Zod) into a single client.
16
+
17
+ ```typescript
18
+ import OpenAI from 'openai';
19
+ import { createLlm } from './src';
20
+
21
+ const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
22
+
23
+ const llm = createLlm({
24
+ openai,
25
+ defaultModel: 'google/gemini-3-pro-preview',
26
+ // optional:
27
+ // cache: Cache instance (cache-manager)
28
+ // queue: PQueue instance for concurrency control
29
+ // maxConversationChars: number (auto-truncation)
30
+ });
31
+ ```
32
+
33
+ ---
34
+
35
+ # Use Case 1: Text & Chat (`llm.prompt` / `llm.promptText`)
36
+
37
+ ### Level 1: The Easy Way (String Output)
38
+ Use `promptText` when you just want the answer as a string.
39
+
40
+ **Return Type:** `Promise<string>`
41
+
42
+ ```typescript
43
+ // 1. Simple User Question
44
+ const ans1 = await llm.promptText("Why is the sky blue?");
45
+
46
+ // 2. System Instruction + User Question
47
+ const ans2 = await llm.promptText("You are a poet", "Describe the sea");
48
+
49
+ // 3. Conversation History (Chat Bots)
50
+ const ans3 = await llm.promptText([
51
+ { role: "user", content: "Hi" },
52
+ { role: "assistant", content: "Ho" }
53
+ ]);
54
+ ```
55
+
56
+ ### Level 2: The Raw Object (Shortcuts)
57
+ Use `prompt` when you need the **Full OpenAI Response** (`usage`, `id`, `choices`, `finish_reason`) but want to use the **Simple Inputs** from Level 1.
58
+
59
+ **Return Type:** `Promise<OpenAI.Chat.Completions.ChatCompletion>`
60
+
61
+ ```typescript
62
+ // Shortcut A: Single String -> User Message
63
+ const res1 = await llm.prompt("Why is the sky blue?");
64
+ console.log(res1.usage.total_tokens); // Access generic OpenAI properties
65
+
66
+ // Shortcut B: Two Strings -> System + User
67
+ const res2 = await llm.prompt(
68
+ "You are a SQL Expert.", // System
69
+ "Write a query for users." // User
70
+ );
71
+ ```
72
+
73
+ ### Level 3: Full Control (Config Object)
74
+ Use the **Config Object** overload for absolute control. This allows you to mix Standard OpenAI flags with Library flags.
75
+
76
+ **Input Type:** `LlmPromptOptions`
77
+
78
+ ```typescript
79
+ const res = await llm.prompt({
80
+ // Standard OpenAI params
81
+ messages: [{ role: "user", content: "Hello" }],
82
+ temperature: 1.5,
83
+ frequency_penalty: 0.2,
84
+ max_tokens: 100,
85
+
86
+ // Library Extensions
87
+ model: "gpt-4o", // Override default model for this call
88
+ ttl: 5000, // Cache this specific call for 5s (in ms)
89
+ retries: 5, // Retry network errors 5 times
90
+ });
91
+ ```
92
+
93
+ ---
94
+
95
+ # Use Case 2: Images (`llm.promptImage`)
96
+
97
+ Generates an image and returns it as a `Buffer`. This handles the fetching of the URL or Base64 decoding automatically.
98
+
99
+ **Return Type:** `Promise<Buffer>`
100
+
101
+ ```typescript
102
+ // 1. Simple Generation
103
+ const buffer1 = await llm.promptImage("A cyberpunk cat");
104
+
105
+ // 2. Advanced Configuration (Model & Aspect Ratio)
106
+ const buffer2 = await llm.promptImage({
107
+ messages: "A cyberpunk cat",
108
+ model: "dall-e-3", // Override default model
109
+ size: "1024x1024", // OpenAI specific params pass through
110
+ quality: "hd"
111
+ });
112
+
113
+ // fs.writeFileSync('cat.png', buffer2);
114
+ ```
115
+
116
+ ---
117
+
118
+ # Use Case 3: Structured Data (`llm.promptZod`)
119
+
120
+ This is a high-level wrapper that employs a **Re-asking Loop**. If the LLM outputs invalid JSON or data that fails the Zod schema validation, the client automatically feeds the error back to the LLM and asks it to fix it (up to `maxRetries`).
121
+
122
+ **Return Type:** `Promise<z.infer<typeof Schema>>`
123
+
124
+ ### Level 1: Generation (Schema Only)
125
+ The client "hallucinates" data matching the shape.
126
+
127
+ ```typescript
128
+ import { z } from 'zod';
129
+ const UserSchema = z.object({ name: z.string(), age: z.number() });
130
+
131
+ // Input: Schema only
132
+ const user = await llm.promptZod(UserSchema);
133
+ // Output: { name: "Alice", age: 32 }
134
+ ```
135
+
136
+ ### Level 2: Extraction (Injection Shortcuts)
137
+ Pass context alongside the schema. This automates the "System Prompt JSON Injection".
138
+
139
+ ```typescript
140
+ // 1. Extract from String
141
+ const email = "Meeting at 2 PM with Bob.";
142
+ const event = await llm.promptZod(email, z.object({ time: z.string(), who: z.string() }));
143
+
144
+ // 2. Strict Separation (System, User, Schema)
145
+ // Useful for auditing code or translations where instructions must not bleed into data.
146
+ const analysis = await llm.promptZod(
147
+ "You are a security auditor.", // Arg 1: System
148
+ "function dangerous() {}", // Arg 2: User Data
149
+ SecuritySchema // Arg 3: Schema
150
+ );
151
+ ```
152
+
153
+ ### Level 3: State & Options (History + Config)
154
+ Process full chat history into state, and use the **Options Object (4th Argument)** to control the internals (Models, Retries, Caching).
155
+
156
+ **Input Type:** `ZodLlmClientOptions`
157
+
158
+ ```typescript
159
+ const history = [
160
+ { role: "user", content: "I cast Fireball." },
161
+ { role: "assistant", content: "It misses." }
162
+ ];
163
+
164
+ const gameState = await llm.promptZod(
165
+ history, // Arg 1: Context
166
+ GameStateSchema, // Arg 2: Schema
167
+ { // Arg 3: Options Override
168
+ model: "google/gemini-flash-1.5",
169
+ disableJsonFixer: true, // Turn off the automatic JSON repair agent
170
+ maxRetries: 0, // Fail immediately on error
171
+ ttl: 60000 // Cache result
172
+ }
173
+ );
174
+ ```
175
+
176
+ ### Level 4: Hooks & Pre-processing
177
+ Sometimes LLMs output data that is *almost* correct (e.g., strings for numbers). You can sanitize data before Zod validation runs.
178
+
179
+ ```typescript
180
+ const result = await llm.promptZod(MySchema, {
181
+ // Transform JSON before Zod validation runs
182
+ beforeValidation: (data) => {
183
+ if (data.price && typeof data.price === 'string') {
184
+ return { ...data, price: parseFloat(data.price) };
185
+ }
186
+ return data;
187
+ },
188
+
189
+ // Toggle usage of 'response_format: { type: "json_object" }'
190
+ // Sometimes strict JSON mode is too restrictive for creative tasks
191
+ useResponseFormat: false
192
+ });
193
+ ```
194
+
195
+ ---
196
+
197
+ # Use Case 4: Agentic Retry Loops (`llm.promptTextRetry`)
198
+
199
+ The library exposes the "Conversational Retry" engine used internally by `promptZod`. You can provide a `validate` function. If it throws a `LlmRetryError`, the error message is fed back to the LLM, and it tries again.
200
+
201
+ **Return Type:** `Promise<string>` (or generic `<T>`)
202
+
203
+ ```typescript
204
+ import { LlmRetryError } from './src';
205
+
206
+ const poem = await llm.promptTextRetry({
207
+ messages: "Write a haiku about coding.",
208
+ maxRetries: 3,
209
+ validate: async (text, info) => {
210
+ // 'info' contains history and attempt number
211
+ // info: { attemptNumber: number, conversation: [...], mode: 'main'|'fallback' }
212
+
213
+ if (!text.toLowerCase().includes("bug")) {
214
+ // This message goes back to the LLM:
215
+ // User: "Please include the word 'bug'."
216
+ throw new LlmRetryError("Please include the word 'bug'.", 'CUSTOM_ERROR');
217
+ }
218
+ return text;
219
+ }
220
+ });
221
+ ```
222
+
223
+ ---
224
+
225
+ # Use Case 5: Architecture & Composition
226
+
227
+ How to build the client manually to enable **Fallback Chains** and **Smart Routing**.
228
+
229
+ ### Level 1: The Base Client (`createLlmClient`)
230
+ This creates the underlying engine that generates Text and Images. It handles Caching and Queuing but *not* Zod or Retry Loops.
231
+
232
+ ```typescript
233
+ import { createLlmClient } from './src';
234
+
235
+ // 1. Define a CHEAP model
236
+ const cheapClient = createLlmClient({
237
+ openai,
238
+ defaultModel: 'google/gemini-flash-1.5'
239
+ });
240
+
241
+ // 2. Define a STRONG model
242
+ const strongClient = createLlmClient({
243
+ openai,
244
+ defaultModel: 'google/gemini-3-pro-preview'
245
+ });
246
+ ```
247
+
248
+ ### Level 2: The Zod Client (`createZodLlmClient`)
249
+ This wraps a Base Client with the "Fixer" logic. You inject the `prompt` function you want it to use.
250
+
251
+ ```typescript
252
+ import { createZodLlmClient } from './src';
253
+
254
+ // A standard Zod client using only the strong model
255
+ const zodClient = createZodLlmClient({
256
+ prompt: strongClient.prompt,
257
+ isPromptCached: strongClient.isPromptCached
258
+ });
259
+ ```
260
+
261
+ ### Level 3: The Fallback Chain (Smart Routing)
262
+ Link two clients together. If the `prompt` function of the first client fails (retries exhausted, refusal, or unfixable JSON), it switches to the `fallbackPrompt`.
263
+
264
+ ```typescript
265
+ const smartClient = createZodLlmClient({
266
+ // Primary Strategy: Try Cheap/Fast
267
+ prompt: cheapClient.prompt,
268
+ isPromptCached: cheapClient.isPromptCached,
269
+
270
+ // Fallback Strategy: Switch to Strong/Expensive
271
+ // This is triggered if the Primary Strategy exhausts its retries or validation fails
272
+ fallbackPrompt: strongClient.prompt,
273
+ });
274
+
275
+ // Usage acts exactly like the standard client
276
+ await smartClient.promptZod(MySchema);
277
+ ```
278
+
279
+ ---
280
+
281
+ # Utilities: Cache Inspection
282
+
283
+ Check if a specific prompt is already cached without making an API call (or partial cache check for Zod calls).
284
+
285
+ **Return Type:** `Promise<boolean>`
286
+
287
+ ```typescript
288
+ const options = { messages: "Compare 5000 files..." };
289
+
290
+ // 1. Check Standard Call
291
+ if (await llm.isPromptCached(options)) {
292
+ console.log("Zero latency result available!");
293
+ }
294
+
295
+ // 2. Check Zod Call (checks exact schema + prompt combo)
296
+ if (await llm.isPromptZodCached(options, MySchema)) {
297
+ // ...
298
+ }
299
+ ```
@@ -0,0 +1,32 @@
1
+ #!/bin/bash
2
+ set -e
3
+ set -x
4
+
5
+ VERSION_TYPE=${1:-patch}
6
+
7
+ echo "🚀 Starting release process..."
8
+ echo "📦 Version bump type: $VERSION_TYPE"
9
+
10
+ # 1. Build
11
+ echo ""
12
+ echo "🔨 Step 1: Building..."
13
+ pnpm run build
14
+
15
+ # 2. Bump Version (updates package.json, creates git commit and tag)
16
+ echo ""
17
+ echo "📈 Step 2: Bumping version ($VERSION_TYPE)..."
18
+ pnpm version $VERSION_TYPE
19
+
20
+ # 3. Push Changes and Tags
21
+ echo ""
22
+ echo "⬆️ Step 3: Pushing to git..."
23
+ git push --follow-tags
24
+
25
+ # 4. Publish to Registry
26
+ echo ""
27
+ echo "📢 Step 4: Publishing to registry..."
28
+ # --no-git-checks avoids errors if pnpm thinks the repo is dirty (though npm version should have committed everything)
29
+ pnpm publish --no-git-checks
30
+
31
+ echo ""
32
+ echo "✅ Release completed successfully!"
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { normalizeOptions } from './createLlmClient.js';
3
+
4
+ describe('normalizeOptions', () => {
5
+ it('should normalize a simple string prompt', () => {
6
+ const result = normalizeOptions('Hello world');
7
+ expect(result).toEqual({
8
+ messages: [{ role: 'user', content: 'Hello world' }]
9
+ });
10
+ });
11
+
12
+ it('should normalize a string prompt with options', () => {
13
+ const result = normalizeOptions('Hello world', { temperature: 0.5 });
14
+ expect(result).toEqual({
15
+ messages: [{ role: 'user', content: 'Hello world' }],
16
+ temperature: 0.5
17
+ });
18
+ });
19
+
20
+ it('should normalize an options object with string messages', () => {
21
+ const result = normalizeOptions({
22
+ messages: 'Hello world',
23
+ temperature: 0.7
24
+ });
25
+ expect(result).toEqual({
26
+ messages: [{ role: 'user', content: 'Hello world' }],
27
+ temperature: 0.7
28
+ });
29
+ });
30
+
31
+ it('should pass through an options object with array messages', () => {
32
+ const messages = [{ role: 'user', content: 'Hello' }] as any;
33
+ const result = normalizeOptions({
34
+ messages,
35
+ temperature: 0.7
36
+ });
37
+ expect(result).toEqual({
38
+ messages,
39
+ temperature: 0.7
40
+ });
41
+ });
42
+ });