Files
agcore/src/llm/convert.rs
T
徐涛 c084c57e2c refactor(core): 扫清 v0.1 Phase A 技术债
- 修复测试编译回归:补全 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 抑制、测试代码清理
2026-07-03 15:19:37 +08:00

450 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 跨 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"),
}
}
}