@luanpoppe/ai 1.1.5 → 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 +5 -1
  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 +11 -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 +12 -1
  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 +51 -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
@@ -1,105 +1,34 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
- import OpenAI from "openai";
4
- import { toFile } from "openai";
5
1
  import type { AudioBuffer, AudioMimeType } from "../@types/audio";
6
- import { MIME_TO_EXTENSION } from "../@types/audio";
2
+ import { AIAudio } from "../audio";
3
+ import type {
4
+ OpenAITranscriptionOptions,
5
+ WhisperModel,
6
+ } from "../audio/types";
7
7
 
8
- /** Modelos disponíveis na API de transcrição OpenAI (Speech-to-Text) */
9
- export type WhisperModel =
10
- | "whisper-1"
11
- | "gpt-4o-transcribe"
12
- | "gpt-4o-mini-transcribe"
13
- | "gpt-4o-mini-transcribe-2025-12-15"
14
- | "gpt-4o-transcribe-diarize";
8
+ export type { WhisperModel };
15
9
 
16
- export type WhisperTranscriptionOptions = {
17
- /** Modelo de transcrição. Padrão: "whisper-1". gpt-4o-transcribe e gpt-4o-mini-transcribe têm maior qualidade. */
18
- model?: WhisperModel;
19
- languageIn2Digits?: string;
20
- prompt?: string;
21
- responseFormat?: "json" | "text" | "srt" | "verbose_json" | "vtt";
22
- temperature?: number;
23
- timestampGranularities?: ("word" | "segment")[];
24
- /** Formato do áudio: extensão ("mp3", "wav", "webm") ou MIME type ("audio/wav", "audio/webm") */
25
- format?: string | AudioMimeType;
26
- };
27
-
28
- function getExtension(format?: string | AudioMimeType): string {
29
- if (!format) return "mp3";
30
- if (format.startsWith("audio/")) {
31
- return MIME_TO_EXTENSION[format as AudioMimeType] ?? "mp3";
32
- }
33
- return format.replace(/^\./, "");
34
- }
35
-
36
- function toBuffer(audioBuffer: AudioBuffer): Buffer {
37
- if (audioBuffer instanceof Buffer) return audioBuffer;
38
- if (audioBuffer instanceof ArrayBuffer) return Buffer.from(audioBuffer);
39
- return Buffer.from(audioBuffer as Uint8Array);
40
- }
10
+ /** @deprecated Use OpenAITranscriptionOptions from AIAudio */
11
+ export type WhisperTranscriptionOptions = OpenAITranscriptionOptions;
41
12
 
