@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.
- package/dist/@types/agent.d.ts +3 -0
- package/dist/@types/agent.d.ts.map +1 -0
- package/dist/@types/agent.js +3 -0
- package/dist/@types/agent.js.map +1 -0
- package/dist/@types/ai-call.d.ts +32 -0
- package/dist/@types/ai-call.d.ts.map +1 -0
- package/dist/@types/ai-call.js +3 -0
- package/dist/@types/ai-call.js.map +1 -0
- package/dist/@types/checkpointers.d.ts +106 -0
- package/dist/@types/checkpointers.d.ts.map +1 -0
- package/dist/@types/checkpointers.js +3 -0
- package/dist/@types/checkpointers.js.map +1 -0
- package/dist/ai.d.ts +49 -0
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +206 -0
- package/dist/ai.js.map +1 -0
- package/dist/index.d.ts +10 -52
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -135
- package/dist/index.js.map +1 -1
- package/dist/langchain/checkpointers.d.ts +86 -0
- package/dist/langchain/checkpointers.d.ts.map +1 -0
- package/dist/langchain/checkpointers.js +242 -0
- package/dist/langchain/checkpointers.js.map +1 -0
- package/package.json +9 -1
- package/src/@types/agent.ts +3 -0
- package/src/@types/ai-call.ts +38 -0
- package/src/@types/checkpointers.ts +117 -0
- package/src/ai.ts +306 -0
- package/src/index.ts +34 -214
- package/src/langchain/checkpointers.ts +331 -0
- package/tests/e2e/ai-retry-fallback.test.ts +213 -0
- package/tests/e2e/ai.test.ts +125 -38
- package/tests/unit/index.test.ts +310 -13
- package/tests/unit/langchain/checkpointers.test.ts +147 -0
- package/tests/unit/langchain/tools.test.ts +2 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseCheckpointSaver,
|
|
3
|
+
MemorySaver,
|
|
4
|
+
type StateSnapshot,
|
|
5
|
+
} from "@langchain/langgraph";
|
|
6
|
+
import type {
|
|
7
|
+
GetHistoryResult,
|
|
8
|
+
GraphWithStateHistory,
|
|
9
|
+
HistoryMessageItem,
|
|
10
|
+
MemoryConfig,
|
|
11
|
+
MessageRole,
|
|
12
|
+
} from "../@types/checkpointers";
|
|
13
|
+
|
|
14
|
+
export type { BaseCheckpointSaver };
|
|
15
|
+
export type {
|
|
16
|
+
MemoryConfig,
|
|
17
|
+
MemoryCheckpointerConfig,
|
|
18
|
+
SqliteCheckpointerConfig,
|
|
19
|
+
PostgresCheckpointerConfig,
|
|
20
|
+
RedisCheckpointerConfig,
|
|
21
|
+
MongoDBCheckpointerConfig,
|
|
22
|
+
GraphWithStateHistory,
|
|
23
|
+
MessageRole,
|
|
24
|
+
HistoryMessageItem,
|
|
25
|
+
GetHistoryResult,
|
|
26
|
+
} from "../@types/checkpointers";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Classe para gerenciar memória e checkpointers de conversas LangGraph.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* // Memória (desenvolvimento)
|
|
33
|
+
* const memory = new AIMemory({ type: "memory" });
|
|
34
|
+
* const checkpointer = await memory.getCheckpointer();
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* // SQLite
|
|
38
|
+
* const memory = new AIMemory({ type: "sqlite", connectionString: "./data.db" });
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* // Acessar histórico de conversa
|
|
42
|
+
* const { agent } = await ai.getRawAgent({ ... });
|
|
43
|
+
* const history = await memory.getHistory("thread-1", agent);
|
|
44
|
+
* const messages = history[0]?.values?.messages ?? [];
|
|
45
|
+
*/
|
|
46
|
+
export class AIMemory {
|
|
47
|
+
private checkpointer: BaseCheckpointSaver | undefined;
|
|
48
|
+
private checkpointerPromise: Promise<BaseCheckpointSaver> | undefined;
|
|
49
|
+
private agent: GraphWithStateHistory | undefined;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private config: MemoryConfig,
|
|
53
|
+
agent?: GraphWithStateHistory
|
|
54
|
+
) {
|
|
55
|
+
this.agent = agent;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Define o agent/grafo para uso em getHistory quando graph não é passado.
|
|
60
|
+
* Chamado automaticamente por AI.getRawAgent() quando a instância está vinculada ao AI.
|
|
61
|
+
*/
|
|
62
|
+
setAgent(agent: GraphWithStateHistory): void {
|
|
63
|
+
this.agent = agent;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Cria ou retorna a instância de checkpointer baseada na configuração.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* const cp = await memory.getCheckpointer();
|
|
71
|
+
* const graph = workflow.compile({ checkpointer: cp });
|
|
72
|
+
*/
|
|
73
|
+
async getCheckpointer(): Promise<BaseCheckpointSaver> {
|
|
74
|
+
if (this.checkpointer) return this.checkpointer;
|
|
75
|
+
if (!this.checkpointerPromise) {
|
|
76
|
+
this.checkpointerPromise = this.createCheckpointer();
|
|
77
|
+
}
|
|
78
|
+
this.checkpointer = await this.checkpointerPromise;
|
|
79
|
+
return this.checkpointer;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Retorna o histórico de checkpoints e lista de mensagens de uma thread.
|
|
84
|
+
*
|
|
85
|
+
* @param threadId - ID da thread/conversa
|
|
86
|
+
* @param graph - Opcional. Grafo compilado com checkpointer. Se não passado, usa o agent
|
|
87
|
+
* definido no construtor ou via setAgent (ex: agent de ai.getRawAgent).
|
|
88
|
+
* @returns Objeto com fullHistory (checkpoints) e messages (lista com role, createdAt, content)
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* // Com ai.memory (agent definido automaticamente em call/getRawAgent)
|
|
92
|
+
* await ai.call({ messages: [...], threadId: "1" });
|
|
93
|
+
* const { fullHistory, messages } = await ai.memory.getHistory("1");
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* // Passando graph explicitamente
|
|
97
|
+
* const { fullHistory, messages } = await memory.getHistory("1", agent);
|
|
98
|
+
*
|
|
99
|
+
* @see https://docs.langchain.com/oss/javascript/langgraph/persistence#get-state-history
|
|
100
|
+
* @see https://docs.langchain.com/oss/javascript/langgraph/add-memory#manage-checkpoints
|
|
101
|
+
*/
|
|
102
|
+
async getHistory(
|
|
103
|
+
threadId: string,
|
|
104
|
+
graph?: GraphWithStateHistory
|
|
105
|
+
): Promise<GetHistoryResult> {
|
|
106
|
+
const graphToUse = graph ?? this.agent;
|
|
107
|
+
if (!graphToUse) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
"É necessário passar graph em getHistory ou definir o agent no construtor/setAgent do AIMemory."
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const config = { configurable: { thread_id: threadId } };
|
|
113
|
+
const fullHistory: StateSnapshot[] = [];
|
|
114
|
+
for await (const snapshot of graphToUse.getStateHistory(config)) {
|
|
115
|
+
fullHistory.push(snapshot);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const messages = this.extractMessagesFromHistory(fullHistory);
|
|
119
|
+
return { fullHistory, messages };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Extrai lista de mensagens com role, createdAt e content a partir do histórico.
|
|
124
|
+
* Percorre os checkpoints do mais antigo ao mais recente para atribuir createdAt correto.
|
|
125
|
+
*/
|
|
126
|
+
private extractMessagesFromHistory(
|
|
127
|
+
history: StateSnapshot[]
|
|
128
|
+
): HistoryMessageItem[] {
|
|
129
|
+
const result: HistoryMessageItem[] = [];
|
|
130
|
+
let previousMessageCount = 0;
|
|
131
|
+
|
|
132
|
+
// history[0] = mais recente, history[length-1] = mais antigo
|
|
133
|
+
// Cada snapshot acumula mensagens; só adicionamos as novas
|
|
134
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
135
|
+
const snapshot = history[i]!;
|
|
136
|
+
const snapshotMessages = (snapshot.values?.messages ?? []) as Array<{
|
|
137
|
+
id?: string;
|
|
138
|
+
getType?: () => string;
|
|
139
|
+
_getType?: () => string;
|
|
140
|
+
content?: string | Array<unknown>;
|
|
141
|
+
constructor?: { name?: string };
|
|
142
|
+
role?: string;
|
|
143
|
+
}>;
|
|
144
|
+
const createdAt =
|
|
145
|
+
(snapshot as { createdAt?: string }).createdAt ??
|
|
146
|
+
new Date().toISOString();
|
|
147
|
+
|
|
148
|
+
for (let j = previousMessageCount; j < snapshotMessages.length; j++) {
|
|
149
|
+
const msg = snapshotMessages[j];
|
|
150
|
+
if (!msg) continue;
|
|
151
|
+
const role = this.getMessageRole(msg);
|
|
152
|
+
if (role === "system") continue;
|
|
153
|
+
|
|
154
|
+
result.push({
|
|
155
|
+
role: role as MessageRole,
|
|
156
|
+
createdAt,
|
|
157
|
+
content: this.extractContent(msg),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
previousMessageCount = snapshotMessages.length;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private getMessageRole(msg: {
|
|
167
|
+
getType?: () => string;
|
|
168
|
+
_getType?: () => string;
|
|
169
|
+
constructor?: { name?: string };
|
|
170
|
+
role?: string;
|
|
171
|
+
}): "human" | "ai" | "tool" | "system" {
|
|
172
|
+
const role = (msg as { role?: string }).role?.toLowerCase();
|
|
173
|
+
if (role === "human" || role === "user") return "human";
|
|
174
|
+
if (role === "ai" || role === "assistant") return "ai";
|
|
175
|
+
if (role === "tool") return "tool";
|
|
176
|
+
|
|
177
|
+
const type =
|
|
178
|
+
msg.getType?.() ??
|
|
179
|
+
msg._getType?.() ??
|
|
180
|
+
msg.constructor?.name?.toLowerCase() ?? "";
|
|
181
|
+
const normalized = type.toLowerCase().replace("message", "").trim();
|
|
182
|
+
if (normalized === "human" || normalized === "user") return "human";
|
|
183
|
+
if (normalized === "ai" || normalized === "assistant") return "ai";
|
|
184
|
+
if (normalized === "tool") return "tool";
|
|
185
|
+
return "system";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private extractContent(msg: {
|
|
189
|
+
content?: string | Array<unknown>;
|
|
190
|
+
}): string {
|
|
191
|
+
const c = msg.content;
|
|
192
|
+
if (typeof c === "string") return c;
|
|
193
|
+
if (Array.isArray(c)) {
|
|
194
|
+
return c
|
|
195
|
+
.map((block: unknown) =>
|
|
196
|
+
typeof block === "string" ? block : (block as { text?: string })?.text ?? ""
|
|
197
|
+
)
|
|
198
|
+
.filter(Boolean)
|
|
199
|
+
.join("\n");
|
|
200
|
+
}
|
|
201
|
+
return "";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Retorna o estado atual (último checkpoint) de uma thread.
|
|
206
|
+
*
|
|
207
|
+
* @param threadId - ID da thread/conversa
|
|
208
|
+
* @param graph - Grafo compilado com checkpointer
|
|
209
|
+
* @param checkpointId - Opcional: ID de um checkpoint específico
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* const state = await memory.getState("1", agent);
|
|
213
|
+
* const messages = state?.values?.messages ?? [];
|
|
214
|
+
*
|
|
215
|
+
* @see https://docs.langchain.com/oss/javascript/langgraph/persistence#get-state
|
|
216
|
+
*/
|
|
217
|
+
async getState(
|
|
218
|
+
threadId: string,
|
|
219
|
+
graph?: GraphWithStateHistory,
|
|
220
|
+
checkpointId?: string
|
|
221
|
+
): Promise<StateSnapshot | null> {
|
|
222
|
+
const graphToUse = graph ?? this.agent;
|
|
223
|
+
if (!graphToUse) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
"É necessário passar graph em getState ou definir o agent no construtor/setAgent do AIMemory."
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (!graphToUse.getState) {
|
|
229
|
+
const { fullHistory } = await this.getHistory(threadId, graphToUse);
|
|
230
|
+
return fullHistory[0] ?? null;
|
|
231
|
+
}
|
|
232
|
+
const config = {
|
|
233
|
+
configurable: { thread_id: threadId, ...(checkpointId && { checkpoint_id: checkpointId }) },
|
|
234
|
+
};
|
|
235
|
+
return (await graphToUse.getState(config)) ?? null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async createCheckpointer(): Promise<BaseCheckpointSaver> {
|
|
239
|
+
if (this.checkpointer) return this.checkpointer;
|
|
240
|
+
|
|
241
|
+
switch (this.config.type) {
|
|
242
|
+
case "memory": {
|
|
243
|
+
return new MemorySaver();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case "sqlite": {
|
|
247
|
+
try {
|
|
248
|
+
const { SqliteSaver } = await import(
|
|
249
|
+
"@langchain/langgraph-checkpoint-sqlite"
|
|
250
|
+
);
|
|
251
|
+
return SqliteSaver.fromConnString(
|
|
252
|
+
this.config.connectionString
|
|
253
|
+
) as unknown as BaseCheckpointSaver;
|
|
254
|
+
} catch (err) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
'Checkpointer SQLite requer o pacote "@langchain/langgraph-checkpoint-sqlite". ' +
|
|
257
|
+
"Instale com: pnpm add @langchain/langgraph-checkpoint-sqlite",
|
|
258
|
+
{ cause: err }
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
case "postgres": {
|
|
264
|
+
try {
|
|
265
|
+
const { PostgresSaver } = await import(
|
|
266
|
+
"@langchain/langgraph-checkpoint-postgres"
|
|
267
|
+
);
|
|
268
|
+
const checkpointer = PostgresSaver.fromConnString(
|
|
269
|
+
this.config.connectionString
|
|
270
|
+
);
|
|
271
|
+
await checkpointer.setup();
|
|
272
|
+
return checkpointer as unknown as BaseCheckpointSaver;
|
|
273
|
+
} catch (err) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
'Checkpointer Postgres requer o pacote "@langchain/langgraph-checkpoint-postgres". ' +
|
|
276
|
+
"Instale com: pnpm add @langchain/langgraph-checkpoint-postgres",
|
|
277
|
+
{ cause: err }
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
case "redis": {
|
|
283
|
+
try {
|
|
284
|
+
const { RedisSaver } = await import(
|
|
285
|
+
"@langchain/langgraph-checkpoint-redis"
|
|
286
|
+
);
|
|
287
|
+
return (await RedisSaver.fromUrl(
|
|
288
|
+
this.config.url,
|
|
289
|
+
this.config.options ?? {}
|
|
290
|
+
)) as unknown as BaseCheckpointSaver;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
'Checkpointer Redis requer o pacote "@langchain/langgraph-checkpoint-redis". ' +
|
|
294
|
+
"Instale com: pnpm add @langchain/langgraph-checkpoint-redis",
|
|
295
|
+
{ cause: err }
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
case "mongodb": {
|
|
301
|
+
try {
|
|
302
|
+
const { MongoDBSaver } = await import(
|
|
303
|
+
"@langchain/langgraph-checkpoint-mongodb"
|
|
304
|
+
);
|
|
305
|
+
if ("client" in this.config) {
|
|
306
|
+
return new MongoDBSaver({
|
|
307
|
+
client: this.config.client as any,
|
|
308
|
+
}) as unknown as BaseCheckpointSaver;
|
|
309
|
+
}
|
|
310
|
+
const { MongoClient } = await import("mongodb");
|
|
311
|
+
const client = new MongoClient(this.config.url) as any;
|
|
312
|
+
return new MongoDBSaver({ client }) as unknown as BaseCheckpointSaver;
|
|
313
|
+
} catch (err) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
'Checkpointer MongoDB requer os pacotes "@langchain/langgraph-checkpoint-mongodb" e "mongodb". ' +
|
|
316
|
+
"Instale com: pnpm add @langchain/langgraph-checkpoint-mongodb mongodb",
|
|
317
|
+
{ cause: err }
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
default: {
|
|
323
|
+
const _exhaustive: never = this.config;
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Tipo de checkpointer não suportado: ${(_exhaustive as any)?.type}`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { AI } from "../../src/index";
|
|
2
|
+
import { AIMessages } from "../../src/langchain/messages";
|
|
3
|
+
import z from "zod";
|
|
4
|
+
import "dotenv/config";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Flag controlada por cada teste. Quando true, a PRÓXIMA chamada a createAgent
|
|
8
|
+
* retornará um agente mock que falha no invoke. As chamadas seguintes usam o real.
|
|
9
|
+
*/
|
|
10
|
+
let simulateNextCreateAgentFailure = false;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Quantidade de chamadas a createAgent que devem falhar. Quando > 0, retorna mock que falha.
|
|
14
|
+
* Usado no teste "todos os modelos falharem" para evitar chamadas reais (timeout).
|
|
15
|
+
*/
|
|
16
|
+
let simulateFailureCount = 0;
|
|
17
|
+
|
|
18
|
+
vi.mock("langchain", async (importOriginal) => {
|
|
19
|
+
const actual = await importOriginal<typeof import("langchain")>();
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
createAgent: vi.fn((config: any) => {
|
|
23
|
+
if (simulateFailureCount > 0) {
|
|
24
|
+
simulateFailureCount--;
|
|
25
|
+
return {
|
|
26
|
+
invoke: vi.fn().mockRejectedValue(new Error("Simulated failure")),
|
|
27
|
+
} as any;
|
|
28
|
+
}
|
|
29
|
+
if (simulateNextCreateAgentFailure) {
|
|
30
|
+
simulateNextCreateAgentFailure = false;
|
|
31
|
+
return {
|
|
32
|
+
invoke: vi.fn().mockRejectedValue(new Error("Simulated failure")),
|
|
33
|
+
} as any;
|
|
34
|
+
}
|
|
35
|
+
return actual.createAgent(config);
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("AI E2E - Retry e Fallback (mock seletivo + real)", () => {
|
|
41
|
+
const openAIApiKey = process.env.OPENAI_API_KEY;
|
|
42
|
+
const openRouterApiKey = process.env.OPENROUTER_API_KEY;
|
|
43
|
+
const timeout = 180 * 1000;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
simulateNextCreateAgentFailure = false;
|
|
47
|
+
simulateFailureCount = 0;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it(
|
|
51
|
+
"deve fazer fallback quando primeiro modelo falhar (mock) e segundo usar real GPT",
|
|
52
|
+
{ timeout },
|
|
53
|
+
async () => {
|
|
54
|
+
if (!openAIApiKey) {
|
|
55
|
+
console.log("OPENAI_API_KEY não está configurada");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
simulateNextCreateAgentFailure = true;
|
|
59
|
+
|
|
60
|
+
const ai = new AI({ openAIApiKey: openAIApiKey });
|
|
61
|
+
|
|
62
|
+
const result = await ai.call({
|
|
63
|
+
aiModel: "gpt-5-nano",
|
|
64
|
+
messages: [AIMessages.human("Responda apenas com 'Fallback mock+real ok'")],
|
|
65
|
+
aiModelsFallback: ["gpt-5-nano"],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(result.text).toBeDefined();
|
|
69
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
70
|
+
expect(result.text.toLowerCase()).toContain("fallback");
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
it(
|
|
75
|
+
"deve fazer fallback quando primeiro modelo falhar (mock) e segundo usar real OpenRouter",
|
|
76
|
+
{ timeout },
|
|
77
|
+
async () => {
|
|
78
|
+
if (!openRouterApiKey) {
|
|
79
|
+
console.log("OPENROUTER_API_KEY não está configurada");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
simulateNextCreateAgentFailure = true;
|
|
83
|
+
|
|
84
|
+
const ai = new AI({ openRouterApiKey: openRouterApiKey! });
|
|
85
|
+
|
|
86
|
+
const result = await ai.call({
|
|
87
|
+
aiModel: "openrouter/openai/gpt-5-nano",
|
|
88
|
+
messages: [AIMessages.human("Responda apenas com 'Fallback OpenRouter ok'")],
|
|
89
|
+
aiModelsFallback: ["openrouter/openai/gpt-5-nano"],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(result.text).toBeDefined();
|
|
93
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
94
|
+
expect(result.text.toLowerCase()).toContain("fallback");
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
it(
|
|
99
|
+
"deve fazer fallback entre provedores: GPT (mock fail) -> OpenRouter (real)",
|
|
100
|
+
{ timeout },
|
|
101
|
+
async () => {
|
|
102
|
+
if (!openAIApiKey || !openRouterApiKey) {
|
|
103
|
+
console.log("OPENAI_API_KEY e OPENROUTER_API_KEY são necessárias");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
simulateNextCreateAgentFailure = true;
|
|
107
|
+
|
|
108
|
+
const ai = new AI({
|
|
109
|
+
openAIApiKey: openAIApiKey!,
|
|
110
|
+
openRouterApiKey: openRouterApiKey!,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const result = await ai.call({
|
|
114
|
+
aiModel: "gpt-5-nano",
|
|
115
|
+
messages: [AIMessages.human("Responda apenas com 'Fallback cross-provider ok'")],
|
|
116
|
+
aiModelsFallback: ["openrouter/openai/gpt-5-nano"],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(result.text).toBeDefined();
|
|
120
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
121
|
+
expect(result.text.toLowerCase()).toContain("fallback");
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
it(
|
|
126
|
+
"deve fazer fallback no callStructuredOutput: primeiro modelo falha (mock), segundo real",
|
|
127
|
+
{ timeout },
|
|
128
|
+
async () => {
|
|
129
|
+
if (!openAIApiKey) {
|
|
130
|
+
console.log("OPENAI_API_KEY não está configurada");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
simulateNextCreateAgentFailure = true;
|
|
134
|
+
|
|
135
|
+
const ai = new AI({ openAIApiKey: openAIApiKey! });
|
|
136
|
+
|
|
137
|
+
const outputSchema = z.object({ answer: z.string() });
|
|
138
|
+
|
|
139
|
+
const result = await ai.callStructuredOutput({
|
|
140
|
+
aiModel: "gpt-5-nano",
|
|
141
|
+
messages: [
|
|
142
|
+
AIMessages.human('Responda com JSON: { "answer": "fallback structured ok" }'),
|
|
143
|
+
],
|
|
144
|
+
outputSchema,
|
|
145
|
+
aiModelsFallback: ["gpt-5-nano"],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(result.response).toBeDefined();
|
|
149
|
+
expect(result.response.answer.toLowerCase()).toContain("fallback");
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
it(
|
|
154
|
+
"deve fazer fallback para o mesmo modelo quando primeira tentativa falhar (mock) e segunda usar real",
|
|
155
|
+
{ timeout },
|
|
156
|
+
async () => {
|
|
157
|
+
if (!openAIApiKey) {
|
|
158
|
+
console.log("OPENAI_API_KEY não está configurada");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
simulateNextCreateAgentFailure = true;
|
|
162
|
+
|
|
163
|
+
const ai = new AI({ openAIApiKey: openAIApiKey! });
|
|
164
|
+
|
|
165
|
+
const result = await ai.call({
|
|
166
|
+
aiModel: "gpt-5-nano",
|
|
167
|
+
messages: [AIMessages.human("Diga: retry ok")],
|
|
168
|
+
maxRetries: 2,
|
|
169
|
+
aiModelsFallback: ["gpt-5-nano"],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(result.text).toBeDefined();
|
|
173
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
it(
|
|
178
|
+
"deve respeitar maxRetries e completar chamada com sucesso",
|
|
179
|
+
{ timeout },
|
|
180
|
+
async () => {
|
|
181
|
+
if (!openAIApiKey) {
|
|
182
|
+
console.log("OPENAI_API_KEY não está configurada");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const ai = new AI({ openAIApiKey: openAIApiKey! });
|
|
186
|
+
|
|
187
|
+
const result = await ai.call({
|
|
188
|
+
aiModel: "gpt-5-nano",
|
|
189
|
+
messages: [AIMessages.human("Diga apenas: retry config ok")],
|
|
190
|
+
maxRetries: 2,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(result.text).toBeDefined();
|
|
194
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
it("deve lançar exceção quando todos os modelos falharem", async () => {
|
|
199
|
+
simulateFailureCount = 2;
|
|
200
|
+
|
|
201
|
+
const ai = new AI({
|
|
202
|
+
openAIApiKey: "test-key",
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await expect(
|
|
206
|
+
ai.call({
|
|
207
|
+
aiModel: "gpt-4",
|
|
208
|
+
messages: [AIMessages.human("teste")],
|
|
209
|
+
aiModelsFallback: ["gpt-5-nano"],
|
|
210
|
+
}),
|
|
211
|
+
).rejects.toThrow("Simulated failure");
|
|
212
|
+
});
|
|
213
|
+
});
|