@luanpoppe/ai 1.1.3 → 1.1.5

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 (41) hide show
  1. package/dist/@types/image.d.ts +4 -0
  2. package/dist/@types/image.d.ts.map +1 -0
  3. package/dist/@types/image.js +10 -0
  4. package/dist/@types/image.js.map +1 -0
  5. package/dist/@types/model-names.d.ts +16 -4
  6. package/dist/@types/model-names.d.ts.map +1 -1
  7. package/dist/ai.d.ts.map +1 -1
  8. package/dist/ai.js +6 -0
  9. package/dist/ai.js.map +1 -1
  10. package/dist/index.d.ts +5 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/langchain/messages.d.ts +17 -0
  15. package/dist/langchain/messages.d.ts.map +1 -1
  16. package/dist/langchain/messages.js +21 -0
  17. package/dist/langchain/messages.js.map +1 -1
  18. package/dist/langchain/models.d.ts +4 -0
  19. package/dist/langchain/models.d.ts.map +1 -1
  20. package/dist/langchain/models.js +14 -2
  21. package/dist/langchain/models.js.map +1 -1
  22. package/dist/utils/image-utils.d.ts +8 -0
  23. package/dist/utils/image-utils.d.ts.map +1 -0
  24. package/dist/utils/image-utils.js +91 -0
  25. package/dist/utils/image-utils.js.map +1 -0
  26. package/package.json +1 -1
  27. package/src/@types/image.ts +14 -0
  28. package/src/@types/model-names.ts +92 -5
  29. package/src/ai.ts +11 -0
  30. package/src/index.ts +6 -0
  31. package/src/langchain/messages.ts +47 -0
  32. package/src/langchain/models.ts +19 -2
  33. package/src/utils/image-utils.ts +113 -0
  34. package/tests/e2e/@fixtures/imagem-teste-bateria.jpg +0 -0
  35. package/tests/e2e/ai-audio.test.ts +3 -3
  36. package/tests/e2e/ai-image.test.ts +173 -0
  37. package/tests/e2e/ai.test.ts +44 -4
  38. package/tests/unit/index.test.ts +95 -0
  39. package/tests/unit/langchain/image-utils.test.ts +127 -0
  40. package/tests/unit/langchain/messages.test.ts +78 -0
  41. package/tests/unit/langchain/models.test.ts +16 -0
