@luanpoppe/ai 1.1.0 → 1.1.2

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.
@@ -2,6 +2,7 @@ import { AI } from "../../src/index";
2
2
  import { AIModels } from "../../src/langchain/models";
3
3
  import { createAgent } from "langchain";
4
4
  import { AIMessages } from "../../src/langchain/messages";
5
+ import { AIMemory } from "../../src/langchain/checkpointers";
5
6
  import z from "zod";
6
7
 
7
8
  // Mock das dependências
@@ -11,10 +12,6 @@ vi.mock("langchain", async () => {
11
12
  ...actual,
12
13
  createAgent: vi.fn(),
13
14
  modelRetryMiddleware: vi.fn((config) => ({ type: "retry", ...config })),
14
- modelFallbackMiddleware: vi.fn((...models) => ({
15
- type: "fallback",
16
- models,
17
- })),
18
15
  };
19
16
  });
20
17
 
@@ -22,9 +19,23 @@ vi.mock("../../src/langchain/models", () => ({
22
19
  AIModels: {
23
20
  gpt: vi.fn(),
24
21
  gemini: vi.fn(),
22
+ openrouter: vi.fn(),
25
23
  },
26
24
  }));
27
25
 
26
+ vi.mock("../../src/langchain/checkpointers", async () => {
27
+ const actual = await vi.importActual("../../src/langchain/checkpointers");
28
+ return {
29
+ ...actual,
30
+ AIMemory: vi.fn().mockImplementation(function () {
31
+ return {
32
+ getCheckpointer: vi.fn().mockResolvedValue({}),
33
+ setAgent: vi.fn(),
34
+ };
35
+ }),
36
+ };
37
+ });
38
+
28
39
  describe("AI", () => {
29
40
  let ai: AI;
30
41
  const mockTokens = {
@@ -52,6 +63,30 @@ describe("AI", () => {
52
63
  const instance = new AI({ googleGeminiToken: "test-token" });
53
64
  expect(instance).toBeInstanceOf(AI);
54
65
  });
66
+
67
+ it("deve criar uma instância com memory config e expor getter memory", () => {
68
+ const instance = new AI({
69
+ openAIApiKey: "test-key",
70
+ memory: { type: "memory" },
71
+ });
72
+ expect(instance).toBeInstanceOf(AI);
73
+ expect(instance.memory).toBeDefined();
74
+ });
75
+
76
+ it("deve criar uma instância com checkpointer", () => {
77
+ const mockCheckpointer = {} as any;
78
+ const instance = new AI({
79
+ openAIApiKey: "test-key",
80
+ checkpointer: mockCheckpointer,
81
+ });
82
+ expect(instance).toBeInstanceOf(AI);
83
+ expect(() => instance.memory).toThrow("memory não está configurado");
84
+ });
85
+
86
+ it("memory getter deve lançar erro quando memory não está configurado", () => {
87
+ const instance = new AI({ openAIApiKey: "test-key" });
88
+ expect(() => instance.memory).toThrow("memory não está configurado");
89
+ });
55
90
  });
56
91
 
57
92
  describe("call", () => {
@@ -159,14 +194,17 @@ describe("AI", () => {
159
194
  invoke: vi.fn().mockResolvedValue(mockResponse),
160
195
  } as any);
161
196
 
197
+ const { modelRetryMiddleware } = await import("langchain");
198
+
162
199
  await ai.call({
163
200
  aiModel: "gpt-4",
164
201
  messages: mockMessages,
165
202
  maxRetries: 5,
166
203
  });
167
204
 
168
- const callArgs = vi.mocked(createAgent).mock.calls[0][0];
169
- expect(callArgs.middleware).toBeDefined();
205
+ expect(modelRetryMiddleware).toHaveBeenCalledWith(
206
+ expect.objectContaining({ maxRetries: 5 }),
207
+ );
170
208
  });
171
209
 
172
210
  it("deve usar modelo GPT quando aiModel começa com 'gpt'", async () => {
@@ -222,7 +260,7 @@ describe("AI", () => {
222
260
  ai.call({
223
261
  aiModel: "unsupported-model" as any,
224
262
  messages: mockMessages,
225
- })
263
+ }),
226
264
  ).rejects.toThrow("Model not supported");
227
265
  });
