OpenAI to Claude Migration Guide 2026 — Complete Tutorial | AI Error Hub
AI Error Hub/ Blog/ Tutorial/ OpenAI to Claude Migration
Tutorial

How to Migrate From OpenAI to Claude Without Breaking Your Users (2026)

The complete production migration path — every API difference with working code, plus shadow-traffic testing, gradual rollout, and the surprises that break migrations at scale. Every code sample is the version that survived production.

· 2026-09-23 · 14 min read

Migrating a production AI product from one provider to another is almost never a "swap the SDK import" job, even though every migration guide starts by suggesting it might be. The API surfaces look similar. The behaviors differ. The failure modes are different enough that a lift-and-shift migration will silently degrade quality, break tool calls in edge cases, and confuse users in ways your integration tests won't catch.

This tutorial walks the actual path — from an OpenAI stack running in production to Claude, in a way that keeps users unbroken during the transition. Every code sample is the version that survived production, not the demo version. Every recommendation comes from watching real migrations succeed or fail.

The reverse direction (Claude → OpenAI) uses the same playbook with the sides swapped. Most of what's here transfers.

Why teams actually migrate (and why they shouldn't)

Before writing any migration code, be clear about why. Migration is expensive — engineering time, risk of regressions, months of parallel operation. The reasons that hold up:

  • Cost: your workload is bill-dominated by input tokens and the target provider's cache pricing or per-token rate is materially better. Model this with real usage data, not published rates. Cache economics dominate for most agent workloads.
  • Capacity / quota: your product is hitting rate limits and your current provider's ceiling is genuinely constraining. Migration to a different provider (or to a gateway with multi-provider routing) unblocks growth.
  • Model capability: a specific capability (long context, tool use reliability, reasoning depth) that materially affects your product quality is stronger on the other side. Test this with your actual workload, not benchmarks.
  • Contractual / procurement: enterprise buyers require a specific vendor relationship (Anthropic direct, AWS Bedrock, GCP Vertex, Azure) for compliance or budget reasons.

The reasons that don't hold up

  • "The new model is smarter." Benchmark deltas rarely survive contact with your actual users' prompts. Test on your data first.
  • "We want provider diversity for reliability." Multi-provider routing gets you this without full migration. Use LiteLLM, Portkey, or OpenRouter as a gateway; keep both providers active.
  • "The other API is easier to use." APIs are all approximately as annoying. This is not a reason to spend three months migrating.

The middle path

Most teams that "migrate" end up multi-provider. Different features route to different providers based on cost/quality tradeoffs. Rare is the production stack that ends up all-in on one provider forever. Design your migration with that in mind — the refactoring you do to migrate is also the refactoring that enables multi-provider routing later.

The 2026 model equivalency map

The first practical question of any migration is: which model on the target replaces which model on the source? There's no perfect mapping — pricing tiers, capability profiles, and context windows differ. This is the working map for 2026:

OpenAI modelClosest Claude equivalentNotes
gpt-5-6 (flagship) claude-opus-5 or claude-sonnet-4-6 Opus 5 matches on reasoning-heavy tasks; Sonnet 4.6 matches on cost-sensitive workloads with 90% of the quality.
gpt-5-6-mini claude-haiku-4-5 Comparable speed and cost. Haiku is stronger on tool use; mini is faster on pure text tasks.
gpt-5-6 with high reasoning_effort claude-opus-5 with extended_thinking Both add latency and cost. Test with your specific reasoning workload; results vary significantly by task.
gpt-4o (legacy) claude-sonnet-4-6 Sonnet 4.6 outperforms gpt-4o on most tasks at similar cost. Straightforward migration target.
o3-mini / o4-mini (reasoning) claude-sonnet-4-6 with extended_thinking Different reasoning profiles — Claude's extended thinking is more transparent (visible thinking tokens).
gpt-5-6 with response_format: json_schema claude-sonnet-4-6 with tool-based structured output Claude achieves structured outputs via forced tool_use rather than a response_format field. See Section 6.
Embedding models (text-embedding-3-large) Voyage 3 or Cohere Embed v4 Anthropic does not offer embedding models. Voyage AI (owned by Anthropic) is the recommended pairing.

Pricing comparison at migration time

Approximate published rates in September 2026 — verify current pricing before committing:

# OpenAI:
gpt-5-6:        $2.50 / 1M input     $10.00 / 1M output
gpt-5-6-mini:   $0.15 / 1M input      $0.60 / 1M output

# Anthropic Claude:
claude-opus-5:      $15.00 / 1M input    $75.00 / 1M output   (with 90% cache discount)
claude-sonnet-4-6:   $3.00 / 1M input    $15.00 / 1M output   (with 90% cache discount)
claude-haiku-4-5:    $0.25 / 1M input     $1.25 / 1M output   (with 90% cache discount)

Nominal rates don't tell the story. For workloads with reusable prompts (agents, RAG, long conversations), Claude's 90% cache discount often makes Sonnet 4.6 net cheaper than gpt-5-6-mini despite the higher headline rate. Model your actual workload — see the Prompt Caching Setup Guide for the math.

The API differences that will break your code

Both APIs look superficially similar — messages array, roles, streaming. Then you look closer and the shapes diverge. Here are the differences that will break your code, in rough order of severity:

ConcernOpenAIClaude
System prompt location First message in messages array with role: "system" Separate top-level system parameter; not in messages
max_tokens parameter Optional; defaults to model max Required; must be explicit
Response structure response.choices[0].message.content response.content is a list of blocks
Finish/stop reason response.choices[0].finish_reason response.stop_reason
Tool definitions Wrapped: {type: "function", function: {name, parameters}} Flat: {name, description, input_schema}
Tool calls in response message.tool_calls array Content blocks with type: "tool_use"
Tool results {role: "tool", tool_call_id, content} {role: "user", content: [{type: "tool_result", tool_use_id, content}]}
Structured outputs response_format: {type: "json_schema", ...} Force tool_use with the target schema as the tool's input_schema
Streaming format Anonymous SSE data events Named SSE events (event: content_block_delta, etc.)
Rate limit codes 429 for TPM/RPM limits 429 for rate limits, 529 for overloaded (transient, retryable)
Image inputs {type: "image_url", image_url: {url}} {type: "image", source: {type: "base64" or "url", ...}}

Everything below in this tutorial walks through refactoring for these differences one at a time. Do them in order — each layer builds on the previous.

Refactoring the request layer

Start with the request-building layer. Before touching your business logic, get a wrapper that turns your existing message list into a working Claude call.

The minimum working Claude call

# Before (OpenAI):
from openai import OpenAI
client = OpenAI()

response = client.chat.completions.create(
    model="gpt-5-6",
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "What's 2+2?"}
    ]
    # max_tokens optional
)
answer = response.choices[0].message.content

