Skip to content

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.

ConceptOpenAI Chat CompletionsAnthropic Messages
System instructionsmessages[] item with role: "system"top-level system field
Developer instructionsmessages[] item with role: "developer"no dedicated role; fold into top-level system
User contentmessages[] item with role: "user"messages[] item with role: "user"
Assistant textmessages[] item with role: "assistant" and contentmessages[] item with role: "assistant" and text content blocks
Tool call requestassistant message tool_calls[]assistant message content block with type: "tool_use"
Tool call resultseparate messages[] item with role: "tool" and tool_call_iduser message content block with type: "tool_result"
Legacy function resultseparate messages[] item with role: "function"unsupported; no reliable tool_result mapping without tool_call_id

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."}
]
}

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.

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.

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.

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 = true to Incomplete. Incomplete in Responses specifically means “output was truncated mid-stream” (e.g. max_output_tokens hit), not “tool execution failed”. Reusing it for failures would mislead clients about the result lifecycle. The error context survives in the output payload text.

  • Anthropic -> OpenAI Responses (FunctionCallOutputResource.status): always Completed. The Responses FunctionCallOutputStatusEnum has only InProgress / Completed / Incomplete, and Incomplete specifically means “output was truncated mid-stream” — a different semantic from “the tool execution failed”. Reusing Incomplete for failures would mislead clients about the result lifecycle. The error context survives in the output payload: Anthropic clients typically populate tool_result.content with the error description when is_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; only content and tool_call_id. The is_error flag is dropped on translation, and any error text in content is 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 synthesize is_error = true from 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.

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.

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.

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 fieldResponses placementOrdering
`choices[0].message.content`OutputItem::Message(OutputMessage { content: [...] }) with OutputMessageContent::OutputTextFirst, when non-empty.
`choices[0].message.refusal`Same OutputMessage.content[], as OutputMessageContent::RefusalAlongside message content; Responses has no message-level refusal field.
`choices[0].message.tool_calls[]`Separate function_call / custom_tool_call output itemsAfter 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.content to Anthropic text blocks;
  • map function tool_calls[] to Anthropic tool_use blocks, parsing Chat function arguments as JSON for tool_use.input;
  • when message.refusal is present, keep the visible refusal wording as a text block and also set stop_reason: "refusal" with stop_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:

  1. wait for the first assistant choice chunk before emitting Anthropic message_start;
  2. translate Chat delta.content / delta.refusal into an Anthropic text block; the first text fragment may be carried by content_block_start, while later fragments use text_delta;
  3. translate Chat function tool-call starts to tool_use block starts with an empty object input, because Chat streaming function.arguments are partial JSON strings; send those argument fragments as input_json_delta events;
  4. when Chat finish_reason arrives, close all open content blocks and retain a pending terminal state containing the finish reason and refusal wording;
  5. emit Anthropic message_delta / message_stop when a later choices: [] 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.completed
  • response.incomplete
  • response.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.