package/src/ai.ts CHANGED
@@ -243,10 +243,21 @@ export class AI {
243
243
  private getModel(params: AICallParams) {
244
244
  const { aiModel, modelConfig } = params;
245
245
 
246
+ const supportsReasoning =
247
+ aiModel.startsWith("gpt") ||
248
+ aiModel.startsWith("openrouter/openai/");
249
+
250
+ if (modelConfig?.reasoningEffort && !supportsReasoning) {
251
+ console.warn(
252
+ `[@luanpoppe/ai] O modelo "${aiModel}" não suporta reasoningEffort. O parâmetro será ignorado. Use modelos OpenAI (gpt*) ou OpenRouter com OpenAI (openrouter/openai/*).`,
253
+ );
254
+ }
255
+
246
256
  const config: LLMModelConfig = {
247
257
  model: aiModel,
248
258
  maxTokens: modelConfig?.maxTokens,
249
259
  temperature: modelConfig?.temperature,
260
+ reasoningEffort: supportsReasoning ? modelConfig?.reasoningEffort : undefined,
250
261
  };
251
262
 
252
263
  if (aiModel.startsWith("gpt")) {
package/src/index.ts CHANGED
@@ -15,8 +15,10 @@ export type {
15
15
  AICallStructuredOutputParams,
16
16
  AICallStructuredOutputReturn,
17
17
  } from "./@types/ai-call";
18
+ export type { AIModelNames } from "./@types/model-names";
18
19
 
19
20
  export { AIModels } from "./langchain/models";
21
+ export type { LLMModelConfig, ReasoningEffort } from "./langchain/models";
20
22
  export { AIMessages } from "./langchain/messages";
21
23
  export { AITools } from "./langchain/tools";
22
24
  export { AIMemory } from "./langchain/memory";
@@ -35,10 +37,14 @@ export type {
35
37
  } from "./langchain/memory";
36
38
  export { AIAudioTranscription } from "./langchain/audio-transcription";
37
39
  export { AudioUtils } from "./utils/audio-utils";
40
+ export { ImageUtils } from "./utils/image-utils";
38
41
  export type { AudioBuffer, AudioMimeType } from "./@types/audio";
42
+ export type { ImageBuffer, ImageMimeType } from "./@types/image";
39
43
  export type {
40
44
  AudioContentBlock,
41
45
  HumanMessageWithAudioOptions,
46
+ ImageContentBlock,
47
+ HumanMessageWithImageOptions,
42
48
  } from "./langchain/messages";
43
49
  export type {
44
50
  WhisperModel,
@@ -1,10 +1,29 @@
1
1
  import { SystemMessage, HumanMessage, AIMessage } from "langchain";
2
2
  import { AudioUtils } from "../utils/audio-utils";
3
+ import { ImageUtils } from "../utils/image-utils";
3
4
  import type { AudioBuffer, AudioMimeType } from "../@types/audio";
5
+ import type { ImageBuffer, ImageMimeType } from "../@types/image";
4
6
  import { AIAudioTranscription } from "./audio-transcription";
5
7
 
6
8
  export type MessageInput = SystemMessage | HumanMessage | AIMessage;
7
9
 
10
+ export type ImageContentBlock = {
11
+ type: "image";
12
+ source_type: "base64";
13
+ data: string;
14
+ mime_type: ImageMimeType;
15
+ metadata?: Record<string, unknown>;
16
+ };
17
+
18
+ export type HumanMessageWithImageOptions = {
19
+ image: {
20
+ buffer: ImageBuffer;
21
+ mimeType?: ImageMimeType;
22
+ filename?: string;
23
+ };
24
+ text?: string;
25
+ };
26
+
8
27
  export type AudioContentBlock = {
9
28
  type: "audio";
10
29
  source_type: "base64";
@@ -33,6 +52,34 @@ export class AIMessages {
33
52
  return new HumanMessage(message);
34
53
  }
35
54
 
55
+ static humanImage(options: HumanMessageWithImageOptions): HumanMessage {
56
+ const { image, text } = options;
57
+ const { buffer, mimeType, filename } = image;
58
+
59
+ const base64Data = ImageUtils.bufferToBase64(buffer);
60
+ const detectedMimeType =
61
+ mimeType ?? ImageUtils.detectImageMimeType(buffer, filename);
62
+
63
+ const content: Array<{ type: "text"; text: string } | ImageContentBlock> =
64
+ [];
65
+
66
+ if (text) {
67
+ content.push({ type: "text", text });
68
+ }
69
+
70
+ const imageBlock: ImageContentBlock = {
71
+ type: "image",
72
+ source_type: "base64",
73
+ data: base64Data,
74
+ mime_type: detectedMimeType,
75
+ };
76
+ content.push(imageBlock);
77
+
78
+ return new HumanMessage({
79
+ content: content as any,
80
+ } as any);
81
+ }
82
+
36
83
  static async humanAudio(
37
84
  options: HumanMessageWithAudioOptions
38
85
  ): Promise<HumanMessage> {
@@ -4,16 +4,21 @@ import {
4
4
  } from "@langchain/google-genai";
5
5
  import { ChatOpenAI, ChatOpenAIFields } from "@langchain/openai";
6
6
 
7
+ /** Nível de esforço de raciocínio para modelos OpenAI (o1, gpt-5, etc.). Valores: "low" | "medium" | "high" */
8
+ export type ReasoningEffort = "low" | "medium" | "high";
9
+
7
10
  export type LLMModelConfig = {
8
11
  model: string;
9
12
  apiKey?: string | undefined;
10
13
  maxTokens?: number | undefined;
11
14
  temperature?: number | undefined;
15
+ /** Nível de esforço de raciocínio (modelos OpenAI: o1, gpt-5, etc.) */
16
+ reasoningEffort?: ReasoningEffort | undefined;
12
17
  };
13
18
 
14
19
  export class AIModels {
15
20
  static gpt(params: LLMModelConfig) {
16
- const { model, apiKey, maxTokens, temperature } = params;
21
+ const { model, apiKey, maxTokens, temperature, reasoningEffort } = params;
17
22
  if (!apiKey)
18
23
  throw new Error("OpenAI API key is not passed in the model parameters");
19
24
 
@@ -24,6 +29,12 @@ export class AIModels {
24
29
 
25
30
  if (maxTokens) options.maxTokens = maxTokens;
26
31
  if (temperature) options.temperature = temperature;
32
+ if (reasoningEffort) {
33
+ options.modelKwargs = {
34
+ ...(options.modelKwargs ?? {}),
35
+ reasoning_effort: reasoningEffort,
36
+ };
37
+ }
27
38
 
28
39
  return new ChatOpenAI(options);
29
40
  }
@@ -48,7 +59,7 @@ export class AIModels {
48
59
  }
49
60
 
50
61
  static openrouter(params: LLMModelConfig) {
51
- const { apiKey, maxTokens, model, temperature } = params;
62
+ const { apiKey, maxTokens, model, temperature, reasoningEffort } = params;
52
63
 
53
64
  if (!apiKey)
54
65
  throw new Error(
@@ -65,6 +76,12 @@ export class AIModels {
65
76
 
66
77
  if (maxTokens) options.maxTokens = maxTokens;
67
78
  if (temperature) options.temperature = temperature;
79
+ if (reasoningEffort) {
80
+ options.modelKwargs = {
81
+ ...(options.modelKwargs ?? {}),
82
+ reasoning_effort: reasoningEffort,
83
+ };
84
+ }
68
85
 
69
86
  return new ChatOpenAI(options);
70
87
  }
@@ -0,0 +1,113 @@
1
+ import type { ImageBuffer, ImageMimeType } from "../@types/image";
2
+
3
+ export class ImageUtils {
4
+ static bufferToBase64(buffer: ImageBuffer): string {
5
+ if (buffer instanceof Buffer) {
6
+ return buffer.toString("base64");
7
+ }
8
+
9
+ if (buffer instanceof ArrayBuffer) {
10
+ const uint8Array = new Uint8Array(buffer);
11
+ return Buffer.from(uint8Array).toString("base64");
12
+ }
13
+
14
+ if (buffer instanceof Uint8Array) {
15
+ return Buffer.from(buffer).toString("base64");
16
+ }
17
+
18
+ throw new Error("Tipo de buffer não suportado");
19
+ }
20
+
21
+ static detectMimeTypeFromExtension(filename?: string): ImageMimeType | null {
22
+ if (!filename) {
23
+ return null;
24
+ }
25
+
26
+ const extension = filename.toLowerCase().split(".").pop();
27
+
28
+ const mimeTypeMap: Record<string, ImageMimeType> = {
29
+ jpg: "image/jpeg",
30
+ jpeg: "image/jpeg",
31
+ png: "image/png",
32
+ gif: "image/gif",
33
+ webp: "image/webp",
34
+ };
35
+
36
+ return extension ? mimeTypeMap[extension] ?? null : null;
37
+ }
38
+
39
+ static detectMimeTypeFromBuffer(buffer: ImageBuffer): ImageMimeType | null {
40
+ let bytes: Uint8Array;
41
+
42
+ if (buffer instanceof Buffer) {
43
+ bytes = new Uint8Array(buffer.slice(0, 16));
44
+ } else if (buffer instanceof ArrayBuffer) {
45
+ bytes = new Uint8Array(buffer.slice(0, 16));
46
+ } else {
47
+ bytes = buffer.slice(0, 16);
48
+ }
49
+
50
+ // JPEG: FF D8 FF
51
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
52
+ return "image/jpeg";
53
+ }
54
+
55
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
56
+ if (
57
+ bytes[0] === 0x89 &&
58
+ bytes[1] === 0x50 &&
59
+ bytes[2] === 0x4e &&
60
+ bytes[3] === 0x47 &&
61
+ bytes[4] === 0x0d &&
62
+ bytes[5] === 0x0a &&
63
+ bytes[6] === 0x1a &&
64
+ bytes[7] === 0x0a
65
+ ) {
66
+ return "image/png";
67
+ }
68
+
69
+ // GIF: 47 49 46 38 (GIF8)
70
+ if (
71
+ bytes[0] === 0x47 &&
72
+ bytes[1] === 0x49 &&
73
+ bytes[2] === 0x46 &&
74
+ bytes[3] === 0x38
75
+ ) {
76
+ return "image/gif";
77
+ }
78
+
79
+ // WebP: 52 49 46 46 (RIFF) ... 57 45 42 50 (WEBP)
80
+ if (
81
+ bytes[0] === 0x52 &&
82
+ bytes[1] === 0x49 &&
83
+ bytes[2] === 0x46 &&
84
+ bytes[3] === 0x46 &&
85
+ bytes[8] === 0x57 &&
86
+ bytes[9] === 0x45 &&
87
+ bytes[10] === 0x42 &&
88
+ bytes[11] === 0x50
89
+ ) {
90
+ return "image/webp";
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ static detectImageMimeType(
97
+ buffer: ImageBuffer,
98
+ filename?: string
99
+ ): ImageMimeType {
100
+ const mimeFromExtension =
101
+ ImageUtils.detectMimeTypeFromExtension(filename);
102
+ if (mimeFromExtension) {
103
+ return mimeFromExtension;
104
+ }
105
+
106
+ const mimeFromBuffer = ImageUtils.detectMimeTypeFromBuffer(buffer);
107
+ if (mimeFromBuffer) {
108
+ return mimeFromBuffer;
109
+ }
110
+
111
+ return "image/jpeg";
112
+ }
113
+ }
@@ -58,7 +58,7 @@ describe("AI Audio E2E Tests", () => {
58
58
  }
59
59
  );
60
60
 
61
- it(
61
+ it.skip(
62
62
  "deve processar áudio diretamente com Gemini (multimodal)",
63
63
  { timeout },
64
64
  async () => {
@@ -132,7 +132,7 @@ describe("AI Audio E2E Tests", () => {
132
132
  }
133
133
  );
134
134
 
135
- it(
135
+ it.skip(
136
136
  "deve processar áudio com OpenRouter usando Gemini (multimodal)",
137
137
  { timeout },
138
138
  async () => {
@@ -168,7 +168,7 @@ describe("AI Audio E2E Tests", () => {
168
168
  }
169
169
  );
170
170
 
171
- it(
171
+ it.skip(
172
172
  "deve processar áudio com OpenRouter usando GPT (transcrição prévia com Whisper)",
173
173
  { timeout },
174
174
  async () => {
@@ -0,0 +1,173 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { AI } from "../../src/index";
4
+ import { AIMessages } from "../../src/langchain/messages";
5
+ import "dotenv/config";
6
+
7
+ const IMAGE_FIXTURE_PATH = path.join(
8
+ process.cwd(),
9
+ "tests",
10
+ "e2e",
11
+ "@fixtures",
12
+ "imagem-teste-bateria.jpg"
13
+ );
14
+
15
+ const PROMPT =
16
+ "Descreva brevemente o que há nesta imagem. Responda em uma frase.";
17
+
18
+ const EXPECTED_TERMS = [
19
+ "bateria",
20
+ "baterista",
21
+ "pratos",
22
+ "tambor",
23
+ "drum",
24
+ "cymbal",
25
+ "palco",
26
+ "stage",
27
+ ];
28
+
29
+ function responseRelatesToImage(text: string): boolean {
30
+ const lower = text.toLowerCase();
31
+ return EXPECTED_TERMS.some((term) => lower.includes(term));
32
+ }
33
+
34
+ describe("AI Image E2E Tests", () => {
35
+ const openAIApiKey = process.env.OPENAI_API_KEY;
36
+ const googleGeminiToken = process.env.GOOGLE_GEMINI_TOKEN;
37
+ const openRouterApiKey = process.env.OPENROUTER_API_KEY;
38
+
39
+ const timeout = 180 * 1000;
40
+
41
+ describe("Suporte a imagens", () => {
42
+ it(
43
+ "deve processar imagem com OpenAI (gpt-4o)",
44
+ { timeout },
45
+ async () => {
46
+ if (!openAIApiKey) {
47
+ console.log("OPENAI_API_KEY não está configurada");
48
+ return;
49
+ }
50
+ const ai = new AI({
51
+ openAIApiKey: openAIApiKey!,
52
+ });
53
+
54
+ const imageBuffer = fs.readFileSync(IMAGE_FIXTURE_PATH);
55
+ const message = AIMessages.humanImage({
56
+ image: {
57
+ buffer: imageBuffer,
58
+ filename: "imagem-teste-bateria.jpg",
59
+ },
60
+ text: PROMPT,
61
+ });
62
+
63
+ const result = await ai.call({
64
+ aiModel: "gpt-4o",
65
+ messages: [message],
66
+ });
67
+
68
+ expect(result.text).toBeDefined();
69
+ expect(result.text.length).toBeGreaterThan(0);
70
+ expect(result.text).not.toContain("Model call failed");
71
+ expect(result.text).not.toContain("Invalid");
72
+ expect(responseRelatesToImage(result.text)).toBe(true);
73
+ }
74
+ );
75
+
76
+ it.skip(
77
+ "deve processar imagem com Gemini (gemini-2.5-flash)",
78
+ { timeout },
79
+ async () => {
80
+ if (!googleGeminiToken) {
81
+ console.log("GOOGLE_GEMINI_TOKEN não está configurada");
82
+ return;
83
+ }
84
+ const ai = new AI({
85
+ googleGeminiToken: googleGeminiToken!,
86
+ });
87
+
88
+ const imageBuffer = fs.readFileSync(IMAGE_FIXTURE_PATH);
89
+ const message = AIMessages.humanImage({
90
+ image: {
91
+ buffer: imageBuffer,
92
+ filename: "imagem-teste-bateria.jpg",
93
+ },
94
+ text: PROMPT,
95
+ });
96
+
97
+ const result = await ai.call({
98
+ aiModel: "gemini-2.5-flash",
99
+ messages: [message],
100
+ });
101
+
102
+ expect(result.text).toBeDefined();
103
+ expect(result.text.length).toBeGreaterThan(0);
104
+ expect(responseRelatesToImage(result.text)).toBe(true);
105
+ }
106
+ );
107
+
108
+ it.skip(
109
+ "deve processar imagem com OpenRouter (openrouter/openai/gpt-4o)",
110
+ { timeout },
111
+ async () => {
112
+ if (!openRouterApiKey) {
113
+ console.log("OPENROUTER_API_KEY não está configurada");
114
+ return;
115
+ }
116
+ const ai = new AI({
117
+ openRouterApiKey: openRouterApiKey!,
118
+ });
119
+
120
+ const imageBuffer = fs.readFileSync(IMAGE_FIXTURE_PATH);
121
+ const message = AIMessages.humanImage({
122
+ image: {
123
+ buffer: imageBuffer,
124
+ filename: "imagem-teste-bateria.jpg",
125
+ },
126
+ text: PROMPT,
127
+ });
128
+
129
+ const result = await ai.call({
130
+ aiModel: "openrouter/openai/gpt-4o",
131
+ messages: [message],
132
+ });
133
+
134
+ expect(result.text).toBeDefined();
135
+ expect(result.text.length).toBeGreaterThan(0);
136
+ expect(result.text).not.toContain("Model call failed");
137
+ expect(responseRelatesToImage(result.text)).toBe(true);
138
+ }
139
+ );
140
+
141
+ it.skip(
142
+ "deve processar imagem com OpenRouter (openrouter/google/gemini-2.5-flash)",
143
+ { timeout },
144
+ async () => {
145
+ if (!openRouterApiKey) {
146
+ console.log("OPENROUTER_API_KEY não está configurada");
147
+ return;
148
+ }
149
+ const ai = new AI({
150
+ openRouterApiKey: openRouterApiKey!,
151
+ });
152
+
153
+ const imageBuffer = fs.readFileSync(IMAGE_FIXTURE_PATH);
154
+ const message = AIMessages.humanImage({
155
+ image: {
156
+ buffer: imageBuffer,
157
+ filename: "imagem-teste-bateria.jpg",
158
+ },
159
+ text: PROMPT,
160
+ });
161
+
162
+ const result = await ai.call({
163
+ aiModel: "openrouter/google/gemini-2.5-flash",
164
+ messages: [message],
165
+ });
166
+
167
+ expect(result.text).toBeDefined();
168
+ expect(result.text.length).toBeGreaterThan(0);
169
+ expect(responseRelatesToImage(result.text)).toBe(true);
170
+ }
171
+ );
172
+ });
173
+ });
@@ -45,7 +45,7 @@ describe("AI E2E Tests", () => {
45
45
  },
46
46
  );
47
47
 
48
- it(
48
+ it.skip(
49
49
  "deve fazer uma chamada real para Gemini e retornar resposta",
50
50
  { timeout },
51
51
  async () => {
@@ -139,7 +139,7 @@ describe("AI E2E Tests", () => {
139
139
  },
140
140
  );
141
141
 
142
- it(
142
+ it.skip(
143
143
  "deve criar múltiplas mensagens e fazer chamada",
144
144
  { timeout },
145
145
  async () => {
@@ -348,7 +348,7 @@ describe("AI E2E Tests", () => {
348
348
  },
349
349
  );
350
350
 
351
- it(
351
+ it.skip(
352
352
  "deve retornar resposta estruturada com Gemini",
353
353
  { timeout },
354
354
  async () => {
@@ -484,8 +484,9 @@ describe("AI E2E Tests", () => {
484
484
  AIMessages.human("Explique o que é TypeScript em uma frase curta."),
485
485
  ];
486
486
 
487
+ // gpt-4o suporta temperature; gpt-5-nano (reasoning) só aceita default
487
488
  const result = await ai.call({
488
- aiModel: "gpt-5-nano",
489
+ aiModel: "gpt-4o",
489
490
  messages,
490
491
  modelConfig: {
491
492
  maxTokens: 50,
@@ -499,6 +500,45 @@ describe("AI E2E Tests", () => {
499
500
  },
500
501
  );
501
502
 
503
+ // Níveis suportados pela API OpenAI (o1, gpt-5-nano, etc.): low, medium, high.
504
+ // Modelos mais recentes (gpt-5.2) podem suportar também: none, minimal, xhigh.
505
+ it.each([
506
+ { reasoningEffort: "low" as const },
507
+ { reasoningEffort: "medium" as const },
508
+ { reasoningEffort: "high" as const },
509
+ ])(
510
+ "deve usar reasoningEffort=$reasoningEffort em chamada real ao GPT",
511
+ { timeout },
512
+ async ({ reasoningEffort }) => {
513
+ if (!openAIApiKey) {
514
+ console.log("OPENAI_API_KEY não está configurada");
515
+ return;
516
+ }
517
+ const ai = new AI({
518
+ openAIApiKey: openAIApiKey!,
519
+ });
520
+
521
+ const messages = [
522
+ AIMessages.human(
523
+ "Responda apenas com a palavra 'OK' (modelo de reasoning).",
524
+ ),
525
+ ];
526
+
527
+ const result = await ai.call({
528
+ aiModel: "gpt-5-nano",
529
+ messages,
530
+ modelConfig: {
531
+ reasoningEffort,
532
+ },
533
+ });
534
+
535
+ expect(result.text).toBeDefined();
536
+ expect(result.text.length).toBeGreaterThan(0);
537
+ expect(result.messages).toBeDefined();
538
+ expect(result.messages.length).toBeGreaterThan(0);
539
+ },
540
+ );
541
+
502
542
  it(
503
543
  "deve usar maxTokens e temperature corretamente com OpenRouter",
504
544
  { timeout },
@@ -293,6 +293,101 @@ describe("AI", () => {
293
293
  });
294
294
  });
295
295
 
296
+ it("deve passar reasoningEffort no modelConfig", async () => {
297
+ const mockModel = {} as any;
298
+ const mockMessages = [AIMessages.human("Teste")];
299
+ const mockResponse = {
300
+ messages: [{ content: "Resposta" } as any],
301
+ };
302
+
303
+ vi.mocked(AIModels.gpt).mockReturnValue(mockModel);
304
+ vi.mocked(createAgent).mockReturnValue({
305
+ invoke: vi.fn().mockResolvedValue(mockResponse),
306
+ } as any);
307
+
308
+ await ai.call({
309
+ aiModel: "gpt-5-nano",
310
+ messages: mockMessages,
311
+ modelConfig: {
312
+ reasoningEffort: "medium",
313
+ },
314
+ });
315
+
316
+ expect(AIModels.gpt).toHaveBeenCalledWith({
317
+ model: "gpt-5-nano",
318
+ apiKey: mockTokens.openAIApiKey,
319
+ reasoningEffort: "medium",
320
+ });
321
+ });
322
+
323
+ it("deve exibir warning quando reasoningEffort é passado com modelo Gemini", async () => {
324
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
325
+ const mockModel = {} as any;
326
+ const mockMessages = [AIMessages.human("Teste")];
327
+ const mockResponse = {
328
+ messages: [{ content: "Resposta" } as any],
329
+ };
330
+
331
+ vi.mocked(AIModels.gemini).mockReturnValue(mockModel);
332
+ vi.mocked(createAgent).mockReturnValue({
333
+ invoke: vi.fn().mockResolvedValue(mockResponse),
334
+ } as any);
335
+
336
+ const aiWithGemini = new AI({
337
+ openAIApiKey: "test-key",
338
+ googleGeminiToken: "test-gemini-token",
339
+ });
340
+
341
+ await aiWithGemini.call({
342
+ aiModel: "gemini-2.5-flash",
343
+ messages: mockMessages,
344
+ modelConfig: {
345
+ reasoningEffort: "high",
346
+ },
347
+ });
348
+
349
+ expect(warnSpy).toHaveBeenCalledWith(
350
+ expect.stringContaining('O modelo "gemini-2.5-flash" não suporta reasoningEffort'),
351
+ );
352
+ expect(AIModels.gemini).toHaveBeenCalledWith(
353
+ expect.not.objectContaining({ reasoningEffort: expect.anything() }),
354
+ );
355
+ warnSpy.mockRestore();
356
+ });
357
+
358
+ it("deve exibir warning quando reasoningEffort é passado com OpenRouter não-OpenAI", async () => {
359
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
360
+ const mockModel = {} as any;
361
+ const mockMessages = [AIMessages.human("Teste")];
362
+ const mockResponse = {
363
+ messages: [{ content: "Resposta" } as any],
364
+ };
365
+
366
+ vi.mocked(AIModels.openrouter).mockReturnValue(mockModel);
367
+ vi.mocked(createAgent).mockReturnValue({
368
+ invoke: vi.fn().mockResolvedValue(mockResponse),
369
+ } as any);
370
+
371
+ const aiWithOpenRouter = new AI({
372
+ openRouterApiKey: "test-openrouter-key",
373
+ });
374
+
375
+ await aiWithOpenRouter.call({
376
+ aiModel: "openrouter/google/gemini-2.5-flash",
377
+ messages: mockMessages,
378
+ modelConfig: {
379
+ reasoningEffort: "high",
380
+ },
381
+ });
382
+
383
+ expect(warnSpy).toHaveBeenCalledWith(
384
+ expect.stringContaining(
385
+ 'O modelo "openrouter/google/gemini-2.5-flash" não suporta reasoningEffort',
386
+ ),
387
+ );
388
+ warnSpy.mockRestore();
389
+ });
390
+
296
391
  it("deve lançar erro quando memory está ativo e threadId não é fornecido", async () => {
297
392
  const aiWithMemory = new AI({
298
393
  openAIApiKey: "test-key",