Skip to content
This repository was archived by the owner on Jan 25, 2026. It is now read-only.
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
24 changes: 5 additions & 19 deletions app/api/generate-options/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { OpenRouter } from "@openrouter/sdk";
import { z } from "zod";

const openrouter = new OpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
import { chatCompletion, getFastModel } from "@/lib/llm-client";

// Schema for options response
const OptionsResponseSchema = z.object({
Expand Down Expand Up @@ -53,27 +49,17 @@ ${questions.map((q: string, i: number) => `${i}. ${q}`).join("\n")}

Generate 3-4 answer options for each question.`;

const completion = await openrouter.chat.send({
model: "openai/gpt-4o-mini", // Fast enough for simple options, better than flash-lite
const content = await chatCompletion({
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMessage },
],
responseFormat: { type: "json_object" },
model: getFastModel(),
jsonMode: true,
});

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

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

// Parse and validate the response
const contentStr = typeof content === "string" ? content : JSON.stringify(content);
const parsed = JSON.parse(contentStr);
const parsed = JSON.parse(content);
const validated = OptionsResponseSchema.parse(parsed);

return NextResponse.json(validated);
Expand Down
26 changes: 5 additions & 21 deletions app/api/plan/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { OpenRouter } from "@openrouter/sdk";
import { z } from "zod";

const openrouter = new OpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
import { chatCompletion, getDefaultModel } from "@/lib/llm-client";

// Schema for clarifying questions response
const ClarifyingResponseSchema = z.object({
Expand Down Expand Up @@ -100,29 +96,17 @@ export async function POST(request: NextRequest) {
userMessage = `Original request: ${prompt}\n\nYou asked these questions:\n${questionTexts.map((q: string, i: number) => `${i + 1}. ${q}`).join("\n")}\n\nUser's answers:\n${userResponses.map((a: string, i: number) => `${i + 1}. ${a}`).join("\n")}\n\nNow generate the itinerary based on this information.`;
}

// Use high-quality model for trip planning
const completion = await openrouter.chat.send({
model: "anthropic/claude-sonnet-4", // Better quality for presentations
const content = await chatCompletion({
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMessage },
],
responseFormat: { type: "json_object" },
max_tokens: 2000, // Limit tokens for faster response
model: getDefaultModel(),
jsonMode: true,
});

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

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

// Parse and validate the response
const contentStr = typeof content === "string" ? content : JSON.stringify(content);
const parsed = JSON.parse(contentStr);
const parsed = JSON.parse(content);
const validated = PlanResponseSchema.parse(parsed);

return NextResponse.json(validated);
Expand Down
24 changes: 5 additions & 19 deletions app/api/refine/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { OpenRouter } from "@openrouter/sdk";
import { z } from "zod";

const openrouter = new OpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
import { chatCompletion, getDefaultModel } from "@/lib/llm-client";

// Schema for refinement response
const RefineResponseSchema = z.object({
Expand Down Expand Up @@ -113,29 +109,19 @@ ${day.activities.map((a) => ` - ${a.time}: ${a.name} - ${a.description}`).join(
)
.join("\n\n")}`;

const completion = await openrouter.chat.send({
model: "anthropic/claude-sonnet-4", // Better quality for presentations
const content = await chatCompletion({
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: `Here is my current trip:\n\n${itineraryContext}` },
...conversationHistory,
{ role: "user", content: message },
],
responseFormat: { type: "json_object" },
model: getDefaultModel(),
jsonMode: true,
});

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

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

// Parse and validate the response
const contentStr = typeof content === "string" ? content : JSON.stringify(content);
const parsed = JSON.parse(contentStr);
const parsed = JSON.parse(content);

// If there's an updatedItinerary, ensure all activities have required fields
if (parsed.updatedItinerary && parsed.updatedItinerary.days) {
Expand Down
15 changes: 14 additions & 1 deletion env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,21 @@ CONVEX_DEPLOYMENT=

NEXT_PUBLIC_CONVEX_URL=

# LLM Backend: "openrouter" (default) or "n8n"
LLM_BACKEND=openrouter

# Required if LLM_BACKEND=openrouter
OPENROUTER_API_KEY=

# Required if LLM_BACKEND=n8n
N8N_WEBHOOK_URL=
# Auth Option 1: Basic Auth
N8N_USER=
N8N_PASSWORD=
# Auth Option 2: Header Auth (takes precedence over Basic Auth)
N8N_AUTH_HEADER_NAME=
N8N_AUTH_HEADER_VALUE=

NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=

ELEVENLABS_API_KEY=
ELEVENLABS_API_KEY=
157 changes: 157 additions & 0 deletions lib/llm-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { OpenRouter } from "@openrouter/sdk";

export type LLMBackend = "openrouter" | "n8n";

interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}

interface ChatCompletionOptions {
messages: ChatMessage[];
model?: string;
jsonMode?: boolean;
sessionId?: string;
}

interface N8nChatResponse {
output?: string | object;
response?: string | object;
text?: string | object;
message?: string | object;
[key: string]: unknown;
}

function getBackend(): LLMBackend {
const backend = process.env.LLM_BACKEND || "openrouter";
if (backend !== "openrouter" && backend !== "n8n") {
console.warn(`Unknown LLM_BACKEND "${backend}", falling back to openrouter`);
return "openrouter";
}
return backend;
}

function createOpenRouterClient(): OpenRouter {
return new OpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
}

function getN8nAuthHeaders(): Record<string, string> {
const user = process.env.N8N_USER;
const password = process.env.N8N_PASSWORD;
const headerAuthName = process.env.N8N_AUTH_HEADER_NAME;
const headerAuthValue = process.env.N8N_AUTH_HEADER_VALUE;

const headers: Record<string, string> = {
"Content-Type": "application/json",
};

// Option 1: Header Auth (custom header name + value)
if (headerAuthName && headerAuthValue) {
headers[headerAuthName] = headerAuthValue;
return headers;
}

// Option 2: Basic Auth (username + password)
if (user && password) {
const credentialString = `${user}:${password}`;
const credentials = Buffer.from(credentialString).toString("base64");
headers["Authorization"] = `Basic ${credentials}`;
}

return headers;
}

async function callN8nChatTrigger(options: ChatCompletionOptions): Promise<string> {
const { messages, sessionId } = options;

// Format messages into a single prompt for n8n Chat Trigger
// Include system message as context, then the conversation
const formattedMessages = messages.map((msg) => {
if (msg.role === "system") {
return `[System Instructions]\n${msg.content}\n[End System Instructions]`;
}
return `${msg.role === "user" ? "User" : "Assistant"}: ${msg.content}`;
}).join("\n\n");

const baseUrl = process.env.N8N_WEBHOOK_URL;

if (!baseUrl) {
throw new Error("N8N_WEBHOOK_URL is not set. Set it in .env.local");
}

const response = await fetch(baseUrl, {
method: "POST",
headers: getN8nAuthHeaders(),
body: JSON.stringify({
chatInput: formattedMessages,
sessionId: sessionId || `session-${Date.now()}`,
}),
});

if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error");
throw new Error(`n8n API error (${response.status}): ${errorText}`);
}

const data: N8nChatResponse = await response.json();

// n8n Chat Trigger can return response in different fields
const content = data.output || data.response || data.text || data.message;

if (!content) {
// If no known field, try to find any string value or stringify the whole response
const firstStringValue = Object.values(data).find((v) => typeof v === "string") as string | undefined;
if (firstStringValue) {
return firstStringValue;
}
throw new Error("No response content from n8n Chat Trigger");
}

// If content is already an object, stringify it
if (typeof content === "object") {
return JSON.stringify(content);
}

return content;
}

export async function chatCompletion(options: ChatCompletionOptions): Promise<string> {
const backend = getBackend();
const { messages, model, jsonMode = true } = options;

if (backend === "n8n") {
// Use n8n Chat Trigger (direct HTTP call)
return callN8nChatTrigger(options);
}

// Default: OpenRouter
if (!process.env.OPENROUTER_API_KEY) {
throw new Error("OPENROUTER_API_KEY is not set. Set it in .env.local or switch to LLM_BACKEND=n8n");
}

const client = createOpenRouterClient();

const completion = await client.chat.send({
model: model || "openai/gpt-4o-mini",
messages,
...(jsonMode && { responseFormat: { type: "json_object" } }),
});

const content = completion.choices?.[0]?.message?.content;
if (!content) {
throw new Error("No response from OpenRouter");
}
return typeof content === "string" ? content : JSON.stringify(content);
}

export function getDefaultModel(): string {
const backend = getBackend();
return backend === "n8n" ? "gpt-4o-mini" : "openai/gpt-4o-mini";
}

export function getFastModel(): string {
const backend = getBackend();
return backend === "n8n" ? "gpt-4o-mini" : "google/gemini-2.0-flash-lite-001";
}
Loading