A provider-neutral AI layer with OpenAI, Gemini and Claude fallbacks
Build one TypeScript interface over the official OpenAI, Gemini and Claude SDKs, with capability checks, retries, fallbacks, shared schemas, tool calling and cost routing.
Updated 24 min read

In this article
Depending on one AI provider is a business risk: outages, rate limits, price changes, model retirements and new regional rules all land on your product at once. Calling three providers from every feature is a maintenance problem instead. The middle path is a thin provider-neutral layer: one interface your features call, one adapter per provider built on its official SDK, and a router that picks a model, retries sensibly and falls back when a provider fails. This tutorial builds that layer in TypeScript, including structured output and tool calls, and shows where the providers genuinely differ so you know what the abstraction can and cannot hide.
Tested with: Node.js 24.19.0, TypeScript 7.0.2, @anthropic-ai/sdk 0.124.0, openai 7.10.0, @google/genai 2.21.0 and Zod 4.5.4. The adapters were type-checked against those SDKs and run against a local mock server that returns each API's documented response and error shapes; run your own contract tests against the live APIs before production.
Build, adopt or route through a gateway?
You do not have to write this layer yourself. Vercel's AI SDK (the ai package, Apache-2.0, version 7 at the time of writing) already offers one TypeScript API over many providers, and hosted or self-hosted gateways put an OpenAI-style endpoint in front of several vendors. Both are good choices when they cover the features you need. Write your own thin layer when you want the official SDKs and each provider's newest features on day one, need your own retry, budget and audit rules in one place, or have a small, stable set of operations. In PHP, the same trade-off is discussed in Laravel AI SDK vs calling OpenAI, Gemini and Claude directly.
One shortcut to avoid: pointing the OpenAI SDK at another provider's compatibility endpoint and calling that provider-neutral. Anthropic's own OpenAI SDK compatibility page says the layer "is primarily intended to test and compare model capabilities, and is not considered a long-term or production-ready solution for most use cases", and lists fields that are ignored, including response_format and the strict flag on tools.
Where the providers really differ
| Claude (Messages API) | OpenAI (Responses API) | Gemini (generateContent) | |
|---|---|---|---|
| System prompt | system | instructions | config.systemInstruction |
| Tool definition | tools[].input_schema | tools[] with type: 'function', parameters, strict | functionDeclarations[].parametersJsonSchema |
| Tool call returned | tool_use block, input is an object | function_call item, arguments is a JSON string | functionCall part, args object, id may be absent |
| Tool result sent back | tool_result in a user message | function_call_output item | functionResponse part |
| Structured output | output_config.format with json_schema | text.format with json_schema, name, strict | responseMimeType plus responseJsonSchema |
| Input tokens | input_tokens excludes cache reads and writes | input_tokens includes cached tokens | promptTokenCount; thinking billed as output via thoughtsTokenCount |
| Refusal or block | stop_reason: 'refusal' | refusal content part | finishReason such as SAFETY, or no candidates |
Two consequences shape the design. Your neutral types must be the intersection of what you use, not the union of everything each API can do. And JSON Schema support differs in detail: Anthropic's structured outputs, for example, do not support minLength or maxLength, so business rules belong in your own validation, as explained in get valid JSON from LLMs every time with structured outputs.
Step 1: the neutral contract
Keep the interface small: a request with system text, messages, optional tools, an optional output schema and a token limit; a result with text, tool calls, a normalised finish reason and usage. Errors become one LlmError with a kind the router can act on.
// The provider-neutral contract the rest of the application codes against.
export type JsonSchema = Record<string, unknown>;
export type ToolDef = { name: string; description: string; parameters: JsonSchema };
export type ToolCall = { id: string; name: string; args: Record<string, unknown> };
export type Msg =
| { role: 'user'; text: string }
| { role: 'assistant'; text?: string; toolCalls?: ToolCall[] }
| { role: 'tool'; callId: string; name: string; result: string };
export type ChatRequest = {
system: string;
messages: Msg[];
tools?: ToolDef[];
output?: { name: string; schema: JsonSchema }; // ask for JSON matching this schema
maxOutputTokens: number;
signal?: AbortSignal;
};
export type FinishReason = 'stop' | 'length' | 'tool_calls' | 'refusal' | 'other';
export type ChatResult = {
provider: string; model: string; text: string; toolCalls: ToolCall[];
finishReason: FinishReason; usage: { input: number; output: number; cachedInput: number };
};
export interface ChatModel {
readonly provider: string;
readonly model: string;
readonly supports: { tools: boolean; jsonSchema: boolean; contextTokens: number };
readonly price: { input: number; output: number }; // USD per million tokens
chat(req: ChatRequest): Promise<ChatResult>;
}
// One error type with a kind the router can act on, whatever the provider said.
export type ErrorKind = 'rate_limit' | 'overloaded' | 'server' | 'timeout' | 'network'
| 'spend_limit' | 'auth' | 'bad_request' | 'invalid_output' | 'refusal';
export class LlmError extends Error {
readonly kind: ErrorKind;
readonly provider: string;
readonly status?: number;
readonly retryAfterMs?: number;
constructor(kind: ErrorKind, provider: string, message: string, opts: { status?: number; retryAfterMs?: number; cause?: unknown } = {}) {
super(message, { cause: opts.cause });
this.name = 'LlmError';
this.kind = kind;
this.provider = provider;
this.status = opts.status;
this.retryAfterMs = opts.retryAfterMs;
}
get retryable() { return ['rate_limit', 'overloaded', 'server', 'timeout', 'network'].includes(this.kind); }
// Fail over on provider trouble and on output we could not use; never on our own bad request or auth.
get failover() { return this.retryable || this.kind === 'invalid_output' || this.kind === 'refusal' || this.kind === 'spend_limit'; }
}
export function classifyHttp(provider: string, status: number | undefined, message: string, code?: string, retryAfter?: string | null, cause?: unknown): LlmError {
const retryAfterMs = retryAfter && !Number.isNaN(Number(retryAfter)) ? Number(retryAfter) * 1000 : undefined;
const make = (k: ErrorKind) => new LlmError(k, provider, message, { status, retryAfterMs, cause });
if ((code && /spend_limit|usage_limit/.test(code)) || /reached your (specified )?(workspace )?API usage limits/i.test(message)) return make('spend_limit');
if (status === 402) return make('spend_limit');
if (status === 429) return make('rate_limit');
if (status === 529 || status === 503) return make('overloaded');
if (status === 408) return make('timeout');
if (status !== undefined && status >= 500) return make('server');
if (status === 401 || status === 403) return make('auth');
if (status !== undefined && status >= 400) return make('bad_request');
return make('network');
}Note what is missing on purpose: temperature, top-p, provider-specific extras. Sampling parameters behave differently across providers and model generations, and some newer models reject them, so features should not depend on them through the neutral layer. If a feature truly needs a provider-only capability, call that adapter directly and mark the feature as not portable.
Step 2: one adapter per provider
Each adapter converts the neutral request to the provider's format with its official SDK, converts the response back, and maps errors. All three disable the SDK's own retries (maxRetries: 0) because the router owns retries; leaving both on multiplies attempts and hides the real failure rate.
import Anthropic from '@anthropic-ai/sdk';
import { classifyHttp, LlmError, type ChatModel, type ChatRequest, type ChatResult, type FinishReason } from './llm.ts';
export class ClaudeModel implements ChatModel {
readonly provider = 'anthropic';
readonly supports = { tools: true, jsonSchema: true, contextTokens: 200_000 };
readonly model: string;
readonly price: { input: number; output: number };
private client: Anthropic;
constructor(model: string, price: { input: number; output: number }, opts: { baseURL?: string; timeoutMs?: number } = {}) {
this.model = model;
this.price = price;
// maxRetries: 0 because the router owns retries; otherwise SDK retries multiply with ours.
this.client = new Anthropic({ maxRetries: 0, timeout: opts.timeoutMs ?? 30_000, baseURL: opts.baseURL });
}
async chat(req: ChatRequest): Promise<ChatResult> {
const messages: Anthropic.MessageParam[] = req.messages.map((m): Anthropic.MessageParam => {
if (m.role === 'user') return { role: 'user', content: m.text };
if (m.role === 'tool') return { role: 'user', content: [{ type: 'tool_result', tool_use_id: m.callId, content: m.result }] };
const blocks: Anthropic.ContentBlockParam[] = [];
if (m.text) blocks.push({ type: 'text', text: m.text });
for (const c of m.toolCalls ?? []) blocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.args });
return { role: 'assistant', content: blocks };
});
try {
const r = await this.client.messages.create({
model: this.model,
max_tokens: req.maxOutputTokens,
system: req.system,
messages,
tools: req.tools?.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters as Anthropic.Tool.InputSchema })),
...(req.output ? { output_config: { format: { type: 'json_schema' as const, schema: req.output.schema } } } : {}),
}, { signal: req.signal });
const stop: Record<string, FinishReason> = { end_turn: 'stop', stop_sequence: 'stop', max_tokens: 'length', tool_use: 'tool_calls', refusal: 'refusal' };
return {
provider: this.provider, model: r.model,
text: r.content.flatMap((b) => (b.type === 'text' ? [b.text] : [])).join(''),
toolCalls: r.content.flatMap((b) => (b.type === 'tool_use' ? [{ id: b.id, name: b.name, args: b.input as Record<string, unknown> }] : [])),
finishReason: stop[r.stop_reason ?? ''] ?? 'other',
// Anthropic's input_tokens excludes cache reads and writes; add them for a comparable total.
usage: {
input: r.usage.input_tokens + (r.usage.cache_read_input_tokens ?? 0) + (r.usage.cache_creation_input_tokens ?? 0),
output: r.usage.output_tokens, cachedInput: r.usage.cache_read_input_tokens ?? 0,
},
};
} catch (e) {
if (e instanceof Anthropic.APIConnectionTimeoutError) throw new LlmError('timeout', this.provider, e.message, { cause: e });
if (e instanceof Anthropic.APIUserAbortError) throw new LlmError('timeout', this.provider, 'aborted by deadline', { cause: e });
if (e instanceof Anthropic.APIError) throw classifyHttp(this.provider, e.status, e.message, undefined, e.headers?.get('retry-after'), e);
throw e;
}
}
}import OpenAI from 'openai';
import { classifyHttp, LlmError, type ChatModel, type ChatRequest, type ChatResult } from './llm.ts';
export class OpenAIModel implements ChatModel {
readonly provider = 'openai';
readonly supports = { tools: true, jsonSchema: true, contextTokens: 400_000 };
readonly model: string;
readonly price: { input: number; output: number };
private client: OpenAI;
constructor(model: string, price: { input: number; output: number }, opts: { baseURL?: string; timeoutMs?: number } = {}) {
this.model = model;
this.price = price;
this.client = new OpenAI({ maxRetries: 0, timeout: opts.timeoutMs ?? 30_000, baseURL: opts.baseURL });
}
async chat(req: ChatRequest): Promise<ChatResult> {
const input: OpenAI.Responses.ResponseInputItem[] = req.messages.flatMap((m): OpenAI.Responses.ResponseInputItem[] => {
if (m.role === 'user') return [{ role: 'user', content: m.text }];
if (m.role === 'tool') return [{ type: 'function_call_output', call_id: m.callId, output: m.result }];
return [
...(m.text ? [{ role: 'assistant' as const, content: m.text }] : []),
...(m.toolCalls ?? []).map((c) => ({ type: 'function_call' as const, call_id: c.id, name: c.name, arguments: JSON.stringify(c.args) })),
];
});
try {
const r = await this.client.responses.create({
model: this.model,
instructions: req.system,
input,
max_output_tokens: req.maxOutputTokens,
store: false,
tools: req.tools?.map((t) => ({ type: 'function' as const, name: t.name, description: t.description, parameters: t.parameters, strict: false })),
...(req.output ? { text: { format: { type: 'json_schema' as const, name: req.output.name, schema: req.output.schema, strict: true } } } : {}),
}, { signal: req.signal });
let text = '';
let refused = false;
const toolCalls = [];
for (const item of r.output) {
if (item.type === 'message') for (const c of item.content) {
if (c.type === 'output_text') text += c.text;
if (c.type === 'refusal') refused = true;
}
if (item.type === 'function_call') toolCalls.push({ id: item.call_id, name: item.name, args: JSON.parse(item.arguments) as Record<string, unknown> });
}
const finishReason = refused ? 'refusal' : r.status === 'incomplete' && r.incomplete_details?.reason === 'max_output_tokens' ? 'length'
: toolCalls.length ? 'tool_calls' : 'stop';
return {
provider: this.provider, model: r.model, text, toolCalls, finishReason,
usage: { input: r.usage?.input_tokens ?? 0, output: r.usage?.output_tokens ?? 0, cachedInput: r.usage?.input_tokens_details?.cached_tokens ?? 0 },
};
} catch (e) {
if (e instanceof OpenAI.APIConnectionTimeoutError) throw new LlmError('timeout', this.provider, e.message, { cause: e });
if (e instanceof OpenAI.APIUserAbortError) throw new LlmError('timeout', this.provider, 'aborted by deadline', { cause: e });
if (e instanceof OpenAI.APIError) throw classifyHttp(this.provider, e.status, e.message, e.code ?? undefined, e.headers?.get('retry-after'), e);
throw e;
}
}
}import { GoogleGenAI, ApiError, FinishReason as GFinish, type Content } from '@google/genai';
import { classifyHttp, LlmError, type ChatModel, type ChatRequest, type ChatResult, type FinishReason } from './llm.ts';
export class GeminiModel implements ChatModel {
readonly provider = 'gcp.gemini';
readonly supports = { tools: true, jsonSchema: true, contextTokens: 1_000_000 };
readonly model: string;
readonly price: { input: number; output: number };
private ai: GoogleGenAI;
private timeoutMs: number;
constructor(model: string, price: { input: number; output: number }, opts: { baseURL?: string; timeoutMs?: number } = {}) {
this.model = model;
this.price = price;
this.timeoutMs = opts.timeoutMs ?? 30_000;
this.ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY, httpOptions: { baseUrl: opts.baseURL, timeout: this.timeoutMs } });
}
async chat(req: ChatRequest): Promise<ChatResult> {
const contents: Content[] = req.messages.map((m): Content => {
if (m.role === 'user') return { role: 'user', parts: [{ text: m.text }] };
if (m.role === 'tool') return { role: 'user', parts: [{ functionResponse: { id: m.callId, name: m.name, response: { result: m.result } } }] };
return { role: 'model', parts: [
...(m.text ? [{ text: m.text }] : []),
...(m.toolCalls ?? []).map((c) => ({ functionCall: { id: c.id, name: c.name, args: c.args } })),
] };
});
try {
const r = await this.ai.models.generateContent({
model: this.model,
contents,
config: {
systemInstruction: req.system,
maxOutputTokens: req.maxOutputTokens,
abortSignal: req.signal,
tools: req.tools ? [{ functionDeclarations: req.tools.map((t) => ({ name: t.name, description: t.description, parametersJsonSchema: t.parameters })) }] : undefined,
...(req.output ? { responseMimeType: 'application/json', responseJsonSchema: req.output.schema } : {}),
},
});
const calls = r.functionCalls ?? [];
const fr = r.candidates?.[0]?.finishReason;
const blocked = !r.candidates?.length || fr === GFinish.SAFETY || fr === GFinish.PROHIBITED_CONTENT || fr === GFinish.BLOCKLIST;
const finishReason: FinishReason = blocked ? 'refusal' : fr === GFinish.MAX_TOKENS ? 'length' : calls.length ? 'tool_calls' : fr === GFinish.STOP ? 'stop' : 'other';
return {
provider: this.provider, model: r.modelVersion ?? this.model,
// Join text parts ourselves: the r.text getter logs a warning when the reply also has function calls.
text: blocked ? '' : (r.candidates?.[0]?.content?.parts ?? []).flatMap((p) => (p.text && !p.thought ? [p.text] : [])).join(''),
toolCalls: calls.map((c, i) => ({ id: c.id ?? `call_${i}`, name: c.name ?? '', args: (c.args ?? {}) as Record<string, unknown> })),
finishReason,
usage: { input: r.usageMetadata?.promptTokenCount ?? 0, output: (r.usageMetadata?.candidatesTokenCount ?? 0) + (r.usageMetadata?.thoughtsTokenCount ?? 0), cachedInput: r.usageMetadata?.cachedContentTokenCount ?? 0 },
};
} catch (e) {
if (e instanceof ApiError) throw classifyHttp(this.provider, e.status, e.message, undefined, undefined, e);
if (e instanceof Error && e.name === 'AbortError') throw new LlmError('timeout', this.provider, 'aborted by deadline', { cause: e });
throw new LlmError('network', this.provider, (e as Error).message, { cause: e });
}
}
}Details that came up while testing:
- Usage is not comparable by default. The Claude adapter adds cache reads and writes to
input_tokens; the Gemini adapter addsthoughtsTokenCountto output, because Gemini bills thinking as output. Without this, cost comparisons between providers are wrong. - The Gemini
textgetter warns on mixed replies. Readingresponse.textwhen the reply contains function calls logged "there are non-text parts functionCall in the response, returning concatenation of all text parts", so the adapter joins text parts itself. - Tool-call IDs. Gemini may omit the call ID, so the adapter generates one; keep it for the tool result.
- OpenAI arguments are a string. Parse them, and treat a parse failure as invalid output rather than crashing.
Step 3: the router
The router filters models by capability (tools, JSON schema, context size), orders them by estimated cost or by an explicit preference, and walks the list. Within one model it retries temporary errors with exponential backoff and jitter, honouring retry-after. It fails over on provider trouble, spend limits, refusals and output that fails validation. It stops immediately on bad requests and authentication errors, because those are your bugs and another provider would only hide them.
import { z } from 'zod';
import { LlmError, type ChatModel, type ChatRequest, type ChatResult } from './llm.ts';
type Policy = { attemptsPerModel: number; baseDelayMs: number; maxDelayMs: number; deadlineMs: number; breakerFailures: number; breakerCooldownMs: number };
const DEFAULTS: Policy = { attemptsPerModel: 2, baseDelayMs: 250, maxDelayMs: 4_000, deadlineMs: 45_000, breakerFailures: 5, breakerCooldownMs: 30_000 };
export type Attempt = { provider: string; model: string; outcome: string; ms: number };
export class Router {
private models: ChatModel[];
private policy: Policy;
private breaker = new Map<string, { failures: number; openUntil: number }>();
readonly log: Attempt[] = [];
constructor(models: ChatModel[], policy: Partial<Policy> = {}) {
this.models = models;
this.policy = { ...DEFAULTS, ...policy };
}
// Cheapest first among models that can do the job; pass `prefer` to pin a primary instead.
private candidates(req: ChatRequest, estInputTokens: number, prefer?: string): ChatModel[] {
const able = this.models.filter((m) => (!req.tools || m.supports.tools) && (!req.output || m.supports.jsonSchema)
&& m.supports.contextTokens >= estInputTokens + req.maxOutputTokens);
const cost = (m: ChatModel) => estInputTokens * m.price.input + req.maxOutputTokens * m.price.output;
return able.sort((a, b) => (a.model === prefer ? -1 : b.model === prefer ? 1 : cost(a) - cost(b)));
}
async chat<T = unknown>(req: ChatRequest, opts: { estInputTokens: number; prefer?: string; validate?: z.ZodType<T> }): Promise<{ result: ChatResult; data?: T }> {
const deadline = AbortSignal.timeout(this.policy.deadlineMs); // one budget for the whole chain
let last: unknown;
for (const m of this.candidates(req, opts.estInputTokens, opts.prefer)) {
const key = `${m.provider}/${m.model}`;
const b = this.breaker.get(key);
if (b && b.openUntil > Date.now()) { this.log.push({ provider: m.provider, model: m.model, outcome: 'skipped (circuit open)', ms: 0 }); continue; }
for (let attempt = 1; attempt <= this.policy.attemptsPerModel; attempt++) {
const started = Date.now();
try {
const result = await m.chat({ ...req, signal: deadline });
if (result.finishReason === 'refusal') throw new LlmError('refusal', m.provider, 'model declined');
let data: T | undefined;
if (opts.validate) {
// Validate in our layer for every provider, even those with constrained decoding.
let parsed: unknown;
try { parsed = JSON.parse(result.text); } catch { throw new LlmError('invalid_output', m.provider, 'not JSON'); }
const v = opts.validate.safeParse(parsed);
if (!v.success) throw new LlmError('invalid_output', m.provider, v.error.issues.map((i) => i.message).join('; '));
data = v.data;
}
this.breaker.delete(key);
this.log.push({ provider: m.provider, model: m.model, outcome: 'ok', ms: Date.now() - started });
return { result, data };
} catch (e) {
last = e;
const err = e instanceof LlmError ? e : new LlmError('network', m.provider, String(e));
this.log.push({ provider: m.provider, model: m.model, outcome: `${err.kind}${err.status ? ` ${err.status}` : ''}`, ms: Date.now() - started });
if (deadline.aborted) throw err;
if (!err.failover) throw err; // our bug or auth problem: stop, do not hide it
if (err.retryable) this.recordFailure(key);
if (!err.retryable || attempt === this.policy.attemptsPerModel) break; // move to the next model
await sleep(backoff(attempt, this.policy, err.retryAfterMs), deadline);
}
}
}
throw last instanceof Error ? last : new Error('no model available for this request');
}
private recordFailure(key: string) {
const b = this.breaker.get(key) ?? { failures: 0, openUntil: 0 };
b.failures++;
if (b.failures >= this.policy.breakerFailures) { b.openUntil = Date.now() + this.policy.breakerCooldownMs; b.failures = 0; }
this.breaker.set(key, b);
}
}
// Exponential backoff with full jitter, honouring retry-after when the provider sends it.
function backoff(attempt: number, p: Policy, retryAfterMs?: number) {
if (retryAfterMs !== undefined) return Math.min(retryAfterMs, p.maxDelayMs);
return Math.random() * Math.min(p.maxDelayMs, p.baseDelayMs * 2 ** (attempt - 1));
}
const sleep = (ms: number, signal: AbortSignal) => new Promise<void>((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason); }, { once: true });
});- One deadline for the chain. A single
AbortSignal.timeout()covers every attempt, so a fallback cannot turn a 30-second wait into 90 seconds. - A small circuit breaker. After five consecutive temporary failures a model is skipped for 30 seconds, so users do not each pay the timeout of a provider that is clearly down.
- Validation in your layer. Even with constrained decoding, check the JSON against your Zod schema; a schema mismatch is a reason to fail over, not to show the user broken data.

