Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions crates/forge_app/src/dto/anthropic/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ pub enum StopReason {
MaxTokens,
StopSequence,
ToolUse,
Refusal,
}

impl From<StopReason> for forge_domain::FinishReason {
Expand All @@ -214,6 +215,7 @@ impl From<StopReason> 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,
}
}
}
Expand Down Expand Up @@ -844,4 +846,36 @@ mod tests {

assert_eq!(actual.usage, None);
}

#[test]
fn test_message_delta_refusal_stop_reason() {
// Fable returns HTTP 200 with stop_reason "refusal" when safety
// classifiers decline a request. This must parse as a known event,
// not fall through to EventData::Unknown (which silently drops the
// finish reason and triggers an empty-completion retry loop).
let fixture = r#"{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null},"usage":{"output_tokens":0}}"#;

let actual = serde_json::from_str::<EventData>(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);
}
}
7 changes: 7 additions & 0 deletions crates/forge_domain/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
47 changes: 46 additions & 1 deletion crates/forge_domain/src/result_stream_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -259,6 +259,14 @@ impl ResultStreamExt<anyhow::Error> for crate::BoxStream<ChatCompletionMessage,
// Get phase from the last message that has one
let phase = messages.iter().rev().find_map(|message| message.phase);

// A refusal/content-filter finish is deterministic - the provider
// will return the same result for the same request - so it must not
// enter the retry loop (issue #3624). Tool calls alongside a
// content-filter finish are left to the normal flow.
if finish_reason == Some(FinishReason::ContentFilter) && tool_calls.is_empty() {
return Err(crate::Error::Refusal.into());
}

// Check for empty completion - map to retryable error for retry
if content.trim().is_empty()
&& tool_calls.is_empty()
Expand Down Expand Up @@ -1221,6 +1229,43 @@ mod tests {
assert_eq!(actual, expected);
}

#[tokio::test]
async fn test_into_full_refusal_is_non_retryable_error() {
// A refusal/content-filter finish is deterministic: retrying the same
// request yields the same refusal. It must NOT surface as the
// retryable EmptyCompletion error (issue #3624).
let messages = vec![Ok(ChatCompletionMessage::assistant(Content::part(""))
.finish_reason(FinishReason::ContentFilter))];
let fixture: BoxStream<ChatCompletionMessage, anyhow::Error> =
Box::pin(tokio_stream::iter(messages));

let actual = fixture.into_full(false).await.unwrap_err();

let domain_error = actual.downcast_ref::<crate::Error>().unwrap();
assert!(matches!(domain_error, crate::Error::Refusal));
}

#[tokio::test]
async fn test_into_full_refusal_with_partial_content_is_error() {
// Mid-stream refusals can arrive after partial output. The partial
// text has already been streamed to the UI; the turn still must end
// with the refusal error rather than looping.
let messages = vec![
Ok(ChatCompletionMessage::assistant(Content::part(
"I was about to say",
))),
Ok(ChatCompletionMessage::assistant(Content::part(""))
.finish_reason(FinishReason::ContentFilter)),
];
let fixture: BoxStream<ChatCompletionMessage, anyhow::Error> =
Box::pin(tokio_stream::iter(messages));

let actual = fixture.into_full(false).await.unwrap_err();

let domain_error = actual.downcast_ref::<crate::Error>().unwrap();
assert!(matches!(domain_error, 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
Expand Down
Loading