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;
  • accumulate signature_delta and redacted_thinking payloads, then append a versioned ProxAI continuation envelope in the final reasoning_content delta. 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.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 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 From impls unless the conversion is total and structural.

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`completedModel refusal is semantic assistant output, not a provider/request failure. Preserve refusal as content/metadata where possible.
_(none / streaming)_in_progressResponse is still being generated.
Responses `status`Anthropic `stop_reason`Rationale
`completed`end_turn / tool_use / refusal inferred from outputUse 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_tokensResponse was cut short by output token budget.
`incomplete` with `incomplete_details.reason: "content_filter"`refusalProvider stopped output for policy/safety; preserve refusal-like stop semantics.
`incomplete` without detailsmax_tokensCompatibility 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.

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 present
max_tokens → incomplete(max_output_tokens) → max_tokens ✅
refusal → completed + refusal content → refusal ✅ when refusal content is preserved
tool_use → completed + tool-call output → tool_use ✅ when tool-call output is preserved

stop_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.