@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
@@ -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
@@ -0,0 +1,118 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { AIAudio } from "../../src/index";
4
+ import "dotenv/config";
5
+
6
+ const AUDIO_FIXTURE_PATH = path.join(
7
+ process.cwd(),
8
+ "tests",
9
+ "e2e",
10
+ "@fixtures",
11
+ "audio-teste-saudacao.mp3",
12
+ );
13
+
14
+ describe("AIAudio multiprovider E2E", () => {
15
+ const openAIApiKey = process.env.OPENAI_API_KEY;
16
+ const openRouterApiKey = process.env.OPENROUTER_API_KEY;
17
+ const timeout = 120_000;
18
+
19
+ it(
20
+ "OpenAI STT + TTS round-trip curto",
21
+ { timeout },
22
+ async () => {
23
+ if (!openAIApiKey) {
24
+ console.log("OPENAI_API_KEY não configurada — pulando");
25
+ return;
26
+ }
27
+
28
+ const audioBuffer = fs.readFileSync(AUDIO_FIXTURE_PATH);
29
+ const { text } = await AIAudio.transcribeDetailedOpenAI(
30
+ audioBuffer,
31
+ { languageIn2Digits: "pt", responseFormat: "json" },
32
+ openAIApiKey,
33
+ );
34
+
35
+ expect(text.length).toBeGreaterThan(0);
36
+
37
+ const speech = await AIAudio.speakOpenAI(
38
+ "OK",
39
+ { voice: "nova", model: "gpt-4o-mini-tts", responseFormat: "mp3" },
40
+ openAIApiKey,
41
+ );
42
+
43
+ expect(speech.audio.length).toBeGreaterThan(100);
44
+ },
45
+ );
46
+
47
+ it(
48
+ "OpenRouter STT",
49
+ { timeout },
50
+ async () => {
51
+ if (!openRouterApiKey) {
52
+ console.log("OPENROUTER_API_KEY não configurada — pulando");
53
+ return;
54
+ }
55
+
56
+ const audioBuffer = fs.readFileSync(AUDIO_FIXTURE_PATH);
57
+ const result = await AIAudio.transcribeOpenRouter(
58
+ audioBuffer,
59
+ {
60
+ model: "openai/gpt-4o-mini-transcribe",
61
+ format: "mp3",
62
+ language: "pt",
63
+ },
64
+ openRouterApiKey,
65
+ );
66
+
67
+ expect(result.text.length).toBeGreaterThan(0);
68
+ },
69
+ );
70
+
71
+ it(
72
+ "OpenRouter TTS (Gemini — catálogo output_modalities=speech)",
73
+ { timeout },
74
+ async () => {
75
+ if (!openRouterApiKey) {
76
+ console.log("OPENROUTER_API_KEY não configurada — pulando");
77
+ return;
78
+ }
79
+
80
+ // Descoberto via GET /api/v1/models?output_modalities=speech
81
+ const speech = await AIAudio.speakOpenRouter(
82
+ "Teste de síntese.",
83
+ {
84
+ model: "google/gemini-3.1-flash-tts-preview",
85
+ voice: "Kore",
86
+ },
87
+ openRouterApiKey,
88
+ );
89
+
90
+ expect(speech.audio.length).toBeGreaterThan(100);
91
+ expect(speech.contentType).toBe("audio/pcm");
92
+ },
93
+ );
94
+
95
+ it(
96
+ "OpenRouter TTS (Mistral — mp3, requer providers liberados na conta)",
97
+ { timeout },
98
+ async () => {
99
+ if (!openRouterApiKey) {
100
+ console.log("OPENROUTER_API_KEY não configurada — pulando");
101
+ return;
102
+ }
103
+
104
+ const speech = await AIAudio.speakOpenRouter(
105
+ "Hello, this is a synthesis test.",
106
+ {
107
+ model: "mistralai/voxtral-mini-tts-2603",
108
+ voice: "en_paul_neutral",
109
+ responseFormat: "mp3",
110
+ },
111
+ openRouterApiKey,
112
+ );
113
+
114
+ expect(speech.audio.length).toBeGreaterThan(100);
115
+ expect(speech.contentType).toBe("audio/mpeg");
116
+ },
117
+ );
118
+ });
@@ -0,0 +1,133 @@
1
+ import { AI } from "../../src/index";
2
+ import { AIMessages } from "../../src/langchain/messages";
3
+ import z from "zod";
4
+ import "dotenv/config";
5
+
6
+ describe("AI E2E - DeepSeek Structured Output", () => {
7
+ const openRouterApiKey = process.env.OPENROUTER_API_KEY;
8
+ const timeout = 180 * 1000;
9
+
10
+ const requireOpenRouter = () => {
11
+ if (!openRouterApiKey) {
12
+ console.log("OPENROUTER_API_KEY não está configurada");
13
+ return false;
14
+ }
15
+ return true;
16
+ };
17
+
18
+ it(
19
+ "deve retornar schema numérico com deepseek-v4-flash",
20
+ { timeout },
21
+ async () => {
22
+ if (!requireOpenRouter()) return;
23
+
24
+ const ai = new AI({ openRouterApiKey: openRouterApiKey! });
25
+ const outputSchema = z.object({
26
+ sum: z.number(),
27
+ product: z.number(),
28
+ });
29
+
30
+ const result = await ai.callStructuredOutput({
31
+ aiModel: "openrouter/deepseek/deepseek-v4-flash",
32
+ messages: [
33
+ AIMessages.human(
34
+ "Calcule a soma e o produto de 7 e 4. Responda em json.",
35
+ ),
36
+ ],
37
+ outputSchema,
38
+ });
39
+
40
+ expect(result.response.sum).toBe(11);
41
+ expect(result.response.product).toBe(28);
42
+ },
43
+ );
44
+
45
+ it(
46
+ "deve extrair nome e idade com deepseek-v4-pro",
47
+ { timeout },
48
+ async () => {
49
+ if (!requireOpenRouter()) return;
50
+
51
+ const ai = new AI({ openRouterApiKey: openRouterApiKey! });
52
+ const outputSchema = z.object({
53
+ nome: z.string(),
54
+ idade: z.number(),
55
+ });
56
+
57
+ const result = await ai.callStructuredOutput({
58
+ aiModel: "openrouter/deepseek/deepseek-v4-pro",
59
+ messages: [
60
+ AIMessages.human(
61
+ "Extraia nome e idade do texto: João tem 30 anos. Responda em json.",
62
+ ),
63
+ ],
64
+ outputSchema,
65
+ });
66
+
67
+ expect(result.response.nome.toLowerCase()).toContain("joão");
68
+ expect(result.response.idade).toBe(30);
69
+ },
70
+ );
71
+
72
+ it(
73
+ "deve suportar campos opcional/nullable com deepseek-v4-flash",
74
+ { timeout },
75
+ async () => {
76
+ if (!requireOpenRouter()) return;
77
+
78
+ const ai = new AI({ openRouterApiKey: openRouterApiKey! });
79
+ const outputSchema = z.object({
80
+ prontuarioFormal: z
81
+ .string()
82
+ .describe("Texto do prontuário")
83
+ .nullable(),
84
+ dataConsulta: z
85
+ .string()
86
+ .optional()
87
+ .describe("Data DD/MM/AAAA"),
88
+ });
89
+
90
+ const result = await ai.callStructuredOutput({
91
+ aiModel: "openrouter/deepseek/deepseek-v4-flash",
92
+ messages: [
93
+ AIMessages.human(
94
+ "Crie um prontuário breve para João, 30 anos, consulta 25/01/2026. Responda em json.",
95
+ ),
96
+ ],
97
+ outputSchema,
98
+ });
99
+
100
+ expect(result.response.prontuarioFormal).toBeDefined();
101
+ expect(typeof result.response.prontuarioFormal).toBe("string");
102
+ if (result.response.dataConsulta) {
103
+ expect(typeof result.response.dataConsulta).toBe("string");
104
+ }
105
+ },
106
+ );
107
+
108
+ it(
109
+ "deve suportar aiModelsFallback deepseek -> openai",
110
+ { timeout },
111
+ async () => {
112
+ if (!requireOpenRouter()) return;
113
+
114
+ const ai = new AI({ openRouterApiKey: openRouterApiKey! });
115
+ const outputSchema = z.object({
116
+ answer: z.string(),
117
+ });
118
+
119
+ const result = await ai.callStructuredOutput({
120
+ aiModel: "openrouter/deepseek/deepseek-v4-flash",
121
+ messages: [
122
+ AIMessages.human(
123
+ 'Responda em json: { "answer": "fallback deepseek ok" }',
124
+ ),
125
+ ],
126
+ outputSchema,
127
+ aiModelsFallback: ["openrouter/openai/gpt-5-nano"],
128
+ });
129
+
130
+ expect(result.response.answer.toLowerCase()).toContain("fallback");
131
+ },
132
+ );
133
+ });
@@ -0,0 +1,87 @@
1
+ import { AIEmbeddings } from "../../src/index";
2
+ import "dotenv/config";
3
+
4
+ describe("AIEmbeddings E2E", () => {
5
+ const openAIApiKey = process.env.OPENAI_API_KEY;
6
+ const googleGeminiToken = process.env.GOOGLE_GEMINI_TOKEN;
7
+ const openRouterApiKey = process.env.OPENROUTER_API_KEY;
8
+ const timeout = 60_000;
9
+
10
+ it(
11
+ "OpenAI text-embedding-3-small",
12
+ { timeout },
13
+ async () => {
14
+ if (!openAIApiKey) {
15
+ console.log("OPENAI_API_KEY não configurada — pulando");
16
+ return;
17
+ }
18
+
19
+ const result = await AIEmbeddings.embedOpenAI(
20
+ "The quick brown fox jumps over the lazy dog.",
21
+ { model: "text-embedding-3-small" },
22
+ openAIApiKey,
23
+ );
24
+
25
+ expect(result.embeddings).toHaveLength(1);
26
+ expect(result.embeddings[0]!.length).toBeGreaterThan(100);
27
+ },
28
+ );
29
+
30
+ it(
31
+ "Gemini gemini-embedding-001",
32
+ { timeout },
33
+ async () => {
34
+ if (!googleGeminiToken) {
35
+ console.log("GOOGLE_GEMINI_TOKEN não configurada — pulando");
36
+ return;
37
+ }
38
+
39
+ const vector = await AIEmbeddings.embedQuery(
40
+ "Semantic search test.",
41
+ { model: "gemini-embedding-001" },
42
+ { googleGeminiToken },
43
+ );
44
+
45
+ expect(vector.length).toBeGreaterThan(100);
46
+ },
47
+ );
48
+
49
+ it(
50
+ "OpenRouter openai/text-embedding-3-small",
51
+ { timeout },
52
+ async () => {
53
+ if (!openRouterApiKey) {
54
+ console.log("OPENROUTER_API_KEY não configurada — pulando");
55
+ return;
56
+ }
57
+
58
+ const result = await AIEmbeddings.embedOpenRouter(
59
+ ["First document.", "Second document."],
60
+ { model: "openrouter/openai/text-embedding-3-small" },
61
+ openRouterApiKey,
62
+ );
63
+
64
+ expect(result.embeddings).toHaveLength(2);
65
+ expect(result.embeddings[0]!.length).toBeGreaterThan(100);
66
+ },
67
+ );
68
+
69
+ it(
70
+ "embedDocuments em lote via roteamento OpenAI",
71
+ { timeout },
72
+ async () => {
73
+ if (!openAIApiKey) {
74
+ console.log("OPENAI_API_KEY não configurada — pulando");
75
+ return;
76
+ }
77
+
78
+ const vectors = await AIEmbeddings.embedDocuments(
79
+ ["doc a", "doc b"],
80
+ { model: "text-embedding-3-small" },
81
+ { openAIApiKey },
82
+ );
83
+
84
+ expect(vectors).toHaveLength(2);
85
+ },
86
+ );
87
+ });