@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/src/ai.ts CHANGED
@@ -1,4 +1,13 @@
1
- import { AIModels, LLMModelConfig } from "./langchain/models";
1
+ import {
2
+ AIModels,
3
+ DEFAULT_OLLAMA_BASE_URL,
4
+ LLMModelConfig,
5
+ } from "./langchain/models";
6
+ import {
7
+ isDeepSeekJsonObjectOnlyModel,
8
+ mergeSystemPromptWithJsonSchema,
9
+ parseJsonFromAssistantMessages,
10
+ } from "./langchain/structured-output";
2
11
  import z from "zod";
3
12
  import { createAgent, modelRetryMiddleware } from "langchain";
4
13
  import type { AIAgent } from "./@types/agent";
@@ -27,6 +36,12 @@ type AIConstructor = {
27
36
  googleGeminiToken?: string;
28
37
  openAIApiKey?: string;
29
38
  openRouterApiKey?: string;
39
+ /** URL base da API nativa do Ollama (modelos `ollama/*`). Padrão: http://127.0.0.1:11434 */
40
+ ollamaBaseUrl?: string;
41
+ /** URL base OpenAI-compatible para modelos `local/*` (ex.: http://localhost:1234/v1) */
42
+ localBaseUrl?: string;
43
+ /** API key enviada ao servidor local; muitos aceitam qualquer valor */
44
+ localApiKey?: string;
30
45
  /** Lista padrão de modelos de fallback (usada em call/callStructuredOutput quando não passada no método) */
31
46
  aiModelsFallback?: AIModelNames[];
32
47
  /** Configuração de persistência de histórico (memory, sqlite, postgres, redis, mongodb) ou instância AIMemory */
@@ -164,24 +179,56 @@ export class AI {
164
179
 
165
180
  const { result: response, agent } = await this.invokeWithRetryAndFallback(
166
181
  params,
167
- (paramsForModel) =>
168
- createAgent({
169
- ...this.standardAgent(paramsForModel, checkpointer),
170
- responseFormat: this.normalizeSchemaForOpenAI(
171
- outputSchema,
172
- paramsForModel.aiModel,
173
- ) as any,
174
- }),
182
+ (paramsForModel) => {
183
+ const useJsonObject = isDeepSeekJsonObjectOnlyModel(
184
+ paramsForModel.aiModel,
185
+ );
186
+ const agentParams = useJsonObject
187
+ ? this.withDeepSeekJsonObjectParams(paramsForModel, outputSchema)
188
+ : paramsForModel;
189
+
190
+ return createAgent({
191
+ ...this.standardAgent(agentParams, checkpointer),
192
+ ...(useJsonObject
193
+ ? { responseFormat: undefined as any }
194
+ : {
195
+ responseFormat: this.normalizeSchemaForOpenAI(
196
+ outputSchema,
197
+ paramsForModel.aiModel,
198
+ ) as any,
199
+ }),
200
+ });
201
+ },
175
202
  (agent) => agent.invoke({ messages }, invokeConfig as any),
176
203
  );
177
204
 
178
205
  this._memory?.setAgent(agent);
179
206
 
180
- const parsedResponse = outputSchema.parse(response?.structuredResponse);
207
+ const parsedResponse =
208
+ response?.structuredResponse != null
209
+ ? outputSchema.parse(response.structuredResponse)
210
+ : outputSchema.parse(parseJsonFromAssistantMessages(response.messages));
181
211
 
182
212
  return { response: parsedResponse };
183
213
  }
184
214
 
215
+ private withDeepSeekJsonObjectParams<T extends AICallParams>(
216
+ params: T,
217
+ outputSchema: z.ZodSchema,
218
+ ): T {
219
+ return {
220
+ ...params,
221
+ systemPrompt: mergeSystemPromptWithJsonSchema(
222
+ params.systemPrompt,
223
+ outputSchema,
224
+ ),
225
+ modelConfig: {
226
+ ...params.modelConfig,
227
+ openRouterForceJsonObject: true,
228
+ },
229
+ };
230
+ }
231
+
185
232
  /**
186
233
  * Normaliza schemas Zod para compatibilidade com OpenAI/OpenRouter
187
234
  * OpenAI exige que todos os campos em properties estejam no array required
@@ -278,6 +325,38 @@ export class AI {
278
325
  ...config,
279
326
  model: modelName,
280
327
  apiKey: this.config.openRouterApiKey,
328
+ openRouterProvider: modelConfig?.openRouterProvider,
329
+ openRouterAllowAllProviders: modelConfig?.openRouterAllowAllProviders,
330
+ openRouterForceJsonObject: modelConfig?.openRouterForceJsonObject,
331
+ });
332
+ }
333
+
334
+ if (aiModel.startsWith("ollama/")) {
335
+ const modelName = aiModel.replace(/^ollama\//, "");
336
+ return AIModels.ollama({
337
+ model: modelName,
338
+ baseUrl: modelConfig?.baseUrl ?? this.config.ollamaBaseUrl ?? DEFAULT_OLLAMA_BASE_URL,
339
+ maxTokens: config.maxTokens,
340
+ temperature: config.temperature,
341
+ numCtx: modelConfig?.numCtx,
342
+ });
343
+ }
344
+
345
+ if (aiModel.startsWith("local/")) {
346
+ const modelName = aiModel.replace(/^local\//, "");
347
+ const baseURL = modelConfig?.baseUrl ?? this.config.localBaseUrl;
348
+ if (!baseURL) {
349
+ throw new Error(
350
+ '[@luanpoppe/ai] localBaseUrl é obrigatório no construtor de AI (ou modelConfig.baseUrl na chamada) para modelos "local/*". ' +
351
+ "Ex.: localBaseUrl: \"http://localhost:1234/v1\" (LM Studio) ou \"http://127.0.0.1:11434/v1\" (Ollama OpenAI-compatible).",
352
+ );
353
+ }
354
+ return AIModels.openaiCompatible({
355
+ model: modelName,
356
+ baseURL,
357
+ apiKey: this.config.localApiKey,
358
+ maxTokens: config.maxTokens,
359
+ temperature: config.temperature,
281
360
  });
282
361
  }
283
362
 
@@ -0,0 +1,47 @@
1
+ import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
2
+ import { HumanMessage } from "langchain";
3
+ import type { AudioBuffer } from "../@types/audio";
4
+ import { AudioUtils } from "../utils/audio-utils";
5
+ import type { GeminiTranscriptionOptions } from "./types";
6
+
7
+ const DEFAULT_GEMINI_TRANSCRIBE_PROMPT =
8
+ "Transcreva o áudio a seguir em texto. Se possível, inclua timestamps aproximados no formato [mm:ss]. Responda apenas com a transcrição.";
9
+
10
+ export async function transcribeWithGeminiPrompt(
11
+ audioBuffer: AudioBuffer,
12
+ options: GeminiTranscriptionOptions = {},
13
+ ): Promise<string> {
14
+ const apiKey = options.googleGeminiToken;
15
+ if (!apiKey) {
16
+ throw new Error(
17
+ "googleGeminiToken é obrigatório para transcribeWithGeminiPrompt.",
18
+ );
19
+ }
20
+
21
+ const model = new ChatGoogleGenerativeAI({
22
+ model: options.model ?? "gemini-2.5-flash",
23
+ apiKey,
24
+ });
25
+
26
+ const base64Data = AudioUtils.bufferToBase64(audioBuffer);
27
+ const mimeType = AudioUtils.detectAudioMimeType(audioBuffer);
28
+
29
+ const message = new HumanMessage({
30
+ content: [
31
+ {
32
+ type: "text",
33
+ text: options.prompt ?? DEFAULT_GEMINI_TRANSCRIBE_PROMPT,
34
+ },
35
+ {
36
+ type: "audio",
37
+ source_type: "base64",
38
+ data: base64Data,
39
+ mime_type: mimeType,
40
+ },
41
+ ] as any,
42
+ } as any);
43
+
44
+ const response = await model.invoke([message]);
45
+ const content = response.content;
46
+ return typeof content === "string" ? content : JSON.stringify(content);
47
+ }
@@ -0,0 +1,101 @@
1
+ import type { AudioBuffer } from "../@types/audio";
2
+ import type {
3
+ GeminiTranscriptionOptions,
4
+ OpenAITranscriptionDetailed,
5
+ OpenAITranscriptionOptions,
6
+ OpenRouterSpeechOptions,
7
+ OpenRouterTranscriptionOptions,
8
+ OpenRouterTranscriptionResult,
9
+ OpenAISpeechOptions,
10
+ SpeechResult,
11
+ } from "./types";
12
+ import {
13
+ transcribeDetailedOpenAI,
14
+ transcribeDiarizedOpenAI,
15
+ transcribeFileDetailedOpenAI,
16
+ transcribeFileWithWhisperOpenAI,
17
+ transcribeToSrtOpenAI,
18
+ transcribeToVttOpenAI,
19
+ transcribeWithWhisperOpenAI,
20
+ translateOpenAI,
21
+ } from "./transcription-openai";
22
+ import { transcribeOpenRouter } from "./transcription-openrouter";
23
+ import { speakOpenAI, speakOpenAIStream } from "./speech-openai";
24
+ import { speakOpenRouter, speakOpenRouterStream } from "./speech-openrouter";
25
+ import { transcribeWithGeminiPrompt } from "./gemini-transcription";
26
+
27
+ export type {
28
+ WhisperModel,
29
+ OpenAITranscriptionOptions,
30
+ OpenAITranscriptionResponseFormat,
31
+ OpenAITranscriptionDetailed,
32
+ OpenRouterTranscriptionOptions,
33
+ OpenRouterTranscriptionResult,
34
+ OpenAISpeechOptions,
35
+ OpenAISpeechModel,
36
+ OpenRouterSpeechOptions,
37
+ SpeechResult,
38
+ TranscriptionWord,
39
+ TranscriptionSegment,
40
+ DiarizedSegment,
41
+ GeminiTranscriptionOptions,
42
+ } from "./types";
43
+
44
+ export { ANTHROPIC_NO_NATIVE_AUDIO } from "./types";
45
+
46
+ export class AIAudio {
47
+ static transcribeDetailedOpenAI = transcribeDetailedOpenAI;
48
+ static transcribeWithWhisper = transcribeWithWhisperOpenAI;
49
+ static transcribeFileWithWhisper = transcribeFileWithWhisperOpenAI;
50
+ static transcribeToSrtOpenAI = transcribeToSrtOpenAI;
51
+ static transcribeToVttOpenAI = transcribeToVttOpenAI;
52
+ static transcribeDiarizedOpenAI = transcribeDiarizedOpenAI;
53
+ static translateOpenAI = translateOpenAI;
54
+ static transcribeOpenRouter = transcribeOpenRouter;
55
+ static speakOpenAI = speakOpenAI;
56
+ static speakOpenAIStream = speakOpenAIStream;
57
+ static speakOpenRouter = speakOpenRouter;
58
+ static speakOpenRouterStream = speakOpenRouterStream;
59
+ static transcribeWithGeminiPrompt = transcribeWithGeminiPrompt;
60
+
61
+ /** @deprecated Use AIAudio.transcribeWithWhisper */
62
+ static transcribeWithWhisperLegacy(
63
+ audioBuffer: AudioBuffer,
64
+ options?: OpenAITranscriptionOptions,
65
+ openAIApiKey?: string,
66
+ ): Promise<string> {
67
+ return transcribeWithWhisperOpenAI(audioBuffer, options, openAIApiKey);
68
+ }
69
+
70
+ static async transcribeFileDetailedOpenAI(
71
+ filePath: string,
72
+ options?: OpenAITranscriptionOptions,
73
+ openAIApiKey?: string,
74
+ ): Promise<OpenAITranscriptionDetailed> {
75
+ return transcribeFileDetailedOpenAI(filePath, options, openAIApiKey);
76
+ }
77
+
78
+ static async transcribeDetailedOpenRouter(
79
+ audioBuffer: AudioBuffer,
80
+ options: OpenRouterTranscriptionOptions,
81
+ openRouterApiKey?: string,
82
+ ): Promise<OpenRouterTranscriptionResult> {
83
+ return transcribeOpenRouter(audioBuffer, options, openRouterApiKey);
84
+ }
85
+
86
+ static async speak(
87
+ text: string,
88
+ options: OpenAISpeechOptions & { provider?: "openai" },
89
+ openAIApiKey?: string,
90
+ ): Promise<SpeechResult> {
91
+ return speakOpenAI(text, options, openAIApiKey);
92
+ }
93
+
94
+ static async speakViaOpenRouter(
95
+ text: string,
96
+ options: OpenRouterSpeechOptions,
97
+ openRouterApiKey?: string,
98
+ ): Promise<SpeechResult> {
99
+ return speakOpenRouter(text, options, openRouterApiKey);
100
+ }
101
+ }
@@ -0,0 +1,4 @@
1
+ export {
2
+ OPENROUTER_API_BASE,
3
+ createOpenRouterOpenAIClient,
4
+ } from "../utils/openrouter-client";
@@ -0,0 +1,79 @@
1
+ import OpenAI from "openai";
2
+ import type { OpenAISpeechOptions, SpeechResult } from "./types";
3
+
4
+ const CONTENT_TYPE_BY_FORMAT: Record<string, string> = {
5
+ mp3: "audio/mpeg",
6
+ opus: "audio/opus",
7
+ aac: "audio/aac",
8
+ flac: "audio/flac",
9
+ wav: "audio/wav",
10
+ pcm: "audio/pcm",
11
+ };
12
+
13
+ function validateSpeechOptions(options: OpenAISpeechOptions): void {
14
+ const model = options.model ?? "gpt-4o-mini-tts";
15
+ if (
16
+ options.instructions &&
17
+ model !== "gpt-4o-mini-tts" &&
18
+ model !== "gpt-4o-mini-tts-2025-12-15"
19
+ ) {
20
+ throw new Error(
21
+ "[@luanpoppe/ai] instructions em TTS só é suportado por modelos gpt-4o-mini-tts.",
22
+ );
23
+ }
24
+ }
25
+
26
+ function createOpenAIClient(apiKey?: string): OpenAI {
27
+ if (!apiKey) {
28
+ throw new Error("OpenAI API key is not passed in the speech parameters");
29
+ }
30
+ return new OpenAI({ apiKey });
31
+ }
32
+
33
+ export async function speakOpenAI(
34
+ text: string,
35
+ options: OpenAISpeechOptions,
36
+ openAIApiKey?: string,
37
+ ): Promise<SpeechResult> {
38
+ validateSpeechOptions(options);
39
+ const openai = createOpenAIClient(openAIApiKey);
40
+ const responseFormat = options.responseFormat ?? "mp3";
41
+ const model = options.model ?? "gpt-4o-mini-tts";
42
+
43
+ const response = await openai.audio.speech.create({
44
+ model,
45
+ voice: options.voice as OpenAI.Audio.SpeechCreateParams["voice"],
46
+ input: text,
47
+ response_format: responseFormat,
48
+ ...(options.speed !== undefined ? { speed: options.speed } : {}),
49
+ ...(options.instructions ? { instructions: options.instructions } : {}),
50
+ });
51
+
52
+ const arrayBuffer = await response.arrayBuffer();
53
+ return {
54
+ audio: Buffer.from(arrayBuffer),
55
+ contentType: CONTENT_TYPE_BY_FORMAT[responseFormat] ?? "application/octet-stream",
56
+ };
57
+ }
58
+
59
+ export async function speakOpenAIStream(
60
+ text: string,
61
+ options: OpenAISpeechOptions,
62
+ openAIApiKey?: string,
63
+ ): Promise<ReadableStream<Uint8Array>> {
64
+ validateSpeechOptions(options);
65
+ const openai = createOpenAIClient(openAIApiKey);
66
+ const responseFormat = options.responseFormat ?? "mp3";
67
+ const model = options.model ?? "gpt-4o-mini-tts";
68
+
69
+ const stream = await openai.audio.speech.create({
70
+ model,
71
+ voice: options.voice as OpenAI.Audio.SpeechCreateParams["voice"],
72
+ input: text,
73
+ response_format: responseFormat,
74
+ ...(options.speed !== undefined ? { speed: options.speed } : {}),
75
+ ...(options.instructions ? { instructions: options.instructions } : {}),
76
+ });
77
+
78
+ return stream.body as ReadableStream<Uint8Array>;
79
+ }
@@ -0,0 +1,121 @@
1
+ import type { OpenRouterSpeechOptions, SpeechResult } from "./types";
2
+ import { createOpenRouterOpenAIClient } from "./openrouter-client";
3
+ import { resolveOpenRouterProvider } from "../langchain/models";
4
+
5
+ const CONTENT_TYPE_BY_FORMAT: Record<string, string> = {
6
+ mp3: "audio/mpeg",
7
+ pcm: "audio/pcm",
8
+ };
9
+
10
+ function isGeminiTtsModel(model: string): boolean {
11
+ return /gemini/i.test(model) && /tts/i.test(model);
12
+ }
13
+
14
+ function resolveOpenRouterSpeechFormat(
15
+ model: string,
16
+ responseFormat?: OpenRouterSpeechOptions["responseFormat"],
17
+ ): "mp3" | "pcm" {
18
+ if (responseFormat) return responseFormat;
19
+ // Gemini TTS no OpenRouter aceita apenas pcm (ver docs OR /audio/speech)
20
+ if (isGeminiTtsModel(model)) return "pcm";
21
+ return "mp3";
22
+ }
23
+
24
+ function validateOpenRouterSpeechOptions(options: OpenRouterSpeechOptions): void {
25
+ if (!options.model) {
26
+ throw new Error("[@luanpoppe/ai] model é obrigatório para speakOpenRouter.");
27
+ }
28
+ if (!options.voice) {
29
+ throw new Error("[@luanpoppe/ai] voice é obrigatório para speakOpenRouter.");
30
+ }
31
+ }
32
+
33
+ export async function speakOpenRouter(
34
+ text: string,
35
+ options: OpenRouterSpeechOptions,
36
+ openRouterApiKey?: string,
37
+ ): Promise<SpeechResult> {
38
+ validateOpenRouterSpeechOptions(options);
39
+ if (!openRouterApiKey) {
40
+ throw new Error(
41
+ "OpenRouter API key is not passed in the speech parameters",
42
+ );
43
+ }
44
+
45
+ const client = createOpenRouterOpenAIClient(openRouterApiKey);
46
+ const responseFormat = resolveOpenRouterSpeechFormat(
47
+ options.model,
48
+ options.responseFormat,
49
+ );
50
+ const provider = resolveOpenRouterProvider(options.model, {
51
+ openRouterProvider: options.openRouterProvider,
52
+ openRouterAllowAllProviders: options.openRouterAllowAllProviders,
53
+ });
54
+
55
+ const body: Record<string, unknown> = {
56
+ model: options.model,
57
+ input: text,
58
+ voice: options.voice,
59
+ response_format: responseFormat,
60
+ };
61
+
62
+ if (options.speed !== undefined) body.speed = options.speed;
63
+ if (provider || options.instructions) {
64
+ const mergedProvider: Record<string, unknown> = { ...(provider ?? {}) };
65
+ if (options.instructions) {
66
+ mergedProvider.options = {
67
+ openai: { instructions: options.instructions },
68
+ };
69
+ }
70
+ body.provider = mergedProvider;
71
+ }
72
+
73
+ const response = await client.audio.speech.create(
74
+ body as unknown as Parameters<typeof client.audio.speech.create>[0],
75
+ );
76
+
77
+ const arrayBuffer = await response.arrayBuffer();
78
+ return {
79
+ audio: Buffer.from(arrayBuffer),
80
+ contentType: CONTENT_TYPE_BY_FORMAT[responseFormat] ?? "application/octet-stream",
81
+ };
82
+ }
83
+
84
+ export async function speakOpenRouterStream(
85
+ text: string,
86
+ options: OpenRouterSpeechOptions,
87
+ openRouterApiKey?: string,
88
+ ): Promise<ReadableStream<Uint8Array>> {
89
+ validateOpenRouterSpeechOptions(options);
90
+ if (!openRouterApiKey) {
91
+ throw new Error(
92
+ "OpenRouter API key is not passed in the speech parameters",
93
+ );
94
+ }
95
+
96
+ const client = createOpenRouterOpenAIClient(openRouterApiKey);
97
+ const responseFormat = resolveOpenRouterSpeechFormat(
98
+ options.model,
99
+ options.responseFormat,
100
+ );
101
+ const provider = resolveOpenRouterProvider(options.model, {
102
+ openRouterProvider: options.openRouterProvider,
103
+ openRouterAllowAllProviders: options.openRouterAllowAllProviders,
104
+ });
105
+
106
+ const body: Record<string, unknown> = {
107
+ model: options.model,
108
+ input: text,
109
+ voice: options.voice,
110
+ response_format: responseFormat,
111
+ };
112
+
113
+ if (options.speed !== undefined) body.speed = options.speed;
114
+ if (provider) body.provider = provider;
115
+
116
+ const stream = await client.audio.speech.create(
117
+ body as unknown as Parameters<typeof client.audio.speech.create>[0],
118
+ );
119
+
120
+ return stream.body as ReadableStream<Uint8Array>;
121
+ }