# After (Claude):
from anthropic import Anthropic
client = Anthropic()

response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,                          # REQUIRED
    system="You are a helpful assistant.",    # Separate parameter
    messages=[
        {"role": "user", "content": "What's 2+2?"}
    ]
)
answer = response.content[0].text             # Content is a list of blocks

Extracting text from Claude's block-based responses

Claude's response can contain multiple content blocks — one text block for plain answers, but multiple blocks (text + tool_use blocks) when tools are involved. Never assume content[0] is text:

def extract_text(response):
    """Extract the assistant's text from a Claude response, handling mixed blocks."""
    text_blocks = [b.text for b in response.content if b.type == "text"]
    return "".join(text_blocks)

def extract_tool_calls(response):
    """Get any tool_use blocks from the response."""
    return [b for b in response.content if b.type == "tool_use"]

# Now works whether response has plain text, tool calls, or both:
text = extract_text(response)
tool_calls = extract_tool_calls(response)

System prompt translation

The system prompt lives in a different place, but the bigger issue is behavior — Claude follows system prompts more strictly than OpenAI models do. Instructions that were suggestions on GPT become hard constraints on Claude. See Section 8 for the full prompt migration story; for now, just move the string to the right parameter:

def normalize_messages_for_claude(openai_messages):
    """Split OpenAI messages into Claude's (system, messages) shape."""
    system_prompt = ""
    claude_messages = []
    for m in openai_messages:
        if m["role"] == "system":
            # Multiple system messages — concatenate (Claude has one system slot)
            system_prompt = (system_prompt + "\n\n" + m["content"]).strip()
        else:
            claude_messages.append(m)
    return system_prompt, claude_messages

