Refusal and Status Mapping
Refusal and Status Mapping
Section titled “Refusal and Status Mapping”Refusal and normal content semantics
Section titled “Refusal and normal content semantics”refusal means model-generated refusal content, not an additional annotation on ordinary assistant text. Keep it separate from normal text when translating between protocols.
The three supported protocols represent this separation differently:
| Protocol | Normal assistant text | Refusal | Can normal text and refusal coexist in one assistant message? |
|---|---|---|---|
| `openai_responses` | output[].content[] part with type: "output_text" | output[].content[] part with type: "refusal" | Structurally possible as different content parts, but semantically unusual; preserve order when the target can express parts. |
| `openai_chat_completions` | choices[].message.content or stream delta.content | choices[].message.refusal or stream delta.refusal | Wire fields are both nullable/optional, but a refusal should not duplicate the same text in content. Assistant request content parts also document either one or more text parts, or exactly one refusal part. |
| `anthropic_messages` | content[] text blocks | stop_reason: "refusal" plus optional stop_details.explanation; visible refusal wording may also arrive as text blocks | There is no separate refusal content block. A refused message can still contain visible text blocks, so translators must decide whether those text blocks are refusal text or ordinary content from context. |
OpenAI Responses
Section titled “OpenAI Responses”Responses keeps message content as typed parts, so normal text and refusal are separate values in the same content[] array:
{ "type": "message", "role": "assistant", "status": "completed", "content": [ { "type": "output_text", "text": "I can help with safe alternatives.", "annotations": [] }, { "type": "refusal", "refusal": "I can't provide instructions for that request." } ]}If a target protocol can preserve typed content parts, keep the distinction. If the target is Chat Completions, avoid merging the refusal text into ordinary message.content unless there is no target-side refusal field available.
OpenAI Chat Completions
Section titled “OpenAI Chat Completions”Chat response messages expose ordinary content and refusal as sibling fields:
{ "role": "assistant", "content": null, "refusal": "I can't provide instructions for that request."}The JSON shape does not make content and refusal mutually exclusive at the top level, but their meanings are different. Do not emit the same refusal text in both fields:
{ "role": "assistant", "content": "I can't provide instructions for that request.", "refusal": "I can't provide instructions for that request."}Treat that duplicated shape as a compatibility artifact to avoid producing, not as desired output.
Assistant request content parts make the separation more explicit: an array can contain one or more text parts, or exactly one refusal part. That reinforces the semantic rule that refusal is an alternative content kind, not a decoration on normal text.
Streaming has the same split:
data: {"choices":[{"index":0,"delta":{"refusal":"I can't help with that."},"finish_reason":null}]}data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}data: [DONE]If ordinary delta.content has already been forwarded and a later upstream event reveals the turn was a refusal, the stream cannot be retracted. In that case, do not send duplicate refusal text; only use delta.refusal when the refusal can be represented before ordinary content has been emitted.
Anthropic Messages
Section titled “Anthropic Messages”Anthropic does not have a dedicated refusal content block. The visible refusal
wording is still ordinary content[] text; the refusal semantics are carried by
message-level stop fields:
- visible wording:
content[]textblock; - refusal marker:
stop_reason: "refusal"; - optional refusal metadata:
stop_details, such asexplanationand provider category.
That differs from Chat Completions, where refusal wording has a sibling field
next to normal content: choices[].message.refusal. In Chat, message.content
and message.refusal are separate content slots. In Anthropic, both ordinary
assistant text and visible refusal wording use the same text block shape, and
only the message-level stop fields tell the translator whether those text blocks
should become Chat message.content or Chat message.refusal.
A refusal is identified at message level:
{ "id": "msg_01", "type": "message", "role": "assistant", "content": [ { "type": "text", "text": "I can't provide instructions for that request." } ], "stop_reason": "refusal", "stop_details": { "category": "safety", "explanation": "The request asks for unsafe instructions." }}For Anthropic -> Chat Completions non-streaming conversion:
- when
stop_reason == "refusal"and visible text blocks exist, put the flattened visible text inmessage.refusaland leavemessage.contentabsent/null; - when
stop_reason == "refusal"and no visible text exists, usestop_details.explanationas the fallbackmessage.refusal; - do not map
stop_details.categorybecause Chat Completions has no equivalent field; - map the choice
finish_reasontostop, because a refusal is a terminal assistant turn, not a tool call.
Example target Chat response:
{ "choices": [ { "index": 0, "message": { "role": "assistant", "refusal": "I can't provide instructions for that request." }, "finish_reason": "stop" } ]}For Anthropic -> Chat Completions streaming conversion, message_delta.stop_reason and stop_details arrive after content block deltas. Therefore proxai uses a best-effort rule:
- map Anthropic
thinkingblock text andthinking_deltafragments to the Zed-supported Chat-compatible extension fielddelta.reasoning_content; do not put thinking text in ordinarydelta.content; - ignore
signature_deltaandredacted_thinkingpayloads instead of leaking them into Chat content, because Chat Completions has no standard safe field for those values; - if no text delta has been emitted, convert
stop_details.explanationtodelta.refusal; - if text has already been emitted as
delta.content, do not emit duplicate refusal text; - still map the final choice
finish_reasontostop.
This is less strict than buffering the entire stream, but it preserves low-latency streaming and avoids retracting already-forwarded content.
Status mapping
Section titled “Status mapping”anthropic_messages and openai_responses represent terminal response state differently. Anthropic uses a single stop_reason enum on the message; Responses uses a top-level status field with richer lifecycle states.
[!IMPORTANT] Streaming and non-streaming conversions must agree on this mapping. The shared
From<StopReason> for Statusimpl intypes.rsis the single source of truth — do not duplicate this match in streaming code.
Bidirectional mapping
Section titled “Bidirectional mapping”| Anthropic `stop_reason` | Responses `status` | Rationale |
|---|---|---|
| `end_turn` | completed | Normal model termination. |
| `stop_sequence` | completed | Hit a stop sequence; still a normal termination. |
| `tool_use` | completed | Model finished its turn and requested tool execution. |
| `pause_turn` | completed | Model finished its turn, waiting for external action. |
| `max_tokens` | incomplete | Output was truncated before natural completion. |
| `refusal` | failed | Model declined to produce useful output. |
| _(none / streaming)_ | in_progress | Response is still being generated. |
| Responses `status` | Anthropic `stop_reason` | Rationale |
|---|---|---|
| `completed` | end_turn | Closest equivalent normal termination. |
| `incomplete` | max_tokens | Response was cut short. |
| `failed` | refusal | Model did not produce a usable response. |
| `cancelled` | (none) | Client-side lifecycle state; no Anthropic equivalent. |
| `queued` | (none) | Client-side lifecycle state; no Anthropic equivalent. |
| `in_progress` | (none) | Streaming in progress; no terminal stop reason yet. |
Round-trip consistency
Section titled “Round-trip consistency”The forward and reverse mappings are designed to round-trip for the three primary terminal states:
end_turn → completed → end_turn ✅max_tokens → incomplete → max_tokens ✅refusal → failed → refusal ✅stop_sequence, pause_turn, and tool_use all map to completed in the forward direction and round-trip as end_turn in the reverse. This is a deliberate loss of granularity — Responses status does not distinguish between these termination modes.
cancelled and queued are Responses client-side lifecycle states with no Anthropic equivalent; they produce no stop_reason in the reverse direction.