228
266
 
@@ -254,6 +292,231 @@ describe("AI", () => {
254
292
  temperature: 0.7,
255
293
  });
256
294
  });
295
+
296
+ it("deve lançar erro quando memory está ativo e threadId não é fornecido", async () => {
297
+ const aiWithMemory = new AI({
298
+ openAIApiKey: "test-key",
299
+ memory: { type: "memory" },
300
+ });
301
+
302
+ await expect(
303
+ aiWithMemory.call({
304
+ aiModel: "gpt-4",
305
+ messages: [AIMessages.human("Olá")],
306
+ }),
307
+ ).rejects.toThrow("threadId é obrigatório");
308
+ });
309
+
310
+ it("deve fazer fallback para o próximo modelo quando o principal falhar", async () => {
311
+ const mockModel = {} as any;
312
+ const mockMessages = [AIMessages.human("Teste")];
313
+ const mockResponse = {
314
+ messages: [{ content: "Resposta do fallback" } as any],
315
+ };
316
+ const error = new Error("Modelo principal falhou");
317
+
318
+ vi.mocked(AIModels.gpt).mockReturnValue(mockModel);
319
+ vi.mocked(AIModels.gemini).mockReturnValue(mockModel);
320
+
321
+ let createCount = 0;
322
+ vi.mocked(createAgent).mockImplementation(() => {
323
+ createCount++;
324
+ return {
325
+ invoke: vi.fn().mockImplementation(() => {
326
+ if (createCount === 1) return Promise.reject(error);
327
+ return Promise.resolve(mockResponse);
328
+ }),
329
+ } as any;
330
+ });
331
+
332
+ const result = await ai.call({
333
+ aiModel: "gpt-4",
334
+ messages: mockMessages,
335
+ aiModelsFallback: ["gemini-2.5-flash"],
336
+ });
337
+
338
+ expect(createAgent).toHaveBeenCalledTimes(2);
339
+ expect(AIModels.gpt).toHaveBeenCalled();
340
+ expect(AIModels.gemini).toHaveBeenCalled();
341
+ expect(result.text).toBe("Resposta do fallback");
342
+ });
343
+
344
+ it("deve fazer fallback com modelo OpenRouter na lista", async () => {
345
+ const mockModel = {} as any;
346
+ const mockMessages = [AIMessages.human("Teste")];
347
+ const mockResponse = {
348
+ messages: [{ content: "Resposta OpenRouter" } as any],
349
+ };
350
+ const error = new Error("GPT falhou");
351
+
352
+ const aiWithOpenRouter = new AI({
353
+ openAIApiKey: "test-key",
354
+ openRouterApiKey: "test-openrouter-key",
355
+ });
356
+
357
+ vi.mocked(AIModels.gpt).mockReturnValue(mockModel);
358
+ vi.mocked(AIModels.openrouter).mockReturnValue(mockModel);
359
+
360
+ let callCount = 0;
361
+ vi.mocked(createAgent).mockImplementation(() => {
362
+ callCount++;
363
+ return {
364
+ invoke: vi.fn().mockImplementation(() => {
365
+ if (callCount === 1) return Promise.reject(error);
366
+ return Promise.resolve(mockResponse);
367
+ }),
368
+ } as any;
369
+ });
370
+
371
+ const result = await aiWithOpenRouter.call({
372
+ aiModel: "gpt-4",
373
+ messages: mockMessages,
374
+ aiModelsFallback: ["openrouter/openai/gpt-5-nano"],
375
+ });
376
+
377
+ expect(AIModels.openrouter).toHaveBeenCalledWith(
378
+ expect.objectContaining({
379
+ model: "openai/gpt-5-nano",
380
+ apiKey: "test-openrouter-key",
381
+ }),
382
+ );
383
+ expect(result.text).toBe("Resposta OpenRouter");
384
+ });
385
+
386
+ it("deve usar aiModelsFallback do construtor quando não passado no método", async () => {
387
+ const mockModel = {} as any;
388
+ const mockMessages = [AIMessages.human("Teste")];
389
+ const mockResponse = {
390
+ messages: [{ content: "Resposta fallback default" } as any],
391
+ };
392
+ const error = new Error("Modelo principal falhou");
393
+
394
+ const aiWithDefaultFallback = new AI({
395
+ openAIApiKey: "test-key",
396
+ aiModelsFallback: ["gemini-2.5-flash"],
397
+ });
398
+
399
+ vi.mocked(AIModels.gpt).mockReturnValue(mockModel);
400
+ vi.mocked(AIModels.gemini).mockReturnValue(mockModel);
401
+
402
+ let createCount = 0;
403
+ vi.mocked(createAgent).mockImplementation(() => {
404
+ createCount++;
405
+ return {
406
+ invoke: vi.fn().mockImplementation(() => {
407
+ if (createCount === 1) return Promise.reject(error);
408
+ return Promise.resolve(mockResponse);
409
+ }),
410
+ } as any;
411
+ });
412
+
413
+ const result = await aiWithDefaultFallback.call({
414
+ aiModel: "gpt-4",
415
+ messages: mockMessages,
416
+ });
417
+
418
+ expect(createAgent).toHaveBeenCalledTimes(2);
419
+ expect(result.text).toBe("Resposta fallback default");
420
+ });
421
+
422
+ it("deve usar aiModelsFallback do método quando passado, sobrescrevendo o do construtor", async () => {
423
+ const mockModel = {} as any;
424
+ const mockMessages = [AIMessages.human("Teste")];
425
+ const mockResponse = {
426
+ messages: [{ content: "Resposta fallback do método" } as any],
427
+ };
428
+ const error = new Error("Modelo principal falhou");
429
+
430
+ const aiWithDefaultFallback = new AI({
431
+ openAIApiKey: "test-key",
432
+ openRouterApiKey: "test-openrouter-key",
433
+ aiModelsFallback: ["gemini-2.5-flash"],
434
+ });
435
+
436
+ vi.mocked(AIModels.gpt).mockReturnValue(mockModel);
437
+ vi.mocked(AIModels.openrouter).mockReturnValue(mockModel);
438
+
439
+ let createCount = 0;
440
+ vi.mocked(createAgent).mockImplementation(() => {
441
+ createCount++;
442
+ return {
443
+ invoke: vi.fn().mockImplementation(() => {
444
+ if (createCount === 1) return Promise.reject(error);
445
+ return Promise.resolve(mockResponse);
446
+ }),
447
+ } as any;
448
+ });
449
+
450
+ const result = await aiWithDefaultFallback.call({
451
+ aiModel: "gpt-4",
452
+ messages: mockMessages,
453
+ aiModelsFallback: ["openrouter/openai/gpt-5-nano"],
454
+ });
455
+
456
+ expect(AIModels.openrouter).toHaveBeenCalled();
457
+ expect(result.text).toBe("Resposta fallback do método");
458
+ });
459
+
460
+ it("deve lançar exceção quando todos os modelos falharem", async () => {
461
+ const mockModel = {} as any;
462
+ const mockMessages = [AIMessages.human("Teste")];
463
+ const error = new Error("Todos os modelos falharam");
464
+
465
+ vi.mocked(AIModels.gpt).mockReturnValue(mockModel);
466
+ vi.mocked(AIModels.gemini).mockReturnValue(mockModel);
467
+
468
+ vi.mocked(createAgent).mockReturnValue({
469
+ invoke: vi.fn().mockRejectedValue(error),
470
+ } as any);
471
+
472
+ await expect(
473
+ ai.call({
474
+ aiModel: "gpt-4",
475
+ messages: mockMessages,
476
+ aiModelsFallback: ["gemini-2.5-flash"],
477
+ }),
478
+ ).rejects.toThrow("Todos os modelos falharam");
479
+
480
+ expect(createAgent).toHaveBeenCalledTimes(2);
481
+ });
482
+
483
+ it("deve passar checkpointer e thread_id quando memory e threadId são fornecidos", async () => {
484
+ const mockCheckpointer = {};
485
+ vi.mocked(AIMemory).mockImplementation(function () {
486
+ return {
487
+ getCheckpointer: vi.fn().mockResolvedValue(mockCheckpointer),
488
+ setAgent: vi.fn(),
489
+ } as any;
490
+ });
491
+
492
+ const aiWithMemory = new AI({
493
+ openAIApiKey: "test-key",
494
+ memory: { type: "memory" },
495
+ });
496
+
497
+ const mockModel = {} as any;
498
+ const mockMessages = [AIMessages.human("Olá")];
499
+ const mockResponse = {
500
+ messages: [mockMessages[0], { content: "Resposta" } as any],
501
+ };
502
+
503
+ vi.mocked(AIModels.gpt).mockReturnValue(mockModel);
504
+ const mockInvoke = vi.fn().mockResolvedValue(mockResponse);
505
+ vi.mocked(createAgent).mockReturnValue({ invoke: mockInvoke } as any);
506
+
507
+ await aiWithMemory.call({
508
+ aiModel: "gpt-4",
509
+ messages: mockMessages,
510
+ threadId: "thread-123",
511
+ });
512
+
513
+ const callArgs = vi.mocked(createAgent).mock.calls[0][0];
514
+ expect(callArgs.checkpointer).toBe(mockCheckpointer);
515
+ expect(mockInvoke).toHaveBeenCalledWith(
516
+ { messages: mockMessages },
517
+ { configurable: { thread_id: "thread-123" } },
518
+ );
519
+ });
257
520
  });
