@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
@@ -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
+ });
@@ -0,0 +1,65 @@
1
+ import { AI } from "../../src/index";
2
+ import { AIMessages } from "../../src/langchain/messages";
3
+ import "dotenv/config";
4
+
5
+ const ollamaBaseUrl = process.env.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434";
6
+ const ollamaModel = process.env.OLLAMA_MODEL_NAME ?? "llama3.2";
7
+ const localBaseUrl = process.env.LOCAL_BASE_URL;
8
+ const localModel = process.env.LOCAL_MODEL_NAME ?? "local-model";
9
+
10
+ const timeout = 180 * 1000;
11
+
12
+ describe("AI E2E — modelos locais", () => {
13
+ describe.skipIf(!process.env.OLLAMA_E2E)(
14
+ "Ollama (API nativa, prefixo ollama/)",
15
+ () => {
16
+ it(
17
+ "deve responder via ChatOllama",
18
+ { timeout },
19
+ async () => {
20
+ const ai = new AI({ ollamaBaseUrl });
21
+
22
+ const result = await ai.call({
23
+ aiModel: `ollama/${ollamaModel}`,
24
+ messages: [
25
+ AIMessages.human(
26
+ "Responda apenas com: teste ollama ok",
27
+ ),
28
+ ],
29
+ });
30
+
31
+ expect(result.text).toBeDefined();
32
+ expect(result.text.length).toBeGreaterThan(0);
33
+ },
34
+ );
35
+ },
36
+ );
37
+
38
+ describe.skipIf(!localBaseUrl)(
39
+ "OpenAI-compatible (prefixo local/)",
40
+ () => {
41
+ it(
42
+ "deve responder via servidor local (LM Studio, Ollama /v1, etc.)",
43
+ { timeout },
44
+ async () => {
45
+ const ai = new AI({
46
+ localBaseUrl,
47
+ localApiKey: process.env.LOCAL_API_KEY,
48
+ });
49
+
50
+ const result = await ai.call({
51
+ aiModel: `local/${localModel}`,
52
+ messages: [
53
+ AIMessages.human(
54
+ "Responda apenas com: teste local ok",
55
+ ),
56
+ ],
57
+ });
58
+
59
+ expect(result.text).toBeDefined();
60
+ expect(result.text.length).toBeGreaterThan(0);
61
+ },
62
+ );
63
+ },
64
+ );
65
+ });
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { AIAudio, ANTHROPIC_NO_NATIVE_AUDIO } from "../../../src/index.js";
3
+
4
+ describe("AIAudio facade", () => {
5
+ it("expõe métodos estáticos principais", () => {
6
+ expect(typeof AIAudio.transcribeWithWhisper).toBe("function");
7
+ expect(typeof AIAudio.transcribeDetailedOpenAI).toBe("function");
8
+ expect(typeof AIAudio.transcribeOpenRouter).toBe("function");
9
+ expect(typeof AIAudio.speakOpenAI).toBe("function");
10
+ expect(typeof AIAudio.speakOpenRouter).toBe("function");
11
+ expect(typeof AIAudio.transcribeWithGeminiPrompt).toBe("function");
12
+ });
13
+
14
+ it("exporta constante Anthropic", () => {
15
+ expect(ANTHROPIC_NO_NATIVE_AUDIO).toContain("Anthropic");
16
+ });
17
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { speakOpenAI } from "../../../src/audio/speech-openai.js";
3
+
4
+ const mockSpeechCreate = vi.fn();
5
+
6
+ vi.mock("openai", () => ({
7
+ default: class MockOpenAI {
8
+ audio = {
9
+ speech: {
10
+ create: mockSpeechCreate,
11
+ },
12
+ };
13
+ },
14
+ }));
15
+
16
+ describe("speech-openai", () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ mockSpeechCreate.mockResolvedValue({
20
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
21
+ });
22
+ });
23
+
24
+ it("cria speech com voice e model padrão", async () => {
25
+ const result = await speakOpenAI(
26
+ "Olá",
27
+ { voice: "nova" },
28
+ "sk-test",
29
+ );
30
+
31
+ expect(result.audio).toEqual(Buffer.from([1, 2, 3]));
32
+ expect(result.contentType).toBe("audio/mpeg");
33
+ expect(mockSpeechCreate).toHaveBeenCalledWith(
34
+ expect.objectContaining({
35
+ model: "gpt-4o-mini-tts",
36
+ voice: "nova",
37
+ input: "Olá",
38
+ response_format: "mp3",
39
+ }),
40
+ );
41
+ });
42
+
43
+ it("repassa instructions para gpt-4o-mini-tts", async () => {
44
+ await speakOpenAI(
45
+ "Oi",
46
+ {
47
+ voice: "alloy",
48
+ model: "gpt-4o-mini-tts",
49
+ instructions: "Fale devagar.",
50
+ },
51
+ "sk-test",
52
+ );
53
+
54
+ expect(mockSpeechCreate).toHaveBeenCalledWith(
55
+ expect.objectContaining({ instructions: "Fale devagar." }),
56
+ );
57
+ });
58
+
59
+ it("rejeita instructions em tts-1", async () => {
60
+ await expect(
61
+ speakOpenAI(
62
+ "Oi",
63
+ { voice: "alloy", model: "tts-1", instructions: "x" },
64
+ "sk-test",
65
+ ),
66
+ ).rejects.toThrow("instructions");
67
+ });
68
+
69
+ it("exige API key", async () => {
70
+ await expect(speakOpenAI("Oi", { voice: "nova" })).rejects.toThrow(
71
+ "OpenAI API key",
72
+ );
73
+ });
74
+ });
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { speakOpenRouter } from "../../../src/audio/speech-openrouter.js";
3
+
4
+ const mockSpeechCreate = vi.fn();
5
+ let capturedBaseURL: string | undefined;
6
+
7
+ vi.mock("../../../src/audio/openrouter-client.js", () => ({
8
+ createOpenRouterOpenAIClient: (apiKey: string) => {
9
+ capturedBaseURL = "https://openrouter.ai/api/v1";
10
+ return {
11
+ apiKey,
12
+ audio: {
13
+ speech: { create: mockSpeechCreate },
14
+ },
15
+ };
16
+ },
17
+ }));
18
+
19
+ describe("speech-openrouter", () => {
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ capturedBaseURL = undefined;
23
+ mockSpeechCreate.mockResolvedValue({
24
+ arrayBuffer: async () => new Uint8Array([9, 8]).buffer,
25
+ });
26
+ });
27
+
28
+ it("cria speech com model slug e voice", async () => {
29
+ const result = await speakOpenRouter(
30
+ "Resumo",
31
+ {
32
+ model: "google/gemini-2.5-flash-preview-tts",
33
+ voice: "Kore",
34
+ responseFormat: "mp3",
35
+ },
36
+ "or-key",
37
+ );
38
+
39
+ expect(result.audio).toEqual(Buffer.from([9, 8]));
40
+ expect(mockSpeechCreate).toHaveBeenCalledWith(
41
+ expect.objectContaining({
42
+ model: "google/gemini-2.5-flash-preview-tts",
43
+ voice: "Kore",
44
+ input: "Resumo",
45
+ }),
46
+ );
47
+ });
48
+
49
+ it("mescla instructions em provider.options sem apagar only", async () => {
50
+ await speakOpenRouter(
51
+ "Oi",
52
+ {
53
+ model: "deepseek/some-tts",
54
+ voice: "alloy",
55
+ instructions: "Tom formal.",
56
+ },
57
+ "or-key",
58
+ );
59
+
60
+ const body = mockSpeechCreate.mock.calls[0][0] as Record<string, unknown>;
61
+ const provider = body.provider as Record<string, unknown>;
62
+ expect(provider.only).toEqual(["deepseek"]);
63
+ expect(provider.options).toEqual({
64
+ openai: { instructions: "Tom formal." },
65
+ });
66
+ });
67
+
68
+ it("usa pcm por padrão para modelos Gemini TTS", async () => {
69
+ await speakOpenRouter(
70
+ "Oi",
71
+ {
72
+ model: "google/gemini-3.1-flash-tts-preview",
73
+ voice: "Kore",
74
+ },
75
+ "or-key",
76
+ );
77
+
78
+ expect(mockSpeechCreate).toHaveBeenCalledWith(
79
+ expect.objectContaining({ response_format: "pcm" }),
80
+ );
81
+ });
82
+
83
+ it("exige model e voice", async () => {
84
+ await expect(
85
+ speakOpenRouter("x", { model: "", voice: "a" }, "k"),
86
+ ).rejects.toThrow("model é obrigatório");
87
+ });
88
+ });