Anthropic Messages, Chat Completions, and Responses: how to choose, convert, and understand compatibility limits
An HTTP 200 response does not mean an Agent is compatible. This guide uses three complete tool-call round trips to explain the real differences among Messages, Chat Completions, and Responses, with migration, testing, and troubleshooting steps.
Contents
An Agent can still receive 200 after you change the endpoint and field names, yet stop executing tools, return JSON that violates the schema, or suddenly lose multi-turn context. This usually does not mean the model has become “less capable.” It means the application treated three different protocols as if they were the same interface.
A successful migration has at least three layers:
- The format is accepted: the server can parse the request and returns a success status.
- The behavior is equivalent: tools are called, results are handed back correctly, streams finish cleanly, and multi-turn context remains coherent.
- The capabilities are preserved: strict schemas, native reasoning state, hosted tools, structured outputs, and similar features are not silently ignored or downgraded.
HTTP 200 proves only the first layer. A simple conversion may be enough for a request that produces one block of text. Once an Agent uses tools, streamed arguments, multi-turn state, or reasoning models, you need to validate the entire interaction chain.
The practical answer: choose the protocol for the client and required capabilities, not the model name
| Scenario | Better starting point | Why |
|---|---|---|
An existing application already uses the OpenAI SDK and messages reliably | Chat Completions | It requires the fewest changes, and the existing message and tool loop can remain in place |
| A new OpenAI Agent needs hosted tools, typed Items, or server-side state continuation | Responses | OpenAI currently recommends it for new projects, and it exposes a broader Agent feature set |
| Claude Code, a native Claude application, or a workflow that depends on Claude-specific capabilities | Anthropic Messages | Content blocks, tool results, thinking, and related behavior follow Anthropic’s native contract |
| A custom gateway or multi-model router | Keep a separate adapter for each upstream protocol | One “universal JSON” object cannot represent every native capability without loss |
OpenAI still supports Chat Completions, so a stable production application does not need an immediate rewrite merely because a newer interface exists. Migration makes more sense for new projects or when you need Responses-native capabilities. Anthropic Messages is also not an OpenAI interface with a field renamed to messages: its content blocks, tool handoff, stream events, and state rules form a separate contract.
The core differences among the three APIs
In this article, Completions means Chat Completions, not the legacy /v1/completions endpoint.
| Dimension | OpenAI Chat Completions | OpenAI Responses | Anthropic Messages |
|---|---|---|---|
| Endpoint | /v1/chat/completions | /v1/responses | /v1/messages |
| Primary input | messages | input Items, with simple message input also accepted | messages, usually with a separate top-level system field |
| Primary output | choices[].message | Typed Items in output[] | Content blocks in content[] |
| Tool definition | tools[].function | name and parameters appear directly in tools[] | input_schema inside tools[] |
| Tool arguments | function.arguments, a JSON string | arguments, a JSON string | tool_use.input, a JSON object |
| Correlation ID | tool_calls[].id | call_id | tool_use.id |
| Returning tool results | role: "tool" plus tool_call_id | function_call_output plus call_id | tool_result plus tool_use_id inside a user message |
| Multi-turn state | The application replays message history | Replay Items, use previous_response_id, or use Conversations | The application replays messages and content blocks |
| Final structured output | response_format | text.format | output_config.format |
| Streaming | choices[].delta | Typed Responses events | Message and content-block events |
The table can make the protocols look like a simple field-renaming exercise. The real failures usually happen on the second request: after the model emits a tool call, how does the application execute it, which ID must it preserve, and in what role and order must it return the result? The following sections walk through the same side-effect-free task in all three protocols.
One shared example: look up a test plan
The user asks:
Look up the
teamplan and tell me whether usage-based overages are supported.
The tool is named get_plan_info. It reads fixed local data and has no external side effects, which makes it suitable for protocol-migration tests.
The plan data below is synthetic teaching data. It does not describe real OpenAI, Anthropic, or BetterToken plans, prices, or entitlements. The three request-and-response sequences demonstrate protocol structure and are not records of live API executions.
The application-side tool can be written as a protocol-independent function:
from __future__ import annotations
import json
from typing import Any
PLAN_FIXTURES: dict[str, dict[str, Any]] = {
"team": {
"plan_code": "team",
"display_name": "Team",
"billing_mode": "usage_based",
"included_requests": 10_000,
"overage_allowed": True,
"source_version": "fixture-2026-09-01",
}
}
def execute_tool(name: str, raw_arguments: str | dict[str, Any]) -> str:
"""Run the read-only teaching tool and return a JSON string that can be sent directly to the model."""
if isinstance(raw_arguments, str):
arguments = json.loads(raw_arguments)
elif isinstance(raw_arguments, dict):
arguments = raw_arguments
else:
raise TypeError("tool arguments must be a JSON string or object")
if name != "get_plan_info":
raise ValueError(f"unknown tool: {name}")
if set(arguments) != {"plan_code"}:
raise ValueError("get_plan_info only accepts plan_code")
plan_code = arguments["plan_code"]
if not isinstance(plan_code, str):
raise TypeError("plan_code must be a string")
plan = PLAN_FIXTURES.get(plan_code)
if plan is None:
return json.dumps(
{"ok": False, "error": "plan_not_found", "plan_code": plan_code},
ensure_ascii=False,
)
return json.dumps({"ok": True, "data": plan}, ensure_ascii=False)
Even when the request enables a strict schema, the application should keep its own input validation. Strict mode constrains model-generated tool arguments; it does not replace authorization, enum validation, idempotency, or security checks in the business layer.
Chat Completions: a complete tool round trip
First request: ask the model to produce a tool call
The examples below use OpenAI’s official endpoint to demonstrate the protocol. When connecting to a compatible service, replace the Base URL, authentication method, and Model ID according to that provider’s documentation.
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"messages": [
{
"role": "system",
"content": "You are a plan assistant. Answer only from the data returned by the tool; do not guess."
},
{
"role": "user",
"content": "Look up the team plan and tell me whether usage-based overages are supported."
}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_plan_info",
"description": "Look up fixed test data by plan code",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
}
],
"tool_choice": "required",
"parallel_tool_calls": false
}'
The application needs to read tool_calls from the assistant message. The following response keeps only the fields needed by the next step:
{
"choices": [
{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_plan_001",
"type": "function",
"function": {
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}
Two values must not be lost:
tool_calls[0].id: send it back unchanged astool_call_idin the second request.function.arguments: this is a JSON string. Parse it first, then apply your own schema and business validation.
Execute the tool:
tool_result = execute_tool(
"get_plan_info",
"{\"plan_code\":\"team\"}",
)
Second request: return the tool result to the model
Chat Completions requires the assistant message containing the original tool call to remain in history, followed by a result message with role: "tool".
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"messages": [
{
"role": "system",
"content": "You are a plan assistant. Answer only from the data returned by the tool; do not guess."
},
{
"role": "user",
"content": "Look up the team plan and tell me whether usage-based overages are supported."
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_plan_001",
"type": "function",
"function": {
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_plan_001",
"content": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"display_name\":\"Team\",\"billing_mode\":\"usage_based\",\"included_requests\":10000,\"overage_allowed\":true,\"source_version\":\"fixture-2026-09-01\"}}"
}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_plan_info",
"description": "Look up fixed test data by plan code",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
}
]
}'
A representative final message is:
{
"choices": [
{
"message": {
"role": "assistant",
"content": "The Team plan supports usage-based overages. The test data includes 10,000 requests, and overage_allowed is true."
},
"finish_reason": "stop"
}
]
}
If an adapter converts only the first user turn but does not retain the assistant’s tool_calls, or if it places the wrong ID in tool_call_id, the second request is no longer a continuation of the same tool call.
Responses: a complete tool round trip
Responses represents messages, reasoning, tool calls, and tool results as different Item types. Do not assume that output[0] is always final text; branch on the type of each Item.
First request: ask the model to return a function_call Item
curl https://api.openai.com/v1/responses \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"instructions": "You are a plan assistant. Answer only from the data returned by the tool; do not guess.",
"input": "Look up the team plan and tell me whether usage-based overages are supported.",
"tools": [
{
"type": "function",
"name": "get_plan_info",
"description": "Look up fixed test data by plan code",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
],
"tool_choice": "required",
"parallel_tool_calls": false,
"store": false
}'
A representative tool-call Item is:
{
"id": "resp_plan_001",
"object": "response",
"output": [
{
"type": "function_call",
"id": "fc_plan_001",
"call_id": "call_plan_001",
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}",
"status": "completed"
}
]
}
Use call_id to correlate the tool result. id: "fc_plan_001" is the ID of the Item itself and must not replace call_id.
Execute the tool:
tool_result = execute_tool(
"get_plan_info",
"{\"plan_code\":\"team\"}",
)
Second request: return a function_call_output
The following example uses stateless manual replay, so it sends the instructions, original user input, tool call, and tool result again.
curl https://api.openai.com/v1/responses \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"instructions": "You are a plan assistant. Answer only from the data returned by the tool; do not guess.",
"input": [
{
"role": "user",
"content": "Look up the team plan and tell me whether usage-based overages are supported."
},
{
"type": "function_call",
"call_id": "call_plan_001",
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}"
},
{
"type": "function_call_output",
"call_id": "call_plan_001",
"output": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"display_name\":\"Team\",\"billing_mode\":\"usage_based\",\"included_requests\":10000,\"overage_allowed\":true,\"source_version\":\"fixture-2026-09-01\"}}"
}
],
"tools": [
{
"type": "function",
"name": "get_plan_info",
"description": "Look up fixed test data by plan code",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
],
"store": false
}'
A representative final output Item is:
{
"id": "resp_plan_002",
"object": "response",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "The Team plan supports usage-based overages. The test data includes 10,000 requests, and overage_allowed is true."
}
]
}
]
}
If you choose server-side state continuation, you can allow the first response to be stored and then use the following form in the second request:
{
"model": "YOUR_OPENAI_MODEL",
"previous_response_id": "resp_plan_001",
"input": [
{
"type": "function_call_output",
"call_id": "call_plan_001",
"output": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"overage_allowed\":true}}"
}
]
}
A previous_response_id belongs to the upstream service that created that response. It cannot be handed to a different provider for continuation. It also does not make prior input free: OpenAI’s current documentation states that earlier input tokens in the chain are still billed as input.
When a response contains a reasoning Item, stateless replay must also preserve the relevant Item as required by the documentation. You cannot discard it to create a “uniform format” and still claim that the reasoning context is equivalent.
Anthropic Messages: a complete tool round trip
Messages represents a tool call as a tool_use block in assistant content and returns the result as a tool_result block in the next user message. Tool arguments are already an object rather than a JSON string that still needs parsing.
First request: ask Claude to return tool_use
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "YOUR_CLAUDE_MODEL",
"max_tokens": 512,
"system": "You are a plan assistant. Answer only from the data returned by the tool; do not guess.",
"messages": [
{
"role": "user",
"content": "Look up the team plan and tell me whether usage-based overages are supported."
}
],
"tools": [
{
"name": "get_plan_info",
"description": "Look up fixed test data by plan code",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
],
"tool_choice": {
"type": "tool",
"name": "get_plan_info"
}
}'
Forcing a specific tool depends on support in the selected model and configuration. If the target model does not support it, use auto and verify in the application that a tool call was actually returned.
A representative response is:
{
"id": "msg_plan_001",
"type": "message",
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_plan_001",
"name": "get_plan_info",
"input": {
"plan_code": "team"
}
}
],
"stop_reason": "tool_use"
}
The input object can be passed directly to the tool executor:
tool_result = execute_tool(
"get_plan_info",
{"plan_code": "team"},
)
Second request: place tool_result in the immediately following user message
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "YOUR_CLAUDE_MODEL",
"max_tokens": 512,
"system": "You are a plan assistant. Answer only from the data returned by the tool; do not guess.",
"messages": [
{
"role": "user",
"content": "Look up the team plan and tell me whether usage-based overages are supported."
},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_plan_001",
"name": "get_plan_info",
"input": {
"plan_code": "team"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_plan_001",
"content": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"display_name\":\"Team\",\"billing_mode\":\"usage_based\",\"included_requests\":10000,\"overage_allowed\":true,\"source_version\":\"fixture-2026-09-01\"}}"
}
]
}
],
"tools": [
{
"name": "get_plan_info",
"description": "Look up fixed test data by plan code",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
]
}'
A representative final response is:
{
"id": "msg_plan_002",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "The Team plan supports usage-based overages. The test data includes 10,000 requests, and overage_allowed is true."
}
],
"stop_reason": "end_turn"
}
Messages has explicit ordering requirements: the tool_result must immediately follow the assistant message that contains the matching tool_use. If one assistant turn produces several client-side tool calls, return all corresponding result blocks in the next user message and correlate each one with tool_use_id. If that same user message also contains ordinary text, place the tool-result blocks before the text.
What maps directly, and what is inevitably lossy
| Capability | Conversion assessment | Correct handling |
|---|---|---|
| Ordinary user text | Usually maps directly | Preserve text, ordering, and multimodal types rather than copying only visible strings |
| Basic function schema | Can be reshaped | Convert among function.parameters, Responses parameters, and Messages input_schema, then revalidate the supported JSON Schema subset |
| Tool arguments | The data type must be converted | The two OpenAI interfaces usually return JSON strings; Messages returns an object. Normalize, parse, and validate before entering business code |
| Tool-call ID | Preserve the meaning, but do not reuse namespaces | Maintain an internal canonical call ID together with the original upstream ID, and return the protocol-specific field |
| Parallel tool calls | Supported in principle, but never correlate by array position | Match every result through tool_call_id, call_id, or tool_use_id |
| system/developer instructions | May be lossy | Distinguish global, session-stage, and single-turn scope; explicitly downgrade or reject when the target protocol cannot express the original scope |
| Final structured output | Fields are not mechanically interchangeable | Chat uses response_format, Responses uses text.format, and Messages uses output_config.format |
| Streamed tool arguments | Require a protocol-specific parser | Accumulate fragments by event and call ID, and parse JSON only after the completion event |
| Multi-turn server-side state | No universal equivalent exists | State IDs such as previous_response_id are bound to their original upstream; replay visible context or use sticky routing across upstreams |
| thinking/reasoning state | Usually cannot be converted without loss | Preserve opaque Items, thinking blocks, signatures, or encrypted content exactly as required by the native protocol; never fabricate them |
| Hosted tools | Often have no direct equivalent | Declare support and fallback behavior separately for web search, file search, computer use, server tools, and similar features |
| Multiple candidate generation | May have no equivalent | Do not assume Chat Completions n maps directly to Responses; issue multiple application-level requests or change product behavior |
The most reliable internal abstraction for a gateway is therefore not one giant object containing every possible field. Model the semantics separately: messages, instruction scope, tool definitions, tool calls, tool results, state handles, stream events, and opaque native state. When a capability cannot be represented, return an explicit “unsupported” or “lossy conversion” status instead of silently deleting the field.
strict, response_format, and text.format solve different problems
A common migration mistake is to treat “valid tool arguments” and “a final answer in a specified JSON shape” as one feature.
| Goal | Chat Completions | Responses | Anthropic Messages |
|---|---|---|---|
| Constrain tool-call arguments | tools[].function.strict | tools[].strict | tools[].strict |
| Constrain the final model output | response_format | text.format | output_config.format |
A tool’s strict setting constrains how the model calls the function. Final structured output constrains the content returned to the user. An Agent may need both at once: call a tool with strict arguments, then return the final result under a fixed JSON Schema.
OpenAI’s current documentation also contains an easy-to-miss default difference:
- Function calls in Chat Completions are non-strict by default.
- When
strictis omitted in Responses, the service attempts to normalize the schema into strict mode. If the schema is incompatible, it may fall back to non-strict mode and showstrict: falsein the parsed tool definition.
To make intent explicit and avoid depending on interface-specific defaults, production requests should set strict: true or strict: false deliberately. A strict schema must also meet the relevant requirements, such as disallowing extra object properties and listing every required field.
More importantly, a compatibility layer may accept a field without enforcing it. Anthropic’s official OpenAI SDK compatibility documentation states that, in that particular layer, fields including function strict, response_format, and reasoning_effort are ignored, and most unsupported fields do not produce errors. A request can therefore return 200 even though the schema or reasoning setting never took effect.
That does not mean native Anthropic Messages lacks equivalent capabilities. Native Messages supports strict tool input and uses output_config.format for final JSON. Troubleshooting must begin by answering one question: are you calling native Messages, or an OpenAI-compatible layer?
system, developer, and instruction scope cannot be preserved by concatenating strings alone
OpenAI-style interfaces allow different roles inside message history, while Responses also exposes instructions. Anthropic Messages has traditionally used a top-level system field. As of September 2026, some current models also support mid-conversation role: "system", but not every model does, and placement and tool-ordering constraints apply.
At the same time, Anthropic’s OpenAI SDK compatibility layer collects system/developer messages from the conversation, joins them with newlines, and promotes them into one system prompt at the beginning. That makes the request usable, but changes the original timing and scope. A developer instruction intended to start only at turn eight may influence the semantics of the first seven turns after being moved to the start.
A safer adapter first distinguishes three scopes internally:
- Global instructions: apply to the entire conversation.
- Conversation-stage instructions: take effect from a particular turn onward.
- Single-turn instructions: control only the current task.
Map an instruction only when the target protocol can express the same scope. Otherwise choose an explicit strategy: keep the request on a model that supports the capability, downgrade the instruction and record the difference, or reject the migration. Silent concatenation takes little code, but it is a common cause of “the request succeeded, but the behavior changed.”
Streaming requires a state machine, not simple text-token concatenation
All three interfaces support streaming, but their events are not equivalent:
- Chat Completions usually accumulates text and
tool_callsfragments fromchoices[].delta. - Responses emits typed events such as
response.output_text.delta,response.function_call_arguments.delta,response.function_call_arguments.done,response.completed, anderror. - Messages uses
message_start,content_block_start,content_block_delta,content_block_stop,message_delta, andmessage_stop; tool arguments arrive in fragments throughinput_json_delta.partial_json.
Tool arguments may be split like this:
{"plan_
code":"te
am"}
None of these fragments is valid JSON by itself. Accumulate them by call ID or content-block index, then parse only after receiving the completion event for those arguments:
from __future__ import annotations
import json
from collections import defaultdict
from typing import Any
class ToolArgumentAssembler:
def __init__(self) -> None:
self._buffers: dict[str, list[str]] = defaultdict(list)
def add_delta(self, call_id: str, fragment: str) -> None:
self._buffers[call_id].append(fragment)
def finish(self, call_id: str) -> dict[str, Any]:
if call_id not in self._buffers:
raise KeyError(f"unknown call_id: {call_id}")
raw = "".join(self._buffers.pop(call_id))
value = json.loads(raw)
if not isinstance(value, dict):
raise TypeError("tool arguments must decode to an object")
return value
def discard(self, call_id: str) -> None:
self._buffers.pop(call_id, None)
The adapter should also record an explicit terminal state:
created -> receiving -> completed
\-> failed
\-> disconnected
disconnected is not completed. Anthropic Messages can report an event: error inside the stream after the HTTP connection has already succeeded, and Responses has separate error events as well. Looking only at the initial HTTP status, or treating a closed connection as a natural completion, can truncate tool arguments or the final answer.
The event parser should also tolerate unknown event types: log and skip events that do not affect the currently supported capability instead of crashing the entire client whenever the server adds an event.
Multi-turn state and reasoning state cannot be fabricated
Chat Completions and traditional Messages flows usually rely on the application replaying history. Responses can also maintain server-side state through previous_response_id or Conversations. Their notion of “the previous turn” is not interchangeable.
When a gateway receives a state ID, it has only three valid strategies:
- Sticky routing: send later requests to the same upstream that created the state.
- Complete replay: resend every message, tool call, tool result, and native-state element that can legally be replayed.
- Explicit rejection: if the target upstream cannot continue the state, return a diagnosable error and let the client restart the conversation.
Do not pass an OpenAI previous_response_id to Anthropic, and do not pretend that an internal gateway conversation ID is a state handle another provider understands.
Reasoning state cannot be solved by renaming fields either:
- In stateless or specific data-retention configurations, Responses can return encrypted reasoning Items that must be replayed in a later request.
- Anthropic thinking workflows can include thinking blocks, signatures, or other opaque state. Tool use and multi-turn conversations must preserve them according to the native documentation.
- As of September 2026, Anthropic’s manual
thinking.type: "enabled"withbudget_tokensis deprecated on 4.6-generation models and rejected on 4.7 and later generations; newer models use adaptive thinking and the corresponding effort control.
You therefore cannot create a permanent rule that equates OpenAI reasoning_effort with Anthropic budget_tokens. A correct capability description includes the target model, its current thinking mode, and the fallback behavior when that mode is unavailable.
Parallel tool calls: correlate by ID, never by array position
A model may request several tools in one turn. Their execution times differ, so results may return in a different order from the calls. The adapter needs a relationship like this:
canonical_call_id
-> provider
-> provider_call_id
-> tool_name
-> validated_arguments
-> execution_status
-> result
When returning results:
- Chat Completions creates one
role: "tool"message for each result and supplies the matchingtool_call_id. - Responses creates one
function_call_outputItem for each result and supplies the matchingcall_id. - Messages places matching
tool_resultblocks in the immediately following user turn and supplies each correspondingtool_use_id.
During migration testing, start with parallel_tool_calls: false and make the single-tool path work before enabling parallel execution. In production, tools with side effects—sending email, charging an account, or creating resources—also need idempotency keys. Network retries, stream disconnects, or upstream replay can deliver the same semantic call again; model-generated wording alone cannot tell you whether it has already run.
Why HTTP 200 is not enough to validate compatibility
A meaningful migration test should cover at least these paths:
| Test | Passing criterion |
|---|---|
| Ordinary text | The content is readable, and system/developer scope behaves as expected |
| Single tool call | Tool name, arguments, call ID, tool result, and final answer form a complete round trip |
| Parallel tool calls | Every result is matched by ID, without crossing or dropping calls |
| Streamed tool arguments | Fragments assemble completely, and the resulting JSON parses after completion |
| Strict tool schema | Invalid fields and types are rejected as expected or downgraded explicitly |
| Final structured output | The final answer satisfies the specified schema, not merely “looks like JSON” |
| Tool execution error | The model receives a structured error and does not loop forever or invent success |
| Multi-turn continuation | The second turn can refer to facts from the first, and state-switching rules are explicit |
| reasoning/thinking | Every claimed mode works, and native state is neither deleted nor fabricated |
| In-stream errors and disconnects | The client distinguishes completion, failure, and connection interruption |
| Controlled API error | Error type, request ID, and retry policy remain diagnosable |
Use fixed inputs and fixed tool fixtures, and record the following separately for each protocol:
- whether the final business result is equivalent;
- whether the tool call and result handoff are complete;
- P50 and P95 latency;
- input, output, and cache-related usage;
- error type, request ID, and terminal state;
- which capabilities were explicitly downgraded.
Do not log API Keys, full sensitive prompts, or private user output. At minimum, error logs should retain HTTP status, upstream error type/code, a short error message, request ID, endpoint, protocol, Model ID, and stream terminal state. Otherwise model_not_found, missing permissions, and incompatible paths can all collapse into one undiagnosable 400.
Troubleshooting by symptom: where did the Agent break?
| Symptom | Common cause | How to diagnose and fix it |
|---|---|---|
The request returns 200, but the model never calls a tool | The tool definition was not sent, tool_choice was ignored, the model does not support tools, or the prompt is insufficient | Print the final outbound request; check the target model and compatibility layer; expose only one read-only tool in the test and force or explicitly request its use |
| The model returns a tool call, but the application does not execute it | The application still reads an old field, such as only message.content | Read tool_calls, a function_call Item, or a tool_use block according to the protocol |
| Tool-argument JSON fails to parse | A streaming fragment was treated as complete JSON, or an object was parsed again as a string | Wait for the argument-completion event; first determine whether the value is a string or an object |
Extra fields appear despite strict | The compatibility layer silently ignores it, the schema does not satisfy strict-mode rules, or the request is not hitting the native endpoint | Verify the final endpoint and documentation; set strict explicitly; add a regression test that deliberately violates the schema |
| The second request says a tool result is missing | The call ID does not match, or the first assistant/tool Item was not retained | Store the upstream call and its ID unchanged; return the result immediately where the protocol requires it |
Messages returns tool_use ids ... without tool_result | The tool_result does not immediately follow the tool call, or ordinary text was inserted before it | Put all matching tool_result blocks in the next user message and place them before optional text |
| Streaming stalls or yields only half of the arguments | The client waits only for a text-ending marker and does not handle tool-argument or error terminal states | Implement a separate event state machine for each protocol and distinguish completed, failed, error, and disconnected |
| The second turn forgets the first | History, tool calls, or result Items were omitted; or previous_response_id belongs to another upstream | Replay the full visible context or keep sticky routing; never pass state IDs across providers |
| A system instruction starts taking effect too early after switching interfaces | The compatibility layer promoted a mid-conversation system/developer instruction to the beginning | Model instruction scope explicitly; downgrade visibly or keep the native protocol when lossless mapping is impossible |
| A tool runs twice | A request was retried, a stream was replayed after a disconnect, or idempotency controls are absent | Use read-only tools during testing; derive idempotency keys for production side-effect tools from a canonical call ID |
| The final content is JSON, but fields are intermittently missing | The prompt merely says “return JSON” instead of enabling structured output | Use response_format, text.format, or output_config.format for the relevant interface, then validate again in the application |
A safer migration sequence
- Identify the protocol the client actually sends. Do not infer it from the model name. Record the full endpoint, SDK method, top-level request fields, and stream-event types.
- List the behaviors that must remain intact. At minimum: tools, parallel calls, strict schemas, final structured output, multi-turn state, streaming, and thinking/reasoning.
- Prefer the native protocol first. When a capability can be implemented directly through native Messages or native Responses, avoid an extra compatibility layer.
- Build a conversion capability matrix. Mark every feature as fully supported, supported with loss, or unsupported, and expose that result to the caller.
- Run a complete two-request loop with a side-effect-free fixture. Do not stop after proving that one request returns text; execute the tool and return its result.
- Then test parallelism, streaming, and error paths. Enable side-effecting tools and real business traffic only after the normal path passes.
- Increase traffic gradually and compare metrics. Monitor correctness, latency, usage, errors, and duplicate tool execution—not only HTTP success rate.
Choosing the matching BetterToken entry point
BetterToken exposes different connection paths for different clients. The protocol is still determined by the wire contract the client actually uses:
- Chat Completions: the complete request URL is
https://www.bettertoken.ai/v1/chat/completions. For SDKs or tools that append the path automatically, the Base URL is usuallyhttps://www.bettertoken.ai/v1. See the Chat Completions API reference. - Codex / Responses: the current Codex documentation uses
base_url = "https://www.bettertoken.ai/v1"andwire_api = "responses"; Codex appends/responsesitself. See the Codex setup guide. - Claude Code / Messages: the current documentation uses
ANTHROPIC_BASE_URL=https://bettertoken.aiwithout adding/v1to the Base URL; the client appends/v1/messages. See the Claude Code setup guide.
Using the same Dashboard, API Key, or model name does not turn three protocols into one format. For an existing tool, choose the protocol that tool expects. For a custom Agent, validate the required capabilities against the complete round trips and acceptance matrix in this article.
Frequently asked questions
Does OpenAI-compatible mean a complete replica of the OpenAI API?
No. It usually means that certain endpoints and data structures can be called by OpenAI-style clients. Models, parameters, stream events, tools, structured outputs, hosted tools, and error semantics still need individual verification.
Can I replace only the Base URL and API Key?
Sometimes, for simple text requests that already use the same wire contract. A tool-using Agent still requires validation of tool definitions, the second-request result handoff, stream events, strict schemas, state, and errors. If a client expects Responses, /chat/completions alone is not sufficient; if it expects Messages, an OpenAI-style endpoint does not adapt automatically.
Can one universal adapter convert all three protocols?
It can cover ordinary text and part of a function-tool loop, but it should not claim full lossless support. Provider-managed state, hosted tools, opaque thinking/reasoning state, some system scopes, and model-specific capabilities often have no universal equivalent. The adapter should expose a capability matrix and downgrade information.
Why do unit tests pass while the real Agent still fails?
Many tests mock only the first model response. They do not validate the second-request tool-result handoff, parallel calls, streamed fragments, or state continuation. Extend the test to cover “user request → model tool call → application execution → tool-result handoff → final response” to reveal actual protocol failures.
Should a migration move Chat Completions or Responses first?
A stable Chat Completions application can continue running and migrate feature by feature according to business value. A new OpenAI Agent, or one that explicitly needs typed Items, hosted tools, or Responses state capabilities, is better started directly on Responses. The deciding factors are capability and migration cost, not whether the interface name sounds newer.