Migrating message history from OpenAI to Claude

Existing conversation history stored in OpenAI format needs translation. Assistant messages with tool_calls and standalone tool-role messages both need conversion (covered in Section 5).

For plain text conversation history, the conversion is trivial — just filter out any role: "system" messages and hoist them into the system parameter.

Refactoring tool / function calling

Tool calling is where migrations most often break. The mental model is the same (model requests a tool call, you execute, feed back the result), but every layer of the wire format differs.

Tool definitions

# OpenAI tool definition:
openai_tool = {
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get current weather for a location",
        "parameters": {
            "type": "object",
            "properties": {
                "location": {"type": "string", "description": "City name"},
                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
            },
            "required": ["location"]
        }
    }
}

# Claude tool definition — flatter, no wrapper:
claude_tool = {
    "name": "get_weather",
    "description": "Get current weather for a location",
    "input_schema": {                    # Not "parameters"
        "type": "object",
        "properties": {
            "location": {"type": "string", "description": "City name"},
            "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
        },
        "required": ["location"]
    }
}

# Automated converter:
def openai_tool_to_claude(t):
    fn = t["function"]
    return {
        "name": fn["name"],
        "description": fn["description"],
        "input_schema": fn["parameters"],
    }

Tool calls in the response

# OpenAI response with tool call:
{
    "choices": [{
        "message": {
            "role": "assistant",
            "content": None,
            "tool_calls": [{
                "id": "call_abc123",
                "type": "function",
                "function": {
                    "name": "get_weather",
                    "arguments": '{"location": "SF", "unit": "celsius"}'  # STRING, not object
                }
            }]
        },
        "finish_reason": "tool_calls"
    }]
}

# Claude response with tool call — content blocks, not a separate array:
{
    "content": [
        {"type": "text", "text": "Let me check the weather for you."},
        {
            "type": "tool_use",
            "id": "toolu_abc123",
            "name": "get_weather",
            "input": {"location": "SF", "unit": "celsius"}   # ACTUAL OBJECT, not string
        }
    ],
    "stop_reason": "tool_use"
}

Notice two things: (1) Claude may include a text block and a tool_use block in the same response — process both. (2) Claude's tool inputs are already parsed JSON objects, not strings that need parsing. This alone eliminates a class of malformed-JSON bugs common on the OpenAI side.

Tool results back to the model

# OpenAI feeding tool result back:
messages.append({
    "role": "assistant",
    "tool_calls": [previous_tool_call]
})
messages.append({
    "role": "tool",
    "tool_call_id": "call_abc123",
    "content": '{"temperature": 22, "condition": "sunny"}'  # STRING
})

# Claude feeding tool result back — user message with tool_result block:
messages.append({
    "role": "assistant",
    "content": previous_response.content   # The whole content list from the assistant
})
messages.append({
    "role": "user",
    "content": [{
        "type": "tool_result",
        "tool_use_id": "toolu_abc123",
        "content": '{"temperature": 22, "condition": "sunny"}',   # Can be string or list
        "is_error": False                                            # Optional, defaults false
    }]
})

Error handling in tool results

Claude explicitly supports marking a tool result as an error, which the model handles more gracefully than OpenAI (where you have to inject error strings into the content):

{
    "role": "user",
    "content": [{
        "type": "tool_result",
        "tool_use_id": "toolu_abc123",
        "content": "Weather API returned 503 — service temporarily unavailable. Try again in a few seconds.",
        "is_error": True   # Model recognizes this is a failure and often retries or explains
    }]
}

Use this pattern — it produces materially better recovery behavior than passing errors as plain content. For deeper patterns on tool call reliability, see Chapter 2 of Building Reliable AI Agents.

Related error page: Claude tool_use JSON delta parsing.

Refactoring structured outputs (JSON mode)

OpenAI's structured output feature uses response_format: {type: "json_schema", ...}. Claude doesn't have an equivalent field — instead, you achieve reliable structured outputs by defining your target as a tool and forcing the model to call it.

The migration pattern

