Message Placement
Message Placement
Section titled “Message Placement”OpenAI Chat ↔ Anthropic Messages message placement
Section titled “OpenAI Chat ↔ Anthropic Messages message placement”OpenAI Chat Completions and Anthropic Messages both model a conversation as ordered turns, but they place system instructions, tool calls, and tool results in different parts of the request body. Keep these placement rules explicit in translation code.
High-level placement
Section titled “High-level placement”| Concept | OpenAI Chat Completions | Anthropic Messages |
|---|---|---|
| System instructions | messages[] item with role: "system" | top-level system field |
| Developer instructions | messages[] item with role: "developer" | no dedicated role; fold into top-level system |
| User content | messages[] item with role: "user" | messages[] item with role: "user" |
| Assistant text | messages[] item with role: "assistant" and content | messages[] item with role: "assistant" and text content blocks |
| Tool call request | assistant message tool_calls[] | assistant message content block with type: "tool_use" |
| Tool call result | separate messages[] item with role: "tool" and tool_call_id | user message content block with type: "tool_result" |
| Legacy function result | separate messages[] item with role: "function" | unsupported; no reliable tool_result mapping without tool_call_id |
System and developer instructions
Section titled “System and developer instructions”Chat keeps system-like instructions inside the ordered messages[] array:
{"role": "system", "content": "You are concise."}{"role": "developer", "content": "Prefer exact answers."}Anthropic has no developer role and does not put system instructions in
messages[]. Translate Chat system and developer content into the top-level
Anthropic system field. If there is a single non-empty text part, use the
string form. If there are multiple parts, use the block form to preserve
boundaries:
{ "system": [ {"type": "text", "text": "You are concise."}, {"type": "text", "text": "Prefer exact answers."} ]}User content
Section titled “User content”Chat role: "user" content does not contain tool results. It contains ordinary
user-provided content parts such as text, images, audio, or files:
{ "role": "user", "content": [ {"type": "text", "text": "Summarize this."}, {"type": "image_url", "image_url": {"url": "https://example.test/a.png"}} ]}Translate these into Anthropic user content text/image/document blocks where
the target protocol can represent the source. Unsupported user parts should fail
with a TranslationError::InvalidPayload rather than being silently dropped.
Tool call request
Section titled “Tool call request”In Chat Completions, a model requests tool execution from an assistant message
via tool_calls[]:
{ "role": "assistant", "content": "I will look that up.", "tool_calls": [ { "id": "call_1", "type": "function", "function": { "name": "lookup", "arguments": "{\"query\":\"proxai\"}" } } ]}In Anthropic Messages, the same request is an assistant content block:
{ "role": "assistant", "content": [ {"type": "text", "text": "I will look that up."}, { "type": "tool_use", "id": "call_1", "name": "lookup", "input": {"query": "proxai"} } ]}Chat function tool arguments are JSON encoded as a string. When translating to
Anthropic tool_use.input, parse that string as JSON and fail the conversion if
it is invalid. Do not replace invalid arguments with {}.
Chat function tools map to Anthropic custom tools because both carry a named
JSON-schema input contract. Chat custom tools are different: their input is
freeform text or grammar-constrained text, not a JSON object described by
input_schema. Reject Chat custom tool definitions, custom tool choices, and
custom tool calls when translating to Anthropic Messages rather than pretending
that they are empty-object JSON tools.
Tool call result
Section titled “Tool call result”In Chat Completions, tool execution output is not part of the assistant message.
It is a separate message with role: "tool":
{ "role": "tool", "tool_call_id": "call_1", "content": "found"}In Anthropic Messages, tool results are user-side content blocks that reference
the earlier tool_use.id:
{ "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "call_1", "content": "found", "is_error": false } ]}This means a Chat role: "tool" message translates to an Anthropic
role: "user" message containing a tool_result block. Do not try to place
Chat tool results inside Chat user content or Anthropic assistant content.
Mapping tool_result.is_error
Section titled “Mapping tool_result.is_error”Anthropic tool_result carries an optional is_error: bool to mark a failed
local tool execution. None of the supported target protocols exposes a
matching dedicated failure flag on tool-result output, so the conversion is
lossy by design and follows these rules:
Do not map
is_error = truetoIncomplete.Incompletein Responses specifically means “output was truncated mid-stream” (e.g.max_output_tokenshit), not “tool execution failed”. Reusing it for failures would mislead clients about the result lifecycle. The error context survives in theoutputpayload text.
- Anthropic -> OpenAI Responses (
FunctionCallOutputResource.status): alwaysCompleted. The ResponsesFunctionCallOutputStatusEnumhas onlyInProgress/Completed/Incomplete, andIncompletespecifically means “output was truncated mid-stream” — a different semantic from “the tool execution failed”. ReusingIncompletefor failures would mislead clients about the result lifecycle. The error context survives in theoutputpayload: Anthropic clients typically populatetool_result.contentwith the error description whenis_error = true, and that text is passed through unchanged. This matches how OpenAI clients and models normally distinguish successful vs. failed tool executions. - Anthropic -> OpenAI Chat Completions: Chat
role: "tool"messages have no status or error field at all; onlycontentandtool_call_id. Theis_errorflag is dropped on translation, and any error text incontentis forwarded verbatim. This is symmetric with the Responses path: the error signal lives in the payload text, not in protocol metadata. - OpenAI Responses / Chat -> Anthropic (
FunctionCallOutput/role: "tool"->tool_result): proxai currently does not synthesizeis_error = truefrom any heuristic on the inbound side, because OpenAI clients have no canonical way to mark a tool call as failed. If a future convention emerges (e.g. an SDK convention for error payloads), revisit this direction.
Legacy function messages
Section titled “Legacy function messages”Chat has legacy function-calling shapes in addition to modern tool_calls.
Reject role: "function" messages when translating to Anthropic Messages.
Legacy function result messages carry a function name but no stable
tool_call_id, while Anthropic tool_result blocks must reference the earlier
tool_use.id. Do not invent an id or downgrade the result into ordinary user
text.
Response choices and candidate replies
Section titled “Response choices and candidate replies”Chat Completions response choices[] is a list of alternative candidate
assistant replies, commonly produced by request parameters such as n. It is
not a list of content blocks and it is not the representation for parallel tool
calls.
{ "choices": [ {"index": 0, "message": {"role": "assistant", "content": "Option A"}}, {"index": 1, "message": {"role": "assistant", "content": "Option B"}} ]}Parallel tool calls live inside one candidate assistant message as
choices[i].message.tool_calls[]; those can map to multiple Anthropic
tool_use blocks in a single assistant message.
Anthropic Messages has no equivalent top-level candidate-list response shape. A
non-streaming Anthropic response is one Message with one content[] sequence,
not a list of alternative assistant messages. OpenAI Responses API also has no
Chat-style choices[] equivalent: its output[] is a sequence of output items
(message, function call, reasoning item, and so on), not a set of candidate
answers.
Do not merge multiple Chat choices into one Anthropic content[] array and do
not silently keep only the first choice. Both approaches lose protocol
semantics: per-choice index, independent finish_reason, and the fact that
the choices are alternatives rather than one assistant turn. When translating a
Chat response to Anthropic Messages or OpenAI Responses, require exactly one
choice and reject multi-choice responses.
Chat -> Responses output placement
Section titled “Chat -> Responses output placement”OpenAI Responses represents returned model work as response.output[], a
sequence of typed output items. A Chat assistant message maps into that sequence
by layer:
| Chat response field | Responses placement | Ordering |
|---|---|---|
| `choices[0].message.content` | OutputItem::Message(OutputMessage { content: [...] }) with OutputMessageContent::OutputText | First, when non-empty. |
| `choices[0].message.refusal` | Same OutputMessage.content[], as OutputMessageContent::Refusal | Alongside message content; Responses has no message-level refusal field. |
| `choices[0].message.tool_calls[]` | Separate function_call / custom_tool_call output items | After the assistant message content item. |
Do not put tool calls inside OutputMessage.content[]: Responses models tool
calls as sibling output items, while OutputMessage.content[] holds the message
content parts such as text and refusal. If a Chat response has only tool calls,
the translated Responses output contains only those tool-call items.
Chat -> Anthropic response and stream semantics
Section titled “Chat -> Anthropic response and stream semantics”For non-streaming Chat -> Anthropic response conversion:
- map
choices[0].message.contentto Anthropictextblocks; - map function
tool_calls[]to Anthropictool_useblocks, parsing Chat functionargumentsas JSON fortool_use.input; - when
message.refusalis present, keep the visible refusal wording as atextblock and also setstop_reason: "refusal"withstop_details.explanation; Chat has no refusal category, so leave it absent; - require exactly one Chat choice and reject responses without representable text, refusal, or function tool calls.
For streaming Chat -> Anthropic conversion, keep an explicit lifecycle:
- wait for the first assistant choice chunk before emitting Anthropic
message_start; - translate Chat
delta.content/delta.refusalinto an Anthropic text block; the first text fragment may be carried bycontent_block_start, while later fragments usetext_delta; - translate Chat function tool-call starts to
tool_useblock starts with an empty objectinput, because Chat streamingfunction.argumentsare partial JSON strings; send those argument fragments asinput_json_deltaevents; - when Chat
finish_reasonarrives, close all open content blocks and retain a pending terminal state containing the finish reason and refusal wording; - emit Anthropic
message_delta/message_stopwhen a laterchoices: []usage-only chunk arrives, or when[DONE]/ EOF ends the stream without final usage.
OpenAI’s final streaming usage, when requested with
stream_options: {"include_usage": true}, is represented by a final
choices: [] chunk. Treat that usage-only chunk as the source of final usage.
Do not treat usage on a non-empty choices chunk as final usage and do not use
it to stop the Anthropic stream. Some OpenAI-compatible servers expose
continuous/intermediate usage statistics on ordinary chunks; those values are
not a replacement for the final usage-only chunk and are ignored by this
conversion.
A choices: [] Chat stream chunk is only valid as a usage-only chunk after a
terminal finish_reason has been seen. Reject usage-only chunks before any
assistant message, before a terminal finish reason, or after the Anthropic
message has stopped. Reject Chat stream logprobs, non-assistant delta roles,
and multi-choice chunks rather than silently dropping information Anthropic
Messages cannot represent.
Streaming terminators: Chat [DONE] vs Responses terminal events
Section titled “Streaming terminators: Chat [DONE] vs Responses terminal events”Keep stream terminators protocol-specific rather than treating all SSE streams alike.
OpenAI Chat Completions streaming is data-only SSE and is terminated by a non-JSON sentinel frame:
data: [DONE]The OpenAI/async-openai schema documents Chat streaming as ending with
data: [DONE], and stream_options.include_usage sends its final usage-only
chunk before that sentinel. Therefore translators that emit Chat Completions
streams must append [DONE] after the terminal finish/usage chunks, and
translators that consume Chat Completions streams should treat [DONE] as the
stream-end marker after a terminal finish_reason.
OpenAI Responses streaming is modeled as typed SSE events (ResponseStreamEvent).
Terminal state is represented by events such as:
response.completedresponse.incompleteresponse.failed
The Responses schema does not model [DONE] as a required terminator for these
events. Therefore translators that emit Responses streams should end with the
appropriate typed terminal event and should not add a Chat-style [DONE]
sentinel unless the Responses wire model is explicitly changed to require one.