codex_convert_proxy/convert/request/
messages.rs1use crate::error::ConversionError;
4use crate::types::chat_api::{
5 ChatMessage, Content, ContentBlock, FunctionCall, ImageUrlField, ImageUrlObject, MessageRole,
6 ToolCall,
7};
8use crate::types::response_api::{
9 Content as ResponseContent, ContentPart, InputItemOrString,
10};
11
12pub fn convert_input_to_messages(
14 input: InputItemOrString,
15 instructions: Option<String>,
16 enforce_tool_result_adjacency: bool,
17) -> Result<Vec<ChatMessage>, ConversionError> {
18 let mut messages = Vec::new();
19
20 if let Some(inst) = instructions {
22 messages.push(ChatMessage {
23 role: MessageRole::System,
24 content: Content::String(inst),
25 name: None,
26 annotations: None,
27 tool_calls: None,
28 tool_call_id: None,
29 function_call: None,
30 refusal: None,
31 });
32 }
33
34 match input {
36 InputItemOrString::String(s) => {
37 messages.push(ChatMessage {
38 role: MessageRole::User,
39 content: Content::String(s),
40 name: None,
41 annotations: None,
42 tool_calls: None,
43 tool_call_id: None,
44 function_call: None,
45 refusal: None,
46 });
47 }
48 InputItemOrString::Array(items) => {
49 let mut pending_tool_calls: Option<Vec<ToolCall>> = None;
50 let mut emitted_tool_call_ids: std::collections::HashSet<String> =
51 std::collections::HashSet::new();
52 let mut emitted_tool_call_names: std::collections::HashMap<String, String> =
53 std::collections::HashMap::new();
54
55 for item in items {
56 match item.item_type {
57 crate::types::response_api::InputItemType::Message => {
58 let role = match item.role.as_deref() {
59 Some("developer") => MessageRole::Developer,
60 Some("system") => MessageRole::System,
61 Some("assistant") => MessageRole::Assistant,
62 Some("tool") => MessageRole::Tool,
63 Some("user") | None => MessageRole::User,
64 Some(other) => {
65 return Err(ConversionError::InvalidFormat(format!(
66 "unsupported message role: {other}"
67 )));
68 }
69 };
70
71 let tool_call_id_for_msg = if matches!(role, MessageRole::Tool) {
74 item.call_id.clone()
75 } else {
76 None
77 };
78
79 let content = extract_content(&item.content)?;
80
81 if enforce_tool_result_adjacency && role == MessageRole::Assistant {
85 if let Some(tool_calls) = pending_tool_calls.take() {
86 for tc in &tool_calls {
87 emitted_tool_call_ids.insert(tc.id.clone());
88 emitted_tool_call_names.insert(tc.id.clone(), tc.function.name.clone());
89 }
90 messages.push(ChatMessage {
91 role,
92 content,
93 name: item.name,
94 annotations: None,
95 tool_calls: Some(tool_calls),
96 tool_call_id: tool_call_id_for_msg.clone(),
97 function_call: None,
98 refusal: None,
99 });
100 tracing::debug!(
101 "[REQUEST_CONVERT] merged assistant message with pending tool_calls to keep tool result adjacency"
102 );
103 continue;
104 }
105 } else if let Some(tool_calls) = pending_tool_calls.take() {
106 for tc in &tool_calls {
108 emitted_tool_call_ids.insert(tc.id.clone());
109 emitted_tool_call_names.insert(tc.id.clone(), tc.function.name.clone());
110 }
111 messages.push(ChatMessage {
112 role: MessageRole::Assistant,
113 content: Content::String(String::new()),
114 name: None,
115 annotations: None,
116 tool_calls: Some(tool_calls),
117 tool_call_id: None,
118 function_call: None,
119 refusal: None,
120 });
121 }
122
123 messages.push(ChatMessage {
124 role,
125 content,
126 name: item.name,
127 annotations: None,
128 tool_calls: None,
129 tool_call_id: tool_call_id_for_msg,
130 function_call: None,
131 refusal: None,
132 });
133 }
134 crate::types::response_api::InputItemType::FunctionCall => {
135 let arguments = item.arguments.unwrap_or_default();
137 let name = item
138 .name
139 .ok_or_else(|| ConversionError::MissingField("name".to_string()))?;
140 let id = item.call_id.or(item.id).unwrap_or_else(|| format!("call_{}", uuid::Uuid::new_v4()));
142
143 let tool_call = ToolCall {
144 id,
145 tool_type: "function".to_string(),
146 function: FunctionCall { name, arguments },
147 };
148
149 pending_tool_calls.get_or_insert_with(Vec::new).push(tool_call);
150 }
151 crate::types::response_api::InputItemType::FunctionCallOutput => {
152 let call_id = item
153 .call_id
154 .clone()
155 .unwrap_or_else(|| format!("call_{}", uuid::Uuid::new_v4()));
156 let output_name = item
157 .name
158 .clone()
159 .or_else(|| emitted_tool_call_names.get(&call_id).cloned())
160 .unwrap_or_else(|| "unknown_tool".to_string());
161
162 if let Some(tool_calls) = pending_tool_calls.take() {
164 for tc in &tool_calls {
165 emitted_tool_call_ids.insert(tc.id.clone());
166 emitted_tool_call_names.insert(tc.id.clone(), tc.function.name.clone());
167 }
168 messages.push(ChatMessage {
169 role: MessageRole::Assistant,
170 content: Content::String(String::new()),
171 name: None,
172 annotations: None,
173 tool_calls: Some(tool_calls),
174 tool_call_id: None,
175 function_call: None,
176 refusal: None,
177 });
178 }
179
180 if enforce_tool_result_adjacency && !emitted_tool_call_ids.contains(&call_id) {
184 tracing::warn!(
185 "[REQUEST_CONVERT] function_call_output without preceding function_call, synthesizing assistant tool_call (call_id={}, name={})",
186 call_id,
187 output_name
188 );
189 let synthetic_tool_call = ToolCall {
190 id: call_id.clone(),
191 tool_type: "function".to_string(),
192 function: FunctionCall {
193 name: output_name.clone(),
194 arguments: "{}".to_string(),
195 },
196 };
197 messages.push(ChatMessage {
198 role: MessageRole::Assistant,
199 content: Content::String(String::new()),
200 name: None,
201 annotations: None,
202 tool_calls: Some(vec![synthetic_tool_call]),
203 tool_call_id: None,
204 function_call: None,
205 refusal: None,
206 });
207 emitted_tool_call_ids.insert(call_id.clone());
208 }
209
210 messages.push(ChatMessage {
211 role: MessageRole::Tool,
212 content: Content::String(item.output.unwrap_or_default()),
213 name: item.name,
214 annotations: None,
215 tool_calls: None,
216 tool_call_id: Some(call_id.clone()),
217 function_call: None,
218 refusal: None,
219 });
220 tracing::debug!(
221 "[REQUEST_CONVERT] emitted tool result message (call_id={}, name={})",
222 call_id,
223 output_name
224 );
225 }
226 }
227 }
228
229 if let Some(tool_calls) = pending_tool_calls {
231 for tc in &tool_calls {
232 emitted_tool_call_ids.insert(tc.id.clone());
233 emitted_tool_call_names.insert(tc.id.clone(), tc.function.name.clone());
234 }
235 messages.push(ChatMessage {
236 role: MessageRole::Assistant,
237 content: Content::String(String::new()),
238 name: None,
239 annotations: None,
240 tool_calls: Some(tool_calls),
241 tool_call_id: None,
242 function_call: None,
243 refusal: None,
244 });
245 }
246 tracing::debug!(
247 "[REQUEST_CONVERT] input array converted: messages={}, emitted_tool_calls={}",
248 messages.len(),
249 emitted_tool_call_ids.len()
250 );
251 }
252 }
253
254 Ok(messages)
255}
256
257pub fn extract_content(content: &Option<ResponseContent>) -> Result<Content, ConversionError> {
259 match content {
260 Some(ResponseContent::String(s)) => Ok(Content::String(s.clone())),
261 Some(ResponseContent::Array(parts)) => {
262 let mut blocks: Vec<ContentBlock> = Vec::new();
263 for part in parts {
264 match part {
265 ContentPart::InputText { text } => blocks.push(ContentBlock {
266 block_type: "text".to_string(),
267 text: Some(text.clone()),
268 image_url: None,
269 }),
270 ContentPart::OutputText { text, .. } => blocks.push(ContentBlock {
271 block_type: "text".to_string(),
272 text: Some(text.clone()),
273 image_url: None,
274 }),
275 ContentPart::InputImage { image_url } => blocks.push(ContentBlock {
276 block_type: "image_url".to_string(),
277 text: None,
278 image_url: Some(ImageUrlField::Object(ImageUrlObject {
279 url: image_url.clone(),
280 })),
281 }),
282 ContentPart::InputFile { file_url, file_id } => {
283 let file_ref = file_url
284 .as_ref()
285 .or(file_id.as_ref())
286 .cloned()
287 .unwrap_or_else(|| "unknown_file".to_string());
288 blocks.push(ContentBlock {
289 block_type: "text".to_string(),
290 text: Some(format!("[input_file] {}", file_ref)),
291 image_url: None,
292 });
293 }
294 }
295 }
296
297 if blocks.is_empty() {
298 Ok(Content::String(String::new()))
299 } else if blocks.len() == 1 && blocks[0].block_type == "text" {
300 Ok(Content::String(blocks[0].text.clone().unwrap_or_default()))
301 } else {
302 Ok(Content::Array(blocks))
303 }
304 }
305 None => Ok(Content::String(String::new())),
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::types::response_api::{Content as ResponseContent, ContentPart};
313
314 #[test]
315 fn test_extract_content_image_url_serializes_as_object() {
316 let content = ResponseContent::Array(vec![
319 ContentPart::InputText { text: "see this:".into() },
320 ContentPart::InputImage { image_url: "https://example.com/x.png".into() },
321 ]);
322 let chat_content = extract_content(&Some(content)).unwrap();
323 let json = serde_json::to_value(&chat_content).unwrap();
324 let arr = json.as_array().expect("array content");
325 let image_block = arr
326 .iter()
327 .find(|b| b["type"] == "image_url")
328 .expect("image_url block present");
329 assert!(image_block["image_url"].is_object(), "image_url must be object: {image_block}");
330 assert_eq!(image_block["image_url"]["url"], "https://example.com/x.png");
331 }
332
333 #[test]
334 fn test_unknown_role_returns_error() {
335 let input = InputItemOrString::Array(vec![crate::types::response_api::InputItem {
336 id: None,
337 item_type: crate::types::response_api::InputItemType::Message,
338 role: Some("alien".to_string()),
339 content: Some(ResponseContent::String("hi".into())),
340 name: None,
341 arguments: None,
342 call_id: None,
343 output: None,
344 namespace: None,
345 }]);
346 let err = convert_input_to_messages(input, None, false)
347 .expect_err("unknown role must fail");
348 assert!(matches!(err, ConversionError::InvalidFormat(_)));
349 }
350}