Skip to content
Merged
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
2 changes: 1 addition & 1 deletion crates/forge_app/src/dto/anthropic/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ impl TryFrom<ContextMessage> for Message {
}

if !chat_message.content.is_empty() {
// note: Anthropic does not allow empty text content.
// NOTE: Anthropic does not allow empty text content.
content.push(Content::Text { text: chat_message.content, cache_control: None });
}
if let Some(tool_calls) = chat_message.tool_calls {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
use forge_domain::Transformer;
use serde_json::json;

use crate::dto::anthropic::{Content, Request};

/// Transformer that normalizes ToolUse content to ensure inputs are always
/// objects.
/// - Preserves `Content::Text` and other content types as-is
/// - For `Content::ToolUse`:
/// - If input is already an object, keeps it unchanged
/// - If input is None, keeps it as None
/// - If input is non-object (string, array, number, etc.), wraps it in
/// `{"json": value}`
pub struct DropInvalidToolUse;

impl Transformer for DropInvalidToolUse {
type Value = Request;

fn transform(&mut self, mut request: Self::Value) -> Self::Value {
for message in request.get_messages_mut() {
for content in &mut message.content {
if let Content::ToolUse { input, .. } = content {
*input = match input.take() {
Some(value) if value.is_object() => Some(value),
Some(value) => Some(json!({ "json": value })),
None => None,
};
}
}
}

request
}
}

#[cfg(test)]
mod tests {
use forge_domain::{
Context, ContextMessage, ModelId, Role, TextMessage, ToolCallArguments, ToolCallFull,
Transformer,
};
use pretty_assertions::assert_eq;
use serde_json::json;

use super::*;
use crate::dto::anthropic::Request;

fn transform_tool_call(json_args: &str) -> Request {
let fixture = Context::default().messages(vec![ContextMessage::Text(TextMessage {
role: Role::User,
content: "Hello".to_string(),
tool_calls: Some(vec![
ToolCallFull::new("test_tool")
.call_id("call_123")
.arguments(ToolCallArguments::from_json(json_args)),
]),
model: Some(ModelId::new("claude-3-5-sonnet-20241022")),
reasoning_details: None,
})]);
DropInvalidToolUse.transform(Request::try_from(fixture).unwrap())
}

fn get_tool_input(request: &Request) -> &Option<serde_json::Value> {
if let Content::ToolUse { input, .. } = &request.messages[0].content[1] {
input
} else {
panic!("Expected ToolUse content")
}
}

#[test]
fn test_preserves_tool_use_with_object_input() {
let actual = transform_tool_call(r#"{"key": "value"}"#);
assert_eq!(get_tool_input(&actual), &Some(json!({"key": "value"})));
}

#[test]
fn test_wraps_tool_use_with_string_input() {
let actual = transform_tool_call(r#""string_value""#);
assert_eq!(
get_tool_input(&actual),
&Some(json!({"json": "string_value"}))
);
}

#[test]
fn test_wraps_tool_use_with_array_input() {
let actual = transform_tool_call(r#"[1, 2, 3]"#);
assert_eq!(get_tool_input(&actual), &Some(json!({"json": [1, 2, 3]})));
}

#[test]
fn test_wraps_tool_use_with_number_input() {
let actual = transform_tool_call(r#"42"#);
assert_eq!(get_tool_input(&actual), &Some(json!({"json": 42})));
}

#[test]
fn test_wraps_tool_use_with_none_input() {
let request = Request::default().messages(vec![crate::dto::anthropic::Message {
role: crate::dto::anthropic::Role::User,
content: vec![Content::ToolUse {
id: "call_123".to_string(),
name: "test_tool".to_string(),
input: None,
cache_control: None,
}],
}]);
let actual = DropInvalidToolUse.transform(request);

if let Content::ToolUse { input, .. } = &actual.messages[0].content[0] {
assert_eq!(input, &None);
}
}

#[test]
fn test_empty_messages_remain_empty() {
let actual = DropInvalidToolUse.transform(Request::try_from(Context::default()).unwrap());
assert_eq!(actual.messages.len(), 0);
}

#[test]
fn test_preserves_text_content() {
let fixture = Context::default().messages(vec![ContextMessage::Text(TextMessage {
role: Role::User,
content: "Hello".to_string(),
tool_calls: None,
model: None,
reasoning_details: None,
})]);
let actual = DropInvalidToolUse.transform(Request::try_from(fixture).unwrap());

assert_eq!(actual.messages.len(), 1);
assert!(matches!(
actual.messages[0].content[0],
Content::Text { .. }
));
}
}
2 changes: 2 additions & 0 deletions crates/forge_app/src/dto/anthropic/transforms/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mod drop_invalid_toolcalls;
mod reasoning_transform;
mod set_cache;

pub use drop_invalid_toolcalls::DropInvalidToolUse;
pub use reasoning_transform::ReasoningTransform;
pub use set_cache::SetCache;
4 changes: 2 additions & 2 deletions crates/forge_services/src/provider/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use forge_app::domain::{
ChatCompletionMessage, Context, Model, ModelId, ResultStream, Transformer,
};
use forge_app::dto::anthropic::{
EventData, ListModelResponse, ReasoningTransform, Request, SetCache,
DropInvalidToolUse, EventData, ListModelResponse, ReasoningTransform, Request, SetCache,
};
use reqwest::Url;
use tracing::debug;
Expand Down Expand Up @@ -67,7 +67,7 @@ impl<T: HttpClientService> Anthropic<T> {
.stream(true)
.max_tokens(max_tokens as u64);

let request = SetCache.transform(request);
let request = DropInvalidToolUse.pipe(SetCache).transform(request);

let url = &self.chat_url;
debug!(url = %url, model = %model, "Connecting Upstream");
Expand Down
Loading