# OpenAI: strict JSON schema response format
response = openai_client.chat.completions.create(
    model="gpt-5-6",
    messages=messages,
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "customer_intent",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "intent": {"type": "string", "enum": ["refund", "cancel", "info"]},
                    "confidence": {"type": "number"},
                    "urgency": {"type": "string", "enum": ["low", "medium", "high"]}
                },
                "required": ["intent", "confidence", "urgency"],
                "additionalProperties": False
            }
        }
    }
)
result = json.loads(response.choices[0].message.content)


# Claude: force tool_use with the target schema
CLASSIFY_TOOL = {
    "name": "classify_intent",
    "description": "Extract the customer's intent and urgency from their message.",
    "input_schema": {
        "type": "object",
        "properties": {
            "intent": {"type": "string", "enum": ["refund", "cancel", "info"]},
            "confidence": {"type": "number"},
            "urgency": {"type": "string", "enum": ["low", "medium", "high"]}
        },
        "required": ["intent", "confidence", "urgency"]
    }
}

response = anthropic_client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    tools=[CLASSIFY_TOOL],
    tool_choice={"type": "tool", "name": "classify_intent"},   # FORCE this specific tool
    messages=messages
)

# Extract the tool call's input — that's your structured output:
tool_use_block = next(b for b in response.content if b.type == "tool_use")
result = tool_use_block.input   # Already a parsed dict, not a JSON string

Why this pattern is actually stronger

The tool-based approach on Claude produces more reliable structured outputs than JSON mode on either provider, because:

  • The tool's input_schema is enforced at the token-generation level (the model can only produce valid schema-compliant tokens)
  • The output arrives as an already-parsed object — no JSON parsing step that can fail on malformed responses
  • The tool_choice forcing means the model can't skip the structured output and produce plain text instead

Optional fields and unions

OpenAI's strict mode has some restrictions (all fields required, no unions in some cases). Claude's tool schemas are more flexible — full JSON Schema draft-07 support including oneOf, anyOf, optional fields via omission from required. If your OpenAI schema was constrained by strict mode, you can often simplify it during migration.

Refactoring streaming

Both providers stream via Server-Sent Events, but the event shapes differ. If you're using an SDK's built-in streaming helper, most of this is handled — the SDK exposes similar iteration patterns. If you're consuming SSE directly, this section applies.

Using the SDK streaming helpers (recommended)

# OpenAI SDK streaming:
stream = openai_client.chat.completions.create(
    model="gpt-5-6",
    messages=messages,
    stream=True
)
for chunk in stream:
    delta = chunk.choices[0].delta
    if delta.content:
        print(delta.content, end="", flush=True)


# Anthropic SDK streaming — similar pattern via .stream() helper:
with anthropic_client.messages.stream(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    messages=messages
) as stream:
    for text in stream.text_stream:      # Convenience: iterates text deltas only
        print(text, end="", flush=True)

    final_message = stream.get_final_message()   # Full response after stream ends

Handling raw SSE events

If you're proxying streams through your own server, you're dealing with SSE frames directly. Claude's events are named (using SSE's event: field), OpenAI's are anonymous:

# OpenAI SSE frame:
data: {"choices":[{"delta":{"content":"Hello"}}]}

data: {"choices":[{"delta":{"content":" world"}}]}

data: [DONE]


# Claude SSE frame — with event type:
event: message_start
data: {"type":"message_start","message":{...}}

event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}

event: content_block_stop
data: {"type":"content_block_stop","index":0}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}

event: message_stop
data: {"type":"message_stop"}

Streaming tool calls

The hardest streaming migration. On both sides, tool call arguments stream in chunks that must be assembled before being usable as valid JSON. See OpenAI streaming partial JSON and Claude tool_use JSON delta parsing for the full pattern.

# Claude tool call streaming — buffer input_json_delta chunks per content block:
async for event in stream:
    if event.type == "content_block_start" and event.content_block.type == "tool_use":
        tool_buffer = {
            "id": event.content_block.id,
            "name": event.content_block.name,
            "input_json": ""
        }
    elif event.type == "content_block_delta" and event.delta.type == "input_json_delta":
        tool_buffer["input_json"] += event.delta.partial_json
    elif event.type == "content_block_stop":
        # Now safe to parse — full JSON assembled
        tool_input = json.loads(tool_buffer["input_json"])
        await execute_tool(tool_buffer["name"], tool_input)

Migrating prompts: the behavior differences

