Skip to main content

codex_convert_proxy/convert/request/
messages.rs

1//! Message conversion utilities for Responses API → Chat API.
2
3use 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
12/// Convert input (with optional instructions) to Chat messages.
13pub 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    // Add system message from instructions
21    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    // Convert input items
35    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                        // tool_call_id is only valid on role=tool per Chat API spec
72                        // (ChatCompletionRequestToolMessage). Avoid leaking it to other roles.
73                        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 we have pending tool calls and now receive an assistant message,
82                        // merge them into ONE assistant message. Some providers require
83                        // tool outputs to immediately follow the assistant tool_calls message.
84                        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                            // Flush pending tool calls before non-assistant message items
107                            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                        // Accumulate FunctionCall items into pending_tool_calls
136                        let arguments = item.arguments.unwrap_or_default();
137                        let name = item
138                            .name
139                            .ok_or_else(|| ConversionError::MissingField("name".to_string()))?;
140                        // Use call_id to match FunctionCallOutput's call_id reference
141                        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                        // Flush pending tool calls before FunctionCallOutput
163                        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                        // Some providers require a preceding assistant.tool_calls message
181                        // before each tool result. If missing in the current input window,
182                        // synthesize a minimal one to preserve protocol validity.
183                        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            // Handle any remaining tool calls
230            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
257/// Extract text content from Response API content.
258pub 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        // OpenAI `ChatCompletionRequestMessageContentPartImage.image_url` is a
317        // required object `{url, detail?}`, not a string.
318        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}