Skip to content

Refusal and Status Mapping

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:

ProtocolNormal assistant textRefusalCan 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.contentchoices[].message.refusal or stream delta.refusalWire 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 blocksstop_reason: "refusal" plus optional stop_details.explanation; visible refusal wording may also arrive as text blocksThere 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.

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.

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 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[] text block;
  • refusal marker: stop_reason: "refusal";
  • optional refusal metadata: stop_details, such as explanation and 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 in message.refusal and leave message.content absent/null;
  • when stop_reason == "refusal" and no visible text exists, use stop_details.explanation as the fallback message.refusal;
  • do not map stop_details.category because Chat Completions has no equivalent field;
  • map the choice finish_reason to stop, 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 thinking block text and thinking_delta fragments to the Zed-supported Chat-compatible extension field delta.reasoning_content; do not put thinking text in ordinary delta.content;
  • ignore signature_delta and redacted_thinking payloads 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.explanation to delta.refusal;
  • if text has already been emitted as delta.content, do not emit duplicate refusal text;
  • still map the final choice finish_reason to stop.

This is less strict than buffering the entire stream, but it preserves low-latency streaming and avoids retracting already-forwarded content.

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 Status impl in types.rs is the single source of truth — do not duplicate this match in streaming code.

Anthropic `stop_reason`Responses `status`Rationale
`end_turn`completedNormal model termination.
`stop_sequence`completedHit a stop sequence; still a normal termination.
`tool_use`completedModel finished its turn and requested tool execution.
`pause_turn`completedModel finished its turn, waiting for external action.
`max_tokens`incompleteOutput was truncated before natural completion.
`refusal`failedModel declined to produce useful output.
_(none / streaming)_in_progressResponse is still being generated.
Responses `status`Anthropic `stop_reason`Rationale
`completed`end_turnClosest equivalent normal termination.
`incomplete`max_tokensResponse was cut short.
`failed`refusalModel 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.

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.