@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
@@ -20,7 +20,10 @@ vi.mock("../../src/langchain/models", () => ({
20
20
  gpt: vi.fn(),
21
21
  gemini: vi.fn(),
22
22
  openrouter: vi.fn(),
23
+ ollama: vi.fn(),
24
+ openaiCompatible: vi.fn(),
23
25
  },
26
+ DEFAULT_OLLAMA_BASE_URL: "http://127.0.0.1:11434",
24
27
  }));
25
28
 
26
29
  vi.mock("../../src/langchain/memory", async () => {
@@ -264,6 +267,77 @@ describe("AI", () => {
264
267
  ).rejects.toThrow("Model not supported");
265
268
  });
266
269
 
270
+ it("deve usar modelo Ollama quando aiModel começa com 'ollama/'", async () => {
271
+ const mockModel = {} as any;
272
+ const mockMessages = [AIMessages.human("Teste")];
273
+ const mockResponse = {
274
+ messages: [{ content: "Resposta local" } as any],
275
+ };
276
+
277
+ vi.mocked(AIModels.ollama).mockReturnValue(mockModel);
278
+ vi.mocked(createAgent).mockReturnValue({
279
+ invoke: vi.fn().mockResolvedValue(mockResponse),
280
+ } as any);
281
+
282
+ const localAi = new AI({ ollamaBaseUrl: "http://ollama:11434" });
283
+
284
+ await localAi.call({
285
+ aiModel: "ollama/llama3.2",
286
+ messages: mockMessages,
287
+ });
288
+
289
+ expect(AIModels.ollama).toHaveBeenCalledWith({
290
+ model: "llama3.2",
291
+ baseUrl: "http://ollama:11434",
292
+ maxTokens: undefined,
293
+ temperature: undefined,
294
+ numCtx: undefined,
295
+ });
296
+ });
297
+
298
+ it("deve usar modelo local OpenAI-compatible quando aiModel começa com 'local/'", async () => {
299
+ const mockModel = {} as any;
300
+ const mockMessages = [AIMessages.human("Teste")];
301
+ const mockResponse = {
302
+ messages: [{ content: "LM Studio" } as any],
303
+ };
304
+
305
+ vi.mocked(AIModels.openaiCompatible).mockReturnValue(mockModel);
306
+ vi.mocked(createAgent).mockReturnValue({
307
+ invoke: vi.fn().mockResolvedValue(mockResponse),
308
+ } as any);
309
+
310
+ const localAi = new AI({
311
+ localBaseUrl: "http://localhost:1234/v1",
312
+ localApiKey: "test-local-key",
313
+ });
314
+
315
+ await localAi.call({
316
+ aiModel: "local/qwen2.5-7b-instruct",
317
+ messages: mockMessages,
318
+ modelConfig: { temperature: 0.3 },
319
+ });
320
+
321
+ expect(AIModels.openaiCompatible).toHaveBeenCalledWith({
322
+ model: "qwen2.5-7b-instruct",
323
+ baseURL: "http://localhost:1234/v1",
324
+ apiKey: "test-local-key",
325
+ maxTokens: undefined,
326
+ temperature: 0.3,
327
+ });
328
+ });
329
+
330
+ it("deve lançar erro para local/* sem localBaseUrl", async () => {
331
+ const mockMessages = [AIMessages.human("Teste")];
332
+
333
+ await expect(
334
+ ai.call({
335
+ aiModel: "local/my-model",
336
+ messages: mockMessages,
337
+ }),
338
+ ).rejects.toThrow("localBaseUrl é obrigatório");
339
+ });
340
+
267
341
  it("deve passar modelConfig quando fornecido", async () => {
268
342
  const mockModel = {} as any;
269
343
  const mockMessages = [AIMessages.human("Teste")];
@@ -388,6 +462,106 @@ describe("AI", () => {
388
462
  warnSpy.mockRestore();
389
463
  });
390
464
 
465
+ it("deve aplicar provider only deepseek por padrão em openrouter/deepseek/*", async () => {
466
+ const mockModel = {} as any;
467
+ const mockMessages = [AIMessages.human("Teste")];
468
+ const mockResponse = {
469
+ messages: [{ content: "Resposta" } as any],
470
+ };
471
+
472
+ vi.mocked(AIModels.openrouter).mockReturnValue(mockModel);
473
+ vi.mocked(createAgent).mockReturnValue({
474
+ invoke: vi.fn().mockResolvedValue(mockResponse),
475
+ } as any);
476
+
477
+ const aiWithOpenRouter = new AI({
478
+ openRouterApiKey: "test-openrouter-key",
479
+ });
480
+
481
+ await aiWithOpenRouter.call({
482
+ aiModel: "openrouter/deepseek/deepseek-v3.2",
483
+ messages: mockMessages,
484
+ });
485
+
486
+ expect(AIModels.openrouter).toHaveBeenCalledWith(
487
+ expect.objectContaining({
488
+ model: "deepseek/deepseek-v3.2",
489
+ apiKey: "test-openrouter-key",
490
+ openRouterProvider: undefined,
491
+ openRouterAllowAllProviders: undefined,
492
+ }),
493
+ );
494
+ });
495
+
496
+ it("deve repassar openRouterProvider e openRouterAllowAllProviders no modelConfig", async () => {
497
+ const mockModel = {} as any;
498
+ const mockMessages = [AIMessages.human("Teste")];
499
+ const mockResponse = {
500
+ messages: [{ content: "Resposta" } as any],
501
+ };
502
+ const openRouterProvider = {
503
+ only: ["deepinfra"],
504
+ max_price: { prompt: 1, completion: 2 },
505
+ };
506
+
507
+ vi.mocked(AIModels.openrouter).mockReturnValue(mockModel);
508
+ vi.mocked(createAgent).mockReturnValue({
509
+ invoke: vi.fn().mockResolvedValue(mockResponse),
510
+ } as any);
511
+
512
+ const aiWithOpenRouter = new AI({
513
+ openRouterApiKey: "test-openrouter-key",
514
+ });
515
+
516
+ await aiWithOpenRouter.call({
517
+ aiModel: "openrouter/deepseek/deepseek-r1",
518
+ messages: mockMessages,
519
+ modelConfig: {
520
+ openRouterProvider,
521
+ openRouterAllowAllProviders: false,
522
+ },
523
+ });
524
+
525
+ expect(AIModels.openrouter).toHaveBeenCalledWith(
526
+ expect.objectContaining({
527
+ model: "deepseek/deepseek-r1",
528
+ openRouterProvider,
529
+ openRouterAllowAllProviders: false,
530
+ }),
531
+ );
532
+ });
533
+
534
+ it("deve repassar openRouterAllowAllProviders true para liberar todos os providers", async () => {
535
+ const mockModel = {} as any;
536
+ const mockMessages = [AIMessages.human("Teste")];
537
+ const mockResponse = {
538
+ messages: [{ content: "Resposta" } as any],
539
+ };
540
+
541
+ vi.mocked(AIModels.openrouter).mockReturnValue(mockModel);
542
+ vi.mocked(createAgent).mockReturnValue({
543
+ invoke: vi.fn().mockResolvedValue(mockResponse),
544
+ } as any);
545
+
546
+ const aiWithOpenRouter = new AI({
547
+ openRouterApiKey: "test-openrouter-key",
548
+ });
549
+
550
+ await aiWithOpenRouter.call({
551
+ aiModel: "openrouter/deepseek/deepseek-v3.2",
552
+ messages: mockMessages,
553
+ modelConfig: {
554
+ openRouterAllowAllProviders: true,
555
+ },
556
+ });
557
+
558
+ expect(AIModels.openrouter).toHaveBeenCalledWith(
559
+ expect.objectContaining({
560
+ openRouterAllowAllProviders: true,
561
+ }),
562
+ );
563
+ });
564
+
391
565
  it("deve lançar erro quando memory está ativo e threadId não é fornecido", async () => {
392
566
  const aiWithMemory = new AI({
393
567
  openAIApiKey: "test-key",
@@ -676,6 +850,41 @@ describe("AI", () => {
676
850
  expect(result.response).toEqual(mockStructuredResponse);
677
851
  });
678
852
 
853
+ it("deve usar json_object para DeepSeek sem responseFormat no agente", async () => {
854
+ const mockModel = {} as any;
855
+ const mockMessages = [AIMessages.human("Teste")];
856
+ const outputSchema = z.object({ sum: z.number(), product: z.number() });
857
+
858
+ vi.mocked(AIModels.openrouter).mockReturnValue(mockModel);
859
+ vi.mocked(createAgent).mockReturnValue({
860
+ invoke: vi.fn().mockResolvedValue({
861
+ messages: [{ content: '{"sum":11,"product":28}' }],
862
+ }),
863
+ } as any);
864
+
865
+ const aiWithOpenRouter = new AI({
866
+ openRouterApiKey: "test-openrouter-key",
867
+ });
868
+
869
+ const result = await aiWithOpenRouter.callStructuredOutput({
870
+ aiModel: "openrouter/deepseek/deepseek-v4-flash",
871
+ messages: mockMessages,
872
+ outputSchema,
873
+ });
874
+
875
+ expect(AIModels.openrouter).toHaveBeenCalledWith(
876
+ expect.objectContaining({
877
+ openRouterForceJsonObject: true,
878
+ }),
879
+ );
880
+ expect(createAgent).toHaveBeenCalledWith(
881
+ expect.objectContaining({
882
+ responseFormat: undefined,
883
+ }),
884
+ );
885
+ expect(result.response).toEqual({ sum: 11, product: 28 });
886
+ });
887
+
679
888
  it("deve validar o schema e lançar erro se inválido", async () => {
680
889
  const mockModel = {} as any;
681
890
  const mockMessages = [AIMessages.human("Teste")];
@@ -1,6 +1,15 @@
1
1
  import { AIMessages } from "../../../src/langchain/messages";
2
2
  import { SystemMessage, HumanMessage, AIMessage } from "langchain";
3
3
 
4
+ vi.mock("../../../src/audio", () => ({
5
+ AIAudio: {
6
+ transcribeWithWhisper: vi.fn().mockResolvedValue("texto whisper"),
7
+ transcribeOpenRouter: vi
8
+ .fn()
9
+ .mockResolvedValue({ text: "texto openrouter" }),
10
+ },
11
+ }));
12
+
4
13
  // Mock do langchain
5
14
  vi.mock("langchain", () => {
6
15
  class MockSystemMessage {
@@ -174,6 +183,34 @@ describe("AIMessages", () => {
174
183
  const callArgs = (HumanMessage as any).mock.calls[0][0];
175
184
  expect(callArgs.content[0].mime_type).toBe("audio/mp4");
176
185
  });
186
+
187
+ it("provider openai usa STT prévio via AIAudio", async () => {
188
+ const { AIAudio } = await import("../../../src/audio");
189
+ const audioBuffer = Buffer.from("fake audio data");
190
+
191
+ const result = await AIMessages.humanAudio({
192
+ audio: { buffer: audioBuffer, mimeType: "audio/mp3" },
193
+ provider: "openai",
194
+ openAIApiKey: "sk-test",
195
+ });
196
+
197
+ expect(AIAudio.transcribeWithWhisper).toHaveBeenCalled();
198
+ expect(result.content).toContain("texto whisper");
199
+ });
200
+
201
+ it("provider openrouter usa transcribeOpenRouter", async () => {
202
+ const { AIAudio } = await import("../../../src/audio");
203
+ const audioBuffer = Buffer.from("fake audio data");
204
+
205
+ const result = await AIMessages.humanAudio({
206
+ audio: { buffer: audioBuffer, mimeType: "audio/mp3" },
207
+ provider: "openrouter",
208
+ openRouterApiKey: "or-test",
209
+ });
210
+
211
+ expect(AIAudio.transcribeOpenRouter).toHaveBeenCalled();
212
+ expect(result.content).toContain("texto openrouter");
213
+ });
177
214
  });
178
215
 
179
216
  describe("humanImage", () => {
@@ -1,6 +1,12 @@
1
- import { AIModels, LLMModelConfig } from "../../../src/langchain/models";
1
+ import {
2
+ AIModels,
3
+ DEFAULT_OLLAMA_BASE_URL,
4
+ LLMModelConfig,
5
+ resolveOpenRouterProvider,
6
+ } from "../../../src/langchain/models";
2
7
  import { ChatOpenAI } from "@langchain/openai";
3
8
  import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
9
+ import { ChatOllama } from "@langchain/ollama";
4
10
 
5
11
  // Mock das dependências
6
12
  vi.mock("@langchain/openai", () => ({
@@ -11,6 +17,10 @@ vi.mock("@langchain/google-genai", () => ({
11
17
  ChatGoogleGenerativeAI: vi.fn(),
12
18
  }));
13
19
 
20
+ vi.mock("@langchain/ollama", () => ({
21
+ ChatOllama: vi.fn(),
22
+ }));
23
+
14
24
  describe("AIModels", () => {
15
25
  beforeEach(() => {
16
26
  vi.clearAllMocks();
@@ -205,4 +215,216 @@ describe("AIModels", () => {
205
215
  );
206
216
  });
207
217
  });
218
+
219
+ describe("resolveOpenRouterProvider", () => {
220
+ it("deve retornar only deepseek para modelos deepseek/ sem config", () => {
221
+ expect(
222
+ resolveOpenRouterProvider("deepseek/deepseek-v3.2", {}),
223
+ ).toEqual({ only: ["deepseek"] });
224
+ });
225
+
226
+ it("não deve aplicar default para modelos que não são deepseek/", () => {
227
+ expect(
228
+ resolveOpenRouterProvider("openai/gpt-5-nano", {}),
229
+ ).toBeUndefined();
230
+ });
231
+
232
+ it("deve retornar undefined quando openRouterAllowAllProviders é true", () => {
233
+ expect(
234
+ resolveOpenRouterProvider("deepseek/deepseek-v3.2", {
235
+ openRouterAllowAllProviders: true,
236
+ }),
237
+ ).toBeUndefined();
238
+ });
239
+
240
+ it("deve usar openRouterProvider explícito em vez do default", () => {
241
+ const custom = { only: ["deepinfra"] };
242
+ expect(
243
+ resolveOpenRouterProvider("deepseek/deepseek-r1", {
244
+ openRouterProvider: custom,
245
+ }),
246
+ ).toEqual(custom);
247
+ });
248
+ });
249
+
250
+ describe("openrouter", () => {
251
+ it("deve criar ChatOpenAI com configurações básicas", () => {
252
+ const config: LLMModelConfig = {
253
+ model: "openai/gpt-5-nano",
254
+ apiKey: "test-openrouter-key",
255
+ };
256
+
257
+ AIModels.openrouter(config);
258
+
259
+ expect(ChatOpenAI).toHaveBeenCalledWith({
260
+ model: "openai/gpt-5-nano",
261
+ apiKey: "test-openrouter-key",
262
+ configuration: {
263
+ baseURL: "https://openrouter.ai/api/v1",
264
+ },
265
+ });
266
+ });
267
+
268
+ it("deve aplicar provider only deepseek por padrão em modelos deepseek/", () => {
269
+ const config: LLMModelConfig = {
270
+ model: "deepseek/deepseek-v3.2",
271
+ apiKey: "test-openrouter-key",
272
+ };
273
+
274
+ AIModels.openrouter(config);
275
+
276
+ expect(ChatOpenAI).toHaveBeenCalledWith(
277
+ expect.objectContaining({
278
+ modelKwargs: { provider: { only: ["deepseek"] } },
279
+ }),
280
+ );
281
+ });
282
+
283
+ it("não deve enviar provider em modelos não-deepseek", () => {
284
+ const config: LLMModelConfig = {
285
+ model: "openai/gpt-5-nano",
286
+ apiKey: "test-openrouter-key",
287
+ };
288
+
289
+ AIModels.openrouter(config);
290
+
291
+ expect(ChatOpenAI).toHaveBeenCalledWith(
292
+ expect.not.objectContaining({
293
+ modelKwargs: expect.anything(),
294
+ }),
295
+ );
296
+ });
297
+
298
+ it("não deve aplicar default deepseek quando openRouterAllowAllProviders é true", () => {
299
+ const config: LLMModelConfig = {
300
+ model: "deepseek/deepseek-v3.2",
301
+ apiKey: "test-openrouter-key",
302
+ openRouterAllowAllProviders: true,
303
+ };
304
+
305
+ AIModels.openrouter(config);
306
+
307
+ expect(ChatOpenAI).toHaveBeenCalledWith(
308
+ expect.not.objectContaining({
309
+ modelKwargs: expect.anything(),
310
+ }),
311
+ );
312
+ });
313
+
314
+ it("deve enviar openRouterProvider customizado", () => {
315
+ const provider = {
316
+ only: ["deepinfra", "fireworks"],
317
+ max_price: { prompt: 0.5, completion: 1 },
318
+ sort: "price" as const,
319
+ };
320
+
321
+ AIModels.openrouter({
322
+ model: "deepseek/deepseek-r1",
323
+ apiKey: "test-openrouter-key",
324
+ openRouterProvider: provider,
325
+ });
326
+
327
+ expect(ChatOpenAI).toHaveBeenCalledWith(
328
+ expect.objectContaining({
329
+ modelKwargs: { provider },
330
+ }),
331
+ );
332
+ });
333
+
334
+ it("deve mesclar provider com reasoningEffort", () => {
335
+ AIModels.openrouter({
336
+ model: "deepseek/deepseek-r1",
337
+ apiKey: "test-openrouter-key",
338
+ reasoningEffort: "high",
339
+ openRouterProvider: { only: ["deepseek"] },
340
+ });
341
+
342
+ expect(ChatOpenAI).toHaveBeenCalledWith(
343
+ expect.objectContaining({
344
+ modelKwargs: {
345
+ reasoning_effort: "high",
346
+ provider: { only: ["deepseek"] },
347
+ },
348
+ }),
349
+ );
350
+ });
351
+
352
+ it("deve lançar erro quando apiKey não é fornecida", () => {
353
+ expect(() =>
354
+ AIModels.openrouter({ model: "openai/gpt-5-nano" }),
355
+ ).toThrow("OpenRouter API key is not passed in the model parameters");
356
+ });
357
+ });
358
+
359
+ describe("ollama", () => {
360
+ it("deve criar ChatOllama com configurações básicas e baseUrl padrão", () => {
361
+ AIModels.ollama({ model: "llama3.2" });
362
+
363
+ expect(ChatOllama).toHaveBeenCalledWith({
364
+ model: "llama3.2",
365
+ baseUrl: DEFAULT_OLLAMA_BASE_URL,
366
+ });
367
+ });
368
+
369
+ it("deve criar ChatOllama com baseUrl, temperature e numCtx customizados", () => {
370
+ AIModels.ollama({
371
+ model: "qwen2.5:7b",
372
+ baseUrl: "http://custom:11434",
373
+ temperature: 0.2,
374
+ numCtx: 8192,
375
+ maxTokens: 512,
376
+ });
377
+
378
+ expect(ChatOllama).toHaveBeenCalledWith({
379
+ model: "qwen2.5:7b",
380
+ baseUrl: "http://custom:11434",
381
+ temperature: 0.2,
382
+ numCtx: 8192,
383
+ numPredict: 512,
384
+ });
385
+ });
386
+ });
387
+
388
+ describe("openaiCompatible", () => {
389
+ it("deve criar ChatOpenAI com baseURL local", () => {
390
+ AIModels.openaiCompatible({
391
+ model: "meta-llama/Llama-3.2-3B-Instruct",
392
+ baseURL: "http://localhost:1234/v1",
393
+ });
394
+
395
+ expect(ChatOpenAI).toHaveBeenCalledWith({
396
+ model: "meta-llama/Llama-3.2-3B-Instruct",
397
+ apiKey: "not-needed",
398
+ configuration: {
399
+ baseURL: "http://localhost:1234/v1",
400
+ },
401
+ });
402
+ });
403
+
404
+ it("deve aceitar apiKey e parâmetros opcionais", () => {
405
+ AIModels.openaiCompatible({
406
+ model: "my-model",
407
+ baseURL: "http://127.0.0.1:11434/v1",
408
+ apiKey: "lm-studio",
409
+ maxTokens: 1000,
410
+ temperature: 0.5,
411
+ });
412
+
413
+ expect(ChatOpenAI).toHaveBeenCalledWith({
414
+ model: "my-model",
415
+ apiKey: "lm-studio",
416
+ maxTokens: 1000,
417
+ temperature: 0.5,
418
+ configuration: {
419
+ baseURL: "http://127.0.0.1:11434/v1",
420
+ },
421
+ });
422
+ });
423
+
424
+ it("deve lançar erro quando baseURL não é fornecida", () => {
425
+ expect(() =>
426
+ AIModels.openaiCompatible({ model: "x", baseURL: "" }),
427
+ ).toThrow("baseURL é obrigatório");
428
+ });
429
+ });
208
430
  });
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import z from "zod";
3
+ import {
4
+ buildJsonObjectSystemAppend,
5
+ isDeepSeekJsonObjectOnlyModel,
6
+ parseJsonFromAssistantMessages,
7
+ } from "../../../src/langchain/structured-output";
8
+
9
+ describe("structured-output", () => {
10
+ describe("isDeepSeekJsonObjectOnlyModel", () => {
11
+ it("deve detectar openrouter/deepseek/*", () => {
12
+ expect(
13
+ isDeepSeekJsonObjectOnlyModel("openrouter/deepseek/deepseek-v4-pro"),
14
+ ).toBe(true);
15
+ });
16
+
17
+ it("não deve detectar openrouter/openai/*", () => {
18
+ expect(
19
+ isDeepSeekJsonObjectOnlyModel("openrouter/openai/gpt-5-nano"),
20
+ ).toBe(false);
21
+ });
22
+ });
23
+
24
+ describe("buildJsonObjectSystemAppend", () => {
25
+ it("deve incluir a palavra json e o schema", () => {
26
+ const append = buildJsonObjectSystemAppend(
27
+ z.object({ name: z.string() }),
28
+ );
29
+ expect(append.toLowerCase()).toContain("json");
30
+ expect(append).toContain("name");
31
+ });
32
+ });
33
+
34
+ describe("parseJsonFromAssistantMessages", () => {
35
+ it("deve parsear JSON direto", () => {
36
+ const result = parseJsonFromAssistantMessages([
37
+ { content: '{"a":1}' } as any,
38
+ ]);
39
+ expect(result).toEqual({ a: 1 });
40
+ });
41
+
42
+ it("deve parsear JSON em fence markdown", () => {
43
+ const result = parseJsonFromAssistantMessages([
44
+ { content: '```json\n{"b":2}\n```' } as any,
45
+ ]);
46
+ expect(result).toEqual({ b: 2 });
47
+ });
48
+ });
49
+ });