Skip to content

Commit e3124fd

Browse files
amitksingh1490forge-code-agentautofix-ci[bot]graphite-app[bot]
authored andcommitted
fix(vertex-anthropic): sanitize tool call IDs to match required pattern (#2394)
Co-authored-by: ForgeCode <noreply@forgecode.dev> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
1 parent e2410e9 commit e3124fd

3 files changed

Lines changed: 231 additions & 3 deletions

File tree

crates/forge_app/src/dto/anthropic/transforms/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod drop_invalid_toolcalls;
44
mod enforce_schema;
55
mod reasoning_transform;
66
mod remove_output_format;
7+
mod sanitize_tool_ids;
78
mod set_cache;
89

910
pub use auth_system_message::AuthSystemMessage;
@@ -12,4 +13,5 @@ pub use drop_invalid_toolcalls::DropInvalidToolUse;
1213
pub use enforce_schema::EnforceStrictObjectSchema;
1314
pub use reasoning_transform::ReasoningTransform;
1415
pub use remove_output_format::RemoveOutputFormat;
16+
pub use sanitize_tool_ids::SanitizeToolIds;
1517
pub use set_cache::SetCache;
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
use forge_domain::Transformer;
2+
use regex::Regex;
3+
4+
use crate::dto::anthropic::{Content, Request};
5+
6+
/// Transformer that sanitizes tool call IDs for Anthropic/Vertex Anthropic
7+
/// compatibility.
8+
///
9+
/// Anthropic requires tool call IDs to match the pattern `^[a-zA-Z0-9_-]+$`.
10+
/// This transformer replaces any invalid characters (non-alphanumeric,
11+
/// non-underscore, non-hyphen) with underscores.
12+
///
13+
/// This is particularly important for Vertex AI Anthropic which strictly
14+
/// validates tool_use.id and tool_result.tool_use_id fields and will reject
15+
/// requests with IDs containing invalid characters with a 400 Bad Request
16+
/// error.
17+
///
18+
/// # Example
19+
///
20+
/// ```ignore
21+
/// // Before transformation:
22+
/// tool_use.id = "call_123@#$%^&*()"
23+
///
24+
/// // After transformation:
25+
/// tool_use.id = "call_123_________"
26+
/// ```
27+
pub struct SanitizeToolIds;
28+
29+
impl Transformer for SanitizeToolIds {
30+
type Value = Request;
31+
32+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
33+
let regex = Regex::new(r"[^a-zA-Z0-9_-]").unwrap();
34+
35+
for message in &mut request.messages {
36+
for content in &mut message.content {
37+
match content {
38+
Content::ToolUse { id, .. } => {
39+
*id = regex.replace_all(id, "_").to_string();
40+
}
41+
Content::ToolResult { tool_use_id, .. } => {
42+
*tool_use_id = regex.replace_all(tool_use_id, "_").to_string();
43+
}
44+
_ => {}
45+
}
46+
}
47+
}
48+
49+
request
50+
}
51+
}
52+
53+
#[cfg(test)]
54+
mod tests {
55+
use forge_domain::{
56+
Context, ContextMessage, ModelId, Role, TextMessage, ToolCallArguments, ToolCallFull,
57+
ToolCallId, ToolResult, Transformer,
58+
};
59+
60+
use super::*;
61+
62+
#[test]
63+
fn test_sanitizes_tool_use_id_with_invalid_chars() {
64+
let fixture = Context::default().messages(vec![
65+
ContextMessage::Text(
66+
TextMessage::new(Role::User, "test")
67+
.tool_calls(vec![
68+
ToolCallFull::new("test_tool")
69+
.call_id("call_123@#$%^&*()")
70+
.arguments(ToolCallArguments::from_json("{}")),
71+
])
72+
.model(ModelId::new("claude-3-5-sonnet-20241022")),
73+
)
74+
.into(),
75+
]);
76+
77+
let mut request = Request::try_from(fixture).unwrap();
78+
request = SanitizeToolIds.transform(request);
79+
80+
// Find the ToolUse content
81+
let tool_use = request.messages.iter().find_map(|msg| {
82+
msg.content.iter().find_map(|content| {
83+
if let Content::ToolUse { id, .. } = content {
84+
Some(id.clone())
85+
} else {
86+
None
87+
}
88+
})
89+
});
90+
91+
assert_eq!(tool_use, Some("call_123_________".to_string()));
92+
}
93+
94+
#[test]
95+
fn test_sanitizes_tool_result_id_with_invalid_chars() {
96+
let fixture = Context::default().messages(vec![
97+
ContextMessage::tool_result(
98+
ToolResult::new("test_tool")
99+
.call_id(ToolCallId::new("toolu_01!@#$ABC123"))
100+
.success("result"),
101+
)
102+
.into(),
103+
]);
104+
105+
let mut request = Request::try_from(fixture).unwrap();
106+
request = SanitizeToolIds.transform(request);
107+
108+
// Find the ToolResult content
109+
let tool_result_id = request.messages.iter().find_map(|msg| {
110+
msg.content.iter().find_map(|content| {
111+
if let Content::ToolResult { tool_use_id, .. } = content {
112+
Some(tool_use_id.clone())
113+
} else {
114+
None
115+
}
116+
})
117+
});
118+
119+
assert_eq!(tool_result_id, Some("toolu_01____ABC123".to_string()));
120+
}
121+
122+
#[test]
123+
fn test_leaves_valid_tool_ids_unchanged() {
124+
let fixture = Context::default().messages(vec![
125+
ContextMessage::Text(
126+
TextMessage::new(Role::User, "test")
127+
.tool_calls(vec![
128+
ToolCallFull::new("test_tool")
129+
.call_id("call_abc-123_XYZ")
130+
.arguments(ToolCallArguments::from_json("{}")),
131+
])
132+
.model(ModelId::new("claude-3-5-sonnet-20241022")),
133+
)
134+
.into(),
135+
]);
136+
137+
let mut request = Request::try_from(fixture).unwrap();
138+
request = SanitizeToolIds.transform(request);
139+
140+
// Find the ToolUse content
141+
let tool_use = request.messages.iter().find_map(|msg| {
142+
msg.content.iter().find_map(|content| {
143+
if let Content::ToolUse { id, .. } = content {
144+
Some(id.clone())
145+
} else {
146+
None
147+
}
148+
})
149+
});
150+
151+
assert_eq!(tool_use, Some("call_abc-123_XYZ".to_string()));
152+
}
153+
154+
#[test]
155+
fn test_handles_multiple_tool_calls_and_results() {
156+
let fixture = Context::default().messages(vec![
157+
ContextMessage::Text(
158+
TextMessage::new(Role::User, "test")
159+
.tool_calls(vec![
160+
ToolCallFull::new("tool1")
161+
.call_id("call_1@#$")
162+
.arguments(ToolCallArguments::from_json("{}")),
163+
ToolCallFull::new("tool2")
164+
.call_id("call_2!@#")
165+
.arguments(ToolCallArguments::from_json("{}")),
166+
])
167+
.model(ModelId::new("claude-3-5-sonnet-20241022")),
168+
)
169+
.into(),
170+
ContextMessage::tool_result(
171+
ToolResult::new("tool1")
172+
.call_id(ToolCallId::new("call_1@#$"))
173+
.success("result1"),
174+
)
175+
.into(),
176+
ContextMessage::tool_result(
177+
ToolResult::new("tool2")
178+
.call_id(ToolCallId::new("call_2!@#"))
179+
.success("result2"),
180+
)
181+
.into(),
182+
]);
183+
184+
let mut request = Request::try_from(fixture).unwrap();
185+
request = SanitizeToolIds.transform(request);
186+
187+
// Collect all tool use IDs
188+
let mut tool_use_ids = Vec::new();
189+
let mut tool_result_ids = Vec::new();
190+
191+
for msg in &request.messages {
192+
for content in &msg.content {
193+
match content {
194+
Content::ToolUse { id, .. } => tool_use_ids.push(id.clone()),
195+
Content::ToolResult { tool_use_id, .. } => {
196+
tool_result_ids.push(tool_use_id.clone())
197+
}
198+
_ => {}
199+
}
200+
}
201+
}
202+
203+
assert_eq!(tool_use_ids, vec!["call_1___", "call_2___"]);
204+
assert_eq!(tool_result_ids, vec!["call_1___", "call_2___"]);
205+
}
206+
207+
#[test]
208+
fn test_handles_empty_messages() {
209+
let fixture = Context::default().messages(vec![
210+
ContextMessage::Text(
211+
TextMessage::new(Role::User, "test")
212+
.model(ModelId::new("claude-3-5-sonnet-20241022")),
213+
)
214+
.into(),
215+
]);
216+
217+
let mut request = Request::try_from(fixture).unwrap();
218+
request = SanitizeToolIds.transform(request);
219+
220+
// Should not panic and should preserve the message
221+
assert_eq!(request.messages.len(), 1);
222+
}
223+
}