Your prompts probably work on Claude. They just work differently. This is where migrations produce the most subtle quality regressions — the model does what you asked, but with a different interpretation of your instructions.

Behavioral differences to expect

  • Claude follows system prompts more strictly. Rules that GPT-5.6 treated as strong suggestions become hard boundaries on Claude. Instructions like "prefer X" become "always X" in practice.
  • Claude tends to be more verbose by default. Responses average 20-40% longer than GPT-5.6 on the same prompt. Add explicit brevity instructions if that matters for your product.
  • Claude hedges more. "I can't be certain but..." and "It's worth noting that..." appear more often. Instructions like "answer directly without hedging" work.
  • Claude refuses more categorically. When Claude declines, it declines clearly (visible in stop_reason: "refusal"). When GPT-5.6 declines, it may produce a compliant-looking non-answer. Different UX implications.
  • Claude is stronger on multi-step reasoning without extended thinking than gpt-5-6-mini. Migration from mini to Sonnet often improves quality even at higher cost.
  • Claude follows formatting instructions more literally. "Respond in JSON" produces JSON reliably. "Respond in this exact format:" followed by an example produces near-perfect adherence.

Prompt migration checklist

  1. Review every system prompt for hedging language you added to soften GPT's occasional overconfidence. Those hedges may cause Claude to over-hedge.
  2. Look for "please" and "try to" — Claude does not treat these as optional. If you meant strict requirements, keep them; if you meant preferences, add "when possible" or "generally."
  3. Check for output-length constraints. If your GPT prompt said "keep it short," strengthen to "respond in 1-2 sentences" or "under 100 words."
  4. Remove OpenAI-specific formatting hacks (e.g., "you are ChatGPT" or references to OpenAI's terms). These confuse Claude or trigger unnecessary safety filtering.
  5. Test any prompt that includes examples of the desired output — Claude adheres to example format more closely, so refresh examples to reflect current expectations.

The prompt A/B test

Before finalizing any migrated prompt, run it against your golden test cases on both providers and diff the outputs. Look for regressions in the specific dimensions your product cares about (accuracy, tone, format, refusal patterns). See the next section for the shadow-traffic testing pattern.

Testing strategy: shadow traffic + parallel evals

The safest migration approach: run both providers in parallel for weeks before switching production traffic. This lets you catch regressions your test suite missed, on real user traffic, without user impact.

Shadow traffic pattern

async def process_user_request(user_message, user_context):
    # Primary: OpenAI response, this is what the user sees
    primary_response = await openai_client.chat.completions.create(
        model="gpt-5-6",
        messages=user_context.messages + [{"role": "user", "content": user_message}]
    )

    # Shadow: fire Claude in the background, log the response, don't return it
    asyncio.create_task(shadow_evaluate(
        user_message=user_message,
        user_context=user_context,
        primary_response=primary_response,
    ))

    return primary_response

async def shadow_evaluate(user_message, user_context, primary_response):
    try:
        system_prompt, claude_messages = normalize_messages_for_claude(
            user_context.messages + [{"role": "user", "content": user_message}]
        )
        shadow_response = await anthropic_client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            system=system_prompt,
            messages=claude_messages,
        )

        # Log both responses for offline comparison
        db.insert("shadow_evals", {
            "user_message": user_message,
            "primary_provider": "openai",
            "primary_response": primary_response.choices[0].message.content,
            "primary_tokens": primary_response.usage.total_tokens,
            "primary_cost_usd": calc_openai_cost(primary_response.usage),
            "shadow_provider": "anthropic",
            "shadow_response": extract_text(shadow_response),
            "shadow_tokens": shadow_response.usage.input_tokens + shadow_response.usage.output_tokens,
            "shadow_cost_usd": calc_anthropic_cost(shadow_response.usage),
            "timestamp": datetime.utcnow(),
        })
    except Exception as e:
        # Never let shadow errors affect user experience
        log.warning(f"Shadow eval failed: {e}")

Analyzing shadow data

