use std::collections::BTreeMap; use serde_json::{json, Map, Value}; use crate::{ formats::context::FormatContext, formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort, protocol::canonical::{ canonical_response_format_to_openai, canonicalize_tool_arguments, is_claude_messages_request, is_claude_system_instruction, is_claude_thinking_block, is_claude_tool_result, media_data_or_url, namespace_extension_object, openai_content_text, openai_extensions, openai_response_format_to_canonical, openai_responses_extension, openai_responses_generation_config, openai_responses_input_to_canonical_messages, openai_responses_tool_choice_to_canonical, openai_responses_tools_to_canonical, CanonicalContentBlock, CanonicalInstruction, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition, OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE, }, }; pub fn from(body: &Value, _ctx: &FormatContext) -> Option { from_raw(body) } pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option { to_raw( request, ctx.mapped_model_or(request.model.as_str()), ctx.upstream_is_stream, false, ) } pub fn to_compact(request: &CanonicalRequest, ctx: &FormatContext) -> Option { to_raw( request, ctx.mapped_model_or(request.model.as_str()), false, true, ) } pub fn from_raw(body_json: &Value) -> Option { let request = body_json.as_object()?; let mut canonical = CanonicalRequest { model: request .get("model") .and_then(Value::as_str) .unwrap_or_default() .to_string(), ..CanonicalRequest::default() }; if let Some(instructions) = request.get("instructions") { let text = openai_content_text(Some(instructions)); if !text.trim().is_empty() { canonical.system = Some(text.clone()); canonical.instructions.push(CanonicalInstruction { role: CanonicalRole::System, text, extensions: std::collections::BTreeMap::new(), }); } } canonical.messages = openai_responses_input_to_canonical_messages(request.get("input"))?; canonical.generation = openai_responses_generation_config(request); canonical.tools = openai_responses_tools_to_canonical(request.get("tools"))?; canonical.tool_choice = openai_responses_tool_choice_to_canonical(request.get("tool_choice")); canonical.parallel_tool_calls = request.get("parallel_tool_calls").and_then(Value::as_bool); canonical.metadata = request.get("metadata").cloned(); canonical.response_format = request .get("text") .and_then(Value::as_object) .and_then(|text| text.get("format")) .and_then(|format| openai_response_format_to_canonical(Some(format))); if let Some(reasoning) = request.get("reasoning").and_then(Value::as_object) { let mut extensions = std::collections::BTreeMap::new(); extensions.insert( OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), Value::Object(reasoning.clone()), ); canonical.thinking = Some(CanonicalThinkingConfig { enabled: true, budget_tokens: reasoning.get("budget_tokens").and_then(Value::as_u64), extensions, }); } canonical.extensions = openai_extensions( request, &[ "model", "instructions", "input", "max_output_tokens", "temperature", "top_p", "metadata", "tools", "tool_choice", "parallel_tool_calls", "text", "reasoning", ], ); if let Some(raw) = canonical.extensions.remove("openai") { canonical .extensions .insert(OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), raw); } if let Some(verbosity) = request .get("text") .and_then(Value::as_object) .and_then(|text| text.get("verbosity")) .cloned() { let entry = canonical .extensions .entry(OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string()) .or_insert_with(|| Value::Object(serde_json::Map::new())); if let Some(object) = entry.as_object_mut() { object.insert("verbosity".to_string(), verbosity); } } Some(canonical) } pub fn to_raw( canonical: &CanonicalRequest, mapped_model: &str, upstream_is_stream: bool, compact: bool, ) -> Option { let mut output = Map::new(); output.insert("model".to_string(), Value::String(mapped_model.to_string())); let instructions = canonical_instructions_to_responses(canonical); if let Some(instructions) = instructions.clone() { output.insert("instructions".to_string(), instructions); } let mut input = canonical_messages_to_responses_input(canonical)?; if let Some(developer_message) = claude_system_instructions_to_responses_developer_message(canonical) { input.insert(0, developer_message); } ensure_json_object_response_input_mentions_json(canonical, instructions.as_ref(), &mut input); output.insert("input".to_string(), Value::Array(input)); if upstream_is_stream && !compact { output.insert("stream".to_string(), Value::Bool(true)); } if let Some(max_tokens) = responses_max_output_tokens(canonical) { output.insert("max_output_tokens".to_string(), Value::from(max_tokens)); } insert_number(&mut output, "temperature", canonical.generation.temperature); insert_number(&mut output, "top_p", canonical.generation.top_p); if let Some(top_logprobs) = canonical.generation.top_logprobs { output.insert("top_logprobs".to_string(), Value::from(top_logprobs)); } if let Some(value) = canonical.parallel_tool_calls { output.insert("parallel_tool_calls".to_string(), Value::Bool(value)); } if let Some(metadata) = canonical.metadata.clone() { output.insert("metadata".to_string(), metadata); } if let Some(text_config) = canonical_text_config_to_responses(canonical) { output.insert("text".to_string(), text_config); } if !canonical.tools.is_empty() { output.insert( "tools".to_string(), Value::Array(canonical_tools_to_responses(canonical)), ); } if let Some(tool_choice) = canonical.tool_choice.as_ref() { output.insert( "tool_choice".to_string(), canonical_tool_choice_to_responses(tool_choice), ); } if let Some(reasoning) = canonical_reasoning_config_to_responses(canonical) { output.insert("reasoning".to_string(), reasoning); } output.extend(namespace_extension_object( &canonical.extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE, &output, )); output.extend(namespace_extension_object( &canonical.extensions, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE, &output, )); apply_claude_responses_request_defaults(canonical, mapped_model, &mut output); if compact { output.remove("stream"); } output.remove("verbosity"); Some(Value::Object(output)) } fn canonical_instructions_to_responses(canonical: &CanonicalRequest) -> Option { let text = canonical .instructions .iter() .filter(|instruction| !is_claude_system_instruction(instruction)) .map(|instruction| instruction.text.as_str()) .filter(|text| !text.trim().is_empty()) .collect::>() .join("\n\n"); if !text.trim().is_empty() { return Some(Value::String(text)); } if canonical .instructions .iter() .any(is_claude_system_instruction) { return None; } canonical .system .as_ref() .filter(|value| !value.trim().is_empty()) .cloned() .map(Value::String) } fn claude_system_instructions_to_responses_developer_message( canonical: &CanonicalRequest, ) -> Option { let content = canonical .instructions .iter() .filter(|instruction| is_claude_system_instruction(instruction)) .filter_map(claude_system_instruction_to_responses_part) .collect::>(); (!content.is_empty()).then(|| { json!({ "type": "message", "role": "developer", "content": content, }) }) } fn claude_system_instruction_to_responses_part( instruction: &CanonicalInstruction, ) -> Option { if instruction.text.trim().is_empty() { return None; } let mut part = Map::new(); part.insert("type".to_string(), Value::String("input_text".to_string())); part.insert("text".to_string(), Value::String(instruction.text.clone())); part.extend(namespace_extension_object( &instruction.extensions, "claude", &part, )); Some(Value::Object(part)) } fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option> { let mut input = Vec::new(); for message in &canonical.messages { let role = match message.role { CanonicalRole::Assistant => "assistant", CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user", CanonicalRole::System | CanonicalRole::Developer => continue, }; let mut content = Vec::new(); let mut saw_tool_item = false; for block in &message.content { match block { CanonicalContentBlock::ToolUse { id, name, input: arguments, .. } => { flush_responses_message(&mut input, role, &mut content); saw_tool_item = true; input.push(json!({ "type": "function_call", "call_id": id, "name": name, "arguments": canonicalize_tool_arguments(arguments), })); } CanonicalContentBlock::ToolResult { tool_use_id, output, content_text, extensions, .. } => { flush_responses_message(&mut input, role, &mut content); saw_tool_item = true; let (tool_output, extra_user_content) = responses_tool_result_payload( output.as_ref(), content_text.as_deref(), extensions, ); input.push(json!({ "type": "function_call_output", "call_id": tool_use_id, "output": tool_output, })); if !extra_user_content.is_empty() { input.push(json!({ "type": "message", "role": "user", "content": extra_user_content, })); } } CanonicalContentBlock::Thinking { text, extensions, .. } => { if is_claude_thinking_block(extensions) { continue; } if role == "assistant" && !text.trim().is_empty() { content.push(json!({ "type": "output_text", "text": format!("{text}"), })); } } other => { if let Some(part) = canonical_block_to_responses_input_part(other, role) { content.push(part); } } } } if content.is_empty() && !saw_tool_item { if role == "assistant" { input.push(json!({ "type": "message", "role": role, "content": [{ "type": "output_text", "text": "", }], })); } else { input.push(json!({ "type": "message", "role": role, "content": "", })); } continue; } flush_responses_message(&mut input, role, &mut content); } Some(input) } fn responses_max_output_tokens(canonical: &CanonicalRequest) -> Option { canonical.generation.max_tokens.map(|max_tokens| { if is_claude_messages_request(&canonical.extensions) && max_tokens < 128 { 128 } else { max_tokens } }) } fn apply_claude_responses_request_defaults( canonical: &CanonicalRequest, mapped_model: &str, output: &mut Map, ) { if !is_claude_messages_request(&canonical.extensions) { return; } if mapped_model .trim() .to_ascii_lowercase() .starts_with("gpt-5") { output.remove("temperature"); output.remove("top_p"); } output .entry("store".to_string()) .or_insert_with(|| Value::Bool(false)); output .entry("parallel_tool_calls".to_string()) .or_insert_with(|| Value::Bool(true)); let include = output .entry("include".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if let Some(include) = include.as_array_mut() { let encrypted_content = Value::String("reasoning.encrypted_content".to_string()); if !include.iter().any(|value| value == &encrypted_content) { include.push(encrypted_content); } } } fn ensure_json_object_response_input_mentions_json( canonical: &CanonicalRequest, instructions: Option<&Value>, input: &mut Vec, ) { if !canonical .response_format .as_ref() .is_some_and(|format| format.format_type.eq_ignore_ascii_case("json_object")) || input.iter().any(value_contains_json_word) || !instructions.is_some_and(value_contains_json_word) { return; } input.insert( 0, json!({ "type": "message", "role": "system", "content": [{ "type": "input_text", "text": "Respond with JSON.", }], }), ); } fn value_contains_json_word(value: &Value) -> bool { match value { Value::String(text) => text.to_ascii_lowercase().contains("json"), Value::Array(items) => items.iter().any(value_contains_json_word), Value::Object(object) => object.values().any(value_contains_json_word), _ => false, } } fn flush_responses_message(input: &mut Vec, role: &str, content: &mut Vec) { if content.is_empty() { return; } input.push(json!({ "type": "message", "role": role, "content": std::mem::take(content), })); } fn canonical_block_to_responses_input_part( block: &CanonicalContentBlock, role: &str, ) -> Option { match block { CanonicalContentBlock::Text { text, .. } => { if text.is_empty() { return None; } Some(json!({ "type": if role == "assistant" { "output_text" } else { "input_text" }, "text": text, })) } CanonicalContentBlock::Image { data, url, media_type, detail, .. } => { let mut item = Map::new(); item.insert( "type".to_string(), Value::String(if role == "assistant" { "output_image".to_string() } else { "input_image".to_string() }), ); item.insert( "image_url".to_string(), Value::String(media_data_or_url(media_type, data, url)), ); if let Some(detail) = detail { item.insert("detail".to_string(), Value::String(detail.clone())); } Some(Value::Object(item)) } CanonicalContentBlock::File { data, file_id, file_url, media_type, filename, .. } => { let mut item = Map::new(); item.insert("type".to_string(), Value::String("input_file".to_string())); if let Some(value) = file_id { item.insert("file_id".to_string(), Value::String(value.clone())); } if data.is_some() || file_url.is_some() { item.insert( "file_data".to_string(), Value::String(media_data_or_url(media_type, data, file_url)), ); } if let Some(value) = filename { item.insert("filename".to_string(), Value::String(value.clone())); } (item.len() > 1).then_some(Value::Object(item)) } CanonicalContentBlock::Audio { data, format, .. } => Some(json!({ "type": "input_audio", "input_audio": { "data": data.clone().unwrap_or_default(), "format": format.clone().unwrap_or_else(|| "mp3".to_string()), } })), CanonicalContentBlock::Unknown { raw_type, payload, .. } if raw_type == "refusal" => payload .get("refusal") .and_then(Value::as_str) .filter(|text| !text.trim().is_empty()) .map(|text| json!({ "type": "refusal", "refusal": text })), CanonicalContentBlock::Thinking { .. } | CanonicalContentBlock::ToolUse { .. } | CanonicalContentBlock::ToolResult { .. } | CanonicalContentBlock::Unknown { .. } => None, } } fn canonical_tools_to_responses(canonical: &CanonicalRequest) -> Vec { let mut tools = canonical .tools .iter() .map(canonical_tool_to_responses) .collect::>(); if let Some(extra_tools) = canonical .extensions .get(OPENAI_RESPONSES_EXTENSION_NAMESPACE) .or_else(|| { canonical .extensions .get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE) }) .and_then(Value::as_object) .and_then(|value| value.get("tools")) .and_then(Value::as_array) { tools.extend(extra_tools.iter().cloned()); } tools } fn canonical_reasoning_config_to_responses(canonical: &CanonicalRequest) -> Option { let is_claude_request = is_claude_messages_request(&canonical.extensions); if !is_claude_request { return canonical .thinking .as_ref() .and_then(reasoning_config_to_responses); } let mut object = canonical .thinking .as_ref() .and_then(|thinking| openai_responses_extension(&thinking.extensions).cloned()) .and_then(|value| match value { Value::Object(object) => Some(object), _ => None, }) .unwrap_or_default(); let effort = canonical .thinking .as_ref() .and_then(|thinking| thinking.extensions.get("claude")) .and_then(|value| value.get("output_config")) .and_then(|value| value.get("effort")) .and_then(Value::as_str) .map(openai_responses_reasoning_effort) .unwrap_or("medium"); object .entry("effort".to_string()) .or_insert_with(|| Value::String(effort.to_string())); object .entry("summary".to_string()) .or_insert_with(|| Value::String("auto".to_string())); Some(Value::Object(object)) } fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option { openai_responses_extension(&thinking.extensions) .cloned() .or_else(|| { thinking .extensions .get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE) .cloned() }) .or_else(|| { thinking .extensions .get("openai") .and_then(|value| value.get("reasoning_effort")) .and_then(Value::as_str) .map(|effort| { json!({ "effort": openai_responses_reasoning_effort(effort), }) }) }) .or_else(|| { thinking.budget_tokens.map(|budget_tokens| { json!({ "effort": map_thinking_budget_to_openai_reasoning_effort(budget_tokens), }) }) }) } fn openai_responses_reasoning_effort(effort: &str) -> &str { match effort.trim().to_ascii_lowercase().as_str() { "xhigh" | "max" => "xhigh", "low" => "low", "medium" => "medium", "high" => "high", _ => effort, } } fn canonical_text_config_to_responses(canonical: &CanonicalRequest) -> Option { let mut text = Map::new(); if let Some(response_format) = &canonical.response_format { text.insert( "format".to_string(), canonical_response_format_to_openai(response_format), ); } if let Some(verbosity) = canonical .extensions .get(OPENAI_RESPONSES_EXTENSION_NAMESPACE) .or_else(|| { canonical .extensions .get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE) }) .and_then(Value::as_object) .and_then(|value| value.get("verbosity")) .cloned() { text.insert("verbosity".to_string(), verbosity); } if is_claude_messages_request(&canonical.extensions) { text.entry("verbosity".to_string()) .or_insert_with(|| Value::String("medium".to_string())); } (!text.is_empty()).then_some(Value::Object(text)) } fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value { if let Some(raw) = tool .extensions .get(OPENAI_RESPONSES_EXTENSION_NAMESPACE) .or_else(|| { tool.extensions .get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE) }) .filter(|value| { value .get("type") .and_then(Value::as_str) .is_some_and(|tool_type| { tool_type == "custom" || tool_type.starts_with("web_search") }) }) { return raw.clone(); } let mut out = Map::new(); out.insert("type".to_string(), Value::String("function".to_string())); out.insert("name".to_string(), Value::String(tool.name.clone())); if let Some(description) = &tool.description { out.insert( "description".to_string(), Value::String(description.clone()), ); } out.insert( "parameters".to_string(), responses_tool_parameters_schema(tool.parameters.as_ref()), ); out.extend(namespace_extension_object( &tool.extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE, &out, )); Value::Object(out) } fn responses_tool_parameters_schema(parameters: Option<&Value>) -> Value { match parameters { Some(Value::Object(schema)) => { let mut schema = schema.clone(); if schema .get("type") .and_then(Value::as_str) .is_some_and(|value| value == "object") && !schema.contains_key("properties") { schema.insert("properties".to_string(), json!({})); } Value::Object(schema) } Some(Value::Null) | None => json!({"type": "object", "properties": {}}), Some(value) => value.clone(), } } fn canonical_tool_choice_to_responses(choice: &CanonicalToolChoice) -> Value { match choice { CanonicalToolChoice::Auto => Value::String("auto".to_string()), CanonicalToolChoice::None => Value::String("none".to_string()), CanonicalToolChoice::Required => Value::String("required".to_string()), CanonicalToolChoice::Tool { name } => json!({ "type": "function", "name": name, }), } } fn responses_tool_result_payload( output: Option<&Value>, content_text: Option<&str>, extensions: &BTreeMap, ) -> (Value, Vec) { if is_claude_tool_result(extensions) { if let Some(Value::Array(parts)) = output { return claude_tool_result_parts_to_responses_payload(parts); } } ( responses_tool_result_output(output, content_text), Vec::new(), ) } fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&str>) -> Value { let text = match output { Some(Value::String(text)) => text.clone(), Some(Value::Null) => String::new(), Some(value) => serde_json::to_string(value).unwrap_or_default(), None => content_text.unwrap_or_default().to_string(), }; Value::String(non_empty_responses_tool_output(&text)) } fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> (Value, Vec) { let mut output_texts = Vec::new(); let mut extra_user_content = Vec::new(); for part in parts { let Some(part_object) = part.as_object() else { output_texts.push("[Claude tool_result non-text content omitted]".to_string()); continue; }; match part_object .get("type") .and_then(Value::as_str) .unwrap_or_default() { "text" => { if let Some(text) = part_object.get("text").and_then(Value::as_str) { if !text.is_empty() { output_texts.push(text.to_string()); } } } "image" => { if let Some(part) = claude_image_block_to_responses_input_part(part_object) { extra_user_content.push(part); } else { output_texts.push(claude_tool_result_media_summary("image", part_object)); } } "document" | "file" => { if let Some(part) = claude_document_block_to_responses_input_part(part_object) { extra_user_content.push(part); } else { output_texts.push(claude_tool_result_media_summary("document", part_object)); } } "" => output_texts.push("[Claude tool_result object content omitted]".to_string()), raw_type => { output_texts.push(format!("[Claude tool_result {raw_type} content omitted]")) } } } ( Value::String(non_empty_responses_tool_output(&output_texts.join("\n\n"))), extra_user_content, ) } fn claude_image_block_to_responses_input_part(block: &Map) -> Option { let source = block.get("source")?.as_object()?; match source .get("type") .and_then(Value::as_str) .unwrap_or_default() { "base64" => { let media_type = claude_source_media_type(source).unwrap_or("image/png"); let data = claude_source_str(source, "data")?; Some(json!({ "type": "input_image", "image_url": format!("data:{media_type};base64,{data}"), })) } "url" => { let url = claude_source_str(source, "url")?; Some(json!({ "type": "input_image", "image_url": url, })) } _ => None, } } fn claude_document_block_to_responses_input_part(block: &Map) -> Option { let source = block.get("source")?.as_object()?; let file_data = match source .get("type") .and_then(Value::as_str) .unwrap_or_default() { "base64" => { let media_type = claude_source_media_type(source).unwrap_or("application/octet-stream"); let data = claude_source_str(source, "data")?; format!("data:{media_type};base64,{data}") } "url" => claude_source_str(source, "url")?.to_string(), _ => return None, }; let mut part = Map::new(); part.insert("type".to_string(), Value::String("input_file".to_string())); part.insert("file_data".to_string(), Value::String(file_data)); if let Some(filename) = block .get("title") .or_else(|| block.get("name")) .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) { part.insert("filename".to_string(), Value::String(filename.to_string())); } Some(Value::Object(part)) } fn claude_tool_result_media_summary(kind: &str, block: &Map) -> String { let media_type = block .get("source") .and_then(Value::as_object) .and_then(claude_source_media_type); match media_type { Some(media_type) if !media_type.trim().is_empty() => { format!("[Claude tool_result {kind} content omitted: {media_type}]") } _ => format!("[Claude tool_result {kind} content omitted]"), } } fn claude_source_media_type(source: &Map) -> Option<&str> { source .get("media_type") .or_else(|| source.get("mime_type")) .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) } fn claude_source_str<'a>(source: &'a Map, key: &str) -> Option<&'a str> { source .get(key) .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) } fn non_empty_responses_tool_output(text: &str) -> String { if text.is_empty() { "(empty)".to_string() } else { text.to_string() } } fn insert_number(output: &mut Map, key: &str, value: Option) { if let Some(value) = value.and_then(serde_json::Number::from_f64) { output.insert(key.to_string(), Value::Number(value)); } } #[cfg(test)] mod tests { use super::to_raw; use crate::protocol::canonical::{ CanonicalContentBlock, CanonicalMessage, CanonicalRequest, CanonicalResponseFormat, CanonicalRole, }; use serde_json::json; #[test] fn json_object_response_injects_json_hint_into_input_when_only_instructions_have_it() { let request = CanonicalRequest { model: "gpt-5.5".to_string(), system: Some("Please answer in JSON.".to_string()), messages: vec![CanonicalMessage { role: CanonicalRole::User, content: vec![CanonicalContentBlock::Text { text: "hello".to_string(), extensions: Default::default(), }], extensions: Default::default(), }], response_format: Some(CanonicalResponseFormat { format_type: "json_object".to_string(), json_schema: None, extensions: Default::default(), }), ..CanonicalRequest::default() }; let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body"); assert_eq!(body["text"]["format"]["type"], json!("json_object")); assert_eq!(body["input"][0]["role"], json!("system")); assert!(body["input"][0]["content"][0]["text"] .as_str() .expect("hint text") .to_ascii_lowercase() .contains("json")); } #[test] fn responses_request_preserves_empty_chat_messages() { let request = CanonicalRequest { model: "gpt-5.5".to_string(), messages: vec![ CanonicalMessage { role: CanonicalRole::User, content: vec![CanonicalContentBlock::Text { text: String::new(), extensions: Default::default(), }], extensions: Default::default(), }, CanonicalMessage { role: CanonicalRole::Assistant, content: Vec::new(), extensions: Default::default(), }, ], ..CanonicalRequest::default() }; let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body"); assert_eq!(body["input"][0]["role"], "user"); assert_eq!(body["input"][0]["content"], ""); assert_eq!(body["input"][1]["role"], "assistant"); assert_eq!(body["input"][1]["content"][0]["type"], "output_text"); assert_eq!(body["input"][1]["content"][0]["text"], ""); } #[test] fn responses_request_uses_empty_marker_for_empty_tool_output() { let request = CanonicalRequest { model: "gpt-5.5".to_string(), messages: vec![CanonicalMessage { role: CanonicalRole::Tool, content: vec![CanonicalContentBlock::ToolResult { tool_use_id: "call_empty".to_string(), name: None, output: Some(json!("")), content_text: None, is_error: false, extensions: Default::default(), }], extensions: Default::default(), }], ..CanonicalRequest::default() }; let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body"); assert_eq!(body["input"].as_array().expect("input").len(), 1); assert_eq!(body["input"][0]["type"], "function_call_output"); assert_eq!(body["input"][0]["call_id"], "call_empty"); assert_eq!(body["input"][0]["output"], "(empty)"); } }