Practice
LLM APIs
Calling the Anthropic and OpenAI APIs, streaming and tool use, retries and rate limits, and keeping cost and secrets under control.
Cheatsheet #
| Task | Snippet |
|---|---|
| Anthropic key | export ANTHROPIC_API_KEY=... |
| OpenAI key | export OPENAI_API_KEY=... |
| Anthropic SDK call | client.messages.create(model=..., max_tokens=1024, messages=[...]) |
| System prompt (Anthropic) | top-level system= parameter, not a message |
| Stream | with client.messages.stream(...) as s: for t in s.text_stream: |
| Force JSON | tool use with a schema, or a prefilled assistant turn |
| Count tokens before sending | client.messages.count_tokens(...) |
| Retry | exponential backoff on 429 and 5xx; SDKs retry by default |
| Rate limit headers | anthropic-ratelimit-*, x-ratelimit-* |
| Cost driver | input tokens × rate + output tokens × rate |
| Cheap repeated context | prompt caching (cache_control) |
| Long batch work | the Batch API, at roughly half price |
Request shape #
Both APIs take a list of alternating user and assistant messages plus generation parameters. The model is stateless: every request must carry whatever history matters, and that history is what you pay for.
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?"},
],
temperature=0,
)
print(resp.content[0].text)
print(resp.usage.input_tokens, resp.usage.output_tokens)from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY
resp = client.responses.create(
model="gpt-4.1",
input="How do I list pods that are not running?",
)
print(resp.output_text)| Parameter | Effect |
|---|---|
max_tokens | Hard cap on output; a truncated answer means this was too low |
temperature | 0 for extraction and classification, higher for drafting |
stop_sequences | End generation at a marker |
system | Role and constraints — Anthropic takes it as a parameter, not a message |
stream | Token-by-token output; required for anything user-facing |
Check stop_reason: max_tokens means the response was cut off, end_turn means it finished, tool_use means it wants a tool result back.
Streaming #
with client.messages.stream(
model="claude-sonnet-5", max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
final = stream.get_final_message()Streaming changes perceived latency far more than any model choice. It also lets a caller abort early, which is the only way to bound cost on a long generation.
Tool use and structured output #
Give the model a schema and it returns arguments that match it — this is the reliable way to get JSON, rather than asking politely in the prompt.
tools = [{
"name": "record_incident",
"description": "Record a structured incident summary.",
"input_schema": {
"type": "object",
"properties": {
"severity": {"type": "string", "enum": ["sev1", "sev2", "sev3"]},
"service": {"type": "string"},
"summary": {"type": "string"},
},
"required": ["severity", "service", "summary"],
},
}]
resp = client.messages.create(
model="claude-sonnet-5", max_tokens=1024, tools=tools,
tool_choice={"type": "tool", "name": "record_incident"},
messages=[{"role": "user", "content": transcript}],
)
block = next(b for b in resp.content if b.type == "tool_use")
incident = block.input # already parsed and schema-checkedFor a real tool loop, execute the call and send the result back as a tool_result content block in a new user message, then continue until stop_reason is end_turn. Validate every tool input server-side: a schema constrains shape, not intent.
Prompt patterns that earn their place #
| Pattern | Use |
|---|---|
| Explicit output contract | State the exact format; show one example of it |
| Few-shot examples | Classification and extraction, where the boundary is fuzzy |
| Delimiters around data | <document>…</document> so instructions and data cannot be confused |
| Prefilled assistant turn | Start the reply with { to force JSON, or with a heading to fix structure |
| Chain of thought | Hard reasoning; ask for the answer last so it is easy to parse |
| Refusal path | Tell it what to output when the input does not fit |
Put static context first and the variable part last: that ordering is what makes prompt caching effective.
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. Content fetched from the web or from a document can carry instructions aimed at your system — that is prompt injection, and the defence is not trusting the output, not a better prompt.
Cost #
Cost is input tokens plus output tokens, each at its own rate, and output is several times more expensive. Roughly four characters per token for English text.
count = client.messages.count_tokens(
model="claude-sonnet-5",
messages=[{"role": "user", "content": prompt}],
)
print(count.input_tokens)| Lever | Effect |
|---|---|
| Smaller model for easy work | Often an order of magnitude cheaper |
| Prompt caching | Repeated prefix billed at a fraction after the first call |
| Batch API | Around half price for work that can wait |
| Trim history | Summarise old turns instead of resending them |
Cap max_tokens | Bounds the expensive half of the bill |
| Stream and abort | Stop paying as soon as the answer is sufficient |
system=[{
"type": "text",
"text": long_static_context,
"cache_control": {"type": "ephemeral"}, # cached prefix
}]Errors and rate limits #
| Status | Meaning | Response |
|---|---|---|
| 400 | Malformed request | Fix it; retrying will not help |
| 401 | Bad key | Check the environment, not the code |
| 413 | Payload too large | Trim context |
| 429 | Rate limited | Back off with jitter; read retry-after |
| 500 / 529 | Server side or overloaded | Retry with backoff |
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_random_exponential
import anthropic
@retry(
retry=retry_if_exception_type((anthropic.RateLimitError, anthropic.APIStatusError)),
wait=wait_random_exponential(min=1, max=30),
stop=stop_after_attempt(5),
reraise=True,
)
def ask(prompt: str) -> str:
r = client.messages.create(model="claude-sonnet-5", max_tokens=512,
messages=[{"role": "user", "content": prompt}])
return r.content[0].textLimits apply to requests per minute and tokens per minute independently; a job can be throttled on tokens while nowhere near the request limit. Read the anthropic-ratelimit-tokens-remaining header and pace rather than discovering it at 429.
Keys and safety #
export ANTHROPIC_API_KEY=$(vault kv get -mount=secret -field=key llm/anthropic)Keys belong in a secret manager, injected at runtime. Never in a repository, an image layer, a client-side bundle or a CI variable that gets echoed. Rotate on a schedule and scope keys per workload so one leak is one revocation.
Never send credentials, customer personal data or production secrets in a prompt unless the data handling agreement covers it. Redact before the call, not after.
Oneliners #
# Smoke test a key
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":16,"messages":[{"role":"user","content":"ping"}]}' | jq -r '.content[0].text'
# Token usage of a single call
curl -s ... | jq '.usage'
# Stream 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":256,"stream":true,"messages":[{"role":"user","content":"list three checks"}]}' | grep -o '"text":"[^"]*"'
# Check rate limit headroom
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-sonnet-5","max_tokens":1,"messages":[{"role":"user","content":"x"}]}' | grep -i ratelimit
# Estimate tokens in a file, roughly
wc -c < prompt.txt | awk '{printf "~%d tokens\n", $1/4}'
# Redact obvious secrets before sending a log as context
sed -E 's/(AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{20,})/[REDACTED]/g' app.log > safe.log