//! 跨 Provider 类型转换 —— `Message` ↔ OpenAI `OpenaiChatMessage`。 //! //! Phase 1 引入:避免转换逻辑在 `LlmCycle`、各 Provider 中重复。 //! 这些函数期望作为**纯函数**被调用 —— 无内部状态,方便跨 Provider 复用。 //! //! 转换范围:仅处理 `OpenaiChatMessage` ↔ `Message`、`ContentField` ↔ `Vec`。 //! 流式 chunk 转换见各 Provider 内部(OpenAI Chat / Anthropic)。 use serde_json::Value; use crate::llm::types::message::{ContentBlock, Message}; use crate::llm::types::openai_message::{ ContentField, OpenaiChatMessage, OpenaiContentPart, }; use crate::llm::types::OpenaiToolCall; /// `OpenaiChatMessage` → IR `Message`。 /// /// 转换规则: /// - `Developer` / `System` → `Message::System` /// - `User` → `Message::User`(含图片/音频等多模态 part → `ContentBlock`) /// - `Assistant` → `Message::Assistant`(`tool_calls` 转为 `ContentBlock::ToolUse`) /// - `Tool` → `Message::ToolResult`(`is_error` 暂为 `false`,OpenAI 不携带此标记) /// - `Function`(已废弃)→ `Message::ToolResult`(`name` 作为 `tool_call_id` 兜底) pub fn from_openai(msg: &OpenaiChatMessage) -> Message { match msg { OpenaiChatMessage::Developer { content, .. } | OpenaiChatMessage::System { content, .. } => { Message::System { content: content_to_blocks(content), } } OpenaiChatMessage::User { content, .. } => Message::User { content: content_to_blocks(content), }, OpenaiChatMessage::Assistant { content, tool_calls, .. } => { let mut blocks = content_to_blocks(content); if let Some(calls) = tool_calls { for call in calls { let OpenaiToolCall::Function { id, function } = call; let input: Value = serde_json::from_str(&function.arguments).unwrap_or(Value::Null); blocks.push(ContentBlock::ToolUse { id: id.clone(), name: function.name.clone(), input, }); } } Message::Assistant { content: blocks } } OpenaiChatMessage::Tool { content, tool_call_id, } => Message::ToolResult { tool_call_id: tool_call_id.clone(), content: content_to_blocks(content), is_error: false, }, // ponytail: `function` 是 OpenAI 旧版 `function_call` API 残留变体; // 当前主流为 `tool_calls`,此处仅保留兼容路径。 OpenaiChatMessage::Function { content, name } => Message::ToolResult { tool_call_id: name.clone(), content: content_to_blocks(content), is_error: false, }, } } /// IR `Message` → `OpenaiChatMessage`。 /// /// ponytail 简化:当前 `Assistant.content` 仅序列化 Text → `content: String`, /// `ToolUse` 拆出到 `tool_calls` 字段;thinking / image / audio / file / extension /// 视为非 OpenAI 原生,**回传时丢弃**。Phase 2 切换为 `Vec` 后该丢弃 /// 自然消失(不再需要回传旧 wire 格式)。 pub fn to_openai(msg: &Message) -> OpenaiChatMessage { match msg { Message::System { content } => OpenaiChatMessage::System { content: blocks_to_content(content), name: None, }, Message::User { content } => OpenaiChatMessage::User { content: blocks_to_content(content), name: None, }, Message::UserImage { data, mime_type, detail } => { // ponytail: 构造为单 image part 的 User 消息(OpenAI 多模态格式)。 let mime = mime_type.clone(); let is_url = data.starts_with("http://") || data.starts_with("https://"); let image_url = if is_url { crate::llm::types::openai_message::ImageURL { url: data.clone(), detail: Some(*detail), } } else { crate::llm::types::openai_message::ImageURL { url: format!("data:{mime};base64,{data}"), detail: Some(*detail), } }; OpenaiChatMessage::User { content: ContentField::Array(vec![OpenaiContentPart::Image { image_url, detail: Some(*detail), }]), name: None, } } Message::Assistant { content } => { let mut text_blocks: Vec = Vec::new(); let mut tool_call_blocks: Vec = Vec::new(); for block in content { match block { ContentBlock::Text { text } => text_blocks.push(text.clone()), ContentBlock::ToolUse { id, name, input } => { tool_call_blocks.push(OpenaiToolCall::Function { id: id.clone(), function: crate::llm::types::tool::FunctionCall { name: name.clone(), arguments: serde_json::to_string(input) .unwrap_or_else(|_| "null".to_string()), }, }); } // ponytail: thinking/refusal/image/audio/file/extension 在回传时被丢弃。 // Phase 2 切换后此函数整体移除,自然修复。 _ => {} } } let text: String = text_blocks.into_iter().collect(); OpenaiChatMessage::Assistant { content: if text.is_empty() { ContentField::Array(vec![]) } else { ContentField::String(text) }, refusal: None, name: None, tool_calls: if tool_call_blocks.is_empty() { None } else { Some(tool_call_blocks) }, } } Message::ToolResult { tool_call_id, content, is_error: _, } => OpenaiChatMessage::Tool { content: blocks_to_content(content), tool_call_id: tool_call_id.clone(), }, } } /// `ContentField` → `Vec`。 /// /// 仅产出 OpenAI 有 wire 对应的 `Text` / `Image` / `Audio` / /// `Refusal`(合并为 `Text`)block;`File` 被截断。 pub fn content_to_blocks(field: &ContentField) -> Vec { match field { ContentField::String(s) => vec![ContentBlock::Text { text: s.clone() }], ContentField::Array(parts) => parts .iter() .filter_map(|p| match p { OpenaiContentPart::Text { text } => { Some(ContentBlock::Text { text: text.clone() }) } OpenaiContentPart::Refusal { refusal } => { Some(ContentBlock::Text { text: refusal.clone() }) } OpenaiContentPart::Image { image_url, .. } => { // ponytail: 简化处理 —— URL 直接通过,data URI 拆出 // data:;base64, → ImageSource { data: b64, mime, is_url: false }。 let url = &image_url.url; if let Some(rest) = url.strip_prefix("data:") && let Some((mime, b64)) = rest.split_once(";base64,") { return Some(ContentBlock::Image { source: crate::llm::types::message::ImageSource { data: b64.to_string(), mime_type: mime.to_string(), is_url: false, }, }); } Some(ContentBlock::Image { source: crate::llm::types::message::ImageSource { data: url.clone(), mime_type: "image/url".to_string(), is_url: true, }, }) } OpenaiContentPart::InputAudio { input_audio } => Some(ContentBlock::Audio { source: crate::llm::types::message::AudioSource { data: input_audio.data.clone(), format: input_audio.format, }, }), // File 暂不映射(OpenAI File API 与 IR 不对齐) OpenaiContentPart::File { .. } => None, }) .collect(), } } /// `Vec` → `ContentField`。 /// /// 单文本块 → `ContentField::String`;多块或非文本主导 → `ContentField::Array`。 pub fn blocks_to_content(blocks: &[ContentBlock]) -> ContentField { if let [ContentBlock::Text { text }] = blocks { return ContentField::String(text.clone()); } let parts: Vec = blocks .iter() .filter_map(|b| match b { ContentBlock::Text { text } => Some(OpenaiContentPart::Text { text: text.clone() }), ContentBlock::Image { source } => Some(OpenaiContentPart::Image { image_url: if source.is_url { crate::llm::types::openai_message::ImageURL { url: source.data.clone(), detail: None, } } else { crate::llm::types::openai_message::ImageURL { url: format!("data:{};base64,{}", source.mime_type, source.data), detail: None, } }, detail: None, }), ContentBlock::Audio { source } => Some(OpenaiContentPart::InputAudio { input_audio: crate::llm::types::openai_message::InputAudio { data: source.data.clone(), format: source.format, }, }), // ponytail: 其他 block 类型在 OpenAI wire 上无对应,回传时被截断。 _ => None, }) .collect(); if parts.is_empty() { ContentField::Array(vec![]) } else { ContentField::Array(parts) } } #[cfg(test)] mod tests { use super::*; use crate::llm::types::shared::ImageDetail; use serde_json::json; #[test] fn from_openai_system_maps_to_message_system() { let m = OpenaiChatMessage::system_text("you are helpful"); let ir = from_openai(&m); match ir { Message::System { content } => { assert_eq!(content.len(), 1); assert!(matches!(&content[0], ContentBlock::Text { text } if text == "you are helpful")); } _ => panic!("expected System variant"), } } #[test] fn from_openai_assistant_tool_call_to_tool_use_block() { let m = OpenaiChatMessage::Assistant { content: ContentField::String("ok".into()), refusal: None, name: None, tool_calls: Some(vec![OpenaiToolCall::Function { id: "call_1".into(), function: crate::llm::types::tool::FunctionCall { name: "search".into(), arguments: r#"{"q":"rust"}"#.into(), }, }]), }; let ir = from_openai(&m); match ir { Message::Assistant { content } => { assert_eq!(content.len(), 2); match &content[1] { ContentBlock::ToolUse { id, name, input } => { assert_eq!(id, "call_1"); assert_eq!(name, "search"); assert_eq!(input, &json!({"q": "rust"})); } _ => panic!("expected ToolUse"), } } _ => panic!("expected Assistant"), } } #[test] fn from_openai_tool_to_tool_result() { let m = OpenaiChatMessage::Tool { content: ContentField::String("ok".into()), tool_call_id: "call_1".into(), }; let ir = from_openai(&m); match ir { Message::ToolResult { tool_call_id, content, is_error, } => { assert_eq!(tool_call_id, "call_1"); assert!(!is_error); assert_eq!(content.len(), 1); } _ => panic!("expected ToolResult"), } } #[test] fn to_openai_assistant_text_singular_string_content() { let m = Message::Assistant { content: vec![ContentBlock::Text { text: "hi".into() }], }; let wire = to_openai(&m); match wire { OpenaiChatMessage::Assistant { content, tool_calls, .. } => { assert!(matches!(content, ContentField::String(s) if s == "hi")); assert!(tool_calls.is_none()); } _ => panic!("expected Assistant"), } } #[test] fn to_openai_assistant_with_tool_use_produces_tool_calls() { let m = Message::Assistant { content: vec![ ContentBlock::Text { text: "ok".into() }, ContentBlock::ToolUse { id: "call_1".into(), name: "search".into(), input: json!({"q": "rust"}), }, ], }; let wire = to_openai(&m); match wire { OpenaiChatMessage::Assistant { content, tool_calls, .. } => { assert!(matches!(content, ContentField::String(_))); let calls = tool_calls.expect("tool_calls"); assert_eq!(calls.len(), 1); let OpenaiToolCall::Function { id, function } = &calls[0]; assert_eq!(id, "call_1"); assert_eq!(function.name, "search"); assert!(function.arguments.contains("rust")); } _ => panic!("expected Assistant"), } } #[test] fn to_openai_user_image_builds_data_uri() { let m = Message::UserImage { data: "BASE64DATA".into(), mime_type: "image/png".into(), detail: ImageDetail::High, }; let wire = to_openai(&m); match wire { OpenaiChatMessage::User { content, .. } => match content { ContentField::Array(parts) => { assert_eq!(parts.len(), 1); match &parts[0] { OpenaiContentPart::Image { image_url, .. } => { assert_eq!( image_url.url, "data:image/png;base64,BASE64DATA" ); } _ => panic!("expected Image part"), } } _ => panic!("expected Array content"), }, _ => panic!("expected User"), } } #[test] fn blocks_to_content_singular_text_uses_string() { let blocks = vec![ContentBlock::Text { text: "hi".into() }]; let f = blocks_to_content(&blocks); assert!(matches!(f, ContentField::String(s) if s == "hi")); } #[test] fn blocks_to_content_multiple_blocks_uses_array() { let blocks = vec![ ContentBlock::Text { text: "a".into() }, ContentBlock::Text { text: "b".into() }, ]; let f = blocks_to_content(&blocks); assert!(matches!(f, ContentField::Array(parts) if parts.len() == 2)); } #[test] fn content_to_blocks_refusal_merges_to_text() { let field = ContentField::Array(vec![OpenaiContentPart::Refusal { refusal: "policy violation".into(), }]); let blocks = content_to_blocks(&field); assert_eq!(blocks.len(), 1); assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "policy violation")); } #[test] fn roundtrip_image_via_data_uri() { let field = ContentField::Array(vec![OpenaiContentPart::Image { image_url: crate::llm::types::openai_message::ImageURL { url: "data:image/png;base64,AAA".into(), detail: Some(ImageDetail::Auto), }, detail: None, }]); let blocks = content_to_blocks(&field); assert_eq!(blocks.len(), 1); match &blocks[0] { ContentBlock::Image { source } => { assert_eq!(source.data, "AAA"); assert_eq!(source.mime_type, "image/png"); assert!(!source.is_url); } _ => panic!("expected Image"), } } }