Writing an Adapter Layer to Isolate Provider-Specific Code
10 min read · updated August 11, 2026
The reason a provider swap turns into a two-week project is almost never the model. It is that the provider’s request shape has leaked into forty call sites, its error classes into the retry code, its streaming event names into the frontend, and its usage field names into the billing table. An adapter fixes that, but only if you are honest about the parts that genuinely do not map.
Where the boundary goes
The instinct is to wrap the SDK client — a thin object with a createChatCompletion method that forwards its argument. That buys nothing, because the argument is still the provider’s request object. The boundary has to sit at the level of your vocabulary, not theirs: a prompt, a set of tools, a budget, and a result. Everything from the wire format inward belongs on the far side of it.
The practical test is a grep. After the adapter exists, searching your repository for the vendor’s package name, for max_completion_tokens, for finish_reason, for stop_reason, for cache_read_input_tokens should return hits in exactly one directory. If it returns hits in your HTTP handlers, your evaluation harness or your logging middleware, the abstraction is decorative.
There is a second, quieter test. Ask what a caller has to know in order to use the adapter correctly. If the answer includes “you have to pass a system prompt as the first message on one provider and as a separate field on the other”, the boundary is in the wrong place: that is a wire detail and it should have been consumed by the implementation.
The interface
Four types carry almost all of the weight. Keep them small; a large interface is one that has absorbed a provider’s options object by accretion.
// adapter/types.ts
export type Role = "system" | "user" | "assistant" | "tool";
export interface Message {
role: Role;
content: string | ContentPart[];
/** Only on role: "tool" — the id of the call this answers. */
toolCallId?: string;
}
export type ContentPart =
| { kind: "text"; text: string }
| { kind: "image"; mediaType: string; dataBase64: string };
export interface ToolDef {
name: string;
description: string;
/** Plain JSON Schema. The adapter dialects it per provider. */
parameters: Record<string, unknown>;
}
export interface ChatRequest {
messages: Message[];
tools?: ToolDef[];
maxOutputTokens: number;
/** Stable across a conversation. Used for cache affinity. */
cacheKey?: string;
stream?: boolean;
}
export interface ChatResult {
text: string;
toolCalls: { id: string; name: string; args: unknown }[];
/** Normalised. "length" always means the output cap was hit. */
stop: "end" | "length" | "tool_call" | "filtered" | "other";
usage: { inputTokens: number; outputTokens: number; cachedInputTokens: number };
/** The provider's own value, for logs. Never branched on by callers. */
rawStop: string;
}
export interface ChatProvider {
readonly id: string;
chat(req: ChatRequest): Promise<ChatResult>;
}Two decisions in there are load-bearing. First, maxOutputTokens is required rather than optional. One major provider requires an output cap on every request and the other treats it as optional with an implicit default; making it required in your interface means the difference cannot express itself as a surprise. Second, rawStop exists alongside the normalised stop. Callers branch on the normalised value; incident response reads the raw one. Throwing the raw value away is the mistake you discover six months later at 3am.
A third decision is visible only by omission: there is no extra or providerOptions escape hatch on ChatRequest. It is tempting to add one so a caller can pass a provider-specific parameter through without waiting for the interface to grow. Resist it for as long as you can. The moment call sites start populating that field, the boundary stops being a boundary: a caller that sets a key only one provider understands is coupled to that provider just as tightly as if it had imported the SDK, but now the coupling is invisible to a grep for the vendor’s name. When a capability genuinely matters to callers, add it to the interface with a defined meaning for every provider — including “this one ignores it” — and let the type system carry the fact rather than a bag of strings.
One concrete implementation
The implementation’s job is translation, in both directions. Notice how much of it is not parameter renaming but structural rearrangement — the system prompt moving out of the message array, the tool schema being rewritten, the stop value being folded into a smaller set.
// adapter/providers/anthropic.ts
import Anthropic from "@anthropic-ai/sdk";
import type { ChatProvider, ChatRequest, ChatResult } from "../types";
const client = new Anthropic();
const PROVIDER_ID = "anthropic";
export const anthropicProvider: ChatProvider = {
id: PROVIDER_ID,
async chat(req: ChatRequest): Promise<ChatResult> {
// 1. System prompt is a top-level field here, not a message role.
const system = req.messages
.filter((m) => m.role === "system")
.map((m) => (typeof m.content === "string" ? m.content : ""))
.join("\n\n");
const messages = req.messages
.filter((m) => m.role !== "system")
.map(toWireMessage);
const res = await client.messages.create({
model: process.env.ANTHROPIC_MODEL!,
max_tokens: req.maxOutputTokens, // required on this API
system: system || undefined,
messages,
tools: req.tools?.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.parameters, // note: not "parameters"
})),
});
const text = res.content
.filter((b) => b.type === "text")
.map((b) => (b as { text: string }).text)
.join("");
return {
text,
toolCalls: res.content
.filter((b) => b.type === "tool_use")
.map((b) => {
const t = b as { id: string; name: string; input: unknown };
return { id: t.id, name: t.name, args: t.input };
}),
stop: normaliseStop(res.stop_reason),
rawStop: String(res.stop_reason),
usage: {
inputTokens: res.usage.input_tokens,
outputTokens: res.usage.output_tokens,
cachedInputTokens: res.usage.cache_read_input_tokens ?? 0,
},
};
},
};
function normaliseStop(s: string | null): ChatResult["stop"] {
switch (s) {
case "end_turn":
case "stop_sequence":
return "end";
case "max_tokens":
return "length";
case "tool_use":
return "tool_call";
case "refusal":
return "filtered";
default:
return "other";
}
}The equivalent implementation for an OpenAI-shaped API keeps the system prompt in the message array, sends tool schemas under parameters inside a function wrapper, reads text from choices[0].message.content, and normalises finish_reason values (stop, length, tool_calls, content_filter) into the same five-member union. Two files, one interface. The rest of your codebase never learns that either exists.
Two translations in there are worth naming, because they are the ones people forget. Tool schemas live under different keys and inside different wrappers, so a tool definition is not portable as a literal — only the JSON Schema inside it is, and even that is subject to per-provider dialect restrictions. And usage accounting is three-way rather than two-way: cached input tokens are billed differently from fresh input tokens, so a usage struct with only two fields will quietly overstate your costs on one provider and understate them on the other.
What the adapter must not hide
An adapter that claims full equivalence is worse than none, because it converts loud failures into quiet ones. Four things should stay visible.
- The context window. Do not silently truncate to fit a smaller target. Expose the window as a property on the provider and let the caller decide — see auditing your real prompt sizes first.
- Caching semantics. One provider wants explicit breakpoints; another matches prefixes automatically. An adapter can expose a
cacheKeyhint, but it cannot make a prompt cacheable that structurally is not. - Schema strictness. A schema that one provider accepts and another rejects is a real difference, and the honest adapter surfaces the rejection rather than quietly stripping the offending keyword.
- Retryability. Two providers can return the same status code for different underlying causes. Classification belongs in the provider file, not in shared retry code.
Doing it in an existing codebase
- Grep for the provider’s package import and list every file. That list is the scope of the work, and it is usually shorter than feared — most call sites do the same three things.
- Write
types.tsfrom the shapes those call sites actually use, not from the provider’s documentation. Fields nobody passes do not belong in the interface. - Implement the provider you are already on, and change one call site to use it. Run that path in production before touching the rest.
- Migrate the remaining call sites mechanically. Do not improve anything while you do this; a rename plus a behaviour change is unbisectable.
- Add a lint rule or CI grep that fails if the provider package is imported outside
adapter/providers/. Without it the boundary erodes within a quarter. - Only now write the second implementation, and validate it against the same call sites rather than against the docs.