c084c57e2c
- 修复测试编译回归:补全 session.rs/cycle.rs 测试模块导入; convert.rs 2 处 irrefutable if let 改为 let - composer.rs 迁移至 IR:OpenaiChatMessage → Message, ContentField/OpenaiContentPart → ContentBlock;删除 set_message_name 和 build_request;developer 消息映射为 Message::System - knowledge.rs 锁修复:std::sync::Mutex → tokio::sync::Mutex; search() 优化锁粒度(锁内仅 clone IDs,避免锁内异步 IO) - 标记 ChatResponse / ToolDefinition 为废弃(#[deprecated(since = "0.1.0")]), 内部使用点加 #[allow(deprecated)] 抑制警告 - clippy 清零:合并冗余 if、手动 strip_prefix 改 strip_prefix、 多处 dead_code 抑制、测试代码清理
450 lines
17 KiB
Rust
450 lines
17 KiB
Rust
//! 跨 Provider 类型转换 —— `Message` ↔ OpenAI `OpenaiChatMessage`。
|
||
//!
|
||
//! Phase 1 引入:避免转换逻辑在 `LlmCycle`、各 Provider 中重复。
|
||
//! 这些函数期望作为**纯函数**被调用 —— 无内部状态,方便跨 Provider 复用。
|
||
//!
|
||
//! 转换范围:仅处理 `OpenaiChatMessage` ↔ `Message`、`ContentField` ↔ `Vec<ContentBlock>`。
|
||
//! 流式 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<Message>` 后该丢弃
|
||
/// 自然消失(不再需要回传旧 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<String> = Vec::new();
|
||
let mut tool_call_blocks: Vec<OpenaiToolCall> = 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<ContentBlock>`。
|
||
///
|
||
/// 仅产出 OpenAI 有 wire 对应的 `Text` / `Image` / `Audio` /
|
||
/// `Refusal`(合并为 `Text`)block;`File` 被截断。
|
||
pub fn content_to_blocks(field: &ContentField) -> Vec<ContentBlock> {
|
||
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:<mime>;base64,<b64> → 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<ContentBlock>` → `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<OpenaiContentPart> = 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"),
|
||
}
|
||
}
|
||
}
|