Every "Claude vs GPT" comparison that leads with a quality verdict is out of date before it is published. Both families release new versions on a cadence no article can track, each release reshuffles whatever ranking the previous one established, and the differences that matter for your workload rarely survive the aggregation into a single score.
What does not churn every few months is the shape of the API. How the system prompt is expressed, how tools are declared, who owns the conversation transcript, how structured output is guaranteed, and what the streaming events look like — these are architectural decisions that have been stable across many releases, and they determine how much of your code you would have to rewrite to switch. That is the comparison worth writing down.
The question that is actually answerable
"Which model is better?" has no portable answer, because model quality is not one dimension and your evaluation set is not anyone else's. Public leaderboards measure tasks that resemble your product roughly as much as a driving test resembles a commute.
Three questions do have answers, and they are the ones to ask:
- What does the integration cost? Not tokens — engineering. How much of your code is provider-shaped?
- What guarantees does the API give? Schema-valid output, deterministic tool argument shapes, explicit stop reasons.
- What does it do on your evaluation set? Which you must build, because nobody else has your inputs.
The first two are inspectable from the documentation right now. The third takes an afternoon and settles the argument permanently.
The system prompt lives in different places
Anthropic's Messages API treats the system prompt as a top-level parameter. It is not a message, and it does not participate in the user/assistant alternation.
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: process.env.ANTHROPIC_MODEL,
max_tokens: 1024,
system: 'You are a release-notes editor. Answer only from the diff provided.',
messages: [{ role: 'user', content: diff }],
}),
});OpenAI's Chat Completions API places instructions in the message array as their own
role, and the newer Responses API exposes them as an instructions field alongside
input.
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: process.env.OPENAI_MODEL,
messages: [
{ role: 'system', content: 'You are a release-notes editor. Answer only from the diff provided.' },
{ role: 'user', content: diff },
],
}),
});Two consequences follow from something that looks cosmetic. First, max_tokens is
required on Messages and optional elsewhere — a small thing that will bite once. Second,
because Anthropic's system prompt is structurally separate from the conversation, it
cannot be displaced or confused by conversational content in the same way an in-array
system message can. That is a real property when untrusted text is entering the
transcript, though it is emphatically not a defence on its own — see
why prompt injection is an architecture problem.
Tool definitions and the return trip
Both providers accept JSON Schema for tool parameters. The nesting and the vocabulary differ.
| Anthropic Messages | OpenAI Chat Completions | |
|---|---|---|
| Tool declaration | tools: [{ name, description, input_schema }] | tools: [{ type: 'function', function: { name, description, parameters } }] |
| Schema key | input_schema | parameters |
| Model asked for a tool | stop_reason: 'tool_use' | finish_reason: 'tool_calls' |
| Where the request appears | A tool_use content block | message.tool_calls[] |
| Arguments format | Parsed object | JSON string, you parse it |
| Sending results back | user message with tool_result blocks | Messages with role: 'tool' and tool_call_id |
| Correlation id | tool_use_id | tool_call_id |
The arguments row causes the most real bugs. OpenAI returns tool arguments as a JSON
string, so a malformed generation surfaces as a JSON.parse throw in your code rather
than as a validation failure you can hand back to the model. Wrap it:
export function parseToolArguments(raw) {
try {
return { ok: true, value: JSON.parse(raw) };
} catch (error) {
return { ok: false, error: `Arguments were not valid JSON: ${error.message}` };
}
}Returning that error to the model as a tool result — rather than throwing — is what lets the run recover. The complete guide to AI agents covers why every failure inside the loop should become an observation rather than an exception.
The other row worth internalising is the return trip. Anthropic models results as user content blocks; OpenAI models them as separate messages with a dedicated role. Any abstraction you write has to normalise this, and it is the single most annoying part of a provider-agnostic layer.
Structured output: two philosophies
Both providers can force a response to match a schema, by different mechanisms.
OpenAI's structured outputs accept a JSON Schema with strict: true and constrain
generation so the result conforms. The guarantee is at the sampling level.
response_format: {
type: 'json_schema',
json_schema: {
name: 'release_note',
strict: true,
schema: {
type: 'object',
properties: {
summary: { type: 'string' },
breaking: { type: 'boolean' },
areas: { type: 'array', items: { type: 'string' } },
},
required: ['summary', 'breaking', 'areas'],
additionalProperties: false,
},
},
}The common Anthropic idiom achieves the same end through the tool mechanism: define a
single tool whose input schema is your desired output shape, and read the arguments of
the resulting tool_use block.
tools: [
{
name: 'emit_release_note',
description: 'Emit the structured release note. Call this exactly once.',
input_schema: {
type: 'object',
properties: {
summary: { type: 'string' },
breaking: { type: 'boolean' },
areas: { type: 'array', items: { type: 'string' } },
},
required: ['summary', 'breaking', 'areas'],
},
},
],
tool_choice: { type: 'tool', name: 'emit_release_note' },Practical difference: strict mode restricts which JSON Schema features you may use,
so an existing schema with exotic constructs may need flattening. The tool-shaped
approach is more permissive about schema features but leaves you validating the result
yourself. Either way, validate on receipt. A schema that constrains structure does not
constrain whether breaking is true when it should be.
Who holds the transcript
Chat Completions and Messages are both stateless: you resend the full conversation each turn. Your database is the source of truth, replay is trivial, and cost grows with transcript length because every prior turn is re-tokenised on every call.
OpenAI's Responses API adds a stateful mode where the provider retains the conversation and you reference a previous response by id. That reduces what you send and simplifies multi-turn code. It also puts conversation state somewhere you do not control, which has retention, residency and portability implications worth deciding deliberately rather than by default.
For agent loops the stateless model is usually easier to live with, because you often want to edit the transcript — summarising old tool results, dropping a failed branch, truncating an oversized observation. That is straightforward when the array is yours.
Streaming, interruption and caching
Both stream over server-sent events, and both emit incremental deltas plus terminal
events carrying usage. The event vocabularies differ enough that you will write two
parsers, and the tool-call streaming semantics differ more than the text streaming
does — Anthropic streams tool input as JSON deltas that must be accumulated, and
OpenAI streams tool_calls fragments indexed by position.
Both offer prompt caching to avoid re-billing a long stable prefix, and both make it worth structuring prompts so the invariant part comes first. That is a genuine architectural constraint: put the system prompt, tool definitions and any large fixed context at the top, and the volatile per-request content at the bottom.
What you cannot compare from outside
Be honest about the limits. Refusal behaviour, tone under ambiguity, willingness to say "I don't know", and long-context recall are all real differences developers notice — and none of them can be quantified from the outside without a controlled evaluation on representative inputs. Anyone publishing a number for these on a general basis is reporting their workload, not yours.
Latency and throughput are similarly unquotable in an article: they depend on region, tier, prompt length, output length and time of day.
Write the seam, then stop worrying
The engineering answer is to make the choice reversible. One interface, two adapters, everything else provider-agnostic:
export function getProvider(name = process.env.MODEL_PROVIDER) {
switch (name) {
case 'anthropic':
return anthropicAdapter;
case 'openai':
return openaiAdapter;
default:
throw new Error(`Unknown model provider: ${name}`);
}
}
// Every adapter implements:
// complete({ system, messages, tools, maxTokens, signal })
// -> { text, toolCalls: [{ id, name, arguments }], stopReason, usage }Normalise to your vocabulary, not to either provider's. Once stopReason is your own
enum and tool arguments are always a parsed object, switching providers becomes a
config change and running both side by side on the same evaluation set becomes trivial —
which is the only way the quality question ever gets answered for your product.
Choosing
Choose on the properties that persist: which structured-output mechanism fits your existing schemas, whether you want conversation state on your side or the provider's, what your compliance position requires about data handling, and — decisively — what your own evaluation set says on tasks that look like your product.
Then keep the seam. The comparison that matters is not Claude against GPT; it is your current provider against the version of itself that ships next quarter, measured on inputs you actually care about. If the surrounding tooling is the real question, the category-level assessment in best AI coding tools for developers covers the layer above the API.