Most teams shipping AI features on the web end up with the same stack: a Node.js or TypeScript backend, often Next.js, that forwards user input to a model and streams the answer back to the browser. Provider docs cover their own models well, but a real project immediately raises three questions they do not answer together: which SDK to use when you need more than one provider, how to stream through your own server, and how to keep long generations from timing out.
This guide answers all three with a single API key. We start with the smallest working call for Claude, GPT and Gemini, then build up to a streaming Next.js route and production-ready timeout and retry settings. If gateways are new to you, what an AI API gateway is explains why one key can reach several providers. If you are moving existing code off a single vendor, pair this with the OpenAI-compatible migration guide.
What You Need
- An API key from the Zivv console. Keep it in an environment variable, never in the repo.
- A base URL, which depends on the protocol you call.
- Model IDs exactly as listed in the Model Hub or returned by
GET /v1/models. Examples here useclaude-sonnet-5,gpt-5.5andgemini-3.5-flash.
| Protocol | Base URL | Node.js SDK | Use it when |
|---|---|---|---|
| OpenAI-compatible | https://zivv.pro/v1 | openai | You want one code path for every model |
| Anthropic native | https://zivv.pro | @anthropic-ai/sdk | You need Claude-native features such as thinking or native tool blocks |
| Gemini-compatible | https://zivv.pro/v1beta | Google GenAI SDK | You already have Gemini-native code |
The pragmatic default: use the openai SDK for everything and bring in @anthropic-ai/sdk only for modules that genuinely need Claude's native message format. Your business logic then deals with one message shape, and switching models is a string change.
Install the SDKs on Node.js 18 or newer, which ships fetch and Web Streams:
npm install openai @anthropic-ai/sdk dotenvOne Client, Three Providers
The official openai package accepts a custom baseURL. Point it at Zivv and the same client reaches Claude, GPT and Gemini:
import "dotenv/config";
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.ZIVV_API_KEY,
baseURL: "https://zivv.pro/v1",
});
for (const model of ["claude-sonnet-5", "gpt-5.5", "gemini-3.5-flash"]) {
const res = await client.chat.completions.create({
model,
messages: [{ role: "user", content: "Explain the event loop in one sentence." }],
});
console.log(model, "=>", res.choices[0].message.content);
console.log("usage:", res.usage);
}Use an .mts file or "type": "module" in package.json for top-level await. The response shape matches OpenAI's exactly, and res.usage gives you prompt and completion tokens for cost tracking.
Claude's Native API with @anthropic-ai/sdk
When you want Claude's own format, with a top-level system field, content blocks and native tool use, use the Anthropic SDK. Note the base URL has no /v1 suffix; the SDK appends /v1/messages itself:
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic({
apiKey: process.env.ZIVV_API_KEY,
baseURL: "https://zivv.pro",
});
const msg = await anthropic.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
system: "You are a senior Node.js engineer.",
messages: [{ role: "user", content: "Express or Fastify for a new API?" }],
});
for (const block of msg.content) {
if (block.type === "text") console.log(block.text);
}Two differences trip people up. max_tokens is required in the Anthropic protocol, and content is an array of blocks, so text comes from blocks where type === "text". Before sending a very large context you can call /v1/messages/count_tokens to size it, then estimate cost with the token cost guide.
Streaming
Streaming gets the first token on screen faster and sidesteps most read timeouts on long outputs. Both SDKs make it short:
// openai: stream: true returns an async iterable of chunks
const stream = await client.chat.completions.create({
model: "gpt-5.5",
messages: [{ role: "user", content: "Show how to read a large file in Node.js" }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
// @anthropic-ai/sdk: event helpers plus the assembled final message
const claudeStream = anthropic.messages.stream({
model: "claude-sonnet-5",
max_tokens: 2048,
messages: [{ role: "user", content: "Explain backpressure in Node streams" }],
});
claudeStream.on("text", (text) => process.stdout.write(text));
const finalMsg = await claudeStream.finalMessage();
console.log("\nusage:", finalMsg.usage);Keep the optional chaining on the OpenAI side: some chunks carry no content (role headers, the final chunk), and an unguarded property access throws. The wire format underneath, how to detect a truncated stream, and why nginx can make streams look frozen are covered in the SSE streaming guide.
Proxying a Stream Through a Next.js Route
The usual production shape is browser to your Next.js server to Zivv, so the key never leaves the server. An App Router handler can return a ReadableStream directly:
// app/api/chat/route.ts
import OpenAI from "openai";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const client = new OpenAI({
apiKey: process.env.ZIVV_API_KEY,
baseURL: "https://zivv.pro/v1",
});
const ALLOWED = new Set(["claude-sonnet-5", "gpt-5.5", "gemini-3.5-flash"]);
export async function POST(req: Request) {
const { messages, model = "claude-sonnet-5" } = await req.json();
if (!ALLOWED.has(model)) return new Response("model not allowed", { status: 400 });
const upstream = await client.chat.completions.create(
{ model, messages, stream: true },
{ signal: req.signal } // cancel upstream when the user leaves
);
const encoder = new TextEncoder();
const body = new ReadableStream({
async start(controller) {
try {
for await (const chunk of upstream) {
const text = chunk.choices[0]?.delta?.content;
if (text) controller.enqueue(encoder.encode(text));
}
controller.close();
} catch (err) {
controller.error(err);
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
}The details that matter:
- Allowlist models on the server. Letting the client pick any model ID invites someone to run your budget through the most expensive one.
- Forward `req.signal`. When the user hits stop or closes the tab, the upstream request is cancelled instead of generating tokens nobody reads.
- Send `X-Accel-Buffering: no`. It tells nginx not to buffer this response; without it users see nothing for seconds, then everything at once.
- Forward plain text for simple chat UIs. If you also need tool calls or usage on the client, define your own SSE or NDJSON events instead.
On the client, read the body incrementally:
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
appendToUI(decoder.decode(value, { stream: true }));
}The { stream: true } flag keeps multi-byte characters intact when they straddle two chunks; without it, non-ASCII text occasionally renders as garbage.
Timeouts and Retries
Both SDKs ship with timeouts and automatic retries with exponential backoff. The defaults are reasonable, but tune them to your workload:
| Setting | openai | @anthropic-ai/sdk | Recommendation |
|---|---|---|---|
| Timeout | timeout (ms) | timeout (ms) | 300000+ for long non-streaming outputs |
| Retries | maxRetries (default 2) | maxRetries (default 2) | 2 to 4 |
| Per request | second argument { timeout, maxRetries } | same | Loosen for batch jobs only |
| Cancellation | signal | signal | Tie to the request or job lifecycle |
const client = new OpenAI({
apiKey: process.env.ZIVV_API_KEY,
baseURL: "https://zivv.pro/v1",
timeout: 300_000,
maxRetries: 3,
});
await client.chat.completions.create(
{ model: "claude-opus-4-8", messages, max_tokens: 16000 },
{ timeout: 600_000, maxRetries: 4 }
);A few rules of thumb:
- Do not retry 400, 401, 402, 403 or 404. They describe the request, not the network. See the fixes for 401 invalid keys, 402 insufficient balance and 404 model not found.
- Give 529 more room. Upstream overload can last minutes; the 529 and timeout guide covers backoff and fallback.
- Retry streams only before the first byte. Retrying after half an answer reached the user duplicates text; offer a regenerate button instead.
- Check your platform limit. Serverless functions often have a maximum duration that will kill a request before your SDK timeout does.
A Small Wrapper Worth Having
Centralize model choice so the rest of the codebase asks for a tier, not a model name:
const TIERS = {
fast: "gemini-3.5-flash",
default: "claude-sonnet-5",
strong: "claude-opus-4-8",
} as const;
export async function chat(
tier: keyof typeof TIERS,
messages: OpenAI.Chat.ChatCompletionMessageParam[]
) {
const res = await client.chat.completions.create({ model: TIERS[tier], messages });
return { text: res.choices[0].message.content ?? "", usage: res.usage };
}Swapping a model, running an A/B comparison or routing by difficulty becomes a one-line change. The multi-model routing guide goes deeper on tiering.
FAQ
Should I call Claude from Node.js with the openai SDK or @anthropic-ai/sdk? For ordinary chat and one code path across providers, use openai with https://zivv.pro/v1. For Claude-native message structure, thinking or native tool blocks, use @anthropic-ai/sdk with https://zivv.pro. The same key works for both.
Can I call the API directly from browser JavaScript? Not safely. A key in client code is a public key. Proxy through your server as shown above, and give each app its own key with a budget; see team keys and budgets.
Does Zivv support embeddings? No. Zivv covers chat completions, messages, responses, images and moderation; the full list is in the endpoint reference. For RAG, use a separate embeddings provider and send generation through Zivv.
Is streaming billed differently from non-streaming? No. Both are billed on actual input and output tokens at the group multiplier times the official model price. Streaming changes delivery, not token count.
Which Node.js version do I need? Node.js 18 or newer, for native fetch, ReadableStream and TextDecoder.
Next Steps
- Create a Zivv account and a development key.
- Run the three-provider snippet to confirm every model answers.
- Drop the Next.js route and client reader into your app.
- Tune timeouts and retries, then pick model tiers in the Model Hub.
The OpenAI SDK docs have further configuration details.