After 1-2 weeks of shadow traffic, you'll have thousands of paired responses. Analyze them across four dimensions:

  1. Cost comparison. Aggregate per-request cost by feature. Multiply by projected traffic. Is the migration net-positive on cost?
  2. Latency comparison. p50 and p95 latency for each provider. Any user-visible regression?
  3. Quality comparison via LLM-as-judge. Sample 1-5% of paired responses through a judge model that scores which response better answered the user's question.
  4. Structural differences. Length distribution, refusal rate, tool call patterns. Anything unexpected?
JUDGE_PROMPT = """Compare these two AI responses to the same user question.
Score which one better addresses the question. Return JSON only.

User question: {user_message}

Response A (from {provider_a}): {response_a}

Response B (from {provider_b}): {response_b}

{{
  "better": "A" | "B" | "tie",
  "reasoning": "",
  "accuracy_a": <1-5>,
  "accuracy_b": <1-5>,
  "helpfulness_a": <1-5>,
  "helpfulness_b": <1-5>
}}"""

# Randomize A/B assignment to avoid position bias
def compare_via_judge(pair):
    a, b = random.sample([pair.primary, pair.shadow], 2)
    judge_result = judge_client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=200,
        messages=[{"role": "user", "content": JUDGE_PROMPT.format(...)}]
    )
    # Parse and record which underlying provider won

See Chapter 6 of the AI Observability Playbook for the full eval infrastructure.

Gradual rollout with feature flags

When shadow data supports the migration, roll out gradually. Never flip 100% of traffic in a single deploy — you cannot see all the failure modes until real users hit them.

The percentage-based rollout

ROLLOUT_CONFIG = {
    "customer_support_agent": {
        "claude_percentage": 5,       # Start at 5%
        "assignment_key": "user_id",  # Consistent per user
    },
    "content_generation": {
        "claude_percentage": 0,       # Not migrating yet
        "assignment_key": "user_id",
    },
}

def get_provider_for_request(feature, user_id):
    config = ROLLOUT_CONFIG[feature]
    # Consistent hash: same user always gets same provider (until % changes)
    bucket = hash(f"{feature}:{user_id}") % 100
    if bucket < config["claude_percentage"]:
        return "anthropic"
    return "openai"

# In your handler:
def handle_request(user_message, feature, user_id):
    provider = get_provider_for_request(feature, user_id)
    metrics.emit("migration.provider_assignment", 1, tags={
        "feature": feature, "provider": provider
    })
    if provider == "anthropic":
        return process_with_claude(user_message)
    else:
        return process_with_openai(user_message)

The rollout schedule

A safe cadence for a critical feature:

  • Day 1: 5% traffic to Claude. Watch dashboards live for 24 hours.
  • Day 3: If metrics are stable, move to 15%.
  • Day 7: 40%. Now you have statistically meaningful comparison data.
  • Day 14: 75% if no regressions. Keep 25% on OpenAI as a control.
  • Day 21+: 100% if evals hold up. Retain OpenAI as fallback (Chapter 7 of Agent Playbook covers provider fallback chains).

The rollback plan

Rollback must be a config change taking effect in seconds — not a deploy. If your dashboards show regression, drop claude_percentage back to 0 and investigate. Never debug in-place while users experience degradation.

# Feature flag service reads config dynamically:
def get_current_rollout_percentage(feature):
    # Redis, LaunchDarkly, or similar — updated by ops without deploy
    return feature_flags.get_int(f"migration.{feature}.claude_percentage", default=0)

What to monitor during the migration

During any migration, you need answers to specific questions from your monitoring, not just "is it up." Instrument these before you start rolling out traffic:

Per-provider dashboards

Every metric that matters, sliced by provider, so you can compare side by side:

  • Error rate per provider. Are Claude requests failing more often? Different error codes?
  • Latency p50 / p95 / p99 per provider. Any regression in user-perceived speed?
  • Cost per request per provider. Is the migration actually saving what you projected?
  • Tokens per request per provider. Are outputs getting longer/shorter than expected?
  • Cache hit rate (Claude only, at first). Are you getting the caching economics you modeled?
  • Refusal rate per provider. Different safety filter behavior showing up?
  • Tool call success rate per provider. Are tools succeeding at the same rate?
  • User feedback signals per provider. Thumbs up/down, session abandonment, retry rate.

Instrument every call with the provider tag

