Index Ai Academy
Phase 1 · Building with LLMs · Quality & production

One app, several model providers

Two API formats side by side, adapters to one internal format, a gateway with fallbacks, and evals before switching.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Few production apps stay on one model forever. A new model wins your eval, prices change, a provider has an outage on launch day, a customer needs their data kept in one region, or a small local model handles the private cases. Each provider's API looks a little different, and an app with one provider's format scattered through its code is painful to move. The fix is old software engineering: your app speaks one internal format, small adapters translate it to and from each provider, and a gateway decides which provider handles each request. This lesson writes them for the Messages API you've been using and for the chat-completions format that many other providers and local model servers share (here, a simulated second provider on the practice API).

What you'll be able to do

  • Compare two common request and response formats for model APIs, field by field
  • Write adapters that translate one internal format to and from each provider, including tool calls
  • Route requests through a gateway that falls back to another provider on errors worth retrying elsewhere
  • Rerun your evals on every provider before switching, instead of assuming prompts transfer

What it covers

  1. Same question, two formats
  2. One internal shape, two adapters
  3. Read a chat-completions reply
  4. What adapters can't hide
  5. Rerun the evals before you switch
Quick check
An app has one provider's request format scattered through 40 files, and a better model appears from another provider. What makes switching easy next time?
  1. Copying every file and editing each one for the new provider
  2. Asking both providers to agree on one shared format
  3. One small adapter layer that turns your format into each provider's
  4. Waiting for your provider to release a better model
Show the answer

One small adapter layer that turns your format into each provider's

If your app speaks one format of its own, and a thin adapter turns it into each provider's, a switch touches one file instead of forty. This lesson builds that adapter.

Same question, two formats

Both formats do the same job, with different shapes:

blankMessages APIchat completions
system prompta top-level system fieldthe first message, with "role": "system"
a tool{"name", "description", "input_schema"}{"type": "function", "function": {"name", "description", "parameters"}}
reply textcontent: a list of blocks, the text ones with "type": "text"choices[0].message.content: a string, or null
a tool calla tool_use block with input as an objectmessage.tool_calls, with function.arguments as a JSON string
a tool resulta user message holding tool_result blocksone message per result, with "role": "tool" and tool_call_id
why it stoppedstop_reason: end_turn, tool_use, max_tokensfinish_reason: stop, tool_calls, length
tokens usedusage.input_tokens, usage.output_tokensusage.prompt_tokens, usage.completion_tokens

Here's one question with one tool, sent to both. (Without an API key both replies are simulated; with one, the first is a real model, and the second is always the practice server's simulated provider.)

import json
import requests

SCHEMA = {"type": "object", "properties": {"trailhead": {"type": "string"}}, "required": ["trailhead"]}
question = "Will Fern Hollow be dry on Saturday morning? Check the forecast."

a = requests.post("https://api.anthropic.com/v1/messages",
    headers={"x-api-key": "added-by-the-academy-server", "anthropic-version": "2026-10-21"},
    json={"model": "claude-haiku-5-5", "max_tokens": 300, "system": "Use the tools.",
          "tools": [{"name": "forecast", "description": "Saturday's forecast for a trailhead.", "input_schema": SCHEMA}],
          "messages": [{"role": "user", "content": question}]}, timeout=90).json()

b = requests.post("https://api.gradient.test/v1/chat/completions",
    json={"model": "practice-chat", "max_tokens": 300,
          "tools": [{"type": "function", "function": {"name": "forecast", "description": "Saturday's forecast for a trailhead.", "parameters": SCHEMA}}],
          "messages": [{"role": "system", "content": "Use the tools."}, {"role": "user", "content": question}]}, timeout=90).json()

call_a = next((block for block in a["content"] if block["type"] == "tool_use"), None)
call_b = (b["choices"][0]["message"].get("tool_calls") or [None])[0]
print("Messages API:    ", a["stop_reason"], "|", call_a and call_a["input"], "|", a["usage"]["input_tokens"], "tokens in")
print("chat completions:", b["choices"][0]["finish_reason"], "|", call_b and call_b["function"]["arguments"], "|", b["usage"]["prompt_tokens"], "tokens in")
print(type(call_a["input"]).__name__ if call_a else "-", "vs", type(call_b["function"]["arguments"]).__name__ if call_b else "-")
It runs right here in your browser, with nothing to install.

The last line is the difference that bites most often: the tool's input arrives as a dictionary from one API and as a string of JSON from the other, which your code must parse (and which a model can occasionally get wrong).