258
521
 
259
522
  describe("callStructuredOutput", () => {
@@ -284,6 +547,40 @@ describe("AI", () => {
284
547
  expect(result.response).toEqual(mockStructuredResponse);
285
548
  });
286
549
 
550
+ it("deve fazer fallback no callStructuredOutput quando o modelo principal falhar", async () => {
551
+ const mockModel = {} as any;
552
+ const mockMessages = [AIMessages.human("Teste")];
553
+ const outputSchema = z.object({ name: z.string() });
554
+ const mockStructuredResponse = { name: "Fallback" };
555
+ const error = new Error("Modelo principal falhou");
556
+
557
+ vi.mocked(AIModels.gpt).mockReturnValue(mockModel);
558
+ vi.mocked(AIModels.gemini).mockReturnValue(mockModel);
559
+
560
+ let createCount = 0;
561
+ vi.mocked(createAgent).mockImplementation(() => {
562
+ createCount++;
563
+ return {
564
+ invoke: vi.fn().mockImplementation(() => {
565
+ if (createCount === 1) return Promise.reject(error);
566
+ return Promise.resolve({
567
+ structuredResponse: mockStructuredResponse,
568
+ });
569
+ }),
570
+ } as any;
571
+ });
572
+
573
+ const result = await ai.callStructuredOutput({
574
+ aiModel: "gpt-4",
575
+ messages: mockMessages,
576
+ outputSchema,
577
+ aiModelsFallback: ["gemini-2.5-flash"],
578
+ });
579
+
580
+ expect(createAgent).toHaveBeenCalledTimes(2);
581
+ expect(result.response).toEqual(mockStructuredResponse);
582
+ });
583
+
287
584
  it("deve validar o schema e lançar erro se inválido", async () => {
288
585
  const mockModel = {} as any;
289
586
  const mockMessages = [AIMessages.human("Teste")];
@@ -305,13 +602,13 @@ describe("AI", () => {
305
602
  aiModel: "gpt-4",
306
603
  messages: mockMessages,
307
604
  outputSchema,
308
- })
605
+ }),
309
606
  ).rejects.toThrow();
310
607
  });
