LLM APIs
Call the Anthropic and OpenAI APIs: models, streaming, tool use, structured output, caching, rate limits, errors and key handling.
On this page
Cheatsheet#
| Task | Anthropic | OpenAI |
|---|---|---|
| API key variable | ANTHROPIC_API_KEY | OPENAI_API_KEY |
| Endpoint | POST https://api.anthropic.com/v1/messages | POST https://api.openai.com/v1/responses |
| Auth header | x-api-key: $ANTHROPIC_API_KEY plus anthropic-version: 2023-06-01 | Authorization: Bearer $OPENAI_API_KEY |
| SDK call | client.messages.create(model=..., max_tokens=..., messages=[...]) | client.responses.create(model=..., input=...) |
| System prompt | Top-level system= parameter, not a message | instructions= parameter |
| Output cap | max_tokens (required) | max_output_tokens |
| Stream | with client.messages.stream(...) as s: for t in s.text_stream: | stream=True |
| JSON output | output_config={"format": {"type": "json_schema", ...}} or client.messages.parse(output_format=Model) | text={"format": {"type": "json_schema", ...}} or client.responses.parse(text_format=Model) |
| Count tokens before sending | client.messages.count_tokens(model=..., messages=[...]) | |
| Rate limit headers | anthropic-ratelimit-*, retry-after | x-ratelimit-*, retry-after |
| Request ID for support | request-id header, resp._request_id in the SDK | |
| Cheap repeated context | Prompt caching (cache_control) | Automatic prompt caching |
| Work that can wait | Message Batches API, 50% off | Batch API |
Current Anthropic models#
| Model | API ID | Input / output per MTok | Context | Max output | Notes |
|---|---|---|---|---|---|
| Claude Fable 5.1 | claude-fable-5-1 | $10 / $50 | 1M | 128K | Most capable. Thinking always on, no forced tool use |
| Claude Opus 5.5 | claude-opus-5-5 | $4 / $20 | 1M | 128K | Anthropic’s default recommendation. Thinking always on, default effort medium, no forced tool use |
| Claude Opus 5 | claude-opus-5 | $5 / $25 | 1M | 128K | Previous Opus, still served |
| Claude Sonnet 5 | claude-sonnet-5 | $2 / $10 | 1M | 128K | Speed and cost balance |
| Claude Haiku 4.5 | claude-haiku-4-5-20251001 (alias claude-haiku-4-5) | $1 / $5 | 200K | 64K | Fastest and cheapest. Uses budget_tokens thinking, no effort |
Dateless IDs from the 4.6 generation onwards are pinned snapshots, not moving aliases. List what your key can use, with context window (max_input_tokens) and output cap (max_tokens), from the Models API:
# List models available to this key (read-only)
curl -s https://api.anthropic.com/v1/models \
-H "x-api-key: $ANTHROPIC_API_KEY" -H 'anthropic-version: 2023-06-01' \
| jq -r '.data[] | [.id, .max_input_tokens, .max_tokens] | @tsv'Prices and IDs change. Check the models overview and pricing before committing to a budget. For OpenAI, see the OpenAI models page.
Request shape#
Both APIs are stateless. Every request carries the whole conversation you want the model to see, and you pay for all of it on every call.
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system="You are a precise assistant. Answer with commands, not prose.",
messages=[
{"role": "user", "content": "How do I list pods that are not running?"},
],
)
text = next(b.text for b in resp.content if b.type == "text")
print(text)
print(resp.stop_reason, resp.usage.input_tokens, resp.usage.output_tokens)Read text by filtering for type == "text" rather than taking content[0]: on models with thinking on, the first block can be a thinking block.
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY
resp = client.responses.create(
model="gpt-6-sol",
instructions="Answer with commands, not prose.",
input="How do I list pods that are not running?",
max_output_tokens=1024,
)
print(resp.output_text)| Parameter (Anthropic) | Effect |
|---|---|
max_tokens | Hard cap on output tokens, thinking included. Hitting it truncates the answer |
system | Role and constraints. A top-level parameter, not a message |
stop_sequences | End generation when a marker string appears |
thinking | {"type": "adaptive"} on current models. Fable 5.1 and Opus 5.5 always think; omit the field |
output_config.effort | low, medium, high, xhigh, max. Trades depth for tokens and latency |
stream | Server-sent events instead of one JSON body |
temperature, top_p, top_k | Rejected with a 400 on Opus 4.7 and later, Sonnet 5 and Fable. Still accepted on Haiku 4.5 and 4.6 models |
stop_reason tells you how the turn ended:
stop_reason | Meaning | Action |
|---|---|---|
end_turn | Finished normally | Use the answer |
max_tokens | Cut off at the cap | Raise max_tokens or ask for less |
stop_sequence | Hit one of stop_sequences | Expected |
tool_use | Wants a tool result | Run the tool, send a tool_result, call again |
pause_turn | A long server-tool turn paused | Send the response back unchanged to continue |
refusal | Declined by a safety classifier (HTTP 200) | Read stop_details.category; do not treat content as an answer |
model_context_window_exceeded | Input plus output reached the context window | Trim or summarise history |
Prefill and sampling parameters were removed
A conversation ending with a partial assistant message (prefill) returns a 400 on Claude 4.6 and later models. Use structured output instead. temperature and top_p return a 400 on Opus 4.7 and later, Sonnet 5 and Fable; control behaviour with the prompt and effort.
Streaming#
with client.messages.stream(
model="claude-sonnet-5",
max_tokens=16000,
messages=[{"role": "user", "content": prompt}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
final = stream.get_final_message() # full Message, same shape as a non-streaming callStreaming changes perceived latency more than model choice does. It also avoids HTTP timeouts on long outputs: the SDKs refuse non-streaming requests they expect to run past 10 minutes, so use streaming for large max_tokens (up to 128K). Closing the stream early stops generation.
Errors can arrive after the HTTP 200 as an error event inside the stream, so a stream handler needs its own error path.
Structured output and tool use#
For JSON, constrain the output with a schema instead of asking for JSON in the prompt.
from pydantic import BaseModel
class Incident(BaseModel):
severity: str
service: str
summary: str
resp = client.messages.parse(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": transcript}],
output_format=Incident,
)
incident = resp.parsed_output # validated Incident instanceWithout Pydantic, pass output_config={"format": {"type": "json_schema", "schema": {...}}} to messages.create and json.loads the text block. The schema needs required and "additionalProperties": False. The older top-level output_format parameter on messages.create is deprecated.
Tools let the model request an action. Set "strict": True on a tool so its input always validates against input_schema:
tools = [{
"name": "get_pod_status",
"description": "Return the status of pods in a namespace.",
"strict": True,
"input_schema": {
"type": "object",
"properties": {"namespace": {"type": "string"}},
"required": ["namespace"],
"additionalProperties": False,
},
}]
messages = [{"role": "user", "content": "What is failing in my-namespace?"}]
while True:
resp = client.messages.create(model="claude-sonnet-5", max_tokens=4096,
tools=tools, messages=messages)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
break
results = [
{"type": "tool_result", "tool_use_id": b.id, "content": run_tool(b.name, b.input)}
for b in resp.content if b.type == "tool_use"
]
messages.append({"role": "user", "content": results}) # all results in one messageThe loop appends the full resp.content, not just the text, because thinking and tool-use blocks must go back unchanged. Return every tool_result from one turn in a single user message, and report a failed tool with "is_error": True rather than dropping it. Validate tool input on your side: a schema constrains shape, not intent.
Forcing a specific tool with tool_choice={"type": "tool", "name": ...} or {"type": "any"} works on Sonnet 5 and Opus 5 but returns a 400 on Opus 5.5 and Fable 5.1. Use auto with strict and an instruction in the prompt, or structured output.
OpenAI’s equivalents are client.responses.parse(text_format=Model) and text={"format": {"type": "json_schema", "name": ..., "schema": ..., "strict": True}} on responses.create. See the OpenAI structured outputs guide.
Treat model output as untrusted input
Never execute generated commands, SQL or code without review or a sandbox, and never interpolate model output into a shell. Documents and web pages you pass in can carry instructions aimed at your system (prompt injection). The defence is limiting what the output can do, not a better prompt.
Prompt patterns that earn their place#
| Pattern | Use |
|---|---|
| Explicit output contract | State the exact format and show one example |
| Few-shot examples | Classification and extraction where the boundary is fuzzy |
| Delimiters around data | <document>...</document> so instructions and data cannot be confused |
| Structured output | Fixed JSON shape, replacing the old prefill trick |
| Refusal path | Say what to output when the input does not fit |
| Static first, variable last | Makes the prefix cacheable |
Cost#
Cost is input tokens plus output tokens, each at its own rate, and output costs five times input on current Anthropic models. Thinking tokens bill as output. Token counts are model-specific, so measure rather than estimate:
count = client.messages.count_tokens(
model="claude-sonnet-5",
messages=[{"role": "user", "content": prompt}],
)
print(count.input_tokens)Do not use tiktoken for Claude. It is OpenAI’s tokenizer and undercounts Claude tokens.
| Lever | Effect |
|---|---|
| Prompt caching | Cache reads bill at 10% of base input (5% on Opus 5.5, 2.5% on Fable 5.1) |
| Message Batches API | 50% off every token, results within 24 hours |
Lower effort | Fewer thinking and output tokens on routine work |
| Smaller model | Haiku 4.5 or Sonnet 5 for extraction and classification |
| Trim history | Summarise old turns instead of resending them |
Cap max_tokens | Bounds the expensive half of the bill |
Mark the end of the stable prefix with cache_control. The render order is tools, then system, then messages, and any byte change before a breakpoint invalidates everything after it.
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system=[{
"type": "text",
"text": long_static_context,
"cache_control": {"type": "ephemeral"}, # 5-minute TTL; {"type": "ephemeral", "ttl": "1h"} for 1 hour
}],
messages=[{"role": "user", "content": question}],
)
print(resp.usage.cache_creation_input_tokens, resp.usage.cache_read_input_tokens)A request allows at most four breakpoints. Prefixes shorter than the model minimum (512 to 4096 tokens depending on model; 1024 on Sonnet 5, 4096 on Haiku 4.5) silently do not cache. See prompt caching.
Errors and rate limits#
| Status | Type | Retry? | Response |
|---|---|---|---|
| 400 | invalid_request_error | No | Fix the request. Also returned when a spend limit you set is reached |
| 401 | authentication_error | No | Check which key the process actually loaded |
| 402 | billing_error | No | Fix billing in the Console |
| 403 | permission_error | No | Key lacks access to the model or workspace |
| 404 | not_found_error | No | Wrong path or model ID not available to this org |
| 413 | request_too_large | No | Messages requests are capped at 32 MB. Use the Files API for large documents |
| 429 | rate_limit_error | Yes | Wait for retry-after. No retry-after means the monthly spend cap was hit |
| 500 | api_error | Yes | Back off; include request-id if it persists |
| 504 | timeout_error | Yes | Stream long requests |
| 529 | overloaded_error | Yes | Back off; consider a less loaded model |
The SDKs already retry connection errors, 408, 409, 429 and 5xx twice with exponential backoff and honour retry-after. Raise the count on the client instead of wrapping calls in a second retry library, which multiplies attempts:
client = anthropic.Anthropic(max_retries=5, timeout=120.0) # timeout in seconds (Python)If you do add your own retry, retry only anthropic.RateLimitError, anthropic.InternalServerError and anthropic.APIConnectionError. Catching APIStatusError also retries 400s, which never succeed.
Anthropic rate limits are per model and measured as requests per minute (RPM), input tokens per minute (ITPM) and output tokens per minute (OTPM), using a token bucket that refills continuously. Cache reads do not count towards ITPM on current models, so caching raises effective throughput. max_tokens does not count against OTPM. Short bursts can trip a per-minute limit enforced per second, and a sudden jump in traffic can trigger acceleration limits, so ramp up gradually. See rate limits.
Prompt patterns in practice#
A prompt is configuration, so treat it like one: version it in the repository, keep the static part identical between requests so it caches, and test it against a fixed set of inputs before changing it. The patterns below are the ones that survive contact with production; each has a concrete shape.
system = """You are an on-call assistant for a Kubernetes platform team.
Answer with commands first, one short sentence of explanation after each.
If the question cannot be answered from the supplied context, reply exactly: INSUFFICIENT_CONTEXT.
Never invent flags. Only cite files that appear in <context>."""
user = f"""<context>
{runbook_excerpt}
</context>
<question>
{question}
</question>"""The system prompt states the role, the output contract and the refusal path; the user turn carries data inside delimiters so the model cannot mistake a line in the runbook for an instruction. Put the long, unchanging material first (context, examples) and the short, per-request question last, which is also the cache-friendly order. Few-shot examples work best as prior conversation turns (user example, assistant answer) rather than pasted into one block, because the model treats them as demonstrated behaviour rather than described behaviour. For classification, list the allowed labels and use structured output with an enum, not a request to “reply with one word”. For extraction, describe each field’s meaning in the schema’s description rather than in prose. Avoid negative-only instructions (“do not be verbose”) without a positive target (“answer in at most three sentences”); the model follows the latter more reliably. On Fable and Opus 5 models, prompts written for older models are often over-prescriptive and reduce quality, so cut step-by-step scaffolding and let effort control depth.
Tool use in Go and curl#
The loop is the same in every language: send tools, read stop_reason, run each tool_use block, return every tool_result in one user message, repeat. In Go the SDK converts the response into history with ToParam(); the raw Input is JSON to unmarshal, never a parsed map.
import (
"context"
"encoding/json"
"github.com/anthropics/anthropic-sdk-go"
)
podTool := anthropic.ToolParam{
Name: "get_pod_status",
Description: anthropic.String("Return the status of pods in a namespace."),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{"namespace": map[string]any{"type": "string"}},
Required: []string{"namespace"},
},
Strict: anthropic.Bool(true),
}
tools := []anthropic.ToolUnionParam{{OfTool: &podTool}}
messages := []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock("What is failing in my-namespace?"))}
for {
resp, err := client.Messages.New(ctx, anthropic.MessageNewParams{
Model: "claude-sonnet-5", MaxTokens: 4096, Tools: tools, Messages: messages,
})
if err != nil { return err }
messages = append(messages, resp.ToParam()) // thinking and tool_use blocks go back unchanged
if resp.StopReason != anthropic.StopReasonToolUse { break }
var results []anthropic.ContentBlockParamUnion
for _, block := range resp.Content {
if tu, ok := block.AsAny().(anthropic.ToolUseBlock); ok {
var in struct{ Namespace string `json:"namespace"` }
if err := json.Unmarshal([]byte(tu.JSON.Input.Raw()), &in); err != nil {
results = append(results, anthropic.NewToolResultBlock(block.ID, "invalid input: "+err.Error(), true))
continue
}
out, err := getPodStatus(ctx, in.Namespace) // validate in.Namespace against an allow-list first
results = append(results, anthropic.NewToolResultBlock(block.ID, out, err != nil))
}
}
messages = append(messages, anthropic.NewUserMessage(results...))
}The same second request in curl, with the assistant’s tool_use block replayed and the result attached:
curl -s https://api.anthropic.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,
"tools": [{"name": "get_pod_status", "description": "Return the status of pods in a namespace.", "strict": true,
"input_schema": {"type": "object", "properties": {"namespace": {"type": "string"}}, "required": ["namespace"], "additionalProperties": false}}],
"messages": [
{"role": "user", "content": "What is failing in my-namespace?"},
{"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01A", "name": "get_pod_status", "input": {"namespace": "my-namespace"}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01A", "content": "api-7d9f CrashLoopBackOff (3 restarts)"}]}
]}' | jq -r '.content[] | select(.type == "text") | .text'OpenAI’s Responses API uses the same loop with different names: tools are {"type": "function", "name": ..., "parameters": ..., "strict": true}, the model returns function_call items with a call_id and an arguments JSON string, and you append {"type": "function_call_output", "call_id": ..., "output": ...} to the input list along with the model’s output items.
resp = client.responses.create(model="gpt-6-sol", tools=tools, input=input_list)
input_list += resp.output # keep the model's items in history
for item in resp.output:
if item.type == "function_call":
args = json.loads(item.arguments)
input_list.append({"type": "function_call_output", "call_id": item.call_id, "output": run_tool(item.name, args)})
resp = client.responses.create(model="gpt-6-sol", tools=tools, input=input_list)Structured output with curl#
The schema goes in output_config.format; the text block is then guaranteed to parse and validate. Keep every property in required and set additionalProperties: false, or the request is rejected.
jq -n --rawfile t transcript.txt '{
model: "claude-sonnet-5", max_tokens: 1024,
messages: [{role: "user", content: $t}],
output_config: {format: {type: "json_schema", schema: {
type: "object",
properties: {
severity: {type: "string", enum: ["sev1", "sev2", "sev3"]},
service: {type: "string"},
summary: {type: "string", description: "One sentence, no more than 30 words"}
},
required: ["severity", "service", "summary"], additionalProperties: false
}}}}' \
| curl -s https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" -H 'anthropic-version: 2023-06-01' -H 'content-type: application/json' -d @- \
| jq -r '.content[] | select(.type == "text") | .text' | jq .Check stop_reason before parsing: a max_tokens stop yields truncated JSON, and a refusal yields no answer at all. The OpenAI equivalent is "text": {"format": {"type": "json_schema", "name": "incident", "schema": {...}, "strict": true}} on /v1/responses, read from output_text.
Streaming in Go and against OpenAI#
Go has no get_final_message; accumulate events into a Message and read text deltas as they arrive.
stream := client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
Model: "claude-sonnet-5", MaxTokens: 64000,
Messages: []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock(prompt))},
})
var msg anthropic.Message
for stream.Next() {
ev := stream.Current()
msg.Accumulate(ev) // builds the complete Message as events arrive
if d, ok := ev.AsAny().(anthropic.ContentBlockDeltaEvent); ok {
if td, ok := d.Delta.AsAny().(anthropic.TextDelta); ok { fmt.Print(td.Text) }
}
}
if err := stream.Err(); err != nil { return err } // includes error events delivered mid-stream
fmt.Println(msg.StopReason, msg.Usage.OutputTokens)with client.responses.stream(model="gpt-6-sol", input=prompt) as stream: # OpenAI
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
final = stream.get_final_response()Both APIs stream Server-Sent Events. Anthropic emits message_start, content_block_start, content_block_delta, content_block_stop, message_delta (with stop_reason and output usage) and message_stop; OpenAI emits typed response.* events with a sequence_number. Tool input streams as input_json_delta fragments that are only valid JSON once the block stops, so buffer them per index before parsing. Behind a reverse proxy, disable response buffering for the streaming path (proxy_buffering off in nginx) or the client sees nothing until the end.
Message Batches#
The Batches API takes up to 100,000 requests (256 MB) in one call, processes them asynchronously within 24 hours (most finish within an hour), and bills every token at half price. Any Messages request, including tools, caching and structured output, is valid as a batch item. Results come back in arbitrary order, keyed by custom_id, and stay available for 29 days.
# Submit: one requests[] entry per prompt, each with a custom_id you can join on later
jq -n '{requests: [inputs | {custom_id: .id, params: {model: "claude-haiku-4-5", max_tokens: 512,
messages: [{role: "user", content: .text}]}}]}' items.jsonl \
| curl -s https://api.anthropic.com/v1/messages/batches \
-H "x-api-key: $ANTHROPIC_API_KEY" -H 'anthropic-version: 2023-06-01' -H 'content-type: application/json' -d @- \
| tee batch.json | jq '{id, processing_status, request_counts}'
# Poll until processing_status is "ended", then stream the JSONL results
batch=$(jq -r .id batch.json)
until [ "$(curl -s https://api.anthropic.com/v1/messages/batches/$batch -H "x-api-key: $ANTHROPIC_API_KEY" -H 'anthropic-version: 2023-06-01' | jq -r .processing_status)" = ended ]; do sleep 60; done
curl -s "https://api.anthropic.com/v1/messages/batches/$batch/results" -H "x-api-key: $ANTHROPIC_API_KEY" -H 'anthropic-version: 2023-06-01' \
| jq -r 'select(.result.type == "succeeded") | [.custom_id, (.result.message.content[] | select(.type == "text") | .text)] | @tsv'from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
batch = client.messages.batches.create(requests=[
Request(custom_id=item.id, params=MessageCreateParamsNonStreaming(
model="claude-haiku-4-5", max_tokens=512, messages=[{"role": "user", "content": item.text}]))
for item in items
])
while client.messages.batches.retrieve(batch.id).processing_status != "ended":
time.sleep(60)
for r in client.messages.batches.results(batch.id):
if r.result.type == "succeeded":
store(r.custom_id, r.result.message)
elif r.result.type == "errored" and r.result.error.type != "invalid_request":
retry_later(r.custom_id) # server-side errors are safe to resubmit; invalid_request is notOpenAI’s Batch API is file-based: write one JSON request per line with a custom_id, method, url (/v1/responses or /v1/chat/completions) and body, upload it with client.files.create(file=..., purpose="batch"), then client.batches.create(input_file_id=..., endpoint="/v1/responses", completion_window="24h") and download output_file_id when status is completed. Batch requests have their own rate limits, so they do not compete with interactive traffic. Use batches for evaluation runs, backfills, nightly classification and anything a human is not waiting on.
Cost control#
Measure before optimising: log usage from every response with the model, route and a request ID, and compute cost per completed task rather than per call, because a cheaper call that needs two retries is not cheaper. The levers, in the order they usually pay off:
| Lever | What it does | Typical saving |
|---|---|---|
| Prompt caching | Stable prefix billed at a fraction of input price on hits | Most of the input bill on chat and agent loops |
| Trim input | Drop boilerplate, old turns and unused tool definitions; summarise history | Proportional to what you remove |
| Batches | Same models, 50% off, asynchronous | Half of any non-interactive workload |
Lower effort | Fewer thinking and output tokens on routine requests | Large on output-heavy routes; measure quality |
| Smaller model per route | Haiku or Sonnet for classification, extraction and routing | 5 to 10 times on those routes |
Cap max_tokens per route | Bounds the expensive half; pairs with structured output | Stops runaway outputs |
| Stop sequences and tight contracts | Model stops when the answer is complete | Small but free |
Put a spend limit on each workspace in the Console so a bug loops against a cap rather than a credit card, and alert on daily spend from your own usage logs, not the invoice. For agentic loops, cap the number of tool turns and the cumulative output tokens per task, and stop when a tool result repeats. A cache hit rate below expectations is nearly always a byte of variability before the last breakpoint: a timestamp in the system prompt, unsorted JSON keys, or a tool list that changes per request. Caches are model-scoped, so routing requests across models forfeits cache reuse; test the newest model at low effort before building a multi-model cascade, because it often beats an older model at high effort for less.
Evaluation#
A prompt change without an evaluation is a guess. The minimum useful eval is a file of real or representative inputs with an expected outcome per row, a runner that calls your application’s actual entry point (not a re-implementation of the API call), and a grader that turns each output into a number. Run it before and after every prompt, model or parameter change and compare.
| Grader | Use when | Cost |
|---|---|---|
| Programmatic check | Output is a label, number, JSON shape, or passes tests | Free, deterministic |
| Pairwise judge | Comparing two versions of open-ended output; randomise A/B order, allow tie | One judge call per case |
| Rubric judge | Absolute score on open-ended output against concrete, checkable criteria | One judge call per case |
| Human spot-check | Criteria are hard to write down | Limits how often the eval runs |
import json, statistics
cases = [json.loads(l) for l in open("evals/triage.jsonl", encoding="utf-8")] # {"input": ..., "expected": "sev2"}
scores, costs = [], []
for c in cases:
resp = client.messages.parse(model=MODEL, max_tokens=512, system=SYSTEM,
messages=[{"role": "user", "content": c["input"]}], output_format=Incident)
scores.append(resp.parsed_output.severity == c["expected"])
costs.append(resp.usage.input_tokens * IN_PRICE + resp.usage.output_tokens * OUT_PRICE)
print(f"accuracy={statistics.mean(scores):.3f} cost_per_case=${statistics.mean(costs):.4f} n={len(cases)}")Report precision and recall per label rather than a single accuracy figure when the classes are unbalanced, because a change can trade one for the other invisibly. When a model grades another model’s output, use structured output for the verdict, never let the model under test judge itself, and read a sample of graded cases by hand before trusting the rubric. Keep the eval set out of the prompt and out of any few-shot examples, split it into a tuning set and a held-out set if you iterate on the prompt against it, and record the model ID, prompt hash and per-case latency and cost alongside the score so a regression can be traced. Run the full set through the Batches API to halve the cost of each evaluation run.
OpenAI-compatible endpoints#
Many servers speak the OpenAI Chat Completions wire format: vLLM, Ollama, llama.cpp’s server, LM Studio, and gateway products such as LiteLLM that translate to other providers. The OpenAI SDKs target them by changing base_url; the API key is often ignored but the SDK requires a non-empty value. Coverage varies: most implement /v1/chat/completions and /v1/models, fewer implement /v1/responses, tool calling depends on the model and the server’s parser, and response_format JSON schemas may be enforced, ignored or approximated.
from openai import OpenAI
local = OpenAI(base_url="http://127.0.0.1:11434/v1", api_key="ollama") # Ollama; vLLM defaults to :8000/v1
resp = local.chat.completions.create(
model="llama3.3",
messages=[{"role": "system", "content": "Answer briefly."}, {"role": "user", "content": "ping"}],
max_tokens=64,
temperature=0,
)
print(resp.choices[0].message.content, resp.usage)# Same call from the shell; works against OpenAI, vLLM, Ollama and LiteLLM with only the URL and model changed
curl -s http://127.0.0.1:8000/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"my-model","messages":[{"role":"user","content":"ping"}],"max_tokens":64,"stream":false}' \
| jq -r '.choices[0].message.content'
# List what the server serves
curl -s http://127.0.0.1:8000/v1/models | jq -r '.data[].id'Anthropic also exposes an OpenAI-compatible /v1/chat/completions layer, documented as intended for testing and comparing models rather than production (base_url="https://api.anthropic.com/v1/", model set to a Claude ID), but it maps only a subset of features; use the native Messages API for anything beyond a first comparison. The reverse (a Claude-shaped client against a local model) is what gateways are for. Whichever shim you use, usage accounting, error codes and rate-limit headers differ from the real provider, so do not tune retry logic against one and deploy against the other. Keep the provider behind one interface in your code (a function that takes messages and returns text plus usage) so switching is a configuration change, and run the same eval set against each candidate before switching.
Troubleshooting#
| Symptom | Likely cause | Check |
|---|---|---|
| 400 mentioning prefill | Last message is from the assistant | End on a user message; use structured output |
400 on temperature or top_p | Sampling parameters removed on this model | Remove them |
400 "thinking.type.enabled" is not supported | budget_tokens on a 4.7 or later model | Use thinking={"type": "adaptive"} and output_config.effort |
400 on tool_choice any or tool | Opus 5.5 or Fable 5.1 | tool_choice auto plus strict |
| 400 about thinking blocks being modified | History was filtered or edited before resending | Append resp.content unchanged |
| Answer cut off mid-sentence | stop_reason is max_tokens | Raise max_tokens; thinking counts towards it |
content[0].text raises AttributeError | First block is thinking or tool_use | Filter blocks by type |
| Empty or odd output with HTTP 200 | stop_reason is refusal | stop_details.category |
| 429 that never clears | Monthly spend cap, no retry-after | error.details.error_code is enforced_spend_limit_reached |
| 429 well below the RPM limit | Token limit (ITPM or OTPM) or a burst | anthropic-ratelimit-*-remaining headers |
cache_read_input_tokens is always 0 | Prefix changes per request, or is below the minimum | Timestamps or unsorted JSON in system; prefix length |
| Non-streaming call rejected or times out | Large max_tokens without streaming | Use messages.stream and get_final_message() |
| 401 in CI but works locally | Different or empty key in that environment | test -n "$ANTHROPIC_API_KEY", without printing it |
| Tool loop never terminates | Tool result omitted, or the same failing result returned each turn | Log stop_reason per turn; cap turns; return is_error: true with a useful message |
tool_use.input fails your parser | Escaping differs between models, or strict not set | json.loads the raw input; set "strict": true and additionalProperties: false |
| Structured output rejected with 400 on the schema | Property missing from required, or additionalProperties not false | Every property required; nested objects follow the same rule |
| Streamed tool input is not valid JSON | Parsed input_json_delta fragments before content_block_stop | Buffer deltas per block index and parse once |
| Stream arrives all at once through a proxy | Response buffering | proxy_buffering off (nginx) or the equivalent on the gateway |
Batch result missing for a custom_id | Result type is errored, canceled or expired | Filter on .result.type; resubmit server errors, fix invalid_request |
Batch stuck in in_progress for hours | Normal within the 24-hour window | Poll every minute or more; check request_counts for progress |
Local OpenAI-compatible server ignores response_format | Server or model does not enforce schemas | Validate the output yourself; check the server’s docs for JSON mode |
openai.AuthenticationError against a local server | Empty api_key | Pass any non-empty string; the server ignores it |
| Eval score moves between identical runs | Non-deterministic output, or a judge with no fixed reference | Run each case several times and report the mean; freeze reference outputs for pairwise judging |
Keys and data#
# Load the key from Vault at runtime (never commit it)
export ANTHROPIC_API_KEY="$(vault kv get -mount=secret -field=key llm/anthropic)"Keep keys in a secret manager and inject them at runtime; see Vault. Never put a key in a repository, an image layer, a client-side bundle or a CI log. Scope keys per workload (workspaces) so one leak is one revocation, and rotate on a schedule.
Do not send credentials, customer personal data or production secrets in a prompt unless your data handling agreement covers it. Redact before the call, not after.
Oneliners#
# Smoke test a key (prints the reply text)
curl -s https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" -H 'anthropic-version: 2023-06-01' -H 'content-type: application/json' \
-d '{"model":"claude-haiku-4-5","max_tokens":64,"messages":[{"role":"user","content":"ping"}]}' \
| jq -r '.content[] | select(.type == "text") | .text'# Show token usage and stop reason for one call
curl -s https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" -H 'anthropic-version: 2023-06-01' -H 'content-type: application/json' \
-d '{"model":"claude-haiku-4-5","max_tokens":64,"messages":[{"role":"user","content":"ping"}]}' \
| jq '{stop_reason, usage}'# Stream text deltas from the shell
curl -sN https://api.anthropic.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,"stream":true,"messages":[{"role":"user","content":"list three checks"}]}' \
| sed -un 's/^data: //p' | jq -rj --unbuffered 'select(.type == "content_block_delta" and .delta.type == "text_delta") | .delta.text'# Show rate limit headroom and the request ID (spends a few tokens)
curl -sD - -o /dev/null https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" -H 'anthropic-version: 2023-06-01' -H 'content-type: application/json' \
-d '{"model":"claude-haiku-4-5","max_tokens":1,"messages":[{"role":"user","content":"x"}]}' \
| grep -iE '^(anthropic-ratelimit|retry-after|request-id)'# Count tokens in a file (free, separate rate limit, no generation)
jq -n --rawfile p prompt.txt '{model:"claude-sonnet-5", messages:[{role:"user", content:$p}]}' \
| curl -s https://api.anthropic.com/v1/messages/count_tokens \
-H "x-api-key: $ANTHROPIC_API_KEY" -H 'anthropic-version: 2023-06-01' -H 'content-type: application/json' -d @- \
| jq .input_tokens# Redact AWS access key IDs and JWTs from a log before using it as context
sed -E 's/(AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{20,})/[REDACTED]/g' app.log > safe.log# OpenAI smoke test
curl -s https://api.openai.com/v1/responses \
-H "Authorization: Bearer $OPENAI_API_KEY" -H 'content-type: application/json' \
-d '{"model":"gpt-6-luna","input":"ping","max_output_tokens":64}' \
| jq -r '.output[] | select(.type == "message") | .content[] | select(.type == "output_text") | .text'