feat(llm): 新增 IR 类型层 + 切换 LlmProvider trait 签名
引入跨 Provider 统一的新类型系统: - Message 扁平大枚举(System/User/UserImage/Assistant/ToolResult) - MessageRequest + MessageResponse + StreamEvent(高精度 IR) - PartialMessageResponse 含 apply_to/finalize 汇聚算法 - ProviderType enum 扩展 + ProviderCapabilities 切换 LlmProvider trait 签名为 chat(MessageRequest) → MessageResponse, chat_stream 返回 Stream<Item = Result<StreamEvent, LlmError>>,新增 capabilities()。 StreamEvent 命名冲突处理:旧变体迁移至 old_stream::LegacyStreamEvent, stream.rs 通过 pub use 重新导出新高精度事件。 OpenaiProvider 添加 Phase 0 临时桥接(MessageRequest ↔ OpenaiChatRequest 转换), 标注 ponytail: Phase 0 临时桥接 标记,Phase 1 重写时移除。 测试 147 passed / 0 failed(新增 31 个新类型测试)。
This commit is contained in:
+23
-12
@@ -4,23 +4,34 @@ use agcore::init_tracing;
|
||||
use agcore::llm::{
|
||||
cycle::{CycleConfig, LlmCycle},
|
||||
provider::{create_provider, ProviderConfig, ProviderType},
|
||||
types::{ChatResponse, OpenaiContentPart},
|
||||
types::{
|
||||
message::ContentBlock, response_v2::MessageResponse,
|
||||
},
|
||||
};
|
||||
|
||||
fn extract_response_text(response: &ChatResponse) -> &str {
|
||||
fn extract_response_text(response: &MessageResponse) -> &str {
|
||||
// Phase 0:MessageResponse.text() 直接给出拼接好的 Assistant 文本。
|
||||
if !response.text().is_empty() {
|
||||
// ⚠️ 返回的是 owned String 的引用,调用方需在 response 生命周期内使用。
|
||||
// 此 example 短命,足以演示。
|
||||
return response_text_ref(response);
|
||||
}
|
||||
"[无文本内容]"
|
||||
}
|
||||
|
||||
fn response_text_ref(response: &MessageResponse) -> &str {
|
||||
// ponytail: example 助手 —— 不在正式 crate API 中,单纯绕开 borrow 限制。
|
||||
// 真实调用请直接使用 `response.text()` 拿到 owned String。
|
||||
match &response.message {
|
||||
agcore::llm::types::OpenaiChatMessage::Assistant { content, .. } => match content {
|
||||
agcore::llm::types::ContentField::String(s) => s,
|
||||
agcore::llm::types::ContentField::Array(parts) => {
|
||||
for part in parts {
|
||||
if let OpenaiContentPart::Text { text } = part {
|
||||
return text;
|
||||
}
|
||||
agcore::llm::types::message::Message::Assistant { content } => {
|
||||
for block in content {
|
||||
if let ContentBlock::Text { text } = block {
|
||||
return text.as_str();
|
||||
}
|
||||
"[无文本内容]"
|
||||
}
|
||||
},
|
||||
_ => "[非 assistant 消息]",
|
||||
""
|
||||
}
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-3
@@ -118,17 +118,34 @@ impl AgentBuilder {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::types::{ChatRequest, ChatResponse};
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
use std::pin::Pin;
|
||||
|
||||
struct StubProvider;
|
||||
#[async_trait]
|
||||
impl LlmProvider for StubProvider {
|
||||
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
|
||||
async fn chat(&self, _request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
_request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
unimplemented!()
|
||||
}
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
provider_name: "stub",
|
||||
supported_models: None,
|
||||
features: ProviderFeatures::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+40
-27
@@ -15,7 +15,9 @@ use crate::agent::runtime::RuntimeBundle;
|
||||
use crate::agent::session_memory::SessionMemory;
|
||||
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
|
||||
use crate::llm::hooks::{HookContext, HookEvent};
|
||||
use crate::llm::types::ChatResponse;
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
use crate::llm::types::openai_message::OpenaiChatMessage;
|
||||
use crate::llm::types::response_v2::MessageResponse;
|
||||
use crate::memory::store::InMemoryStore;
|
||||
|
||||
/// Agent 会话实例。
|
||||
@@ -120,7 +122,7 @@ impl AgentSession {
|
||||
pub async fn submit_turn(
|
||||
&mut self,
|
||||
user_input: impl Into<String>,
|
||||
) -> Result<ChatResponse, AgentError> {
|
||||
) -> Result<MessageResponse, AgentError> {
|
||||
let turn_index = self.turn_index;
|
||||
let hook_executor = Arc::clone(&self.bundle.hook_executor);
|
||||
|
||||
@@ -168,13 +170,15 @@ impl AgentSession {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::builder::AgentBuilder;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult};
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::types::{
|
||||
ChatRequest, ChatResponse, FinishReason, OpenaiChatMessage,
|
||||
};
|
||||
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent};
|
||||
use crate::tools::ToolRegistry;
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// 计数 hook —— 每被调用一次 +1。
|
||||
@@ -200,11 +204,11 @@ mod tests {
|
||||
|
||||
/// MockProvider:按调用顺序返回预设响应。
|
||||
struct MockProvider {
|
||||
responses: std::sync::Mutex<Vec<ChatResponse>>,
|
||||
responses: std::sync::Mutex<Vec<MessageResponse>>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
fn new(responses: Vec<ChatResponse>) -> Self {
|
||||
fn new(responses: Vec<MessageResponse>) -> Self {
|
||||
Self {
|
||||
responses: std::sync::Mutex::new(responses),
|
||||
}
|
||||
@@ -213,15 +217,27 @@ mod tests {
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for MockProvider {
|
||||
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, crate::llm::error::LlmError> {
|
||||
async fn chat(&self, _request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
let mut responses = self.responses.lock().unwrap();
|
||||
if responses.is_empty() {
|
||||
return Err(crate::llm::error::LlmError::Other(
|
||||
"no more mock responses".into(),
|
||||
));
|
||||
return Err(LlmError::Other("no more mock responses".into()));
|
||||
}
|
||||
Ok(responses.remove(0))
|
||||
}
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
_request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
unimplemented!()
|
||||
}
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
provider_name: "mock",
|
||||
supported_models: None,
|
||||
features: ProviderFeatures::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StubAgent {
|
||||
@@ -238,11 +254,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_text(text: &str) -> ChatResponse {
|
||||
ChatResponse {
|
||||
message: OpenaiChatMessage::assistant_text(text),
|
||||
fn assistant_text(text: &str) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.into(),
|
||||
}],
|
||||
},
|
||||
usage: crate::llm::types::Usage::from_input_output(10, 5),
|
||||
stop_reason: Some(FinishReason::Stop),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,17 +290,7 @@ mod tests {
|
||||
assert_eq!(session.turn_index(), 0);
|
||||
|
||||
let response = session.submit_turn("hi").await.unwrap();
|
||||
let text = match &response.message {
|
||||
OpenaiChatMessage::Assistant { content, .. } => {
|
||||
if let crate::llm::types::ContentField::String(s) = content {
|
||||
s.clone()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
assert_eq!(text, "hello back");
|
||||
assert_eq!(response.text(), "hello back");
|
||||
assert_eq!(session.turn_index(), 1);
|
||||
assert_eq!(session.usage().total().prompt_tokens, 10);
|
||||
assert_eq!(session.usage().total().completion_tokens, 5);
|
||||
|
||||
+283
-177
@@ -16,11 +16,16 @@ use crate::llm::compact::{should_compact, microcompact, CompactConfig, CompactSt
|
||||
use crate::llm::cycle::retry::should_retry;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::hooks::{HookContext, HookExecutor};
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use crate::llm::stream::StreamEvent;
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
use crate::llm::types::openai_message::{
|
||||
ContentField, OpenaiChatMessage, OpenaiContentPart,
|
||||
};
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use crate::llm::types::{
|
||||
ChatRequest, ChatResponse, FinishReason, OpenaiChatMessage, OpenaiTool, OpenaiToolCall,
|
||||
ToolChoice, ToolDefinition,
|
||||
FinishReason, FunctionCall, OpenaiToolCall, ToolChoice, ToolDefinition,
|
||||
};
|
||||
|
||||
/// LLM 调用周期配置。
|
||||
@@ -155,27 +160,18 @@ impl LlmCycle {
|
||||
&mut self,
|
||||
messages: Vec<OpenaiChatMessage>,
|
||||
tools: Vec<ToolDefinition>,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
let openai_tools: Option<Vec<OpenaiTool>> = if tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
tools
|
||||
.iter()
|
||||
.map(|t| OpenaiTool::Function {
|
||||
function: t.clone(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
) -> Result<MessageResponse, LlmError> {
|
||||
let ir_messages: Vec<Message> =
|
||||
messages.iter().map(chat_message_to_ir_message).collect();
|
||||
let ir_tools: Vec<ToolDefinition> = tools.clone();
|
||||
|
||||
let request = ChatRequest {
|
||||
let request = MessageRequest {
|
||||
model: self.config.model.clone(),
|
||||
messages,
|
||||
messages: ir_messages,
|
||||
tools: ir_tools,
|
||||
tool_choice: ToolChoice::Auto,
|
||||
max_tokens: self.config.max_tokens,
|
||||
temperature: self.config.temperature,
|
||||
tools: openai_tools,
|
||||
tool_choice: Some(ToolChoice::Auto),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -198,11 +194,7 @@ impl LlmCycle {
|
||||
match self.provider.chat(request).await {
|
||||
Ok(response) => {
|
||||
if let Some(ref executor) = self.hook_executor {
|
||||
let post_request = ChatRequest {
|
||||
model: self.config.model.clone(),
|
||||
messages: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let post_request = MessageRequest::default();
|
||||
let ctx = HookContext::new(crate::llm::hooks::HookEvent::PostRequest)
|
||||
.with_request(&post_request);
|
||||
executor
|
||||
@@ -229,7 +221,7 @@ impl LlmCycle {
|
||||
&mut self,
|
||||
prompt: String,
|
||||
tools: Vec<ToolDefinition>,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
) -> Result<MessageResponse, LlmError> {
|
||||
self.messages.push(OpenaiChatMessage::user_text(prompt));
|
||||
|
||||
if let Some(ref config) = self.compact_config
|
||||
@@ -273,7 +265,9 @@ impl LlmCycle {
|
||||
.await;
|
||||
}
|
||||
|
||||
self.messages.push(response.message.clone());
|
||||
// ponytail: Phase 0 中内部消息存储仍为 Vec<OpenaiChatMessage>,
|
||||
// 把 IR Message 转回旧 wire 格式以便存储。Phase 2 切换后移除。
|
||||
self.messages.push(message_to_chat_message(&response.message));
|
||||
self.usage.add(&response.usage);
|
||||
|
||||
return Ok(response);
|
||||
@@ -346,59 +340,28 @@ impl LlmCycle {
|
||||
}
|
||||
}
|
||||
|
||||
let chunk_stream = self.provider.chat_stream(request).await?;
|
||||
let ir_event_stream = self.provider.chat_stream(request).await?;
|
||||
let hook_executor = self.hook_executor.clone();
|
||||
let post_request = self.build_request(&tools);
|
||||
|
||||
// ponytail: Phase 0 中 Provider 临时桥接层把 IR 事件再回填成本 phase 的
|
||||
// 信息冗余(OpenaiProvider 的 chat_stream 现在已输出 IR 事件)。当前实现
|
||||
// 直接转发 IR 事件,仅在 `MessageComplete` 到达时收尾。
|
||||
Ok(Box::pin(stream! {
|
||||
use futures_util::StreamExt;
|
||||
let mut chunk_stream = chunk_stream;
|
||||
let mut ir_event_stream = ir_event_stream;
|
||||
|
||||
while let Some(result) = chunk_stream.next().await {
|
||||
while let Some(result) = ir_event_stream.next().await {
|
||||
match result {
|
||||
Ok(chunk) => {
|
||||
let mut assistant_text = String::new();
|
||||
let mut tool_started: Option<(String, String, String)> = None;
|
||||
|
||||
for choice in &chunk.choices {
|
||||
let delta = &choice.delta;
|
||||
|
||||
if let Some(content) = &delta.content {
|
||||
assistant_text.push_str(content);
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = &delta.tool_calls
|
||||
&& let Some(tc) = tool_calls.first()
|
||||
{
|
||||
let crate::llm::types::OpenaiToolCall::Function { id, function } = tc;
|
||||
tool_started = Some((id.clone(), function.name.clone(), function.arguments.clone()));
|
||||
}
|
||||
Ok(event) => {
|
||||
let is_terminal = matches!(event, StreamEvent::MessageComplete { .. });
|
||||
let is_error = matches!(event, StreamEvent::Error { .. });
|
||||
yield event;
|
||||
if is_terminal {
|
||||
break;
|
||||
}
|
||||
|
||||
if !assistant_text.is_empty() {
|
||||
yield StreamEvent::AssistantTextDelta { text: assistant_text };
|
||||
}
|
||||
|
||||
if let Some((tool_call_id, tool_name, arguments)) = tool_started {
|
||||
let args: serde_json::Value = serde_json::from_str(&arguments)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
yield StreamEvent::ToolExecutionStarted {
|
||||
tool_name,
|
||||
input: args,
|
||||
tool_call_id,
|
||||
};
|
||||
}
|
||||
|
||||
for choice in &chunk.choices {
|
||||
if let Some(finish_reason) = &choice.finish_reason {
|
||||
yield StreamEvent::TurnComplete {
|
||||
reason: *finish_reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(usage_info) = &chunk.usage {
|
||||
yield StreamEvent::CostUpdate { usage: *usage_info };
|
||||
if is_error {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -429,37 +392,30 @@ impl LlmCycle {
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_request(&self, tools: &[ToolDefinition]) -> ChatRequest {
|
||||
let mut messages = self.messages.clone();
|
||||
fn build_request(&self, tools: &[ToolDefinition]) -> MessageRequest {
|
||||
let messages = self.messages.clone();
|
||||
|
||||
let ir_messages: Vec<Message> = messages
|
||||
.iter()
|
||||
.map(chat_message_to_ir_message)
|
||||
.collect();
|
||||
|
||||
let mut ir_messages = ir_messages;
|
||||
if let Some(sys_prompt) = &self.system_prompt
|
||||
&& !messages
|
||||
&& !ir_messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, OpenaiChatMessage::System { .. }))
|
||||
.any(|m| matches!(m, Message::System { .. }))
|
||||
{
|
||||
messages.insert(0, OpenaiChatMessage::system_text(sys_prompt));
|
||||
ir_messages.insert(0, Message::system(sys_prompt.as_str()));
|
||||
}
|
||||
|
||||
let openai_tools: Option<Vec<OpenaiTool>> = if tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
tools
|
||||
.iter()
|
||||
.map(|t| OpenaiTool::Function {
|
||||
function: t.clone(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
|
||||
ChatRequest {
|
||||
MessageRequest {
|
||||
model: self.config.model.clone(),
|
||||
messages,
|
||||
messages: ir_messages,
|
||||
tools: tools.to_vec(),
|
||||
tool_choice: ToolChoice::Auto,
|
||||
max_tokens: self.config.max_tokens,
|
||||
temperature: self.config.temperature,
|
||||
tools: openai_tools,
|
||||
tool_choice: Some(ToolChoice::Auto),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -470,7 +426,7 @@ impl LlmCycle {
|
||||
async fn submit_request(
|
||||
&mut self,
|
||||
tools: &[ToolDefinition],
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
) -> Result<MessageResponse, LlmError> {
|
||||
let mut attempts = 0;
|
||||
|
||||
loop {
|
||||
@@ -548,7 +504,7 @@ impl LlmCycle {
|
||||
&mut self,
|
||||
prompt: String,
|
||||
registry: &crate::tools::ToolRegistry,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
) -> Result<MessageResponse, LlmError> {
|
||||
let tools = registry.definitions();
|
||||
let max_turns = self.config.max_tool_turns.unwrap_or(10);
|
||||
let tool_timeout = self.config.tool_timeout_secs;
|
||||
@@ -570,24 +526,25 @@ impl LlmCycle {
|
||||
let response = self.submit_request(&tools).await?;
|
||||
|
||||
// 判断是否需要执行工具
|
||||
let should_execute = matches!(response.stop_reason, Some(FinishReason::ToolCalls))
|
||||
&& has_tool_calls_in_message(&response.message);
|
||||
let should_execute = matches!(response.stop_reason, StopReason::ToolUse)
|
||||
&& has_tool_calls_in_response(&response);
|
||||
|
||||
// 将 Assistant 响应(含 tool_calls 或最终文本)追加到消息历史
|
||||
self.messages.push(response.message.clone());
|
||||
// ponytail: Phase 0 中内部消息存储仍为 Vec<OpenaiChatMessage>,
|
||||
// 把 IR Message 转回旧 wire 格式以便存储。Phase 2 切换后移除。
|
||||
self.messages.push(message_to_chat_message(&response.message));
|
||||
|
||||
if !should_execute {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// 解析 tool_calls 并执行
|
||||
let tool_calls = extract_tool_calls_from_message(&response.message);
|
||||
let tool_calls = extract_tool_calls_from_response(&response);
|
||||
let calls: Vec<(String, serde_json::Value)> = tool_calls
|
||||
.into_iter()
|
||||
.map(|(_id, name, args)| {
|
||||
let args: serde_json::Value =
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&args).unwrap_or(serde_json::Value::Null);
|
||||
(name, args)
|
||||
(name, value)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -642,37 +599,185 @@ impl LlmCycle {
|
||||
}
|
||||
|
||||
/// 判断 Assistant 消息是否包含 tool_calls。
|
||||
fn has_tool_calls_in_message(msg: &OpenaiChatMessage) -> bool {
|
||||
matches!(
|
||||
msg,
|
||||
OpenaiChatMessage::Assistant {
|
||||
tool_calls: Some(calls),
|
||||
..
|
||||
} if !calls.is_empty()
|
||||
)
|
||||
fn has_tool_calls_in_response(response: &MessageResponse) -> bool {
|
||||
match &response.message {
|
||||
Message::Assistant { content } => content
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::ToolUse { .. })),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取 Assistant 消息中的 tool_calls。
|
||||
///
|
||||
/// 返回 `(tool_call_id, tool_name, arguments_json_string)` 列表。
|
||||
fn extract_tool_calls_from_message(
|
||||
msg: &OpenaiChatMessage,
|
||||
///
|
||||
/// ponytail: 当前 Phase 0 实现,`arguments_json_string` 内含 JSON 序列化的 input。
|
||||
/// 消费方在调用 `registry.invoke_all()` 时反序列化一次。该小段冗余序列化
|
||||
/// 在 Phase 2 切换为 `Vec<Message>` 后可整体消除。
|
||||
fn extract_tool_calls_from_response(
|
||||
response: &MessageResponse,
|
||||
) -> Vec<(String, String, String)> {
|
||||
if let OpenaiChatMessage::Assistant {
|
||||
tool_calls: Some(calls),
|
||||
..
|
||||
} = msg
|
||||
{
|
||||
calls
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
OpenaiToolCall::Function { id, function } => {
|
||||
(id.clone(), function.name.clone(), function.arguments.clone())
|
||||
let mut out = Vec::new();
|
||||
if let Message::Assistant { content } = &response.message {
|
||||
for block in content {
|
||||
if let ContentBlock::ToolUse { id, name, input } = block {
|
||||
let args = serde_json::to_string(input).unwrap_or_else(|_| "null".to_string());
|
||||
out.push((id.clone(), name.clone(), args));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 把 `OpenaiChatMessage` 转换为 IR `Message`。
|
||||
///
|
||||
/// ponytail: Phase 0 转换层 —— build_request 中需要把存储用的
|
||||
/// `Vec<OpenaiChatMessage>` 转为 `MessageRequest.messages`。Phase 2 中
|
||||
/// `self.messages` 直接改为 `Vec<Message>` 时此函数整体删除。
|
||||
fn chat_message_to_ir_message(msg: &OpenaiChatMessage) -> Message {
|
||||
match msg {
|
||||
OpenaiChatMessage::Developer { content, .. }
|
||||
| OpenaiChatMessage::System { content, .. } => Message::System {
|
||||
content: content_field_to_blocks(content),
|
||||
},
|
||||
OpenaiChatMessage::User { content, .. } => Message::User {
|
||||
content: content_field_to_blocks(content),
|
||||
},
|
||||
OpenaiChatMessage::Assistant {
|
||||
content,
|
||||
tool_calls,
|
||||
..
|
||||
} => {
|
||||
let mut blocks = content_field_to_blocks(content);
|
||||
if let Some(calls) = tool_calls {
|
||||
for call in calls {
|
||||
if let OpenaiToolCall::Function { id, function } = call {
|
||||
let input: serde_json::Value = serde_json::from_str(&function.arguments)
|
||||
.unwrap_or(serde_json::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_field_to_blocks(content),
|
||||
is_error: false,
|
||||
},
|
||||
OpenaiChatMessage::Function { content, name } => Message::ToolResult {
|
||||
tool_call_id: name.clone(),
|
||||
content: content_field_to_blocks(content),
|
||||
is_error: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 IR `Message` 转换为 `OpenaiChatMessage`(用于 `self.messages` 持久化)。
|
||||
///
|
||||
/// ponytail: Phase 0 转换层 —— submit 后 `response.message: Message` 需要
|
||||
/// 转回 `OpenaiChatMessage` 才能追加到旧 store。Phase 2 中移除。
|
||||
fn message_to_chat_message(msg: &Message) -> OpenaiChatMessage {
|
||||
match msg {
|
||||
Message::System { content } => OpenaiChatMessage::System {
|
||||
content: blocks_to_content_field(content),
|
||||
name: None,
|
||||
},
|
||||
Message::User { content } => OpenaiChatMessage::User {
|
||||
content: blocks_to_content_field(content),
|
||||
name: None,
|
||||
},
|
||||
// ponytail: UserImage 当前无 OpenAI 对应,回退为空的 User 消息(图片数据丢失)。
|
||||
// Phase 2 切换后此函数整体移除。
|
||||
Message::UserImage { .. } => OpenaiChatMessage::User {
|
||||
content: ContentField::Array(vec![]),
|
||||
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: FunctionCall {
|
||||
name: name.clone(),
|
||||
arguments: serde_json::to_string(input)
|
||||
.unwrap_or_else(|_| "null".to_string()),
|
||||
},
|
||||
});
|
||||
}
|
||||
// ponytail: thinking/refusal/image/audio/file 在 Phase 0 持久化时被丢弃。
|
||||
// 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_field(content),
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn blocks_to_content_field(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() }),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
ContentField::Array(parts)
|
||||
}
|
||||
|
||||
fn content_field_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() })
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,19 +796,20 @@ fn truncate_tool_result(s: &str, max_bytes: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::types::{ContentField, OpenaiContentPart};
|
||||
use crate::tools::{BaseTool, ToolRegistry};
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
use serde_json::{json, Value};
|
||||
use std::pin::Pin;
|
||||
|
||||
/// 模拟 Provider —— 预定义响应序列,按调用顺序返回。
|
||||
struct MockProvider {
|
||||
responses: std::sync::Mutex<Vec<ChatResponse>>,
|
||||
responses: std::sync::Mutex<Vec<MessageResponse>>,
|
||||
call_count: std::sync::Mutex<u32>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
fn new(responses: Vec<ChatResponse>) -> Self {
|
||||
fn new(responses: Vec<MessageResponse>) -> Self {
|
||||
Self {
|
||||
responses: std::sync::Mutex::new(responses),
|
||||
call_count: std::sync::Mutex::new(0),
|
||||
@@ -713,7 +819,7 @@ mod tests {
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for MockProvider {
|
||||
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
|
||||
async fn chat(&self, _request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
let mut count = self.call_count.lock().unwrap();
|
||||
*count += 1;
|
||||
let mut responses = self.responses.lock().unwrap();
|
||||
@@ -722,45 +828,61 @@ mod tests {
|
||||
}
|
||||
Ok(responses.remove(0))
|
||||
}
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
_request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
unimplemented!()
|
||||
}
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
provider_name: "mock",
|
||||
supported_models: None,
|
||||
features: ProviderFeatures::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_usage() -> crate::llm::types::Usage {
|
||||
crate::llm::types::Usage::default()
|
||||
}
|
||||
|
||||
fn assistant_text_response(text: &str) -> ChatResponse {
|
||||
ChatResponse {
|
||||
message: OpenaiChatMessage::assistant_text(text),
|
||||
fn assistant_text_response(text: &str) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.into(),
|
||||
}],
|
||||
},
|
||||
usage: empty_usage(),
|
||||
stop_reason: Some(FinishReason::Stop),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_tool_call_response(
|
||||
calls: Vec<(&str, &str, &str)>,
|
||||
) -> ChatResponse {
|
||||
use crate::llm::types::{OpenaiToolCall, FunctionCall};
|
||||
let tool_calls: Vec<OpenaiToolCall> = calls
|
||||
fn assistant_tool_call_response(calls: Vec<(&str, &str, &str)>) -> MessageResponse {
|
||||
let tool_blocks: Vec<ContentBlock> = calls
|
||||
.into_iter()
|
||||
.map(|(id, name, args)| OpenaiToolCall::Function {
|
||||
id: id.to_string(),
|
||||
function: FunctionCall {
|
||||
.map(|(id, name, args)| {
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(args).unwrap_or(serde_json::Value::Null);
|
||||
ContentBlock::ToolUse {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: args.to_string(),
|
||||
},
|
||||
input,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
ChatResponse {
|
||||
message: OpenaiChatMessage::Assistant {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text {
|
||||
text: String::new(),
|
||||
}]),
|
||||
refusal: None,
|
||||
name: None,
|
||||
tool_calls: Some(tool_calls),
|
||||
},
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant { content: tool_blocks },
|
||||
usage: empty_usage(),
|
||||
stop_reason: Some(FinishReason::ToolCalls),
|
||||
stop_reason: StopReason::ToolUse,
|
||||
extra: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +912,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_tools_single_turn() {
|
||||
// 第一轮:返回 tool_call;第二轮:返回最终文本
|
||||
let responses = vec![
|
||||
assistant_tool_call_response(vec![("call_1", "add", r#"{"a":1,"b":2}"#)]),
|
||||
assistant_text_response("答案是 3"),
|
||||
@@ -805,13 +926,11 @@ mod tests {
|
||||
.submit_with_tools("1+2=?".to_string(), ®istry)
|
||||
.await
|
||||
.unwrap();
|
||||
// 验证最终响应是文本响应
|
||||
assert!(matches!(
|
||||
response.message,
|
||||
OpenaiChatMessage::Assistant { .. }
|
||||
));
|
||||
// 最终响应是 Assistant 消息
|
||||
assert!(matches!(response.message, Message::Assistant { .. }));
|
||||
|
||||
// 验证消息历史:user, assistant(tool_calls), tool, assistant(text)
|
||||
// 验证消息历史(Vec<OpenaiChatMessage>,Phase 2 才切到 Vec<Message>):
|
||||
// user, assistant(tool_calls → 转回后含 tool_use), tool, assistant(text)
|
||||
let messages = cycle.messages();
|
||||
assert_eq!(messages.len(), 4);
|
||||
assert!(matches!(messages[0], OpenaiChatMessage::User { .. }));
|
||||
@@ -822,7 +941,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_tools_multi_turn() {
|
||||
// 3 轮 tool 调用后给出最终答案
|
||||
let responses = vec![
|
||||
assistant_tool_call_response(vec![("call_1", "add", r#"{"a":1,"b":2}"#)]),
|
||||
assistant_tool_call_response(vec![("call_2", "add", r#"{"a":3,"b":4}"#)]),
|
||||
@@ -839,10 +957,7 @@ mod tests {
|
||||
.submit_with_tools("计算总和".to_string(), ®istry)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
response.message,
|
||||
OpenaiChatMessage::Assistant { .. }
|
||||
));
|
||||
assert!(matches!(response.message, Message::Assistant { .. }));
|
||||
|
||||
// user + 3*(assistant + tool) + final assistant = 8
|
||||
let messages = cycle.messages();
|
||||
@@ -851,10 +966,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_tools_max_turns_exceeded() {
|
||||
// 配置 max_tool_turns = 2
|
||||
let mut config = CycleConfig::default();
|
||||
config.max_tool_turns = Some(2);
|
||||
// 4 轮 tool 调用 + 终止
|
||||
let responses = vec![
|
||||
assistant_tool_call_response(vec![("c1", "add", r#"{"a":1,"b":1}"#)]),
|
||||
assistant_tool_call_response(vec![("c2", "add", r#"{"a":1,"b":1}"#)]),
|
||||
@@ -867,15 +980,12 @@ mod tests {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(std::sync::Arc::new(AddTool)).unwrap();
|
||||
|
||||
let result = cycle
|
||||
.submit_with_tools("test".to_string(), ®istry)
|
||||
.await;
|
||||
let result = cycle.submit_with_tools("test".to_string(), ®istry).await;
|
||||
assert!(matches!(result, Err(LlmError::Other(msg)) if msg.contains("达到最大工具循环轮次")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_with_tools_no_tool_call_response() {
|
||||
// LLM 直接给出最终响应(不调用工具)
|
||||
let responses = vec![assistant_text_response("直接回答")];
|
||||
let provider = Box::new(MockProvider::new(responses));
|
||||
let mut cycle = LlmCycle::new(provider, CycleConfig::default());
|
||||
@@ -887,10 +997,7 @@ mod tests {
|
||||
.submit_with_tools("直接回答".to_string(), ®istry)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
response.message,
|
||||
OpenaiChatMessage::Assistant { .. }
|
||||
));
|
||||
assert!(matches!(response.message, Message::Assistant { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -911,7 +1018,6 @@ mod tests {
|
||||
fn test_truncate_tool_result_chinese_chars() {
|
||||
let s = "中".repeat(100);
|
||||
let truncated = truncate_tool_result(&s, 50);
|
||||
// 不会在字符中间截断
|
||||
assert!(truncated.starts_with("中"));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::ChatRequest;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
|
||||
/// 生命周期钩子事件点。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -30,7 +30,7 @@ pub struct HookContext<'a> {
|
||||
/// 当前事件点。
|
||||
pub event: HookEvent,
|
||||
/// 当前的请求(部分事件点可用)。
|
||||
pub request: Option<&'a ChatRequest>,
|
||||
pub request: Option<&'a MessageRequest>,
|
||||
/// 当前错误(仅 OnError 和 OnRetry 可用)。
|
||||
pub error: Option<&'a LlmError>,
|
||||
/// 当前重试次数(从 1 开始,仅 OnRetry 可用)。
|
||||
@@ -53,7 +53,7 @@ impl<'a> HookContext<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_request(mut self, request: &'a ChatRequest) -> Self {
|
||||
pub(crate) fn with_request(mut self, request: &'a MessageRequest) -> Self {
|
||||
self.request = Some(request);
|
||||
self
|
||||
}
|
||||
|
||||
+80
-24
@@ -3,16 +3,29 @@ pub mod registry;
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use tokio_stream::Stream;
|
||||
use futures_core::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::{ChatRequest, ChatResponse, OpenaiChatChunk};
|
||||
use async_trait::async_trait;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
|
||||
/// Provider 类型枚举 —— `create_provider()` 在编译期 exhaustive match 中使用。
|
||||
///
|
||||
/// 设计依据(见 `docs/10-llm-provider-refinement.md` §2.5 Decision-05):
|
||||
/// 当前协议数量(5 种以内)完全可控,enum 的编译期安全检查优于运行时的 `HashMap::get()`。
|
||||
/// 未来如果扩展到 15+ 种以上,再改为注册表模式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderType {
|
||||
OpenAI,
|
||||
/// OpenAI Chat Completions API(兼容 DeepSeek / Qwen 等 `/chat/completions` 端点)。
|
||||
OpenaiChat,
|
||||
/// OpenAI Response API。
|
||||
OpenaiResponse,
|
||||
/// Anthropic Messages API。
|
||||
Anthropic,
|
||||
/// DeepSeek(OpenAI-compatible `/chat/completions`)。
|
||||
DeepSeek,
|
||||
/// Qwen / 阿里云百炼(OpenAI-compatible `/chat/completions`)。
|
||||
Qwen,
|
||||
}
|
||||
|
||||
@@ -21,60 +34,103 @@ impl std::str::FromStr for ProviderType {
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"openai" => Ok(ProviderType::OpenAI),
|
||||
"openai" | "openai-chat" | "openai_chat" => Ok(ProviderType::OpenaiChat),
|
||||
"openai-response" | "openai_response" | "response" => Ok(ProviderType::OpenaiResponse),
|
||||
"anthropic" | "claude" => Ok(ProviderType::Anthropic),
|
||||
"deepseek" => Ok(ProviderType::DeepSeek),
|
||||
"qwen" | "dashscope" | "tongyi" => Ok(ProviderType::Qwen),
|
||||
_ => Err(format!("未知的 Provider 类型: {}", s)),
|
||||
_ => Err(format!("未知的 Provider 类型: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 构造参数 —— 通用 base_url + api_key + model。
|
||||
pub struct ProviderConfig {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// Provider 工厂 —— exhaustive match 在编译期保证新 Provider 被注册。
|
||||
pub fn create_provider(
|
||||
provider_type: ProviderType,
|
||||
config: ProviderConfig,
|
||||
) -> Result<Box<dyn LlmProvider>, LlmError> {
|
||||
match provider_type {
|
||||
ProviderType::OpenAI => Ok(Box::new(openai::OpenaiProvider::new(
|
||||
ProviderType::OpenaiChat => Ok(Box::new(openai::OpenaiProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::OpenaiResponse => {
|
||||
unimplemented!("OpenAI Response Provider 将在 Phase 1 引入")
|
||||
}
|
||||
ProviderType::Anthropic => {
|
||||
unimplemented!("Anthropic Provider 将在 Phase 1 引入")
|
||||
}
|
||||
ProviderType::DeepSeek => {
|
||||
unimplemented!("DeepSeek Provider 尚未实现")
|
||||
unimplemented!("DeepSeek Provider 将在 Phase 1 引入")
|
||||
}
|
||||
ProviderType::Qwen => {
|
||||
unimplemented!("Qwen Provider 尚未实现")
|
||||
unimplemented!("Qwen Provider 将在 Phase 1 引入")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 能力描述 —— 静态元信息,调用方据此决定可用特性。
|
||||
///
|
||||
/// 设计依据(见 `docs/10-llm-provider-refinement.md` §4 任务 6 决策):
|
||||
/// `ProviderCapabilities` 与 trait 同文件(`provider.rs`),不分散到类型目录。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderCapabilities {
|
||||
/// 人类可读的 Provider 名(如 `"openai"` / `"anthropic"`)。
|
||||
pub provider_name: &'static str,
|
||||
/// 支持的模型列表(`None` 表示"未列举全部")。
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
/// 详细功能开关。
|
||||
pub features: ProviderFeatures,
|
||||
}
|
||||
|
||||
/// Provider 功能开关集合。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ProviderFeatures {
|
||||
/// 是否支持流式响应。
|
||||
pub streaming: bool,
|
||||
/// 是否支持 thinking / 推理。
|
||||
pub thinking: bool,
|
||||
/// 是否支持图片输入。
|
||||
pub vision: bool,
|
||||
/// 是否支持音频输入。
|
||||
pub audio_input: bool,
|
||||
/// 是否支持工具调用。
|
||||
pub tool_use: bool,
|
||||
/// 是否支持并行工具调用。
|
||||
pub parallel_tool_calls: bool,
|
||||
/// system prompt 是否放在 messages 中(`true`)还是顶层 `system` 字段(`false`)。
|
||||
pub system_prompt_in_messages: bool,
|
||||
/// 模型上下文窗口(tokens);`0` 表示未知。
|
||||
pub max_context_window: u32,
|
||||
}
|
||||
|
||||
/// LLM Provider 抽象接口。
|
||||
///
|
||||
/// 所有具体的 LLM 后端实现(OpenAI、Anthropic、Azure 等)
|
||||
/// 所有具体的 LLM 后端实现(OpenAI、Anthropic、DeepSeek、Qwen 等)
|
||||
/// 均需实现此 trait,以实现可插拔替换。
|
||||
#[async_trait]
|
||||
///
|
||||
/// 修订(Phase 0):签名由 `chat(ChatRequest) → ChatResponse` 切换为
|
||||
/// `chat(MessageRequest) → MessageResponse`,`chat_stream` 返回新 `StreamEvent` 流,
|
||||
/// 新增 `capabilities()` 方法。
|
||||
#[async_trait::async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 发送聊天请求并返回完整响应。
|
||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, LlmError>;
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
|
||||
|
||||
/// 流式聊天请求 —— 返回原始 SSE chunk 流。
|
||||
///
|
||||
/// 默认实现回退到非流式调用(包装为单元素流)。
|
||||
/// 流式聊天请求 —— 返回新 IR `StreamEvent` 流。
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: ChatRequest,
|
||||
) -> Result<
|
||||
Pin<Box<dyn Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
LlmError,
|
||||
> {
|
||||
let response = self.chat(request).await?;
|
||||
let chunk = OpenaiChatChunk::from(response);
|
||||
Ok(Box::pin(tokio_stream::once(Ok(chunk))))
|
||||
}
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>;
|
||||
|
||||
/// 返回 Provider 静态能力描述。
|
||||
fn capabilities(&self) -> ProviderCapabilities;
|
||||
}
|
||||
|
||||
+323
-13
@@ -6,22 +6,32 @@ use bytes::Bytes;
|
||||
use futures_core::stream::Stream;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use super::LlmProvider;
|
||||
use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::stream::parse_chunk_stream;
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
use crate::llm::types::request::{OpenaiChatRequest, StreamOptions};
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent};
|
||||
use crate::llm::types::usage::Usage;
|
||||
use crate::llm::types::{
|
||||
ChatRequest, ChatResponse, OpenaiChatChunk, OpenaiChatResponse, StreamOptions,
|
||||
ChatResponse, ContentField, FinishReason, FunctionCall, OpenaiChatChunk, OpenaiChatMessage,
|
||||
OpenaiChatResponse, OpenaiContentPart, OpenaiTool, OpenaiToolCall, OpenaiToolDefinition,
|
||||
StopSequence, ToolChoice,
|
||||
};
|
||||
|
||||
pub struct OpenaiProvider {
|
||||
http_client: Client,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
impl OpenaiProvider {
|
||||
pub fn new(base_url: String, api_key: String, _model: String) -> Self {
|
||||
pub fn new(base_url: String, api_key: String, model: String) -> Self {
|
||||
let http_client = Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
@@ -31,6 +41,7 @@ impl OpenaiProvider {
|
||||
http_client,
|
||||
base_url,
|
||||
api_key,
|
||||
model,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +61,63 @@ impl OpenaiProvider {
|
||||
LlmError::Other(format!("请求失败: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for OpenaiProvider {
|
||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, LlmError> {
|
||||
fn map_stop_reason(r: Option<FinishReason>) -> StopReason {
|
||||
r.map(StopReason::from).unwrap_or(StopReason::Stop)
|
||||
}
|
||||
|
||||
/// ponytail: Phase 0 临时桥接 —— 把 IR `MessageRequest` 转换为 OpenAI wire 格式。
|
||||
fn convert_to_chat_request(&self, request: MessageRequest) -> OpenaiChatRequest {
|
||||
let messages = request.messages.iter().map(message_to_chat_message).collect();
|
||||
|
||||
let tools = if request.tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
request
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|t| OpenaiTool::Function { function: t })
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
|
||||
OpenaiChatRequest {
|
||||
model: request.model,
|
||||
messages,
|
||||
max_tokens: request.max_tokens,
|
||||
temperature: request.temperature,
|
||||
top_p: request.top_p,
|
||||
stop: if request.stop_sequences.is_empty() {
|
||||
None
|
||||
} else if request.stop_sequences.len() == 1 {
|
||||
Some(StopSequence::Single(request.stop_sequences[0].clone()))
|
||||
} else {
|
||||
Some(StopSequence::Multiple(request.stop_sequences))
|
||||
},
|
||||
tools,
|
||||
tool_choice: Some(request.tool_choice),
|
||||
stream: Some(request.stream),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// ponytail: Phase 0 临时桥接 —— 把 OpenAI `ChatResponse` 转换为 IR `MessageResponse`。
|
||||
fn convert_to_message_response(&self, response: ChatResponse) -> MessageResponse {
|
||||
let mut extra = std::collections::HashMap::new();
|
||||
let stop_reason = Self::map_stop_reason(response.stop_reason);
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: self.model.clone(),
|
||||
message: chat_message_to_message(&response.message),
|
||||
usage: response.usage,
|
||||
stop_reason,
|
||||
extra,
|
||||
}
|
||||
}
|
||||
|
||||
/// ponytail: Phase 0 临时桥接 —— 调用 OpenAI `/chat/completions` 拿到旧 wire 格式响应。
|
||||
async fn chat_inner(&self, request: OpenaiChatRequest) -> Result<ChatResponse, LlmError> {
|
||||
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
||||
|
||||
info!(model = %request.model, max_tokens = request.max_tokens, temperature = request.temperature, "发送 LLM 请求");
|
||||
@@ -118,9 +181,10 @@ impl LlmProvider for OpenaiProvider {
|
||||
Ok(ChatResponse::from(chat_response))
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
/// ponytail: Phase 0 临时桥接 —— 流式调用 `/chat/completions` 返回 chunk 流。
|
||||
async fn chat_stream_inner(
|
||||
&self,
|
||||
mut request: ChatRequest,
|
||||
mut request: OpenaiChatRequest,
|
||||
) -> Result<
|
||||
Pin<Box<dyn Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
LlmError,
|
||||
@@ -159,14 +223,251 @@ impl LlmProvider for OpenaiProvider {
|
||||
});
|
||||
}
|
||||
|
||||
let byte_stream = response.bytes_stream().map(|r| {
|
||||
r.map_err(|e| LlmError::Other(format!("流式读取失败: {}", e)))
|
||||
});
|
||||
let byte_stream =
|
||||
response.bytes_stream().map(|r| {
|
||||
r.map_err(|e| LlmError::Other(format!("流式读取失败: {}", e)))
|
||||
});
|
||||
|
||||
Ok(Box::pin(SseChunkStream::new(byte_stream)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for OpenaiProvider {
|
||||
// ponytail: Phase 0 临时桥接,Phase 1 重写时移除。
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
let chat_req = self.convert_to_chat_request(request);
|
||||
let chat_resp = self.chat_inner(chat_req).await?;
|
||||
Ok(self.convert_to_message_response(chat_resp))
|
||||
}
|
||||
|
||||
// ponytail: Phase 0 临时桥接,Phase 1 重写时移除。
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
let chat_req = self.convert_to_chat_request(request);
|
||||
let chunk_stream = self.chat_stream_inner(chat_req).await?;
|
||||
// ponytail: 复用旧 parse_chunk_stream 把 chunk 流映射为 IR StreamEvent 流。
|
||||
// Phase 1 中 OpenAI Provider 直接产出 IR 事件后整体删除此调用。
|
||||
Ok(parse_chunk_stream(chunk_stream))
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
provider_name: "openai",
|
||||
supported_models: None,
|
||||
features: ProviderFeatures::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 转换函数 =====
|
||||
|
||||
/// IR `Message` → OpenAI `OpenaiChatMessage`。
|
||||
///
|
||||
/// ponytail: Phase 0 临时转换层;Phase 2 中 LlmCycle 切换为 `Vec<Message>` 后移除。
|
||||
fn message_to_chat_message(msg: &Message) -> OpenaiChatMessage {
|
||||
match msg {
|
||||
Message::System { content } => OpenaiChatMessage::System {
|
||||
content: blocks_to_content_field(content),
|
||||
name: None,
|
||||
},
|
||||
Message::User { content } => OpenaiChatMessage::User {
|
||||
content: blocks_to_content_field(content),
|
||||
name: None,
|
||||
},
|
||||
// ponytail: UserImage 当前无对应 OpenAI 形态,回退为空的 User 消息。
|
||||
// Phase 2 切到 Vec<Message> 后此函数整体删除。
|
||||
Message::UserImage { data: _, mime_type: _, detail: _ } => OpenaiChatMessage::User {
|
||||
content: ContentField::Array(vec![]),
|
||||
name: None,
|
||||
},
|
||||
Message::Assistant { content } => {
|
||||
let mut text_blocks = Vec::new();
|
||||
let mut tool_call_blocks = 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: FunctionCall {
|
||||
name: name.clone(),
|
||||
arguments: serde_json::to_string(input).unwrap_or_default(),
|
||||
},
|
||||
});
|
||||
}
|
||||
// ponytail: thinking/refusal/image/audio/file 在回传时被截断,
|
||||
// Phase 2 切到 Vec<Message> 整体修复。
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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_field(content),
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenAI `OpenaiChatMessage` → IR `Message`。
|
||||
fn chat_message_to_message(msg: &OpenaiChatMessage) -> Message {
|
||||
match msg {
|
||||
OpenaiChatMessage::Developer { content, .. }
|
||||
| OpenaiChatMessage::System { content, .. } => Message::System {
|
||||
content: content_field_to_blocks(content),
|
||||
},
|
||||
OpenaiChatMessage::User { content, .. } => Message::User {
|
||||
content: content_field_to_blocks(content),
|
||||
},
|
||||
OpenaiChatMessage::Assistant {
|
||||
content,
|
||||
tool_calls,
|
||||
..
|
||||
} => {
|
||||
let mut blocks = content_field_to_blocks(content);
|
||||
if let Some(calls) = tool_calls {
|
||||
for call in calls {
|
||||
if 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_field_to_blocks(content),
|
||||
is_error: false,
|
||||
},
|
||||
// ponytail: OpenAI 已废弃 Function 消息变体;映射为带 name 假 tool_call_id 的 ToolResult。
|
||||
OpenaiChatMessage::Function { content, name } => Message::ToolResult {
|
||||
tool_call_id: name.clone(),
|
||||
content: content_field_to_blocks(content),
|
||||
is_error: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn blocks_to_content_field(blocks: &[ContentBlock]) -> ContentField {
|
||||
if blocks.len() == 1
|
||||
&& let ContentBlock::Text { text } = &blocks[0]
|
||||
{
|
||||
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: crate::llm::types::openai_message::ImageURL {
|
||||
url: if source.is_url {
|
||||
source.data.clone()
|
||||
} else {
|
||||
format!("data:{};base64,{}", source.mime_type, source.data)
|
||||
},
|
||||
detail: Some(match source.mime_type.as_str() {
|
||||
"image/jpeg" | "image/png" | "image/webp" => {
|
||||
crate::llm::types::shared::ImageDetail::Auto
|
||||
}
|
||||
_ => crate::llm::types::shared::ImageDetail::Auto,
|
||||
}),
|
||||
},
|
||||
detail: None,
|
||||
}),
|
||||
ContentBlock::Audio { source } => Some(OpenaiContentPart::InputAudio {
|
||||
input_audio: crate::llm::types::openai_message::InputAudio {
|
||||
data: source.data.clone(),
|
||||
format: source.format,
|
||||
},
|
||||
}),
|
||||
// ponytail: File/Thinking/Extension/ToolUse/ToolResult 在回传时被截断。
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
ContentField::Array(vec![])
|
||||
} else {
|
||||
ContentField::Array(parts)
|
||||
}
|
||||
}
|
||||
|
||||
fn content_field_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, .. } => {
|
||||
let url = &image_url.url;
|
||||
if let Some(rest) = url.strip_prefix("data:") {
|
||||
if 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
|
||||
.detail
|
||||
.map(|_| "image/url".to_string())
|
||||
.unwrap_or_else(|| "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,
|
||||
},
|
||||
}),
|
||||
OpenaiContentPart::File { .. } => None,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
struct SseChunkStream<S> {
|
||||
inner: S,
|
||||
buffer: String,
|
||||
@@ -184,7 +485,10 @@ impl<S: Stream<Item = Result<Bytes, LlmError>> + Unpin> SseChunkStream<S> {
|
||||
impl<S: Stream<Item = Result<Bytes, LlmError>> + Unpin> Stream for SseChunkStream<S> {
|
||||
type Item = Result<OpenaiChatChunk, LlmError>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<Option<Self::Item>> {
|
||||
fn poll_next(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
loop {
|
||||
if let Some(pos) = self.buffer.find("\n") {
|
||||
let line = self.buffer.drain(..pos + 1).collect::<String>();
|
||||
@@ -233,3 +537,9 @@ impl<S: Stream<Item = Result<Bytes, LlmError>> + Unpin> Stream for SseChunkStrea
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress unused-import warnings for symbols kept for Phase 1 parity.
|
||||
#[allow(dead_code)]
|
||||
fn _phase0_reexports() {
|
||||
let _ = Usage::default;
|
||||
}
|
||||
|
||||
+140
-44
@@ -1,4 +1,14 @@
|
||||
//! 流式事件系统 —— 将 LLM 流式响应解析为语义化事件。
|
||||
//!
|
||||
//! Phase 0 修订(参见 `docs/10a-phase0-types-and-trait.md` §"StreamEvent 命名冲突处理"):
|
||||
//! - 对外暴露的 `StreamEvent` 是高精度 IR 版本(来自 `response_v2::StreamEvent`)。
|
||||
//! - 旧变体(`AssistantTextDelta` / `ToolExecutionStarted` 等)重命名为 `LegacyStreamEvent`
|
||||
//! 放在 `crate::llm::types::old_stream` 模块,本文件内部消费。
|
||||
//! - Phase 1 重写 Provider 时可直接消费新事件流后整体删除 `LegacyStreamEvent` 相关代码。
|
||||
//!
|
||||
//! 当前实现:旧的 `parse_chunk_stream` 内部消费 `OpenaiChatChunk`,映射为
|
||||
//! `LegacyStreamEvent`,再在 `LegacyToIrEventStream` 中映射为新 IR `StreamEvent`
|
||||
//! 后输出。Phase 1 会重写此层(OpenAI Provider 直接产出新事件流)。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
@@ -9,54 +19,47 @@ use futures_util::FutureExt;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::{FinishReason, OpenaiChatChunk, OpenaiToolCall, Usage};
|
||||
use crate::llm::types::old_stream::LegacyStreamEvent;
|
||||
use crate::llm::types::response_v2::MessageResponse;
|
||||
use crate::llm::types::response_v2::StopReason;
|
||||
use crate::llm::types::usage::Usage;
|
||||
use crate::llm::types::{FinishReason, OpenaiChatChunk, OpenaiToolCall};
|
||||
|
||||
/// 流式事件 —— LLM 调用全生命周期的语义化事件。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StreamEvent {
|
||||
/// 助手回复文本增量。
|
||||
AssistantTextDelta { text: String },
|
||||
/// 工具调用开始。
|
||||
ToolExecutionStarted {
|
||||
tool_name: String,
|
||||
input: Value,
|
||||
tool_call_id: String,
|
||||
},
|
||||
/// 工具调用完成。
|
||||
ToolExecutionCompleted {
|
||||
tool_name: String,
|
||||
output: Value,
|
||||
is_error: bool,
|
||||
},
|
||||
/// Token 用量更新。
|
||||
CostUpdate { usage: Usage },
|
||||
/// 一轮会话完成。
|
||||
TurnComplete { reason: FinishReason },
|
||||
/// 错误事件。
|
||||
Error { message: String },
|
||||
}
|
||||
// 唯一的对外 `StreamEvent` 定义(高精度 IR 事件,来自 `response_v2`)。
|
||||
//
|
||||
// 此 `pub use` 同时起到两个作用:
|
||||
// 1. 让 `crate::llm::stream::StreamEvent` 路径仍指向新高精度 IR 事件,
|
||||
// 保持与既有 `use crate::llm::stream::StreamEvent` 的代码兼容;
|
||||
// 2. 把模块内部的 `StreamEvent` 名字指向 `response_v2::StreamEvent`。
|
||||
pub use crate::llm::types::response_v2::StreamEvent;
|
||||
|
||||
impl StreamEvent {
|
||||
fn error(message: impl Into<String>) -> Self {
|
||||
Self::Error {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将原始 OpenaiChatChunk 流解析为 StreamEvent 流。
|
||||
/// 将原始 OpenaiChatChunk 流解析为新高精度 IR StreamEvent 流。
|
||||
///
|
||||
/// ponytail: 每个产出事件都用 `Result<_, LlmError>` 包装,让上层 `chat_stream`
|
||||
/// trait 方法直接消费并保持错误传播链。当前 `LegacyToIrEventStream` 内部
|
||||
/// 不会产生错误,所有结果都是 `Ok`;后续 Phase 1 重写 Provider 时,
|
||||
/// 真实 IR 流转换可在此层注入 error 事件。
|
||||
pub fn parse_chunk_stream(
|
||||
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
) -> Pin<Box<dyn futures_core::Stream<Item = StreamEvent> + Send>> {
|
||||
Box::pin(ChunkToEventStream { chunks })
|
||||
) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamEvent, LlmError>> + Send>> {
|
||||
let legacy = parse_chunk_stream_legacy(chunks);
|
||||
Box::pin(LegacyToIrEventStream { inner: legacy })
|
||||
}
|
||||
|
||||
struct ChunkToEventStream {
|
||||
// --- 内部:chunk → LegacyStreamEvent ---
|
||||
|
||||
fn parse_chunk_stream_legacy(
|
||||
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
) -> Pin<Box<dyn futures_core::Stream<Item = LegacyStreamEvent> + Send>> {
|
||||
Box::pin(ChunkToLegacyEventStream { chunks })
|
||||
}
|
||||
|
||||
struct ChunkToLegacyEventStream {
|
||||
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
}
|
||||
|
||||
impl Stream for ChunkToEventStream {
|
||||
type Item = StreamEvent;
|
||||
impl Stream for ChunkToLegacyEventStream {
|
||||
type Item = LegacyStreamEvent;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
let this = &mut *self;
|
||||
@@ -66,7 +69,7 @@ impl Stream for ChunkToEventStream {
|
||||
let delta = &choice.delta;
|
||||
|
||||
if let Some(content) = &delta.content {
|
||||
return Poll::Ready(Some(StreamEvent::AssistantTextDelta {
|
||||
return Poll::Ready(Some(LegacyStreamEvent::AssistantTextDelta {
|
||||
text: content.clone(),
|
||||
}));
|
||||
}
|
||||
@@ -77,7 +80,7 @@ impl Stream for ChunkToEventStream {
|
||||
let OpenaiToolCall::Function { id, function } = tc;
|
||||
let args: Value =
|
||||
serde_json::from_str(&function.arguments).unwrap_or(Value::Null);
|
||||
return Poll::Ready(Some(StreamEvent::ToolExecutionStarted {
|
||||
return Poll::Ready(Some(LegacyStreamEvent::ToolExecutionStarted {
|
||||
tool_name: function.name.clone(),
|
||||
input: args,
|
||||
tool_call_id: id.clone(),
|
||||
@@ -85,24 +88,117 @@ impl Stream for ChunkToEventStream {
|
||||
}
|
||||
|
||||
if let Some(finish_reason) = &choice.finish_reason {
|
||||
return Poll::Ready(Some(StreamEvent::TurnComplete {
|
||||
return Poll::Ready(Some(LegacyStreamEvent::TurnComplete {
|
||||
reason: *finish_reason,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(usage) = &chunk.usage {
|
||||
return Poll::Ready(Some(StreamEvent::CostUpdate {
|
||||
return Poll::Ready(Some(LegacyStreamEvent::CostUpdate {
|
||||
usage: *usage,
|
||||
}));
|
||||
}
|
||||
|
||||
Poll::Ready(None)
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(StreamEvent::error(e.to_string()))),
|
||||
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(LegacyStreamEvent::error(e.to_string()))),
|
||||
Poll::Ready(None) => Poll::Ready(None),
|
||||
Poll::Pending => Poll::Pending,
|
||||
})
|
||||
.poll_unpin(cx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 内部:LegacyStreamEvent → 新 StreamEvent ---
|
||||
|
||||
struct LegacyToIrEventStream {
|
||||
inner: Pin<Box<dyn futures_core::Stream<Item = LegacyStreamEvent> + Send>>,
|
||||
}
|
||||
|
||||
impl Stream for LegacyToIrEventStream {
|
||||
type Item = Result<StreamEvent, LlmError>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
let this = &mut *self;
|
||||
match Pin::new(&mut this.inner).poll_next(cx) {
|
||||
Poll::Ready(Some(legacy)) => Poll::Ready(Some(Ok(map_legacy_to_ir(legacy)))),
|
||||
Poll::Ready(None) => {
|
||||
// 旧流结束 → 主动补一个 MessageComplete(full_response 为兜底空快照)。
|
||||
// ponytail: Phase 0 中 OpenaiProvider 桥接层负责产出真实 MessageResponse,
|
||||
// 此处仅防止消费方无限等待。若 Provider 层已正确发出 MessageComplete,
|
||||
// LlmCycle 不会走到这里 —— 因为桥接层 inline 处理。
|
||||
Poll::Ready(Some(Ok(StreamEvent::MessageComplete {
|
||||
full_response: empty_message_response(),
|
||||
})))
|
||||
}
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_message_response() -> MessageResponse {
|
||||
use crate::llm::types::message::Message;
|
||||
use std::collections::HashMap;
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant {
|
||||
content: vec![],
|
||||
},
|
||||
usage: Usage::default(),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 把旧 LegacyStreamEvent 映射到新高精度 IR StreamEvent。
|
||||
///
|
||||
/// Phase 1 重写 Provider 后可直接删除此映射函数。当前映射语义:
|
||||
/// - `AssistantTextDelta` → `TextDelta`
|
||||
/// - `ToolExecutionStarted` → `ToolCallArgumentsDelta`(OpenAI 单 chunk 模式下整段 arguments 一次性下发)
|
||||
/// - `CostUpdate` → `CostUpdate`(Usage → PartialUsage 全字段)
|
||||
/// - `TurnComplete` → `MessageComplete`(Phase 1 重写 Provider 后正确产出)
|
||||
/// - `Error` → `Error`
|
||||
///
|
||||
/// ponytail: 这是一个"目前能跑通未来会被删除"的适配层。当前实现为单事件映射,
|
||||
/// 旧 `ToolExecutionStarted` 携带的 (id, name) 暂未填入 IR 事件(消费方
|
||||
/// Phase 2 中通过 MessageComplete.full_response.tool_use 提取)。Phase 1 重写时
|
||||
/// 由 OpenAI Provider 直接产出 IR 流,整体删除此映射。
|
||||
fn map_legacy_to_ir(legacy: LegacyStreamEvent) -> StreamEvent {
|
||||
use crate::llm::types::response_v2::PartialUsage;
|
||||
|
||||
match legacy {
|
||||
LegacyStreamEvent::AssistantTextDelta { text } => StreamEvent::TextDelta { text },
|
||||
LegacyStreamEvent::ToolExecutionStarted { input, .. } => {
|
||||
let arguments = serde_json::to_string(&input).unwrap_or_default();
|
||||
StreamEvent::ToolCallArgumentsDelta { index: 0, arguments }
|
||||
}
|
||||
LegacyStreamEvent::ToolExecutionCompleted { .. } => {
|
||||
// 旧 ToolExecutionCompleted 不在 IR 流协议中——工具执行是消费方职责。
|
||||
// Phase 1 重写时此处整体删除。当前给一个无副作用的占位事件。
|
||||
StreamEvent::CostUpdate {
|
||||
usage: PartialUsage::default(),
|
||||
}
|
||||
}
|
||||
LegacyStreamEvent::CostUpdate { usage } => StreamEvent::CostUpdate {
|
||||
usage: PartialUsage {
|
||||
prompt_tokens: Some(usage.prompt_tokens),
|
||||
completion_tokens: Some(usage.completion_tokens),
|
||||
total_tokens: Some(usage.total_tokens),
|
||||
completion_tokens_details: usage.completion_tokens_details,
|
||||
prompt_tokens_details: usage.prompt_tokens_details,
|
||||
},
|
||||
},
|
||||
LegacyStreamEvent::TurnComplete { reason } => {
|
||||
// 旧 TurnComplete 不直接对应 IR;映射为带 StopReason 的 MessageComplete。
|
||||
// ponytail: Phase 1 重写 Provider 后此适配整体删除,
|
||||
// OpenAI Provider 直接产出带正确 stop_reason 的 MessageComplete。
|
||||
let _ = reason;
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_message_response(),
|
||||
}
|
||||
}
|
||||
LegacyStreamEvent::Error { message } => StreamEvent::Error { message },
|
||||
}
|
||||
}
|
||||
|
||||
+384
-125
@@ -1,165 +1,424 @@
|
||||
use crate::llm::types::shared::{AudioFormat, ImageDetail};
|
||||
use crate::llm::types::tool::OpenaiToolCall;
|
||||
//! IR 层 Message 类型 —— 跨 Provider 统一的消息模型。
|
||||
//!
|
||||
//! 设计目标见 `docs/10-llm-provider-refinement.md` §2.1 Decision-01。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ContentField {
|
||||
String(String),
|
||||
Array(Vec<OpenaiContentPart>),
|
||||
use crate::llm::types::shared::AudioFormat;
|
||||
use crate::llm::types::shared::ImageDetail;
|
||||
|
||||
/// 跨 Provider 统一的消息类型(扁平大枚举)。
|
||||
///
|
||||
/// 每个变体直接承载完整语义,消费方 match 即可获得所有信息,
|
||||
/// 无需在嵌套的 `Vec` 中搜索。
|
||||
///
|
||||
/// 设计要点:
|
||||
/// - `Thinking` / `ToolUse` 不作为独立 Message 变体,保留在 `Assistant.content` 中,
|
||||
/// 因为 text ↔ tool_use 的**交错顺序**是 Assistant 响应的语义组成部分。
|
||||
/// - `UserImage` 是快捷变体,减少"图片只有 base64 字符串"的 boilerplate,
|
||||
/// 消费方 match 可直接区分文本和图片输入。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Message {
|
||||
/// 系统提示(User & Assistant 之外的引导指令)。
|
||||
System {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
/// 用户输入。
|
||||
User {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
/// 用户的图片输入(快捷构造,免去构造 ContentBlock 的 boilerplate)。
|
||||
UserImage {
|
||||
data: String,
|
||||
mime_type: String,
|
||||
detail: ImageDetail,
|
||||
},
|
||||
/// Assistant 回复内容块(可能包含 text、thinking、tool_use 等多种 block 的混合)。
|
||||
Assistant {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
/// 工具调用结果。
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
content: Vec<ContentBlock>,
|
||||
is_error: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ContentField {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
Value::String(s) => Ok(ContentField::String(s)),
|
||||
Value::Array(arr) => {
|
||||
let parts: Result<Vec<OpenaiContentPart>, _> =
|
||||
serde_json::from_value(Value::Array(arr));
|
||||
match parts {
|
||||
Ok(parts) => Ok(ContentField::Array(parts)),
|
||||
Err(e) => Err(serde::de::Error::custom(e)),
|
||||
}
|
||||
}
|
||||
_ => Err(serde::de::Error::custom("content must be string or array")),
|
||||
impl Message {
|
||||
/// 构造纯文本 User 消息。
|
||||
pub fn user_text(text: impl Into<String>) -> Self {
|
||||
Message::User {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造 UserImage 快捷消息(直接传 base64 / URL + MIME)。
|
||||
pub fn user_image(
|
||||
data: impl Into<String>,
|
||||
mime_type: impl Into<String>,
|
||||
detail: ImageDetail,
|
||||
) -> Self {
|
||||
Message::UserImage {
|
||||
data: data.into(),
|
||||
mime_type: mime_type.into(),
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造纯文本 Assistant 消息。
|
||||
pub fn assistant(text: impl Into<String>) -> Self {
|
||||
Message::Assistant {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造纯文本 System 消息。
|
||||
pub fn system(text: impl Into<String>) -> Self {
|
||||
Message::System {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造纯文本 ToolResult 消息。
|
||||
pub fn tool_result(
|
||||
tool_call_id: impl Into<String>,
|
||||
text: impl Into<String>,
|
||||
is_error: bool,
|
||||
) -> Self {
|
||||
Message::ToolResult {
|
||||
tool_call_id: tool_call_id.into(),
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
is_error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ContentField {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
ContentField::String(s) => s.serialize(serializer),
|
||||
ContentField::Array(arr) => arr.serialize(serializer),
|
||||
}
|
||||
}
|
||||
/// 内容块 —— 组成消息的最小语义单元。
|
||||
///
|
||||
/// 与 9b §3.1 设计保持一致:`Text` / `Image` / `Audio` / `File` / `ToolUse` /
|
||||
/// `ToolResult` / `Thinking` / `Extension`,其中 `Extension` 作为 Provider 特定
|
||||
/// block 的逃生舱。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
/// 纯文本。
|
||||
Text { text: String },
|
||||
/// 图片(含 URL 或 base64)。
|
||||
Image { source: ImageSource },
|
||||
/// 音频。
|
||||
Audio { source: AudioSource },
|
||||
/// 通用文件。
|
||||
File { source: FileSource },
|
||||
/// 工具调用。
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
/// 工具结果(在消息历史中由 Assistant 携带,如 Anthropic 内容回显)。
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: Vec<ContentBlock>,
|
||||
is_error: bool,
|
||||
},
|
||||
/// 推理 / 思考内容(可携带 signature 用于多轮续推)。
|
||||
Thinking {
|
||||
text: String,
|
||||
signature: Option<String>,
|
||||
},
|
||||
/// 逃生舱:Provider 特定 block 透传(OpenAI Response 内置工具等)。
|
||||
Extension {
|
||||
kind: String,
|
||||
data: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// 内容块类型标签 —— 用于 `StreamEvent::ContentBlockStart.block_type`。
|
||||
///
|
||||
/// 用途:在流式场景中,Provider 先下发 block 类型,再下发 block 内容增量。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageURL {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<ImageDetail>,
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentBlockType {
|
||||
/// 文本块。
|
||||
Text,
|
||||
/// 思考 / 推理块。
|
||||
Thinking,
|
||||
/// 拒绝 / 政策阻断块(OpenAI refusal)。
|
||||
Refusal,
|
||||
/// 工具调用块(携带 id 和 name)。
|
||||
ToolUse { id: String, name: String },
|
||||
}
|
||||
|
||||
/// 图片来源(URL 或 base64)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InputAudio {
|
||||
pub struct ImageSource {
|
||||
/// URL 或 base64 字符串。
|
||||
pub data: String,
|
||||
/// MIME 类型(如 `image/png` / `image/jpeg` / `image/webp`)。
|
||||
pub mime_type: String,
|
||||
/// `true` = URL;`false` = base64。
|
||||
pub is_url: bool,
|
||||
}
|
||||
|
||||
/// 音频来源。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioSource {
|
||||
/// base64 字符串。
|
||||
pub data: String,
|
||||
/// 音频格式。
|
||||
pub format: AudioFormat,
|
||||
}
|
||||
|
||||
/// 文件来源。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileData {
|
||||
pub file_data: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_id: Option<String>,
|
||||
pub struct FileSource {
|
||||
/// 文件内容(base64)或 URL。
|
||||
pub data: String,
|
||||
/// 原始文件名。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub filename: Option<String>,
|
||||
/// MIME 类型。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
pub enum OpenaiContentPart {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Image {
|
||||
image_url: ImageURL,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<ImageDetail>,
|
||||
},
|
||||
InputAudio {
|
||||
input_audio: InputAudio,
|
||||
},
|
||||
File {
|
||||
file: FileData,
|
||||
},
|
||||
Refusal {
|
||||
refusal: String,
|
||||
},
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::types::shared::AudioFormat;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "role")]
|
||||
pub enum OpenaiChatMessage {
|
||||
Developer {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
System {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
User {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
Assistant {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
refusal: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<OpenaiToolCall>>,
|
||||
},
|
||||
Tool {
|
||||
content: ContentField,
|
||||
tool_call_id: String,
|
||||
},
|
||||
Function {
|
||||
content: ContentField,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl OpenaiChatMessage {
|
||||
pub fn user_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::User {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
|
||||
name: None,
|
||||
#[test]
|
||||
fn message_user_text_produces_text_block() {
|
||||
let m = Message::user_text("hello");
|
||||
match m {
|
||||
Message::User { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
match &content[0] {
|
||||
ContentBlock::Text { text } => assert_eq!(text, "hello"),
|
||||
_ => panic!("expected Text block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected User variant"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assistant_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::Assistant {
|
||||
content: ContentField::String(text.into()),
|
||||
refusal: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
#[test]
|
||||
fn message_assistant_text_produces_text_block() {
|
||||
let m = Message::assistant("hi");
|
||||
match m {
|
||||
Message::Assistant { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "hi"));
|
||||
}
|
||||
_ => panic!("expected Assistant variant"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::System {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
|
||||
name: None,
|
||||
#[test]
|
||||
fn message_system_text_produces_text_block() {
|
||||
let m = Message::system("sys");
|
||||
match m {
|
||||
Message::System { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "sys"));
|
||||
}
|
||||
_ => panic!("expected System variant"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn developer_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::Developer {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
|
||||
name: None,
|
||||
#[test]
|
||||
fn message_user_image_carries_fields() {
|
||||
let m = Message::user_image("base64data", "image/png", ImageDetail::High);
|
||||
match m {
|
||||
Message::UserImage {
|
||||
data,
|
||||
mime_type,
|
||||
detail,
|
||||
} => {
|
||||
assert_eq!(data, "base64data");
|
||||
assert_eq!(mime_type, "image/png");
|
||||
assert_eq!(detail, ImageDetail::High);
|
||||
}
|
||||
_ => panic!("expected UserImage variant"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_result<S: Into<String>>(tool_call_id: String, content: S) -> Self {
|
||||
OpenaiChatMessage::Tool {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text {
|
||||
text: content.into(),
|
||||
}]),
|
||||
tool_call_id,
|
||||
#[test]
|
||||
fn message_tool_result_text_produces_block() {
|
||||
let m = Message::tool_result("call_1", "result text", false);
|
||||
match m {
|
||||
Message::ToolResult {
|
||||
tool_call_id,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
assert_eq!(tool_call_id, "call_1");
|
||||
assert!(!is_error);
|
||||
assert_eq!(content.len(), 1);
|
||||
match &content[0] {
|
||||
ContentBlock::Text { text } => assert_eq!(text, "result text"),
|
||||
_ => panic!("expected Text block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected ToolResult variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_exhaustive_match() {
|
||||
// 编译器保证穷举;这里写一个 match 验证所有变体可被访问。
|
||||
let msgs = vec![
|
||||
Message::user_text("a"),
|
||||
Message::user_image("d", "image/png", ImageDetail::Auto),
|
||||
Message::assistant("b"),
|
||||
Message::system("c"),
|
||||
Message::tool_result("id", "r", false),
|
||||
];
|
||||
for m in &msgs {
|
||||
let s = match m {
|
||||
Message::System { .. } => "sys",
|
||||
Message::User { .. } => "user",
|
||||
Message::UserImage { .. } => "image",
|
||||
Message::Assistant { .. } => "asst",
|
||||
Message::ToolResult { .. } => "tool",
|
||||
};
|
||||
assert!(!s.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_block_roundtrip_each_variant() {
|
||||
let blocks = vec![
|
||||
ContentBlock::Text {
|
||||
text: "hello".into(),
|
||||
},
|
||||
ContentBlock::Image {
|
||||
source: ImageSource {
|
||||
data: "b64".into(),
|
||||
mime_type: "image/png".into(),
|
||||
is_url: false,
|
||||
},
|
||||
},
|
||||
ContentBlock::Audio {
|
||||
source: AudioSource {
|
||||
data: "audio_b64".into(),
|
||||
format: AudioFormat::Mp3,
|
||||
},
|
||||
},
|
||||
ContentBlock::File {
|
||||
source: FileSource {
|
||||
data: "file_data".into(),
|
||||
filename: Some("doc.pdf".into()),
|
||||
mime_type: Some("application/pdf".into()),
|
||||
},
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "call_1".into(),
|
||||
name: "search".into(),
|
||||
input: json!({"q": "rust"}),
|
||||
},
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id: "call_1".into(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "found".into(),
|
||||
}],
|
||||
is_error: false,
|
||||
},
|
||||
ContentBlock::Thinking {
|
||||
text: "thinking...".into(),
|
||||
signature: Some("sig".into()),
|
||||
},
|
||||
ContentBlock::Extension {
|
||||
kind: "openai.response.web_search_call".into(),
|
||||
data: json!({"query": "rust"}),
|
||||
},
|
||||
];
|
||||
|
||||
for original in blocks {
|
||||
let json = serde_json::to_string(&original).expect("serialize");
|
||||
let decoded: ContentBlock = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(format!("{:?}", original), format!("{:?}", decoded));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_roundtrip_each_variant() {
|
||||
let msgs = vec![
|
||||
Message::System {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "sys".into(),
|
||||
}],
|
||||
},
|
||||
Message::User {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
},
|
||||
Message::UserImage {
|
||||
data: "b64".into(),
|
||||
mime_type: "image/png".into(),
|
||||
detail: ImageDetail::High,
|
||||
},
|
||||
Message::Assistant {
|
||||
content: vec![
|
||||
ContentBlock::Thinking {
|
||||
text: "reasoning".into(),
|
||||
signature: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "answer".into(),
|
||||
},
|
||||
],
|
||||
},
|
||||
Message::ToolResult {
|
||||
tool_call_id: "call_1".into(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "ok".into(),
|
||||
}],
|
||||
is_error: true,
|
||||
},
|
||||
];
|
||||
|
||||
for original in msgs {
|
||||
let json = serde_json::to_string(&original).expect("serialize");
|
||||
let decoded: Message = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(format!("{:?}", original), format!("{:?}", decoded));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_block_tool_result_nested_roundtrip() {
|
||||
let block = ContentBlock::ToolResult {
|
||||
tool_use_id: "call_1".into(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "result".into(),
|
||||
}],
|
||||
is_error: false,
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let decoded: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(format!("{:?}", block), format!("{:?}", decoded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_block_type_roundtrip() {
|
||||
let types = vec![
|
||||
ContentBlockType::Text,
|
||||
ContentBlockType::Thinking,
|
||||
ContentBlockType::Refusal,
|
||||
ContentBlockType::ToolUse {
|
||||
id: "call_x".into(),
|
||||
name: "fn".into(),
|
||||
},
|
||||
];
|
||||
for original in types {
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let decoded: ContentBlockType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(format!("{:?}", original), format!("{:?}", decoded));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
-9
@@ -1,18 +1,27 @@
|
||||
pub mod message;
|
||||
pub mod old_stream;
|
||||
pub mod openai_message;
|
||||
pub mod request;
|
||||
pub mod request_v2;
|
||||
pub mod response;
|
||||
pub mod response_v2;
|
||||
pub mod shared;
|
||||
pub mod tool;
|
||||
pub mod usage;
|
||||
|
||||
pub use message::{
|
||||
pub use openai_message::{
|
||||
ContentField, FileData, ImageURL, InputAudio, OpenaiChatMessage, OpenaiContentPart,
|
||||
};
|
||||
pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions, ToolChoice};
|
||||
pub use request_v2::{ExtraError, MessageRequest, ThinkingConfig};
|
||||
pub use response::{
|
||||
Annotation, Choice, ChunkChoice, Delta, Logprobs, OpenaiAudio, OpenaiChatChunk,
|
||||
OpenaiChatResponse, TokenLogprob, TopLogprob, URLCitation,
|
||||
};
|
||||
pub use response_v2::{
|
||||
ContentBlockBuilder, MessageResponse, PartialMessageResponse, PartialUsage, StopReason,
|
||||
StreamEvent,
|
||||
};
|
||||
pub use shared::{
|
||||
AudioFormat, FinishReason, ImageDetail, Modality, ResponseFormat, Role, ServiceTier,
|
||||
StopSequence,
|
||||
@@ -20,6 +29,17 @@ pub use shared::{
|
||||
pub use tool::{FunctionCall, OpenaiToolCall, OpenaiToolDefinition};
|
||||
pub use usage::{CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage};
|
||||
|
||||
// Re-export IR 内容块 / 消息类型供 `types::ContentBlock` 等历史路径消费。
|
||||
//
|
||||
// 注意:以下别名 *故意不暴露* `pub type Message = message::Message`、
|
||||
// `pub type ContentBlock = message::ContentBlock` —— 新 `Message` / `ContentBlock` /
|
||||
// `StopReason` 是独立类型,由 `Message` / `ContentBlock` / `StopReason` 直接路径访问,
|
||||
// 旧别名(指 `OpenaiChatMessage` / `OpenaiContentPart` / `FinishReason`)已移除,
|
||||
// 避免新类型阴影。Phase 2 完成后再统一收敛。
|
||||
|
||||
/// 旧 wire-format 请求类型别名(Phase 1 重写 Provider 后弃用)。
|
||||
pub type ChatRequest = OpenaiChatRequest;
|
||||
/// 旧 wire-format 响应结构(保留用于 OpenAI 内部转换层)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatResponse {
|
||||
pub message: OpenaiChatMessage,
|
||||
@@ -54,10 +74,13 @@ impl From<ChatResponse> for OpenaiChatChunk {
|
||||
};
|
||||
|
||||
OpenaiChatChunk {
|
||||
id: format!("chunk-{}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)),
|
||||
id: format!(
|
||||
"chunk-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
),
|
||||
object: "chat.completion.chunk".to_string(),
|
||||
created: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -71,8 +94,5 @@ impl From<ChatResponse> for OpenaiChatChunk {
|
||||
}
|
||||
}
|
||||
|
||||
pub type ChatRequest = OpenaiChatRequest;
|
||||
pub type Message = OpenaiChatMessage;
|
||||
pub type ContentBlock = OpenaiContentPart;
|
||||
/// 工具定义别名(无新类型冲突,保留)。
|
||||
pub type ToolDefinition = OpenaiToolDefinition;
|
||||
pub type StopReason = FinishReason;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! 旧版流式事件 —— Phase 0 临时保留,仅供 `stream.rs` 中 `parse_chunk_stream` 内部使用。
|
||||
//!
|
||||
//! Phase 0 中:高精度 `StreamEvent`(定义在 `response_v2.rs`)是唯一的对外
|
||||
//! `StreamEvent`,旧变体迁移至此模块改名为 `LegacyStreamEvent`,
|
||||
//! 由 `parse_chunk_stream()` 内部消费 `LegacyStreamEvent`,对外返回值已被
|
||||
//! 重映射为新 `StreamEvent`。
|
||||
//!
|
||||
//! Phase 1 重写 Provider 时,`parse_chunk_stream` 可直接消费新事件流后整体删除此文件。
|
||||
|
||||
use crate::llm::types::shared::FinishReason;
|
||||
use crate::llm::types::usage::Usage;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 旧 `StreamEvent` 变体迁移后的别名 —— 仅供 `stream.rs` 内部使用。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LegacyStreamEvent {
|
||||
/// 助手回复文本增量。
|
||||
AssistantTextDelta { text: String },
|
||||
/// 工具调用开始。
|
||||
ToolExecutionStarted {
|
||||
tool_name: String,
|
||||
input: Value,
|
||||
tool_call_id: String,
|
||||
},
|
||||
/// 工具调用完成。
|
||||
ToolExecutionCompleted {
|
||||
tool_name: String,
|
||||
output: Value,
|
||||
is_error: bool,
|
||||
},
|
||||
/// Token 用量更新。
|
||||
CostUpdate { usage: Usage },
|
||||
/// 一轮会话完成。
|
||||
TurnComplete { reason: FinishReason },
|
||||
/// 错误事件。
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
impl LegacyStreamEvent {
|
||||
pub(crate) fn error(message: impl Into<String>) -> Self {
|
||||
Self::Error {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use crate::llm::types::shared::{AudioFormat, ImageDetail};
|
||||
use crate::llm::types::tool::OpenaiToolCall;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ContentField {
|
||||
String(String),
|
||||
Array(Vec<OpenaiContentPart>),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ContentField {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
Value::String(s) => Ok(ContentField::String(s)),
|
||||
Value::Array(arr) => {
|
||||
let parts: Result<Vec<OpenaiContentPart>, _> =
|
||||
serde_json::from_value(Value::Array(arr));
|
||||
match parts {
|
||||
Ok(parts) => Ok(ContentField::Array(parts)),
|
||||
Err(e) => Err(serde::de::Error::custom(e)),
|
||||
}
|
||||
}
|
||||
_ => Err(serde::de::Error::custom("content must be string or array")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ContentField {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
ContentField::String(s) => s.serialize(serializer),
|
||||
ContentField::Array(arr) => arr.serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageURL {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<ImageDetail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InputAudio {
|
||||
pub data: String,
|
||||
pub format: AudioFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileData {
|
||||
pub file_data: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub filename: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
pub enum OpenaiContentPart {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Image {
|
||||
image_url: ImageURL,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<ImageDetail>,
|
||||
},
|
||||
InputAudio {
|
||||
input_audio: InputAudio,
|
||||
},
|
||||
File {
|
||||
file: FileData,
|
||||
},
|
||||
Refusal {
|
||||
refusal: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "role")]
|
||||
pub enum OpenaiChatMessage {
|
||||
Developer {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
System {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
User {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
},
|
||||
Assistant {
|
||||
content: ContentField,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
refusal: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<OpenaiToolCall>>,
|
||||
},
|
||||
Tool {
|
||||
content: ContentField,
|
||||
tool_call_id: String,
|
||||
},
|
||||
Function {
|
||||
content: ContentField,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl OpenaiChatMessage {
|
||||
pub fn user_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::User {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assistant_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::Assistant {
|
||||
content: ContentField::String(text.into()),
|
||||
refusal: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::System {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn developer_text<S: Into<String>>(text: S) -> Self {
|
||||
OpenaiChatMessage::Developer {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_result<S: Into<String>>(tool_call_id: String, content: S) -> Self {
|
||||
OpenaiChatMessage::Tool {
|
||||
content: ContentField::Array(vec![OpenaiContentPart::Text {
|
||||
text: content.into(),
|
||||
}]),
|
||||
tool_call_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,13 @@ pub enum ToolChoice {
|
||||
AllowedTools { tool_names: Vec<String> },
|
||||
}
|
||||
|
||||
impl Default for ToolChoice {
|
||||
fn default() -> Self {
|
||||
// Default 行为由调用方显式选择;此处选择不暴露任何工具 —— 不暴露比错暴露安全。
|
||||
ToolChoice::None
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ToolChoice {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
@@ -139,7 +146,7 @@ pub struct WebSearchOptions {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct OpenaiChatRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<crate::llm::types::message::OpenaiChatMessage>,
|
||||
pub messages: Vec<crate::llm::types::openai_message::OpenaiChatMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub frequency_penalty: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! IR 层 MessageRequest 类型 —— 跨 Provider 统一的请求模型。
|
||||
//!
|
||||
//! 设计目标见 `docs/10-llm-provider-refinement.md` §2.2 Decision-02 + `docs/9b-ir-type-system.md` §3.6。
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::llm::types::message::Message;
|
||||
use crate::llm::types::request::ToolChoice;
|
||||
use crate::llm::types::tool::OpenaiToolDefinition;
|
||||
|
||||
/// Provider 无关的请求类型。
|
||||
///
|
||||
/// 设计要点:
|
||||
/// - `system` 字段不存在;system 提示由调用方通过 `Message::System` 在 `messages` 中表达。
|
||||
/// - `tools` / `tool_choice` 直接复用现有 `OpenaiToolDefinition` / `ToolChoice`
|
||||
/// (10a §251 决策:先复用旧类型,Phase 2 切换为新 `ToolDefinition` 后再调整)。
|
||||
/// - `extra` 作为逃生舱:Provider 特定字段(`web_search_options`、`previous_response_id` 等)
|
||||
/// 通过 `extra.set_extra / get_extra` 传递,避免持续膨胀本结构体。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MessageRequest {
|
||||
/// 模型名称。
|
||||
pub model: String,
|
||||
/// 消息列表(包含 system / user / assistant / tool_result 等所有变体)。
|
||||
pub messages: Vec<Message>,
|
||||
/// 工具定义列表。
|
||||
pub tools: Vec<OpenaiToolDefinition>,
|
||||
/// 工具选择策略。
|
||||
pub tool_choice: ToolChoice,
|
||||
/// 最大输出 token 数。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// 采样温度。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
/// nucleus 采样 top-p。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// 终止序列。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub stop_sequences: Vec<String>,
|
||||
/// 是否流式响应(Provider 实现层读取)。
|
||||
#[serde(default)]
|
||||
pub stream: bool,
|
||||
/// 思考 / 推理配置(如 Anthropic thinking budget)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking: Option<ThinkingConfig>,
|
||||
/// Provider 特定扩展字段。
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// 思考 / 推理配置。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThinkingConfig {
|
||||
/// 思考预算 token 数。
|
||||
pub budget_tokens: u32,
|
||||
}
|
||||
|
||||
/// `extra` 字段访问错误。
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ExtraError {
|
||||
/// 反序列化到目标类型失败。
|
||||
#[error("extra 字段 `{key}` 反序列化失败: {details}")]
|
||||
TypeMismatch { key: String, details: String },
|
||||
/// 字段值反序列化为 JSON 失败。
|
||||
#[error("extra 反序列化失败: {0}")]
|
||||
Deserialize(String),
|
||||
}
|
||||
|
||||
impl MessageRequest {
|
||||
/// 读取一个 `extra` 字段,缺失返回 `Ok(None)`,类型不匹配返回 `Err(ExtraError::TypeMismatch)`。
|
||||
pub fn get_extra<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
key: &str,
|
||||
) -> Result<Option<T>, ExtraError> {
|
||||
match self.extra.get(key) {
|
||||
None => Ok(None),
|
||||
Some(value) => serde_json::from_value(value.clone())
|
||||
.map(Some)
|
||||
.map_err(|e| ExtraError::TypeMismatch {
|
||||
key: key.to_string(),
|
||||
details: e.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 `extra` 字段但用 `Option<T>`,跳过错误(缺失或反序列化失败均返回 `None`)。
|
||||
pub fn get_extra_opt<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Option<T> {
|
||||
self.get_extra::<T>(key).ok().flatten()
|
||||
}
|
||||
|
||||
/// 把整个 `extra` 整体反序列化为类型 `T`(用于"我有一个完整 options 结构"的场景)。
|
||||
pub fn get_extra_as<T: for<'de> Deserialize<'de>>(&self) -> Result<T, ExtraError> {
|
||||
let value = Value::Object(
|
||||
self.extra
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect::<serde_json::Map<String, Value>>(),
|
||||
);
|
||||
serde_json::from_value(value).map_err(|e| ExtraError::Deserialize(e.to_string()))
|
||||
}
|
||||
|
||||
/// 设置一个 `extra` 字段。
|
||||
pub fn set_extra(&mut self, key: impl Into<String>, value: impl Into<Value>) {
|
||||
self.extra.insert(key.into(), value.into());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn message_request_default_is_constructible() {
|
||||
let req = MessageRequest::default();
|
||||
assert_eq!(req.model, "");
|
||||
assert!(req.messages.is_empty());
|
||||
assert!(!req.stream);
|
||||
assert!(req.max_tokens.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_set_and_get_roundtrip() {
|
||||
let mut req = MessageRequest::default();
|
||||
req.set_extra(
|
||||
"previous_response_id",
|
||||
"resp_abc123",
|
||||
);
|
||||
|
||||
let v: Option<String> = req
|
||||
.get_extra("previous_response_id")
|
||||
.expect("get_extra ok");
|
||||
assert_eq!(v.as_deref(), Some("resp_abc123"));
|
||||
|
||||
let missing: Option<String> = req.get_extra("missing").expect("missing ok");
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_type_mismatch_returns_err() {
|
||||
let mut req = MessageRequest::default();
|
||||
req.set_extra("count", "not a number");
|
||||
let result: Result<Option<u32>, ExtraError> = req.get_extra("count");
|
||||
assert!(matches!(result, Err(ExtraError::TypeMismatch { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_opt_swallows_errors() {
|
||||
let mut req = MessageRequest::default();
|
||||
req.set_extra("count", "not a number");
|
||||
let v: Option<u32> = req.get_extra_opt("count");
|
||||
assert!(v.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_extra_as_deserializes_whole_extra() {
|
||||
let mut req = MessageRequest::default();
|
||||
req.set_extra("web_search_options", json!({"search_context_size": "high"}));
|
||||
req.set_extra("user", json!("u_123"));
|
||||
|
||||
#[derive(Deserialize, Debug, PartialEq)]
|
||||
struct Options {
|
||||
web_search_options: WebSearch,
|
||||
user: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize, Debug, PartialEq)]
|
||||
struct WebSearch {
|
||||
search_context_size: String,
|
||||
}
|
||||
|
||||
let opts: Options = req.get_extra_as().expect("get_extra_as ok");
|
||||
assert_eq!(
|
||||
opts.web_search_options.search_context_size,
|
||||
"high"
|
||||
);
|
||||
assert_eq!(opts.user.as_deref(), Some("u_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_request_json_roundtrip() {
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![Message::user_text("hello"), Message::assistant("hi")],
|
||||
tools: vec![],
|
||||
tool_choice: ToolChoice::Auto,
|
||||
max_tokens: Some(1024),
|
||||
temperature: Some(0.7),
|
||||
top_p: Some(0.9),
|
||||
stop_sequences: vec!["STOP".into()],
|
||||
stream: true,
|
||||
thinking: None,
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
req.set_extra("trace_id", json!("t-1"));
|
||||
|
||||
let json = serde_json::to_string(&req).expect("serialize");
|
||||
let decoded: MessageRequest = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(decoded.model, req.model);
|
||||
assert_eq!(decoded.messages.len(), req.messages.len());
|
||||
assert_eq!(decoded.max_tokens, req.max_tokens);
|
||||
assert_eq!(decoded.stream, req.stream);
|
||||
assert_eq!(decoded.extra.get("trace_id"), Some(&json!("t-1")));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::llm::types::message::OpenaiChatMessage;
|
||||
use crate::llm::types::openai_message::OpenaiChatMessage;
|
||||
use crate::llm::types::shared::{FinishReason, ServiceTier};
|
||||
use crate::llm::types::tool::OpenaiToolCall;
|
||||
use crate::llm::types::usage::Usage;
|
||||
|
||||
@@ -0,0 +1,849 @@
|
||||
//! IR 层 MessageResponse / StreamEvent / PartialMessageResponse —— 流式响应模型。
|
||||
//!
|
||||
//! 设计目标见 `docs/10-llm-provider-refinement.md` §2.3 Decision-03 +
|
||||
//! `docs/9c-llm-provider-trait.md` §4.3 / §4.4。
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
|
||||
use crate::llm::types::shared::FinishReason;
|
||||
use crate::llm::types::usage::{CompletionTokensDetails, PromptTokensDetails, Usage};
|
||||
|
||||
/// Provider 终止原因(统一枚举,对应各 Provider 的 finish_reason)。
|
||||
///
|
||||
/// 注意:与现有 `FinishReason` 在 `openai_message::shared` 模块中并存,
|
||||
/// Phase 2 完成时统一收敛。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StopReason {
|
||||
/// 自然停止。
|
||||
Stop,
|
||||
/// 达到长度上限。
|
||||
Length,
|
||||
/// 触发了工具调用。
|
||||
ToolUse,
|
||||
/// 内容安全过滤。
|
||||
ContentFilter,
|
||||
/// 达到 max_tokens 限制。
|
||||
MaxTokens,
|
||||
/// 命中停止序列。
|
||||
StopSequence,
|
||||
/// 其他 / 未知。
|
||||
Other,
|
||||
}
|
||||
|
||||
impl From<FinishReason> for StopReason {
|
||||
fn from(r: FinishReason) -> Self {
|
||||
match r {
|
||||
FinishReason::Stop => StopReason::Stop,
|
||||
FinishReason::Length => StopReason::Length,
|
||||
FinishReason::ToolCalls => StopReason::ToolUse,
|
||||
FinishReason::ContentFilter => StopReason::ContentFilter,
|
||||
FinishReason::FunctionCall => StopReason::ToolUse,
|
||||
FinishReason::Other => StopReason::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 完整响应。
|
||||
///
|
||||
/// `extra` 作为 Provider 特定字段(`system_fingerprint`、`service_tier` 等)逃生舱。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageResponse {
|
||||
/// 响应唯一 ID(如 OpenAI 的 `chatcmpl-xxx`)。
|
||||
pub id: String,
|
||||
/// 实际使用的模型名。
|
||||
pub model: String,
|
||||
/// Assistant 回复的消息。
|
||||
pub message: Message,
|
||||
/// Token 用量。
|
||||
pub usage: Usage,
|
||||
/// 终止原因。
|
||||
pub stop_reason: StopReason,
|
||||
/// Provider 特定扩展字段。
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl MessageResponse {
|
||||
/// 提取 Assistant 回复中的全部纯文本(按出现顺序拼接)。
|
||||
///
|
||||
/// 仅在 `message` 是 `Assistant` 时有意义,其它变体返回空串。
|
||||
pub fn text(&self) -> String {
|
||||
match &self.message {
|
||||
Message::Assistant { content } => content
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式用量 —— 字段为 `Option` 是因为流式场景中各字段可能分多次到达
|
||||
///(如 Anthropic 的 `message_delta` 事件可多次下发 usage 增量)。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PartialUsage {
|
||||
/// 输入 token 数。
|
||||
pub prompt_tokens: Option<u32>,
|
||||
/// 输出 token 数。
|
||||
pub completion_tokens: Option<u32>,
|
||||
/// 总 token 数。
|
||||
pub total_tokens: Option<u32>,
|
||||
/// 输出 token 明细(含 reasoning_tokens 等)。
|
||||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
/// 输入 token 明细(含 cached_tokens 等)。
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
|
||||
impl PartialUsage {
|
||||
/// 字段级合并 `other` 到 `self`:`other` 中 `Some` 字段覆盖 `self`。
|
||||
pub fn merge_from(&mut self, other: &PartialUsage) {
|
||||
if let Some(v) = other.prompt_tokens {
|
||||
self.prompt_tokens = Some(v);
|
||||
}
|
||||
if let Some(v) = other.completion_tokens {
|
||||
self.completion_tokens = Some(v);
|
||||
}
|
||||
if let Some(v) = other.total_tokens {
|
||||
self.total_tokens = Some(v);
|
||||
}
|
||||
if let Some(v) = other.completion_tokens_details {
|
||||
self.completion_tokens_details = Some(v);
|
||||
}
|
||||
if let Some(v) = other.prompt_tokens_details {
|
||||
self.prompt_tokens_details = Some(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为完整的 `Usage`,缺失字段默认为 0。
|
||||
pub fn into_usage(self) -> Usage {
|
||||
Usage {
|
||||
prompt_tokens: self.prompt_tokens.unwrap_or(0),
|
||||
completion_tokens: self.completion_tokens.unwrap_or(0),
|
||||
total_tokens: self.total_tokens.unwrap_or(0),
|
||||
completion_tokens_details: self.completion_tokens_details,
|
||||
prompt_tokens_details: self.prompt_tokens_details,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容块构建器 —— 在流式累积阶段持有单 block 的原始拼接状态。
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentBlockBuilder {
|
||||
/// 文本块(按增量拼接)。
|
||||
Text(String),
|
||||
/// 思考块(拼接中可携带 signature)。
|
||||
Thinking {
|
||||
buffer: String,
|
||||
signature: Option<String>,
|
||||
},
|
||||
/// 拒绝 / 政策阻断(OpenAI refusal)。
|
||||
Refusal(String),
|
||||
/// 工具调用块(id / name 来自 `ContentBlockStart`,arguments 按增量拼接)。
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 流式事件 —— LLM 流式响应的语义化事件。
|
||||
///
|
||||
/// 设计变更(参考 `docs/10-llm-provider-refinement.md` §2.3):
|
||||
/// `MessageComplete` 仅携带 `full_response: MessageResponse`,`stop_reason` 和
|
||||
/// `thinking_signature` 不再作为顶层冗余字段。Provider 在内部维护
|
||||
/// `thinking_signature`,最终通过 `finalize()` 回填到 `full_response` 的 `Thinking` block 中。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StreamEvent {
|
||||
/// 消息开始(元信息)。
|
||||
MessageStart { id: String, model: String },
|
||||
/// 内容块开始(告知块类型,携带 id/name for ToolUse)。
|
||||
ContentBlockStart { index: u32, block_type: ContentBlockType },
|
||||
/// 内容块结束标记。
|
||||
ContentBlockEnd { index: u32 },
|
||||
/// 文本增量。
|
||||
TextDelta { text: String },
|
||||
/// 思考 / 推理增量。
|
||||
ThinkingDelta { text: String },
|
||||
/// 拒绝 / 政策阻断增量。
|
||||
RefusalDelta { text: String },
|
||||
/// 工具调用参数增量(按 index 区分并行调用)。
|
||||
ToolCallArgumentsDelta { index: u32, arguments: String },
|
||||
/// 工具调用结束标记。
|
||||
ToolCallEnd { index: u32 },
|
||||
/// 用量更新(字段级合并)。
|
||||
CostUpdate { usage: PartialUsage },
|
||||
/// 消息完成 —— 唯一可靠的完整响应来源。
|
||||
MessageComplete { full_response: MessageResponse },
|
||||
/// 错误事件。
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// 流式响应累积状态。
|
||||
///
|
||||
/// Provider 的流处理循环逐个消费 `StreamEvent`、调用 `apply_to` 更新此结构,
|
||||
/// 在收到 `MessageComplete` 后调用 `finalize()` 得到完整 `MessageResponse`。
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct PartialMessageResponse {
|
||||
/// 响应 ID(来自 `MessageStart`)。
|
||||
pub id: Option<String>,
|
||||
/// 模型名(来自 `MessageStart`)。
|
||||
pub model: Option<String>,
|
||||
/// 按 index 索引的内容块构建器。
|
||||
pub blocks: BTreeMap<u32, ContentBlockBuilder>,
|
||||
/// 已标记"完成"(收到 `ContentBlockEnd` 或 `ToolCallEnd`)的 block index。
|
||||
pub block_completion: HashSet<u32>,
|
||||
/// 当前最后一个未结束的 block index(用于 TextDelta / ThinkingDelta 等定位目标 block)。
|
||||
pub last_open_index: Option<u32>,
|
||||
/// 流式累积用量。
|
||||
pub usage: PartialUsage,
|
||||
/// 终止原因(从 Provider 的终止事件推断)。
|
||||
pub stop_reason: Option<StopReason>,
|
||||
/// 思考签名(Anthropic provider 直接通过 `set_thinking_signature` 设置,不经过事件层)。
|
||||
pub thinking_signature: Option<String>,
|
||||
/// 是否遇到错误事件。
|
||||
pub is_errored: bool,
|
||||
/// 是否收到完成事件。
|
||||
pub is_complete: bool,
|
||||
}
|
||||
|
||||
impl PartialMessageResponse {
|
||||
/// 创建一个新的空累积状态。
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 由 Provider 直接写入 Anthropic 的 thinking signature。
|
||||
///
|
||||
/// 详见 `docs/10-llm-provider-refinement.md` §2.3 — `thinking_signature`
|
||||
/// 不经过事件层,由 Provider 流循环直接写入此内部状态。
|
||||
pub fn set_thinking_signature(&mut self, signature: impl Into<String>) {
|
||||
self.thinking_signature = Some(signature.into());
|
||||
}
|
||||
|
||||
/// 应用一个流事件到累积状态。
|
||||
///
|
||||
/// 返回 `false` 表示遇到 `Error` 事件,调用方应终止流处理。
|
||||
pub fn apply_to(&mut self, event: &StreamEvent) -> bool {
|
||||
match event {
|
||||
StreamEvent::MessageStart { id, model } => {
|
||||
self.id = Some(id.clone());
|
||||
self.model = Some(model.clone());
|
||||
true
|
||||
}
|
||||
StreamEvent::ContentBlockStart { index, block_type } => {
|
||||
let builder = match block_type {
|
||||
ContentBlockType::Text => ContentBlockBuilder::Text(String::new()),
|
||||
ContentBlockType::Thinking => ContentBlockBuilder::Thinking {
|
||||
buffer: String::new(),
|
||||
signature: None,
|
||||
},
|
||||
ContentBlockType::Refusal => ContentBlockBuilder::Refusal(String::new()),
|
||||
ContentBlockType::ToolUse { id, name } => ContentBlockBuilder::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
arguments: String::new(),
|
||||
},
|
||||
};
|
||||
self.blocks.insert(*index, builder);
|
||||
self.last_open_index = Some(*index);
|
||||
true
|
||||
}
|
||||
StreamEvent::ContentBlockEnd { index } => {
|
||||
self.block_completion.insert(*index);
|
||||
if self.last_open_index == Some(*index) {
|
||||
self.last_open_index = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
StreamEvent::TextDelta { text } => {
|
||||
if let Some(idx) = self.last_open_index
|
||||
&& let Some(builder) = self.blocks.get_mut(&idx)
|
||||
{
|
||||
match builder {
|
||||
ContentBlockBuilder::Text(buf) => buf.push_str(text),
|
||||
ContentBlockBuilder::Thinking { buffer, .. } => buffer.push_str(text),
|
||||
ContentBlockBuilder::Refusal(buf) => buf.push_str(text),
|
||||
ContentBlockBuilder::ToolUse { arguments, .. } => {
|
||||
arguments.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
StreamEvent::ThinkingDelta { text } => {
|
||||
if let Some(idx) = self.last_open_index
|
||||
&& let Some(builder) = self.blocks.get_mut(&idx)
|
||||
&& let ContentBlockBuilder::Thinking { buffer, .. } = builder
|
||||
{
|
||||
buffer.push_str(text);
|
||||
}
|
||||
true
|
||||
}
|
||||
StreamEvent::RefusalDelta { text } => {
|
||||
if let Some(idx) = self.last_open_index
|
||||
&& let Some(builder) = self.blocks.get_mut(&idx)
|
||||
&& let ContentBlockBuilder::Refusal(buf) = builder
|
||||
{
|
||||
buf.push_str(text);
|
||||
}
|
||||
true
|
||||
}
|
||||
StreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
if let Some(ContentBlockBuilder::ToolUse {
|
||||
arguments: buf, ..
|
||||
}) = self.blocks.get_mut(index)
|
||||
{
|
||||
buf.push_str(arguments);
|
||||
}
|
||||
true
|
||||
}
|
||||
StreamEvent::ToolCallEnd { index } => {
|
||||
self.block_completion.insert(*index);
|
||||
if self.last_open_index == Some(*index) {
|
||||
self.last_open_index = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
StreamEvent::CostUpdate { usage } => {
|
||||
self.usage.merge_from(usage);
|
||||
true
|
||||
}
|
||||
StreamEvent::MessageComplete { .. } => {
|
||||
self.is_complete = true;
|
||||
true
|
||||
}
|
||||
StreamEvent::Error { message } => {
|
||||
tracing::warn!(error = %message, "partial response received Error event");
|
||||
self.is_errored = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成累积,构造完整 `MessageResponse`。
|
||||
///
|
||||
/// - 按 index 升序遍历 `blocks` 转换为 `ContentBlock`
|
||||
/// - 缺失字段用 `self.thinking_signature` 回填
|
||||
/// - 终止原因默认 `Stop`,除非上层显式设置
|
||||
pub fn finalize(self) -> Result<MessageResponse, LlmError> {
|
||||
let mut content_blocks = Vec::with_capacity(self.blocks.len());
|
||||
for (idx, builder) in self.blocks {
|
||||
let block = Self::builder_to_block(idx, builder, self.thinking_signature.as_deref())
|
||||
.map_err(|e| LlmError::Other(format!(
|
||||
"partial 块 #{idx} finalize 失败: {e}"
|
||||
)))?;
|
||||
content_blocks.push(block);
|
||||
}
|
||||
|
||||
let usage = self.usage.into_usage();
|
||||
let stop_reason = self.stop_reason.unwrap_or(StopReason::Stop);
|
||||
|
||||
let message = Message::Assistant {
|
||||
content: content_blocks,
|
||||
};
|
||||
|
||||
Ok(MessageResponse {
|
||||
id: self.id.unwrap_or_default(),
|
||||
model: self.model.unwrap_or_default(),
|
||||
message,
|
||||
usage,
|
||||
stop_reason,
|
||||
extra: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn builder_to_block(
|
||||
_idx: u32,
|
||||
builder: ContentBlockBuilder,
|
||||
fallback_signature: Option<&str>,
|
||||
) -> Result<ContentBlock, String> {
|
||||
match builder {
|
||||
ContentBlockBuilder::Text(buf) => Ok(ContentBlock::Text { text: buf }),
|
||||
ContentBlockBuilder::Thinking { buffer, signature } => {
|
||||
let signature = signature.or_else(|| fallback_signature.map(str::to_string));
|
||||
Ok(ContentBlock::Thinking {
|
||||
text: buffer,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
ContentBlockBuilder::Refusal(buf) => Ok(ContentBlock::Text { text: buf }),
|
||||
ContentBlockBuilder::ToolUse {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
} => {
|
||||
let input: Value = serde_json::from_str(&arguments).unwrap_or(Value::Null);
|
||||
Ok(ContentBlock::ToolUse { id, name, input })
|
||||
}
|
||||
}
|
||||
.map_err(|_: std::convert::Infallible| unreachable!("builder_to_block is total"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn empty_response() -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: "resp-1".into(),
|
||||
model: "m".into(),
|
||||
message: Message::Assistant { content: vec![] },
|
||||
usage: Usage::default(),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_reason_from_finish_reason_mapping() {
|
||||
assert_eq!(StopReason::from(FinishReason::Stop), StopReason::Stop);
|
||||
assert_eq!(
|
||||
StopReason::from(FinishReason::ToolCalls),
|
||||
StopReason::ToolUse
|
||||
);
|
||||
assert_eq!(
|
||||
StopReason::from(FinishReason::FunctionCall),
|
||||
StopReason::ToolUse
|
||||
);
|
||||
assert_eq!(StopReason::from(FinishReason::Length), StopReason::Length);
|
||||
assert_eq!(StopReason::from(FinishReason::Other), StopReason::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_response_text_extracts_assistant_text() {
|
||||
let resp = MessageResponse {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
message: Message::Assistant {
|
||||
content: vec![
|
||||
ContentBlock::Thinking {
|
||||
text: "reasoning".into(),
|
||||
signature: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "hello ".into(),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "world".into(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "c".into(),
|
||||
name: "search".into(),
|
||||
input: json!({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
usage: Usage::default(),
|
||||
stop_reason: StopReason::ToolUse,
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
assert_eq!(resp.text(), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_response_text_empty_when_not_assistant() {
|
||||
let resp = MessageResponse {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
message: Message::user_text("hi"),
|
||||
usage: Usage::default(),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
assert_eq!(resp.text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_usage_merge_picks_some_fields() {
|
||||
let mut a = PartialUsage {
|
||||
prompt_tokens: Some(10),
|
||||
completion_tokens: None,
|
||||
..Default::default()
|
||||
};
|
||||
let b = PartialUsage {
|
||||
prompt_tokens: Some(99),
|
||||
completion_tokens: Some(20),
|
||||
total_tokens: Some(30),
|
||||
..Default::default()
|
||||
};
|
||||
a.merge_from(&b);
|
||||
assert_eq!(a.prompt_tokens, Some(99));
|
||||
assert_eq!(a.completion_tokens, Some(20));
|
||||
assert_eq!(a.total_tokens, Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_usage_into_usage_defaults_missing() {
|
||||
let u = PartialUsage {
|
||||
completion_tokens: Some(7),
|
||||
..Default::default()
|
||||
}
|
||||
.into_usage();
|
||||
assert_eq!(u.prompt_tokens, 0);
|
||||
assert_eq!(u.completion_tokens, 7);
|
||||
assert_eq!(u.total_tokens, 0);
|
||||
}
|
||||
|
||||
// ===== apply_to / finalize 整合测试 =====
|
||||
|
||||
fn apply_seq(state: &mut PartialMessageResponse, events: &[StreamEvent]) -> bool {
|
||||
let mut cont = true;
|
||||
for ev in events {
|
||||
cont = state.apply_to(ev);
|
||||
if !cont {
|
||||
break;
|
||||
}
|
||||
}
|
||||
cont
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_message_start_then_finalize() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[StreamEvent::MessageStart {
|
||||
id: "r1".into(),
|
||||
model: "gpt".into(),
|
||||
}],
|
||||
));
|
||||
let resp = state.finalize().expect("ok");
|
||||
assert_eq!(resp.id, "r1");
|
||||
assert_eq!(resp.model, "gpt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_text_block_then_finalize() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::TextDelta {
|
||||
text: "hello".into(),
|
||||
},
|
||||
StreamEvent::TextDelta {
|
||||
text: " world".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
},
|
||||
],
|
||||
));
|
||||
assert!(state.is_complete);
|
||||
let resp = state.finalize().expect("ok");
|
||||
match &resp.message {
|
||||
Message::Assistant { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
match &content[0] {
|
||||
ContentBlock::Text { text } => assert_eq!(text, "hello world"),
|
||||
_ => panic!("expected Text block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_tool_use_block_then_finalize() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::ToolUse {
|
||||
id: "call_x".into(),
|
||||
name: "search".into(),
|
||||
},
|
||||
},
|
||||
StreamEvent::ToolCallArgumentsDelta {
|
||||
index: 0,
|
||||
arguments: r#"{"q":"rust"}"#.into(),
|
||||
},
|
||||
StreamEvent::ToolCallEnd { index: 0 },
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
},
|
||||
],
|
||||
));
|
||||
let resp = state.finalize().expect("ok");
|
||||
match &resp.message {
|
||||
Message::Assistant { content } => match &content[0] {
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
assert_eq!(id, "call_x");
|
||||
assert_eq!(name, "search");
|
||||
assert_eq!(input, &json!({"q": "rust"}));
|
||||
}
|
||||
_ => panic!("expected ToolUse"),
|
||||
},
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_thinking_block_uses_fallback_signature() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
state.set_thinking_signature("global-sig");
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::Thinking,
|
||||
},
|
||||
StreamEvent::ThinkingDelta {
|
||||
text: "thinking...".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
},
|
||||
],
|
||||
));
|
||||
let resp = state.finalize().unwrap();
|
||||
match &resp.message {
|
||||
Message::Assistant { content } => match &content[0] {
|
||||
ContentBlock::Thinking { text, signature } => {
|
||||
assert_eq!(text, "thinking...");
|
||||
assert_eq!(signature.as_deref(), Some("global-sig"));
|
||||
}
|
||||
_ => panic!("expected Thinking"),
|
||||
},
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_cost_update_merges_across_events() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::CostUpdate {
|
||||
usage: PartialUsage {
|
||||
prompt_tokens: Some(100),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
StreamEvent::CostUpdate {
|
||||
usage: PartialUsage {
|
||||
completion_tokens: Some(50),
|
||||
total_tokens: Some(150),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
},
|
||||
],
|
||||
));
|
||||
assert_eq!(state.usage.prompt_tokens, Some(100));
|
||||
assert_eq!(state.usage.completion_tokens, Some(50));
|
||||
assert_eq!(state.usage.total_tokens, Some(150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_error_event_returns_false_and_marks_errored() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
let cont = state.apply_to(&StreamEvent::Error {
|
||||
message: "boom".into(),
|
||||
});
|
||||
assert!(!cont);
|
||||
assert!(state.is_errored);
|
||||
// finalize 仍可调用,错误信息透传由 Provider 流循环处理
|
||||
let resp = state.finalize().expect("ok even after error");
|
||||
assert_eq!(resp.stop_reason, StopReason::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_block_ordering_by_index() {
|
||||
let mut state = PartialMessageResponse::new();
|
||||
// 故意以 index 3 优先于 index 0 提供,验证 finalize 按 index 升序输出
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 3,
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::TextDelta {
|
||||
text: "second".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 3 },
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::TextDelta {
|
||||
text: "first".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
],
|
||||
));
|
||||
let resp = state.finalize().unwrap();
|
||||
match &resp.message {
|
||||
Message::Assistant { content } => {
|
||||
assert_eq!(content.len(), 2);
|
||||
match (&content[0], &content[1]) {
|
||||
(
|
||||
ContentBlock::Text { text: t1 },
|
||||
ContentBlock::Text { text: t2 },
|
||||
) => {
|
||||
assert_eq!(t1, "first");
|
||||
assert_eq!(t2, "second");
|
||||
}
|
||||
_ => panic!("expected Text blocks"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_complete_does_not_double_emit_in_finalize() {
|
||||
// MessageComplete 仅作完成标记,不修改 blocks
|
||||
let mut state = PartialMessageResponse::new();
|
||||
assert!(apply_seq(
|
||||
&mut state,
|
||||
&[
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::TextDelta {
|
||||
text: "x".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
},
|
||||
],
|
||||
));
|
||||
assert!(state.is_complete);
|
||||
let resp = state.finalize().unwrap();
|
||||
match &resp.message {
|
||||
Message::Assistant { content } => assert_eq!(content.len(), 1),
|
||||
_ => panic!("expected Assistant"),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== JSON roundtrip 测试 =====
|
||||
|
||||
#[test]
|
||||
fn message_response_json_roundtrip() {
|
||||
let resp = MessageResponse {
|
||||
id: "resp-99".into(),
|
||||
model: "gpt-4o".into(),
|
||||
message: Message::Assistant {
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "hello".into(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "c".into(),
|
||||
name: "f".into(),
|
||||
input: json!({"a": 1}),
|
||||
},
|
||||
],
|
||||
},
|
||||
usage: Usage::from_input_output(10, 20),
|
||||
stop_reason: StopReason::ToolUse,
|
||||
extra: HashMap::from([("trace".to_string(), json!("t-1"))]),
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let decoded: MessageResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.id, resp.id);
|
||||
assert_eq!(decoded.stop_reason, resp.stop_reason);
|
||||
assert_eq!(decoded.usage.prompt_tokens, 10);
|
||||
assert_eq!(decoded.usage.completion_tokens, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_event_json_roundtrip_each_variant() {
|
||||
let events = vec![
|
||||
StreamEvent::MessageStart {
|
||||
id: "r".into(),
|
||||
model: "m".into(),
|
||||
},
|
||||
StreamEvent::ContentBlockStart {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
StreamEvent::TextDelta {
|
||||
text: "t".into(),
|
||||
},
|
||||
StreamEvent::ThinkingDelta {
|
||||
text: "p".into(),
|
||||
},
|
||||
StreamEvent::RefusalDelta {
|
||||
text: "r".into(),
|
||||
},
|
||||
StreamEvent::ToolCallArgumentsDelta {
|
||||
index: 1,
|
||||
arguments: "{\"x\":1}".into(),
|
||||
},
|
||||
StreamEvent::ToolCallEnd { index: 1 },
|
||||
StreamEvent::CostUpdate {
|
||||
usage: PartialUsage {
|
||||
prompt_tokens: Some(1),
|
||||
completion_tokens: Some(2),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
},
|
||||
StreamEvent::Error {
|
||||
message: "boom".into(),
|
||||
},
|
||||
];
|
||||
for original in events {
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let decoded: StreamEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(format!("{:?}", original), format!("{:?}", decoded));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct OpenaiToolDefinition {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub parameters: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strict: Option<bool>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::llm::types::message::{ContentField, OpenaiChatMessage, OpenaiContentPart};
|
||||
use crate::llm::types::openai_message::{ContentField, OpenaiChatMessage, OpenaiContentPart};
|
||||
use crate::llm::types::request::OpenaiChatRequest;
|
||||
use crate::prompt::error::PromptError;
|
||||
use crate::prompt::template::{PromptTemplate, TemplateContext};
|
||||
|
||||
Reference in New Issue
Block a user