Lesson 5 showed that many backends, Groq, Ollama, vLLM, OpenRouter, already speak the OpenAI-compatible shape, which makes their adapters nearly a pass-through. Anthropic does not, its Messages API is a genuinely different schema, and translating between them is the clearest possible illustration of what a provider adapter actually does. This lesson builds that adapter for real.
The Two Schemas, Side by Side
Canonical request (what the gateway receives from a client):
{
"model": "claude-sonnet-4-5",
"messages": [
{"role": "system", "content": "You are a concise support assistant."},
{"role": "user", "content": "My order hasn't arrived in 10 days."}
],
"max_tokens": 300
}
What Anthropic’s Messages API actually expects, system pulled out as a top-level field, not a message:
{
"model": "claude-sonnet-4-5",
"system": "You are a concise support assistant.",
"messages": [
{"role": "user", "content": "My order hasn't arrived in 10 days."}
],
"max_tokens": 300
}
max_tokens happens to be present in this example, but it is worth stressing: Anthropic’s API requires it on every call, while the canonical schema, following OpenAI’s convention, treats it as optional. A correct adapter has to supply a default when a caller omits it, or every Anthropic-routed request from a caller that didn’t set max_tokens would fail with a provider-side 400 that has nothing to do with anything the caller actually did wrong.
Building the Adapter
from dataclasses import dataclass
from anthropic import Anthropic
DEFAULT_MAX_TOKENS = 1024
@dataclass
class CanonicalRequest:
model: str
messages: list[dict]
max_tokens: int | None = None
temperature: float | None = None
class AnthropicAdapter:
"""Translates the gateway's canonical (OpenAI-shaped) request into
Anthropic's Messages API, and Anthropic's response back into the
canonical (OpenAI-shaped) response every caller of the gateway expects."""
def __init__(self, api_key: str):
self._client = Anthropic(api_key=api_key)
def call(self, req: CanonicalRequest) -> dict:
system_prompt = None
chat_messages = []
for m in req.messages:
if m["role"] == "system":
# Anthropic wants this as a top-level field, not a message
system_prompt = m["content"]
else:
chat_messages.append(m)
response = self._client.messages.create(
model=req.model,
system=system_prompt,
messages=chat_messages,
max_tokens=req.max_tokens or DEFAULT_MAX_TOKENS, # Anthropic requires this field
temperature=req.temperature if req.temperature is not None else 1.0,
)
return self._to_canonical_response(response)
def _to_canonical_response(self, response) -> dict:
# Anthropic returns content as a list of blocks; a plain-text reply
# is block[0].text. This normalizes it back to OpenAI's flat
# choices[0].message.content shape.
return {
"id": response.id,
"object": "chat.completion",
"model": response.model,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": response.content[0].text},
"finish_reason": "stop" if response.stop_reason == "end_turn" else response.stop_reason,
}],
"usage": {
"prompt_tokens": response.usage.input_tokens,
"completion_tokens": response.usage.output_tokens,
"total_tokens": response.usage.input_tokens + response.usage.output_tokens,
},
}
Two translations happen here, one on the way in and one on the way out. Going in: the system-role message is extracted from the messages list and moved to Anthropic’s separate system parameter, and a missing max_tokens is defaulted rather than left to fail at Anthropic. Going out: Anthropic’s content list of blocks is flattened to a single string at choices[0].message.content, and its usage.input_tokens/output_tokens fields are renamed to the canonical prompt_tokens/completion_tokens so that every downstream layer, cost calculation, observability, caching, works identically regardless of which adapter produced the response.
Adapters at the Two Ends of the Spectrum
flowchart LR
Canon["Canonical Request\n(OpenAI-shaped)"] --> ThinAdapter["Thin adapter\n(Groq, vLLM, Ollama, OpenRouter)\nswap base_url + key only"]
Canon --> FullAdapter["Full adapter\n(Anthropic, Gemini)\nreshape request + response"]
ThinAdapter --> P1["Provider\n(already OpenAI-compatible)"]
FullAdapter --> P2["Provider\n(native schema)"]
style Canon fill:#4338CA,stroke:#4338CA,color:#ffffff,rx:8px,ry:8px
style ThinAdapter fill:#EEF0F7,stroke:#6366F1,color:#0F172A,rx:8px,ry:8px
style FullAdapter fill:#EEF0F7,stroke:#6366F1,color:#0F172A,rx:8px,ry:8px
style P1 fill:#F7F8FC,stroke:#0D9488,color:#0F172A,rx:8px,ry:8px
style P2 fill:#F7F8FC,stroke:#0D9488,color:#0F172A,rx:8px,ry:8px
Every provider in a gateway’s model list falls somewhere on this spectrum: a thin adapter is a handful of lines (swap the base URL, inject the real key, normalize error codes); a full adapter, like the one above, has real translation logic with real edge cases worth unit-testing. This is exactly the abstraction LiteLLM’s provider list implements internally for over a hundred providers, and it is what you configure, rather than write, starting in the next module. With the request lifecycle (Lesson 4), the canonical schema (Lesson 5), and the adapter pattern (this lesson) covered, Module 3 puts all three to work building a real, running gateway with LiteLLM.