Next.js
1. Add this file as src/lib/serveConversation.ts
// src/lib/serveConversation.ts
/*
────────────────────────────────────────────────────────────────────────────
Code Chat v2 (chatEngine: 'v2')
Handles a v2 request: the conversation is passed through to your AI provider
and the model's reply is returned as-is.
────────────────────────────────────────────────────────────────────────────
*/
import { NextResponse } from 'next/server';
type Body = {
messages?: unknown;
tools?: Array<Record<string, unknown>>;
system?: string;
model?: string;
temperature?: number | string;
topP?: number | string;
cacheContext?: boolean;
stream?: boolean;
};
/*
Marks the end of the conversation as cacheable, so the next call in the same
Code Chat request re-uses everything sent so far instead of paying for it again.
*/
function markConversationCacheable(messages: unknown[]): unknown[] {
const out = messages.slice() as Array<Record<string, unknown>>;
for (let i = out.length - 1; i >= 0; i--) {
const m = out[i];
if (!m || !m.role) continue;
if (Array.isArray(m.content)) {
const parts = (m.content as Array<Record<string, unknown>>).map(p => ({ ...p }));
const last = parts[parts.length - 1];
if (last && last.type === 'text') {
parts[parts.length - 1] = { ...last, cache_control: { type: 'ephemeral' } };
out[i] = { ...m, content: parts };
return out;
}
continue;
}
if (typeof m.content === 'string' && m.content.length > 0) {
out[i] = {
...m,
content: [{ type: 'text', text: m.content, cache_control: { type: 'ephemeral' } }],
};
return out;
}
}
return out;
}
export async function serveConversation(
body: Body,
{ url, apiKey, defaultModel }: { url: string; apiKey?: string; defaultModel: string }
) {
const messages = body.messages;
if (!Array.isArray(messages) || messages.length === 0) {
return NextResponse.json({ error: 'messages must be a non-empty array' });
}
// Mark the instructions as cacheable. One Code Chat request makes several
// calls with the same instructions, so this cuts the cost significantly.
const cacheable = !!body.system && body.system.length > 2000 && body.cacheContext !== false;
const systemMessage = cacheable
? { role: 'system', content: [{ type: 'text', text: body.system, cache_control: { type: 'ephemeral' } }] }
: { role: 'system', content: body.system };
// Cache what has been sent so far, so later calls in the same request do not
// pay for the whole conversation again.
const conversation = cacheable ? markConversationCacheable(messages) : messages;
const withSystem = body.system ? [systemMessage, ...conversation] : conversation;
const payload: Record<string, unknown> = {
model: body.model || defaultModel,
messages: withSystem,
temperature: body.temperature === undefined ? 0.7 : parseFloat(String(body.temperature)),
top_p: body.topP === undefined ? 0.9 : parseFloat(String(body.topP)),
};
// Code Chat sends plain tool definitions; providers expect each wrapped.
if (Array.isArray(body.tools) && body.tools.length > 0) {
payload.tools = body.tools.map(t => ({ type: 'function', function: t }));
payload.tool_choice = 'auto';
}
// Streams the reply back so the answer appears as it is written.
// Used automatically when the request has `stream: true`.
if (body.stream) {
payload.stream = true;
payload.stream_options = { include_usage: true };
}
const send = () => fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
});
let response = await send();
let errorText = '';
if (response.status >= 400) {
errorText = await response.text();
/*
Some newer OpenAI models apply a default reasoning effort their
chat/completions endpoint will not combine with function tools, and say
so. Older models reject the argument outright, so it cannot always be
sent — the provider's own message decides, once.
*/
if (/reasoning_effort/i.test(errorText) && payload.reasoning_effort === undefined) {
payload.reasoning_effort = 'none';
response = await send();
errorText = response.status >= 400 ? await response.text() : '';
}
}
// Pass the provider's status through, so Code Chat waits and retries
// when you are rate limited instead of giving up.
if (response.status >= 400) {
let message = `Request failed with status code ${response.status}`;
try {
const body = JSON.parse(errorText);
if (body?.error?.message) message = body.error.message;
} catch { /* not JSON */ }
const retryAfter = response.headers.get('retry-after');
return NextResponse.json({
error: message,
status: response.status,
...(retryAfter ? { retryAfter: Number(retryAfter) } : {}),
});
}
if (body.stream && response.body) {
return new NextResponse(response.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
},
});
}
const data = await response.json();
if (data?.error) {
return NextResponse.json({ error: data.error.message || String(data.error) });
}
const message = data?.choices?.[0]?.message;
if (!message) {
return NextResponse.json({ error: 'no message in provider response' });
}
// Return the whole message: Code Chat needs `tool_calls` to continue.
return NextResponse.json({ message, usage: data.usage });
}
2. Add this file as src/lib/serveImageGeneration.ts
// src/lib/serveImageGeneration.ts
//
// Image generation via OpenRouter — one call, no queue.
//
// Unlike Fal there is no request id to poll and no result to fetch: the image
// comes back in the same response. It arrives as base64 and is saved here, so
// the browser only ever receives a URL — base64 in the page would be written
// into every save of that content.
import { NextResponse } from 'next/server';
import path from 'path';
import fs from 'fs';
const uploadPath = process.env.UPLOAD_PATH || ''; // Physical path (e.g. "/public/uploads/")
const uploadUrl = process.env.UPLOAD_URL || ''; // URL base (e.g. "/uploads/")
function randomName() {
return 'ai-' + Math.random().toString(36).substring(2, 7);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function serveImageGeneration(body: any, apiKey?: string, provider: 'openrouter' | 'openai' = 'openrouter') {
const references: string[] = Array.isArray(body.input_references) ? body.input_references : [];
const isOpenAI = provider === 'openai';
let response: Response;
if (isOpenAI && references.length) {
/*
OpenAI is the only one that splits the two operations: an image derived
from a reference goes to /images/edits as multipart form data, not JSON.
A reference arriving as a data URL is decoded back to bytes for the form.
*/
const form = new FormData();
form.append('model', body.imageModel);
form.append('prompt', body.prompt || '');
if (body.imageSize) form.append('size', body.imageSize); // OpenAI takes pixels
for (let i = 0; i < references.length; i++) {
const url = references[i];
const blob = /^data:/i.test(url)
? await (await fetch(url)).blob()
: await (await fetch(url)).blob();
form.append('image[]', blob, `reference-${i + 1}.png`);
}
response = await fetch('https://api.openai.com/v1/images/edits', {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
});
} else {
const payload: Record<string, unknown> = {
model: body.imageModel,
prompt: body.prompt || '',
};
// OpenRouter takes an aspect ratio, OpenAI takes pixels
if (body.imageSize) payload[isOpenAI ? 'size' : 'aspect_ratio'] = body.imageSize;
if (!isOpenAI && references.length) {
payload.input_references = references.map((url: string) => ({
type: 'image_url',
image_url: { url },
}));
}
response = await fetch(
isOpenAI ? 'https://api.openai.com/v1/images/generations' : 'https://openrouter.ai/api/v1/images',
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(payload),
});
}
const data = await response.json();
if (!response.ok) {
return NextResponse.json({
error: data?.error?.message || `Request failed with status code ${response.status}`,
status: response.status,
});
}
const b64 = data?.data?.[0]?.b64_json;
if (!b64) return NextResponse.json({ error: 'The generation returned no image.' });
try {
const folderPath = body?.customData?.folderPath || '';
const filename = `${randomName()}.jpg`;
const filePath = path.join(process.cwd(), uploadPath, folderPath, filename);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, Buffer.from(b64, 'base64'));
return NextResponse.json({ url: uploadUrl + path.posix.join(folderPath, filename) });
} catch (e) {
return NextResponse.json({ error: 'Could not save the generated image: ' + (e as Error).message });
}
}
3. Using OpenRouter — replace src/app/api/openrouter/route.ts with this
// src/app/api/openrouter/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { serveConversation } from '@/lib/serveConversation';
import { serveImageGeneration } from '@/lib/serveImageGeneration';
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
export async function POST(req: NextRequest) {
try {
const url = "https://openrouter.ai/api/v1/chat/completions";
const DEFAULT_MODEL = 'openai/gpt-4o-mini';
const DEFAULT_TEMPERATURE = 0.6;
const DEFAULT_TOP_P = 0.9;
const DEFAULT_NUM = 1;
const body = await req.json();
// Image generation — one call, answered from the same route so a host
// needs no extra endpoint. Neither v1 nor v2 sends `imageModel`.
if (body?.imageModel) {
return await serveImageGeneration(body, OPENROUTER_API_KEY);
}
// chatEngine 'v2' — conversational shape. No `messages` = v1, unchanged.
if (body?.messages) {
return await serveConversation(body, {
url,
apiKey: OPENROUTER_API_KEY,
defaultModel: DEFAULT_MODEL,
});
}
const { question, context, system, functs = [], temperature, topP, num, model } = body;
const messages = [
{ role: 'system', content: system },
{ role: 'assistant', content: context || '' },
{ role: 'user', content: question }
];
try {
if (functs.length > 0) {
// --- Function Calling Branch ---
const tools = [
{
type: "function",
function: {
...functs[0]
}
}
];
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
},
body: JSON.stringify({
model: 'anthropic/claude-3.5-sonnet',
/*
Models for function calling:
- anthropic/claude-3.5-sonnet
- qwen/qwen-2.5-72b-instruct
*/
messages,
temperature: parseFloat(temperature) || DEFAULT_TEMPERATURE,
top_p: parseFloat(topP) || DEFAULT_TOP_P,
n: parseInt(num) || DEFAULT_NUM,
tools,
})
});
const data = await response.json();
if (data && data.error && data.error.message) {
return NextResponse.json(
{ ok: false, status: 404, error: data.error.message },
{ status: 200 }
);
}
const answer = functs.length === 0
? data
: (data.choices[0].message.tool_calls
? data.choices[0].message.tool_calls[0].function.arguments
: data.choices[0].message);
const usage = data.usage;
return NextResponse.json({ answer, usage });
} else {
// --- Content Generation Branch ---
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
},
body: JSON.stringify({
model: model || DEFAULT_MODEL,
messages,
temperature: parseFloat(temperature) || DEFAULT_TEMPERATURE,
top_p: parseFloat(topP) || DEFAULT_TOP_P,
n: parseInt(num) || DEFAULT_NUM
})
});
const data = await response.json();
const answer = data;
const usage = data.usage;
return NextResponse.json({ answer, usage });
}
} catch (error) {
return NextResponse.json(
{ ok: false, status: 500, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 200 }
);
}
} catch {
return NextResponse.json(
{ ok: true, status: 500, error: 'Something went wrong.' },
{ status: 200 }
);
}
}
4. Using OpenAI — replace src/app/api/openai/route.ts with this
// src/app/api/openai/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { serveConversation } from '@/lib/serveConversation';
import { serveImageGeneration } from '@/lib/serveImageGeneration';
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
export async function POST(req: NextRequest) {
try {
const DEFAULT_MODEL = 'gpt-4o-mini';
const body = await req.json();
// Image generation — one call, answered from the same route so a host
// needs no extra endpoint. Neither v1 nor v2 sends `imageModel`.
if (body?.imageModel) {
return await serveImageGeneration(body, OPENAI_API_KEY, 'openai');
}
// chatEngine 'v2' — conversational shape. No `messages` = v1, unchanged.
// v2 always uses chat/completions, never the v1/responses endpoint.
if (body?.messages) {
return await serveConversation(body, {
url: 'https://api.openai.com/v1/chat/completions',
apiKey: OPENAI_API_KEY,
defaultModel: DEFAULT_MODEL,
});
}
const { question, context, system, functs = [], model } = body;
const RESPONSE_ENDPOINT_MODELS = ['gpt-5.1-codex-mini', 'gpt-5.1-codex'];
const useResponsesEndpoint = RESPONSE_ENDPOINT_MODELS.includes(model);
const url = useResponsesEndpoint
? 'https://api.openai.com/v1/responses'
: 'https://api.openai.com/v1/chat/completions';
const messages = [
{ role: 'system', content: system },
{ role: 'assistant', content: context || '' },
{ role: 'user', content: question }
];
const requestBody = useResponsesEndpoint
? {
model,
input: messages,
}
: {
model: model || DEFAULT_MODEL,
messages,
functions: functs.length === 0 ? undefined : functs
};
const openAiResponse = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OPENAI_API_KEY}`
},
body: JSON.stringify(requestBody)
});
if (!openAiResponse.ok) {
const errorData = await openAiResponse.json();
return NextResponse.json({ error: errorData }, { status: openAiResponse.status });
}
const data = await openAiResponse.json();
let answer, usage;
if (useResponsesEndpoint) {
// Normalize v1/responses format to match v1/chat/completions
type ResponseOutputItem = { type: string; role: string; content: { text: string }[] };
const messageOutput = data.output.find((item: ResponseOutputItem) => item.type === 'message');
answer = {
choices: [{
message: {
role: messageOutput.role,
content: messageOutput.content[0].text
}
}]
};
usage = data.usage;
} else {
const choices = data.choices;
answer = functs.length === 0
? data
: (choices[0]?.message?.function_call
? choices[0].message.function_call.arguments
: choices[0]?.message);
usage = data.usage;
}
return NextResponse.json({ answer, usage });
} catch (err: unknown) {
return NextResponse.json(
{ ok: false, error: err instanceof Error ? err.message : "Unknown error" },
{ status: 500 }
);
}
}