311
608
  });
312
609
 
313
610
  describe("getRawAgent", () => {
314
- it("deve retornar um agente sem outputSchema", () => {
611
+ it("deve retornar um agente sem outputSchema", async () => {
315
612
  const mockModel = {} as any;
316
613
  const mockMessages = [AIMessages.human("Teste")];
317
614
  const mockAgent = {
@@ -321,7 +618,7 @@ describe("AI", () => {
321
618
  vi.mocked(AIModels.gpt).mockReturnValue(mockModel);
322
619
  vi.mocked(createAgent).mockReturnValue(mockAgent as any);
323
620
 
324
- const result = ai.getRawAgent({
621
+ const result = await ai.getRawAgent({
325
622
  aiModel: "gpt-4",
326
623
  messages: mockMessages,
327
624
  });
@@ -330,7 +627,7 @@ describe("AI", () => {
330
627
  expect(result.agent).toBe(mockAgent);
331
628
  });
332
629
 
333
- it("deve retornar um agente com outputSchema quando fornecido", () => {
630
+ it("deve retornar um agente com outputSchema quando fornecido", async () => {
334
631
  const mockModel = {} as any;
335
632
  const mockMessages = [AIMessages.human("Teste")];
336
633
  const outputSchema = z.object({ result: z.string() });
@@ -341,12 +638,12 @@ describe("AI", () => {
341
638
  vi.mocked(AIModels.gpt).mockReturnValue(mockModel);
342
639
  vi.mocked(createAgent).mockReturnValue(mockAgent as any);
343
640
 
344
- const result = ai.getRawAgent(
641
+ const result = await ai.getRawAgent(
345
642
  {
346
643
  aiModel: "gpt-4",
347
644
  messages: mockMessages,
348
645
  },
349
- outputSchema
646
+ outputSchema,
350
647
  );
351
648
 
352
649
  expect(createAgent).toHaveBeenCalled();
@@ -0,0 +1,147 @@
1
+ import { AIMemory } from "../../../src/langchain/checkpointers";
2
+ import { MemorySaver } from "@langchain/langgraph";
3
+ import { describe, it, expect, vi } from "vitest";
4
+
5
+ describe("AIMemory", () => {
6
+ describe("getCheckpointer", () => {
7
+ it("deve criar MemorySaver para type memory", async () => {
8
+ const memory = new AIMemory({ type: "memory" });
9
+ const checkpointer = await memory.getCheckpointer();
10
+ expect(checkpointer).toBeInstanceOf(MemorySaver);
11
+ });
12
+
13
+ it("deve retornar a mesma instância em chamadas subsequentes", async () => {
14
+ const memory = new AIMemory({ type: "memory" });
15
+ const cp1 = await memory.getCheckpointer();
16
+ const cp2 = await memory.getCheckpointer();
17
+ expect(cp1).toBe(cp2);
18
+ });
19
+
20
+ it("deve criar MongoDBSaver com url quando pacotes estão instalados", async () => {
21
+ const memory = new AIMemory({
22
+ type: "mongodb",
23
+ url: "mongodb://localhost:27017/test",
24
+ });
25
+ const checkpointer = await memory.getCheckpointer();
26
+ expect(checkpointer).toBeDefined();
27
+ expect(checkpointer).toHaveProperty("get");
28
+ expect(checkpointer).toHaveProperty("put");
29
+ });
30
+ });
31
+
32
+ describe("getHistory", () => {
33
+ it("deve retornar fullHistory e messages via graph.getStateHistory", async () => {
34
+ const memory = new AIMemory({ type: "memory" });
35
+ const mockSnapshot1 = {
36
+ values: { messages: [{ role: "user", content: "oi" }] },
37
+ config: { configurable: { thread_id: "1" } },
38
+ next: [],
39
+ tasks: [],
40
+ createdAt: "2024-01-01T10:00:00Z",
41
+ };
42
+ const mockSnapshot2 = {
43
+ values: {
44
+ messages: [
45
+ { role: "user", content: "oi" },
46
+ { role: "assistant", content: "olá" },
47
+ ],
48
+ },
49
+ config: { configurable: { thread_id: "1" } },
50
+ next: [],
51
+ tasks: [],
52
+ createdAt: "2024-01-01T10:01:00Z",
53
+ };
54
+ // getStateHistory retorna mais recente primeiro: snapshot2 (2 msgs) depois snapshot1 (1 msg)
55
+ const mockGraph = {
56
+ getStateHistory: vi.fn(async function* () {
57
+ yield mockSnapshot2;
58
+ yield mockSnapshot1;
59
+ }),
60
+ };
61
+ const result = await memory.getHistory("1", mockGraph as any);
62
+ expect(result.fullHistory).toHaveLength(2);
63
+ expect(result.fullHistory[0]).toEqual(mockSnapshot2);
64
+ expect(result.fullHistory[1]).toEqual(mockSnapshot1);
65
+ expect(result.messages).toHaveLength(2);
66
+ expect(result.messages[0]).toEqual({
67
+ role: "human",
68
+ createdAt: "2024-01-01T10:00:00Z",
69
+ content: "oi",
70
+ });
71
+ expect(result.messages[1]).toEqual({
72
+ role: "ai",
73
+ createdAt: "2024-01-01T10:01:00Z",
74
+ content: "olá",
75
+ });
76
+ expect(mockGraph.getStateHistory).toHaveBeenCalledWith({
77
+ configurable: { thread_id: "1" },
78
+ });
79
+ });
80
+
81
+ it("deve usar agent do setAgent quando graph não é passado", async () => {
82
+ const memory = new AIMemory({ type: "memory" });
83
+ const mockGraph = {
84
+ getStateHistory: vi.fn(async function* () {
85
+ yield {
86
+ values: { messages: [{ role: "user", content: "teste" }] },
87
+ createdAt: "2024-01-01T10:00:00Z",
88
+ };
89
+ }),
90
+ };
91
+ memory.setAgent(mockGraph as any);
92
+ const result = await memory.getHistory("1");
93
+ expect(result.messages).toHaveLength(1);
94
+ expect(result.messages[0].content).toBe("teste");
95
+ expect(mockGraph.getStateHistory).toHaveBeenCalledWith({
96
+ configurable: { thread_id: "1" },
97
+ });
98
+ });
99
+
100
+ it("deve lançar erro quando graph não é passado e agent não foi definido", async () => {
101
+ const memory = new AIMemory({ type: "memory" });
102
+ await expect(memory.getHistory("1")).rejects.toThrow(
103
+ "É necessário passar graph em getHistory ou definir o agent"
104
+ );
105
+ });
106
+ });
107
+
108
+ describe("getState", () => {
109
+ it("deve retornar estado atual via graph.getState quando disponível", async () => {
110
+ const memory = new AIMemory({ type: "memory" });
111
+ const mockState = {
112
+ values: { messages: [{ role: "user", content: "oi" }] },
113
+ config: { configurable: { thread_id: "1" } },
114
+ next: [],
115
+ tasks: [],
116
+ };
117
+ const mockGraph = {
118
+ getState: vi.fn().mockResolvedValue(mockState),
119
+ getStateHistory: vi.fn(async function* () {
120
+ yield mockState;
121
+ }),
122
+ };
123
+ const state = await memory.getState("1", mockGraph as any);
124
+ expect(state).toEqual(mockState);
125
+ expect(mockGraph.getState).toHaveBeenCalledWith({
126
+ configurable: { thread_id: "1" },
127
+ });
128
+ });
129
+
130
+ it("deve usar getStateHistory quando getState não existe", async () => {
131
+ const memory = new AIMemory({ type: "memory" });
132
+ const mockState = {
133
+ values: { messages: [] },
134
+ config: { configurable: { thread_id: "1" } },
135
+ next: [],
136
+ tasks: [],
137
+ };
138
+ const mockGraph = {
139
+ getStateHistory: vi.fn(async function* () {
140
+ yield mockState;
141
+ }),
142
+ };
143
+ const state = await memory.getState("1", mockGraph as any);
144
+ expect(state).toEqual(mockState);
145
+ });
146
+ });
147
+ });
@@ -103,7 +103,8 @@ describe("AITools", () => {
103
103
 
104
104
  it("deve criar múltiplas ferramentas independentes", () => {
105
105
  const toolFunction1 = async (input: { x: number }) => input.x * 2;
106
- const toolFunction2 = async (input: { y: string }) => y.toUpperCase();
106
+ const toolFunction2 = async (input: { y: string }) =>
107
+ input.y.toUpperCase();
107
108
 
108
109
  const params1: CreateToolParams = {
109
110
  toolFunction: toolFunction1,