1use 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
12pub 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 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 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 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 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 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 let arguments = item.arguments.unwrap_or_default();
146 let name = item
147 .name
148 .ok_or_else(|| ConversionError::MissingField("name".to_string()))?;
149 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 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 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 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 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 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 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
452pub 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 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}