def instrumented_call(provider, model, feature, user_id, call_fn):
    start = time.time()
    tags = {"provider": provider, "model": model, "feature": feature}
    try:
        response = call_fn()
        metrics.emit("ai.request.success", 1, tags=tags)
        metrics.emit("ai.latency.total_ms", (time.time() - start) * 1000, tags=tags)
        tokens = get_total_tokens(response, provider)
        cost = calculate_cost(response, provider, model)
        metrics.emit("ai.tokens.total", tokens, tags=tags)
        metrics.emit("ai.cost.usd", cost, tags=tags)
        return response
    except Exception as e:
        metrics.emit("ai.request.error", 1, tags={**tags, "error_type": type(e).__name__})
        raise

Alerts to add during migration

  • Claude error rate > 2× OpenAI error rate for 15 minutes → alert engineering
  • Claude p95 latency > 1.5× OpenAI p95 for 30 minutes → alert engineering
  • User feedback score dropped > 0.3 on migrated feature vs 7-day baseline → alert product
  • Refusal rate > 3× baseline on Claude → alert engineering (safety filter mismatch)

The weekly migration review

60 minutes weekly during rollout. Walk through:

  1. Cost per request comparison. On track vs projection?
  2. Quality metrics. Any dimension where Claude is regressing?
  3. User feedback trend. Any complaints correlated with provider assignment?
  4. Rollout percentage decision. Increase, hold, or roll back?

Document the decision at each checkpoint. If you eventually decide to fully migrate, you want the paper trail. If you decide to reverse, same reason.

Frequently asked questions

Common questions from teams planning or executing an OpenAI-to-Claude migration.

How long does a realistic OpenAI → Claude migration take?

For a mid-size AI feature with tools and structured outputs, expect 2-4 weeks of engineering work plus 4-8 weeks of shadow-traffic evaluation and gradual rollout. Simple chat features can compress to 1-2 weeks; agent stacks with dozens of tools stretch to months.

Can I use LiteLLM or another gateway to avoid rewriting my code?

For prototyping, yes — gateways abstract the API differences enough for basic calls. For production, gateway abstractions leak when you need provider-specific features (Claude prompt caching, extended thinking, specific tool_choice behavior). Most teams end up bypassing the gateway for their hot paths and using it only for less-critical features.

Do my OpenAI prompts need rewriting or can I just swap the API call?

Simple prompts often work as-is; complex ones (agent instructions, structured extraction, safety-sensitive workflows) usually need adjustment. Test on your golden case set before assuming zero prompt work is needed. See Section 8 for what to watch.

How do I compare Claude's tool-based structured outputs to OpenAI's JSON schema mode?

For most workloads Claude's tool-based approach is more reliable — the input_schema is enforced at token generation, and the output arrives as a parsed dict rather than a JSON string you have to parse. See Section 6 for the pattern.

What's the biggest surprise teams hit in production after migrating?

Response length inflation. Claude responses average 20-40% longer than GPT-5.6 on the same prompt. This shows up as increased output token cost (partially offsetting input cost savings) and occasionally as UI regressions if your frontend assumed shorter responses.

Should I migrate embeddings too or keep OpenAI just for embeddings?

Anthropic doesn't offer embedding models, so if you're using OpenAI embeddings you'll keep that dependency or migrate to Voyage AI (owned by Anthropic) or Cohere. Migrating embeddings requires re-indexing your vector database — significant work, only worth it if embedding quality matters for your use case.

How do I handle prompts that used OpenAI's function call names or IDs in their conversation history?

During migration you'll process message history that references OpenAI-format tool_call_id values. Translate them to Claude's tool_use_id at the message-normalization layer, before history hits the model. Or store both formats side-by-side during the transition period.

What if I need to keep OpenAI as a fallback for capacity reasons?

That's the right pattern. Design your call layer to route to a primary provider with automatic fallback to a secondary on rate limits or overload. See Chapter 7 of the Building Reliable AI Agents playbook for the fallback chain pattern.

SK
About the author
Sana K.
Senior Engineer, AI Error Hub

Sana leads the multi-provider integration work at AI Error Hub — comparing SDKs, benchmarking behavior across Claude, OpenAI, Gemini, and Bedrock, and writing the wrappers that make provider switches survivable. She spent the previous three years building AI-powered features for fintech and healthcare products where downtime costs are measured in per-minute revenue.