Skip to content
Open
Show file tree
Hide file tree
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
8 changes: 5 additions & 3 deletions app/api/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { saveMockKit } from "@/lib/mock-store";
import { getActiveSubscription, isPaidPlan, isSubscriptionCurrentlyActive } from "@/lib/payment/entitlements";
import { PLAN_MONTHLY_LIMITS } from "@/lib/payment";
import { createSupabaseAdminClient, createSupabaseServerClient, getCurrentUserId, hasSupabaseConfig } from "@/lib/supabase";
import { createLettaAgent, getLettaAgent, isLettaConfigured } from "@/lib/letta";
import { createLettaAgent, getLettaAgent, isLettaConfigured, isAgentNotFoundError } from "@/lib/letta";

export async function POST(request: Request) {
try {
Expand Down Expand Up @@ -191,8 +191,9 @@ async function resolveLettaAgentId(userId: string): Promise<string | null> {
try {
await getLettaAgent(agentId);
return agentId; // still valid
} catch {
} catch (error) {
// Stale / deleted agent — clear mapping and fall through to recreate
console.warn("[generate] Stale Letta agent detected, clearing:", agentId, error instanceof Error ? error.message : String(error));
if (admin) {
await admin.from("user_agents").delete().eq("user_id", userId);
}
Expand Down Expand Up @@ -223,8 +224,9 @@ async function resolveLettaAgentId(userId: string): Promise<string | null> {
}

return newAgent.id;
} catch {
} catch (error) {
// Letta unavailable — return null so generateKitOutputs skips Letta path
console.warn("[generate] Failed to create Letta agent, will use direct LLM fallback:", error instanceof Error ? error.message : String(error));
return null;
}
}
46 changes: 16 additions & 30 deletions app/api/letta/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getLettaAgent,
createLettaAgent,
isLettaConfigured,
isAgentNotFoundError,
} from "@/lib/letta";

/**
Expand Down Expand Up @@ -81,48 +82,33 @@ export async function POST(request: Request) {
.select("letta_agent_id")
.eq("user_id", authUser.id)
.maybeSingle();
if (mapping) agentId = mapping.letta_agent_id;
if (mapping?.letta_agent_id) {
agentId = mapping.letta_agent_id;
}
}

if (!agentId) {
const meta = authUser.user_metadata ?? {};
agentId = meta.letta_agent_id ?? null;
}

// If no agent, create one on the fly
if (!agentId) {
// Validate the agent still exists on Letta
if (agentId) {
try {
const newAgent = await createLettaAgent(authUser.id, authUser.email ?? "");

await getLettaAgent(agentId);
} catch (error) {
// Agent was deleted / stale — clear mapping and recreate
console.warn("[letta/chat] Stale agent detected, recreating:", agentId);
if (admin) {
await admin.from("user_agents").upsert(
{
user_id: authUser.id,
letta_agent_id: newAgent.id,
letta_agent_name: newAgent.name,
},
{ onConflict: "user_id" }
);
await admin.from("user_agents").delete().eq("user_id", authUser.id);
}

await supabase.auth.updateUser({
data: { letta_agent_id: newAgent.id },
});

agentId = newAgent.id;
} catch (e) {
return NextResponse.json(
{ error: `Failed to create AI agent: ${(e as Error).message}` },
{ status: 500 }
);
await supabase.auth.updateUser({ data: { letta_agent_id: null } });
agentId = null;
}
}

// Verify the agent still exists
try {
await getLettaAgent(agentId);
} catch {
// Agent was deleted — recreate
// If no agent, create one on the fly
if (!agentId) {
try {
const newAgent = await createLettaAgent(authUser.id, authUser.email ?? "");

Expand All @@ -144,7 +130,7 @@ export async function POST(request: Request) {
agentId = newAgent.id;
} catch (e) {
return NextResponse.json(
{ error: `Failed to recreate AI agent: ${(e as Error).message}` },
{ error: `Failed to create AI agent: ${(e as Error).message}` },
{ status: 500 }
);
}
Expand Down
65 changes: 65 additions & 0 deletions app/api/llm/generate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* POST /api/llm/generate
*
* Internal API route that wraps z-ai-web-dev-sdk for LLM generation.
* This route MUST run on Node.js runtime (not edge) because the SDK
* uses Node.js modules (fs, path, os) that are not available in edge.
*
* Expected body: { prompt: string }
* Returns: { content: string }
*/

import { NextResponse } from "next/server";

export const runtime = "nodejs";

export async function POST(request: Request) {
try {
const body = (await request.json()) as { prompt?: string };
const prompt = body.prompt?.trim();

if (!prompt) {
return NextResponse.json(
{ error: "Missing prompt." },
{ status: 400 }
);
}

// Use z-ai-web-dev-sdk for LLM calls
const ZAI = (await import("z-ai-web-dev-sdk")).default;
const zai = await ZAI.create();

const completion = await zai.chat.completions.create({
messages: [
{
role: "system",
content:
"You are a senior growth strategist. Return strict JSON that matches the requested schema. Do not include any text outside the JSON object."
},
{
role: "user",
content: prompt
}
],
temperature: 0.7,
});

const content = completion.choices?.[0]?.message?.content;

if (!content) {
return NextResponse.json(
{ error: "LLM returned an empty response." },
{ status: 500 }
);
}

return NextResponse.json({ content });
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
console.error("[LLM API] Generation failed:", detail);
return NextResponse.json(
{ error: `AI generation failed: ${detail}` },
{ status: 500 }
);
}
}
70 changes: 61 additions & 9 deletions lib/letta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
* - Send structured generation requests (for /api/generate etc.)
*
* Letta docs: https://docs.letta.com/api-reference
*
* IMPORTANT: The Letta /v1/chat/completions endpoint is NOT a standard
* OpenAI-compatible endpoint — it requires an agent_id. Therefore, we
* must NOT use it as an LLM fallback. The LLM fallback uses the
* z-ai-web-dev-sdk instead (see lib/llm.ts).
*/

const LETTA_BASE_URL = process.env.LETTA_API_URL ?? "https://api.letta.com";
Expand Down Expand Up @@ -67,6 +72,22 @@ function headers(): Record<string, string> {
};
}

