@imenam/unified-llm-client 2.2.0 → 2.3.1

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.
Files changed (32) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +383 -359
  3. package/dist/adapters/openrouter/OpenRouterLogAdapter.d.ts +11 -0
  4. package/dist/adapters/openrouter/OpenRouterLogAdapter.d.ts.map +1 -0
  5. package/dist/adapters/openrouter/OpenRouterLogAdapter.js +51 -0
  6. package/dist/adapters/openrouter/OpenRouterOutputAdapter.d.ts +6 -0
  7. package/dist/adapters/openrouter/OpenRouterOutputAdapter.d.ts.map +1 -0
  8. package/dist/adapters/openrouter/OpenRouterOutputAdapter.js +9 -0
  9. package/dist/adapters/openrouter/OpenRouterPromptAdapter.d.ts +15 -0
  10. package/dist/adapters/openrouter/OpenRouterPromptAdapter.d.ts.map +1 -0
  11. package/dist/adapters/openrouter/OpenRouterPromptAdapter.js +395 -0
  12. package/dist/adapters/openrouter/OpenRouterToolAdapter.d.ts +6 -0
  13. package/dist/adapters/openrouter/OpenRouterToolAdapter.d.ts.map +1 -0
  14. package/dist/adapters/openrouter/OpenRouterToolAdapter.js +16 -0
  15. package/dist/common/types.d.ts +10 -1
  16. package/dist/common/types.d.ts.map +1 -1
  17. package/dist/core/LLMAdapter.d.ts.map +1 -1
  18. package/dist/core/LLMAdapter.js +16 -0
  19. package/dist/core/Models.d.ts +6 -0
  20. package/dist/core/Models.d.ts.map +1 -1
  21. package/dist/core/Models.js +9 -0
  22. package/dist/core/UnifiedLLMClient.d.ts +2 -2
  23. package/dist/core/UnifiedLLMClient.d.ts.map +1 -1
  24. package/dist/core/UnifiedLLMClient.js +1 -1
  25. package/dist/examples/audio-example.js +1 -1
  26. package/dist/examples/example.js +16 -16
  27. package/dist/examples/qwen-omni-audio-example.js +5 -5
  28. package/dist/examples/qwen-vl-example.js +7 -7
  29. package/dist/index.d.ts +5 -4
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +4 -1
  32. package/package.json +71 -71
