Provider Adapters and the Adapter Pattern

9 min read Module 2 of 10 Lesson 6 of 28

What you'll learn

  • Explain the adapter pattern's role in a gateway: one canonical shape in, N provider shapes out
  • Translate a canonical chat request into Anthropic's Messages API shape, including the system-prompt and max_tokens differences
  • Translate an Anthropic response and its usage block back into the canonical OpenAI-shaped response
  • Identify what a thin pass-through adapter looks like for an already-compatible backend versus a full-translation adapter
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

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.

Knowledge Check

3 questions to test your understanding

1 In Anthropic's Messages API, where does the system prompt go, and how does this differ from the OpenAI schema an adapter receives?

2 Anthropic's Messages API requires `max_tokens` as a mandatory parameter, while OpenAI's `max_tokens` (or newer `max_completion_tokens`) is optional. What must a canonical-to-Anthropic adapter do if the incoming gateway request omits it?

3 What is the practical benefit of writing one Anthropic adapter inside the gateway, versus every application team writing their own OpenAI-to-Anthropic translation when they want to call Claude?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan