Skip to content
Relaykit
Tutorials

Use the Anthropic Messages Format Through a Compatible Gateway

Call Claude through a gateway with the Anthropic SDKs: base URL, headers, streaming, a tool-use loop and prompt caching, in Python, Node and curl.

Relaykit TeamPublished 7 min read
On this page

Anthropic’s Messages API is the native way to call Claude. It is what Claude Code and the official Anthropic SDKs speak. A compatible gateway accepts the same requests at a different address, so you can keep using the Anthropic SDK and its features without rewriting your code into the OpenAI format.

This tutorial covers the setup with Relaykit’s endpoint, the request basics, streaming, a complete tool-use loop and prompt caching. The examples use {sonnet.id}.

Which models accept this format

On Relaykit, the Messages format works with these models:

  • claude-opus-5-5 (Claude Opus 5.5): $4.40 input, $22 output per 1M tokens, $0.22 cached input
  • claude-sonnet-5 (Claude Sonnet 5): $2.20 input, $11 output per 1M tokens, $0.22 cached input
  • claude-haiku-4-5 (Claude Haiku 4.5): $1.10 input, $5.50 output per 1M tokens, $0.11 cached input
  • claude-sonnet-5-5 (Claude Sonnet 5.5): $2.20 input, $11 output per 1M tokens, $0.22 cached input

The other models in our catalogue (GPT-6.1 Sol, GPT-5.6 Sol, GPT-5.6 Terra, Grok 4.6, Grok 4.7, DeepSeek V4 Flash, DeepSeek V4 Pro) are served through the OpenAI format only. To call them, use /v1/chat/completions with the OpenAI SDK. Our tutorial on switching an OpenAI SDK app to a different base URL shows how.

The one difference that matters: the base URL

The two SDK families build URLs differently, and this is the most common setup mistake:

SDK Base URL to set Request goes to
Anthropic (Python, TypeScript) {SITE.apiBase} {SITE.apiBase}/v1/messages
OpenAI (Python, Node) {SITE.apiBase}/v1 {SITE.apiBase}/v1/chat/completions

The Anthropic SDK adds /v1/messages itself. If you set the base URL to .../v1, requests go to /v1/v1/messages and fail with a 404.

Authentication. The Anthropic SDKs send the key in an x-api-key header, along with an anthropic-version header. The gateway also accepts Authorization: Bearer, which is what Claude Code sends when you set ANTHROPIC_AUTH_TOKEN. Either way, it is the same Relaykit key you use for the OpenAI format.

Step 1: environment variables

environment
# The Anthropic SDKs read these when you create a client with no arguments.
export ANTHROPIC_BASE_URL=https://api.oneaiapikey.com        # no /v1 here
export ANTHROPIC_API_KEY=sk-your-key-here

If you also use Claude Code, it reads ANTHROPIC_BASE_URL too. Our Claude Code setup guide covers the details specific to that tool.

Step 2: the first request

Test with curl first, so you know the URL, headers and key are right before you debug any code:

curl
curl https://api.oneaiapikey.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-sonnet-5",
    "max_tokens": 1024,
    "system": "You are a careful code reviewer. Answer in short bullet points.",
    "messages": [{"role": "user", "content": "Review: def add(a, b): return a - b"}]
  }'

Then the same request with the SDKs:

Python (pip install anthropic)
import os
import anthropic

client = anthropic.Anthropic(base_url="https://api.oneaiapikey.com", api_key=os.environ["ANTHROPIC_API_KEY"])

msg = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=2048,
    system="You are a careful code reviewer. Answer in short bullet points.",
    messages=[{"role": "user", "content": "Review: def add(a, b): return a - b"}],
)

# content is a list of blocks. Keep only the text blocks:
# newer Claude models can return thinking blocks before the answer.
print("".join(b.text for b in msg.content if b.type == "text"))
print(msg.stop_reason, msg.usage.input_tokens, msg.usage.output_tokens)
Node.js (npm install @anthropic-ai/sdk)
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic({
  baseURL: "https://api.oneaiapikey.com",
  apiKey: process.env.ANTHROPIC_API_KEY,
});

const msg = await client.messages.create({
  model: "claude-sonnet-5",
  max_tokens: 2048,
  system: "You are a careful code reviewer. Answer in short bullet points.",
  messages: [{ role: "user", content: "Review: def add(a, b): return a - b" }],
});

const text = msg.content.filter((b) => b.type === "text").map((b) => b.text).join("");
console.log(text, msg.stop_reason, msg.usage);

How the Messages format differs from the OpenAI format:

  • system is a top-level field, not a message with a system role.
  • max_tokens is required. It caps the cost of every request. On newer Claude models, thinking tokens count toward it, so don’t set it too low.
  • The reply is a list of content blocks, not a single string. A response can contain thinking, text and tool_use blocks. Always filter by type. Don’t assume content[0] is the text.
  • stop_reason tells you why generation ended: end_turn (done), max_tokens (cut off, so raise the limit or ask for less), or tool_use (the model wants to call a tool).
  • Usage is reported as input_tokens and output_tokens, plus cache fields, covered below.

