Skip to content

Commit a35b674

Browse files
fix(openai): trim tool call IDs to 40 chars max (#2342)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent cce0536 commit a35b674

File tree

3 files changed

+314
-0
lines changed

3 files changed

+314
-0
lines changed

crates/forge_app/src/dto/openai/transformers/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod normalize_tool_schema;
77
mod pipeline;
88
mod set_cache;
99
mod tool_choice;
10+
mod trim_tool_call_ids;
1011
mod when_model;
1112
mod zai_reasoning;
1213

crates/forge_app/src/dto/openai/transformers/pipeline.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use super::minimax::SetMinimaxParams;
99
use super::normalize_tool_schema::NormalizeToolSchema;
1010
use super::set_cache::SetCache;
1111
use super::tool_choice::SetToolChoice;
12+
use super::trim_tool_call_ids::TrimToolCallIds;
1213
use super::when_model::when_model;
1314
use super::zai_reasoning::SetZaiThinking;
1415
use crate::dto::openai::{Request, ToolChoice};
@@ -49,11 +50,14 @@ impl Transformer for ProviderPipeline<'_> {
4950

5051
let cerebras_compat = MakeCerebrasCompat.when(move |_| provider.id == ProviderId::CEREBRAS);
5152

53+
let trim_tool_call_ids = TrimToolCallIds.when(move |_| provider.id == ProviderId::OPENAI);
54+
5255
let mut combined = zai_thinking
5356
.pipe(or_transformers)
5457
.pipe(open_ai_compat)
5558
.pipe(github_copilot_reasoning)
5659
.pipe(cerebras_compat)
60+
.pipe(trim_tool_call_ids)
5761
.pipe(NormalizeToolSchema);
5862
combined.transform(request)
5963
}
@@ -292,4 +296,57 @@ mod tests {
292296
// OpenAI compat transformer removes reasoning field
293297
assert_eq!(actual.reasoning, None);
294298
}
299+
300+
#[test]
301+
fn test_openai_provider_trims_tool_call_ids() {
302+
let provider = openai("openai");
303+
let long_id = "call_12345678901234567890123456789012345678901234567890";
304+
305+
let fixture = Request::default().messages(vec![crate::dto::openai::Message {
306+
role: crate::dto::openai::Role::Tool,
307+
content: None,
308+
name: None,
309+
tool_call_id: Some(forge_domain::ToolCallId::new(long_id)),
310+
tool_calls: None,
311+
reasoning_details: None,
312+
reasoning_text: None,
313+
reasoning_opaque: None,
314+
}]);
315+
316+
let mut pipeline = ProviderPipeline::new(&provider);
317+
let actual = pipeline.transform(fixture);
318+
319+
let expected_id = "call_12345678901234567890123456789012345";
320+
assert_eq!(expected_id.len(), 40);
321+
322+
let messages = actual.messages.unwrap();
323+
assert_eq!(
324+
messages[0].tool_call_id.as_ref().unwrap().as_str(),
325+
expected_id
326+
);
327+
}
328+
329+
#[test]
330+
fn test_non_openai_provider_does_not_trim_tool_call_ids() {
331+
let provider = anthropic("claude");
332+
let long_id = "call_12345678901234567890123456789012345678901234567890";
333+
334+
let fixture = Request::default().messages(vec![crate::dto::openai::Message {
335+
role: crate::dto::openai::Role::Tool,
336+
content: None,
337+
name: None,
338+
tool_call_id: Some(forge_domain::ToolCallId::new(long_id)),
339+
tool_calls: None,
340+
reasoning_details: None,
341+
reasoning_text: None,
342+
reasoning_opaque: None,
343+
}]);
344+
345+
let mut pipeline = ProviderPipeline::new(&provider);
346+
let actual = pipeline.transform(fixture);
347+
348+
// Anthropic provider should not trim tool call IDs
349+
let messages = actual.messages.unwrap();
350+
assert_eq!(messages[0].tool_call_id.as_ref().unwrap().as_str(), long_id);
351+
}
295352
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
use forge_domain::Transformer;
2+
3+
use crate::dto::openai::Request;
4+
5+
/// Trims tool call IDs to a maximum of 40 characters for OpenAI compatibility.
6+
/// OpenAI requires tool call IDs to be max 40 characters.
7+
pub struct TrimToolCallIds;
8+
9+
impl Transformer for TrimToolCallIds {
10+
type Value = Request;
11+
12+
fn transform(&mut self, mut request: Self::Value) -> Self::Value {
13+
if let Some(messages) = request.messages.as_mut() {
14+
for message in messages.iter_mut() {
15+
// Trim tool_call_id in tool role messages
16+
if let Some(ref mut tool_call_id) = message.tool_call_id {
17+
*tool_call_id = forge_domain::ToolCallId::new(
18+
tool_call_id.as_str().chars().take(40).collect::<String>(),
19+
);
20+
}
21+
22+
// Trim tool call IDs in assistant messages
23+
if let Some(ref mut id) = message.tool_calls {
24+
for tool_call in id.iter_mut() {
25+
if let Some(ref mut tool_call_id) = tool_call.id {
26+
let trimmed_id =
27+
tool_call_id.as_str().chars().take(40).collect::<String>();
28+
*tool_call_id = forge_domain::ToolCallId::new(trimmed_id);
29+
}
30+
}
31+
}
32+
}
33+
}
34+
request
35+
}
36+
}
37+
38+
#[cfg(test)]
39+
mod tests {
40+
use pretty_assertions::assert_eq;
41+
42+
use super::*;
43+
use crate::dto::openai::response::{FunctionCall, ToolCall as ResponseToolCall};
44+
use crate::dto::openai::tool_choice::FunctionType;
45+
use crate::dto::openai::{Message, Role};
46+
47+
#[test]
48+
fn test_trim_tool_call_id_in_tool_message() {
49+
// Create a tool call ID that's longer than 40 characters
50+
let long_id = "call_12345678901234567890123456789012345678901234567890";
51+
assert!(long_id.len() > 40);
52+
53+
let fixture = Request::default().messages(vec![Message {
54+
role: Role::Tool,
55+
content: None,
56+
name: None,
57+
tool_call_id: Some(forge_domain::ToolCallId::new(long_id)),
58+
tool_calls: None,
59+
reasoning_details: None,
60+
reasoning_text: None,
61+
reasoning_opaque: None,
62+
}]);
63+
64+
let actual = TrimToolCallIds.transform(fixture);
65+
66+
let expected_id = "call_12345678901234567890123456789012345";
67+
assert_eq!(expected_id.len(), 40);
68+
69+
let messages = actual.messages.unwrap();
70+
assert_eq!(
71+
messages[0].tool_call_id.as_ref().unwrap().as_str(),
72+
expected_id
73+
);
74+
}
75+
76+
#[test]
77+
fn test_trim_tool_call_id_in_assistant_message() {
78+
// Create tool calls with IDs longer than 40 characters
79+
let long_id = "call_12345678901234567890123456789012345678901234567890";
80+
assert!(long_id.len() > 40);
81+
82+
let fixture = Request::default().messages(vec![Message {
83+
role: Role::Assistant,
84+
content: None,
85+
name: None,
86+
tool_call_id: None,
87+
tool_calls: Some(vec![ResponseToolCall {
88+
id: Some(forge_domain::ToolCallId::new(long_id)),
89+
r#type: FunctionType,
90+
function: FunctionCall {
91+
name: Some(forge_domain::ToolName::new("test_tool")),
92+
arguments: "{}".to_string(),
93+
},
94+
}]),
95+
reasoning_details: None,
96+
reasoning_text: None,
97+
reasoning_opaque: None,
98+
}]);
99+
100+
let actual = TrimToolCallIds.transform(fixture);
101+
102+
let expected_id = "call_12345678901234567890123456789012345";
103+
assert_eq!(expected_id.len(), 40);
104+
105+
let messages = actual.messages.unwrap();
106+
assert_eq!(
107+
messages[0].tool_calls.as_ref().unwrap()[0]
108+
.id
109+
.as_ref()
110+
.unwrap()
111+
.as_str(),
112+
expected_id
113+
);
114+
}
115+
116+
#[test]
117+
fn test_trim_multiple_tool_calls_in_assistant_message() {
118+
let long_id_1 = "call_11111111111111111111111111111111111111111111111111";
119+
let long_id_2 = "call_22222222222222222222222222222222222222222222222222";
120+
assert!(long_id_1.len() > 40);
121+
assert!(long_id_2.len() > 40);
122+
123+
let fixture = Request::default().messages(vec![Message {
124+
role: Role::Assistant,
125+
content: None,
126+
name: None,
127+
tool_call_id: None,
128+
tool_calls: Some(vec![
129+
ResponseToolCall {
130+
id: Some(forge_domain::ToolCallId::new(long_id_1)),
131+
r#type: FunctionType,
132+
function: FunctionCall {
133+
name: Some(forge_domain::ToolName::new("tool_1")),
134+
arguments: "{}".to_string(),
135+
},
136+
},
137+
ResponseToolCall {
138+
id: Some(forge_domain::ToolCallId::new(long_id_2)),
139+
r#type: FunctionType,
140+
function: FunctionCall {
141+
name: Some(forge_domain::ToolName::new("tool_2")),
142+
arguments: "{}".to_string(),
143+
},
144+
},
145+
]),
146+
reasoning_details: None,
147+
reasoning_text: None,
148+
reasoning_opaque: None,
149+
}]);
150+
151+
let actual = TrimToolCallIds.transform(fixture);
152+
153+
let expected_id_1 = "call_11111111111111111111111111111111111";
154+
let expected_id_2 = "call_22222222222222222222222222222222222";
155+
assert_eq!(expected_id_1.len(), 40);
156+
assert_eq!(expected_id_2.len(), 40);
157+
158+
let messages = actual.messages.unwrap();
159+
let tool_calls = messages[0].tool_calls.as_ref().unwrap();
160+
assert_eq!(tool_calls[0].id.as_ref().unwrap().as_str(), expected_id_1);
161+
assert_eq!(tool_calls[1].id.as_ref().unwrap().as_str(), expected_id_2);
162+
}
163+
164+
#[test]
165+
fn test_trim_does_not_affect_short_ids() {
166+
// Create a tool call ID that's already under 40 characters
167+
let short_id = "call_123";
168+
assert!(short_id.len() < 40);
169+
170+
let fixture = Request::default().messages(vec![Message {
171+
role: Role::Tool,
172+
content: None,
173+
name: None,
174+
tool_call_id: Some(forge_domain::ToolCallId::new(short_id)),
175+
tool_calls: None,
176+
reasoning_details: None,
177+
reasoning_text: None,
178+
reasoning_opaque: None,
179+
}]);
180+
181+
let actual = TrimToolCallIds.transform(fixture);
182+
183+
let messages = actual.messages.unwrap();
184+
assert_eq!(
185+
messages[0].tool_call_id.as_ref().unwrap().as_str(),
186+
short_id
187+
);
188+
}
189+
190+
#[test]
191+
fn test_trim_exactly_40_chars_id() {
192+
// Create a tool call ID that's exactly 40 characters
193+
let exact_id = "call_12345678901234567890123456789012345";
194+
assert_eq!(exact_id.len(), 40);
195+
196+
let fixture = Request::default().messages(vec![Message {
197+
role: Role::Tool,
198+
content: None,
199+
name: None,
200+
tool_call_id: Some(forge_domain::ToolCallId::new(exact_id)),
201+
tool_calls: None,
202+
reasoning_details: None,
203+
reasoning_text: None,
204+
reasoning_opaque: None,
205+
}]);
206+
207+
let actual = TrimToolCallIds.transform(fixture);
208+
209+
let messages = actual.messages.unwrap();
210+
assert_eq!(
211+
messages[0].tool_call_id.as_ref().unwrap().as_str(),
212+
exact_id
213+
);
214+
}
215+
216+
#[test]
217+
fn test_trim_handles_multiple_messages() {
218+
let long_id = "call_12345678901234567890123456789012345678901234567890";
219+
let short_id = "call_abc";
220+
221+
let fixture = Request::default().messages(vec![
222+
Message {
223+
role: Role::Tool,
224+
content: None,
225+
name: None,
226+
tool_call_id: Some(forge_domain::ToolCallId::new(long_id)),
227+
tool_calls: None,
228+
reasoning_details: None,
229+
reasoning_text: None,
230+
reasoning_opaque: None,
231+
},
232+
Message {
233+
role: Role::Tool,
234+
content: None,
235+
name: None,
236+
tool_call_id: Some(forge_domain::ToolCallId::new(short_id)),
237+
tool_calls: None,
238+
reasoning_details: None,
239+
reasoning_text: None,
240+
reasoning_opaque: None,
241+
},
242+
]);
243+
244+
let actual = TrimToolCallIds.transform(fixture);
245+
246+
let messages = actual.messages.unwrap();
247+
assert_eq!(
248+
messages[0].tool_call_id.as_ref().unwrap().as_str().len(),
249+
40
250+
);
251+
assert_eq!(
252+
messages[1].tool_call_id.as_ref().unwrap().as_str().len(),
253+
short_id.len()
254+
);
255+
}
256+
}

0 commit comments

Comments
 (0)