13
+ /**
14
+ * @deprecated Use {@link AIAudio.transcribeWithWhisper} instead.
15
+ */
42
16
  export class AIAudioTranscription {
43
- static async transcribeWithWhisper(
17
+ static transcribeWithWhisper(
44
18
  audioBuffer: AudioBuffer,
45
19
  options: WhisperTranscriptionOptions = {},
46
- openAIApiKey?: string
20
+ openAIApiKey?: string,
47
21
  ): Promise<string> {
48
- if (openAIApiKey) {
49
- process.env.OPENAI_API_KEY = openAIApiKey;
50
- }
51
-
52
- const buffer = toBuffer(audioBuffer);
53
- const extension = getExtension(options.format);
54
- const fileName = `whisper-${Date.now()}.${extension}`;
55
-
56
- const file = await toFile(buffer, fileName);
57
-
58
- const openai = new OpenAI();
59
-
60
- const transcriptionParams: OpenAI.Audio.TranscriptionCreateParams = {
61
- file,
62
- model: options.model ?? "whisper-1",
63
- response_format: options.responseFormat ?? "text",
64
- };
65
-
66
- if (options.languageIn2Digits) {
67
- transcriptionParams.language = options.languageIn2Digits;
68
- }
69
- if (options.prompt) {
70
- transcriptionParams.prompt = options.prompt;
71
- }
72
- if (options.temperature !== undefined) {
73
- transcriptionParams.temperature = options.temperature;
74
- }
75
- if (options.timestampGranularities) {
76
- transcriptionParams.timestamp_granularities =
77
- options.timestampGranularities;
78
- }
79
-
80
- const response = await openai.audio.transcriptions.create(
81
- transcriptionParams
82
- );
83
-
84
- return typeof response === "string" ? response : response.text;
22
+ return AIAudio.transcribeWithWhisper(audioBuffer, options, openAIApiKey);
85
23
  }
86
24
 
87
- static async transcribeFileWithWhisper(
25
+ static transcribeFileWithWhisper(
88
26
  filePath: string,
89
27
  options: WhisperTranscriptionOptions = {},
90
- openAIApiKey?: string
28
+ openAIApiKey?: string,
91
29
  ): Promise<string> {
92
- if (!fs.existsSync(filePath)) {
93
- throw new Error(`Arquivo não encontrado: ${filePath}`);
94
- }
95
-
96
- const audioBuffer = fs.readFileSync(filePath);
97
- const format =
98
- options.format ?? (path.extname(filePath).replace(/^\./, "") || "mp3");
99
- return this.transcribeWithWhisper(
100
- audioBuffer,
101
- { ...options, format },
102
- openAIApiKey
103
- );
30
+ return AIAudio.transcribeFileWithWhisper(filePath, options, openAIApiKey);
104
31
  }
105
32
  }
33
+
34
+ export type { AudioBuffer, AudioMimeType };
@@ -3,7 +3,7 @@ import { AudioUtils } from "../utils/audio-utils";
3
3
  import { ImageUtils } from "../utils/image-utils";
4
4
  import type { AudioBuffer, AudioMimeType } from "../@types/audio";
5
5
  import type { ImageBuffer, ImageMimeType } from "../@types/image";
6
- import { AIAudioTranscription } from "./audio-transcription";
6
+ import { AIAudio } from "../audio";
7
7
 
8
8
  export type MessageInput = SystemMessage | HumanMessage | AIMessage;
9
9
 
@@ -39,8 +39,11 @@ export type HumanMessageWithAudioOptions = {
39
39
  filename?: string;
40
40
  };
41
41
  text?: string;
42
- provider?: "openai" | "gemini" | "auto";
42
+ provider?: "openai" | "gemini" | "openrouter" | "auto";
43
43
  openAIApiKey?: string;
44
+ openRouterApiKey?: string;
45
+ /** Modelo OpenRouter STT (ex.: openai/whisper-1) quando provider é openrouter ou auto com OR */
46
+ openRouterTranscriptionModel?: string;
44
47
  };
45
48
 
46
49
  export class AIMessages {
@@ -83,16 +86,31 @@ export class AIMessages {
83
86
  static async humanAudio(
84
87
  options: HumanMessageWithAudioOptions
85
88
  ): Promise<HumanMessage> {
86
- const { audio, text, provider = "auto", openAIApiKey } = options;
89
+ const {
90
+ audio,
91
+ text,
92
+ provider = "auto",
93
+ openAIApiKey,
94
+ openRouterApiKey,
95
+ openRouterTranscriptionModel,
96
+ } = options;
87
97
  const { buffer, mimeType, filename } = audio;
88
98
 
99
+ const resolvedProvider = this.resolveAudioProvider(
100
+ provider,
101
+ openAIApiKey,
102
+ openRouterApiKey,
103
+ );
104
+
89
105
  return this.createHumanMessageWithAudio(
90
106
  buffer,
91
107
  text,
92
108
  mimeType,
93
109
  filename,
94
- provider,
95
- openAIApiKey
110
+ resolvedProvider,
111
+ openAIApiKey,
112
+ openRouterApiKey,
113
+ openRouterTranscriptionModel,
96
114
  );
97
115
  }
98
116
 
@@ -100,22 +118,45 @@ export class AIMessages {
100
118
  return new AIMessage(message);
101
119
  }
102
120
 
121
+ private static resolveAudioProvider(
122
+ provider: "openai" | "gemini" | "openrouter" | "auto",
123
+ openAIApiKey?: string,
124
+ openRouterApiKey?: string,
125
+ ): "openai" | "gemini" | "openrouter" {
126
+ if (provider !== "auto") return provider;
127
+ if (openAIApiKey) return "openai";
128
+ if (openRouterApiKey) return "openrouter";
129
+ return "gemini";
130
+ }
131
+
103
132
  private static async createHumanMessageWithAudio(
104
133
  audioBuffer: AudioBuffer,
105
134
  text?: string,
106
135
  mimeType?: AudioMimeType,
107
136
  filename?: string,
108
- provider: "openai" | "gemini" | "auto" = "auto",
109
- openAIApiKey?: string
137
+ provider: "openai" | "gemini" | "openrouter" = "gemini",
138
+ openAIApiKey?: string,
139
+ openRouterApiKey?: string,
140
+ openRouterTranscriptionModel = "openai/whisper-1",
110
141
  ): Promise<HumanMessage> {
111
- // Para OpenAI, faz transcrição prévia automaticamente devido à limitação da API
112
- if (provider === "openai")
142
+ if (provider === "openai") {
113
143
  return await this.processAudioOpenAi(
114
144
  audioBuffer,
115
145
  mimeType,
116
146
  text,
117
- openAIApiKey
147
+ openAIApiKey,
148
+ );
149
+ }
150
+
151
+ if (provider === "openrouter") {
152
+ return await this.processAudioOpenRouter(
153
+ audioBuffer,
154
+ mimeType,
155
+ text,
156
+ openRouterApiKey,
157
+ openRouterTranscriptionModel,
118
158
  );
159
+ }
119
160
 
120
161
  // Para Gemini e outros, usa multimodal direto (trabalho síncrono, mas retorna Promise para compatibilidade)
121
162
  const base64Data = AudioUtils.bufferToBase64(audioBuffer);
@@ -168,10 +209,10 @@ export class AIMessages {
168
209
  ? { format: mimeType }
169
210
  : { language: "pt" };
170
211
 
171
- const transcribedText = await AIAudioTranscription.transcribeWithWhisper(
212
+ const transcribedText = await AIAudio.transcribeWithWhisper(
172
213
  audioBuffer,
173
214
  transcriptionOptions,
174
- openAIApiKey
215
+ openAIApiKey,
175
216
  );
176
217
 
177
218
  // Combina o texto original (se fornecido) com a transcrição
@@ -192,4 +233,35 @@ export class AIMessages {
192
233
  );
193
234
  }
194
235
  }
236
+
237
+ private static async processAudioOpenRouter(
238
+ audioBuffer: AudioBuffer,
239
+ mimeType?: AudioMimeType,
240
+ text?: string,
241
+ openRouterApiKey?: string,
242
+ openRouterTranscriptionModel = "openai/whisper-1",
243
+ ) {
244
+ if (!openRouterApiKey) {
245
+ throw new Error(
246
+ "openRouterApiKey é necessária quando provider é 'openrouter'.",
247
+ );
248
+ }
249
+
250
+ const format = mimeType ?? AudioUtils.detectAudioMimeType(audioBuffer);
251
+ const { text: transcribedText } = await AIAudio.transcribeOpenRouter(
252
+ audioBuffer,
253
+ {
254
+ model: openRouterTranscriptionModel,
255
+ format,
256
+ ...(text ? {} : { language: "pt" }),
257
+ },
258
+ openRouterApiKey,
259
+ );
260
+
261
+ const finalText = text
262
+ ? `${text}\n\nÁudio transcrito: ${transcribedText}`
263
+ : `Áudio transcrito: ${transcribedText}`;
264
+
265
+ return new HumanMessage(finalText);
266
+ }
195
267
  }
@@ -2,20 +2,90 @@ import {
2
2
  ChatGoogleGenerativeAI,
3
3
  GoogleGenerativeAIChatInput,
4
4
  } from "@langchain/google-genai";
5
+ import { ChatOllama, type ChatOllamaInput } from "@langchain/ollama";
5
6
  import { ChatOpenAI, ChatOpenAIFields } from "@langchain/openai";
7
+ import type { OpenRouterProviderPreferences } from "../@types/openrouter-provider";
8
+
9
+ export const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
10
+
11
+ export const DEFAULT_LOCAL_API_KEY = "not-needed";
12
+
13
+ export type {
14
+ OpenRouterMaxPrice,
15
+ OpenRouterProviderPreferences,
16
+ OpenRouterProviderSort,
17
+ } from "../@types/openrouter-provider";
6
18
 
7
19
  /** Nível de esforço de raciocínio para modelos OpenAI (o1, gpt-5, etc.). Valores: "low" | "medium" | "high" */
8
20
  export type ReasoningEffort = "low" | "medium" | "high";
9
21
 
22
+ export type OllamaModelConfig = {
23
+ model: string;
24
+ baseUrl?: string | undefined;
25
+ maxTokens?: number | undefined;
26
+ temperature?: number | undefined;
27
+ numCtx?: number | undefined;
28
+ };
29
+
30
+ export type OpenAICompatibleModelConfig = {
31
+ model: string;
32
+ baseURL: string;
33
+ apiKey?: string | undefined;
34
+ maxTokens?: number | undefined;
35
+ temperature?: number | undefined;
36
+ };
37
+
10
38
  export type LLMModelConfig = {
11
39
  model: string;
12
40
  apiKey?: string | undefined;
41
+ /** Override da URL do servidor (Ollama nativo ou OpenAI-compatible local) */
42
+ baseUrl?: string | undefined;
13
43
  maxTokens?: number | undefined;
14
44
  temperature?: number | undefined;
45
+ /** Tamanho do contexto (Ollama nativo, `num_ctx`) */
46
+ numCtx?: number | undefined;
15
47
  /** Nível de esforço de raciocínio (modelos OpenAI: o1, gpt-5, etc.) */
16
48
  reasoningEffort?: ReasoningEffort | undefined;
49
+ /** Preferências de roteamento de providers do OpenRouter */
50
+ openRouterProvider?: OpenRouterProviderPreferences | undefined;
51
+ /**
52
+ * Se true, não aplica `only: ["deepseek"]` em modelos `deepseek/*` via OpenRouter.
53
+ * Use para aceitar o roteamento padrão do OpenRouter (todos os providers).
54
+ */
55
+ openRouterAllowAllProviders?: boolean | undefined;
56
+ /**
57
+ * Força `response_format: { type: "json_object" }` no OpenRouter (ex.: DeepSeek).
58
+ * Uso interno via `callStructuredOutput`.
59
+ */
60
+ openRouterForceJsonObject?: boolean | undefined;
17
61
  };
18
62
 
63
+ const DEEPSEEK_OPENROUTER_PROVIDER: OpenRouterProviderPreferences = {
64
+ only: ["deepseek"],
65
+ };
66
+
67
+ export function resolveOpenRouterProvider(
68
+ model: string,
69
+ config: Pick<
70
+ LLMModelConfig,
71
+ "openRouterProvider" | "openRouterAllowAllProviders"
72
+ >,
73
+ ): OpenRouterProviderPreferences | undefined {
74
+ if (config.openRouterProvider !== undefined) {
75
+ return config.openRouterProvider;
76
+ }
77
+
78
+ if (config.openRouterAllowAllProviders === true) {
79
+ return undefined;
80
+ }
81
+
82
+ if (model.startsWith("deepseek/")) {
83
+ return DEEPSEEK_OPENROUTER_PROVIDER;
84
+ }
85
+
86
+ return undefined;
87
+ }
88
+
19
89
  export class AIModels {
20
90
  static gpt(params: LLMModelConfig) {
21
91
  const { model, apiKey, maxTokens, temperature, reasoningEffort } = params;
@@ -59,7 +129,16 @@ export class AIModels {
59
129
  }
60
130
 
61
131
  static openrouter(params: LLMModelConfig) {
62
- const { apiKey, maxTokens, model, temperature, reasoningEffort } = params;
132
+ const {
133
+ apiKey,
134
+ maxTokens,
135
+ model,
136
+ temperature,
137
+ reasoningEffort,
138
+ openRouterProvider,
139
+ openRouterAllowAllProviders,
140
+ openRouterForceJsonObject,
141
+ } = params;
63
142
 
64
143
  if (!apiKey)
65
144
  throw new Error(
@@ -76,13 +155,61 @@ export class AIModels {
76
155
 
77
156
  if (maxTokens) options.maxTokens = maxTokens;
78
157
  if (temperature) options.temperature = temperature;
79
- if (reasoningEffort) {
158
+
159
+ const provider = resolveOpenRouterProvider(model, {
160
+ openRouterProvider,
161
+ openRouterAllowAllProviders,
162
+ });
163
+
164
+ if (reasoningEffort || provider || openRouterForceJsonObject) {
80
165
  options.modelKwargs = {
81
166
  ...(options.modelKwargs ?? {}),
82
- reasoning_effort: reasoningEffort,
167
+ ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
168
+ ...(provider ? { provider } : {}),
169
+ ...(openRouterForceJsonObject
170
+ ? { response_format: { type: "json_object" } }
171
+ : {}),
83
172
  };
84
173
  }
85
174
 
86
175
  return new ChatOpenAI(options);
87
176
  }
177
+
178
+ static ollama(params: OllamaModelConfig) {
179
+ const { model, baseUrl, maxTokens, temperature, numCtx } = params;
180
+
181
+ const options: ChatOllamaInput = {
182
+ model,
183
+ baseUrl: baseUrl ?? DEFAULT_OLLAMA_BASE_URL,
184
+ };
185
+
186
+ if (temperature !== undefined) options.temperature = temperature;
187
+ if (numCtx !== undefined) options.numCtx = numCtx;
188
+ if (maxTokens !== undefined) options.numPredict = maxTokens;
189
+
190
+ return new ChatOllama(options);
191
+ }
192
+
193
+ static openaiCompatible(params: OpenAICompatibleModelConfig) {
194
+ const { model, baseURL, apiKey, maxTokens, temperature } = params;
195
+
196
+ if (!baseURL) {
197
+ throw new Error(
198
+ "baseURL é obrigatório para servidores OpenAI-compatible (LM Studio, Ollama /v1, vLLM, etc.).",
199
+ );
200
+ }
201
+
202
+ const options: ChatOpenAIFields = {
203
+ model,
204
+ apiKey: apiKey ?? DEFAULT_LOCAL_API_KEY,
205
+ configuration: {
206
+ baseURL,
207
+ },
208
+ };
209
+
210
+ if (maxTokens) options.maxTokens = maxTokens;
211
+ if (temperature !== undefined) options.temperature = temperature;
212
+
213
+ return new ChatOpenAI(options);
214
+ }
88
215
  }
@@ -0,0 +1,77 @@
1
+ import type { BaseMessage } from "langchain";
2
+ import z from "zod";
3
+
4
+ /** Modelos DeepSeek (direto ou via OpenRouter) não aceitam `json_schema`; apenas `json_object`. */
5
+ export function isDeepSeekJsonObjectOnlyModel(aiModel: string): boolean {
6
+ return (
7
+ aiModel.startsWith("openrouter/deepseek/") || aiModel.startsWith("deepseek/")
8
+ );
9
+ }
10
+
11
+ export function buildJsonObjectSystemAppend(schema: z.ZodSchema): string {
12
+ const jsonSchema = z.toJSONSchema(schema);
13
+ return [
14
+ "",
15
+ "IMPORTANTE: Responda somente com um objeto JSON válido (sem markdown, sem texto extra).",
16
+ "O JSON deve seguir este schema:",
17
+ JSON.stringify(jsonSchema, null, 2),
18
+ ].join("\n");
19
+ }
20
+
21
+ export function mergeSystemPromptWithJsonSchema(
22
+ systemPrompt: string | undefined,
23
+ schema: z.ZodSchema,
24
+ ): string {
25
+ const base = systemPrompt?.trim() ?? "";
26
+ const append = buildJsonObjectSystemAppend(schema);
27
+ return base ? `${base}\n${append}` : append.trim();
28
+ }
29
+
30
+ function contentToString(content: unknown): string {
31
+ if (typeof content === "string") return content;
32
+ if (Array.isArray(content)) {
33
+ return content
34
+ .map((part) => {
35
+ if (typeof part === "string") return part;
36
+ if (part && typeof part === "object" && "text" in part) {
37
+ return String((part as { text?: unknown }).text ?? "");
38
+ }
39
+ return "";
40
+ })
41
+ .join("");
42
+ }
43
+ return "";
44
+ }
45
+
46
+ /** Extrai e faz parse de JSON na última mensagem do assistente (suporta fences ```json). */
47
+ export function parseJsonFromAssistantMessages(
48
+ messages: BaseMessage[],
49
+ ): unknown {
50
+ const last = messages.at(-1);
51
+ if (!last) {
52
+ throw new Error(
53
+ "[@luanpoppe/ai] Nenhuma mensagem do assistente para extrair JSON.",
54
+ );
55
+ }
56
+
57
+ const raw = contentToString((last as { content?: unknown }).content).trim();
58
+ if (!raw) {
59
+ throw new Error("[@luanpoppe/ai] Resposta vazia ao extrair JSON estruturado.");
60
+ }
61
+
62
+ const fenced = raw.match(/^```(?:json)?\s*([\s\S]*?)```$/i);
63
+ const jsonText = (fenced?.[1] ?? raw).trim();
64
+
65
+ try {
66
+ return JSON.parse(jsonText);
67
+ } catch {
68
+ const start = jsonText.indexOf("{");
69
+ const end = jsonText.lastIndexOf("}");
70
+ if (start >= 0 && end > start) {
71
+ return JSON.parse(jsonText.slice(start, end + 1));
72
+ }
73
+ throw new Error(
74
+ "[@luanpoppe/ai] Não foi possível fazer parse do JSON retornado pelo modelo.",
75
+ );
76
+ }
77
+ }
@@ -0,0 +1,10 @@
1
+ import OpenAI from "openai";
2
+
3
+ export const OPENROUTER_API_BASE = "https://openrouter.ai/api/v1";
4
+
5
+ export function createOpenRouterOpenAIClient(apiKey: string): OpenAI {
6
+ return new OpenAI({
7
+ apiKey,
8
+ baseURL: OPENROUTER_API_BASE,
9
+ });
10
+ }
@@ -25,6 +25,59 @@ O arquivo `.env` é carregado automaticamente pelo `dotenv` configurado no `vite
25
25
 
26
26
  ## Executando os testes
27
27
 
28
+ ### DeepSeek structured output
29
+ ```bash
30
+ pnpm test:e2e -- ai-deepseek-structured-output
31
+ ```
32
+ Requer `OPENROUTER_API_KEY`. Valida `callStructuredOutput` com `openrouter/deepseek/deepseek-v4-flash` e `deepseek-v4-pro`.
33
+
34
+ ### Modelos locais (Ollama + LM Studio / OpenAI-compatible)
35
+ ```bash
36
+ pnpm test:e2e -- ai-local-models
37
+ ```
38
+
39
+ **Ollama (prefixo `ollama/`, API nativa):**
40
+ 1. Instale e inicie o Ollama (`ollama serve`)
41
+ 2. Baixe um modelo: `ollama pull llama3.2`
42
+ 3. No `.env`: `OLLAMA_E2E=1`, opcionalmente `OLLAMA_BASE_URL` e `OLLAMA_MODEL_NAME`
43
+
44
+ **LM Studio / Ollama `/v1` (prefixo `local/`):**
45
+ 1. Ative o servidor local compatível com OpenAI (ex.: LM Studio em `http://localhost:1234/v1`)
46
+ 2. No `.env`: `LOCAL_BASE_URL=http://localhost:1234/v1` e `LOCAL_MODEL_NAME` com o nome exato do modelo no servidor
47
+
48
+ ```typescript
49
+ const ai = new AI({
50
+ ollamaBaseUrl: "http://127.0.0.1:11434",
51
+ localBaseUrl: "http://localhost:1234/v1",
52
+ localApiKey: "not-needed",
53
+ });
54
+
55
+ await ai.call({ aiModel: "ollama/llama3.2", messages: [...] });
56
+ await ai.call({ aiModel: "local/seu-modelo", messages: [...] });
57
+ ```
58
+
59
+ Limitações: tools e structured output dependem do modelo local; áudio (STT/TTS) continua via `AIAudio` na nuvem.
60
+
61
+ ### Embeddings (`AIEmbeddings`)
62
+ ```bash
63
+ pnpm test:e2e -- ai-embeddings
64
+ ```
65
+ - OpenAI: `OPENAI_API_KEY` — `text-embedding-3-small`
66
+ - Gemini: `GOOGLE_GEMINI_TOKEN` — `gemini-embedding-001`
67
+ - OpenRouter: `OPENROUTER_API_KEY` — `openrouter/openai/text-embedding-3-small`
68
+
69
+ Listar modelos: `GET https://openrouter.ai/api/v1/embeddings/models`
70
+
71
+ ### STT/TTS multiprovider (`AIAudio`)
72
+ ```bash
73
+ pnpm test:e2e -- ai-audio-multiprovider
74
+ ```
75
+ - OpenAI: `OPENAI_API_KEY` (STT detailed + TTS)
76
+ - OpenRouter: `OPENROUTER_API_KEY` (STT + TTS)
77
+ - TTS Gemini: `google/gemini-3.1-flash-tts-preview` + `Kore` + `pcm`
78
+ - TTS Mistral: `mistralai/voxtral-mini-tts-2603` + `en_paul_neutral` + `mp3` (exige providers liberados na conta OR)
79
+ - Listar modelos TTS: `GET https://openrouter.ai/api/v1/models?output_modalities=speech`
80
+
28
81
  ### Todos os testes E2E
29
82
  ```bash
30
83
  pnpm test:e2e