Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 72 additions & 19 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,50 @@
import { NextResponse } from "next/server";
import { TamboAI } from "@tambo-ai/typescript-sdk";

const tambo = new TamboAI();
type ToolArgs = Record<string, unknown>;

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",
Expand Down Expand Up @@ -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: {},
Expand All @@ -74,8 +82,9 @@ async function runTambo(
],
});

const abortState = { aborted: false };
const timeout = setTimeout(() => {
safeAbort(stream.controller);
safeAbort(stream.controller, abortState);
}, 55_000);

try {
Expand Down Expand Up @@ -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;
Expand All @@ -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 };
}

Expand All @@ -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);

Expand All @@ -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",
Expand All @@ -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 });
Expand Down