diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 2ab5cfc..9e3484b 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,42 +1,50 @@ import { NextResponse } from "next/server"; import { TamboAI } from "@tambo-ai/typescript-sdk"; -const tambo = new TamboAI(); +type ToolArgs = Record; type ToolCall = { id: string; name: string; - args: unknown; + args: ToolArgs; }; +const MAX_MESSAGE_CHARS = 8000; + function getModelCandidates(): string[] { const envModel = process.env.TAMBO_MODEL?.trim(); return [ envModel, + // Matches the model suggested in the issue as the primary free-tier option. + "gpt-4.1-2025-04-14", // Prefer a cheap/free-tier model if the project supports it. - "gpt-4o-mini-2024-07-18", "gpt-4o-mini", - // Matches the model suggested in the issue. - "gpt-4.1-2025-04-14", + "gpt-4o-mini-2024-07-18", ].filter((m): m is string => Boolean(m)); } function isModelConfigError(err: unknown): boolean { if (!(err instanceof Error)) return false; const msg = err.message.toLowerCase(); - return msg.includes("model") || msg.includes("unknown") || msg.includes("not found") || msg.includes("unsupported"); + return msg.includes("unknown model") || msg.includes("model not found") || msg.includes("unsupported model"); } -function safeAbort(controller: AbortController) { +function safeAbort(controller: unknown, state?: { aborted: boolean }) { + if (state?.aborted) return; + try { - controller.abort(); - } catch { - // Best-effort cleanup. + if (controller && typeof (controller as { abort?: unknown }).abort === "function") { + (controller as { abort: () => void }).abort(); + if (state) state.aborted = true; + } + } catch (error) { + console.error("[tambo] Failed to abort stream controller", error); } } async function runTambo( + tambo: TamboAI, message: string, model: string, toolChoice: "auto" | "required" | "none" | { name: string } = "auto", @@ -64,7 +72,7 @@ async function runTambo( { name: "AgentGrid", description: - "Render the AgentGrid system monitor when the user asks for status, monitoring, dashboard, system health, or similar.", + "Render the AgentGrid system monitor when the user asks for status, monitoring, dashboard, system health, or similar. This tool takes no arguments.", inputSchema: { type: "object", properties: {}, @@ -74,8 +82,9 @@ async function runTambo( ], }); + const abortState = { aborted: false }; const timeout = setTimeout(() => { - safeAbort(stream.controller); + safeAbort(stream.controller, abortState); }, 55_000); try { @@ -135,20 +144,32 @@ async function runTambo( const existing = toolCallsById.get(toolCallId); if (!existing) continue; - let args: unknown = {}; + let args: ToolArgs = {}; const argsJson = existing.argsJson.trim(); if (argsJson.length > 0) { try { - args = JSON.parse(argsJson); + const parsed = JSON.parse(argsJson); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + args = parsed as ToolArgs; + } else { + console.warn("[tambo] Tool args was not an object", { + toolCallId, + toolName: existing.name, + }); + } } catch { - args = argsJson; + console.warn("[tambo] Failed to parse tool args as JSON", { + toolCallId, + toolName: existing.name, + }); + args = {}; } } completedToolCalls.push({ id: toolCallId, name: existing.name, args }); if (existing.name === "AgentGrid") { - safeAbort(stream.controller); + safeAbort(stream.controller, abortState); break; } continue; @@ -167,6 +188,10 @@ async function runTambo( clearTimeout(timeout); } + if (!reply.trim() && completedToolCalls.some((t) => t.name === "AgentGrid")) { + reply = "Launching AgentGrid system monitor."; + } + return { reply: reply.trim(), toolCalls: completedToolCalls, threadId, runId }; } @@ -176,12 +201,18 @@ export async function POST(req: Request) { } try { + const tambo = new TamboAI({ apiKey: process.env.TAMBO_API_KEY }); + const body = await req.json().catch(() => null); const message = typeof body?.message === "string" ? body.message.trim() : ""; if (!message) { return NextResponse.json({ reply: "Missing message" }, { status: 400 }); } + if (message.length > MAX_MESSAGE_CHARS) { + return NextResponse.json({ reply: `Message too long (max ${MAX_MESSAGE_CHARS} chars)` }, { status: 400 }); + } + const isShortQuery = message.split(/\s+/).filter(Boolean).length <= 10; const forceAgentGrid = isShortQuery && /\b(status|monitor|dashboard|health)\b/i.test(message); @@ -190,6 +221,7 @@ export async function POST(req: Request) { for (const model of getModelCandidates()) { try { const { reply, toolCalls, threadId, runId } = await runTambo( + tambo, message, model, forceAgentGrid ? { name: "AgentGrid" } : "auto", @@ -209,13 +241,34 @@ export async function POST(req: Request) { lastError = err; if (err instanceof Error) errorsByModel[model] = err.message; if (isModelConfigError(err)) continue; - throw err; + + console.error("[tambo] Model attempt failed", { + model, + error: err instanceof Error ? err.message : String(err), + }); + + if (err instanceof Error) throw err; + throw new Error("Tambo request failed."); } } const errorMessage = lastError instanceof Error ? lastError.message : "Tambo request failed."; - console.error("Tambo model attempts failed", errorsByModel); - return NextResponse.json({ reply: errorMessage }, { status: 500 }); + console.error("Tambo model attempts failed", { errorsByModel, errorMessage }); + + const isProd = process.env.NODE_ENV === "production"; + return NextResponse.json( + isProd + ? { + reply: "Upstream AI service is currently unavailable. Please try again later.", + errorCode: "TAMBO_UPSTREAM_FAILURE", + } + : { + reply: errorMessage, + errorCode: "TAMBO_UPSTREAM_FAILURE", + errorsByModel, + }, + { status: 502 }, + ); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected server error."; return NextResponse.json({ reply: message }, { status: 500 });