Function calling migration OpenAI to Claude requires more than swapping the model name. The two providers use different request shapes, tool schema formats, and response structures. This tutorial walks through a concrete Python migration, from an OpenAI baseline to a working Claude implementation.
Prerequisites
- Python 3.10 or newer
openaiandanthropicPython SDKs- API keys for both providers (set as
OPENAI_API_KEYandANTHROPIC_API_KEY)
pip install openai anthropic
You should understand the basic OpenAI tool-calling loop. We’ll migrate a simple weather lookup tool.
OpenAI baseline
Here is a minimal OpenAI function calling loop. It defines a get_weather tool, asks the model to use it, executes the function, and returns the result.
from openai import OpenAI
import json
client = OpenAI()
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current temperature for a location",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
},
}
]
messages = [{"role": "user", "content": "What's the weather in San Francisco?"}]
resp = client.chat.completions.create(model="gpt-4o", tools=tools, messages=messages)
msg = resp.choices[0].message
if msg.tool_calls:
call = msg.tool_calls[0]
args = json.loads(call.function.arguments)
# Fake execution
result = f"{args['location']} is 72F"
messages.append({"role": "assistant", "content": None, "tool_calls": [call]})
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
final = client.chat.completions.create(model="gpt-4o", tools=tools, messages=messages)
print(final.choices[0].message.content)
Expected output:
San Francisco is 72F.
Key differences in Claude’s tool use
Before rewriting, note the structural gaps:
- Tool schema: OpenAI wraps everything in
function. Claude uses a flat object withinput_schema. - System prompt: OpenAI accepts
systeminside messages. Claude takes a top-levelsystemparameter. - Response: OpenAI returns
tool_callson the message. Claude returnscontentblocks of typetool_use. - Round-trip: OpenAI uses
role: "tool". Claude expects ausermessage containingtool_resultblocks.
Step 1: Convert tool definitions
Map the OpenAI tool to Anthropic shape.
openai_tool = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current temperature for a location",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
},
}
anthropic_tool = {
"name": "get_weather",
"description": "Get current temperature for a location",
"input_schema": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
}
No type: "function" wrapper. The JSON schema lives under input_schema.
Nested and array parameters
Both providers use JSON Schema, but Claude is stricter about type on every property. If your OpenAI schema omits type for some fields, Claude rejects it. Always specify type.
{
"input_schema": {
"type": "object",
"properties": {
"coordinates": {
"type": "array",
"items": {"type": "number"}
}
}
}
}
OpenAI often tolerates implicit typing; Claude does not.
Step 2: Call Claude with tools
Instantiate the Anthropic client and call messages.create. You must set max_tokens (Claude has no default). Pass system separately.
from anthropic import Anthropic
client = Anthropic()
SYSTEM = "You are a helpful assistant that can check weather."
initial = client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=1024,
system=SYSTEM,
tools=[anthropic_tool],
messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
)
print(initial.content)
Expected output (abbreviated):
[{'type': 'tool_use', 'id': 'toolu_01A', 'name': 'get_weather', 'input': {'location': 'San Francisco'}}]
Step 3: Handle the tool_use response
Claude returns a list of content blocks. Iterate and filter for type == "tool_use".
tool_use = next(b for b in initial.content if b.type == "tool_use")
args = tool_use.input
result = f"{args['location']} is 72F"
The id field is required for the round-trip.
Step 4: Return tool results
Claude expects the assistant’s tool_use block echoed, then a user message with tool_result.
follow_up = client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=1024,
system=SYSTEM,
tools=[anthropic_tool],
messages=[
{"role": "user", "content": "What's the weather in San Francisco?"},
{"role": "assistant", "content": [tool_use]},
{"role": "user", "content": [
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": result,
}
]},
],
)
print(follow_up.content[0].text)
Expected output:
San Francisco is 72F.
Step 5: Complete migrated loop
Here is the full Claude migration in one script.
from anthropic import Anthropic
client = Anthropic()
SYSTEM = "You are a helpful assistant that can check weather."
tool = {
"name": "get_weather",
"description": "Get current temperature for a location",
"input_schema": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
}
messages = [{"role": "user", "content": "What's the weather in San Francisco?"}]
resp = client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=1024,
system=SYSTEM,
tools=[tool],
messages=messages,
)
tool_use = next((b for b in resp.content if b.type == "tool_use"), None)
if tool_use:
result = f"{tool_use.input['location']} is 72F"
messages.append({"role": "assistant", "content": [tool_use]})
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": tool_use.id, "content": result}
]})
final = client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=1024,
system=SYSTEM,
tools=[tool],
messages=messages,
)
print(final.content[0].text)
Run it. You get the same answer as the OpenAI version, but the wire format is entirely different.
Parallel tool calls
Both models can request multiple tools in one turn. OpenAI returns a list of tool_calls; Claude returns multiple tool_use blocks in content. Handle them uniformly by iterating.
tool_uses = [b for b in resp.content if b.type == "tool_use"]
results = []
for tu in tool_uses:
results.append({"tool_use_id": tu.id, "content": f"{tu.input['location']} is 72F"})
messages.append({"role": "assistant", "content": tool_uses})
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": r["tool_use_id"], "content": r["content"]}
for r in results
]})
This mirrors OpenAI’s list of tool_calls and keeps your orchestration logic provider-agnostic.
Alternative: skip the rewrite
If you want to avoid maintaining two code paths, an OpenAI-compatible gateway like n4n.ai fronts 240+ models behind one endpoint with automatic fallback. Your existing OpenAI function calling code runs against Claude without schema translation. That trades per-provider control for operational simplicity.
Gotchas
max_tokensis mandatory on Claude. Forget it and the SDK raises.- Tool name limits: Claude restricts names to
^[a-zA-Z0-9_-]{1,64}$. OpenAI is looser. - Streaming: OpenAI streams
tool_callsdeltas; Claude streamscontentblocks withtool_usepartial JSON. Parse differently. - Error handling: Claude returns
errorblocks insidecontentif a tool result is malformed. Check block types before assumingtext. - System separation: Putting system text in the first
usermessage degrades Claude’s instruction adherence. Use thesystemparameter.
Function calling migration OpenAI to Claude is mechanical once you internalize the schema and message shape differences. Write a thin adapter for tools and responses, and your app logic stays stable.