Porting a Production Workload Between OpenAI and Claude

Quick answer: The obvious differences are the easy half: endpoint shape, message roles, and max_tokens being mandatory on /v1/messages. What breaks production is token counts that do not transfer between vendors or even within one lineup, caching that rewards a different prompt layout, refusals returned as HTTP 200, reasoning defaults that vary by model and by API surface, and rate limits pinned to account spend.

Last updated: August 2026

A client asked us to move a document classification service from one frontier vendor to the other. The estimate they had been handed was two days, and the API surface really is about two days. The other three weeks went into things no migration guide mentions: an invoice well above the rate card, a cache hit rate that collapsed on day one, and a dashboard that stayed green while some traffic got answered by a different model.


This is not a which-is-better piece. We wrote that one: ChatGPT vs Claude vs Gemini. This is the port itself, in the order the problems arrive, with both directions in the tables. Every number was read off vendor documentation on 11 August 2026, and one is scheduled to change three weeks later.


The Easy Half, and Why Nobody Should Bill You For It


Endpoint shape, message roles, system prompt placement, streaming events, tool schemas. All mechanical: one engineer, one day, plus an afternoon of test fixes.


ConcernOpenAI (Responses API)Anthropic (Messages API)
EndpointPOST /v1/responsesPOST /v1/messages
AuthAuthorization: Bearerx-api-key plus anthropic-version
System promptinstructions or a developer-role itemTop level system, one per request
Output capmax_output_tokens, optionalmax_tokens, required
Tool definition{type, name, parameters, strict}{name, description, input_schema}
Tool round tripfunction_call then function_call_output, keyed by call_idtool_use then tool_result in a user message, keyed by id
Reasoning depthreasoning.effortoutput_config.effort
Why generation stoppedstatus plus incomplete_detailsstop_reason plus stop_details

Failure One: Token Counts Do Not Transfer, Even Inside One Lineup


Two vendors quoting the same dollar per million tokens are not quoting the same price, because they are not counting the same tokens. It holds inside one vendor's own family too. From Anthropic's pricing page on 11 August 2026: Claude 4.7 and later models use a newer tokenizer that produces approximately 30 percent more tokens for the same text, with the exact increase depending on content and workload shape. Sonnet 4.6 and earlier use the previous one. Moving up the lineup at an identical rate is a bigger bill for identical documents, and nothing on the rate card says so.


Do not estimate Claude tokens with tiktoken. It is OpenAI's tokenizer, and across our corpora it undercounts Claude by roughly 15 to 20 percent on prose and further on code. Anthropic's own instruction is to recount against the model you intend to deploy rather than reuse a number measured on an earlier one. POST /v1/messages/count_tokens is free and counts under whichever model ID you pass.


Python - re-price a migration against your real corpus
# Re-price a migration on your own corpus, not on the rate card.
# The only authoritative token count is the one the target vendor returns.
# pip install anthropic tiktoken
import glob
import tiktoken
from anthropic import Anthropic

client = Anthropic()
enc = tiktoken.get_encoding("o200k_base")   # OpenAI side, counted locally

# USD per 1M input tokens, read off the vendor pricing pages on 2026-08-11.
PRICE_IN = {
    "gpt-5.6-terra":   2.00,
    "claude-sonnet-5": 2.00,   # introductory; 3.00 from 2026-09-01
    "claude-opus-5":   5.00,
}

SYSTEM = open("prompts/classifier_system.txt", encoding="utf-8").read()
totals = {"gpt-5.6-terra": 0, "claude-sonnet-5": 0, "claude-opus-5": 0}

for path in sorted(glob.glob("corpus/*.txt"))[:500]:
    doc = open(path, encoding="utf-8").read()
    totals["gpt-5.6-terra"] += len(enc.encode(SYSTEM + doc))
    for model in ("claude-sonnet-5", "claude-opus-5"):
        r = client.messages.count_tokens(
            model=model,                    # counts under THAT model's tokenizer
            system=SYSTEM,
            messages=[{"role": "user", "content": doc}],
        )
        totals[model] += r.input_tokens

base = totals["gpt-5.6-terra"]
for model, tokens in totals.items():
    cost = tokens / 1e6 * PRICE_IN[model]
    print(f"{model:16} {tokens:>10,} tok  {tokens / base:5.2f}x  ${cost:8.2f} / 500 docs")

# count_tokens is free and rate limited separately (2,000 RPM on Start,
# 4,000 on Build, 8,000 on Scale as of 2026-08-11). Then replay the same
# 500 documents through both APIs for real and diff the usage blocks.

Run it before the business case. On one recent job the corpus repriced at 1.28x, turning a projected 20 percent saving into break-even. Headline prices, read 11 August 2026:


ModelInput / 1MCache read / 1MOutput / 1MNote
gpt-5.6-sol$5.00$0.50$30.001,050,000 context. Over 272k input: 2x input, 1.5x output, whole request
gpt-5.6-terra$2.00$0.20$12.001,050,000 context. Same 272k threshold, same multipliers
claude-opus-5$5.00$0.50$25.00Cache write $6.25 at 5 min, $10 at 1 hour
claude-sonnet-5$2.00$0.20$10.00Introductory to 31 Aug 2026, then $3.00 / $15.00
claude-haiku-4-5$1.00$0.10$5.004,096 token minimum cache prefix

Watch the Sonnet 5 row. An introductory rate expiring weeks after your research is the number that ends up in a board deck, quoted for a year.


Failure Two: Caching Semantics, Not Caching Existence


Both cache prompt prefixes, and not the same way, so a layout that is optimal on one is a cost regression on the other. The fix is reordering the prefix, not changing models.


Cache mechanicOpenAI (gpt-5.6 family)Anthropic (Claude 4.x and 5)
Opt in?Automatic above 1,024 tokensA cache_control breakpoint, or one top level
Write cost1.25x uncached input on gpt-5.6 and later, as cache_write_tokens1.25x base input at 5 min, 2x at 1 hour
Break evenSecond identical requestOne read at 5 min, two at 1 hour
Lifetime30 minutes on gpt-5.6 and later, the only supported value5 minutes default, 1 hour optional
Minimum prefix1,024 tokens512 to 4,096 tokens, by model
Routing controlprompt_cache_key, about 15 requests per minute per keyUp to 4 breakpoints per request
Silent failurePrefix hash covers the first 256 tokens or so, so a timestamp at position 0 kills every hitUnder the minimum, nothing caches, no error

The minimum prefix row catches people because it varies by model, not by vendor: 512 tokens on Opus 5, 1,024 on Sonnet 5 and Opus 4.8, 2,048 on Opus 4.7, 4,096 on Opus 4.6, 4.5 and Haiku 4.5. A 1,500 token system prompt caches on one and silently caches nothing on the next. If both usage cache counters read zero, you are paying full price.



Trying to get this past a proof of concept? Enterprise AI work rarely stalls on the model. It stalls on what sits underneath: retrieval that returns the wrong chunk, permissions that leak across tenants, and no agreed way to tell a good answer from a bad one. We build the pipeline, the access rules and the evaluation harness so the thing can actually go live.

Book an AI readiness call AI and ML services

Failure Three: A Refusal Is an HTTP 200


Claude Fable 5 and Claude Opus 5 carry safety classifiers that can decline a request. You get a normal 200, empty content, stop_reason set to refusal, and stop_details naming the category: cyber, bio, frontier_llm, reasoning_extraction or general_harms. Branch on stop_reason and nothing else. Both category and explanation come back null when a refusal maps to no named category, and that null is a permanent valid state rather than a placeholder. The explanation text is documented as unstable, so display it, never parse it. A refusal before any output is not billed, a mid-stream one bills what streamed, and both burn rate limit budget without tripping a status-code SLO.


Retrying elsewhere is opt in. Set fallbacks to "default" with the server-side-fallback-2026-07-01 beta header, or name up to three models. The answering model shows up in the top level model field and as a fallback_message entry in usage.iterations. The parameter is rejected inside batches and unavailable on Bedrock, Google Cloud and Microsoft Foundry, where SDK middleware covers it. Two gaps to size for: a category with no recommended fallback still refuses, and if the fallback model is itself rate limited or overloaded the retry never runs and the original refusal comes back. Fallbacks degrade to refusals under exactly the load that makes you want them.


Python - refusal-aware call with server-side fallback
# A refusal is HTTP 200. If you only catch exceptions, your success rate stays
# green while a share of traffic is answered by a different model.
from anthropic import Anthropic

client = Anthropic()
PRIMARY = "claude-opus-5"

def classify(document: str) -> dict:
    r = client.beta.messages.create(
        model=PRIMARY,
        max_tokens=1024,                     # required on /v1/messages
        fallbacks="default",                 # opt in; omit it and a refusal stands
        betas=["server-side-fallback-2026-07-01"],
        output_config={"effort": "medium"},  # pin it; the API default is "high"
        system="Classify the document. Reply with exactly one label.",
        messages=[{"role": "user", "content": document}],
    )

    served_by_fallback = any(
        it.type == "fallback_message" for it in (r.usage.iterations or [])
    )

    if r.stop_reason == "refusal":
        # content is empty. Not billed if nothing streamed, but it still
        # consumed rate limit budget. Count it as a failure, not a success.
        return {
            "ok": False,
            "reason": "refusal",
            "category": r.stop_details.category if r.stop_details else None,
            "model": r.model,
        }

    return {
        "ok": True,
        "label": r.content[0].text.strip(),
        "model": r.model,             # NOT always PRIMARY once fallbacks is on
        "fallback": served_by_fallback,
    }

# Emit r.model and served_by_fallback as metric dimensions from day one.
# Without them your eval numbers average over an unknown model mix.

Turn fallback on, but log the answering model from request one. Otherwise your eval set measures a mixture of two models and you lose a week on a routing change you mistook for a regression.


Failure Four: Reasoning Defaults Vary by Model and by Surface


Almost nobody sets this parameter on the first pass, which is the problem: both vendors pick a value for you, and they do not pick the same one. On Anthropic the parameter is output_config.effort, values low through max. The API default is high on Opus 4.7, 4.8, Opus 5, Sonnet 5, Sonnet 4.6 and Fable 5, and passing high is documented as identical to omitting it. Thinking tokens bill as output and count toward max_tokens, which is why the docs suggest 64k at xhigh or max.


On OpenAI the values run from none to max, each model supporting a subset, and the guidance page states GPT-5.6 defaults to medium if you omit it. Because the model reasons by default, developers report that a Chat Completions request carrying function tools fails with a 400 telling them to use /v1/responses or set reasoning_effort to none, on requests that never set it. That error string comes from the OpenAI forum and third-party issue trackers, not a docs page, but it matches OpenAI's own steer toward the Responses API for reasoning and tool calling.


So your notebook calls chat.completions without tools and works, your service calls it with tools and 400s, both on the same model ID. Pin effort explicitly in every environment, and sweep it on your evals rather than reusing the old model's level.


Failure Five: Rate Limits Follow the Account, Not the Contract


Throughput is priced off your payment history with that specific vendor. Your contract does not move it and the volume you were already running elsewhere does not either. A pipeline that ran fine on a three-year-old account gets 429s on a fresh one at identical volume, on cutover day.


ProviderTierQualifierMonthly capExample per-model limit
OpenAITier 1$5 paid$100gpt-5.6-sol: 500 RPM, 500k TPM
OpenAITier 5$1,000 paid$200,000gpt-5.6-sol: 15,000 RPM, 40M TPM
AnthropicStartSet from usage history$500Opus 5: 1,000 RPM, 2M ITPM, 400k OTPM
AnthropicScaleSet from usage history$200,000Opus 5: 10,000 RPM, 10M ITPM, 2M OTPM

OpenAI publishes per-model numbers on the model pages, not the rate limit guide. These rows were read for one model on 11 August 2026 without cross-checking model by model, so pull the numbers for the ID you deploy. New Anthropic organizations, and any with thin usage history, can land in the Evaluation tier below the published Start numbers.


Anthropic also documents acceleration limits: a sharp jump in usage produces 429s even inside your stated limit. A big-bang cutover is exactly that pattern, so ramp over days. The one asymmetry in your favour is that cache_read_input_tokens do not count toward ITPM on current Claude models, only uncached input and cache writes do, so the prefix work from failure two buys throughput as well as margin.


Tool Use and Structured Output: The Fields the Code Touches


The same tool on both providers (annotated, not literal JSON)
// The same tool on both providers. Small differences, all load bearing.

// OpenAI, POST /v1/responses
{
  "model": "gpt-5.6-terra",
  "tools": [{
    "type": "function",
    "name": "lookup_invoice",
    "description": "Fetch an invoice by number.",
    "parameters": {
      "type": "object",
      "properties": {"invoice_no": {"type": "string"}},
      "required": ["invoice_no"],
      "additionalProperties": false
    },
    "strict": true
  }],
  "reasoning": {"effort": "medium"},
  "text": {"format": {"type": "json_schema", "name": "result", "schema": { /* elided */ }}}
}
// Model emits a function_call item carrying call_id.
// You reply with a function_call_output item keyed by the SAME call_id.

// Anthropic, POST /v1/messages
{
  "model": "claude-sonnet-5",
  "max_tokens": 1024,
  "tools": [{
    "name": "lookup_invoice",
    "description": "Fetch an invoice by number.",
    "input_schema": {
      "type": "object",
      "properties": {"invoice_no": {"type": "string"}},
      "required": ["invoice_no"],
      "additionalProperties": false
    },
    "strict": true
  }],
  "output_config": {
    "effort": "medium",
    "format": {"type": "json_schema", "schema": { /* elided */ }}
  }
}
// strict on Anthropic needs BOTH required and additionalProperties: false.
// Ship it with only required and the schema is rejected.
//
// Model emits a tool_use content block carrying id.
// You reply with a tool_result content block, inside a USER message, whose
// tool_use_id matches that id. Wrong role, no error: the model just answers
// as if the tool never ran.

Two things bite. Anthropic expects tool_result inside a user message, and a wrong role produces no error, just an answer that ignores the tool. And tools inject a system prompt costing 286 tokens on Opus 5 at tool_choice: auto, 675 on Opus 4.7, per request: invisible in code, visible on the bill.


The Compatibility Shim Will Pass Your Tests and Wreck Your Budget


Anthropic ships an OpenAI SDK compatibility layer: point base_url at https://api.anthropic.com/v1/, swap the key and model name, and your client runs. Anthropic says plainly it is for testing and comparing capabilities, not a production-ready solution. Read what it drops against the failures above.



seed, logit_bias, the penalties and user go the same way, and none of it raises an error, so your tests pass. Use the shim for evaluation, then write the native client.


If the Workload Is Offline, Batch Changes the Arithmetic


Before arguing about a 20 percent rate difference, check whether the workload needs to be synchronous. Both vendors halve the price of asynchronous work, which dwarfs the cross-vendor gap.


Batch mechanicOpenAI Batch APIAnthropic Message Batches API
Discount50 percent, input and output50 percent, input and output
Turnaround24 hour window, the only optionMost finish inside 1 hour, unfinished work expires at 24
Size ceiling50,000 requests, 200 MB file100,000 requests or 256 MB
Watch out forWindow is not tunablefallbacks is rejected, max_tokens: 0 pre-warming unsupported

Batch and cache discounts stack on Anthropic. For a nightly enrichment job, moving to batch on your existing provider usually beats the whole migration. Fix the workload shape first.


The Sequence That Works



When I Would Not Do the Port at All


If the only driver is the rate card, do not port. A 20 percent headline gap disappears into a tokenizer that counts your documents differently, a cache prefix you have not rebuilt, effort defaults you have not swept, and a few weeks of engineering. On the last three we modelled, the saving landed inside the noise. The ports worth doing answer to something else.



None of that argues for staying put. The teams that get hurt are the ones treating a provider swap as a config change. Budget three weeks and keep the old client warm through one invoice.


Pranay Vatsal, Founder & CEO

Pranay Vatsal is the Founder & CEO of CelestInfo with deep expertise in Snowflake, data architecture, and building production-grade data systems for global enterprises.

Related Articles

Frequently Asked Questions

Q: Is there a drop-in adapter to migrate from the OpenAI API to the Anthropic API?

Yes. Anthropic hosts an OpenAI SDK compatibility layer at https://api.anthropic.com/v1/, so you swap the base URL, key and model name. Anthropic documents it as an evaluation tool rather than a production path. It ignores reasoning_effort, response_format and tool strict, and does not support prompt caching. Use it for evaluation, then write the native client.

Q: Will my costs go up or down when I switch LLM providers?

You cannot tell from the rate cards. Tokenizers differ between vendors, and Anthropic's docs state that Claude 4.7 and later models produce roughly 30 percent more tokens for the same text than earlier Claude models. Count several hundred real documents with the target model's own counter, then multiply by prices you have dated.

Q: How do I detect a refusal on the Anthropic API?

Check stop_reason for the value refusal. It arrives as HTTP 200 with empty content and a stop_details object naming the category: cyber, bio, frontier_llm, reasoning_extraction or general_harms. A refusal before any output is not billed but still consumes rate limit budget. Count it as a failure in your metrics, not a success.

Q: Does prompt caching work the same way on OpenAI and Anthropic?

No. OpenAI caches automatically above 1,024 tokens and hashes roughly the first 256 tokens for routing. Anthropic uses explicit cache_control breakpoints, up to four per request, with a minimum cacheable prefix from 512 to 4,096 tokens depending on the model. Both charge 1.25x for cache writes on current models as of August 2026, and Anthropic's optional one-hour tier costs 2x instead.

Q: Why did my requests start getting 429s right after the migration?

Rate limits track account spend history, not your contract or your previous volume. A new organization starts at the entry tier on either provider. Both also throttle sharp ramps: Anthropic documents acceleration limits that fire on sudden usage increases even inside your stated limit. Ramp over days and request an increase before the cutover.

Q: Do I have to set max_tokens on the Anthropic Messages API?

Yes. max_tokens is required on /v1/messages, unlike OpenAI's optional max_output_tokens. It is also a hard ceiling on thinking plus visible response text, so a value tuned on a non-reasoning model will truncate answers. Anthropic suggests starting around 64k at xhigh or max effort on Opus 4.7 and later.