Step 4: run it
The demo defines the ticket schema once with Zod. The wire schema sent to providers uses only features all three accept (objects, required fields, enums, strings, additionalProperties: false) and drops Zod's $schema URL; the validation schema adds the 120-character summary limit. A local server plays the three APIs and can be told to return 529, 400 or a spend-limit 429 next.
import { z } from 'zod';
import { startMock, queue, seen } from './mock.ts';
import { ClaudeModel } from './anthropic.ts';
import { OpenAIModel } from './openai.ts';
import { GeminiModel } from './gemini.ts';
import { Router } from './router.ts';
import { LlmError, type ChatRequest } from './llm.ts';
const PORT = Number(process.env.MOCK_PORT ?? 4747);
const server = await startMock(PORT);
const base = `http://127.0.0.1:${PORT}`;
process.env.ANTHROPIC_API_KEY ??= 'test'; process.env.OPENAI_API_KEY ??= 'test'; process.env.GEMINI_API_KEY ??= 'test';
const claude = new ClaudeModel('claude-sonnet-4-6', { input: 3, output: 15 }, { baseURL: `${base}/anthropic` });
const gpt = new OpenAIModel('gpt-5-mini', { input: 0.25, output: 2 }, { baseURL: `${base}/openai/v1` });
const gemini = new GeminiModel('gemini-2.5-flash', { input: 0.3, output: 2.5 }, { baseURL: `${base}/gemini` });
const router = new Router([claude, gpt, gemini], { baseDelayMs: 50 });
// The wire schema uses only features all three providers accept (objects, required, enums, strings,
// additionalProperties: false). Business rules such as a length limit live in the validation schema.
const TicketWire = z.strictObject({
category: z.enum(['billing', 'delivery', 'technical', 'other']),
urgency: z.enum(['low', 'normal', 'high']),
summary: z.string(),
});
const Ticket = TicketWire.extend({ summary: z.string().max(120) });
const wire = (schema: z.ZodType) => {
const { $schema, ...rest } = z.toJSONSchema(schema) as Record<string, unknown>; // drop the meta-schema URL
return rest;
};
const req: ChatRequest = {
system: 'Classify the support ticket.', maxOutputTokens: 300,
messages: [{ role: 'user', text: 'I was charged twice for my August invoice, please fix this today.' }],
output: { name: 'ticket', schema: wire(TicketWire) },
};
const run = async (label: string, prefer?: string) => {
router.log.length = 0;
try {
const { result, data } = await router.chat(req, { estInputTokens: 800, prefer, validate: Ticket });
console.log(`\n# ${label}\n answered by ${result.provider}/${result.model}: ${JSON.stringify(data)}`);
} catch (e) {
const err = e as LlmError;
console.log(`\n# ${label}\n failed: ${err.kind ?? ''} ${err.message}`);
}
for (const a of router.log) console.log(` ${a.provider}/${a.model} -> ${a.outcome}`);
};
await run('A. no preference: cheapest capable model first');
queue.anthropic.push({ status: 529, error: { type: 'error', error: { type: 'overloaded_error', message: 'Overloaded' } } },
{ status: 529, error: { type: 'error', error: { type: 'overloaded_error', message: 'Overloaded' } } });
queue.openai.push({ body: { id: 'resp_02', object: 'response', created_at: 1757203200, status: 'completed', model: 'gpt-5-mini-2025-08-07', incomplete_details: null,
output: [{ type: 'message', id: 'm2', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: '{"category":"billing","urgency":"extreme","summary":"x"}', annotations: [] }] }],
usage: { input_tokens: 790, output_tokens: 20, total_tokens: 810, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } } } });
await run('B. prefer Claude: overloaded twice, then invalid output, then Gemini', 'claude-sonnet-4-6');
queue.anthropic.push({ status: 400, error: { type: 'error', error: { type: 'invalid_request_error', message: 'messages: text content blocks must be non-empty' } } });
await run('C. prefer Claude: our own bad request is not failed over', 'claude-sonnet-4-6');
queue.openai.push({ status: 429, error: { error: { message: 'Project spend limit reached', type: 'insufficient_quota', code: 'project_spend_limit_exceeded', param: null } } });
await run('D. OpenAI project spend limit: no retry, straight to the next provider');
// The same tool call, as each provider returns it, normalised to one shape.
queue.anthropic.push({ body: { id: 'msg_t', type: 'message', role: 'assistant', model: 'claude-sonnet-4-6', stop_reason: 'tool_use', stop_sequence: null,
content: [{ type: 'tool_use', id: 'toolu_01', name: 'lookup_invoice', input: { month: '2026-08' } }], usage: { input_tokens: 500, output_tokens: 40 } } });
queue.openai.push({ body: { id: 'resp_t', object: 'response', created_at: 1757203200, status: 'completed', model: 'gpt-5-mini-2025-08-07', incomplete_details: null,
output: [{ type: 'function_call', id: 'fc_1', call_id: 'call_abc', name: 'lookup_invoice', arguments: '{"month":"2026-08"}', status: 'completed' }],
usage: { input_tokens: 480, output_tokens: 22, total_tokens: 502, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } } } });
queue.gemini.push({ body: { candidates: [{ content: { role: 'model', parts: [{ functionCall: { name: 'lookup_invoice', args: { month: '2026-08' } } }] }, finishReason: 'STOP', index: 0 }],
usageMetadata: { promptTokenCount: 470, candidatesTokenCount: 12, totalTokenCount: 482 } } });
const toolReq: ChatRequest = { system: 'Help with billing.', maxOutputTokens: 300, messages: [{ role: 'user', text: 'What did I pay in August?' }],
tools: [{ name: 'lookup_invoice', description: 'Find the invoice for a month', parameters: { type: 'object', properties: { month: { type: 'string' } }, required: ['month'], additionalProperties: false } }] };
console.log('\n# E. one tool call, three providers');
for (const m of [claude, gpt, gemini]) {
const r = await m.chat(toolReq);
console.log(` ${m.provider.padEnd(10)} finish=${r.finishReason} calls=${JSON.stringify(r.toolCalls)}`);
}
Scenario A picks GPT-5 mini because it is the cheapest capable model for an 800-token input with up to 300 output tokens: (800 x $0.25 + 300 x $2) / 1,000,000 = $0.0008 per request, against $0.00099 for Gemini 2.5 Flash ($0.30 / $2.50) and $0.0069 for Claude Sonnet 4.6 ($3 / $15). Scenario B prefers Claude, gets two 529 overloaded responses, moves to OpenAI, rejects an urgency of "extreme" that is not in the enum, and is answered by Gemini. Scenario C stops on a 400. Scenario D treats OpenAI's project_spend_limit_exceeded 429 as a spend limit, not a rate limit, and moves on without retrying. The final lines confirm what each adapter actually sent: output_config.format, text.format with strict: true, and responseJsonSchema.
Cost routing without losing quality
Cheapest-first is only safe among models that pass your quality bar for that task. Build an evaluation set per feature, run every candidate model through it, and keep an allowlist of models per feature; the router then orders only within that list. LLM evals: a practical guide covers building that set. Record the provider, model, tokens and cost of every call, as described in AI agent observability, and put the router behind the per-tenant ledger from token budgets and cost limits so a fallback to a dearer model is still within budget.
Fallbacks also change behaviour. A different model may follow your prompt differently, refuse different things, or format answers differently. Test the fallback path in your evaluation, not just the primary, and decide per feature whether a fallback is acceptable at all: a contract-drafting feature may prefer a clear "try again later" to a different model's draft.
If your product already calls one provider directly from many places and you want to add a second without rewriting every feature, Cloudsoft Zone's API integration team can introduce this layer behind your existing code.
Testing the layer
- Unit tests with fakes for the router: retry counts, failover order, the deadline and the circuit breaker, using adapters that throw chosen
LlmErrorkinds. - Adapter tests against a mock server, as in the demo, using response bodies copied from each provider's documentation; they catch mapping mistakes without spending money.
- Nightly contract tests against the live APIs with a tiny prompt per provider, covering plain text, a tool call and structured output. SDK upgrades and API changes show up here first.
- Evaluation runs for quality on every model in a feature's allowlist, including fallbacks.
Rolling out model changes
A neutral layer makes switching models easy, which makes it easy to switch them carelessly. Keep the model list per feature in configuration with an owner and a review date, pin exact model IDs or dated snapshots where the provider offers them, and watch the providers' deprecation pages, because a retired model turns into a 404 overnight. When you add or change a model, send it a small share of real traffic first, or run it in shadow mode where its answers are recorded and compared but not shown, and only then move it up the list. Streaming fits the same design: give the interface a second method that yields text deltas and a final result, and fail over only before the first token reaches the user, because switching models halfway through an answer produces nonsense.
Security and data handling
- Keep every API key server-side, one key per environment and per provider, with each provider's own spend limit as a backstop.
- Failover sends the same data to a different company. Check that each provider in a feature's list is covered by your contracts, data processing agreements and regional requirements (for example GDPR transfers for EU personal data) before adding it.
- Log provider and model per request so you can answer "where did this customer's data go?"
- Tool calls still go through your own authorisation; the neutral layer only normalises their shape.
Common mistakes
- Retries in two places. SDK retries plus router retries multiply attempts and latency.
- Failing over on 400s. It hides bugs and sends malformed requests to every provider in turn.
- Retrying spend-limit errors. They share 429 with rate limits on OpenAI; check the error code.
- A lowest-common-denominator schema that is too low. Validate rich rules in Zod instead of dropping them.
- Comparing raw token counts. Normalise cached and thinking tokens first.
- Exposing provider types to features. Once a feature imports an SDK type, it is no longer portable.
When not to build a neutral layer
- One provider, low risk. If an outage only delays non-urgent work, a queue with retries is simpler.
- Heavy use of provider-only features. Server-side tools, provider-hosted agents, file stores and vendor-specific caching do not abstract well; use that provider's SDK directly.
- An existing library already fits. If the AI SDK or a gateway covers your features and policies, adopt it rather than maintaining your own.
Cloudsoft Zone builds API integrations that keep AI features running across providers: adapters on the official SDKs, retry and fallback policies, budgets and monitoring. See our API development and integration service.
Frequently asked questions
Should I use an OpenAI-compatible endpoint for every provider?
Only for testing. Anthropic describes its compatibility layer as not production-ready for most uses and ignores fields such as
response_formatand toolstrict. Use each provider's native API through an adapter.Which errors should trigger a fallback to another provider?
Rate limits and overload after a retry or two, server errors, timeouts, spend limits, refusals and output that fails validation. Not 400, 401 or 403, which point to a problem in your own request or credentials.
Why turn off the SDKs' built-in retries?
So there is exactly one retry policy. SDK retries plus router retries multiply attempts and make one deadline impossible to enforce.
Is cheapest-first routing safe?
Only among models that pass your evaluation for that feature. Keep a per-feature allowlist and let cost decide the order within it.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- OpenAI SDK compatibility — Anthropic
- Structured outputs (Claude API) — Anthropic
- anthropic-sdk-typescript — Anthropic
- openai-node — OpenAI
- Google Gen AI SDK for TypeScript and JavaScript — Google
- Spend limits — OpenAI
- Gemini Developer API pricing — Google AI for Developers
- AI SDK — Vercel
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
Related tools
- Directory profile
Claude
Anthropic's AI assistant for chat, writing, research, coding and file analysis, available on web, desktop and mobile and through a developer API.
This article discusses Claude. Read the directory profile alongside the guide and verify current product details with its official sources.
- Directory profile
Google Gemini
Google's AI assistant for chat, research, image and video generation and document analysis, with a developer API for the Gemini models.
This article discusses Google Gemini. Read the directory profile alongside the guide and verify current product details with its official sources.
More from Cloudsoft Zone

Architecture guideAI and Automation
How to build a safe AI content pipeline with human fact review
An architecture for AI-assisted publishing where people stay responsible: untrusted source snapshots, claim checking with citations, a review queue AI cannot skip, labels and rollback.
By Cloudsoft Zone

TutorialNode.js
How to test a Node.js API with the built-in test runner (node:test)
Write, run, mock and measure coverage for a small Node.js HTTP API using only node:test and node:assert. Covers port-0 servers, fetch(), spies, t.mock.method(), fake dates and coverage thresholds.
By Cloudsoft Zone

TutorialNode.js
Node.js streams in practice: pipeline(), backpressure and async generators
Process files of any size in Node.js with flat memory use. A hands-on guide to stream/promises pipeline(), backpressure, async generator transforms, AbortSignal timeouts and streaming downloads over HTTP.
By Cloudsoft Zone