/**
* Check if an error is an "agent-not-found" error from the Letta API.
* This happens when a stale agent ID is stored in user metadata.
*/
export function isAgentNotFoundError(error: unknown): boolean {
if (error instanceof Error) {
const msg = error.message.toLowerCase();
return (
msg.includes("agent-not-found") ||
msg.includes("could not be found") ||
(msg.includes("429") && msg.includes("agent"))
);
}
return false;
}

async function request<T>(
path: string,
options: RequestInit = {}
Expand Down Expand Up @@ -101,6 +122,7 @@ export async function listLettaAgents(): Promise<LettaAgent[]> {

/**
* Get a single Letta agent by ID.
* Throws if the agent does not exist or the API key is invalid.
*/
export async function getLettaAgent(agentId: string): Promise<LettaAgent> {
return request<LettaAgent>(`/v1/agents/${agentId}`);
Expand All @@ -111,6 +133,10 @@ export async function getLettaAgent(agentId: string): Promise<LettaAgent> {
*
* The agent is given a persona that matches Finfold's brand content
* creation use-case, plus a human block that identifies the user.
*
* Uses the model specified in LETTA_MODEL env var (default: openai/gpt-4o-mini).
* If the model is not available (e.g. openai-proxy/gpt-4.1-mini not registered),
* falls back to openai/gpt-4o-mini which is always available on Letta.
*/
export async function createLettaAgent(
userId: string,
Expand Down Expand Up @@ -148,10 +174,29 @@ export async function createLettaAgent(
memory_blocks: memoryBlocks,
};

return request<LettaAgent>("/v1/agents", {
method: "POST",
body: JSON.stringify(body),
});
try {
return await request<LettaAgent>("/v1/agents", {
method: "POST",
body: JSON.stringify(body),
});
} catch (primaryError) {
// If the configured model is not found, fall back to openai/gpt-4o-mini
const errMsg = primaryError instanceof Error ? primaryError.message : String(primaryError);
if (errMsg.includes("NOT_FOUND") || errMsg.includes("not found")) {
console.warn(
`[Letta] Model "${LETTA_MODEL}" not found, falling back to openai/gpt-4o-mini`
);
const fallbackBody: Record<string, unknown> = {
...body,
model: "openai/gpt-4o-mini",
};
return request<LettaAgent>("/v1/agents", {
method: "POST",
body: JSON.stringify(fallbackBody),
});
}
throw primaryError;
}
}

/**
Expand All @@ -165,6 +210,12 @@ export async function deleteLettaAgent(agentId: string): Promise<void> {

/**
* Send a user message to a Letta agent and return the parsed response.
*
* Uses the non-streaming API for reliability. The Letta API returns:
* { messages: [...], stop_reason, usage }
*
* Each message has: { id, date, message_type, content, ... }
* message_type can be: "assistant_message", "reasoning_message", "tool_call_message", etc.
*/
export async function sendLettaMessage(
agentId: string,
Expand All @@ -181,12 +232,10 @@ export async function sendLettaMessage(
);

// Parse response — Letta returns { messages: [...], stop_reason, usage }
// but may also return a plain array or content array in some versions
let rawMessages: unknown[] = [];

if (data && typeof data === "object") {
if ("messages" in data && Array.isArray((data as Record<string, unknown>).messages)) {
// Standard Letta response: { messages: [...], stop_reason, usage }
rawMessages = (data as Record<string, unknown>).messages as unknown[];
} else if (Array.isArray(data)) {
rawMessages = data;
Expand Down Expand Up @@ -219,9 +268,9 @@ export async function sendLettaMessage(
};
});

// Extract the assistant message
// Extract the assistant message — prefer assistant_message type
const assistantMessage = messages
.filter((m) => m.role === "assistant" || m.message_type === "assistant_message")
.filter((m) => m.message_type === "assistant_message" || m.role === "assistant")
.map((m) => m.content)
.filter(Boolean)
.join("\n\n");
Expand Down Expand Up @@ -259,6 +308,9 @@ export async function sendLettaStructuredRequest(
/**
* Send a user message to a Letta agent using streaming (SSE).
* Returns a ReadableStream for the frontend to consume.
*
* NOTE: The Letta streaming API requires "streaming: true" for SDK v1.0+.
* If streaming is not available, falls back to non-streaming.
*/
export async function streamLettaMessage(
agentId: string,
Expand All @@ -271,7 +323,7 @@ export async function streamLettaMessage(
headers: headers(),
body: JSON.stringify({
messages: [{ role: "user", content: userMessage }],
stream_tokens: true,
stream_steps: true,
}),
});

Expand Down
Loading