Quick check
A chat-completions tool call arrives like this. What does the code print?
import json
call = {"id": "call_1", "type": "function", "function": {"name": "forecast", "arguments": '{"trailhead": "fern-hollow"}'}}
args = call["function"]["arguments"]
print(type(args).__name__, json.loads(args)["trailhead"])
  1. str trailhead
  2. str fern-hollow
  3. dict fern-hollow
  4. An error: arguments isn't a dictionary
Show the answer

str fern-hollow

arguments is a string holding JSON, so its type is str. json.loads turns it into a dictionary, and then ["trailhead"] gives fern-hollow. Indexing the string directly would fail, which is exactly the bug an adapter prevents.

One internal shape, two adapters

Pick a shape your app uses everywhere, independent of any provider. A reply is text, a list of tool calls, why it stopped, and token counts; a tool call is an id, a name and a dictionary of arguments. The conversation history is a list of plain dictionaries: user turns, assistant turns (with any tool calls), and tool results.

history = [
    {"role": "user", "text": "Will Fern Hollow be dry on Saturday?"},
    {"role": "assistant", "text": "", "tool_calls": [{"id": "c1", "name": "forecast", "args": {"trailhead": "fern-hollow"}}]},
    {"role": "tool", "id": "c1", "result": "rain chance 10% before noon"},
]

Then each provider gets two functions: one that turns the internal request into that provider's JSON, and one that turns its response back into a reply. Nothing else in the app ever sees a provider's format.

import json

def from_messages(data):
    calls = [{"id": b["id"], "name": b["name"], "args": b["input"]} for b in data["content"] if b["type"] == "tool_use"]
    return {"text": "".join(b["text"] for b in data["content"] if b["type"] == "text"),
            "tool_calls": calls,
            "stop": {"end_turn": "done", "tool_use": "tool", "max_tokens": "length"}.get(data["stop_reason"], data["stop_reason"]),
            "tokens": (data["usage"]["input_tokens"], data["usage"]["output_tokens"])}

def to_messages(system, history, tools, max_tokens=500):
    messages = []
    for turn in history:
        if turn["role"] == "user":
            messages.append({"role": "user", "content": turn["text"]})
        elif turn["role"] == "assistant":
            blocks = [{"type": "text", "text": turn["text"]}] if turn["text"] else []
            blocks += [{"type": "tool_use", "id": c["id"], "name": c["name"], "input": c["args"]} for c in turn.get("tool_calls", [])]
            messages.append({"role": "assistant", "content": blocks})
        else:   # tool results travel inside one user message
            result = {"type": "tool_result", "tool_use_id": turn["id"], "content": turn["result"]}
            if messages and messages[-1]["role"] == "user" and isinstance(messages[-1]["content"], list):
                messages[-1]["content"].append(result)
            else:
                messages.append({"role": "user", "content": [result]})
    return {"system": system, "messages": messages, "max_tokens": max_tokens,
            "tools": [{"name": t["name"], "description": t["description"], "input_schema": t["schema"]} for t in tools]}

reply = {"content": [{"type": "text", "text": "Checking."}, {"type": "tool_use", "id": "toolu_1", "name": "forecast", "input": {"trailhead": "fern-hollow"}}],
         "stop_reason": "tool_use", "usage": {"input_tokens": 120, "output_tokens": 18}}
print(from_messages(reply))
history = [{"role": "user", "text": "Dry on Saturday?"},
           {"role": "assistant", "text": "", "tool_calls": [{"id": "toolu_1", "name": "forecast", "args": {"trailhead": "fern-hollow"}}]},
           {"role": "tool", "id": "toolu_1", "result": "rain chance 10%"}]
print(json.dumps(to_messages("Use the tools.", history, [])["messages"], indent=1)[:420])

The internal reply says "stop": "tool", not tool_use or tool_calls: the app's loop checks one word, whatever the provider. A reason the adapter doesn't recognize, such as a refusal, passes through unchanged rather than pretending to be done. Notice the tool result: in this format it rides inside a user message, so several results in a row share one message.

Drill

Read a chat-completions reply

Write from_chat(data), the twin of from_messages for the chat-completions format. Return a dictionary with "text" (the message's content, or "" if it's null), "tool_calls" (a list of {"id", "name", "args"} with args parsed from the JSON string), "stop" ("done" for stop, "tool" for tool_calls, "length" for length, and any other reason unchanged) and "tokens" (a tuple of prompt and completion tokens). If a call's arguments aren't valid JSON, raise ValueError with a message that names the tool.

import json


