n4nAI

Building a model-agnostic chat app with GPT-5 and Claude

Hands-on tutorial: build a model-agnostic chat app with GPT-5 and Claude via one OpenAI-compatible endpoint. Runnable Node.js code, prerequisites, and output.

n4n Team3 min read572 words

Audio narration

Coming soon — every post will get a voice note here.

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) and dotenv
  • 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).
  • usage logged 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.

Tagschat-appgpt-5claude-opus-4-8model-agnostic

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All integrating gpt-5, claude opus 4.8, gemini 3, llama 4 & more via one api posts →