crates/forge_repo/src/provider/anthropic.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ use forge_app::domain::{
88
};
99
use forge_app::dto::anthropic::{
1010
AuthSystemMessage, CapitalizeToolNames, DropInvalidToolUse, EnforceStrictObjectSchema,
11-
EventData, ListModelResponse, ReasoningTransform, RemoveOutputFormat, Request, SetCache,
11+
EventData, ListModelResponse, ReasoningTransform, RemoveOutputFormat, Request, SanitizeToolIds,
12+
SetCache,
1213
};
1314
use forge_domain::{ChatRepository, Provider, ProviderId};
1415
use reqwest::Url;
@@ -105,7 +106,8 @@ impl<T: HttpInfra> Anthropic<T> {
105106
let pipeline = AuthSystemMessage::default()
106107
.when(|_| self.use_oauth)
107108
.pipe(CapitalizeToolNames)
108-
.pipe(DropInvalidToolUse);
109+
.pipe(DropInvalidToolUse)
110+
.pipe(SanitizeToolIds);
109111

110112
// Vertex AI does not support output_format, so we skip schema enforcement
111113
// and remove any output_format field
@@ -666,7 +668,8 @@ mod tests {
666668
let pipeline = AuthSystemMessage::default()
667669
.when(|_| false) // Not using OAuth
668670
.pipe(CapitalizeToolNames)
669-
.pipe(DropInvalidToolUse);
671+
.pipe(DropInvalidToolUse)
672+
.pipe(SanitizeToolIds);
670673

671674
let request = pipeline
672675
.pipe(RemoveOutputFormat)

0 commit comments

Comments
 (0)