Calling LLM APIs (OpenAI, Anthropic, and open source)
One mental model across providers: messages, roles, streaming, timeouts, retries, and self-hosted parity.
Why this matters
Every agentic system you will build sends LLM API calls. The patterns in this lesson β message roles, streaming, error handling, async β are reused in every framework on top. Master the raw API and the frameworks become trivial.
In 2026 OpenAI ships two APIs (Chat Completions and the newer Responses API), and Anthropic ships a single Messages API with prompt caching and a built-in Tool Runner. You will learn both because production code uses both.
Learning objectives
- Make synchronous and asynchronous calls to OpenAI Responses, OpenAI Chat Completions, and Anthropic Messages APIs.
- Handle streaming, tool calls, and structured outputs.
- Use prompt caching to cut Claude costs by up to 90%.
- Switch providers behind a single function (provider-agnostic code).
- Handle retries, timeouts, and rate-limit errors like a senior engineer.
1. The three message roles
Every chat-style API uses these:
| Role | What it is |
|---|---|
system | Boss instructions ("You are a polite SQL expert") |
user | What the human typed |
assistant | What the model previously said (history) |
tool | Result of a tool call (newer APIs use this) |
The model is stateless. You must include the full history every time.
2. OpenAI β the Responses API (2026 default)
OpenAI now recommends the Responses API for all new projects. It is an evolution of Chat Completions with cleaner agentic primitives (instructions separated from input, typed output items, built-in web_search, file_search, computer_use tools).
# pyproject.toml: uv add openai
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from env
resp = client.responses.create(
model="gpt-5.5", # or "gpt-4.1", "gpt-4.1-mini", "gpt-5-nano"
instructions="You answer in 1 sentence.",
input="Why is the sky blue?",
)
print(resp.output_text)output_text is the helper that gives you the final string. For agents you will iterate over resp.output because it contains typed items: message, reasoning, function_call, function_call_output, web_search_call, etc.
Multi-turn
history = [
{"role": "user", "content": "I want to buy a laptop."},
{"role": "assistant", "content": "Sure! What is your budget?"},
{"role": "user", "content": "Around 80,000 INR."},
]
resp = client.responses.create(
model="gpt-4.1-mini",
instructions="You are a helpful shopping advisor.",
input=history,
)
print(resp.output_text)Streaming
with client.responses.stream(
model="gpt-4.1-mini",
instructions="Answer briefly.",
input="Explain RAG.",
) as stream:
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)Async
import asyncio
from openai import AsyncOpenAI
aclient = AsyncOpenAI()
async def ask(q: str) -> str:
r = await aclient.responses.create(
model="gpt-4.1-mini",
input=q,
)
return r.output_text
async def main():
qs = ["What is RAG?", "What is LangGraph?", "What is MCP?"]
answers = await asyncio.gather(*(ask(q) for q in qs))
for q, a in zip(qs, answers):
print(q, "->", a[:60], "...")
asyncio.run(main())3. OpenAI β Chat Completions (still widely used)
You will see this in older code and many tutorials. Same model, different shape.
resp = client.chat.completions.create(
model="gpt-4.1-mini",
messages=[
{"role": "system", "content": "You answer in 1 sentence."},
{"role": "user", "content": "Why is the sky blue?"},
],
temperature=0.2,
)
print(resp.choices[0].message.content)
print(resp.usage) # prompt_tokens, completion_tokens, total_tokensWhen to use which:
- New projects β Responses API.
- Existing code, simple use cases β Chat Completions is still fully supported.
- Need built-in
web_search,file_search,computer_usetools β Responses API only.
4. Anthropic Messages API
# uv add anthropic
import anthropic
aclient = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
msg = aclient.messages.create(
model="claude-opus-4-7", # or claude-sonnet-4-6, claude-haiku-4-5
max_tokens=1024,
system="You answer in 1 sentence.",
messages=[{"role": "user", "content": "Why is the sky blue?"}],
)
print(msg.content[0].text)Note max_tokens is required in Anthropic.
Streaming
with aclient.messages.stream(
model="claude-haiku-4-5",
max_tokens=512,
messages=[{"role": "user", "content": "Explain MCP."}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)Prompt caching (the cost-killer)
Anthropic's prompt caching gives up to 90% off on cached input tokens. Use it whenever you have a long, stable prefix (system prompt, document, code base context).
big_doc = open("contract.txt").read() # tens of thousands of tokens
resp = aclient.messages.create(
model="claude-opus-4-7",
max_tokens=1024,
system=[
{
"type": "text",
"text": f"You analyze legal contracts.\n\nCONTRACT:\n{big_doc}",
"cache_control": {"type": "ephemeral"}, # 5-minute cache
}
],
messages=[{"role": "user", "content": "List the termination clauses."}],
)
print(resp.usage)
# Look for cache_creation_input_tokens and cache_read_input_tokensRules to remember:
- Cached prefix must be identical byte-for-byte. A timestamp in the system prompt = silent cache miss.
- Minimum block size: 1024 tokens for Sonnet/Opus, 2048 for Haiku.
- Default TTL is 5 minutes; new 1-hour TTL costs 2x to write but lasts 12x longer.
- Cache hits show in
usage.cache_read_input_tokens. Always check this in dev.
You can also use the simpler automatic caching mode by passing cache_control at the top level:
resp = aclient.messages.create(
model="claude-opus-4-7",
max_tokens=1024,
cache_control={"type": "ephemeral"},
system=long_system_prompt,
messages=history,
)This caches the last cacheable block automatically and is ideal for multi-turn chat where the conversation history grows.
5. Provider-agnostic wrapper (a 30-line skill that pays a salary premium)
Senior engineers wrap providers behind one interface so the rest of the codebase does not care.
from typing import Protocol, Literal
from openai import OpenAI
import anthropic
class LLMClient(Protocol):
def complete(self, system: str, user: str, *, model: str, temperature: float = 0.2) -> str: ...
class OpenAIBackend:
def __init__(self): self.c = OpenAI()
def complete(self, system, user, *, model, temperature=0.2):
r = self.c.responses.create(model=model, instructions=system, input=user, temperature=temperature)
return r.output_text
class AnthropicBackend:
def __init__(self): self.c = anthropic.Anthropic()
def complete(self, system, user, *, model, temperature=0.2):
r = self.c.messages.create(
model=model, max_tokens=1024, system=system,
messages=[{"role":"user","content":user}], temperature=temperature,
)
return r.content[0].text
def make_client(provider: Literal["openai","anthropic"]) -> LLMClient:
return {"openai": OpenAIBackend, "anthropic": AnthropicBackend}[provider]()Now your agent code calls client.complete(system, user, model=...) and you can swap providers without touching it.
A more powerful pre-built option: litellm β a single library that proxies 100+ providers (OpenAI, Anthropic, Google, Azure, Bedrock, Together, Groq, Ollama). It is the de-facto standard for provider abstraction in 2026.
# uv add litellm
from litellm import completion
r = completion(
model="anthropic/claude-haiku-4-5", # or "openai/gpt-4.1-mini", "groq/llama-3.3-70b"
messages=[{"role":"user","content":"Hi"}],
)
print(r.choices[0].message.content)6. Errors, retries, timeouts (production hygiene)
You will hit rate limits and 5xx errors. Wrap every call.
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
import openai
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, min=1, max=30),
retry=retry_if_exception_type((
openai.RateLimitError,
openai.APITimeoutError,
openai.InternalServerError,
)),
)
def safe_call(prompt: str) -> str:
r = client.responses.create(model="gpt-4.1-mini", input=prompt, timeout=30)
return r.output_textThe 5 errors you must handle:
RateLimitError(429) β back off + retry.APITimeoutErrorβ your timeout fired; retry once.InternalServerError(5xx) β provider problem; retry.BadRequestError(400) β your bug; do not retry.AuthenticationError(401) β bad key; do not retry.
7. Cost awareness (count before you call)
import tiktoken
def estimate_cost(prompt: str, model: str = "gpt-4.1-mini") -> dict:
enc = tiktoken.encoding_for_model("gpt-4.1") # close enough for 4.1-mini
n = len(enc.encode(prompt))
# 2026 published rates (always check the model page!):
# gpt-4.1-mini: $0.40 / 1M input, $1.60 / 1M output
in_cost = n / 1_000_000 * 0.40
return {"tokens": n, "input_usd": round(in_cost, 6)}
print(estimate_cost("hello"))Habit: instrument every LLM call with token usage logs. You cannot optimize what you do not measure.
Hands-on lab (2 hours)
Build multi_llm.py:
- Defines a
LLMClientprotocol and three backends:OpenAIResponses,OpenAIChat,Anthropic. - Adds
tenacity-based retry with exponential backoff. - Loads 5 prompts from
prompts.txt. - Calls all three providers in parallel (
asyncio.gather). - Saves a CSV with: prompt, provider, latency_ms, input_tokens, output_tokens, estimated_cost_usd.
- Prints a summary table sorted by cost-per-quality (you decide quality with a tiny rubric).
Acceptance criteria:
- Async + parallel: total runtime β€ 1.5x the slowest single call.
- Retries triggered on simulated 429 (you can monkeypatch).
- README explains which model wins for which prompt with data.
Common pitfalls
- Forgetting
max_tokensfor Anthropic β request fails immediately. - Long system prompts without caching β paying full price every call.
- Mixing sync/async in tests β separate test files for each.
- Catching all exceptions β never
except Exception:β be specific. - Logging the full prompt β leaks user data. Hash or redact.
Self-check
- Two differences between Responses API and Chat Completions.
- What goes in
cache_controland what does the TTL mean? - When should you NOT retry on a 429?
- What is the minimum cacheable block size for Haiku 4.5?
- Why is
tiktokennot exact forgpt-4.1-minibut still useful?
References
Sign in to save your progress and earn badges.