def from_chat(data):
    message = data["choices"][0]["message"]
    return {"text": message["content"], "tool_calls": message.get("tool_calls", []), "stop": data["choices"][0]["finish_reason"], "tokens": None}


reply = {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": [
            {"id": "call_1", "type": "function", "function": {"name": "forecast", "arguments": '{"trailhead": "fern-hollow"}'}}]},
          "finish_reason": "tool_calls"}],
         "usage": {"prompt_tokens": 120, "completion_tokens": 18, "total_tokens": 138}}
print(from_chat(reply))
In the lesson, your code is checked as you write it.

What adapters can't hide

Formats are the easy part. Some features exist on one provider and not the other, or work differently: prompt-caching markers, thinking, citations from documents, structured-output modes. Even parameter names drift: some newer chat-completions models expect a "developer" message instead of "system", and max_completion_tokens instead of max_tokens. For each, an adapter has three honest choices. Map it when the other provider has an equivalent. Leave it out when it's only an optimization: a cache marker the other side doesn't understand costs money, not correctness. Raise an error when dropping it would change the answer: a request that needs citations shouldn't quietly come back without any. Never drop something silently.

Streaming differs too. One API sends typed events (a block starts, text arrives, the block stops); the other sends chunks carrying small pieces of text or of a tool call's arguments. A streaming adapter turns both into the same internal events, such as "text arrived" and "tool call finished", so the screen code doesn't care. And model names belong to providers: keep one table that maps your app's own names, like "fast" and "careful", to each provider's current model ids, so trying a new model is a one-line change.

A gateway that falls back

With adapters in place, a gateway is a short function: try the first provider, and if it fails in a way another provider might not, try the next. Which failures count? A timeout, a rate limit (429) or an overloaded or broken server (500 and up) are about that provider right now: worth trying elsewhere. Other 4xx codes, such as 400, 401 or 413, mean your request is wrong (its format, its key, its size), and the next provider will most likely reject it too, so raise it and fix the bug. One exception is worth routing: a 400 because the conversation is too long for this model's context window is a reason to send it to a model with a bigger one. (Retrying the same provider once or twice, with a growing wait between tries, fits inside each provider's attempt.)

import time

class ProviderError(Exception):
    def __init__(self, status, message):
        super().__init__(f"{status}: {message}")
        self.status = status

def complete(request, providers):
    """providers: a list of (name, send) pairs; send(request) returns an internal reply or raises."""
    errors = []
    for name, send in providers:
        start = time.perf_counter()
        try:
            reply = send(request)
        except ProviderError as e:
            if 400 <= e.status < 500 and e.status != 429:
                raise                       # our request is wrong: another provider won't fix it
            errors.append(f"{name}: {e}")
            continue
        except TimeoutError:                # send() turns requests' own Timeout into TimeoutError
            errors.append(f"{name}: timed out")
            continue
        return {**reply, "provider": name, "seconds": round(time.perf_counter() - start, 3), "skipped": errors}
    raise RuntimeError("every provider failed: " + "; ".join(errors))

def overloaded(request):
    raise ProviderError(529, "overloaded")

def backup(request):
    return {"text": "Dry until noon.", "tool_calls": [], "stop": "done", "tokens": (60, 6)}

print(complete({"text": "Dry Saturday?"}, [("primary", overloaded), ("backup", backup)]))

Every reply records which provider answered and what was skipped, so your logs show how often the fallback ran. A gateway is also where routing lives: send short, easy requests to a cheaper model and long documents to one with a bigger context window, as the cost lesson suggested.

Try it now

Format differences belong in adapters, provider choice in the gateway, and quality differences in your evals.

Rerun the evals before you switch

Adapters make switching possible; they don't make it safe. The same prompt behaves differently on another model: it may call tools more or less often, follow formatting rules more loosely, refuse different things, or write twice as much. So before moving traffic, run your eval set from the evals lesson through each provider via the same adapters, and compare like with like: the pass rate, the cost per case (each provider's token prices times its token counts), and the p95 latency. Expect to adjust prompts per provider; keep those variants in one place, next to the adapter, rather than scattering if provider == ... through the app.

Practice what you just read

  • 4 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Your app has requests.post calls to one provider in 30 files, each reading its response fields directly. A second provider is cheaper for half the traffic. What's the first change to make?

  1. Put one internal request and reply format behind adapters, used by every call
  2. Copy each of the 30 calls and add an if for the second provider in each
  3. Ask the second provider to accept the first one's format for your account
  4. Switch everything to the cheaper provider at once and fix what breaks

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson