@luanpoppe/ai 1.1.4 → 1.1.6

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 (151) hide show
  1. package/.env.example +13 -1
  2. package/README.md +795 -0
  3. package/dist/@types/embedding-model-names.d.ts +11 -0
  4. package/dist/@types/embedding-model-names.d.ts.map +1 -0
  5. package/dist/@types/embedding-model-names.js +3 -0
  6. package/dist/@types/embedding-model-names.js.map +1 -0
  7. package/dist/@types/model-names.d.ts +21 -5
  8. package/dist/@types/model-names.d.ts.map +1 -1
  9. package/dist/@types/openrouter-provider.d.ts +25 -0
  10. package/dist/@types/openrouter-provider.d.ts.map +1 -0
  11. package/dist/@types/openrouter-provider.js +3 -0
  12. package/dist/@types/openrouter-provider.js.map +1 -0
  13. package/dist/ai.d.ts +7 -0
  14. package/dist/ai.d.ts.map +1 -1
  15. package/dist/ai.js +56 -5
  16. package/dist/ai.js.map +1 -1
  17. package/dist/audio/gemini-transcription.d.ts +4 -0
  18. package/dist/audio/gemini-transcription.d.ts.map +1 -0
  19. package/dist/audio/gemini-transcription.js +37 -0
  20. package/dist/audio/gemini-transcription.js.map +1 -0
  21. package/dist/audio/index.d.ts +33 -0
  22. package/dist/audio/index.d.ts.map +1 -0
  23. package/dist/audio/index.js +43 -0
  24. package/dist/audio/index.js.map +1 -0
  25. package/dist/audio/openrouter-client.d.ts +2 -0
  26. package/dist/audio/openrouter-client.d.ts.map +1 -0
  27. package/dist/audio/openrouter-client.js +7 -0
  28. package/dist/audio/openrouter-client.js.map +1 -0
  29. package/dist/audio/speech-openai.d.ts +4 -0
  30. package/dist/audio/speech-openai.d.ts.map +1 -0
  31. package/dist/audio/speech-openai.js +65 -0
  32. package/dist/audio/speech-openai.js.map +1 -0
  33. package/dist/audio/speech-openrouter.d.ts +4 -0
  34. package/dist/audio/speech-openrouter.d.ts.map +1 -0
  35. package/dist/audio/speech-openrouter.js +89 -0
  36. package/dist/audio/speech-openrouter.js.map +1 -0
  37. package/dist/audio/transcription-openai.d.ts +11 -0
  38. package/dist/audio/transcription-openai.d.ts.map +1 -0
  39. package/dist/audio/transcription-openai.js +204 -0
  40. package/dist/audio/transcription-openai.js.map +1 -0
  41. package/dist/audio/transcription-openrouter.d.ts +4 -0
  42. package/dist/audio/transcription-openrouter.d.ts.map +1 -0
  43. package/dist/audio/transcription-openrouter.js +44 -0
  44. package/dist/audio/transcription-openrouter.js.map +1 -0
  45. package/dist/audio/types.d.ts +92 -0
  46. package/dist/audio/types.d.ts.map +1 -0
  47. package/dist/audio/types.js +6 -0
  48. package/dist/audio/types.js.map +1 -0
  49. package/dist/audio/utils.d.ts +5 -0
  50. package/dist/audio/utils.d.ts.map +1 -0
  51. package/dist/audio/utils.js +25 -0
  52. package/dist/audio/utils.js.map +1 -0
  53. package/dist/embeddings/embed-gemini.d.ts +3 -0
  54. package/dist/embeddings/embed-gemini.d.ts.map +1 -0
  55. package/dist/embeddings/embed-gemini.js +38 -0
  56. package/dist/embeddings/embed-gemini.js.map +1 -0
  57. package/dist/embeddings/embed-openai.d.ts +3 -0
  58. package/dist/embeddings/embed-openai.d.ts.map +1 -0
  59. package/dist/embeddings/embed-openai.js +44 -0
  60. package/dist/embeddings/embed-openai.js.map +1 -0
  61. package/dist/embeddings/embed-openrouter.d.ts +3 -0
  62. package/dist/embeddings/embed-openrouter.d.ts.map +1 -0
  63. package/dist/embeddings/embed-openrouter.js +47 -0
  64. package/dist/embeddings/embed-openrouter.js.map +1 -0
  65. package/dist/embeddings/index.d.ts +19 -0
  66. package/dist/embeddings/index.d.ts.map +1 -0
  67. package/dist/embeddings/index.js +36 -0
  68. package/dist/embeddings/index.js.map +1 -0
  69. package/dist/embeddings/route.d.ts +12 -0
  70. package/dist/embeddings/route.d.ts.map +1 -0
  71. package/dist/embeddings/route.js +42 -0
  72. package/dist/embeddings/route.js.map +1 -0
  73. package/dist/embeddings/types.d.ts +37 -0
  74. package/dist/embeddings/types.d.ts.map +1 -0
  75. package/dist/embeddings/types.js +6 -0
  76. package/dist/embeddings/types.js.map +1 -0
  77. package/dist/embeddings/utils.d.ts +6 -0
  78. package/dist/embeddings/utils.d.ts.map +1 -0
  79. package/dist/embeddings/utils.js +25 -0
  80. package/dist/embeddings/utils.js.map +1 -0
  81. package/dist/index.d.ts +12 -2
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +16 -3
  84. package/dist/index.js.map +1 -1
  85. package/dist/langchain/audio-transcription.d.ts +8 -13
  86. package/dist/langchain/audio-transcription.d.ts.map +1 -1
  87. package/dist/langchain/audio-transcription.js +8 -92
  88. package/dist/langchain/audio-transcription.js.map +1 -1
  89. package/dist/langchain/messages.d.ts +6 -1
  90. package/dist/langchain/messages.d.ts.map +1 -1
  91. package/dist/langchain/messages.js +35 -7
  92. package/dist/langchain/messages.js.map +1 -1
  93. package/dist/langchain/models.d.ts +38 -0
  94. package/dist/langchain/models.d.ts.map +1 -1
  95. package/dist/langchain/models.js +63 -4
  96. package/dist/langchain/models.js.map +1 -1
  97. package/dist/langchain/structured-output.d.ts +9 -0
  98. package/dist/langchain/structured-output.d.ts.map +1 -0
  99. package/dist/langchain/structured-output.js +70 -0
  100. package/dist/langchain/structured-output.js.map +1 -0
  101. package/dist/langchain/tools.d.ts +1 -1
  102. package/dist/utils/openrouter-client.d.ts +4 -0
  103. package/dist/utils/openrouter-client.d.ts.map +1 -0
  104. package/dist/utils/openrouter-client.js +16 -0
  105. package/dist/utils/openrouter-client.js.map +1 -0
  106. package/docs/LANGCHAIN_WHISPER_WINDOWS_BUG.md +2 -0
  107. package/package.json +4 -4
  108. package/src/@types/embedding-model-names.ts +36 -0
  109. package/src/@types/model-names.ts +104 -6
  110. package/src/@types/openrouter-provider.ts +27 -0
  111. package/src/ai.ts +89 -10
  112. package/src/audio/gemini-transcription.ts +47 -0
  113. package/src/audio/index.ts +101 -0
  114. package/src/audio/openrouter-client.ts +4 -0
  115. package/src/audio/speech-openai.ts +79 -0
  116. package/src/audio/speech-openrouter.ts +121 -0
  117. package/src/audio/transcription-openai.ts +259 -0
  118. package/src/audio/transcription-openrouter.ts +59 -0
  119. package/src/audio/types.ts +127 -0
  120. package/src/audio/utils.ts +20 -0
  121. package/src/embeddings/embed-gemini.ts +54 -0
  122. package/src/embeddings/embed-openai.ts +53 -0
  123. package/src/embeddings/embed-openrouter.ts +64 -0
  124. package/src/embeddings/index.ts +83 -0
  125. package/src/embeddings/route.ts +62 -0
  126. package/src/embeddings/types.ts +53 -0
  127. package/src/embeddings/utils.ts +21 -0
  128. package/src/index.ts +52 -5
  129. package/src/langchain/audio-transcription.ts +19 -90
  130. package/src/langchain/messages.ts +84 -12
  131. package/src/langchain/models.ts +130 -3
  132. package/src/langchain/structured-output.ts +77 -0
  133. package/src/utils/openrouter-client.ts +10 -0
  134. package/tests/e2e/README.md +53 -0
  135. package/tests/e2e/ai-audio-multiprovider.test.ts +118 -0
  136. package/tests/e2e/ai-deepseek-structured-output.test.ts +133 -0
  137. package/tests/e2e/ai-embeddings.test.ts +87 -0
  138. package/tests/e2e/ai-local-models.test.ts +65 -0
  139. package/tests/unit/audio/index.test.ts +17 -0
  140. package/tests/unit/audio/speech-openai.test.ts +74 -0
  141. package/tests/unit/audio/speech-openrouter.test.ts +88 -0
  142. package/tests/unit/audio/transcription-openai.test.ts +130 -0
  143. package/tests/unit/audio/transcription-openrouter.test.ts +82 -0
  144. package/tests/unit/embeddings/embed-gemini.test.ts +52 -0
  145. package/tests/unit/embeddings/embed-openai.test.ts +68 -0
  146. package/tests/unit/embeddings/embed-openrouter.test.ts +62 -0
  147. package/tests/unit/embeddings/index.test.ts +55 -0
  148. package/tests/unit/index.test.ts +209 -0
  149. package/tests/unit/langchain/messages.test.ts +37 -0
  150. package/tests/unit/langchain/models.test.ts +223 -1
  151. package/tests/unit/langchain/structured-output.test.ts +49 -0
package/README.md ADDED
@@ -0,0 +1,795 @@
1
+ # @luanpoppe/ai
2
+
3
+ TypeScript library for building LLM-powered applications with **OpenAI**, **Google Gemini**, **OpenRouter** (Anthropic, DeepSeek, Qwen, and more), and **local models** via **Ollama** or **OpenAI-compatible** servers (LM Studio, Ollama `/v1`, vLLM, etc.). Built on [LangChain](https://js.langchain.com/) agents and [LangGraph](https://langchain-ai.github.io/langgraph/) checkpointers.
4
+
5
+ It provides a single `AI` facade for chat, structured JSON output, conversation memory, multimodal messages, a dedicated **`AIAudio`** API for speech-to-text (STT) and text-to-speech (TTS), and **`AIEmbeddings`** for vector embeddings via OpenAI, Google Gemini, and OpenRouter.
6
+
7
+ ---
8
+
9
+ ## Table of contents
10
+
11
+ - [Installation](#installation)
12
+ - [Quick start](#quick-start)
13
+ - [Configuration](#configuration)
14
+ - [Chat completions (`AI.call`)](#chat-completions-aicall)
15
+ - [Structured output (`AI.callStructuredOutput`)](#structured-output-aicallstructuredoutput)
16
+ - [Model names](#model-names)
17
+ - [Local models (Ollama and LM Studio)](#local-models-ollama-and-lm-studio)
18
+ - [OpenRouter provider routing](#openrouter-provider-routing)
19
+ - [Retry and model fallback](#retry-and-model-fallback)
20
+ - [Messages (`AIMessages`)](#messages-aimessages)
21
+ - [Tools (`AITools`)](#tools-aitools)
22
+ - [Conversation memory (`AIMemory`)](#conversation-memory-aimemory)
23
+ - [Audio (`AIAudio`)](#audio-aiaudio)
24
+ - [Embeddings (`AIEmbeddings`)](#embeddings-aiembeddings)
25
+ - [Utilities](#utilities)
26
+ - [Advanced: raw LangChain agent](#advanced-raw-langchain-agent)
27
+ - [Deprecated exports](#deprecated-exports)
28
+ - [Development](#development)
29
+ - [License](#license)
30
+
31
+ ---
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ npm install @luanpoppe/ai
37
+ # or
38
+ pnpm add @luanpoppe/ai
39
+ ```
40
+
41
+ **Peer requirements:** Node.js 20+ and API keys for the cloud providers you use. Local models only need a running Ollama or OpenAI-compatible server (no cloud API key).
42
+
43
+ **Optional dependencies** (install only if you need that checkpointer):
44
+
45
+ | Package | Use case |
46
+ |---------|----------|
47
+ | `@langchain/langgraph-checkpoint-sqlite` | SQLite persistence |
48
+ | `@langchain/langgraph-checkpoint-postgres` | PostgreSQL persistence |
49
+ | `@langchain/langgraph-checkpoint-redis` | Redis persistence |
50
+ | `@langchain/langgraph-checkpoint-mongodb` + `mongodb` | MongoDB persistence |
51
+
52
+ ---
53
+
54
+ ## Quick start
55
+
56
+ ```typescript
57
+ import { AI, AIMessages } from "@luanpoppe/ai";
58
+
59
+ const ai = new AI({
60
+ openAIApiKey: process.env.OPENAI_API_KEY,
61
+ openRouterApiKey: process.env.OPENROUTER_API_KEY,
62
+ googleGeminiToken: process.env.GOOGLE_GEMINI_TOKEN,
63
+ });
64
+
65
+ const { text } = await ai.call({
66
+ aiModel: "gpt-4o",
67
+ messages: [AIMessages.human("Say hello in one sentence.")],
68
+ });
69
+
70
+ console.log(text);
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Configuration
76
+
77
+ Pass API keys to the `AI` constructor. Keys are read per request based on the model prefix you choose.
78
+
79
+ ```typescript
80
+ const ai = new AI({
81
+ openAIApiKey: "sk-...", // models starting with gpt*
82
+ googleGeminiToken: "...", // models starting with gemini*
83
+ openRouterApiKey: "sk-or-...", // models starting with openrouter/*
84
+ aiModelsFallback: ["gpt-4o-mini"], // default fallback list (optional)
85
+ memory: { type: "memory" }, // optional — see AIMemory
86
+ });
87
+ ```
88
+
89
+ | Constructor option | Description |
90
+ |--------------------|-------------|
91
+ | `openAIApiKey` | OpenAI API key |
92
+ | `googleGeminiToken` | Google AI / Gemini API key |
93
+ | `openRouterApiKey` | OpenRouter API key |
94
+ | `ollamaBaseUrl` | Ollama native API base URL for `ollama/*` models (default: `http://127.0.0.1:11434`) |
95
+ | `localBaseUrl` | OpenAI-compatible base URL for `local/*` models (e.g. `http://localhost:1234/v1` for LM Studio) |
96
+ | `localApiKey` | API key sent to the local server (many accept any value; default: `"not-needed"`) |
97
+ | `aiModelsFallback` | Default model list used when `aiModelsFallback` is omitted on `call` |
98
+ | `memory` | `MemoryConfig` or `AIMemory` instance for thread persistence |
99
+ | `checkpointer` | Custom LangGraph `BaseCheckpointSaver` (advanced) |
100
+
101
+ **Per-call override:** `modelConfig.baseUrl` overrides `ollamaBaseUrl` / `localBaseUrl` for that request. For Ollama, `modelConfig.numCtx` sets context window size.
102
+
103
+ ---
104
+
105
+ ## Chat completions (`AI.call`)
106
+
107
+ ```typescript
108
+ import { AI, AIMessages } from "@luanpoppe/ai";
109
+
110
+ const { text, messages } = await ai.call({
111
+ aiModel: "openrouter/anthropic/claude-sonnet-4.6",
112
+ systemPrompt: "You are a helpful assistant.",
113
+ messages: [
114
+ AIMessages.human("What is 2 + 2?"),
115
+ ],
116
+ modelConfig: {
117
+ temperature: 0.2,
118
+ maxTokens: 1024,
119
+ // OpenRouter-only:
120
+ openRouterProvider: { sort: "price" },
121
+ openRouterAllowAllProviders: true,
122
+ },
123
+ maxRetries: 3,
124
+ threadId: "user-123", // required when memory/checkpointer is enabled
125
+ });
126
+ ```
127
+
128
+ ### Parameters (`AICallParams`)
129
+
130
+ | Field | Description |
131
+ |-------|-------------|
132
+ | `aiModel` | Model id — see [Model names](#model-names) |
133
+ | `messages` | `SystemMessage`, `HumanMessage`, or `AIMessage` (from `AIMessages` or LangChain) |
134
+ | `systemPrompt` | Optional system instructions |
135
+ | `modelConfig` | `maxTokens`, `temperature`, `reasoningEffort` (OpenAI), `baseUrl`, `numCtx` (Ollama), OpenRouter provider options |
136
+ | `maxRetries` | Retries per model via LangChain `modelRetryMiddleware` (default: 3) |
137
+ | `aiModelsFallback` | Try these models if the primary fails after retries |
138
+ | `threadId` | Conversation id when using memory/checkpointer |
139
+ | `agent.tools` | LangChain tools |
140
+ | `agent.middleware` | Extra agent middleware |
141
+
142
+ ---
143
+
144
+ ## Structured output (`AI.callStructuredOutput`)
145
+
146
+ Returns JSON validated with a **Zod** schema.
147
+
148
+ ```typescript
149
+ import z from "zod";
150
+ import { AI, AIMessages } from "@luanpoppe/ai";
151
+
152
+ const schema = z.object({
153
+ name: z.string(),
154
+ age: z.number(),
155
+ });
156
+
157
+ const { response } = await ai.callStructuredOutput({
158
+ aiModel: "gpt-4o",
159
+ outputSchema: schema,
160
+ messages: [AIMessages.human("Extract: João is 30 years old.")],
161
+ });
162
+
163
+ // response: { name: "João", age: 30 }
164
+ ```
165
+
166
+ ### OpenAI / OpenRouter (GPT)
167
+
168
+ Uses LangChain `responseFormat` with JSON schema. Optional fields (`.optional()`) are normalized to `.nullable()` for OpenAI compatibility.
169
+
170
+ ### DeepSeek via OpenRouter
171
+
172
+ DeepSeek models do **not** support `json_schema`. The library automatically:
173
+
174
+ 1. Sets `response_format: { type: "json_object" }` on OpenRouter
175
+ 2. Merges the Zod schema into the system prompt
176
+ 3. Parses JSON from the assistant message if `structuredResponse` is missing
177
+
178
+ ```typescript
179
+ await ai.callStructuredOutput({
180
+ aiModel: "openrouter/deepseek/deepseek-v4-flash",
181
+ outputSchema: schema,
182
+ messages: [AIMessages.human("Return JSON only.")],
183
+ });
184
+ ```
185
+
186
+ ### Local models (`ollama/*`, `local/*`)
187
+
188
+ Structured output uses LangChain `responseFormat` when the model supports it. Capability varies by model; see [Local models](#local-models-ollama-and-lm-studio).
189
+
190
+ ---
191
+
192
+ ## Model names
193
+
194
+ `AIModelNames` is a TypeScript union for autocomplete and safety.
195
+
196
+ | Prefix | Provider | Example |
197
+ |--------|----------|---------|
198
+ | `gpt-*`, `o3`, `o4-mini` | OpenAI | `"gpt-4o"`, `"gpt-5.4"` |
199
+ | `gemini-*` | Google Gemini | `"gemini-2.5-flash"` |
200
+ | `openrouter/<provider>/<model>` | OpenRouter | `"openrouter/deepseek/deepseek-v4-flash"` |
201
+ | `ollama/<model>` | Ollama (native API) | `"ollama/llama3.2"`, `"ollama/qwen2.5:7b"` |
202
+ | `local/<model>` | OpenAI-compatible local server | `"local/meta-llama/Llama-3.2-3B-Instruct"` |
203
+
204
+ OpenRouter slug format: `openrouter/{provider}/{model}` — e.g. `openrouter/openai/gpt-4o`, `openrouter/anthropic/claude-sonnet-4.6`, `openrouter/google/gemini-2.5-flash`.
205
+
206
+ For `ollama/*` and `local/*`, the segment after the prefix must match the model name on your server (`ollama list` or LM Studio model id).
207
+
208
+ Use `AIModels` directly if you need low-level LangChain chat models:
209
+
210
+ ```typescript
211
+ import { AIModels } from "@luanpoppe/ai";
212
+
213
+ const model = AIModels.openrouter({
214
+ model: "deepseek/deepseek-v4-flash",
215
+ apiKey: process.env.OPENROUTER_API_KEY!,
216
+ });
217
+
218
+ const ollama = AIModels.ollama({
219
+ model: "llama3.2",
220
+ baseUrl: "http://127.0.0.1:11434",
221
+ });
222
+
223
+ const lmStudio = AIModels.openaiCompatible({
224
+ model: "your-model-id",
225
+ baseURL: "http://localhost:1234/v1",
226
+ });
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Local models (Ollama and LM Studio)
232
+
233
+ Run LLMs on your machine without cloud API keys. Two prefixes cover the common setups:
234
+
235
+ | Prefix | Server | Typical URL |
236
+ |--------|--------|-------------|
237
+ | `ollama/` | [Ollama](https://ollama.com/) native API | `http://127.0.0.1:11434` |
238
+ | `local/` | LM Studio, Ollama OpenAI mode (`/v1`), vLLM, LocalAI | `http://localhost:1234/v1` or `http://127.0.0.1:11434/v1` |
239
+
240
+ ### Quick example
241
+
242
+ ```typescript
243
+ const ai = new AI({
244
+ ollamaBaseUrl: "http://127.0.0.1:11434",
245
+ localBaseUrl: "http://localhost:1234/v1", // LM Studio local server
246
+ localApiKey: "lm-studio", // optional — many servers ignore this
247
+ });
248
+
249
+ // Ollama: ollama pull llama3.2 first
250
+ const { text: fromOllama } = await ai.call({
251
+ aiModel: "ollama/llama3.2",
252
+ messages: [AIMessages.human("Hello!")],
253
+ });
254
+
255
+ // LM Studio: start the local server and use the exact model id shown in the UI
256
+ const { text: fromLmStudio } = await ai.call({
257
+ aiModel: "local/your-model-id",
258
+ messages: [AIMessages.human("Hello!")],
259
+ });
260
+ ```
261
+
262
+ ### Ollama via OpenAI-compatible API
263
+
264
+ If you prefer a single integration style, point `localBaseUrl` at Ollama’s OpenAI endpoint and use the `local/` prefix:
265
+
266
+ ```typescript
267
+ const ai = new AI({
268
+ localBaseUrl: "http://127.0.0.1:11434/v1",
269
+ });
270
+
271
+ await ai.call({
272
+ aiModel: "local/llama3.2",
273
+ messages: [AIMessages.human("Hello!")],
274
+ });
275
+ ```
276
+
277
+ ### Structured output and tools
278
+
279
+ - **`callStructuredOutput`:** support depends on the local model. Small models may need plain JSON in the reply; the library does not apply OpenAI schema normalization to `ollama/*` or `local/*`.
280
+ - **Tools (`agent.tools`):** requires a model with function calling (e.g. some Llama 3.1+ variants on Ollama).
281
+ - **Audio:** STT/TTS stays on `AIAudio` (cloud APIs). Local chat does not replace Whisper/TTS.
282
+ - **Fallback:** `aiModelsFallback` can mix local and cloud models, e.g. `["gpt-4o-mini"]` after `ollama/llama3.2`.
283
+
284
+ ### Constants
285
+
286
+ ```typescript
287
+ import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_LOCAL_API_KEY } from "@luanpoppe/ai";
288
+ ```
289
+
290
+ ---
291
+
292
+ ## OpenRouter provider routing
293
+
294
+ Control which upstream hosts OpenRouter uses ([docs](https://openrouter.ai/docs/guides/routing/provider-selection)).
295
+
296
+ ```typescript
297
+ await ai.call({
298
+ aiModel: "openrouter/deepseek/deepseek-v4-pro",
299
+ messages: [...],
300
+ modelConfig: {
301
+ // Custom routing (overrides defaults):
302
+ openRouterProvider: {
303
+ only: ["deepseek"],
304
+ sort: "price",
305
+ max_price: { prompt: 1, completion: 2 },
306
+ },
307
+ // Or allow OpenRouter’s default routing for all providers:
308
+ openRouterAllowAllProviders: true,
309
+ },
310
+ });
311
+ ```
312
+
313
+ **Default behavior:** for models whose slug starts with `deepseek/`, the library applies `provider: { only: ["deepseek"] }` unless you pass `openRouterProvider` or `openRouterAllowAllProviders: true`.
314
+
315
+ `resolveOpenRouterProvider(model, config)` is exported if you build custom OpenRouter requests.
316
+
317
+ ---
318
+
319
+ ## Retry and model fallback
320
+
321
+ 1. **Retries:** `maxRetries` (default 3) with exponential backoff on the same model.
322
+ 2. **Fallback:** if all retries fail, the next model in `aiModelsFallback` is tried.
323
+
324
+ ```typescript
325
+ await ai.call({
326
+ aiModel: "openrouter/deepseek/deepseek-v4-flash",
327
+ aiModelsFallback: [
328
+ "openrouter/openai/gpt-4o-mini",
329
+ "gpt-4o-mini",
330
+ ],
331
+ messages: [...],
332
+ });
333
+ ```
334
+
335
+ ---
336
+
337
+ ## Messages (`AIMessages`)
338
+
339
+ Helpers for LangChain message types.
340
+
341
+ ```typescript
342
+ import { AIMessages } from "@luanpoppe/ai";
343
+
344
+ AIMessages.system("You are a tutor.");
345
+ AIMessages.human("Explain recursion.");
346
+ AIMessages.ai("Recursion is...");
347
+ ```
348
+
349
+ ### Images (multimodal)
350
+
351
+ ```typescript
352
+ const msg = AIMessages.humanImage({
353
+ image: { buffer: imageBuffer, filename: "photo.jpg" },
354
+ text: "Describe this image.",
355
+ });
356
+ ```
357
+
358
+ ### Audio in chat
359
+
360
+ OpenAI and Anthropic chat APIs do not accept raw audio in the same way as Gemini. This library handles that for you:
361
+
362
+ | `provider` | Behavior |
363
+ |------------|----------|
364
+ | `"gemini"` | Native audio block (multimodal) |
365
+ | `"openai"` | Pre-transcribes with Whisper, sends text |
366
+ | `"openrouter"` | Pre-transcribes via OpenRouter STT, sends text |
367
+ | `"auto"` | OpenAI key → OpenAI; else OpenRouter key → OpenRouter; else Gemini |
368
+
369
+ ```typescript
370
+ const msg = await AIMessages.humanAudio({
371
+ audio: { buffer: audioBuffer, filename: "note.mp3" },
372
+ text: "Summarize this recording.",
373
+ provider: "openai",
374
+ openAIApiKey: process.env.OPENAI_API_KEY,
375
+ });
376
+
377
+ await ai.call({ aiModel: "gpt-4o", messages: [msg] });
378
+ ```
379
+
380
+ > **Anthropic:** there is no native public STT/TTS API. Use STT first, then chat. The constant `ANTHROPIC_NO_NATIVE_AUDIO` documents this limitation.
381
+
382
+ ---
383
+
384
+ ## Tools (`AITools`)
385
+
386
+ Wrap LangChain tools with Zod schemas:
387
+
388
+ ```typescript
389
+ import z from "zod";
390
+ import { AITools } from "@luanpoppe/ai";
391
+
392
+ const tools = new AITools();
393
+
394
+ const getWeather = tools.createTool({
395
+ name: "get_weather",
396
+ description: "Get weather for a city",
397
+ schema: z.object({ city: z.string() }),
398
+ toolFunction: async ({ city }) => `Weather in ${city}: sunny`,
399
+ });
400
+
401
+ await ai.call({
402
+ aiModel: "gpt-4o",
403
+ messages: [AIMessages.human("Weather in Paris?")],
404
+ agent: { tools: [getWeather] },
405
+ });
406
+ ```
407
+
408
+ ---
409
+
410
+ ## Conversation memory (`AIMemory`)
411
+
412
+ Persist multi-turn conversations with LangGraph checkpointers.
413
+
414
+ ```typescript
415
+ const ai = new AI({
416
+ openAIApiKey: process.env.OPENAI_API_KEY!,
417
+ memory: { type: "sqlite", connectionString: "./chat.db" },
418
+ });
419
+
420
+ await ai.call({
421
+ aiModel: "gpt-4o",
422
+ threadId: "session-42",
423
+ messages: [AIMessages.human("My name is Ana.")],
424
+ });
425
+
426
+ await ai.call({
427
+ aiModel: "gpt-4o",
428
+ threadId: "session-42",
429
+ messages: [AIMessages.human("What is my name?")],
430
+ });
431
+
432
+ const { messages } = await ai.memory.getHistory("session-42");
433
+ ```
434
+
435
+ ### Checkpointer types
436
+
437
+ | `type` | Config |
438
+ |--------|--------|
439
+ | `"memory"` | In-memory (dev/tests) |
440
+ | `"sqlite"` | `{ connectionString: "./data.db" }` or `":memory:"` |
441
+ | `"postgres"` | `{ connectionString: "postgresql://..." }` |
442
+ | `"redis"` | `{ url: "redis://..." }` |
443
+ | `"mongodb"` | `{ url: "..." }` or `{ client: mongoClient }` |
444
+
445
+ When `memory` or `checkpointer` is set, **`threadId` is required** on every `call` / `callStructuredOutput`.
446
+
447
+ ---
448
+
449
+ ## Audio (`AIAudio`)
450
+
451
+ Standalone STT/TTS APIs (not coupled to `AI.call`). Pass API keys as method arguments.
452
+
453
+ ### OpenAI — speech-to-text
454
+
455
+ ```typescript
456
+ import { AIAudio } from "@luanpoppe/ai";
457
+ import fs from "fs";
458
+
459
+ const buffer = fs.readFileSync("meeting.mp3");
460
+
461
+ // Simple text
462
+ const text = await AIAudio.transcribeWithWhisper(
463
+ buffer,
464
+ { languageIn2Digits: "pt" },
465
+ process.env.OPENAI_API_KEY,
466
+ );
467
+
468
+ // Rich response (words, segments, duration)
469
+ const detailed = await AIAudio.transcribeDetailedOpenAI(
470
+ buffer,
471
+ {
472
+ model: "whisper-1",
473
+ responseFormat: "verbose_json",
474
+ timestampGranularities: ["word", "segment"],
475
+ },
476
+ process.env.OPENAI_API_KEY,
477
+ );
478
+
479
+ // Subtitles
480
+ const srt = await AIAudio.transcribeToSrtOpenAI(buffer, {}, apiKey);
481
+ const vtt = await AIAudio.transcribeToVttOpenAI(buffer, {}, apiKey);
482
+
483
+ // Speaker diarization
484
+ const diarized = await AIAudio.transcribeDiarizedOpenAI(buffer, {}, apiKey);
485
+
486
+ // Translate to English
487
+ const english = await AIAudio.translateOpenAI(buffer, {}, apiKey);
488
+ ```
489
+
490
+ Supported Whisper / transcription models include `whisper-1`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, and `gpt-4o-transcribe-diarize`.
491
+
492
+ ### OpenAI — text-to-speech
493
+
494
+ ```typescript
495
+ const { audio, contentType } = await AIAudio.speakOpenAI(
496
+ "Hello!",
497
+ {
498
+ model: "gpt-4o-mini-tts",
499
+ voice: "nova",
500
+ instructions: "Speak warmly.", // gpt-4o-mini-tts* only
501
+ responseFormat: "mp3",
502
+ },
503
+ process.env.OPENAI_API_KEY,
504
+ );
505
+
506
+ // Streaming
507
+ const stream = await AIAudio.speakOpenAIStream("Hello!", { voice: "nova" }, apiKey);
508
+ ```
509
+
510
+ ### OpenRouter — STT
511
+
512
+ Uses `POST https://openrouter.ai/api/v1/audio/transcriptions`.
513
+
514
+ ```typescript
515
+ const { text, usage } = await AIAudio.transcribeOpenRouter(
516
+ buffer,
517
+ {
518
+ model: "openai/gpt-4o-mini-transcribe",
519
+ format: "mp3",
520
+ language: "pt",
521
+ openRouterProvider: { sort: "price" },
522
+ },
523
+ process.env.OPENROUTER_API_KEY,
524
+ );
525
+ ```
526
+
527
+ ### OpenRouter — TTS
528
+
529
+ Uses `POST https://openrouter.ai/api/v1/audio/speech`.
530
+
531
+ Discover models:
532
+
533
+ ```http
534
+ GET https://openrouter.ai/api/v1/models?output_modalities=speech
535
+ ```
536
+
537
+ Each model lists `supported_voices` in the response.
538
+
539
+ ```typescript
540
+ // Gemini TTS (pcm only on OpenRouter)
541
+ const gemini = await AIAudio.speakOpenRouter(
542
+ "Summary of the day.",
543
+ {
544
+ model: "google/gemini-3.1-flash-tts-preview",
545
+ voice: "Kore",
546
+ // responseFormat defaults to "pcm" for gemini*TTS* models
547
+ },
548
+ process.env.OPENROUTER_API_KEY,
549
+ );
550
+
551
+ // Mistral TTS (mp3)
552
+ const mistral = await AIAudio.speakOpenRouter(
553
+ "Hello world.",
554
+ {
555
+ model: "mistralai/voxtral-mini-tts-2603",
556
+ voice: "en_paul_neutral",
557
+ responseFormat: "mp3",
558
+ },
559
+ process.env.OPENROUTER_API_KEY,
560
+ );
561
+ ```
562
+
563
+ > Enable the required **providers** in your OpenRouter account settings if you get `No allowed providers are available`.
564
+
565
+ ### Gemini — transcription helper
566
+
567
+ Multimodal transcription without `@google/genai` (uses LangChain + your Gemini token):
568
+
569
+ ```typescript
570
+ const transcript = await AIAudio.transcribeWithGeminiPrompt(
571
+ buffer,
572
+ {
573
+ model: "gemini-2.5-flash",
574
+ prompt: "Transcribe with approximate [mm:ss] timestamps.",
575
+ googleGeminiToken: process.env.GOOGLE_GEMINI_TOKEN,
576
+ },
577
+ );
578
+ ```
579
+
580
+ ### `AIAudio` method reference
581
+
582
+ | Method | Description |
583
+ |--------|-------------|
584
+ | `transcribeWithWhisper` | OpenAI STT → plain text |
585
+ | `transcribeDetailedOpenAI` | OpenAI STT → detailed object |
586
+ | `transcribeToSrtOpenAI` / `transcribeToVttOpenAI` | Subtitle formats |
587
+ | `transcribeDiarizedOpenAI` | Speaker diarization |
588
+ | `translateOpenAI` | Translate audio to English |
589
+ | `transcribeFileWithWhisper` / `transcribeFileDetailedOpenAI` | File path variants |
590
+ | `transcribeOpenRouter` | OpenRouter STT |
591
+ | `speakOpenAI` / `speakOpenAIStream` | OpenAI TTS |
592
+ | `speakOpenRouter` / `speakOpenRouterStream` | OpenRouter TTS |
593
+ | `transcribeWithGeminiPrompt` | Gemini multimodal STT |
594
+
595
+ ---
596
+
597
+ ## Embeddings (`AIEmbeddings`)
598
+
599
+ Standalone embedding APIs (not coupled to `AI.call`). Use typed model names from `AIEmbeddingModelNames` (separate from chat `AIModelNames`).
600
+
601
+ > **Anthropic:** there is no public Anthropic embeddings API. The package exports `ANTHROPIC_NO_NATIVE_EMBEDDINGS`. Vectorize text with OpenAI, Gemini, or OpenRouter before using `openrouter/anthropic/*` chat models in RAG pipelines.
602
+
603
+ ### TypeScript types
604
+
605
+ ```typescript
606
+ import {
607
+ AIEmbeddings,
608
+ type AIEmbeddingModelNames,
609
+ type EmbeddingResult,
610
+ type EmbedOptions,
611
+ type EmbedApiKeys,
612
+ ANTHROPIC_NO_NATIVE_EMBEDDINGS,
613
+ } from "@luanpoppe/ai";
614
+
615
+ const model: AIEmbeddingModelNames = "text-embedding-3-small";
616
+ ```
617
+
618
+ ### OpenAI
619
+
620
+ ```typescript
621
+ import { AIEmbeddings } from "@luanpoppe/ai";
622
+
623
+ const { embeddings, usage } = await AIEmbeddings.embedOpenAI(
624
+ ["Document one.", "Document two."],
625
+ { model: "text-embedding-3-small", dimensions: 512 },
626
+ process.env.OPENAI_API_KEY,
627
+ );
628
+
629
+ const queryVector = await AIEmbeddings.embedQuery(
630
+ "search query",
631
+ { model: "text-embedding-3-large" },
632
+ { openAIApiKey: process.env.OPENAI_API_KEY },
633
+ );
634
+ ```
635
+
636
+ Supported direct models: `text-embedding-3-small`, `text-embedding-3-large`, `text-embedding-ada-002`.
637
+
638
+ Optional: `dimensions` (for `text-embedding-3-*`) and `encodingFormat: "float" | "base64"` (default: `"float"`).
639
+
640
+ ### Google Gemini
641
+
642
+ ```typescript
643
+ const vectors = await AIEmbeddings.embedDocuments(
644
+ ["chunk a", "chunk b"],
645
+ {
646
+ model: "gemini-embedding-001",
647
+ taskType: "RETRIEVAL_DOCUMENT",
648
+ dimensions: 768,
649
+ },
650
+ { googleGeminiToken: process.env.GOOGLE_GEMINI_TOKEN },
651
+ );
652
+ ```
653
+
654
+ Models: `gemini-embedding-001`, `gemini-embedding-2-preview` (text via this API; multimodal inputs require the Google GenAI SDK directly).
655
+
656
+ For `gemini-embedding-001`, when `dimensions` is set below the native 3072 (e.g. `768`), the library truncates the vector and applies **L2 normalization** so cosine similarity remains meaningful.
657
+
658
+ ### OpenRouter
659
+
660
+ OpenAI-compatible `POST /api/v1/embeddings` with provider routing.
661
+
662
+ ```typescript
663
+ const result = await AIEmbeddings.embedOpenRouter(
664
+ "Semantic search benchmark.",
665
+ {
666
+ model: "openrouter/openai/text-embedding-3-small",
667
+ openRouterAllowAllProviders: true,
668
+ },
669
+ process.env.OPENROUTER_API_KEY,
670
+ );
671
+ ```
672
+
673
+ Discover models:
674
+
675
+ ```http
676
+ GET https://openrouter.ai/api/v1/embeddings/models
677
+ ```
678
+
679
+ Typed OpenRouter examples: `openrouter/google/gemini-embedding-001`, `openrouter/qwen/qwen3-embedding-8b`, `openrouter/BAAI/bge-m3`.
680
+
681
+ Supports `dimensions`, `encodingFormat`, `openRouterProvider`, and `openRouterAllowAllProviders` (enable all providers in your OpenRouter account if routing fails).
682
+
683
+ ### Routing helpers
684
+
685
+ ```typescript
686
+ // Auto-route by model prefix (text-embedding-* | gemini-embedding-* | openrouter/*)
687
+ await AIEmbeddings.embed("text", {
688
+ model: "openrouter/openai/text-embedding-3-small",
689
+ }, {
690
+ openRouterApiKey: process.env.OPENROUTER_API_KEY,
691
+ });
692
+ ```
693
+
694
+ ### `AIEmbeddings` method reference
695
+
696
+ | Method | Description |
697
+ |--------|-------------|
698
+ | `embedOpenAI` | OpenAI `embeddings.create` |
699
+ | `embedGemini` | LangChain `GoogleGenerativeAIEmbeddings` |
700
+ | `embedOpenRouter` | OpenRouter embeddings + `resolveOpenRouterProvider` |
701
+ | `embed` | Routes by model prefix |
702
+ | `embedDocuments` | Batch embed (default Gemini task: `RETRIEVAL_DOCUMENT`) |
703
+ | `embedQuery` | Single query vector (default Gemini task: `RETRIEVAL_QUERY`) |
704
+
705
+ ---
706
+
707
+ ## Utilities
708
+
709
+ ```typescript
710
+ import { AudioUtils, ImageUtils } from "@luanpoppe/ai";
711
+
712
+ AudioUtils.bufferToBase64(buffer);
713
+ AudioUtils.detectAudioMimeType(buffer, "audio.mp3");
714
+
715
+ ImageUtils.bufferToBase64(buffer);
716
+ ImageUtils.detectImageMimeType(buffer, "photo.png");
717
+ ```
718
+
719
+ ---
720
+
721
+ ## Advanced: raw LangChain agent
722
+
723
+ For full control over the LangGraph agent:
724
+
725
+ ```typescript
726
+ const { agent } = await ai.getRawAgent({
727
+ aiModel: "gpt-4o",
728
+ messages: [],
729
+ threadId: "t-1",
730
+ });
731
+
732
+ const state = await agent.invoke(
733
+ { messages: [AIMessages.human("Hi")] },
734
+ { configurable: { thread_id: "t-1" } },
735
+ );
736
+ ```
737
+
738
+ ---
739
+
740
+ ## Deprecated exports
741
+
742
+ These aliases remain for backward compatibility and will be removed in **v2.0.0**:
743
+
744
+ | Deprecated | Use instead |
745
+ |------------|-------------|
746
+ | `Langchain` | `AI` |
747
+ | `LangchainModels` | `AIModels` |
748
+ | `LangchainMessages` | `AIMessages` |
749
+ | `LangchainTools` | `AITools` |
750
+ | `AIAudioTranscription` | `AIAudio` |
751
+
752
+ ---
753
+
754
+ ## Development
755
+
756
+ From `packages/ai`:
757
+
758
+ ```bash
759
+ pnpm install
760
+ pnpm build # compile to dist/
761
+ pnpm test:unit # unit tests (mocked APIs)
762
+ pnpm test:e2e # live API tests — requires .env keys
763
+ ```
764
+
765
+ Copy `.env.example` to `.env` and set:
766
+
767
+ - `OPENAI_API_KEY`
768
+ - `GOOGLE_GEMINI_TOKEN`
769
+ - `OPENROUTER_API_KEY`
770
+
771
+ Targeted E2E suites:
772
+
773
+ ```bash
774
+ pnpm test:e2e -- ai-embeddings # OpenAI + Gemini + OpenRouter embeddings
775
+ pnpm test:e2e -- ai-audio-multiprovider # STT/TTS multiprovider
776
+ pnpm test:e2e -- ai-deepseek-structured-output
777
+ pnpm test:e2e -- ai-local-models # Ollama + LM Studio (optional)
778
+ ```
779
+
780
+ For `ai-local-models`:
781
+
782
+ - `OLLAMA_E2E=1` with Ollama running (`ollama serve`, model pulled)
783
+ - `LOCAL_BASE_URL` + `LOCAL_MODEL_NAME` with LM Studio (or similar) server active
784
+
785
+ See `tests/e2e/README.md` for E2E details.
786
+
787
+ ### Known issue (Windows + Whisper file upload)
788
+
789
+ If you hit file-upload errors with Whisper on Windows, see [docs/LANGCHAIN_WHISPER_WINDOWS_BUG.md](./docs/LANGCHAIN_WHISPER_WINDOWS_BUG.md). This package uses the OpenAI SDK `toFile` helper directly in `AIAudio` to avoid the LangChain loader bug.
790
+
791
+ ---
792
+
793
+ ## License
794
+
795
+ ISC