diff --git a/src/agent.ts b/src/agent.ts index 5e075b38..654abc19 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -1,5 +1,4 @@ import { Agent } from "agents"; -import { streamText } from "ai"; import type { SystemModelMessage } from "ai"; import type { Env } from "./env"; import { Db } from "./db/client"; @@ -16,6 +15,8 @@ import type { Tier } from "./upgrade/modelSelector"; import { monthIaCostUsd, applyBudgetGuard } from "./budget"; import { CustomerFactsRepo } from "./db/facts"; import { createModel } from "./llm/provider"; +import { formatLlmError } from "./llm/errorDetail"; +import { runLlmTurn } from "./llm/runTurn"; import { costOfUsage } from "./pricing"; import type { ChannelId } from "./channels/shared"; import { maskTelegramToken, unmaskTelegramToken } from "./telegramFiles"; @@ -350,7 +351,7 @@ export class SupportAgent extends Agent { // Corre el loop del LLM con un modelo dado; deja los resultados en las vars. const attempt = async (m: any) => { - const result = streamText({ + const turn = await runLlmTurn({ model: m, system, messages: aiMessages, @@ -358,25 +359,14 @@ export class SupportAgent extends Agent { stopWhen: ({ steps }) => steps.length >= 6, ...(cfg.temperature !== undefined ? { temperature: cfg.temperature } : {}), }); - let text = ""; - for await (const chunk of result.textStream) { - text += chunk; - } - assistantText = text; - const usage = await result.usage; - inputTokens = usage?.inputTokens ?? 0; - outputTokens = usage?.outputTokens ?? 0; - cachedTokens = usage?.cachedInputTokens ?? 0; - const steps = await result.steps; - toolCallCount = steps.reduce((n, s) => n + (s.toolCalls?.length ?? 0), 0); + assistantText = turn.text; + inputTokens = turn.inputTokens; + outputTokens = turn.outputTokens; + cachedTokens = turn.cachedTokens; + toolCallCount = turn.toolCallCount; // Persist what the agent DID (not just what it said): tool name + input, // feeding the dashboard's thread chips, stats and the Mi Agente counters. - toolCallsMade = steps.flatMap((s) => - (s.toolCalls ?? []).map((tc: any) => ({ - toolName: tc.toolName as string, - input: tc.input, - })), - ); + toolCallsMade = turn.toolCallsMade; }; try { @@ -387,7 +377,7 @@ export class SupportAgent extends Agent { // mayoría; si no, se prueba el proveedor alterno (también con un segundo // intento). El jitter des-sincroniza mensajes que llegaron en el mismo // segundo. El bot no puede quedarse mudo el día del evento. - console.error("[SupportAgent.processBuffer] streamText failed:", e); + console.error("[SupportAgent.processBuffer] streamText failed:", formatLlmError(e)); const backoff = (ms: number) => new Promise((r) => setTimeout(r, ms)); const { fallbackModel } = await import("./llm/provider"); const primary = createModel(this.env, tier, cfg.llm); @@ -399,7 +389,7 @@ export class SupportAgent extends Agent { await attempt(model); ok = true; } catch (e1: any) { - console.error("[SupportAgent.processBuffer] primary retry failed:", e1); + console.error("[SupportAgent.processBuffer] primary retry failed:", formatLlmError(e1)); } if (!ok && fb) { @@ -411,14 +401,14 @@ export class SupportAgent extends Agent { usedModelId = fb.modelId; ok = true; } catch (e2: any) { - console.error("[SupportAgent.processBuffer] fallback failed:", e2); + console.error("[SupportAgent.processBuffer] fallback failed:", formatLlmError(e2)); await backoff(2500 + Math.floor(Math.random() * 1500)); try { await attempt(fb.model); usedModelId = fb.modelId; ok = true; } catch (e3: any) { - console.error("[SupportAgent.processBuffer] fallback retry failed:", e3); + console.error("[SupportAgent.processBuffer] fallback retry failed:", formatLlmError(e3)); } } } diff --git a/src/llm/errorDetail.ts b/src/llm/errorDetail.ts new file mode 100644 index 00000000..0021563c --- /dev/null +++ b/src/llm/errorDetail.ts @@ -0,0 +1,55 @@ +/** + * Extrae lo útil de un error del AI SDK (APICallError / NoOutputGeneratedError) + * para los logs del Worker. El `message` solo suele decir "Bad Request"; el + * body de OpenAI (schema inválido, etc.) vive en responseBody / cause. + */ +export function formatLlmError(e: unknown): string { + if (e == null) return String(e); + const err = e as Record; + const cause = + err.cause && typeof err.cause === "object" + ? (err.cause as Record) + : undefined; + const parts: string[] = []; + const msg = typeof err.message === "string" ? err.message : String(e); + parts.push(msg); + const status = err.statusCode ?? cause?.statusCode; + if (status != null) parts.push(`status=${status}`); + const url = err.url ?? cause?.url; + if (typeof url === "string" && url) parts.push(`url=${url}`); + const body = err.responseBody ?? err.data ?? cause?.responseBody ?? cause?.data; + if (body != null) { + const s = typeof body === "string" ? body : safeJson(body); + if (s) parts.push(`body=${s.slice(0, 800)}`); + } + if (cause?.message && cause.message !== msg) { + parts.push(`cause=${cause.message}`); + } + return parts.join(" | "); +} + +function safeJson(v: unknown): string { + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +/** + * 400 / "No output generated" — vale la pena reintentar el mismo modelo + * sin SSE (generateText). Rate-limits y 5xx se dejan al failover de proveedor. + */ +export function isLikelyRequestOrStreamFailure(e: unknown): boolean { + const err = e as Record | undefined; + if (!err) return false; + const cause = + err.cause && typeof err.cause === "object" + ? (err.cause as Record) + : undefined; + const status = err.statusCode ?? cause?.statusCode; + if (status === 400) return true; + const name = `${err.name ?? ""} ${cause?.name ?? ""}`; + const msg = `${err.message ?? ""} ${cause?.message ?? ""}`; + return /Bad Request|No output generated/i.test(`${name} ${msg}`); +} diff --git a/src/llm/provider.ts b/src/llm/provider.ts index 16370860..3ae67cb3 100644 --- a/src/llm/provider.ts +++ b/src/llm/provider.ts @@ -139,7 +139,19 @@ export function createModel(env: Env, tier: Tier, ov?: LlmOverrides): ResolvedMo if (provider === "openai") { const openai = createOpenAI({ apiKey }); - return { provider, modelId, model: openai(modelId), supportsPromptCache: false }; + // Chat Completions (`openai.chat`), NO la Responses API que es el default + // de `openai(modelId)` desde AI SDK 5. Responses trata las function tools + // como JSON Schema strict si `strict` se omite; nuestras tools (y las de + // giros Forja+ como coach: agendarCita/cancelarCita) usan muchos + // `.optional()` / `.default()` y OpenAI responde 400 Bad Request → el bot + // contesta "Algo falló de mi lado...". El mismo payload por Chat Completions + // (lo que se prueba con curl) acepta parámetros opcionales. + // `.chat` existe en @ai-sdk/openai v3 y v4; el fallback cubre mocks viejos. + const model = + typeof (openai as any).chat === "function" + ? (openai as any).chat(modelId) + : openai(modelId); + return { provider, modelId, model, supportsPromptCache: false }; } if (provider === "xai") { diff --git a/src/llm/runTurn.ts b/src/llm/runTurn.ts new file mode 100644 index 00000000..78933644 --- /dev/null +++ b/src/llm/runTurn.ts @@ -0,0 +1,87 @@ +import { generateText, streamText } from "ai"; +import { formatLlmError, isLikelyRequestOrStreamFailure } from "./errorDetail"; + +export interface LlmTurnArgs { + model: any; + system: any; + messages: any[]; + tools: Record; + stopWhen: (args: { steps: any[] }) => boolean; + temperature?: number; +} + +export interface LlmTurnResult { + text: string; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + toolCallCount: number; + toolCallsMade: { toolName: string; input: unknown }[]; +} + +function callArgs(args: LlmTurnArgs) { + return { + model: args.model, + system: args.system, + messages: args.messages, + tools: args.tools, + stopWhen: args.stopWhen, + ...(args.temperature !== undefined ? { temperature: args.temperature } : {}), + }; +} + +function fromUsage(usage: any) { + return { + inputTokens: usage?.inputTokens ?? 0, + outputTokens: usage?.outputTokens ?? 0, + cachedTokens: usage?.cachedInputTokens ?? 0, + }; +} + +function fromSteps(steps: any[] | undefined) { + const list = steps ?? []; + return { + toolCallCount: list.reduce((n, s) => n + (s.toolCalls?.length ?? 0), 0), + toolCallsMade: list.flatMap((s) => + (s.toolCalls ?? []).map((tc: any) => ({ + toolName: tc.toolName as string, + input: tc.input, + })), + ), + }; +} + +async function streamTurn(args: LlmTurnArgs): Promise { + const result = streamText(callArgs(args)); + let text = ""; + for await (const chunk of result.textStream) { + text += chunk; + } + const usage = await result.usage; + const steps = await result.steps; + return { text, ...fromUsage(usage), ...fromSteps(steps) }; +} + +async function generateTurn(args: LlmTurnArgs): Promise { + const result = await generateText(callArgs(args)); + return { + text: result.text ?? "", + ...fromUsage(result.usage), + ...fromSteps(result.steps), + }; +} + +/** + * streamText primero (todas las providers). Si OpenAI/Workers devuelve 400 + * o un stream vacío, un generateText no-SSE suele pasar — Telegram igual + * espera el texto completo antes de mandar chunks. + */ +export async function runLlmTurn(args: LlmTurnArgs): Promise { + try { + return await streamTurn(args); + } catch (e) { + if (!isLikelyRequestOrStreamFailure(e)) throw e; + console.warn("[runLlmTurn] streamText failed; retrying without SSE:", formatLlmError(e)); + return await generateTurn(args); + } +} diff --git a/test/agent.media.test.ts b/test/agent.media.test.ts index a8790834..c20cfebb 100644 --- a/test/agent.media.test.ts +++ b/test/agent.media.test.ts @@ -49,9 +49,11 @@ function stubSettings(overrides: Record = {}) { // import("./media/transcribe") inside ingest() resolves to the real module. const streamTextMock = vi.fn(); +const generateTextMock = vi.fn(); vi.mock("ai", () => ({ streamText: (...args: any[]) => streamTextMock(...args), + generateText: (...args: any[]) => generateTextMock(...args), tool: (def: any) => def, })); @@ -219,6 +221,7 @@ describe("SupportAgent.alarm — multimodal last message (Task 6.3)", () => { // Fresh stream result per call (the async generator is one-shot). streamTextMock.mockReset(); streamTextMock.mockImplementation(() => makeStreamResult("ok")); + generateTextMock.mockReset(); vi.spyOn(MessagesRepo.prototype, "append").mockResolvedValue( undefined as any, @@ -326,6 +329,44 @@ describe("SupportAgent.alarm — multimodal last message (Task 6.3)", () => { const arg = streamTextMock.mock.calls[0][0]; expect(arg.model).toEqual({ modelId: "claude-sonnet-4-5-20250929" }); }); + + it("si streamText tira 400, manda la respuesta de generateText (no el error genérico)", async () => { + const { agent } = makeAgent({ tier: "free" }); + const sendReply = vi.fn(async () => {}); + + streamTextMock.mockReset(); + streamTextMock.mockImplementation(() => { + throw Object.assign(new Error("No output generated. Check the stream for errors."), { + name: "AI_NoOutputGeneratedError", + cause: Object.assign(new Error("Bad Request"), { + name: "AI_APICallError", + statusCode: 400, + }), + }); + }); + generateTextMock.mockReset(); + generateTextMock.mockResolvedValue({ + text: "Hola, ¿en qué te ayudo?", + usage: { inputTokens: 12, outputTokens: 6, cachedInputTokens: 0 }, + steps: [], + }); + + vi.spyOn(MessagesRepo.prototype, "append").mockResolvedValue(undefined as any); + vi.spyOn(MessagesRepo.prototype, "lastN").mockResolvedValue([ + { role: "user", content: "hola" }, + ] as any); + vi.spyOn(ConversationsRepo.prototype, "touchLastMessage").mockResolvedValue(undefined as any); + vi.spyOn(senderMod, "pickAdapter").mockReturnValue({ sendReply } as any); + + agent.state.pendingMessages = [{ text: "hola", receivedAt: Date.now() }]; + await agent.processBuffer(); + + expect(generateTextMock).toHaveBeenCalledTimes(1); + expect(sendReply).toHaveBeenCalled(); + const sent = sendReply.mock.calls.at(0)?.at(0) as { chunks: string[] } | undefined; + expect(sent?.chunks.join("")).toContain("Hola, ¿en qué te ayudo?"); + expect(sent?.chunks.join("")).not.toMatch(/Algo falló de mi lado/); + }); }); describe("SupportAgent.ingest — bot_paused (settings)", () => { diff --git a/test/llm/errorDetail.test.ts b/test/llm/errorDetail.test.ts new file mode 100644 index 00000000..9232d3cd --- /dev/null +++ b/test/llm/errorDetail.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { formatLlmError, isLikelyRequestOrStreamFailure } from "../../src/llm/errorDetail"; + +describe("formatLlmError", () => { + it("incluye status, url y body (lo que wrangler tail suele omitir)", () => { + const e = Object.assign(new Error("Bad Request"), { + name: "AI_APICallError", + statusCode: 400, + url: "https://api.openai.com/v1/responses", + responseBody: '{"error":{"message":"Invalid schema for function \\"agendarCita\\""}}', + }); + const s = formatLlmError(e); + expect(s).toContain("Bad Request"); + expect(s).toContain("status=400"); + expect(s).toContain("https://api.openai.com/v1/responses"); + expect(s).toContain("Invalid schema for function"); + }); + + it("baja al cause cuando el stream envuelve el 400", () => { + const cause = Object.assign(new Error("Bad Request"), { + name: "AI_APICallError", + statusCode: 400, + responseBody: '{"error":{"message":"strict schema"}}', + }); + const e = Object.assign(new Error("No output generated. Check the stream for errors."), { + name: "AI_NoOutputGeneratedError", + cause, + }); + const s = formatLlmError(e); + expect(s).toContain("No output generated"); + expect(s).toContain("status=400"); + expect(s).toContain("strict schema"); + expect(s).toContain("cause=Bad Request"); + }); +}); + +describe("isLikelyRequestOrStreamFailure", () => { + it("true para 400 y para NoOutputGeneratedError", () => { + expect( + isLikelyRequestOrStreamFailure( + Object.assign(new Error("Bad Request"), { statusCode: 400 }), + ), + ).toBe(true); + expect( + isLikelyRequestOrStreamFailure( + Object.assign(new Error("No output generated. Check the stream for errors."), { + name: "AI_NoOutputGeneratedError", + }), + ), + ).toBe(true); + }); + + it("false para rate-limit / 5xx (van al failover de proveedor)", () => { + expect( + isLikelyRequestOrStreamFailure( + Object.assign(new Error("Too Many Requests"), { statusCode: 429 }), + ), + ).toBe(false); + expect( + isLikelyRequestOrStreamFailure( + Object.assign(new Error("Internal Server Error"), { statusCode: 500 }), + ), + ).toBe(false); + }); +}); diff --git a/test/llm/provider.test.ts b/test/llm/provider.test.ts index b1248882..8e9515d4 100644 --- a/test/llm/provider.test.ts +++ b/test/llm/provider.test.ts @@ -6,7 +6,12 @@ vi.mock("@ai-sdk/anthropic", () => ({ createAnthropic: () => (modelId: string) => ({ p: "anthropic", modelId }), })); vi.mock("@ai-sdk/openai", () => ({ - createOpenAI: () => (modelId: string) => ({ p: "openai", modelId }), + createOpenAI: () => { + const chat = (modelId: string) => ({ p: "openai", modelId, api: "chat" }); + const fn = (modelId: string) => ({ p: "openai", modelId, api: "responses" }); + (fn as any).chat = chat; + return fn; + }, })); import { resolveProvider, modelIdFor, createModel } from "../../src/llm/provider"; @@ -59,4 +64,8 @@ describe("createModel", () => { expect(r.supportsPromptCache).toBe(false); expect(r.modelId).toBe("gpt-4o"); }); + it("usa Chat Completions (openai.chat), no la Responses API default", () => { + const r = createModel(env({ LLM_PROVIDER: "openai", OPENAI_API_KEY: "sk-oa" }), "smart"); + expect(r.model).toEqual({ p: "openai", modelId: "gpt-4o", api: "chat" }); + }); }); diff --git a/test/llm/runTurn.test.ts b/test/llm/runTurn.test.ts new file mode 100644 index 00000000..a5ac448f --- /dev/null +++ b/test/llm/runTurn.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const streamTextMock = vi.fn(); +const generateTextMock = vi.fn(); + +vi.mock("ai", () => ({ + streamText: (...args: unknown[]) => streamTextMock(...args), + generateText: (...args: unknown[]) => generateTextMock(...args), +})); + +import { runLlmTurn } from "../../src/llm/runTurn"; + +function streamOk(text: string) { + async function* gen() { + yield text; + } + return { + textStream: gen(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 4, cachedInputTokens: 0 }), + steps: Promise.resolve([{ toolCalls: [] }]), + }; +} + +const ARGS = { + model: { id: "gpt-4o" }, + system: [{ role: "system" as const, content: "hola" }], + messages: [{ role: "user" as const, content: "hola" }], + tools: { searchKb: {} }, + stopWhen: ({ steps }: { steps: any[] }) => steps.length >= 6, +}; + +describe("runLlmTurn", () => { + beforeEach(() => { + streamTextMock.mockReset(); + generateTextMock.mockReset(); + }); + + it("usa streamText cuando el stream produce texto", async () => { + streamTextMock.mockImplementation(() => streamOk("buen día")); + const r = await runLlmTurn(ARGS); + expect(r.text).toBe("buen día"); + expect(generateTextMock).not.toHaveBeenCalled(); + expect(streamTextMock.mock.calls[0][0].tools).toEqual({ searchKb: {} }); + }); + + it("si streamText da 400 / NoOutput, reintenta con generateText (sin SSE)", async () => { + const streamErr = Object.assign(new Error("No output generated. Check the stream for errors."), { + name: "AI_NoOutputGeneratedError", + cause: Object.assign(new Error("Bad Request"), { + name: "AI_APICallError", + statusCode: 400, + url: "https://api.openai.com/v1/responses", + responseBody: '{"error":{"message":"Invalid schema"}}', + }), + }); + streamTextMock.mockImplementation(() => { + throw streamErr; + }); + generateTextMock.mockResolvedValue({ + text: "hola, ¿en qué te ayudo?", + usage: { inputTokens: 20, outputTokens: 8, cachedInputTokens: 0 }, + steps: [{ toolCalls: [{ toolName: "searchKb", input: { query: "hola" } }] }], + }); + + const r = await runLlmTurn(ARGS); + expect(r.text).toBe("hola, ¿en qué te ayudo?"); + expect(r.toolCallCount).toBe(1); + expect(r.toolCallsMade).toEqual([{ toolName: "searchKb", input: { query: "hola" } }]); + expect(generateTextMock).toHaveBeenCalledTimes(1); + expect(generateTextMock.mock.calls[0][0].messages).toEqual(ARGS.messages); + }); + + it("no usa generateText en un 429 — eso es failover de proveedor", async () => { + streamTextMock.mockImplementation(() => { + throw Object.assign(new Error("Too Many Requests"), { statusCode: 429 }); + }); + await expect(runLlmTurn(ARGS)).rejects.toThrow(/Too Many Requests/); + expect(generateTextMock).not.toHaveBeenCalled(); + }); +});