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, MessageRole, ToolCall,
6};
7use crate::types::response_api::{
8    Content as ResponseContent, ContentPart, InputItemOrString,
9};
10
11/// Convert input (with optional instructions) to Chat messages.
12pub fn convert_input_to_messages(
13    input: InputItemOrString,
14    instructions: Option<String>,
15    enforce_tool_result_adjacency: bool,
16) -> Result<Vec<ChatMessage>, ConversionError> {
17    let mut messages = Vec::new();
18
19    // Add system message from instructions
20    if let Some(inst) = instructions {
21        messages.push(ChatMessage {
22            role: MessageRole::System,
23            content: Content::String(inst),
24            name: None,
25            annotations: None,
26            tool_calls: None,
27            tool_call_id: None,
28                function_call: None,
29                refusal: None,
30        });
31    }
32
33    // Convert input items
34    match input {
35        InputItemOrString::String(s) => {
36            messages.push(ChatMessage {
37                role: MessageRole::User,
38                content: Content::String(s),
39                name: None,
40                annotations: None,
41                tool_calls: None,
42                tool_call_id: None,
43                function_call: None,
44                refusal: None,
45            });
46        }
47        InputItemOrString::Array(items) => {
48            let mut pending_tool_calls: Option<Vec<ToolCall>> = None;
49            let mut emitted_tool_call_ids: std::collections::HashSet<String> =
50                std::collections::HashSet::new();
51            let mut emitted_tool_call_names: std::collections::HashMap<String, String> =
52                std::collections::HashMap::new();
53
54            for item in items {
55                match item.item_type {
56                    crate::types::response_api::InputItemType::Message => {
57                        let role = match item.role.as_deref() {
58                            Some("developer") => MessageRole::Developer,
59                            Some("system") => MessageRole::System,
60                            Some("assistant") => MessageRole::Assistant,
61                            Some("tool") => MessageRole::Tool,
62                            _ => MessageRole::User,
63                        };
64
65                        let content = extract_content(&item.content)?;
66
67                        // If we have pending tool calls and now receive an assistant message,
68                        // merge them into ONE assistant message. Some providers require
69                        // tool outputs to immediately follow the assistant tool_calls message.
70                        if enforce_tool_result_adjacency && role == MessageRole::Assistant {
71                            if let Some(tool_calls) = pending_tool_calls.take() {
72                                for tc in &tool_calls {
73                                    emitted_tool_call_ids.insert(tc.id.clone());
74                                    emitted_tool_call_names.insert(tc.id.clone(), tc.function.name.clone());
75                                }
76                                messages.push(ChatMessage {
77                                    role,
78                                    content,
79                                    name: item.name,
80                                    annotations: None,
81                                    tool_calls: Some(tool_calls),
82                                    tool_call_id: item.call_id,
83                                    function_call: None,
84                                    refusal: None,
85                                });
86                                tracing::debug!(
87                                    "[REQUEST_CONVERT] merged assistant message with pending tool_calls to keep tool result adjacency"
88                                );
89                                continue;
90                            }
91                        } else if let Some(tool_calls) = pending_tool_calls.take() {
92                            // Flush pending tool calls before non-assistant message items
93                            for tc in &tool_calls {
94                                emitted_tool_call_ids.insert(tc.id.clone());
95                                emitted_tool_call_names.insert(tc.id.clone(), tc.function.name.clone());
96                            }
97                            messages.push(ChatMessage {
98                                role: MessageRole::Assistant,
99                                content: Content::String(String::new()),
100                                name: None,
101                                annotations: None,
102                                tool_calls: Some(tool_calls),
103                                tool_call_id: None,
104                                function_call: None,
105                                refusal: None,
106                            });
107                        }
108
109                        messages.push(ChatMessage {
110                            role,
111                            content,
112                            name: item.name,
113                            annotations: None,
114                            tool_calls: None,
115                            tool_call_id: item.call_id,
116                            function_call: None,
117                            refusal: None,
118                        });
119                    }
120                    crate::types::response_api::InputItemType::FunctionCall => {
121                        // Accumulate FunctionCall items into pending_tool_calls
122                        let arguments = item.arguments.unwrap_or_default();
123                        let name = item
124                            .name
125                            .ok_or_else(|| ConversionError::MissingField("name".to_string()))?;
126                        // Use call_id to match FunctionCallOutput's call_id reference
127                        let id = item.call_id.or(item.id).unwrap_or_else(|| format!("call_{}", uuid::Uuid::new_v4()));
128
129                        let tool_call = ToolCall {
130                            id,
131                            tool_type: "function".to_string(),
132                            function: FunctionCall { name, arguments },
133                        };
134
135                        pending_tool_calls.get_or_insert_with(Vec::new).push(tool_call);
136                    }
137                    crate::types::response_api::InputItemType::FunctionCallOutput => {
138                        let call_id = item
139                            .call_id
140                            .clone()
141                            .unwrap_or_else(|| format!("call_{}", uuid::Uuid::new_v4()));
142                        let output_name = item
143                            .name
144                            .clone()
145                            .or_else(|| emitted_tool_call_names.get(&call_id).cloned())
146                            .unwrap_or_else(|| "unknown_tool".to_string());
147
148                        // Flush pending tool calls before FunctionCallOutput
149                        if let Some(tool_calls) = pending_tool_calls.take() {
150                            for tc in &tool_calls {
151                                emitted_tool_call_ids.insert(tc.id.clone());
152                                emitted_tool_call_names.insert(tc.id.clone(), tc.function.name.clone());
153                            }
154                            messages.push(ChatMessage {
155                                role: MessageRole::Assistant,
156                                content: Content::String(String::new()),
157                                name: None,
158                                annotations: None,
159                                tool_calls: Some(tool_calls),
160                                tool_call_id: None,
161                                function_call: None,
162                                refusal: None,
163                            });
164                        }
165
166                        // Some providers require a preceding assistant.tool_calls message
167                        // before each tool result. If missing in the current input window,
168                        // synthesize a minimal one to preserve protocol validity.
169                        if enforce_tool_result_adjacency && !emitted_tool_call_ids.contains(&call_id) {
170                            tracing::warn!(
171                                "[REQUEST_CONVERT] function_call_output without preceding function_call, synthesizing assistant tool_call (call_id={}, name={})",
172                                call_id,
173                                output_name
174                            );
175                            let synthetic_tool_call = ToolCall {
176                                id: call_id.clone(),
177                                tool_type: "function".to_string(),
178                                function: FunctionCall {
179                                    name: output_name.clone(),
180                                    arguments: "{}".to_string(),
181                                },
182                            };
183                            messages.push(ChatMessage {
184                                role: MessageRole::Assistant,
185                                content: Content::String(String::new()),
186                                name: None,
187                                annotations: None,
188                                tool_calls: Some(vec![synthetic_tool_call]),
189                                tool_call_id: None,
190                                function_call: None,
191                                refusal: None,
192                            });
193                            emitted_tool_call_ids.insert(call_id.clone());
194                        }
195
196                        messages.push(ChatMessage {
197                            role: MessageRole::Tool,
198                            content: Content::String(item.output.unwrap_or_default()),
199                            name: item.name,
200                            annotations: None,
201                            tool_calls: None,
202                            tool_call_id: Some(call_id.clone()),
203                            function_call: None,
204                            refusal: None,
205                        });
206                        tracing::debug!(
207                            "[REQUEST_CONVERT] emitted tool result message (call_id={}, name={})",
208                            call_id,
209                            output_name
210                        );
211                    }
212                }
213            }
214
215            // Handle any remaining tool calls
216            if let Some(tool_calls) = pending_tool_calls {
217                for tc in &tool_calls {
218                    emitted_tool_call_ids.insert(tc.id.clone());
219                    emitted_tool_call_names.insert(tc.id.clone(), tc.function.name.clone());
220                }
221                messages.push(ChatMessage {
222                    role: MessageRole::Assistant,
223                    content: Content::String(String::new()),
224                    name: None,
225                    annotations: None,
226                    tool_calls: Some(tool_calls),
227                    tool_call_id: None,
228                    function_call: None,
229                    refusal: None,
230                });
231            }
232            tracing::debug!(
233                "[REQUEST_CONVERT] input array converted: messages={}, emitted_tool_calls={}",
234                messages.len(),
235                emitted_tool_call_ids.len()
236            );
237        }
238    }
239
240    Ok(messages)
241}
242
243/// Extract text content from Response API content.
244pub fn extract_content(content: &Option<ResponseContent>) -> Result<Content, ConversionError> {
245    match content {
246        Some(ResponseContent::String(s)) => Ok(Content::String(s.clone())),
247        Some(ResponseContent::Array(parts)) => {
248            let mut blocks: Vec<ContentBlock> = Vec::new();
249            for part in parts {
250                match part {
251                    ContentPart::InputText { text } => blocks.push(ContentBlock {
252                        block_type: "text".to_string(),
253                        text: Some(text.clone()),
254                        image_url: None,
255                    }),
256                    ContentPart::OutputText { text, .. } => blocks.push(ContentBlock {
257                        block_type: "text".to_string(),
258                        text: Some(text.clone()),
259                        image_url: None,
260                    }),
261                    ContentPart::InputImage { image_url } => blocks.push(ContentBlock {
262                        block_type: "image_url".to_string(),
263                        text: None,
264                        image_url: Some(image_url.clone().into()),
265                    }),
266                    ContentPart::InputFile { file_url, file_id } => {
267                        let file_ref = file_url
268                            .as_ref()
269                            .or(file_id.as_ref())
270                            .cloned()
271                            .unwrap_or_else(|| "unknown_file".to_string());
272                        blocks.push(ContentBlock {
273                            block_type: "text".to_string(),
274                            text: Some(format!("[input_file] {}", file_ref)),
275                            image_url: None,
276                        });
277                    }
278                }
279            }
280
281            if blocks.is_empty() {
282                Ok(Content::String(String::new()))
283            } else if blocks.len() == 1 && blocks[0].block_type == "text" {
284                Ok(Content::String(blocks[0].text.clone().unwrap_or_default()))
285            } else {
286                Ok(Content::Array(blocks))
287            }
288        }
289        None => Ok(Content::String(String::new())),
290    }
291}