package/README.md CHANGED
@@ -1,359 +1,383 @@
1
- # @imenam/unified-llm-client
2
-
3
- A unified interface for multiple LLM providers — OpenAI, Gemini, Anthropic, and Qwen — with built-in support for tool-calling, streaming, multimodal inputs, audio generation, and a proxy mode for API key isolation.
4
-
5
- ## Features
6
-
7
- - **Unified API** — one interface, four providers
8
- - **Tool-calling** — automatic execution loop with structured results
9
- - **Streaming** — chunk-by-chunk responses via callbacks
10
- - **Multimodal** — image and audio file inputs
11
- - **Audio generation** — Text-to-Speech (Gemini)
12
- - **Proxy mode** — delegate LLM calls to a server, no API keys on the client
13
- - **API key resolver** — dynamically resolve API keys per request from a user-provided context
14
- - **Cancellation** — native `AbortSignal` support
15
- - **Usage tracking** — token counts and estimated costs
16
- - **Full TypeScript support** — exported types and `.d.ts` declarations
17
-
18
- ## Installation
19
-
20
- ```bash
21
- npm install @imenam/unified-llm-client
22
- ```
23
-
24
- ## Supported Providers
25
-
26
- | Provider | Chat | Streaming | Tool-calling | Multimodal | Audio TTS |
27
- |-----------|:----:|:---------:|:------------:|:----------:|:---------:|
28
- | OpenAI | ✅ | ✅ | ✅ | ✅ | ❌ |
29
- | Gemini | ✅ | ✅ | ✅ | ✅ | ✅ |
30
- | Anthropic | ✅ | ✅ | ✅ | ✅ | ❌ |
31
- | Qwen | ✅ | ✅ | ✅ | ✅ | ❌ |
32
-
33
- ## Quick Start
34
-
35
- Set your API keys in a `.env` file:
36
-
37
- ```env
38
- OPENAI_API_KEY=sk-...
39
- GOOGLE_API_KEY=...
40
- ANTHROPIC_API_KEY=sk-ant-...
41
- ALIBABA_CLOUD_API_KEY=... # for Qwen
42
- ```
43
-
44
- ```typescript
45
- import { UnifiedLLMClient, MODELS } from "@imenam/unified-llm-client";
46
-
47
- const client = new UnifiedLLMClient(MODELS.GPT_4O_MINI);
48
-
49
- const response = await client.chat({
50
- message: "What is the capital of France?",
51
- });
52
-
53
- console.log(response.content); // "The capital of France is Paris."
54
- ```
55
-
56
- ## Available Models
57
-
58
- ```typescript
59
- import { MODELS } from "@imenam/unified-llm-client";
60
-
61
- // OpenAI
62
- MODELS.GPT_4O_MINI
63
- MODELS.GPT_5
64
-
65
- // Gemini
66
- MODELS.GEMINI_FLASH_2_5
67
- MODELS.GEMINI_2_5_PRO
68
- MODELS.GEMINI_3_FLASH
69
- MODELS.GEMINI_3_PRO
70
- MODELS.GEMINI_3_1_PRO
71
-
72
- // Anthropic
73
- MODELS.CLAUDE_SONNET_4_6
74
- MODELS.CLAUDE_OPUS_4_6
75
-
76
- // Qwen
77
- MODELS.QWEN_VL_3
78
- MODELS.QWEN_VL_FLASH
79
- MODELS.QWEN_VL_3_FLASH
80
- ```
81
-
82
- You can also pass a custom model:
83
-
84
- ```typescript
85
- const client = new UnifiedLLMClient({
86
- provider: "openai",
87
- modelName: "gpt-4o",
88
- costsPer1MTokens: { prompt: 2.5, output: 10 },
89
- currency: "USD",
90
- });
91
- ```
92
-
93
- ## Tool-calling
94
-
95
- Define tools with an `executor` function — the client will call them automatically and loop until the LLM produces a final text response.
96
-
97
- ```typescript
98
- import { UnifiedLLMClient, MODELS } from "@imenam/unified-llm-client";
99
- import type { ToolDefinition } from "@imenam/unified-llm-client";
100
-
101
- const tools: ToolDefinition[] = [
102
- {
103
- name: "get_weather",
104
- description: "Get the current weather for a city",
105
- parameters: {
106
- type: "object",
107
- properties: {
108
- city: { type: "string", description: "City name" },
109
- },
110
- required: ["city"],
111
- },
112
- executor: async ({ city }) => {
113
- return `The weather in ${city} is sunny and 22°C.`;
114
- },
115
- },
116
- ];
117
-
118
- const client = new UnifiedLLMClient(MODELS.GEMINI_FLASH_2_5);
119
-
120
- const response = await client.chat({
121
- message: "What's the weather like in Paris?",
122
- tools,
123
- });
124
-
125
- console.log(response.content);
126
- ```
127
-
128
- ## Streaming
129
-
130
- ```typescript
131
- const client = new UnifiedLLMClient(MODELS.GPT_4O_MINI);
132
-
133
- await client.chat({
134
- message: "Tell me a short story.",
135
- stream: true,
136
- onChunk: (chunk) => {
137
- process.stdout.write(chunk.contentChunk ?? "");
138
- },
139
- onFinalResponse: (final) => {
140
- console.log("\n\nTotal tokens:", final.usage?.totalTokens);
141
- },
142
- });
143
- ```
144
-
145
- ## Multimodal Inputs
146
-
147
- Attach images or audio files directly to a message:
148
-
149
- ```typescript
150
- const response = await client.chat({
151
- message: {
152
- role: "user",
153
- content: "What do you see in this image?",
154
- attachedFile: "./photo.jpg",
155
- },
156
- });
157
- ```
158
-
159
- Multiple files are also supported:
160
-
161
- ```typescript
162
- await client.chat({
163
- message: {
164
- role: "user",
165
- content: "Compare these two images.",
166
- attachedFile: ["./image1.jpg", "./image2.png"],
167
- },
168
- });
169
- ```
170
-
171
- ## Audio Generation (Gemini)
172
-
173
- ```typescript
174
- import { UnifiedLLMClient, MODELS } from "@imenam/unified-llm-client";
175
-
176
- const client = new UnifiedLLMClient(MODELS.GEMINI_FLASH_2_5);
177
-
178
- const audio = await client.generateAudio({
179
- text: "Hello, this is a test of text-to-speech.",
180
- outputPath: "./output.wav",
181
- voiceConfig: { voiceName: "Kore" },
182
- });
183
- ```
184
-
185
- ## Proxy Mode
186
-
187
- Run an HTTP server that holds your API keys server-side, and connect clients without any keys:
188
-
189
- ```typescript
190
- import { ProxyServer } from "@imenam/unified-llm-client";
191
-
192
- // Server side — API keys are read from process.env
193
- const server = new ProxyServer({ port: 3000, apiKey: "my-secret" });
194
- await server.start();
195
- ```
196
-
197
- ```typescript
198
- // Client side — no API keys needed
199
- import { UnifiedLLMClient, MODELS } from "@imenam/unified-llm-client";
200
-
201
- const client = new UnifiedLLMClient(MODELS.GEMINI_FLASH_2_5, {
202
- proxyUrl: "http://localhost:3000",
203
- proxyApiKey: "my-secret",
204
- });
205
-
206
- // The chat() API is identical — streaming and file attachments are fully supported
207
- const response = await client.chat({ message: "Hello!" });
208
- ```
209
-
210
- The proxy server also supports an `apiKeyResolver` for per-request dynamic key resolution (e.g. multi-tenant scenarios):
211
-
212
- ```typescript
213
- import { ProxyServer } from "@imenam/unified-llm-client";
214
- import type { ApiKeyResolver } from "@imenam/unified-llm-client";
215
-
216
- const resolver: ApiKeyResolver<{ userId: string }> = async (ctx) =>
217
- (await userKeyStore.get(ctx.userId)) ?? false;
218
-
219
- const server = new ProxyServer({ port: 3000, apiKey: "my-secret", apiKeyResolver: resolver });
220
- await server.start();
221
- ```
222
-
223
- ```typescript
224
- // Client passes the context — server resolves the key
225
- const client = new UnifiedLLMClient(MODELS.GPT_4O_MINI, {
226
- proxyUrl: "http://localhost:3000",
227
- proxyApiKey: "my-secret",
228
- });
229
-
230
- const response = await client.chat({
231
- message: "Hello!",
232
- apiKeyContext: { userId: "user-123" },
233
- });
234
- ```
235
-
236
- ## API Key Resolver
237
-
238
- Dynamically resolve which API key to use for each request based on a custom context (e.g. per-user key, request metadata):
239
-
240
- ```typescript
241
- import { UnifiedLLMClient, MODELS } from "@imenam/unified-llm-client";
242
- import type { ApiKeyResolver } from "@imenam/unified-llm-client";
243
-
244
- const resolver: ApiKeyResolver<{ userId: string }> = async (ctx) => {
245
- const key = await userKeyStore.get(ctx.userId);
246
- return key ?? false; // return false to deny access
247
- };
248
-
249
- const client = new UnifiedLLMClient(MODELS.GPT_4O_MINI, {
250
- apiKeyResolver: resolver,
251
- });
252
-
253
- const response = await client.chat({
254
- message: "Hello!",
255
- apiKeyContext: { userId: "user-123" },
256
- });
257
- ```
258
-
259
- You can also set a global default resolver for all instances:
260
-
261
- ```typescript
262
- UnifiedLLMClient.setDefaultApiKeyResolver((ctx) => {
263
- return ctx.apiKey ?? false;
264
- });
265
- ```
266
-
267
- > The resolver is called on every `chat()` call. Returning `false` throws an access-denied error.
268
- > The client-side `apiKeyResolver` option is for direct mode only. In proxy mode, the resolver is defined on the `ProxyServer` — the client passes `apiKeyContext` and the server resolves the key.
269
-
270
- ## Cancellation
271
-
272
- Pass an `AbortSignal` to cancel a request mid-flight:
273
-
274
- ```typescript
275
- const ac = new AbortController();
276
- setTimeout(() => ac.abort(), 800);
277
-
278
- try {
279
- await client.chat({
280
- message: "Write a very long story...",
281
- stream: true,
282
- signal: ac.signal,
283
- onChunk: (chunk) => process.stdout.write(chunk.contentChunk ?? ""),
284
- });
285
- } catch (e) {
286
- if (e instanceof Error && e.name === "AbortError") {
287
- console.log("Request cancelled.");
288
- }
289
- }
290
- ```
291
-
292
- ## Logging
293
-
294
- Pass a `logger` callback to capture structured logs for every request:
295
-
296
- ```typescript
297
- const client = new UnifiedLLMClient(MODELS.GPT_4O_MINI, {
298
- logger: (log) => {
299
- console.log(`[${log.provider}] ${log.model} — ${log.usage.totalTokens} tokens`);
300
- },
301
- });
302
- ```
303
-
304
- Set a global default logger applied to all instances:
305
-
306
- ```typescript
307
- UnifiedLLMClient.setDefaultLogger((log) => {
308
- console.log(`[${log.provider}] ${log.model} — ${log.usage.totalTokens} tokens`);
309
- });
310
- ```
311
-
312
- ## API Reference
313
-
314
- ### `new UnifiedLLMClient(model, options?)`
315
-
316
- | Parameter | Type | Description |
317
- |-----------|------|-------------|
318
- | `model` | `Model` | Model object from `MODELS` or a custom `{ provider, modelName, costsPer1MTokens, currency }` |
319
- | `options.logger` | `(log: UnifiedLog) => void` | Optional logging callback |
320
- | `options.proxyUrl` | `string` | Proxy server URL — activates proxy mode |
321
- | `options.proxyApiKey` | `string` | Key sent in the `x-api-key` header (proxy mode) |
322
- | `options.apiKey` | `string` | Static API key passed directly to the provider (overrides env var) |
323
- | `options.apiKeyResolver` | `ApiKeyResolver` | Dynamic per-request key resolver (direct mode only — in proxy mode, set this on `ProxyServer`) |
324
-
325
- ### `client.chat(options)`
326
-
327
- | Option | Type | Default | Description |
328
- |--------|------|---------|-------------|
329
- | `message` | `string \| ChatMessage \| ChatMessage[]` | — | User message or full conversation history |
330
- | `tools` | `ToolDefinition[]` | `[]` | Tools available to the LLM |
331
- | `toolChoice` | `"auto" \| "none" \| string` | `"auto"` | Tool selection strategy |
332
- | `stream` | `boolean` | `false` | Enable streaming |
333
- | `onChunk` | `(chunk: StreamedChatChunk) => void` | — | Streaming chunk callback |
334
- | `onFinalResponse` | `(res: ChatResponse) => void` | — | Final response callback |
335
- | `onLog` | `(log: UnifiedLog) => void` | — | Per-call log callback |
336
- | `config` | `ChatOptionalConfig` | — | Advanced options: `thinkingBudget`, `metadata` |
337
- | `signal` | `AbortSignal` | — | Cancellation signal |
338
- | `apiKeyContext` | `unknown` | — | Context passed to `apiKeyResolver` to resolve the API key |
339
-
340
- ### `ChatResponse`
341
-
342
- ```typescript
343
- interface ChatResponse {
344
- content: string | null;
345
- toolCalls?: ToolCall[];
346
- usage?: {
347
- promptTokens: number;
348
- completionTokens: number;
349
- totalTokens: number;
350
- };
351
- finishReason?: string;
352
- requestId?: string;
353
- raw: unknown;
354
- }
355
- ```
356
-
357
- ## License
358
-
359
- MIT
1
+ # @imenam/unified-llm-client
2
+
3
+ A unified interface for multiple LLM providers — OpenAI, Gemini, Anthropic, and Qwen — with built-in support for tool-calling, streaming, multimodal inputs, audio generation, and a proxy mode for API key isolation.
4
+
5
+ ## Features
6
+
7
+ - **Unified API** — one interface, four providers
8
+ - **Tool-calling** — automatic execution loop with structured results
9
+ - **Streaming** — chunk-by-chunk responses via callbacks
10
+ - **Multimodal** — image and audio file inputs
11
+ - **Audio generation** — Text-to-Speech (Gemini)
12
+ - **Proxy mode** — delegate LLM calls to a server, no API keys on the client
13
+ - **API key resolver** — dynamically resolve API keys per request from a user-provided context
14
+ - **Cancellation** — native `AbortSignal` support
15
+ - **Usage tracking** — token counts and estimated costs
16
+ - **Full TypeScript support** — exported types and `.d.ts` declarations
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install @imenam/unified-llm-client
22
+ ```
23
+
24
+ ## Supported Providers
25
+
26
+ | Provider | Chat | Streaming | Tool-calling | Multimodal | Audio TTS |
27
+ |-----------|:----:|:---------:|:------------:|:----------:|:---------:|
28
+ | OpenAI | ✅ | ✅ | ✅ | ✅ | ❌ |
29
+ | Gemini | ✅ | ✅ | ✅ | ✅ | ✅ |
30
+ | Anthropic | ✅ | ✅ | ✅ | ✅ | ❌ |
31
+ | Qwen | ✅ | ✅ | ✅ | ✅ | ❌ |
32
+ | OpenRouter | ✅ | ✅ | ✅ | ✅ | ❌ |
33
+
34
+ ## Quick Start
35
+
36
+ Set your API keys in a `.env` file:
37
+
38
+ ```env
39
+ OPENAI_API_KEY=sk-...
40
+ GOOGLE_API_KEY=...
41
+ ANTHROPIC_API_KEY=sk-ant-...
42
+ ALIBABA_CLOUD_API_KEY=... # for Qwen
43
+ OPEN_ROUTER_API_KEY=sk-or-... # for OpenRouter (OPENROUTER_API_KEY is also read)
44
+ ```
45
+
46
+ ```typescript
47
+ import { UnifiedLLMClient, MODELS } from "@imenam/unified-llm-client";
48
+
49
+ const client = new UnifiedLLMClient(MODELS.GPT_4O_MINI);
50
+
51
+ const response = await client.chat({
52
+ message: "What is the capital of France?",
53
+ });
54
+
55
+ console.log(response.content); // "The capital of France is Paris."
56
+ ```
57
+
58
+ ## Available Models
59
+
60
+ ```typescript
61
+ import { MODELS } from "@imenam/unified-llm-client";
62
+
63
+ // OpenAI
64
+ MODELS.GPT_4O_MINI
65
+ MODELS.GPT_5
66
+
67
+ // Gemini
68
+ MODELS.GEMINI_FLASH_2_5
69
+ MODELS.GEMINI_2_5_PRO
70
+ MODELS.GEMINI_3_FLASH
71
+ MODELS.GEMINI_3_PRO
72
+ MODELS.GEMINI_3_1_PRO
73
+
74
+ // Anthropic
75
+ MODELS.CLAUDE_SONNET_4_6
76
+ MODELS.CLAUDE_OPUS_4_6
77
+
78
+ // Qwen
79
+ MODELS.QWEN_VL_3
80
+ MODELS.QWEN_VL_FLASH
81
+ MODELS.QWEN_VL_3_FLASH
82
+ ```
83
+
84
+ You can also pass a custom model:
85
+
86
+ ```typescript
87
+ const client = new UnifiedLLMClient({
88
+ provider: "openai",
89
+ modelName: "gpt-4o",
90
+ costsPer1MTokens: { prompt: 2.5, output: 10 },
91
+ currency: "USD",
92
+ });
93
+ ```
94
+
95
+ ## OpenRouter
96
+
97
+ OpenRouter gives access to every model of its catalogue with a single key. Build the
98
+ model from its OpenRouter id:
99
+
100
+ ```typescript
101
+ import { UnifiedLLMClient, openRouterModel } from "@imenam/unified-llm-client";
102
+
103
+ const client = new UnifiedLLMClient(openRouterModel("google/gemini-3-flash-preview"));
104
+ ```
105
+
106
+ - Images are sent as `image_url`, PDFs as `file` (the model reads them natively when it can),
107
+ `.mp3` / `.wav` as `input_audio`.
108
+ - `response.usage.cost` holds the real cost of the call in USD, as reported by OpenRouter.
109
+ - `config.thinkingBudget` (> 0) is sent as `reasoning.max_tokens`.
110
+ - A failed generation throws an error that contains the model's native finish reason,
111
+ e.g. `OpenRouter: generation failed (MALFORMED_FUNCTION_CALL)`.
112
+ - Multi-turn tool calls: copy `response.reasoningDetails` onto the assistant message you
113
+ send back (`{ role: "assistant", tool_calls, reasoningDetails }`). Reasoning models such as
114
+ Gemini 3 reject the next turn otherwise.
115
+ - Optional headers: `OPENROUTER_APP_URL` (`HTTP-Referer`) and `OPENROUTER_APP_NAME` (`X-Title`).
116
+
117
+ ## Tool-calling
118
+
119
+ Define tools with an `executor` function — the client will call them automatically and loop until the LLM produces a final text response.
120
+
121
+ ```typescript
122
+ import { UnifiedLLMClient, MODELS } from "@imenam/unified-llm-client";
123
+ import type { ToolDefinition } from "@imenam/unified-llm-client";
124
+
125
+ const tools: ToolDefinition[] = [
126
+ {
127
+ name: "get_weather",
128
+ description: "Get the current weather for a city",
129
+ parameters: {
130
+ type: "object",
131
+ properties: {
132
+ city: { type: "string", description: "City name" },
133
+ },
134
+ required: ["city"],
135
+ },
136
+ executor: async ({ city }) => {
137
+ return `The weather in ${city} is sunny and 22°C.`;
138
+ },
139
+ },
140
+ ];
141
+
142
+ const client = new UnifiedLLMClient(MODELS.GEMINI_FLASH_2_5);
143
+
144
+ const response = await client.chat({
145
+ message: "What's the weather like in Paris?",
146
+ tools,
147
+ });
148
+
149
+ console.log(response.content);
150
+ ```
151
+
152
+ ## Streaming
153
+
154
+ ```typescript
155
+ const client = new UnifiedLLMClient(MODELS.GPT_4O_MINI);
156
+
157
+ await client.chat({
158
+ message: "Tell me a short story.",
159
+ stream: true,
160
+ onChunk: (chunk) => {
161
+ process.stdout.write(chunk.contentChunk ?? "");
162
+ },
163
+ onFinalResponse: (final) => {
164
+ console.log("\n\nTotal tokens:", final.usage?.totalTokens);
165
+ },
166
+ });
167
+ ```
168
+
169
+ ## Multimodal Inputs
170
+
171
+ Attach images or audio files directly to a message:
172
+
173
+ ```typescript
174
+ const response = await client.chat({
175
+ message: {
176
+ role: "user",
177
+ content: "What do you see in this image?",
178
+ attachedFile: "./photo.jpg",
179
+ },
180
+ });
181
+ ```
182
+
183
+ Multiple files are also supported:
184
+
185
+ ```typescript
186
+ await client.chat({
187
+ message: {
188
+ role: "user",
189
+ content: "Compare these two images.",
190
+ attachedFile: ["./image1.jpg", "./image2.png"],
191
+ },
192
+ });
193
+ ```
194
+
195
+ ## Audio Generation (Gemini)
196
+
197
+ ```typescript
198
+ import { UnifiedLLMClient, MODELS } from "@imenam/unified-llm-client";
199
+
200
+ const client = new UnifiedLLMClient(MODELS.GEMINI_FLASH_2_5);
201
+
202
+ const audio = await client.generateAudio({
203
+ text: "Hello, this is a test of text-to-speech.",
204
+ outputPath: "./output.wav",
205
+ voiceConfig: { voiceName: "Kore" },
206
+ });
207
+ ```
208
+
209
+ ## Proxy Mode
210
+
211
+ Run an HTTP server that holds your API keys server-side, and connect clients without any keys:
212
+
213
+ ```typescript
214
+ import { ProxyServer } from "@imenam/unified-llm-client";
215
+
216
+ // Server side — API keys are read from process.env
217
+ const server = new ProxyServer({ port: 3000, apiKey: "my-secret" });
218
+ await server.start();
219
+ ```
220
+
221
+ ```typescript
222
+ // Client side — no API keys needed
223
+ import { UnifiedLLMClient, MODELS } from "@imenam/unified-llm-client";
224
+
225
+ const client = new UnifiedLLMClient(MODELS.GEMINI_FLASH_2_5, {
226
+ proxyUrl: "http://localhost:3000",
227
+ proxyApiKey: "my-secret",
228
+ });
229
+
230
+ // The chat() API is identical — streaming and file attachments are fully supported
231
+ const response = await client.chat({ message: "Hello!" });
232
+ ```
233
+
234
+ The proxy server also supports an `apiKeyResolver` for per-request dynamic key resolution (e.g. multi-tenant scenarios):
235
+
236
+ ```typescript
237
+ import { ProxyServer } from "@imenam/unified-llm-client";
238
+ import type { ApiKeyResolver } from "@imenam/unified-llm-client";
239
+
240
+ const resolver: ApiKeyResolver<{ userId: string }> = async (ctx) =>
241
+ (await userKeyStore.get(ctx.userId)) ?? false;
242
+
243
+ const server = new ProxyServer({ port: 3000, apiKey: "my-secret", apiKeyResolver: resolver });
244
+ await server.start();
245
+ ```
246
+
247
+ ```typescript
248
+ // Client passes the context — server resolves the key
249
+ const client = new UnifiedLLMClient(MODELS.GPT_4O_MINI, {
250
+ proxyUrl: "http://localhost:3000",
251
+ proxyApiKey: "my-secret",
252
+ });
253
+
254
+ const response = await client.chat({
255
+ message: "Hello!",
256
+ apiKeyContext: { userId: "user-123" },
257
+ });
258
+ ```
259
+
260
+ ## API Key Resolver
261
+
262
+ Dynamically resolve which API key to use for each request based on a custom context (e.g. per-user key, request metadata):
263
+
264
+ ```typescript
265
+ import { UnifiedLLMClient, MODELS } from "@imenam/unified-llm-client";
266
+ import type { ApiKeyResolver } from "@imenam/unified-llm-client";
267
+
268
+ const resolver: ApiKeyResolver<{ userId: string }> = async (ctx) => {
269
+ const key = await userKeyStore.get(ctx.userId);
270
+ return key ?? false; // return false to deny access
271
+ };
272
+
273
+ const client = new UnifiedLLMClient(MODELS.GPT_4O_MINI, {
274
+ apiKeyResolver: resolver,
275
+ });
276
+
277
+ const response = await client.chat({
278
+ message: "Hello!",
279
+ apiKeyContext: { userId: "user-123" },
280
+ });
281
+ ```
282
+
283
+ You can also set a global default resolver for all instances:
284
+
285
+ ```typescript
286
+ UnifiedLLMClient.setDefaultApiKeyResolver((ctx) => {
287
+ return ctx.apiKey ?? false;
288
+ });
289
+ ```
290
+
291
+ > The resolver is called on every `chat()` call. Returning `false` throws an access-denied error.
292
+ > The client-side `apiKeyResolver` option is for direct mode only. In proxy mode, the resolver is defined on the `ProxyServer` — the client passes `apiKeyContext` and the server resolves the key.
293
+
294
+ ## Cancellation
295
+
296
+ Pass an `AbortSignal` to cancel a request mid-flight:
297
+
298
+ ```typescript
299
+ const ac = new AbortController();
300
+ setTimeout(() => ac.abort(), 800);
301
+
302
+ try {
303
+ await client.chat({
304
+ message: "Write a very long story...",
305
+ stream: true,
306
+ signal: ac.signal,
307
+ onChunk: (chunk) => process.stdout.write(chunk.contentChunk ?? ""),
308
+ });
309
+ } catch (e) {
310
+ if (e instanceof Error && e.name === "AbortError") {
311
+ console.log("Request cancelled.");
312
+ }
313
+ }
314
+ ```
315
+
316
+ ## Logging
317
+
318
+ Pass a `logger` callback to capture structured logs for every request:
319
+
320
+ ```typescript
321
+ const client = new UnifiedLLMClient(MODELS.GPT_4O_MINI, {
322
+ logger: (log) => {
323
+ console.log(`[${log.provider}] ${log.model} — ${log.usage.totalTokens} tokens`);
324
+ },
325
+ });
326
+ ```
327
+
328
+ Set a global default logger applied to all instances:
329
+
330
+ ```typescript
331
+ UnifiedLLMClient.setDefaultLogger((log) => {
332
+ console.log(`[${log.provider}] ${log.model} — ${log.usage.totalTokens} tokens`);
333
+ });
334
+ ```
335
+
336
+ ## API Reference
337
+
338
+ ### `new UnifiedLLMClient(model, options?)`
339
+
340
+ | Parameter | Type | Description |
341
+ |-----------|------|-------------|
342
+ | `model` | `Model` | Model object from `MODELS` or a custom `{ provider, modelName, costsPer1MTokens, currency }` |
343
+ | `options.logger` | `(log: UnifiedLog) => void` | Optional logging callback |
344
+ | `options.proxyUrl` | `string` | Proxy server URL — activates proxy mode |
345
+ | `options.proxyApiKey` | `string` | Key sent in the `x-api-key` header (proxy mode) |
346
+ | `options.apiKey` | `string` | Static API key passed directly to the provider (overrides env var) |
347
+ | `options.apiKeyResolver` | `ApiKeyResolver` | Dynamic per-request key resolver (direct mode only — in proxy mode, set this on `ProxyServer`) |
348
+
349
+ ### `client.chat(options)`
350
+
351
+ | Option | Type | Default | Description |
352
+ |--------|------|---------|-------------|
353
+ | `message` | `string \| ChatMessage \| ChatMessage[]` | — | User message or full conversation history |
354
+ | `tools` | `ToolDefinition[]` | `[]` | Tools available to the LLM |
355
+ | `toolChoice` | `"auto" \| "none" \| string` | `"auto"` | Tool selection strategy |
356
+ | `stream` | `boolean` | `false` | Enable streaming |
357
+ | `onChunk` | `(chunk: StreamedChatChunk) => void` | — | Streaming chunk callback |
358
+ | `onFinalResponse` | `(res: ChatResponse) => void` | — | Final response callback |
359
+ | `onLog` | `(log: UnifiedLog) => void` | — | Per-call log callback |
360
+ | `config` | `ChatOptionalConfig` | — | Advanced options: `thinkingBudget`, `metadata` |
361
+ | `signal` | `AbortSignal` | — | Cancellation signal |
362
+ | `apiKeyContext` | `unknown` | — | Context passed to `apiKeyResolver` to resolve the API key |
363
+
364
+ ### `ChatResponse`
365
+
366
+ ```typescript
367
+ interface ChatResponse {
368
+ content: string | null;
369
+ toolCalls?: ToolCall[];
370
+ usage?: {
371
+ promptTokens: number;
372
+ completionTokens: number;
373
+ totalTokens: number;
374
+ };
375
+ finishReason?: string;
376
+ requestId?: string;
377
+ raw: unknown;
378
+ }
379
+ ```
380
+
381
+ ## License
382
+
383
+ MIT