The newest Claude models also reject some older request patterns, such as non-default sampling parameters and assistant-turn prefill. If you are moving code from an older model, read the checklist in our Claude 5.5 family post before you change the model ID.

Step 3: streaming

For anything a person waits for, stream. The SDK helper handles the event stream and returns the final message with usage at the end:

streaming (Python)
with client.messages.stream(
    model="claude-sonnet-5",
    max_tokens=4096,
    messages=[{"role": "user", "content": "Write a short README for a CLI todo app."}],
) as stream:
    for text in stream.text_stream:      # text deltas only; thinking is skipped
        print(text, end="", flush=True)
    final = stream.get_final_message()   # full message with usage

print("\n", final.stop_reason, final.usage.output_tokens)

In the TypeScript SDK the equivalent is client.messages.stream(...) with an on("text") handler, or a for await loop over the events. To send the stream on to a browser, see streaming tokens to a browser with SSE.

Step 4: a complete tool-use loop

Tools are where the Messages format differs most from the OpenAI format. You describe each tool with a JSON Schema in input_schema. The model answers with a tool_use block. You run the tool and send back a tool_result block that carries the matching tool_use_id.

tool loop (Python)
import json

tools = [{
    "name": "get_order_status",
    "description": "Look up the shipping status of an order by its ID.",
    "input_schema": {
        "type": "object",
        "properties": {"order_id": {"type": "string", "description": "Order ID, e.g. A-1042"}},
        "required": ["order_id"],
    },
}]

def get_order_status(order_id: str) -> str:
    # Replace with a real lookup. Return a string (JSON is fine).
    return json.dumps({"order_id": order_id, "status": "shipped", "eta": "2026-10-12"})

messages = [{"role": "user", "content": "Where is my order A-1042?"}]

for _ in range(5):  # hard stop: an agent loop must never run unbounded
    resp = client.messages.create(
        model="claude-sonnet-5", max_tokens=2048, tools=tools, messages=messages,
    )
    # Append the assistant turn exactly as received, every block included.
    messages.append({"role": "assistant", "content": resp.content})
    if resp.stop_reason != "tool_use":
        break
    results = []
    for block in resp.content:
        if block.type == "tool_use" and block.name == "get_order_status":
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": get_order_status(**block.input),
            })
    messages.append({"role": "user", "content": results})

print("".join(b.text for b in resp.content if b.type == "text"))

Three rules keep this loop reliable:

  1. Append the assistant turn unchanged. Include every block, thinking blocks too, in the order received. Editing or dropping blocks from earlier turns can make later requests fail.
  2. Put all tool results for one turn in one user message, one tool_result block per tool_use block.
  3. Cap the number of rounds. Each round is a separate, separately billed request. A loop limit turns a confused model into a bounded cost rather than an open-ended one.

Step 5: prompt caching

If every request starts with the same long text, such as a style guide, a schema or a large system prompt, you can mark it as cacheable. Later requests that start with the identical prefix read it from the cache instead of processing it again:

prompt caching (Python)
STYLE_GUIDE = open("style-guide.md").read()  # long, identical on every call

resp = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    system=[{
        "type": "text",
        "text": STYLE_GUIDE,
        "cache_control": {"type": "ephemeral"},  # cache everything up to here
    }],
    messages=[{"role": "user", "content": "Rewrite this sentence in our house style: ..."}],
)

u = resp.usage
print("fresh input:", u.input_tokens)
print("written to cache:", u.cache_creation_input_tokens)
print("read from cache:", u.cache_read_input_tokens)

The first call writes the cache and reports the tokens in cache_creation_input_tokens. Calls within the cache lifetime report them in cache_read_input_tokens instead. For Claude Sonnet 5, cached input is billed at $0.22 per 1M tokens instead of $2.20. A few things to know:

  • The cached part must be byte-for-byte identical and come first. Put stable content before anything that changes per request.
  • Below a minimum length, which depends on the model, nothing is cached. Check the cache fields in usage rather than assuming it worked.
  • Cache entries expire after a few minutes without use, so caching pays off most under steady traffic.

Our cost estimation guide shows how to include cache hits in a monthly budget.

Troubleshooting

Symptom Cause Fix
404 on every request Base URL ends in /v1 Use {SITE.apiBase} for the Anthropic SDK
401 Key missing, or a different provider’s key still in ANTHROPIC_API_KEY Check which variable the SDK reads, and print the first few characters
400 mentioning temperature or prefill Newer Claude models reject these Remove sampling parameters, and end messages with a user turn
Empty text, stop_reason: max_tokens Thinking used the whole budget Raise max_tokens
Model error The model isn’t served in this format Use a model from the list above, or the OpenAI format

The full list of status codes is in the errors reference. For a side-by-side look at both formats, see OpenAI vs Anthropic compatible APIs.

More in Tutorials

Try it

One API key for Claude, GPT, Grok and DeepSeek.

Relaykit speaks the OpenAI and Anthropic formats, so you change the base URL and the key, not your code. Top-ups start at $3, credit packs at $10, and every model has a published per-token rate.