Skip to content
Closed
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
30 changes: 30 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::Refusal,
}
}
}
Expand Down Expand Up @@ -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::<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_refusal() {
let fixture = StopReason::Refusal;

let actual = forge_domain::FinishReason::from(fixture);

let expected = forge_domain::FinishReason::Refusal;
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
7 changes: 7 additions & 0 deletions crates/forge_domain/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
34 changes: 33 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,10 @@ 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);

if finish_reason == Some(FinishReason::Refusal) && 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 +1225,34 @@ mod tests {
assert_eq!(actual, expected);
}

#[tokio::test]
async fn test_into_full_refusal_is_non_retryable_error() {
let messages = vec![Ok(ChatCompletionMessage::assistant(Content::part(""))
.finish_reason(FinishReason::Refusal))];
let fixture: BoxStream<ChatCompletionMessage, anyhow::Error> =
Box::pin(tokio_stream::iter(messages));

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

assert!(matches!(
actual.downcast_ref::<crate::Error>(),
Some(crate::Error::Refusal)
));
}

#[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<ChatCompletionMessage, anyhow::Error> =
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
Expand Down
Loading