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 inputclaude-sonnet-5(Claude Sonnet 5): $2.20 input, $11 output per 1M tokens, $0.22 cached inputclaude-haiku-4-5(Claude Haiku 4.5): $1.10 input, $5.50 output per 1M tokens, $0.11 cached inputclaude-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
# 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-hereIf 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 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:
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)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:
systemis a top-level field, not a message with a system role.max_tokensis 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,textandtool_useblocks. Always filter bytype. Don’t assumecontent[0]is the text. stop_reasontells you why generation ended:end_turn(done),max_tokens(cut off, so raise the limit or ask for less), ortool_use(the model wants to call a tool).- Usage is reported as
input_tokensandoutput_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:
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.
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:
- 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.
- Put all tool results for one turn in one user message, one
tool_resultblock pertool_useblock. - 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:
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
usagerather 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.