A model-agnostic chat app gpt-5 claude support should let you swap underlying models without touching request shapes or auth logic. This tutorial builds exactly that: a small Node.js client that talks to a single OpenAI-compatible endpoint and dispatches to either GPT-5 or Claude Opus 4.8 based on a runtime parameter.
Prerequisites
- Node.js 18 or newer
- An API key from a gateway that exposes GPT-5 and Claude Opus 4.8 through the OpenAI chat completions interface. One such gateway is n4n.ai, which fronts 240+ models behind one endpoint and handles provider fallback automatically.
- The official OpenAI Node SDK (
openai) anddotenv - Basic comfort with async/await and terminal work
Project setup
Create the project and install dependencies:
mkdir model-agnostic-chat && cd model-agnostic-chat
npm init -y
npm install openai dotenv
Store credentials in .env. The base URL is the only gateway-specific piece:
GATEWAY_API_KEY=sk-your-key
GATEWAY_BASE_URL=https://api.n4n.ai/v1
If you use a different OpenAI-compatible proxy, change the URL. The application code does not change.
Initialize the client
The OpenAI SDK speaks the wire format both GPT-5 and Claude accept when proxied through a compatible gateway. Configure it once:
import OpenAI from 'openai';
import dotenv from 'dotenv';
dotenv.config();
const client = new OpenAI({
apiKey: process.env.GATEWAY_API_KEY,
baseURL: process.env.GATEWAY_BASE_URL,
timeout: 30_000,
});
Building the chat function
A model-agnostic chat app gpt-5 claude capability reduces to a single function that takes a model id and a message list. No conditionals on provider.
async function chat(model, messages, opts = {}) {
const response = await client.chat.completions.create({
model,
messages,
temperature: opts.temperature ?? 0.7,
max_tokens: opts.max_tokens ?? 512,
});
return response.choices[0].message.content;
}
model is just a string: "gpt-5" or "claude-opus-4-8". The gateway routes it to the correct upstream.
Interactive REPL loop
Wire a minimal terminal chat to test both models from the same binary:
import readline from 'node:readline/promises';
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
async function main() {
const model = process.argv[2] || 'gpt-5';
console.log(`Chatting with ${model}. Type 'exit' to quit.`);
const history = [];
while (true) {
const userInput = await rl.question('you> ');
if (userInput === 'exit') break;
history.push({ role: 'user', content: userInput });
const reply = await chat(model, history);
history.push({ role: 'assistant', content: reply });
console.log(`bot> ${reply}`);
}
rl.close();
}
main();
Run with GPT-5:
node chat.js gpt-5
Expected output:
Chatting with gpt-5. Type 'exit' to quit.
you> What is the capital of France?
bot> The capital of France is Paris.
you> exit
Run with Claude:
node chat.js claude-opus-4-8
Same code path, different model string. That is the entire abstraction.
Streaming responses
A chat UI needs tokens as they arrive. The SDK supports stream: true uniformly:
async function* streamChat(model, messages) {
const stream = await client.chat.completions.create({
model, messages, stream: true,
});
for await (const chunk of stream) {
yield chunk.choices[0]?.delta?.content || '';
}
}
Drop it into the REPL:
process.stdout.write('bot> ');
for await (const token of streamChat(model, history)) {
process.stdout.write(token);
}
console.log();
Streaming works identically for GPT-5 and Claude Opus 4.8 because the chunk shape is normalized by the gateway.
Exposing an HTTP endpoint
For a web frontend, wrap the function in Express:
npm install express
import express from 'express';
const app = express();
app.use(express.json());
app.post('/chat', async (req, res) => {
const { model = 'gpt-5', messages } = req.body;
if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages required' });
try {
const reply = await chat(model, messages);
res.json({ reply });
} catch (err) {
res.status(502).json({ error: err.message });
}
});
app.listen(3000, () => console.log('Listening on :3000'));
Test with curl:
curl -s localhost:3000/chat -H 'content-type: application/json' \
-d '{"model":"claude-opus-4-8","messages":[{"role":"user","content":"Hi"}]}'
Expected output:
{ "reply": "Hello! How can I help you today?" }
Honoring cache and routing hints
Production gateways forward provider cache-control and routing directives. To enable Claude prompt caching, pass the hint through extra headers:
await client.chat.completions.create({
model: 'claude-opus-4-8',
messages,
extra_headers: { 'x-cache-control': 'ephemeral' },
});
The gateway forwards that to Anthropic. If you need strict model pinning and want to opt out of automatic provider failover, send a client routing directive via header or body field as your gateway specifies.
Error handling and fallback
A robust model-agnostic chat app gpt-5 claude design assumes upstream degradation. Because the gateway already performs automatic fallback when a provider is rate-limited, your code mostly needs retry logic:
async function chatWithRetry(model, messages, retries = 2) {
for (let i = 0; i <= retries; i++) {
try {
return await chat(model, messages);
} catch (err) {
if (i === retries) throw err;
await new Promise(r => setTimeout(r, 300 * (i + 1)));
}
}
}
Manual cross-model fallback is a few lines:
const fallback = model === 'gpt-5' ? 'claude-opus-4-8' : 'gpt-5';
try {
return await chat(model, messages);
} catch {
return await chat(fallback, messages);
}
Extending to Gemini, Llama, and more
The interface is uniform. To support Gemini 3 or Llama 4, pass "gemini-3" or "llama-4" as the model argument. The gateway maps those to the correct provider. Per-token usage metering arrives in the standard usage field:
const res = await client.chat.completions.create({ model, messages });
console.log(res.usage);
// { prompt_tokens: 12, completion_tokens: 34, total_tokens: 46 }
You get cost visibility without custom provider parsers.
Why this beats direct provider SDKs
Direct Anthropic and OpenAI SDKs force two auth flows, two response shapes, and two retry strategies. A single OpenAI-compatible client collapses that to one. The model-agnostic chat app gpt-5 claude pattern scales to any model the gateway supports, and you keep the freedom to switch without code changes or dependency swaps.
Pre-ship checklist
- Keys in env, never hardcoded.
- Client timeout set (
client.timeout). usagelogged for metering.- User input length validated against
max_tokens. - Both models exercised from CLI before UI work.
- Streaming path tested for interrupted connections.
That is the whole app: a thin client that treats GPT-5 and Claude as interchangeable strings, with the freedom to add any other model later.