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; - accumulate
signature_deltaandredacted_thinkingpayloads, then append a versioned ProxAI continuation envelope in the finalreasoning_contentdelta. A compatible client replays it in assistant history, and ProxAI strips it before forwarding to Anthropic; - 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 terminal semantics. Stop/status/finish mappings are intentionally implemented as explicit pair-local functions when information is lossy or context-sensitive. Do not hide those mappings behind broad
Fromimpls unless the conversion is total and structural.
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` | completed | Model refusal is semantic assistant output, not a provider/request failure. Preserve refusal as content/metadata where possible. |
| _(none / streaming)_ | in_progress | Response is still being generated. |
| Responses `status` | Anthropic `stop_reason` | Rationale |
|---|---|---|
| `completed` | end_turn / tool_use / refusal inferred from output | Use output content/items to infer terminal kind: refusal parts imply refusal, function/custom tool calls imply tool use, otherwise normal end turn. |
| `incomplete` with `incomplete_details.reason: "max_output_tokens"` | max_tokens | Response was cut short by output token budget. |
| `incomplete` with `incomplete_details.reason: "content_filter"` | refusal | Provider stopped output for policy/safety; preserve refusal-like stop semantics. |
| `incomplete` without details | max_tokens | Compatibility fallback for upstreams that omit details; trace the ambiguity. |
| `incomplete` with unknown reason | (none) | Unknown lifecycle reason; trace it rather than fabricating a stop reason. |
| `failed` | (none) | Provider/request failure state, not model refusal. Surface as error/lifecycle state, not Anthropic refusal. |
| `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 mappings preserve behavior, but they are not perfect round-trips because the protocols expose different information:
end_turn → completed → end_turn ✅ when no tool/refusal output is presentmax_tokens → incomplete(max_output_tokens) → max_tokens ✅refusal → completed + refusal content → refusal ✅ when refusal content is preservedtool_use → completed + tool-call output → tool_use ✅ when tool-call output is preservedstop_sequence and pause_turn map to completed in the forward direction and generally round-trip as end_turn unless additional target-side information is available. Responses status alone does not distinguish those termination modes.
failed is reserved for response lifecycle/provider failures. Do not use it to represent a model refusal; a refusal is successful model output with refusal semantics.
cancelled and queued are Responses client-side lifecycle states with no Anthropic equivalent; they produce no stop_reason in the reverse direction.