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, Tool,
10};
11
12/// Convert input (with optional instructions) to Chat messages.
13///
14/// Returns:
15/// - `messages`: The converted chat messages
16/// - `extracted_tools`: Tools extracted from `tool_search_output` items
17pub fn convert_input_to_messages(
18    input: InputItemOrString,
19    instructions: Option<String>,
20    enforce_tool_result_adjacency: bool,
21) -> Result<(Vec<ChatMessage>, Vec<Tool>), ConversionError> {
22    let mut messages = Vec::new();
23    #[allow(unused_mut)]
24    let mut extracted_tools: Vec<Tool> = Vec::new();
25    let mut pending_tool_calls: Option<Vec<ToolCall>> = None;
26    let mut emitted_tool_call_ids: std::collections::HashSet<String> =
27        std::collections::HashSet::new();
28    let mut emitted_tool_call_names: std::collections::HashMap<String, String> =
29        std::collections::HashMap::new();
30
31    // Add system message from instructions
32    if let Some(inst) = instructions {
33        messages.push(ChatMessage {
34            role: MessageRole::System,
35            content: Content::String(inst),
36            name: None,
37            annotations: None,
38            tool_calls: None,
39            tool_call_id: None,
40            function_call: None,
41            refusal: None,
42        });
43    }
44
45    // Convert input items
46    match input {
47        InputItemOrString::String(s) => {
48            messages.push(ChatMessage {
49                role: MessageRole::User,
50                content: Content::String(s),
51                name: None,
52                annotations: None,
53                tool_calls: None,
54                tool_call_id: None,
55                function_call: None,
56                refusal: None,
57            });
58        }
59        InputItemOrString::Array(items) => {
60
61            for mut item in items {
62                match item.item_type {
63                    crate::types::response_api::InputItemType::Message => {
64                        let role = match item.role.as_deref() {
65                            Some("developer") => MessageRole::Developer,
66                            Some("system") => MessageRole::System,
67                            Some("assistant") => MessageRole::Assistant,
68                            Some("tool") => MessageRole::Tool,
69                            Some("user") | None => MessageRole::User,
70                            Some("unknown") => MessageRole::Unknown,
71                            Some("critic") => MessageRole::Critic,
72                            Some("discriminator") => MessageRole::Discriminator,
73                            Some(other) => {
74                                return Err(ConversionError::InvalidFormat(format!(
75                                    "unsupported message role: {other}"
76                                )));
77                            }
78                        };
79
80                        // tool_call_id is only valid on role=tool per Chat API spec
81                        // (ChatCompletionRequestToolMessage). Avoid leaking it to other roles.
82                        let tool_call_id_for_msg = if matches!(role, MessageRole::Tool) {
83                            item.call_id.clone()
84                        } else {
85                            None
86                        };
87
88                        let content = extract_content(&item.content)?;
89
90                        // If we have pending tool calls and now receive an assistant message,
91                        // merge them into ONE assistant message. Some providers require
92                        // tool outputs to immediately follow the assistant tool_calls message.
93                        if enforce_tool_result_adjacency && role == MessageRole::Assistant {
94                            if let Some(tool_calls) = pending_tool_calls.take() {
95                                for tc in &tool_calls {
96                                    emitted_tool_call_ids.insert(tc.id.clone());
97                                    emitted_tool_call_names.insert(tc.id.clone(), tc.function.name.clone());
98                                }
99                                messages.push(ChatMessage {
100                                    role,
101                                    content,
102                                    name: item.name,
103                                    annotations: None,
104                                    tool_calls: Some(tool_calls),
105                                    tool_call_id: tool_call_id_for_msg.clone(),
106                                    function_call: None,
107                                    refusal: None,
108                                });
109                                tracing::debug!(
110                                    "[REQUEST_CONVERT] merged assistant message with pending tool_calls to keep tool result adjacency"
111                                );
112                                continue;
113                            }
114                        } else if let Some(tool_calls) = pending_tool_calls.take() {
115                            // Flush pending tool calls before non-assistant message items
116                            for tc in &tool_calls {
117                                emitted_tool_call_ids.insert(tc.id.clone());
118                                emitted_tool_call_names.insert(tc.id.clone(), tc.function.name.clone());
119                            }
120                            messages.push(ChatMessage {
121                                role: MessageRole::Assistant,
122                                content: Content::String(String::new()),
123                                name: None,
124                                annotations: None,
125                                tool_calls: Some(tool_calls),
126                                tool_call_id: None,
127                                function_call: None,
128                                refusal: None,
129                            });
130                        }
131
132                        messages.push(ChatMessage {
133                            role,
134                            content,
135                            name: item.name,
136                            annotations: None,
137                            tool_calls: None,
138                            tool_call_id: tool_call_id_for_msg,
139                            function_call: None,
140                            refusal: None,
141                        });
142                    }
143                    crate::types::response_api::InputItemType::FunctionCall => {
144                        // Accumulate FunctionCall items into pending_tool_calls
145                        let arguments = item.arguments.unwrap_or_default();
146                        let name = item
147                            .name
148                            .ok_or_else(|| ConversionError::MissingField("name".to_string()))?;
149                        // Use call_id to match FunctionCallOutput's call_id reference
150                        let id = item.call_id.or(item.id).unwrap_or_else(|| format!("call_{}", uuid::Uuid::new_v4()));
151
152                        let tool_call = ToolCall {
153                            id,
154                            tool_type: "function".to_string(),
155                            function: FunctionCall { name, arguments },
156                        };
157
158                        pending_tool_calls.get_or_insert_with(Vec::new).push(tool_call);
159                    }
160                    crate::types::response_api::InputItemType::FunctionCallOutput => {
161                        let call_id = item
162                            .call_id
163                            .clone()
164                            .unwrap_or_else(|| format!("call_{}", uuid::Uuid::new_v4()));
165                        let output_name = item
166                            .name
167                            .clone()
168                            .or_else(|| emitted_tool_call_names.get(&call_id).cloned())
169                            .unwrap_or_else(|| "unknown_tool".to_string());
170
171                        // Flush pending tool calls before FunctionCallOutput
172                        if let Some(tool_calls) = pending_tool_calls.take() {
173                            for tc in &tool_calls {
174                                emitted_tool_call_ids.insert(tc.id.clone());
175                                emitted_tool_call_names.insert(tc.id.clone(), tc.function.name.clone());
176                            }
177                            messages.push(ChatMessage {
178                                role: MessageRole::Assistant,
179                                content: Content::String(String::new()),
180                                name: None,
181                                annotations: None,
182                                tool_calls: Some(tool_calls),
183                                tool_call_id: None,
184                                function_call: None,
185                                refusal: None,
186                            });
187                        }
188
189                        // Some providers require a preceding assistant.tool_calls message
190                        // before each tool result. If missing in the current input window,
191                        // synthesize a minimal one to preserve protocol validity.
192                        if enforce_tool_result_adjacency && !emitted_tool_call_ids.contains(&call_id) {
193                            tracing::warn!(
194                                "[REQUEST_CONVERT] function_call_output without preceding function_call, synthesizing assistant tool_call (call_id={}, name={})",
195                                call_id,
196                                output_name
197                            );
198                            let synthetic_tool_call = ToolCall {
199                                id: call_id.clone(),
200                                tool_type: "function".to_string(),
201                                function: FunctionCall {
202                                    name: output_name.clone(),
203                                    arguments: "{}".to_string(),
204                                },
205                            };
206                            messages.push(ChatMessage {
207                                role: MessageRole::Assistant,
208                                content: Content::String(String::new()),
209                                name: None,
210                                annotations: None,
211                                tool_calls: Some(vec![synthetic_tool_call]),
212                                tool_call_id: None,
213                                function_call: None,
214                                refusal: None,
215                            });
216                            emitted_tool_call_ids.insert(call_id.clone());
217                        }
218
219                        messages.push(ChatMessage {
220                            role: MessageRole::Tool,
221                            content: Content::String(item.output.unwrap_or_default()),
222                            name: item.name,
223                            annotations: None,
224                            tool_calls: None,
225                            tool_call_id: Some(call_id.clone()),
226                            function_call: None,
227                            refusal: None,
228                        });
229                        tracing::debug!(
230                            "[REQUEST_CONVERT] emitted tool result message (call_id={}, name={})",
231                            call_id,
232                            output_name
233                        );
234                    }
235                    // --- Unsupported InputItemType variants ---
236                    // These are valid Response API types but not supported for Chat API conversion.
237                    // Log warning with item id if available for debugging.
238                    // --- Unsupported InputItemType variants ---
239                    // These are valid Response API types but not supported for Chat API conversion.
240                    // We skip them with a warning so the request can continue processing.
241                    // These items typically represent internal/server-side features that don't
242                    // affect the actual conversation flow when skipped.
243                    crate::types::response_api::InputItemType::ComputerCall => {
244                        tracing::warn!(
245                            "[REQUEST_CONVERT] skipping computer_call input item (id={:?}), \
246                            computer use feature not supported in Chat API",
247                            item.id
248                        );
249                        continue;
250                    }
251                    crate::types::response_api::InputItemType::ComputerCallOutput => {
252                        tracing::warn!(
253                            "[REQUEST_CONVERT] skipping computer_call_output input item (id={:?})",
254                            item.id
255                        );
256                        continue;
257                    }
258                    crate::types::response_api::InputItemType::FileSearchCall => {
259                        tracing::warn!(
260                            "[REQUEST_CONVERT] skipping file_search_call input item (id={:?})",
261                            item.id
262                        );
263                        continue;
264                    }
265                    crate::types::response_api::InputItemType::WebSearchCall => {
266                        tracing::warn!(
267                            "[REQUEST_CONVERT] skipping web_search_call input item (id={:?})",
268                            item.id
269                        );
270                        continue;
271                    }
272                    crate::types::response_api::InputItemType::CodeInterpreterCall => {
273                        tracing::warn!(
274                            "[REQUEST_CONVERT] skipping code_interpreter_call input item (id={:?})",
275                            item.id
276                        );
277                        continue;
278                    }
279                    crate::types::response_api::InputItemType::Reasoning => {
280                        tracing::warn!(
281                            "[REQUEST_CONVERT] skipping reasoning input item (id={:?}), \
282                            reasoning items are for context but cannot be converted to Chat API format",
283                            item.id
284                        );
285                        continue;
286                    }
287                    crate::types::response_api::InputItemType::ToolSearchCall => {
288                        tracing::warn!(
289                            "[REQUEST_CONVERT] skipping tool_search_call input item (id={:?}, call_id={:?}), \
290                            tool_search_call is an output item type",
291                            item.id,
292                            item.call_id
293                        );
294                        continue;
295                    }
296                    crate::types::response_api::InputItemType::ToolSearchOutput => {
297                        // Extract tools from tool_search_output and merge them
298                        if let Some(tools) = item.tools.take() {
299                            let count = tools.len();
300                            extracted_tools.extend(tools);
301                            tracing::debug!(
302                                "[REQUEST_CONVERT] extracted {} tools from tool_search_output (id={:?})",
303                                count,
304                                item.id
305                            );
306                        } else {
307                            tracing::debug!(
308                                "[REQUEST_CONVERT] tool_search_output has no tools (id={:?})",
309                                item.id
310                            );
311                        }
312                        // tool_search_output is just a tool carrier - no message emitted
313                        continue;
314                    }
315                    crate::types::response_api::InputItemType::ImageGenerationCall => {
316                        tracing::warn!(
317                            "[REQUEST_CONVERT] skipping image_generation_call input item (id={:?})",
318                            item.id
319                        );
320                        continue;
321                    }
322                    crate::types::response_api::InputItemType::LocalShellCall => {
323                        tracing::warn!(
324                            "[REQUEST_CONVERT] skipping local_shell_call input item (id={:?})",
325                            item.id
326                        );
327                        continue;
328                    }
329                    crate::types::response_api::InputItemType::LocalShellCallOutput => {
330                        tracing::warn!(
331                            "[REQUEST_CONVERT] skipping local_shell_call_output input item (id={:?})",
332                            item.id
333                        );
334                        continue;
335                    }
336                    crate::types::response_api::InputItemType::ShellCall => {
337                        tracing::warn!(
338                            "[REQUEST_CONVERT] skipping shell_call input item (id={:?})",
339                            item.id
340                        );
341                        continue;
342                    }
343                    crate::types::response_api::InputItemType::ShellCallOutput => {
344                        tracing::warn!(
345                            "[REQUEST_CONVERT] skipping shell_call_output input item (id={:?})",
346                            item.id
347                        );
348                        continue;
349                    }
350                    crate::types::response_api::InputItemType::McpListTools => {
351                        tracing::warn!(
352                            "[REQUEST_CONVERT] skipping mcp_list_tools input item (id={:?})",
353                            item.id
354                        );
355                        continue;
356                    }
357                    crate::types::response_api::InputItemType::McpApprovalRequest => {
358                        tracing::warn!(
359                            "[REQUEST_CONVERT] skipping mcp_approval_request input item (id={:?})",
360                            item.id
361                        );
362                        continue;
363                    }
364                    crate::types::response_api::InputItemType::McpApprovalResponse => {
365                        tracing::warn!(
366                            "[REQUEST_CONVERT] skipping mcp_approval_response input item (id={:?})",
367                            item.id
368                        );
369                        continue;
370                    }
371                    crate::types::response_api::InputItemType::McpCall => {
372                        tracing::warn!(
373                            "[REQUEST_CONVERT] skipping mcp_call input item (id={:?})",
374                            item.id
375                        );
376                        continue;
377                    }
378                    crate::types::response_api::InputItemType::CustomToolCall => {
379                        tracing::warn!(
380                            "[REQUEST_CONVERT] skipping custom_tool_call input item (id={:?})",
381                            item.id
382                        );
383                        continue;
384                    }
385                    crate::types::response_api::InputItemType::CustomToolCallOutput => {
386                        tracing::warn!(
387                            "[REQUEST_CONVERT] skipping custom_tool_call_output input item (id={:?})",
388                            item.id
389                        );
390                        continue;
391                    }
392                    crate::types::response_api::InputItemType::ApplyPatchCall => {
393                        tracing::warn!(
394                            "[REQUEST_CONVERT] skipping apply_patch_call input item (id={:?})",
395                            item.id
396                        );
397                        continue;
398                    }
399                    crate::types::response_api::InputItemType::ApplyPatchCallOutput => {
400                        tracing::warn!(
401                            "[REQUEST_CONVERT] skipping apply_patch_call_output input item (id={:?})",
402                            item.id
403                        );
404                        continue;
405                    }
406                    crate::types::response_api::InputItemType::Compaction => {
407                        tracing::warn!(
408                            "[REQUEST_CONVERT] skipping compaction input item (id={:?})",
409                            item.id
410                        );
411                        continue;
412                    }
413                    crate::types::response_api::InputItemType::Unknown => {
414                        tracing::warn!(
415                            "[REQUEST_CONVERT] skipping unknown input item type (id={:?}), \
416                            this may be a new type not yet supported by the proxy",
417                            item.id
418                        );
419                        continue;
420                    }
421                }
422            }
423
424            // Handle any remaining tool calls
425            if let Some(tool_calls) = pending_tool_calls {
426                for tc in &tool_calls {
427                    emitted_tool_call_ids.insert(tc.id.clone());
428                    emitted_tool_call_names.insert(tc.id.clone(), tc.function.name.clone());
429                }
430                messages.push(ChatMessage {
431                    role: MessageRole::Assistant,
432                    content: Content::String(String::new()),
433                    name: None,
434                    annotations: None,
435                    tool_calls: Some(tool_calls),
436                    tool_call_id: None,
437                    function_call: None,
438                    refusal: None,
439                });
440            }
441            tracing::debug!(
442                "[REQUEST_CONVERT] input array converted: messages={}, emitted_tool_calls={}",
443                messages.len(),
444                emitted_tool_call_ids.len()
445            );
446        }
447    }
448
449    Ok((messages, extracted_tools))
450}
451
452/// Extract text content from Response API content.
453pub fn extract_content(content: &Option<ResponseContent>) -> Result<Content, ConversionError> {
454    match content {
455        Some(ResponseContent::String(s)) => Ok(Content::String(s.clone())),
456        Some(ResponseContent::Array(parts)) => {
457            let mut blocks: Vec<ContentBlock> = Vec::new();
458            for part in parts {
459                match part {
460                    ContentPart::InputText { text } => blocks.push(ContentBlock {
461                        block_type: "text".to_string(),
462                        text: Some(text.clone()),
463                        image_url: None,
464                        input_audio: None,
465                        file: None,
466                        refusal: None,
467                    }),
468                    ContentPart::OutputText { text, .. } => blocks.push(ContentBlock {
469                        block_type: "text".to_string(),
470                        text: Some(text.clone()),
471                        image_url: None,
472                        input_audio: None,
473                        file: None,
474                        refusal: None,
475                    }),
476                    ContentPart::InputImage { image_url } => blocks.push(ContentBlock {
477                        block_type: "image_url".to_string(),
478                        text: None,
479                        image_url: Some(ImageUrlField::Object(ImageUrlObject {
480                            url: image_url.clone(),
481                            detail: None,
482                        })),
483                        input_audio: None,
484                        file: None,
485                        refusal: None,
486                    }),
487                    ContentPart::InputFile { file_url, file_id } => {
488                        let file_ref = file_url
489                            .as_ref()
490                            .or(file_id.as_ref())
491                            .cloned()
492                            .unwrap_or_else(|| "unknown_file".to_string());
493                        blocks.push(ContentBlock {
494                            block_type: "text".to_string(),
495                            text: Some(format!("[input_file] {}", file_ref)),
496                            image_url: None,
497                            input_audio: None,
498                            file: None,
499                            refusal: None,
500                        });
501                    }
502                }
503            }
504
505            if blocks.is_empty() {
506                Ok(Content::String(String::new()))
507            } else if blocks.len() == 1 && blocks[0].block_type == "text" {
508                Ok(Content::String(blocks[0].text.clone().unwrap_or_default()))
509            } else {
510                Ok(Content::Array(blocks))
511            }
512        }
513        None => Ok(Content::String(String::new())),
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::types::response_api::{Content as ResponseContent, ContentPart};
521
522    #[test]
523    fn test_extract_content_image_url_serializes_as_object() {
524        // OpenAI `ChatCompletionRequestMessageContentPartImage.image_url` is a
525        // required object `{url, detail?}`, not a string.
526        let content = ResponseContent::Array(vec![
527            ContentPart::InputText { text: "see this:".into() },
528            ContentPart::InputImage { image_url: "https://example.com/x.png".into() },
529        ]);
530        let chat_content = extract_content(&Some(content)).unwrap();
531        let json = serde_json::to_value(&chat_content).unwrap();
532        let arr = json.as_array().expect("array content");
533        let image_block = arr
534            .iter()
535            .find(|b| b["type"] == "image_url")
536            .expect("image_url block present");
537        assert!(image_block["image_url"].is_object(), "image_url must be object: {image_block}");
538        assert_eq!(image_block["image_url"]["url"], "https://example.com/x.png");
539    }
540
541    #[test]
542    fn test_unknown_role_returns_error() {
543        let input = InputItemOrString::Array(vec![crate::types::response_api::InputItem {
544            id: None,
545            item_type: crate::types::response_api::InputItemType::Message,
546            role: Some("alien".to_string()),
547            content: Some(ResponseContent::String("hi".into())),
548            name: None,
549            arguments: None,
550            call_id: None,
551            output: None,
552            namespace: None,
553            tools: None,
554        }]);
555        let err = convert_input_to_messages(input, None, false)
556            .expect_err("unknown role must fail");
557        assert!(matches!(err, ConversionError::InvalidFormat(_)));
558    }
559}