Skip to content

Commit b8ac475

Browse files
jeffbrynerclaudeamitksingh1490
authored
fix: handle Anthropic 'refusal' stop reason instead of retry-looping (#3640)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Amit Singh <amitksingh1490@gmail.com>
1 parent 7e3f235 commit b8ac475

3 files changed

Lines changed: 87 additions & 1 deletion

File tree

crates/forge_app/src/dto/anthropic/response.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ pub enum StopReason {
205205
MaxTokens,
206206
StopSequence,
207207
ToolUse,
208+
Refusal,
208209
}
209210

210211
impl From<StopReason> for forge_domain::FinishReason {
@@ -214,6 +215,7 @@ impl From<StopReason> for forge_domain::FinishReason {
214215
StopReason::MaxTokens => forge_domain::FinishReason::Length,
215216
StopReason::StopSequence => forge_domain::FinishReason::Stop,
216217
StopReason::ToolUse => forge_domain::FinishReason::ToolCalls,
218+
StopReason::Refusal => forge_domain::FinishReason::ContentFilter,
217219
}
218220
}
219221
}
@@ -844,4 +846,36 @@ mod tests {
844846

845847
assert_eq!(actual.usage, None);
846848
}
849+
850+
#[test]
851+
fn test_message_delta_refusal_stop_reason() {
852+
// Fable returns HTTP 200 with stop_reason "refusal" when safety
853+
// classifiers decline a request. This must parse as a known event,
854+
// not fall through to EventData::Unknown (which silently drops the
855+
// finish reason and triggers an empty-completion retry loop).
856+
let fixture = r#"{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null},"usage":{"output_tokens":0}}"#;
857+
858+
let actual = serde_json::from_str::<EventData>(fixture).unwrap();
859+
860+
let expected = EventData::KnownEvent(Event::MessageDelta {
861+
delta: MessageDelta { stop_reason: StopReason::Refusal, stop_sequence: None },
862+
usage: Usage {
863+
input_tokens: None,
864+
output_tokens: Some(0),
865+
cache_creation_input_tokens: None,
866+
cache_read_input_tokens: None,
867+
},
868+
});
869+
assert_eq!(actual, expected);
870+
}
871+
872+
#[test]
873+
fn test_refusal_maps_to_content_filter() {
874+
let fixture = StopReason::Refusal;
875+
876+
let actual = forge_domain::FinishReason::from(fixture);
877+
878+
let expected = forge_domain::FinishReason::ContentFilter;
879+
assert_eq!(actual, expected);
880+
}
847881
}

crates/forge_domain/src/error.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ pub enum Error {
7474
#[error("Empty completion received - no content, tool calls, or valid finish reason")]
7575
EmptyCompletion,
7676

77+
#[error(
78+
"The model refused to generate a response (safety/content filter). \
79+
Retrying the same request will produce the same refusal - rephrase \
80+
the request or switch to a different model."
81+
)]
82+
Refusal,
83+
7784
#[error(transparent)]
7885
Retryable(anyhow::Error),
7986

crates/forge_domain/src/result_stream_ext.rs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use tokio_stream::StreamExt;
44
use crate::reasoning::{Reasoning, ReasoningFull};
55
use crate::{
66
ArcSender, ChatCompletionMessage, ChatCompletionMessageFull, ChatResponse, ChatResponseContent,
7-
ToolCallFull, ToolCallPart, Usage,
7+
FinishReason, ToolCallFull, ToolCallPart, Usage,
88
};
99

1010
/// Extension trait for ResultStream to provide additional functionality
@@ -259,6 +259,14 @@ impl ResultStreamExt<anyhow::Error> for crate::BoxStream<ChatCompletionMessage,
259259
// Get phase from the last message that has one
260260
let phase = messages.iter().rev().find_map(|message| message.phase);
261261

262+
// A refusal/content-filter finish is deterministic - the provider
263+
// will return the same result for the same request - so it must not
264+
// enter the retry loop (issue #3624). Tool calls alongside a
265+
// content-filter finish are left to the normal flow.
266+
if finish_reason == Some(FinishReason::ContentFilter) && tool_calls.is_empty() {
267+
return Err(crate::Error::Refusal.into());
268+
}
269+
262270
// Check for empty completion - map to retryable error for retry
263271
if content.trim().is_empty()
264272
&& tool_calls.is_empty()
@@ -1221,6 +1229,43 @@ mod tests {
12211229
assert_eq!(actual, expected);
12221230
}
12231231

1232+
#[tokio::test]
1233+
async fn test_into_full_refusal_is_non_retryable_error() {
1234+
// A refusal/content-filter finish is deterministic: retrying the same
1235+
// request yields the same refusal. It must NOT surface as the
1236+
// retryable EmptyCompletion error (issue #3624).
1237+
let messages = vec![Ok(ChatCompletionMessage::assistant(Content::part(""))
1238+
.finish_reason(FinishReason::ContentFilter))];
1239+
let fixture: BoxStream<ChatCompletionMessage, anyhow::Error> =
1240+
Box::pin(tokio_stream::iter(messages));
1241+
1242+
let actual = fixture.into_full(false).await.unwrap_err();
1243+
1244+
let domain_error = actual.downcast_ref::<crate::Error>().unwrap();
1245+
assert!(matches!(domain_error, crate::Error::Refusal));
1246+
}
1247+
1248+
#[tokio::test]
1249+
async fn test_into_full_refusal_with_partial_content_is_error() {
1250+
// Mid-stream refusals can arrive after partial output. The partial
1251+
// text has already been streamed to the UI; the turn still must end
1252+
// with the refusal error rather than looping.
1253+
let messages = vec![
1254+
Ok(ChatCompletionMessage::assistant(Content::part(
1255+
"I was about to say",
1256+
))),
1257+
Ok(ChatCompletionMessage::assistant(Content::part(""))
1258+
.finish_reason(FinishReason::ContentFilter)),
1259+
];
1260+
let fixture: BoxStream<ChatCompletionMessage, anyhow::Error> =
1261+
Box::pin(tokio_stream::iter(messages));
1262+
1263+
let actual = fixture.into_full(false).await.unwrap_err();
1264+
1265+
let domain_error = actual.downcast_ref::<crate::Error>().unwrap();
1266+
assert!(matches!(domain_error, crate::Error::Refusal));
1267+
}
1268+
12241269
#[tokio::test]
12251270
async fn test_into_full_empty_completion_with_tool_calls_should_not_error() {
12261271
// Fixture: Create a stream with empty content but with tool calls

0 commit comments

Comments
 (0)