OpenAI SDK and Claude SDK for production apps
Map clients, messages, streaming, tools, structured outputs, errors, retries and timeouts between the OpenAI and Claude SDKs, and add a provider fallback for temporary failures.
Words in this lessonSDK · API key · Streaming · Tokenizer · Provider fallback · Vendor lock-in
- SDK
- A ready-made library that lets you call an API in a few lines of your own programming language. Like a waiter who already speaks the kitchen's language, so you do not have to shout into the kitchen yourself.
- API key
- A long secret string that identifies your account when your code calls an API. Like the key to the shop's back door: anyone holding it gets in, and whatever they take goes on your tab.
- Streaming
- Sending an answer to the user piece by piece as it is generated, instead of all at once at the end. Like serving each dish as it leaves the kitchen instead of holding every plate until the whole order is ready.
- Tokenizer
- The tool that splits text into tokens and turns them into numbers a model can process. Like a dough cutter that always slices text into the same set of shapes.
- Provider fallback
- Switching to a backup model provider when the main one is temporarily unavailable. Like switching to the second card machine when the first one is down.
- Vendor lock-in
- Depending on one supplier so heavily that switching becomes slow or expensive. Like a bakery whose recipes only work with one supplier’s flour.
On this page
- Arjun's eleven errors
- Two card machines at the counter
- Following one question through both SDKs
- Opening the hood: the full mapping
- 1. Clients, timeouts and retries
- 2. Messages and the system prompt
- 3. Streaming
- 4. Tools
- 5. Structured outputs
- 6. Errors
- Building a small adapter of your own
- Where it breaks
- Try it yourself
- Common mistakes
- Explaining it to Meera
- Recap
- Go deeper
Meera’s question hung in the air for a few days: “If one AI company has an outage on Diwali, or doubles its prices, are we stuck?”
Then, on a Saturday afternoon in late November, it stopped being hypothetical. For 25 minutes, Arjun’s dashboard showed most Claude calls failing with 529 overloaded errors. The error handling from Module 05 did its job: customers saw a polite “our assistant is busy” message and their questions went to the WhatsApp team’s queue. But 212 questions piled up in that queue, and the team spent the evening clearing it.
Across the office, Farah’s WhatsApp summaries kept working. Her team used OpenAI.
“Can we just switch to whichever one is up?” Meera asked on Monday.
“Should be easy,” Arjun said. He copied his support-bot file, changed import anthropic to from openai import OpenAI, changed the model name, and ran it. The first line that touched the API failed: AttributeError: 'OpenAI' object has no attribute 'messages'. He fixed that one. The next line failed. Then the next. Eleven errors later he stopped counting.
Priya pulled up a chair. “The two SDKs do the same job with different buttons. Let’s learn the buttons properly, side by side, so you can move between them on purpose instead of by trial and error.”
Important
The idea in one sentence: The OpenAI and Claude SDKs share the same core ideas (a client, a list of messages, streaming, tools, structured outputs, typed errors, retries and timeouts) but differ in names and shapes, so learn the mapping once, keep provider-specific code behind a small function of your own, and fall back between providers only for temporary failures.
Arjun’s eleven errors
Here is a selection of what broke when Arjun pointed Claude code at the OpenAI SDK, and why:
| Arjun’s Claude code | What OpenAI’s SDK wanted |
|---|---|
client.messages.create(...) |
client.chat.completions.create(...) |
system="You are..." as its own parameter |
A message with "role": "system" at the start of the list |
max_tokens=2000 |
max_completion_tokens=2000 on current models |
response.content (a list of blocks) |
response.choices[0].message.content (a string) |
response.stop_reason == "end_turn" |
response.choices[0].finish_reason == "stop" |
response.usage.input_tokens |
response.usage.prompt_tokens |
tools=[{"name", "description", "input_schema"}] |
tools=[{"type": "function", "function": {"name", "description", "parameters"}}] |
Tool results in a user message as tool_result blocks |
A separate message with "role": "tool" |
anthropic.RateLimitError |
openai.RateLimitError |
Nothing on the left is wrong, and nothing on the right is better. They are two dialects of the same language. The mistake was treating a translation as a find-and-replace.
Two card machines at the counter
“You’ve used the card machines at the Baner store?” Priya asked. “They have two, from different banks.”
“Yes. One’s a grey box, one’s the new one with the touchscreen.”
“Same job?”
“Take a payment, print a receipt.”
“Same buttons?”
“No. On the grey one you type the amount and press green. On the new one you tap ‘Sale’ first. The receipts look different too. One puts the total at the top.”
“If the grey one breaks mid-queue, what does the cashier do?”
“Switches to the other one. She knows both.”
“Does she switch if a customer’s card is declined?”
Arjun thought. “No. The card will be declined on both.”
“That’s this whole lesson,” Priya said. “Learn both machines’ buttons. Switch when a machine is down, not when the card is the problem. And keep the cashier’s job, ‘take a payment’, separate from which machine she uses.”
Where the analogy breaks: two card machines charge the same amount and print the same total. Two AI models do not give the same answer. Switching providers changes the words your customers read, the cost, and sometimes the quality. A fallback is not free, and you should test it before you need it.
Following one question through both SDKs
The best way to learn the mapping is to send one question through both and compare what goes out and what comes back. The question: “Do you have eggless cakes?”
Claude:
import anthropic
from dotenv import load_dotenv
load_dotenv()
claude = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
r = claude.messages.create(
model="claude-opus-5-5",
max_tokens=2000,
system="You are Crumb & Co.'s friendly support assistant. Be brief.",
messages=[{"role": "user", "content": "Do you have eggless cakes?"}],
)
print(next(b.text for b in r.content if b.type == "text"))
print(r.stop_reason, r.usage.input_tokens, r.usage.output_tokens)
Yes! We have a full eggless range, including chocolate truffle, pineapple and our Diwali dry-fruit cake...
end_turn 31 96
OpenAI:
import os
from openai import OpenAI
MODEL = os.getenv("OPENAI_MODEL", "gpt-5-mini") # model names change; see OpenAI's models page
oai = OpenAI() # reads OPENAI_API_KEY
r = oai.chat.completions.create(
model=MODEL,
max_completion_tokens=2000,
messages=[
{"role": "system", "content": "You are Crumb & Co.'s friendly support assistant. Be brief."},
{"role": "user", "content": "Do you have eggless cakes?"},
],
)
print(r.choices[0].message.content)
print(r.choices[0].finish_reason, r.usage.prompt_tokens, r.usage.completion_tokens)
Yes, we do! Our eggless options include chocolate truffle, pineapple and a Diwali dry-fruit cake...
stop 33 214
Look at the four differences in what came back:
- Where the text lives. Claude returns
content, a list of blocks, because a response can contain thinking, text and tool calls together; you pick the text blocks. OpenAI’s Chat Completions returnschoices, a list of alternative answers (almost always just one), each with amessagewhosecontentis a string. - Why it stopped. Claude says
end_turn; OpenAI saysstop. Same meaning, different word. - How tokens are named.
input_tokens/output_tokensversusprompt_tokens/completion_tokens. Same idea. - How many tokens. 31 versus 33 input tokens for identical text, because each provider uses its own tokenizer (the rules that split text into tokens, from Module 01). And very different output counts: models from different providers think and write at different lengths, and reasoning tokens are counted in the output on both. Token budgets and costs do not transfer one-to-one between providers.
Notice also what is the same: a list of messages with role and content, the API key read from the environment, and a client object you create once. A plain-text conversation history like [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}] works on both without changes. That shared core is what makes switching possible at all.
Note
Pause and think. Arjun’s monthly budget alert is set at “10 million output tokens”. If he moves half his traffic to the other provider, can he keep the same alert?
Show the answer
Not safely. The same conversation produces different token counts on each provider (different tokenizers, different answer lengths, different amounts of reasoning), and each provider charges different prices per token. A budget in tokens means something different on each side. Track spend in rupees or dollars, computed per provider from its own usage numbers and its own price list, and set the alert on money.
Opening the hood: the full mapping
Here are the seven things every production app touches, side by side. Read this section as a reference you will come back to.
1. Clients, timeouts and retries
Both SDKs are built the same way underneath, and their client options match closely:
import anthropic
from openai import OpenAI
claude = anthropic.Anthropic(timeout=30.0, max_retries=3) # seconds; retries 408/409/429/5xx + network
oai = OpenAI(timeout=30.0, max_retries=3) # same idea, same names
# Per-request override, identical on both:
claude.with_options(timeout=10.0).messages.create(...)
oai.with_options(timeout=10.0).chat.completions.create(...)
Both default to 2 retries with exponential backoff and a 10-minute timeout, and both have an async twin (anthropic.AsyncAnthropic, openai.AsyncOpenAI) for the concurrency patterns in Module 05.
2. Messages and the system prompt
| Claude | OpenAI Chat Completions | |
|---|---|---|
| System prompt | system="..." parameter |
First message, "role": "system" |
| Output limit | max_tokens (required) |
max_completion_tokens (optional) |
| Reading the text | first block with type == "text" |
choices[0].message.content |
| Why it stopped | stop_reason: end_turn, max_tokens, tool_use, refusal |
finish_reason: stop, length, tool_calls, content_filter |
OpenAI also has a newer interface, the Responses API (oai.responses.create(model=MODEL, input="..."), with the text in r.output_text), which OpenAI recommends for new projects and which supports its built-in tools. Chat Completions remains widely used and is closest in shape to Claude’s Messages API, so it is the one compared here.
3. Streaming
# Claude: a context manager with a ready-made text iterator
with claude.messages.stream(model="claude-opus-5-5", max_tokens=2000,
messages=[{"role": "user", "content": "Hi"}]) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
# OpenAI: stream=True returns an iterator of chunks; the text is in each chunk's delta
stream = oai.chat.completions.create(model=MODEL, stream=True,
messages=[{"role": "user", "content": "Hi"}])
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
Same experience for the customer; different plumbing for you. Claude’s helper hands you the text pieces directly. With OpenAI you dig each piece out of delta.content, skipping chunks that carry no text.
4. Tools
The biggest differences live here. Here is the get_order_status tool from this module’s first lesson in both dialects:
schema = {"type": "object",
"properties": {"order_id": {"type": "string", "description": "e.g. CR-1042"}},
"required": ["order_id"]}
claude_tools = [{"name": "get_order_status",
"description": "Look up the live status of a Crumb order.",
"input_schema": schema}]
openai_tools = [{"type": "function",
"function": {"name": "get_order_status",
"description": "Look up the live status of a Crumb order.",
"parameters": schema}}]
The JSON Schema itself is identical; only the wrapping differs. The loop differs more:
import json
# OpenAI tool loop (compare with the Claude loop in "What is function calling?")
messages = [{"role": "user", "content": "Where is my order CR-1042?"}]
for _ in range(5):
r = oai.chat.completions.create(model=MODEL, messages=messages, tools=openai_tools)
msg = r.choices[0].message
if not msg.tool_calls: # no tool requested: this is the answer
print(msg.content)
break
messages.append(msg) # keep the assistant's tool request in the history
for call in msg.tool_calls:
args = json.loads(call.function.arguments) # arguments arrive as a JSON STRING, not a dict
result = run_tool(call.function.name, args)
messages.append({"role": "tool", # one "tool" message per call
"tool_call_id": call.id,
"content": json.dumps(result)})
| Claude | OpenAI | |
|---|---|---|
| Definition | name, description, input_schema |
{"type": "function", "function": {name, description, parameters}} |
| The model’s request | tool_use blocks in content; stop_reason == "tool_use" |
message.tool_calls; finish_reason == "tool_calls" |
| Arguments | block.input, already a dict |
call.function.arguments, a JSON string: json.loads it |
| Sending results | One user message with a tool_result block per call |
One role: "tool" message per call, with tool_call_id |
| Force a call | any/tool exist, but not supported on claude-opus-5-5 |
"required" or a named function |
| Limit to one call per turn | "disable_parallel_tool_use": True in tool_choice |
parallel_tool_calls=False |
| Exact argument shape | "strict": True on the tool |
"strict": True inside function |
Note
Pause and think. In Claude’s loop, all the tool results for one turn go back together in a single user message. Where do they go in OpenAI’s loop, and what is the one thing both loops must do before sending results?
Show the answer
In OpenAI’s loop each result is its own message with "role": "tool" and the matching tool_call_id, one per call. In both loops you must first append the assistant’s message that contains the tool requests, exactly as received, so the results have something to answer. Forget it on either provider and the next call fails with a 400. Different envelopes, same rule: the slip goes into the history before the food does.
5. Structured outputs
Both accept the same Pydantic class, as you saw in the previous lesson:
# Claude
r = claude.messages.parse(model="claude-opus-5-5", max_tokens=2000,
messages=[...], output_format=RefundDecision)
decision = r.parsed_output
# OpenAI
r = oai.chat.completions.parse(model=MODEL, messages=[...], response_format=RefundDecision)
decision = r.choices[0].message.parsed
Both return None in place of the object when the model refuses, so the same if decision is None check works on both.
6. Errors
Both SDKs are generated from similar foundations, so their exception families line up almost name for name:
| Situation | Claude (anthropic.) |
OpenAI (openai.) |
|---|---|---|
| Bad request (400) | BadRequestError |
BadRequestError |
| Bad key (401) | AuthenticationError |
AuthenticationError |
| Not found (404) | NotFoundError |
NotFoundError |
| Rate limit (429) | RateLimitError |
RateLimitError |
| Any HTTP error | APIStatusError (has .status_code) |
APIStatusError (has .status_code) |
| Timeout | APITimeoutError |
APITimeoutError |
| Network | APIConnectionError |
APIConnectionError |
The four families from Module 05’s error lesson apply unchanged. Only the module name in front differs.
Building a small adapter of your own
Now Arjun could write what Meera asked for. The idea: keep the rest of the app talking to one function of yours, and put each provider’s dialect inside it. Then the app does not care which card machine is in use.
Step 1: a common shape for a reply.
from dataclasses import dataclass
@dataclass
class Reply:
text: str
provider: str
input_tokens: int
output_tokens: int
Step 2: one function per provider, translating into that shape.
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-opus-5-5")
def ask_claude(system: str, messages: list[dict]) -> Reply:
r = claude.messages.create(model=CLAUDE_MODEL, max_tokens=2000, system=system, messages=messages)
text = next((b.text for b in r.content if b.type == "text"), "")
return Reply(text, "claude", r.usage.input_tokens, r.usage.output_tokens)
def ask_openai(system: str, messages: list[dict]) -> Reply:
r = oai.chat.completions.create(
model=MODEL, max_completion_tokens=2000,
messages=[{"role": "system", "content": system}, *messages], # system goes INTO the list
)
return Reply(r.choices[0].message.content or "", "openai",
r.usage.prompt_tokens, r.usage.completion_tokens)
Step 3: fall back only when the primary is temporarily down.
import logging
log = logging.getLogger("crumb.llm")
def is_temporary(err: Exception) -> bool:
if isinstance(err, anthropic.APIConnectionError): # network trouble or timeout
return True
if isinstance(err, anthropic.APIStatusError):
return err.status_code == 429 or err.status_code >= 500 # busy or overloaded
return False
def ask(system: str, messages: list[dict]) -> Reply:
try:
return ask_claude(system, messages)
except Exception as err:
if not is_temporary(err):
raise # our bug: switching won't fix it
log.warning("Claude unavailable (%s); falling back to OpenAI", type(err).__name__)
return ask_openai(system, messages)
Remember that the SDK has already retried twice before raising, so by the time ask falls back, Claude has been failing for several seconds. Run the 25-minute outage through this code and every one of those 212 questions would have been answered by OpenAI, with a provider="openai" tag in the logs so Arjun could review them afterwards.
What Arjun deliberately did not abstract: tools and structured outputs. The differences there are big enough that a one-size-fits-all wrapper either hides important features (like strict) or grows into a project of its own. For those, he kept separate, clearly named code paths for each provider. Module 19 builds a fuller LLM gateway that handles routing, fallback and cost tracking across providers properly.
Where it breaks
Same prompt, different behaviour. Arjun’s system prompt had been tuned on Claude for weeks. On the fallback, answers were noticeably longer and occasionally offered things Crumb does not do (“We can deliver in 30 minutes!”). Each model needs its own prompt testing. Before relying on a fallback, run your test questions through it; Module 23 builds a proper evaluation set for exactly this.
Feature gaps. Not every feature exists on both sides, or on every model of one provider. Forced tool choice works on OpenAI and is rejected by claude-opus-5-5. Claude has a refusal stop reason; OpenAI signals some declines through content_filter or a refusal field on the message. Code that depends on one provider’s special feature will not survive a fallback without a plan.
Data and contracts. Sending customer messages to a second company is a business decision, not only a technical one. Check both providers’ data-handling terms, and make sure your privacy policy covers both before any customer data goes to the fallback.
The fallback can be down too. Large incidents sometimes affect several providers, especially when they share cloud infrastructure. Your Module 05 fallback, the polite message plus a human queue, is still the last line of defence.
And the misconception to drop: “Using an abstraction layer means I’m not locked in.” Your real lock-in is in your prompts, your evaluation results, and the features you depend on. A wrapper makes switching possible; testing makes it safe.
Try it yourself
- Get API keys for both providers (Module 01 covered setup) and put both in
.env. - Run the “eggless cakes” pair above and compare the token counts and the wording.
- Build
ask()with the fallback. To simulate an outage, temporarily setCLAUDE_MODELto a valid model and create the Claude client withbase_url="http://127.0.0.1:9"(a port where nothing is listening). - Call
ask(...)and check the log.
Expected: step 4 logs “Claude unavailable (APIConnectionError); falling back to OpenAI” and returns a Reply with provider="openai". Now set CLAUDE_MODEL=claude-does-not-exist and restore the real base_url: ask() should raise a NotFoundError instead of falling back, because that is your bug, not an outage.
Stuck? Hint
Create the broken client as claude = anthropic.Anthropic(base_url="http://127.0.0.1:9", max_retries=0, timeout=2.0) so the test fails quickly instead of waiting for retries. Remember to restore the normal client afterwards.
Common mistakes
- Find-and-replace migration. The SDKs differ in shape, not just names. Translate concept by concept using the tables above.
- Forgetting that OpenAI tool arguments are a JSON string. Always
json.loads(call.function.arguments). - Assuming features are the same everywhere. Check per model: forced tool choice is the classic trap.
- Falling back on every error. Switch providers only for temporary failures (429, 5xx, timeouts, network). A 400 or 401 is your bug.
- Budgeting in tokens across providers. Tokenizers and prices differ; budget in money.
- An untested fallback. If you have never run your test questions through the backup, you do not have a backup.
Explaining it to Meera
Arjun showed Meera the log from a test he had run that morning, with Claude deliberately switched off.
“Think of the two card machines at Baner,” he said. “Different buttons, same job. Our app now talks to a little ‘cashier’ function of ours, and the cashier knows both machines. If Claude is down or overloaded, the cashier waits a few seconds, then quietly switches to OpenAI. Customers get an answer either way.”
“So we’re not stuck.”
“We’re less stuck. If Claude’s prices doubled, switching most traffic would be days of work, mostly re-testing our prompts on the other model, not months. But the two AIs don’t answer identically, so I tested our top fifty customer questions on both. Two OpenAI answers needed prompt fixes before I was comfortable.”
“And if both are down?”
“Then we’re back to the polite message and the WhatsApp queue. That part never changes.”
Recap
- Both SDKs share the same core: a client with timeouts and retries, a message list, streaming, tools, structured outputs and typed errors.
- They differ in shape: Claude’s
systemparameter and content blocks versus OpenAI’s system message andchoices;stop_reasonversusfinish_reason;input_tokensversusprompt_tokens. - Tools differ most:
input_schemaversusparameters, dict arguments versus JSON strings, ausermessage oftool_resultblocks versus onetoolmessage per call. - Keep the rest of your app behind a small function of your own, and fall back between providers only for temporary failures.
- Your real lock-in is prompts, evaluations and special features: test the fallback before you need it.
With orders, deliveries, refunds and two providers under control, the assistant could handle almost everything customers asked about their own orders. What it still could not do was answer questions about Crumb itself: “Can I return a custom cake?”, “What temperature should I store the plum cake at?” Those answers lived in the 200-page store operations handbook, and no tool could look them up by order id. “For that,” Priya said, “the model needs a way to find the right paragraph by meaning, not by number. Next module: embeddings.”
Go deeper
- OpenAI docs (Chat Completions, Responses API, function calling, structured outputs): https://platform.openai.com/docs
- Anthropic docs (Messages API, tool use, structured outputs): https://docs.anthropic.com
- Building effective agents: https://www.anthropic.com/research/building-effective-agents