From 2865c53fd9619cc670f6815ecd74297f624c13ef Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Thu, 13 Aug 2026 04:58:38 -0700 Subject: [PATCH 1/2] fix(anthropic): stop retrying content-filter refusals Co-Authored-By: ForgeCode --- .../forge_app/src/dto/anthropic/response.rs | 30 +++++++++++++++++++ crates/forge_domain/src/error.rs | 7 +++++ crates/forge_domain/src/result_stream_ext.rs | 21 ++++++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/forge_app/src/dto/anthropic/response.rs b/crates/forge_app/src/dto/anthropic/response.rs index 8837a25604..3ea16fdd2a 100644 --- a/crates/forge_app/src/dto/anthropic/response.rs +++ b/crates/forge_app/src/dto/anthropic/response.rs @@ -205,6 +205,7 @@ pub enum StopReason { MaxTokens, StopSequence, ToolUse, + Refusal, } impl From for forge_domain::FinishReason { @@ -214,6 +215,7 @@ impl From for forge_domain::FinishReason { StopReason::MaxTokens => forge_domain::FinishReason::Length, StopReason::StopSequence => forge_domain::FinishReason::Stop, StopReason::ToolUse => forge_domain::FinishReason::ToolCalls, + StopReason::Refusal => forge_domain::FinishReason::ContentFilter, } } } @@ -844,4 +846,32 @@ mod tests { assert_eq!(actual.usage, None); } + + #[test] + fn test_message_delta_refusal_stop_reason() { + let fixture = r#"{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null},"usage":{"output_tokens":0}}"#; + + let actual = serde_json::from_str::(fixture).unwrap(); + + let expected = EventData::KnownEvent(Event::MessageDelta { + delta: MessageDelta { stop_reason: StopReason::Refusal, stop_sequence: None }, + usage: Usage { + input_tokens: None, + output_tokens: Some(0), + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }, + }); + assert_eq!(actual, expected); + } + + #[test] + fn test_refusal_maps_to_content_filter() { + let fixture = StopReason::Refusal; + + let actual = forge_domain::FinishReason::from(fixture); + + let expected = forge_domain::FinishReason::ContentFilter; + assert_eq!(actual, expected); + } } diff --git a/crates/forge_domain/src/error.rs b/crates/forge_domain/src/error.rs index 02d8f60529..31db4e4203 100644 --- a/crates/forge_domain/src/error.rs +++ b/crates/forge_domain/src/error.rs @@ -74,6 +74,13 @@ pub enum Error { #[error("Empty completion received - no content, tool calls, or valid finish reason")] EmptyCompletion, + #[error( + "The model refused to generate a response (safety/content filter). \ + Retrying the same request will produce the same refusal - rephrase \ + the request or switch to a different model." + )] + Refusal, + #[error(transparent)] Retryable(anyhow::Error), diff --git a/crates/forge_domain/src/result_stream_ext.rs b/crates/forge_domain/src/result_stream_ext.rs index 9250ff0adc..f8aef3cfa7 100644 --- a/crates/forge_domain/src/result_stream_ext.rs +++ b/crates/forge_domain/src/result_stream_ext.rs @@ -4,7 +4,7 @@ use tokio_stream::StreamExt; use crate::reasoning::{Reasoning, ReasoningFull}; use crate::{ ArcSender, ChatCompletionMessage, ChatCompletionMessageFull, ChatResponse, ChatResponseContent, - ToolCallFull, ToolCallPart, Usage, + FinishReason, ToolCallFull, ToolCallPart, Usage, }; /// Extension trait for ResultStream to provide additional functionality @@ -259,6 +259,10 @@ impl ResultStreamExt for crate::BoxStream = + Box::pin(tokio_stream::iter(messages)); + + let actual = fixture.into_full(false).await.unwrap_err(); + + assert!(matches!( + actual.downcast_ref::(), + Some(crate::Error::Refusal) + )); + } + #[tokio::test] async fn test_into_full_empty_completion_with_tool_calls_should_not_error() { // Fixture: Create a stream with empty content but with tool calls From 253dff1148618173f4f197ddc79d7a3e254ad21e Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Thu, 13 Aug 2026 17:50:53 -0700 Subject: [PATCH 2/2] fix(anthropic): scope refusal handling to Anthropic Co-Authored-By: ForgeCode --- crates/forge_app/src/dto/anthropic/response.rs | 6 +++--- crates/forge_domain/src/message.rs | 7 +++++++ crates/forge_domain/src/result_stream_ext.rs | 17 +++++++++++++++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/forge_app/src/dto/anthropic/response.rs b/crates/forge_app/src/dto/anthropic/response.rs index 3ea16fdd2a..5f7f72a0e0 100644 --- a/crates/forge_app/src/dto/anthropic/response.rs +++ b/crates/forge_app/src/dto/anthropic/response.rs @@ -215,7 +215,7 @@ impl From for forge_domain::FinishReason { StopReason::MaxTokens => forge_domain::FinishReason::Length, StopReason::StopSequence => forge_domain::FinishReason::Stop, StopReason::ToolUse => forge_domain::FinishReason::ToolCalls, - StopReason::Refusal => forge_domain::FinishReason::ContentFilter, + StopReason::Refusal => forge_domain::FinishReason::Refusal, } } } @@ -866,12 +866,12 @@ mod tests { } #[test] - fn test_refusal_maps_to_content_filter() { + fn test_refusal_maps_to_refusal() { let fixture = StopReason::Refusal; let actual = forge_domain::FinishReason::from(fixture); - let expected = forge_domain::FinishReason::ContentFilter; + let expected = forge_domain::FinishReason::Refusal; assert_eq!(actual, expected); } } diff --git a/crates/forge_domain/src/message.rs b/crates/forge_domain/src/message.rs index 38440ef061..03dfc2bdee 100644 --- a/crates/forge_domain/src/message.rs +++ b/crates/forge_domain/src/message.rs @@ -162,6 +162,9 @@ pub enum FinishReason { /// violated filters. #[strum(serialize = "content_filter")] ContentFilter, + /// The Anthropic provider refused to generate a response. + #[strum(serialize = "refusal")] + Refusal, /// The model stopped generating output because it made a tool call. #[strum(serialize = "tool_calls")] ToolCalls, @@ -394,6 +397,10 @@ mod tests { FinishReason::from_str("content_filter").unwrap(), FinishReason::ContentFilter ); + assert_eq!( + FinishReason::from_str("refusal").unwrap(), + FinishReason::Refusal + ); assert_eq!( FinishReason::from_str("tool_calls").unwrap(), FinishReason::ToolCalls diff --git a/crates/forge_domain/src/result_stream_ext.rs b/crates/forge_domain/src/result_stream_ext.rs index f8aef3cfa7..42b0fc57d5 100644 --- a/crates/forge_domain/src/result_stream_ext.rs +++ b/crates/forge_domain/src/result_stream_ext.rs @@ -259,7 +259,7 @@ impl ResultStreamExt for crate::BoxStream = Box::pin(tokio_stream::iter(messages)); @@ -1240,6 +1240,19 @@ mod tests { )); } + #[tokio::test] + async fn test_into_full_content_filter_remains_a_completion() { + let messages = vec![Ok(ChatCompletionMessage::assistant(Content::part("")) + .finish_reason(FinishReason::ContentFilter))]; + let fixture: BoxStream = + Box::pin(tokio_stream::iter(messages)); + + let actual = fixture.into_full(false).await.unwrap(); + + let expected = Some(FinishReason::ContentFilter); + assert_eq!(actual.finish_reason, expected); + } + #[tokio::test] async fn test_into_full_empty_completion_with_tool_calls_should_not_error() { // Fixture: Create a stream with empty content but with tool calls