I treat model selection as server policy: the browser requests a model, the backend decides whether it is allowed, and one streaming endpoint carries the response. There is no reason to put provider credentials or routing authority in a dropdown.
For this implementation, CometAPI provides the unified OpenAI-compatible endpoint at https://api.cometapi.com/v1. The application keeps one server-side key, sends the selected model to POST /v1/chat/completions, and forwards the Server-Sent Events response through /api/chat. No provider SDK is required.
Keep the Model Contract Explicit
A shared request format does not make models interchangeable. Context limits, supported parameters, tool behavior, output style, latency, and pricing still differ. I would test every allowed route with the same application prompts before exposing it.
The source lists the following routes as available, not upcoming, and supporting chat completions on August 21, 2026. Treat these as dated catalog and pricing claims, not a live availability check. Prices are USD per million tokens.
| Model ID | Intended use | Input / output price |
|---|---|---|
gemini-3.7-flash |
High-volume chat, coding, and knowledge workflows; multimodal input and a 1,048,576-token context window | $0.60 / $3.00 |
claude-opus-5 |
Premium multi-step reasoning, code, and long-form writing | $4 / $20 |
gpt-5.6 |
General-purpose reasoning, tool use, and drafting; the generic route maps to the Sol tier in the source snapshot | $3.2 / $16 in the short-context tier |
GPT-5.6 has a higher price tier above 272,000 tokens. The source describes token billing for models with official unified pricing as 0.8:1 of official prices, a 20% discount. Models without official APIs, including MidJourney, Kling, and Luma, use set per-call rates described as discounted 20%. Verify current billing units and rates in the pricing guide; this table is a routing example, not a quality ranking.
Bootstrap and Configure
You need Node.js, npm, an account with the API service, and a server-side key. Create the TypeScript App Router project, then configure .env.local in its root. The commands below separate the lines that were run together in the source.
npx create-next-app@latest multi-model-chat --ts --app --eslint
cd multi-model-chat
COMETAPI_API_KEY=replace_with_your_cometapi_key
COMETAPI_BASE_URL=https://api.cometapi.com/v1
Never give the credential a NEXT_PUBLIC_ prefix: that prefix makes environment variables available to browser bundles. The client only needs your own /api/chat URL.
Validate Before Opening a Stream
The allowlist belongs in app/api/chat/route.ts, even when the browser offers exactly the same choices. This handler accepts 1 to 50 text messages, each containing 1 to 20,000 characters, and forwards request.signal so a disconnected request can cancel upstream work. These character limits are not model token-budget checks.
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const ALLOWED_MODELS = ["gemini-3.7-flash", "claude-opus-5", "gpt-5.6"] as const;
type ModelId = (typeof ALLOWED_MODELS)[number];
type ChatMessage = { role: "system" | "user" | "assistant"; content: string };
function isModelId(value: unknown): value is ModelId { return typeof value === "string" && (ALLOWED_MODELS as readonly string[]).includes(value); }
function isChatMessage(value: unknown): value is ChatMessage { if (typeof value !== "object" || value === null) return false; const m = value as Record; return typeof m.role === "string" && ["system", "user", "assistant"].includes(m.role) && typeof m.content === "string" && m.content.length > 0 && m.content.length <= 20_000; }
export async function POST(request: Request) {
const apiKey = process.env.COMETAPI_API_KEY; const baseUrl = (process.env.COMETAPI_BASE_URL || "https://api.cometapi.com/v1").replace(/\/$/, "");
if (!apiKey) return Response.json({ error: "COMETAPI_API_KEY is not configured." }, { status: 500 });
let body: unknown; try { body = await request.json(); } catch { return Response.json({ error: "Invalid JSON body." }, { status: 400 }); }
if (typeof body !== "object" || body === null) return Response.json({ error: "Expected a JSON object." }, { status: 400 });
const payload = body as Record;
if (!isModelId(payload.model)) return Response.json({ error: "Unsupported model ID." }, { status: 400 });
if (!Array.isArray(payload.messages) || payload.messages.length === 0 || payload.messages.length > 50 || !payload.messages.every(isChatMessage)) return Response.json({ error: "messages must contain 1 to 50 valid text messages." }, { status: 400 });
const upstream = await fetch(`${baseUrl}/chat/completions`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: payload.model, messages: payload.messages, stream: true }), cache: "no-store", signal: request.signal });
if (!upstream.ok) { const requestId = upstream.headers.get("x-request-id"); console.error("Upstream request failed", { status: upstream.status, requestId }); return Response.json({ error: "The selected model request failed.", status: upstream.status, requestId }, { status: upstream.status }); }
if (!upstream.body) return Response.json({ error: "The model returned no response body." }, { status: 502 });
return new Response(upstream.body, { status: 200, headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform" } });
}
I have added a top-level object check so valid JSON such as null produces a 400 instead of failing during property access. Non-success upstream HTTP responses return a sanitized error; connection failures and application timeouts still need production handling. Allowing system messages matches this example's contract, but an application-owned system prompt should be constructed on the server.
Consume Text Deltas in the Browser
Put the following in app/page.tsx. The important detail is the buffer: network chunks need not end at an SSE line boundary. The decoder preserves split UTF-8 sequences, and incomplete lines wait for the next read. This parser targets single-line JSON data: records from chat completions, not every possible SSE payload.
"use client";
import { useState, type FormEvent } from "react";
const MODEL_OPTIONS = [{ id: "gemini-3.7-flash", label: "Gemini 3.7 Flash" }, { id: "claude-opus-5", label: "Claude Opus 5" }, { id: "gpt-5.6", label: "GPT-5.6" }] as const;
type Message = { role: "user" | "assistant"; content: string };
function textFromSseLine(line: string): string { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) return ""; const data = trimmed.slice(5).trim(); if (!data || data === "[DONE]") return ""; try { const text = JSON.parse(data).choices?.[0]?.delta?.content; return typeof text === "string" ? text : ""; } catch { return ""; } }
export default function Home() {
const [model, setModel] = useState("gemini-3.7-flash"), [messages, setMessages] = useState([]), [input, setInput] = useState(""), [loading, setLoading] = useState(false), [error, setError] = useState("");
function appendAssistantText(text: string) { if (!text) return; setMessages(current => { const next = [...current]; const i = next.length - 1; if (i >= 0 && next[i].role === "assistant") next[i] = { ...next[i], content: next[i].content + text }; return next; }); }
async function sendMessage(event: FormEvent) {
event.preventDefault(); const content = input.trim(); if (!content || loading) return;
const outgoing: Message[] = [...messages.filter(message => message.content.length > 0), { role: "user", content }]; setMessages([...outgoing, { role: "assistant", content: "" }]); setInput(""); setError(""); setLoading(true);
try { const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, messages: outgoing }) }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Request failed with ${response.status}`); } if (!response.body) throw new Error("Streaming is not available.");
const reader = response.body.getReader(), decoder = new TextDecoder(); let buffer = "";
while (true) { const { value, done } = await reader.read(); buffer += decoder.decode(value, { stream: !done }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) appendAssistantText(textFromSseLine(line)); if (done) { appendAssistantText(textFromSseLine(buffer)); break; } }
} catch (e) { setError(e instanceof Error ? e.message : "Request failed."); } finally { setLoading(false); }
}
return # Multi-model chat
Model setModel(e.target.value)} disabled={loading}>{MODEL_OPTIONS.map(o => {o.label})}{messages.map((m, i) => {m.role === "user" ? "You" : "Assistant"}{m.content || "..."}
)} setInput(e.target.value)} rows={3} maxLength={20_000} /><button disabled={loading || !input.trim()} type="submit">{loading ? "Streaming..." : "Send"}</button></form>{error ? <p className="error" role="alert">{error}</p> : null}</section></main>;
}
Empty assistant placeholders are filtered from subsequent requests, avoiding a validation failure after an unsuccessful stream. Partial replies remain in history; a production UI should distinguish interrupted responses and offer an explicit retry. Changing the selector sends the existing conversation to the newly selected model on the next request.
Layout and Styles
Use this complete app/layout.tsx, followed by app/globals.css. The styling keeps long output inside the message area and the form usable on narrow screens.
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = { title: "Multi-Model Chat", description: "A streaming Next.js chat app." };
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return <html lang="en"><body>{children}</body></html>; }
:root { color-scheme: light; font-family: Arial, sans-serif; background: #f5f5f5; color: #202020; } * { box-sizing: border-box; } body { margin: 0; } button, select, textarea { font: inherit; }
.shell { min-height: 100vh; padding: 32px 16px; } .chat { width: min(820px, 100%); margin: auto; } header label { display: flex; align-items: center; gap: 12px; } select { min-width: 0; max-width: 100%; }
.messages { min-height: 360px; display: grid; align-content: start; gap: 12px; margin: 24px 0; } .messages article { min-width: 0; max-width: 85%; padding: 12px 14px; border-radius: 8px; white-space: pre-wrap; overflow-wrap: anywhere; } .user { justify-self: end; background: #dceee4; } .assistant { justify-self: start; background: #e6e6e6; }
form { display: grid; gap: 12px; } textarea { width: 100%; min-width: 0; resize: vertical; padding: 12px; } button { justify-self: end; padding: 10px 18px; background: #235c40; color: white; border: 0; border-radius: 4px; } button:disabled { opacity: .6; } .error { color: #a51d2d; overflow-wrap: anywhere; }
Verify the Route Before Deploying
Run npm run dev, open http://localhost:3000, and test each selector option. I also test the backend directly so browser rendering cannot hide a routing problem:
curl -N http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"model":"gemini-3.7-flash","messages":[{"role":"user","content":"Explain model routing in two sentences."}]}'
A successful response contains records shaped like data: {"choices":[{"delta":{"content":"Model"}}]}, followed by more deltas and data: [DONE]. Exact text and IDs vary. A 401 points to a missing or invalid key; a 404 can indicate a base URL without /v1; an unsupported-model 400 means the requested ID failed the backend allowlist. If text arrives all at once, inspect host or proxy buffering.
For deployment, configure both environment variables on the server, then run npm run build followed by npm run start. Use a streaming-capable Node.js deployment: a static export cannot execute this Route Handler. The response headers request no caching or transformation, but they do not guarantee every intermediary will stream.
What I Would Gate Before Launch
Authenticate application users and rate-limit by user and IP before exposing a credit-spending endpoint. Bound concurrent streams and requests per minute as well as message size and count. Preserve disconnect cancellation, add an application timeout, redact prompts and credentials from logs, and track model, request ID, latency, token usage, and user ID with appropriate spending caps.
Validate configured IDs against GET https://api.cometapi.com/api/models during deployment; reject routes that are unavailable, upcoming, or missing chat-completions support. Do not mask 400 or 401 configuration errors with automatic fallback. Restrict retries to explicitly retryable failures and compatible request schemas; a fallback chain can move from the primary route to a secondary model and then an official provider.
The same account and key can also access native image and video APIs, including Flux and Kling, but those are separate integrations from this text-only handler. I would keep the chat contract narrow until authentication, cost controls, failure recovery, and per-model compatibility tests are in place.
Top comments (0)