1. 删除缓存诊断日志 4 处(openai.rs):排查已完结,字段已补齐,每次 LLM 调用都刷屏。 2. 删除 AgentPromptProvider 模型配置日志(agent_prompt_provider.rs):排查遗留,每次请求刷。 3. AgentFactory 创建日志 info→debug(agent_factory.rs):每轮对话刷,降级保留供偶发排查。 4. Calling tool / Tool calls detected info→debug(agent_loop.rs):每次工具调用都刷,全量参数打印开销大,tracing 在未启用级别时不评估字段。 5. LoadTaskMessages 3 条日志 info→debug(load_task_messages.rs):每次前端 trigger 都刷,一次 3 条。 6. 旧结果诊断日志 info→debug(execution.rs):每条消息首次 agent 迭代都触发。 7. pending subagents 日志 info→debug(processor.rs):子代理运行期高频率重复触发。 8. Updating current_session_id 仅变化时打(ws.rs):old==new 时每条消息都刷,无信息量。 编译通过,测试全绿。
1947 lines
73 KiB
Rust
1947 lines
73 KiB
Rust
use async_trait::async_trait;
|
||
use futures_util::StreamExt;
|
||
use reqwest::Client;
|
||
use serde::Deserialize;
|
||
use serde_json::{Value, json};
|
||
use std::collections::BTreeMap;
|
||
use std::collections::HashMap;
|
||
use std::time::Duration;
|
||
|
||
use super::traits::{StreamCallback, StreamDelta, Usage};
|
||
use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, ToolCall};
|
||
use crate::domain::messages::ContentBlock;
|
||
use crate::utils::format_error_chain;
|
||
|
||
const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &[
|
||
"tool_call_arguments_json",
|
||
"mock_response_content",
|
||
"supported_content_types",
|
||
];
|
||
|
||
/// 流式响应中的工具调用增量
|
||
#[derive(Debug, Default)]
|
||
struct StreamingToolCall {
|
||
id: String,
|
||
name: String,
|
||
arguments: String,
|
||
}
|
||
|
||
/// 流式响应累积器
|
||
#[derive(Debug, Default)]
|
||
struct StreamingAccumulator {
|
||
content: String,
|
||
reasoning_content: Option<String>,
|
||
tool_calls: BTreeMap<usize, StreamingToolCall>,
|
||
response_id: String,
|
||
/// 流式末帧返回的 usage(需要 stream_options.include_usage=true)
|
||
usage: Option<OpenAIUsage>,
|
||
}
|
||
|
||
impl StreamingAccumulator {
|
||
fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
/// 添加内容增量
|
||
fn add_content(&mut self, delta: &str) {
|
||
self.content.push_str(delta);
|
||
}
|
||
|
||
/// 添加推理内容增量
|
||
fn add_reasoning_content(&mut self, delta: &str) {
|
||
if self.reasoning_content.is_none() {
|
||
self.reasoning_content = Some(String::new());
|
||
}
|
||
self.reasoning_content.as_mut().unwrap().push_str(delta);
|
||
}
|
||
|
||
/// 添加工具调用增量
|
||
fn add_tool_call(
|
||
&mut self,
|
||
index: usize,
|
||
id: Option<&str>,
|
||
name: Option<&str>,
|
||
arguments: Option<&str>,
|
||
) {
|
||
let entry = self
|
||
.tool_calls
|
||
.entry(index)
|
||
.or_insert_with(StreamingToolCall::default);
|
||
|
||
// 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值
|
||
if let Some(id) = id {
|
||
if !id.is_empty() {
|
||
entry.id = id.to_string();
|
||
}
|
||
}
|
||
// 只在 name 非空时才更新,防止流式响应中后续 chunk 的 None 覆盖之前的值
|
||
if let Some(name) = name {
|
||
if !name.is_empty() {
|
||
entry.name = name.to_string();
|
||
}
|
||
}
|
||
if let Some(args) = arguments {
|
||
entry.arguments.push_str(args);
|
||
}
|
||
}
|
||
|
||
/// 设置响应 ID
|
||
fn set_response_id(&mut self, id: String) {
|
||
if self.response_id.is_empty() {
|
||
self.response_id = id;
|
||
}
|
||
}
|
||
|
||
/// 设置 usage(来自流式末帧的 usage 字段)
|
||
/// 跳过 total_tokens=0 的占位帧,避免覆盖真实值。
|
||
fn set_usage(&mut self, usage: OpenAIUsage) {
|
||
if usage.total_tokens > 0 {
|
||
self.usage = Some(usage);
|
||
}
|
||
}
|
||
|
||
/// 构建最终的 ChatCompletionResponse
|
||
fn build_response(self, model: String) -> ChatCompletionResponse {
|
||
let tool_calls: Vec<ToolCall> = self
|
||
.tool_calls
|
||
.into_iter()
|
||
.filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty())
|
||
.map(|(_, call)| {
|
||
let arguments = serde_json::from_str(&call.arguments)
|
||
.unwrap_or_else(|_| serde_json::Value::Null);
|
||
ToolCall {
|
||
id: call.id,
|
||
name: call.name,
|
||
arguments,
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
ChatCompletionResponse {
|
||
id: if self.response_id.is_empty() {
|
||
format!("stream-{}", uuid::Uuid::new_v4())
|
||
} else {
|
||
self.response_id
|
||
},
|
||
model,
|
||
content: self.content,
|
||
reasoning_content: self.reasoning_content,
|
||
tool_calls,
|
||
usage: self
|
||
.usage
|
||
.clone()
|
||
.map(|u| Usage {
|
||
prompt_tokens: u.prompt_tokens,
|
||
completion_tokens: u.completion_tokens,
|
||
total_tokens: u.total_tokens,
|
||
cached_tokens: u.cached_tokens(),
|
||
})
|
||
.unwrap_or(Usage {
|
||
prompt_tokens: 0,
|
||
completion_tokens: 0,
|
||
total_tokens: 0,
|
||
cached_tokens: 0,
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn format_transport_error_context(
|
||
provider_name: &str,
|
||
model_id: &str,
|
||
url: &str,
|
||
timeout_secs: u64,
|
||
error: &(dyn std::error::Error + 'static),
|
||
) -> String {
|
||
format!(
|
||
"transport error: provider={} model={} url={} timeout_secs={} details={}",
|
||
provider_name,
|
||
model_id,
|
||
url,
|
||
timeout_secs,
|
||
format_error_chain(error)
|
||
)
|
||
}
|
||
|
||
fn convert_content_blocks(
|
||
supports_images: bool,
|
||
provider_name: &str,
|
||
model_id: &str,
|
||
blocks: &[ContentBlock],
|
||
message_idx: usize,
|
||
) -> Value {
|
||
// 检查是否有图片且模型不支持
|
||
if !supports_images {
|
||
let has_images = blocks
|
||
.iter()
|
||
.any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
|
||
|
||
if has_images {
|
||
let image_count = blocks
|
||
.iter()
|
||
.filter(|b| matches!(b, ContentBlock::ImageUrl { .. }))
|
||
.count();
|
||
|
||
tracing::warn!(
|
||
provider = %provider_name,
|
||
model = %model_id,
|
||
filtered_images = image_count,
|
||
message_idx,
|
||
"模型不支持图片;将图片转换为通知文本"
|
||
);
|
||
|
||
// 复用通知格式,将图片转换为文本通知
|
||
let mut converted_blocks: Vec<Value> = Vec::new();
|
||
let mut notices: Vec<String> = Vec::new();
|
||
let mut image_idx = 0;
|
||
|
||
for block in blocks.iter() {
|
||
match block {
|
||
ContentBlock::Text { text } => {
|
||
converted_blocks.push(json!({ "type": "text", "text": text }));
|
||
}
|
||
ContentBlock::ImageUrl { .. } => {
|
||
image_idx += 1;
|
||
notices.push(format!(
|
||
"- 第 {} 张图片:当前模型不支持图片输入,该图片未能成功入模,请直接告知用户。",
|
||
image_idx
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 添加通知文本块
|
||
if !notices.is_empty() {
|
||
let notice_text =
|
||
format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n"));
|
||
converted_blocks.push(json!({ "type": "text", "text": notice_text }));
|
||
}
|
||
|
||
// 如果只有一个文本块且没有通知,返回字符串形式
|
||
if converted_blocks.len() == 1 {
|
||
if let Some(block) = converted_blocks.first() {
|
||
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||
return Value::String(text.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return Value::Array(converted_blocks);
|
||
}
|
||
}
|
||
|
||
// 原有逻辑 - 模型支持图片,正常转换
|
||
if blocks.len() == 1 {
|
||
if let ContentBlock::Text { text } = &blocks[0] {
|
||
return Value::String(text.clone());
|
||
}
|
||
}
|
||
Value::Array(
|
||
blocks
|
||
.iter()
|
||
.map(|b| match b {
|
||
ContentBlock::Text { text } => json!({ "type": "text", "text": text }),
|
||
ContentBlock::ImageUrl { image_url } => {
|
||
json!({ "type": "image_url", "image_url": { "url": image_url.url } })
|
||
}
|
||
})
|
||
.collect(),
|
||
)
|
||
}
|
||
|
||
pub struct OpenAIProvider {
|
||
client: Client,
|
||
name: String,
|
||
api_key: String,
|
||
base_url: String,
|
||
extra_headers: HashMap<String, String>,
|
||
llm_timeout_secs: u64,
|
||
model_id: String,
|
||
temperature: Option<f32>,
|
||
max_tokens: Option<u32>,
|
||
model_extra: HashMap<String, serde_json::Value>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
#[serde(untagged)]
|
||
enum OAIFunctionArguments {
|
||
Json(Value),
|
||
String(String),
|
||
}
|
||
|
||
impl OpenAIProvider {
|
||
pub fn new(
|
||
name: String,
|
||
api_key: String,
|
||
base_url: String,
|
||
extra_headers: HashMap<String, String>,
|
||
llm_timeout_secs: u64,
|
||
model_id: String,
|
||
temperature: Option<f32>,
|
||
max_tokens: Option<u32>,
|
||
model_extra: HashMap<String, serde_json::Value>,
|
||
) -> Self {
|
||
let client = Client::builder()
|
||
.timeout(Duration::from_secs(llm_timeout_secs))
|
||
.build()
|
||
.unwrap_or_else(|_| Client::new());
|
||
|
||
// 兼容带末尾斜杠的 base_url(如 https://opencode.ai/zen/go/v1/),
|
||
// 否则 format!("{}/chat/completions", base_url) 会产生双斜杠导致 404
|
||
let base_url = base_url.trim_end_matches('/').to_string();
|
||
|
||
Self {
|
||
client,
|
||
name,
|
||
api_key,
|
||
base_url,
|
||
extra_headers,
|
||
llm_timeout_secs,
|
||
model_id,
|
||
temperature,
|
||
max_tokens,
|
||
model_extra,
|
||
}
|
||
}
|
||
|
||
fn uses_json_tool_arguments(&self) -> bool {
|
||
self.model_extra
|
||
.get("tool_call_arguments_json")
|
||
.and_then(|value| value.as_bool())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// 检查是否启用流式输出,默认启用
|
||
fn is_streaming_enabled(&self) -> bool {
|
||
self.model_extra
|
||
.get("enable_streaming")
|
||
.and_then(|value| value.as_bool())
|
||
.unwrap_or(true)
|
||
}
|
||
|
||
/// 检查模型是否支持指定内容类型
|
||
/// 默认支持所有类型(text, image)
|
||
fn supports_content_type(&self, content_type: &str) -> bool {
|
||
self.model_extra
|
||
.get("supported_content_types")
|
||
.and_then(|value| value.as_array())
|
||
.map(|types| types.iter().any(|t| t.as_str() == Some(content_type)))
|
||
.unwrap_or(true)
|
||
}
|
||
|
||
/// 检查模型是否支持图片
|
||
fn supports_images(&self) -> bool {
|
||
self.supports_content_type("image")
|
||
}
|
||
|
||
fn normalize_tool_arguments(&self, arguments: &Value) -> Value {
|
||
match arguments {
|
||
Value::String(raw) => serde_json::from_str(raw).unwrap_or_else(|_| arguments.clone()),
|
||
_ => arguments.clone(),
|
||
}
|
||
}
|
||
|
||
fn serialize_tool_arguments(&self, arguments: &Value) -> Value {
|
||
let normalized = self.normalize_tool_arguments(arguments);
|
||
|
||
if self.uses_json_tool_arguments() {
|
||
// Model expects JSON object format (e.g., some code models)
|
||
normalized
|
||
} else {
|
||
// Standard OpenAI format: arguments as JSON string
|
||
// But ensure we serialize valid JSON, not null
|
||
match normalized {
|
||
Value::Null => Value::String("{}".to_string()),
|
||
Value::String(raw) => {
|
||
// If the string is already valid JSON, keep it as-is
|
||
// Otherwise, ensure it's a proper JSON string
|
||
if serde_json::from_str::<Value>(&raw).is_ok() {
|
||
Value::String(raw)
|
||
} else {
|
||
// Invalid JSON string - wrap it as a proper JSON string
|
||
Value::String(
|
||
serde_json::to_string(&raw).unwrap_or_else(|_| "null".to_string()),
|
||
)
|
||
}
|
||
}
|
||
value => Value::String(
|
||
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string()),
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn request_model_extra(&self) -> impl Iterator<Item = (&String, &Value)> {
|
||
self.model_extra.iter().filter(|(key, _)| {
|
||
!INTERNAL_MODEL_EXTRA_KEYS
|
||
.iter()
|
||
.any(|internal| internal == &key.as_str())
|
||
})
|
||
}
|
||
|
||
/// 内部流式聊天实现,可选传入流式回调
|
||
async fn chat_streaming_internal(
|
||
&self,
|
||
request: &ChatCompletionRequest,
|
||
stream_callback: Option<&StreamCallback>,
|
||
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||
tracing::debug!(provider = %self.name, model = %self.model_id, "Starting streaming chat");
|
||
|
||
let url = format!("{}/chat/completions", self.base_url);
|
||
|
||
let mut body = self.build_request_body(request);
|
||
// 启用流式输出
|
||
body["stream"] = json!(true);
|
||
// 请求在流式末帧返回 usage(DeepSeek/OpenAI 兼容协议)
|
||
body["stream_options"] = json!({ "include_usage": true });
|
||
|
||
let mut req_builder = self
|
||
.client
|
||
.post(&url)
|
||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||
.header("Content-Type", "application/json")
|
||
.header("Accept", "text/event-stream");
|
||
|
||
for (key, value) in &self.extra_headers {
|
||
req_builder = req_builder.header(key.as_str(), value.as_str());
|
||
}
|
||
|
||
let resp = req_builder.json(&body).send().await.map_err(|err| {
|
||
format_transport_error_context(
|
||
&self.name,
|
||
&self.model_id,
|
||
&url,
|
||
self.llm_timeout_secs,
|
||
&err,
|
||
)
|
||
})?;
|
||
|
||
let status = resp.status();
|
||
if !status.is_success() {
|
||
let text = resp.text().await.unwrap_or_default();
|
||
let sequence = format_message_sequence(&body);
|
||
tracing::error!(
|
||
provider = %self.name,
|
||
model = %self.model_id,
|
||
url = %url,
|
||
status = %status,
|
||
response_len = text.len(),
|
||
response_body = %text,
|
||
sequence = ?sequence,
|
||
"OpenAI-compatible streaming API request failed"
|
||
);
|
||
return Err(format!("API error {}: {}", status, text).into());
|
||
}
|
||
|
||
let mut accumulator = StreamingAccumulator::new();
|
||
|
||
// 读取 SSE 流
|
||
let mut stream = resp.bytes_stream();
|
||
let mut buffer = String::new();
|
||
let mut raw_body = String::new(); // 完整原始响应,用于非 SSE JSON 回退
|
||
let mut done_received = false;
|
||
|
||
while let Some(chunk_result) = stream.next().await {
|
||
let chunk = chunk_result?;
|
||
let text = String::from_utf8_lossy(&chunk);
|
||
raw_body.push_str(&text);
|
||
buffer.push_str(&text);
|
||
|
||
// 处理缓冲区中的完整行
|
||
while let Some(newline_pos) = buffer.find('\n') {
|
||
let line = buffer[..newline_pos].to_string();
|
||
buffer = buffer[newline_pos + 1..].to_string();
|
||
|
||
let line_trimmed = line.trim();
|
||
|
||
if line_trimmed.is_empty() || line_trimmed.starts_with(':') {
|
||
continue;
|
||
}
|
||
|
||
// SSE 格式: data: {...} 或 data:{...}(某些 API 如 139 云没有空格)
|
||
let data_opt = line_trimmed
|
||
.strip_prefix("data: ")
|
||
.or_else(|| line_trimmed.strip_prefix("data:"));
|
||
|
||
if let Some(data) = data_opt {
|
||
if data == "[DONE]" {
|
||
// 流结束
|
||
done_received = true;
|
||
break;
|
||
}
|
||
|
||
// 解析 JSON
|
||
match serde_json::from_str::<Value>(data) {
|
||
Ok(json) => {
|
||
// 提取响应 ID
|
||
if let Some(id) = json.get("id").and_then(|v| v.as_str()) {
|
||
accumulator.set_response_id(id.to_string());
|
||
}
|
||
|
||
// 提取流式末帧的 usage(stream_options.include_usage=true 时返回)
|
||
if let Some(usage_val) = json.get("usage") {
|
||
if !usage_val.is_null() {
|
||
if let Ok(u) =
|
||
serde_json::from_value::<OpenAIUsage>(usage_val.clone())
|
||
{
|
||
accumulator.set_usage(u);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 提取 choices
|
||
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
|
||
for choice in choices {
|
||
// 尝试从 delta 提取(标准 OpenAI 流式格式)
|
||
if let Some(delta) = choice.get("delta") {
|
||
// 提取内容增量
|
||
if let Some(content) =
|
||
delta.get("content").and_then(|c| c.as_str())
|
||
{
|
||
accumulator.add_content(content);
|
||
if let Some(cb) = &stream_callback {
|
||
cb(StreamDelta {
|
||
content: content.to_string(),
|
||
reasoning_content: None,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 提取推理内容增量
|
||
if let Some(reasoning) =
|
||
delta.get("reasoning_content").and_then(|r| r.as_str())
|
||
{
|
||
accumulator.add_reasoning_content(reasoning);
|
||
if let Some(cb) = &stream_callback {
|
||
cb(StreamDelta {
|
||
content: String::new(),
|
||
reasoning_content: Some(reasoning.to_string()),
|
||
});
|
||
}
|
||
}
|
||
|
||
// 提取工具调用增量
|
||
if let Some(tool_calls) =
|
||
delta.get("tool_calls").and_then(|t| t.as_array())
|
||
{
|
||
for tool_call in tool_calls {
|
||
let index = tool_call
|
||
.get("index")
|
||
.and_then(|i| i.as_u64())
|
||
.unwrap_or(0)
|
||
as usize;
|
||
|
||
let id =
|
||
tool_call.get("id").and_then(|v| v.as_str());
|
||
let name = tool_call
|
||
.get("function")
|
||
.and_then(|f| f.get("name"))
|
||
.and_then(|n| n.as_str());
|
||
let arguments = tool_call
|
||
.get("function")
|
||
.and_then(|f| f.get("arguments"))
|
||
.and_then(|a| a.as_str());
|
||
|
||
accumulator
|
||
.add_tool_call(index, id, name, arguments);
|
||
}
|
||
}
|
||
}
|
||
// 尝试从 message 提取(某些非标准 API 格式)
|
||
else if let Some(message) = choice.get("message") {
|
||
if let Some(content) =
|
||
message.get("content").and_then(|c| c.as_str())
|
||
{
|
||
accumulator.add_content(content);
|
||
}
|
||
if let Some(reasoning) = message
|
||
.get("reasoning_content")
|
||
.and_then(|r| r.as_str())
|
||
{
|
||
accumulator.add_reasoning_content(reasoning);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Err(e) => {
|
||
tracing::debug!(
|
||
error = %e,
|
||
data = %data,
|
||
"Failed to parse SSE data"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if done_received {
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 处理缓冲区中剩余的内容
|
||
for line in buffer.lines() {
|
||
let line_trimmed = line.trim();
|
||
if line_trimmed.is_empty() || line_trimmed.starts_with(':') {
|
||
continue;
|
||
}
|
||
|
||
// 同样支持 data: {...} 和 data:{...} 两种格式
|
||
let data_opt = line_trimmed
|
||
.strip_prefix("data: ")
|
||
.or_else(|| line_trimmed.strip_prefix("data:"));
|
||
|
||
if let Some(data) = data_opt {
|
||
if data == "[DONE]" {
|
||
break;
|
||
}
|
||
|
||
if let Ok(json) = serde_json::from_str::<Value>(data) {
|
||
if let Some(id) = json.get("id").and_then(|v| v.as_str()) {
|
||
accumulator.set_response_id(id.to_string());
|
||
}
|
||
|
||
// 提取流式末帧的 usage(与主循环一致)
|
||
if let Some(usage_val) = json.get("usage") {
|
||
if !usage_val.is_null() {
|
||
if let Ok(u) = serde_json::from_value::<OpenAIUsage>(usage_val.clone())
|
||
{
|
||
accumulator.set_usage(u);
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
|
||
for choice in choices {
|
||
// 尝试从 delta 提取
|
||
if let Some(delta) = choice.get("delta") {
|
||
if let Some(content) = delta.get("content").and_then(|c| c.as_str())
|
||
{
|
||
accumulator.add_content(content);
|
||
if let Some(cb) = &stream_callback {
|
||
cb(StreamDelta {
|
||
content: content.to_string(),
|
||
reasoning_content: None,
|
||
});
|
||
}
|
||
}
|
||
if let Some(reasoning) =
|
||
delta.get("reasoning_content").and_then(|r| r.as_str())
|
||
{
|
||
accumulator.add_reasoning_content(reasoning);
|
||
if let Some(cb) = &stream_callback {
|
||
cb(StreamDelta {
|
||
content: String::new(),
|
||
reasoning_content: Some(reasoning.to_string()),
|
||
});
|
||
}
|
||
}
|
||
if let Some(tool_calls) =
|
||
delta.get("tool_calls").and_then(|t| t.as_array())
|
||
{
|
||
for tool_call in tool_calls {
|
||
let index = tool_call
|
||
.get("index")
|
||
.and_then(|i| i.as_u64())
|
||
.unwrap_or(0)
|
||
as usize;
|
||
let id = tool_call.get("id").and_then(|v| v.as_str());
|
||
let name = tool_call
|
||
.get("function")
|
||
.and_then(|f| f.get("name"))
|
||
.and_then(|n| n.as_str());
|
||
let arguments = tool_call
|
||
.get("function")
|
||
.and_then(|f| f.get("arguments"))
|
||
.and_then(|a| a.as_str());
|
||
accumulator.add_tool_call(index, id, name, arguments);
|
||
}
|
||
}
|
||
}
|
||
// 尝试从 message 提取(某些非标准 API 格式)
|
||
else if let Some(message) = choice.get("message") {
|
||
if let Some(content) =
|
||
message.get("content").and_then(|c| c.as_str())
|
||
{
|
||
accumulator.add_content(content);
|
||
}
|
||
if let Some(reasoning) =
|
||
message.get("reasoning_content").and_then(|r| r.as_str())
|
||
{
|
||
accumulator.add_reasoning_content(reasoning);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut response = accumulator.build_response(self.model_id.clone());
|
||
|
||
// 回退:当流式解析未获取到任何内容且无 tool call 时,
|
||
// 服务器可能返回的是非 SSE 格式的纯 JSON,尝试直接反序列化整个响应体
|
||
if response.content.is_empty() && response.tool_calls.is_empty() {
|
||
if let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body) {
|
||
let fallback_content = openai_resp
|
||
.choices
|
||
.first()
|
||
.and_then(|c| c.message.content.as_deref())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
if !fallback_content.is_empty() {
|
||
tracing::debug!(
|
||
model = %self.model_id,
|
||
"Streaming accumulator empty, falling back to non-SSE JSON parsing"
|
||
);
|
||
response.content = fallback_content;
|
||
response.reasoning_content = openai_resp
|
||
.choices
|
||
.first()
|
||
.and_then(|c| c.message.reasoning_content.clone());
|
||
response.tool_calls = openai_resp
|
||
.choices
|
||
.first()
|
||
.map(|c| {
|
||
c.message
|
||
.tool_calls
|
||
.iter()
|
||
.map(|tc| ToolCall {
|
||
id: tc.id.clone(),
|
||
name: tc.function.name.clone(),
|
||
arguments: match &tc.function.arguments {
|
||
OAIFunctionArguments::Json(args) => args.clone(),
|
||
OAIFunctionArguments::String(args) => {
|
||
serde_json::from_str(args)
|
||
.unwrap_or(serde_json::Value::Null)
|
||
}
|
||
},
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
// 回退场景下也从非流式响应提取 usage
|
||
response.usage = Usage {
|
||
prompt_tokens: openai_resp.usage.prompt_tokens,
|
||
completion_tokens: openai_resp.usage.completion_tokens,
|
||
total_tokens: openai_resp.usage.total_tokens,
|
||
cached_tokens: openai_resp.usage.cached_tokens(),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
tracing::debug!(
|
||
content_len = response.content.len(),
|
||
tool_calls_count = response.tool_calls.len(),
|
||
has_reasoning = response.reasoning_content.is_some(),
|
||
"Streaming response built"
|
||
);
|
||
Ok(response)
|
||
}
|
||
|
||
fn build_request_body(&self, request: &ChatCompletionRequest) -> Value {
|
||
let supports_images = self.supports_images();
|
||
|
||
// --- Final defense: position-aware tool_call / tool result validation ---
|
||
//
|
||
// Scan right-to-left (matching sanitize_incomplete_tool_call_sequences)
|
||
// so that only tool results appearing AFTER an assistant message count
|
||
// as "resolved". A simple global scan would incorrectly accept a tool
|
||
// result that precedes its parent assistant (e.g. after compaction
|
||
// boundary splits), leading to API 400 errors:
|
||
// "insufficient tool messages following tool_calls message".
|
||
let mut resolved_tool_ids: std::collections::HashSet<&str> =
|
||
std::collections::HashSet::new();
|
||
let mut with_parent: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||
let mut skip_assistant_indices: std::collections::HashSet<usize> =
|
||
std::collections::HashSet::new();
|
||
|
||
for (i, m) in request.messages.iter().enumerate().rev() {
|
||
if m.role == "tool" {
|
||
if let Some(ref tc_id) = m.tool_call_id {
|
||
resolved_tool_ids.insert(tc_id.as_str());
|
||
}
|
||
}
|
||
|
||
if m.role == "assistant" {
|
||
if let Some(ref calls) = m.tool_calls {
|
||
if !calls.is_empty() {
|
||
let all_resolved = calls
|
||
.iter()
|
||
.all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
|
||
if all_resolved {
|
||
for tc in calls {
|
||
with_parent.insert(tc.id.as_str());
|
||
}
|
||
} else {
|
||
skip_assistant_indices.insert(i);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Forward-order check: verify tool messages IMMEDIATELY follow the
|
||
// assistant(tool_calls). If any non-tool message appears between the
|
||
// assistant and its tool results, the API rejects with
|
||
// "insufficient tool messages following tool_calls message".
|
||
//
|
||
// The reverse scan above only checks existence (tool result appears
|
||
// somewhere after assistant), NOT immediacy. This forward pass catches:
|
||
// [assistant(tool_calls=[A]), user, tool(A)]
|
||
// ^ reverse scan sees tool(A) after assistant → "resolved"
|
||
// but API requires tool(A) to be IMMEDIATELY after assistant
|
||
{
|
||
let mut pending_tool_ids: std::collections::HashSet<&str> =
|
||
std::collections::HashSet::new();
|
||
let mut pending_assistant_idx: Option<usize> = None;
|
||
|
||
for (i, m) in request.messages.iter().enumerate() {
|
||
// If we have pending tool_ids and encounter a non-tool message,
|
||
// the assistant's tool results were NOT immediately following.
|
||
if !pending_tool_ids.is_empty() && m.role != "tool" {
|
||
if let Some(idx) = pending_assistant_idx {
|
||
skip_assistant_indices.insert(idx);
|
||
tracing::warn!(
|
||
message_index = idx,
|
||
interrupted_by_index = i,
|
||
interrupted_by_role = %m.role,
|
||
pending_tool_call_count = pending_tool_ids.len(),
|
||
"build_request_body: assistant tool_calls not immediately \
|
||
followed by tool results — stripping tool_calls"
|
||
);
|
||
// Remove this assistant's tool_call_ids from with_parent
|
||
// so orphaned tool messages are dropped during serialization
|
||
if let Some(calls) = &request.messages[idx].tool_calls {
|
||
for tc in calls.iter() {
|
||
with_parent.remove(tc.id.as_str());
|
||
}
|
||
}
|
||
}
|
||
pending_tool_ids.clear();
|
||
pending_assistant_idx = None;
|
||
}
|
||
|
||
if m.role == "assistant" {
|
||
if let Some(ref calls) = m.tool_calls {
|
||
if !calls.is_empty() && !skip_assistant_indices.contains(&i) {
|
||
pending_tool_ids = calls.iter().map(|tc| tc.id.as_str()).collect();
|
||
pending_assistant_idx = Some(i);
|
||
}
|
||
}
|
||
} else if m.role == "tool" {
|
||
if let Some(ref tc_id) = m.tool_call_id {
|
||
pending_tool_ids.remove(tc_id.as_str());
|
||
if pending_tool_ids.is_empty() {
|
||
pending_assistant_idx = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle trailing assistant with unresolved immediate tool results
|
||
if !pending_tool_ids.is_empty() {
|
||
if let Some(idx) = pending_assistant_idx {
|
||
skip_assistant_indices.insert(idx);
|
||
tracing::warn!(
|
||
message_index = idx,
|
||
pending_tool_call_count = pending_tool_ids.len(),
|
||
"build_request_body: trailing assistant tool_calls without \
|
||
immediately following tool results — stripping tool_calls"
|
||
);
|
||
if let Some(calls) = &request.messages[idx].tool_calls {
|
||
for tc in calls.iter() {
|
||
with_parent.remove(tc.id.as_str());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// valid_tool_call_parent_ids = with_parent (assistant tool_call_ids
|
||
// whose parent assistant has ALL results after it)
|
||
let valid_tool_call_parent_ids = &with_parent;
|
||
|
||
let mut body = json!({
|
||
"model": self.model_id,
|
||
"messages": request.messages.iter().enumerate().filter_map(|(i, m)| {
|
||
// Skip assistant messages identified as having incomplete tool_calls
|
||
// (position-aware scan found missing tool results after this assistant).
|
||
if skip_assistant_indices.contains(&i) {
|
||
tracing::warn!(
|
||
message_index = i,
|
||
"build_request_body: skipping assistant with incomplete tool call sequence \
|
||
(tool results missing after this position)"
|
||
);
|
||
// Serialize as plain assistant message (strip tool_calls)
|
||
let mut message = json!({
|
||
"role": m.role,
|
||
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
|
||
});
|
||
if let Some(reasoning_content) = &m.reasoning_content {
|
||
message["reasoning_content"] = Value::String(reasoning_content.clone());
|
||
}
|
||
return Some(message);
|
||
}
|
||
|
||
if m.role == "tool" {
|
||
// Skip orphaned tool results (no matching assistant tool_call)
|
||
let is_orphaned = match &m.tool_call_id {
|
||
Some(tc_id) => !valid_tool_call_parent_ids.contains(tc_id.as_str()),
|
||
None => true,
|
||
};
|
||
if is_orphaned {
|
||
tracing::warn!(
|
||
tool_call_id = ?m.tool_call_id,
|
||
message_index = i,
|
||
"build_request_body: skipping orphaned tool result message"
|
||
);
|
||
return None;
|
||
}
|
||
Some(json!({
|
||
"role": m.role,
|
||
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i),
|
||
"tool_call_id": m.tool_call_id,
|
||
"name": m.name,
|
||
}))
|
||
} else if m.role == "assistant" && m.tool_calls.is_some() {
|
||
let calls = m.tool_calls.as_ref().unwrap();
|
||
// Filter to only valid tool_calls (all have results)
|
||
let valid_calls: Vec<&ToolCall> = calls.iter()
|
||
.filter(|tc| valid_tool_call_parent_ids.contains(tc.id.as_str()))
|
||
.collect();
|
||
|
||
if valid_calls.is_empty() {
|
||
// All tool_calls are orphaned — serialize as plain assistant message
|
||
tracing::warn!(
|
||
orphaned_tool_call_count = calls.len(),
|
||
message_index = i,
|
||
"build_request_body: stripping all orphaned tool_calls from assistant message"
|
||
);
|
||
let mut message = json!({
|
||
"role": m.role,
|
||
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
|
||
});
|
||
if let Some(reasoning_content) = &m.reasoning_content {
|
||
message["reasoning_content"] = Value::String(reasoning_content.clone());
|
||
}
|
||
Some(message)
|
||
} else {
|
||
let mut message = json!({
|
||
"role": m.role,
|
||
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i),
|
||
"tool_calls": valid_calls.iter().map(|call| json!({
|
||
"id": call.id,
|
||
"type": "function",
|
||
"function": {
|
||
"name": call.name,
|
||
"arguments": self.serialize_tool_arguments(&call.arguments)
|
||
}
|
||
})).collect::<Vec<_>>()
|
||
});
|
||
|
||
if let Some(reasoning_content) = &m.reasoning_content {
|
||
message["reasoning_content"] = Value::String(reasoning_content.clone());
|
||
}
|
||
|
||
Some(message)
|
||
}
|
||
} else {
|
||
let mut message = json!({
|
||
"role": m.role,
|
||
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
|
||
});
|
||
|
||
if m.role == "assistant" {
|
||
if let Some(reasoning_content) = &m.reasoning_content {
|
||
message["reasoning_content"] = Value::String(reasoning_content.clone());
|
||
}
|
||
}
|
||
|
||
Some(message)
|
||
}
|
||
}).collect::<Vec<_>>(),
|
||
});
|
||
|
||
// 只有配置了才添加 temperature,否则让模型使用默认值
|
||
if let Some(temp) = request.temperature.or(self.temperature) {
|
||
body["temperature"] = json!(temp);
|
||
}
|
||
|
||
// 只有配置了才添加 max_tokens
|
||
if let Some(tokens) = request.max_tokens.or(self.max_tokens) {
|
||
body["max_tokens"] = json!(tokens);
|
||
}
|
||
|
||
for (key, value) in self.request_model_extra() {
|
||
body[key] = value.clone();
|
||
}
|
||
|
||
if let Some(tools) = &request.tools {
|
||
body["tools"] = json!(tools);
|
||
}
|
||
|
||
body
|
||
}
|
||
}
|
||
|
||
/// Builds a compact, human-readable summary of the message sequence in `body`
|
||
/// for diagnostic logging. Only emitted on API errors (e.g. 400 responses) to
|
||
/// avoid flooding logs on every request — see callers in `chat` and
|
||
/// `chat_streaming_internal`.
|
||
fn format_message_sequence(body: &Value) -> Vec<String> {
|
||
body["messages"]
|
||
.as_array()
|
||
.map(|msgs| {
|
||
msgs.iter()
|
||
.enumerate()
|
||
.map(|(i, m)| {
|
||
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
|
||
match role {
|
||
"assistant" => {
|
||
let tc_count = m
|
||
.get("tool_calls")
|
||
.and_then(|t| t.as_array())
|
||
.map(|a| a.len())
|
||
.unwrap_or(0);
|
||
if tc_count > 0 {
|
||
format!("[{}] assistant(tool_calls={})", i, tc_count)
|
||
} else {
|
||
format!("[{}] assistant", i)
|
||
}
|
||
}
|
||
"tool" => {
|
||
let tcid = m
|
||
.get("tool_call_id")
|
||
.and_then(|t| t.as_str())
|
||
.unwrap_or("??");
|
||
format!("[{}] tool(id={})", i, tcid)
|
||
}
|
||
_ => format!("[{}] {}", i, role),
|
||
}
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct OpenAIResponse {
|
||
id: String,
|
||
model: String,
|
||
choices: Vec<OpenAIChoice>,
|
||
#[serde(default)]
|
||
usage: OpenAIUsage,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct OpenAIChoice {
|
||
message: OpenAIMessage,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct OpenAIMessage {
|
||
#[serde(default)]
|
||
content: Option<String>,
|
||
#[serde(default)]
|
||
reasoning_content: Option<String>,
|
||
#[allow(dead_code)]
|
||
#[serde(default)]
|
||
name: Option<String>,
|
||
#[serde(default)]
|
||
tool_calls: Vec<OpenAIToolCall>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct OpenAIToolCall {
|
||
id: String,
|
||
#[serde(rename = "function")]
|
||
function: OAIFunction,
|
||
#[allow(dead_code)]
|
||
#[serde(default)]
|
||
index: Option<u32>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct OAIFunction {
|
||
name: String,
|
||
arguments: OAIFunctionArguments,
|
||
}
|
||
|
||
#[derive(Deserialize, Default, Clone, Debug)]
|
||
struct OpenAIUsage {
|
||
#[serde(default)]
|
||
prompt_tokens: u32,
|
||
#[serde(default)]
|
||
completion_tokens: u32,
|
||
#[serde(default)]
|
||
total_tokens: u32,
|
||
/// DeepSeek 原生缓存字段:本次请求输入中命中缓存的 tokens 数
|
||
#[serde(default)]
|
||
prompt_cache_hit_tokens: Option<u32>,
|
||
/// OpenAI 兼容嵌套字段:prompt_tokens_details.cached_tokens
|
||
#[serde(default)]
|
||
prompt_tokens_details: Option<OpenAIPromptTokensDetails>,
|
||
}
|
||
|
||
#[derive(Deserialize, Default, Clone, Debug)]
|
||
struct OpenAIPromptTokensDetails {
|
||
#[serde(default)]
|
||
cached_tokens: Option<u32>,
|
||
}
|
||
|
||
impl OpenAIUsage {
|
||
/// 缓存命中的输入 tokens 数。
|
||
/// 两种 API 形态互斥:优先 DeepSeek 顶层字段,回退 OpenAI 嵌套字段。
|
||
fn cached_tokens(&self) -> u32 {
|
||
self.prompt_cache_hit_tokens
|
||
.or_else(|| {
|
||
self.prompt_tokens_details
|
||
.as_ref()
|
||
.and_then(|d| d.cached_tokens)
|
||
})
|
||
.unwrap_or(0)
|
||
}
|
||
}
|
||
|
||
#[async_trait]
|
||
impl LLMProvider for OpenAIProvider {
|
||
async fn chat(
|
||
&self,
|
||
request: ChatCompletionRequest,
|
||
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||
// 检查是否启用流式输出
|
||
if self.is_streaming_enabled() {
|
||
// 优先尝试流式输出(无回调)
|
||
match self.chat_streaming_internal(&request, None).await {
|
||
Ok(response) => return Ok(response),
|
||
Err(e) => {
|
||
tracing::debug!(
|
||
provider = %self.name,
|
||
model = %self.model_id,
|
||
error = %e,
|
||
"Streaming failed, falling back to non-streaming"
|
||
);
|
||
// 流式失败,回退到非流式
|
||
}
|
||
}
|
||
} else {
|
||
tracing::debug!(provider = %self.name, model = %self.model_id, "Streaming disabled, using non-streaming");
|
||
}
|
||
|
||
// 非流式回退实现
|
||
let url = format!("{}/chat/completions", self.base_url);
|
||
|
||
let body = self.build_request_body(&request);
|
||
|
||
// Debug: Log LLM request summary (only in debug builds)
|
||
#[cfg(debug_assertions)]
|
||
{
|
||
// Log messages summary
|
||
let msg_count = body["messages"].as_array().map(|a| a.len()).unwrap_or(0);
|
||
tracing::debug!(msg_count = msg_count, "LLM request messages count");
|
||
|
||
// Log first 20 bytes of base64 images (don't log full base64)
|
||
if let Some(msgs) = body["messages"].as_array() {
|
||
for (i, msg) in msgs.iter().enumerate() {
|
||
if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
|
||
for (j, item) in content.iter().enumerate() {
|
||
if item.get("type").and_then(|t| t.as_str()) == Some("image_url") {
|
||
if let Some(url_str) = item
|
||
.get("image_url")
|
||
.and_then(|u| u.get("url"))
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
let prefix: String = url_str.chars().take(20).collect();
|
||
tracing::debug!(msg_idx = i, item_idx = j, image_prefix = %prefix, image_url_len = %url_str.len(), "Image in LLM request (first 20 bytes shown)");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut req_builder = self
|
||
.client
|
||
.post(&url)
|
||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||
.header("Content-Type", "application/json");
|
||
|
||
for (key, value) in &self.extra_headers {
|
||
req_builder = req_builder.header(key.as_str(), value.as_str());
|
||
}
|
||
|
||
let resp = req_builder.json(&body).send().await.map_err(|err| {
|
||
let error_context = format_transport_error_context(
|
||
&self.name,
|
||
&self.model_id,
|
||
&url,
|
||
self.llm_timeout_secs,
|
||
&err,
|
||
);
|
||
tracing::error!(
|
||
provider = %self.name,
|
||
model = %self.model_id,
|
||
url = %url,
|
||
base_url = %self.base_url,
|
||
timeout_secs = self.llm_timeout_secs,
|
||
error = %error_context,
|
||
"OpenAI-compatible API transport request failed"
|
||
);
|
||
error_context
|
||
})?;
|
||
|
||
let status = resp.status();
|
||
let text = resp.text().await?;
|
||
|
||
// Debug: Log LLM response (only in debug builds)
|
||
if !status.is_success() {
|
||
let sequence = format_message_sequence(&body);
|
||
tracing::error!(
|
||
provider = %self.name,
|
||
model = %self.model_id,
|
||
url = %url,
|
||
status = %status,
|
||
response_len = text.len(),
|
||
response_body = %text,
|
||
sequence = ?sequence,
|
||
"OpenAI-compatible API request failed"
|
||
);
|
||
return Err(format!("API error {}: {}", status, text).into());
|
||
}
|
||
|
||
#[cfg(debug_assertions)]
|
||
{
|
||
let resp_preview: String = text.chars().take(100).collect();
|
||
tracing::debug!(status = %status, response_preview = %resp_preview, response_len = %text.len(), timeout_secs = self.llm_timeout_secs, "LLM response (first 100 chars shown)");
|
||
}
|
||
|
||
let openai_resp: OpenAIResponse = serde_json::from_str(&text).map_err(|e| {
|
||
tracing::error!(
|
||
provider = %self.name,
|
||
model = %self.model_id,
|
||
url = %url,
|
||
error = %format_error_chain(&e),
|
||
response_len = text.len(),
|
||
response_body = %text,
|
||
"Failed to decode OpenAI-compatible API response"
|
||
);
|
||
format!("decode error: {} | body: {}", e, &text)
|
||
})?;
|
||
|
||
let content = openai_resp.choices[0]
|
||
.message
|
||
.content
|
||
.as_ref()
|
||
.unwrap_or(&String::new())
|
||
.clone();
|
||
|
||
let tool_calls: Vec<ToolCall> = openai_resp.choices[0]
|
||
.message
|
||
.tool_calls
|
||
.iter()
|
||
.map(|tc| ToolCall {
|
||
id: tc.id.clone(),
|
||
name: tc.function.name.clone(),
|
||
arguments: match &tc.function.arguments {
|
||
OAIFunctionArguments::Json(arguments) => arguments.clone(),
|
||
OAIFunctionArguments::String(arguments) => {
|
||
serde_json::from_str(arguments).unwrap_or(serde_json::Value::Null)
|
||
}
|
||
},
|
||
})
|
||
.collect();
|
||
|
||
Ok(ChatCompletionResponse {
|
||
id: openai_resp.id,
|
||
model: openai_resp.model,
|
||
content,
|
||
reasoning_content: openai_resp.choices[0].message.reasoning_content.clone(),
|
||
tool_calls,
|
||
usage: {
|
||
Usage {
|
||
prompt_tokens: openai_resp.usage.prompt_tokens,
|
||
completion_tokens: openai_resp.usage.completion_tokens,
|
||
total_tokens: openai_resp.usage.total_tokens,
|
||
cached_tokens: openai_resp.usage.cached_tokens(),
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
async fn chat_with_streaming(
|
||
&self,
|
||
request: ChatCompletionRequest,
|
||
callback: StreamCallback,
|
||
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||
if self.is_streaming_enabled() {
|
||
match self
|
||
.chat_streaming_internal(&request, Some(&callback))
|
||
.await
|
||
{
|
||
Ok(response) => return Ok(response),
|
||
Err(e) => {
|
||
tracing::debug!(
|
||
provider = %self.name,
|
||
model = %self.model_id,
|
||
error = %e,
|
||
"Streaming (with callback) failed, falling back to non-streaming"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// 回退到非流式
|
||
self.chat(request).await
|
||
}
|
||
|
||
fn ptype(&self) -> &str {
|
||
"openai"
|
||
}
|
||
|
||
fn name(&self) -> &str {
|
||
&self.name
|
||
}
|
||
|
||
fn model_id(&self) -> &str {
|
||
&self.model_id
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::providers::Message;
|
||
|
||
#[test]
|
||
fn test_build_request_body_includes_assistant_tool_calls() {
|
||
let provider = OpenAIProvider::new(
|
||
"test".to_string(),
|
||
"key".to_string(),
|
||
"https://example.com/v1".to_string(),
|
||
HashMap::new(),
|
||
120,
|
||
"gpt-test".to_string(),
|
||
None,
|
||
None,
|
||
HashMap::new(),
|
||
);
|
||
|
||
let request = ChatCompletionRequest {
|
||
messages: vec![
|
||
Message {
|
||
role: "assistant".to_string(),
|
||
content: vec![ContentBlock::text("calling tool")],
|
||
reasoning_content: None,
|
||
tool_call_id: None,
|
||
name: None,
|
||
tool_calls: Some(vec![ToolCall {
|
||
id: "call_1".to_string(),
|
||
name: "calculator".to_string(),
|
||
arguments: json!({"expression": "1+1"}),
|
||
}]),
|
||
},
|
||
Message {
|
||
role: "tool".to_string(),
|
||
content: vec![ContentBlock::text("2")],
|
||
reasoning_content: None,
|
||
tool_call_id: Some("call_1".to_string()),
|
||
name: Some("calculator".to_string()),
|
||
tool_calls: None,
|
||
},
|
||
],
|
||
temperature: None,
|
||
max_tokens: None,
|
||
tools: None,
|
||
};
|
||
|
||
let body = provider.build_request_body(&request);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
let tool_calls = messages[0]["tool_calls"].as_array().unwrap();
|
||
|
||
assert_eq!(tool_calls.len(), 1);
|
||
assert_eq!(tool_calls[0]["id"], "call_1");
|
||
assert_eq!(tool_calls[0]["type"], "function");
|
||
assert_eq!(tool_calls[0]["function"]["name"], "calculator");
|
||
assert_eq!(
|
||
tool_calls[0]["function"]["arguments"],
|
||
"{\"expression\":\"1+1\"}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_request_body_uses_json_tool_arguments_when_enabled() {
|
||
let provider = OpenAIProvider::new(
|
||
"test".to_string(),
|
||
"key".to_string(),
|
||
"https://example.com/v1".to_string(),
|
||
HashMap::new(),
|
||
120,
|
||
"gpt-test".to_string(),
|
||
None,
|
||
None,
|
||
HashMap::from([("tool_call_arguments_json".to_string(), Value::Bool(true))]),
|
||
);
|
||
|
||
let request = ChatCompletionRequest {
|
||
messages: vec![
|
||
Message {
|
||
role: "assistant".to_string(),
|
||
content: vec![ContentBlock::text("calling tool")],
|
||
reasoning_content: None,
|
||
tool_call_id: None,
|
||
name: None,
|
||
tool_calls: Some(vec![ToolCall {
|
||
id: "call_1".to_string(),
|
||
name: "calculator".to_string(),
|
||
arguments: json!({"expression": "1+1"}),
|
||
}]),
|
||
},
|
||
Message {
|
||
role: "tool".to_string(),
|
||
content: vec![ContentBlock::text("2")],
|
||
reasoning_content: None,
|
||
tool_call_id: Some("call_1".to_string()),
|
||
name: Some("calculator".to_string()),
|
||
tool_calls: None,
|
||
},
|
||
],
|
||
temperature: None,
|
||
max_tokens: None,
|
||
tools: None,
|
||
};
|
||
|
||
let body = provider.build_request_body(&request);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
let tool_calls = messages[0]["tool_calls"].as_array().unwrap();
|
||
|
||
assert_eq!(
|
||
tool_calls[0]["function"]["arguments"],
|
||
json!({"expression": "1+1"})
|
||
);
|
||
assert!(body.get("tool_call_arguments_json").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_request_body_preserves_raw_json_string_arguments() {
|
||
let provider = OpenAIProvider::new(
|
||
"test".to_string(),
|
||
"key".to_string(),
|
||
"https://example.com/v1".to_string(),
|
||
HashMap::new(),
|
||
120,
|
||
"gpt-test".to_string(),
|
||
None,
|
||
None,
|
||
HashMap::new(),
|
||
);
|
||
|
||
let request = ChatCompletionRequest {
|
||
messages: vec![
|
||
Message {
|
||
role: "assistant".to_string(),
|
||
content: vec![ContentBlock::text("calling tool")],
|
||
reasoning_content: None,
|
||
tool_call_id: None,
|
||
name: None,
|
||
tool_calls: Some(vec![ToolCall {
|
||
id: "call_1".to_string(),
|
||
name: "calculator".to_string(),
|
||
arguments: Value::String("{\"expression\":\"1+1\"}".to_string()),
|
||
}]),
|
||
},
|
||
Message {
|
||
role: "tool".to_string(),
|
||
content: vec![ContentBlock::text("2")],
|
||
reasoning_content: None,
|
||
tool_call_id: Some("call_1".to_string()),
|
||
name: Some("calculator".to_string()),
|
||
tool_calls: None,
|
||
},
|
||
],
|
||
temperature: None,
|
||
max_tokens: None,
|
||
tools: None,
|
||
};
|
||
|
||
let body = provider.build_request_body(&request);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
let tool_calls = messages[0]["tool_calls"].as_array().unwrap();
|
||
|
||
assert_eq!(
|
||
tool_calls[0]["function"]["arguments"],
|
||
"{\"expression\":\"1+1\"}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_request_body_omits_internal_model_extra_keys() {
|
||
let provider = OpenAIProvider::new(
|
||
"test".to_string(),
|
||
"key".to_string(),
|
||
"https://example.com/v1".to_string(),
|
||
HashMap::new(),
|
||
120,
|
||
"gpt-test".to_string(),
|
||
None,
|
||
None,
|
||
HashMap::from([
|
||
("tool_call_arguments_json".to_string(), Value::Bool(true)),
|
||
(
|
||
"mock_response_content".to_string(),
|
||
Value::String("stub".to_string()),
|
||
),
|
||
("parallel_tool_calls".to_string(), Value::Bool(true)),
|
||
]),
|
||
);
|
||
|
||
let request = ChatCompletionRequest {
|
||
messages: vec![Message::user("hello")],
|
||
temperature: None,
|
||
max_tokens: None,
|
||
tools: None,
|
||
};
|
||
|
||
let body = provider.build_request_body(&request);
|
||
|
||
assert!(body.get("tool_call_arguments_json").is_none());
|
||
assert!(body.get("mock_response_content").is_none());
|
||
assert_eq!(body["parallel_tool_calls"], Value::Bool(true));
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_request_body_includes_assistant_reasoning_content() {
|
||
let provider = OpenAIProvider::new(
|
||
"test".to_string(),
|
||
"key".to_string(),
|
||
"https://example.com/v1".to_string(),
|
||
HashMap::new(),
|
||
120,
|
||
"gpt-test".to_string(),
|
||
None,
|
||
None,
|
||
HashMap::new(),
|
||
);
|
||
|
||
let request = ChatCompletionRequest {
|
||
messages: vec![Message {
|
||
role: "assistant".to_string(),
|
||
content: vec![ContentBlock::text("final answer")],
|
||
reasoning_content: Some("step by step".to_string()),
|
||
tool_call_id: None,
|
||
name: None,
|
||
tool_calls: None,
|
||
}],
|
||
temperature: None,
|
||
max_tokens: None,
|
||
tools: None,
|
||
};
|
||
|
||
let body = provider.build_request_body(&request);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
|
||
assert_eq!(messages[0]["reasoning_content"], "step by step");
|
||
}
|
||
|
||
#[test]
|
||
fn test_openai_response_parses_reasoning_content() {
|
||
let response: OpenAIResponse = serde_json::from_value(json!({
|
||
"id": "resp_1",
|
||
"model": "gpt-test",
|
||
"choices": [{
|
||
"message": {
|
||
"content": "final answer",
|
||
"reasoning_content": "hidden reasoning",
|
||
"tool_calls": []
|
||
}
|
||
}],
|
||
"usage": {
|
||
"prompt_tokens": 10,
|
||
"completion_tokens": 5,
|
||
"total_tokens": 15
|
||
}
|
||
}))
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
response.choices[0].message.reasoning_content.as_deref(),
|
||
Some("hidden reasoning")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_usage_parses_deepseek_cache_fields() {
|
||
// DeepSeek 官方 API 的 usage 顶层缓存字段
|
||
let usage: OpenAIUsage = serde_json::from_value(json!({
|
||
"prompt_tokens": 345,
|
||
"completion_tokens": 564,
|
||
"total_tokens": 909,
|
||
"prompt_cache_hit_tokens": 128,
|
||
"prompt_cache_miss_tokens": 217
|
||
}))
|
||
.unwrap();
|
||
|
||
assert_eq!(usage.cached_tokens(), 128);
|
||
assert!(usage.prompt_cache_hit_tokens.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_usage_parses_openai_nested_cache_details() {
|
||
// OpenAI 兼容格式:嵌套在 prompt_tokens_details 中
|
||
let usage: OpenAIUsage = serde_json::from_value(json!({
|
||
"prompt_tokens": 1000,
|
||
"completion_tokens": 100,
|
||
"total_tokens": 1100,
|
||
"prompt_tokens_details": { "cached_tokens": 512 }
|
||
}))
|
||
.unwrap();
|
||
|
||
assert_eq!(usage.cached_tokens(), 512);
|
||
|
||
// 无缓存字段时回退为 0
|
||
let no_cache: OpenAIUsage = serde_json::from_value(json!({
|
||
"prompt_tokens": 10,
|
||
"completion_tokens": 5,
|
||
"total_tokens": 15
|
||
}))
|
||
.unwrap();
|
||
assert_eq!(no_cache.cached_tokens(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_openai_response_parses_json_tool_arguments() {
|
||
let response: OpenAIResponse = serde_json::from_value(json!({
|
||
"id": "resp_1",
|
||
"model": "gpt-test",
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_1",
|
||
"function": {
|
||
"name": "scheduler_manage",
|
||
"arguments": {"action": "list"}
|
||
}
|
||
}]
|
||
}
|
||
}],
|
||
"usage": {
|
||
"prompt_tokens": 1,
|
||
"completion_tokens": 1,
|
||
"total_tokens": 2
|
||
}
|
||
}))
|
||
.unwrap();
|
||
|
||
match &response.choices[0].message.tool_calls[0].function.arguments {
|
||
OAIFunctionArguments::Json(arguments) => {
|
||
assert_eq!(arguments, &json!({"action": "list"}));
|
||
}
|
||
OAIFunctionArguments::String(_) => panic!("expected JSON tool arguments"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_streaming_accumulator_preserves_tool_call_id_with_empty_subsequent_chunks() {
|
||
// 模拟阿里云等云服务商的流式响应行为:
|
||
// 第一个 chunk 包含 id 和 name,后续 chunk 的 id 为空字符串、name 为 None
|
||
let mut accumulator = StreamingAccumulator::new();
|
||
|
||
// 第一个 chunk:包含完整的 id 和 name
|
||
accumulator.add_tool_call(
|
||
0,
|
||
Some("call_abc123"),
|
||
Some("memory_search"),
|
||
Some("{\"action\":\""),
|
||
);
|
||
// 第二个 chunk:只有参数增量
|
||
accumulator.add_tool_call(0, None, None, Some("list"));
|
||
// 第三个 chunk:参数继续
|
||
accumulator.add_tool_call(0, None, None, Some("\""));
|
||
// 第四个 chunk:id 为空字符串(某些云服务商的行为)
|
||
accumulator.add_tool_call(0, Some(""), None, Some(", \"limit\": 20"));
|
||
// 最后一个 chunk:name 为 None
|
||
accumulator.add_tool_call(0, None, None, Some("}"));
|
||
|
||
let response = accumulator.build_response("test-model".to_string());
|
||
|
||
// 验证工具调用被正确保留,id 没有被空字符串覆盖
|
||
assert_eq!(response.tool_calls.len(), 1);
|
||
assert_eq!(response.tool_calls[0].id, "call_abc123");
|
||
assert_eq!(response.tool_calls[0].name, "memory_search");
|
||
assert_eq!(
|
||
response.tool_calls[0].arguments,
|
||
json!({"action":"list", "limit": 20})
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_streaming_accumulator_handles_multiple_tool_calls() {
|
||
let mut accumulator = StreamingAccumulator::new();
|
||
|
||
// 第一个工具调用
|
||
accumulator.add_tool_call(
|
||
0,
|
||
Some("call_1"),
|
||
Some("calculator"),
|
||
Some("{\"expr\": \"1+1\"}"),
|
||
);
|
||
// 第二个工具调用(id 和 name 只在第一个 chunk 出现)
|
||
accumulator.add_tool_call(1, Some("call_2"), Some("get_time"), Some("{}"));
|
||
|
||
let response = accumulator.build_response("test-model".to_string());
|
||
|
||
assert_eq!(response.tool_calls.len(), 2);
|
||
assert_eq!(response.tool_calls[0].id, "call_1");
|
||
assert_eq!(response.tool_calls[0].name, "calculator");
|
||
assert_eq!(response.tool_calls[1].id, "call_2");
|
||
assert_eq!(response.tool_calls[1].name, "get_time");
|
||
}
|
||
|
||
#[test]
|
||
fn test_supports_images_default_true() {
|
||
let provider = OpenAIProvider::new(
|
||
"test".to_string(),
|
||
"key".to_string(),
|
||
"https://example.com/v1".to_string(),
|
||
HashMap::new(),
|
||
120,
|
||
"gpt-test".to_string(),
|
||
None,
|
||
None,
|
||
HashMap::new(),
|
||
);
|
||
|
||
assert!(provider.supports_images());
|
||
}
|
||
|
||
#[test]
|
||
fn test_supports_images_disabled_via_config() {
|
||
let provider = OpenAIProvider::new(
|
||
"test".to_string(),
|
||
"key".to_string(),
|
||
"https://example.com/v1".to_string(),
|
||
HashMap::new(),
|
||
120,
|
||
"gpt-test".to_string(),
|
||
None,
|
||
None,
|
||
HashMap::from([(
|
||
"supported_content_types".to_string(),
|
||
Value::Array(vec![Value::String("text".to_string())]),
|
||
)]),
|
||
);
|
||
|
||
assert!(!provider.supports_images());
|
||
}
|
||
|
||
#[test]
|
||
fn test_convert_content_blocks_converts_images_to_notice_when_disabled() {
|
||
let blocks = vec![
|
||
ContentBlock::text("hello"),
|
||
ContentBlock::image_url("data:image/png;base64,abc123"),
|
||
ContentBlock::text("world"),
|
||
];
|
||
|
||
let result = convert_content_blocks(false, "test", "test-model", &blocks, 0);
|
||
|
||
// 应该是数组形式
|
||
let arr = result.as_array().unwrap();
|
||
assert_eq!(arr.len(), 3); // 两个文本块 + 一个通知块
|
||
|
||
// 检查通知内容
|
||
let notice_block = arr[2].as_object().unwrap();
|
||
assert_eq!(notice_block["type"], "text");
|
||
let notice_text = notice_block["text"].as_str().unwrap();
|
||
assert!(notice_text.contains("[系统提示] 以下图片未能成功入模"));
|
||
assert!(notice_text.contains("第 1 张图片"));
|
||
assert!(notice_text.contains("当前模型不支持图片输入"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_convert_content_blocks_keeps_images_when_enabled() {
|
||
let blocks = vec![
|
||
ContentBlock::text("hello"),
|
||
ContentBlock::image_url("data:image/png;base64,abc123"),
|
||
];
|
||
|
||
let result = convert_content_blocks(true, "test", "test-model", &blocks, 0);
|
||
|
||
// 应该是数组形式,包含文本和图片
|
||
let arr = result.as_array().unwrap();
|
||
assert_eq!(arr.len(), 2);
|
||
assert_eq!(arr[0]["type"], "text");
|
||
assert_eq!(arr[1]["type"], "image_url");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_request_body_omits_supported_content_types_from_api() {
|
||
let provider = OpenAIProvider::new(
|
||
"test".to_string(),
|
||
"key".to_string(),
|
||
"https://example.com/v1".to_string(),
|
||
HashMap::new(),
|
||
120,
|
||
"gpt-test".to_string(),
|
||
None,
|
||
None,
|
||
HashMap::from([
|
||
(
|
||
"supported_content_types".to_string(),
|
||
Value::Array(vec![Value::String("text".to_string())]),
|
||
),
|
||
(
|
||
"custom_param".to_string(),
|
||
Value::String("value".to_string()),
|
||
),
|
||
]),
|
||
);
|
||
|
||
let request = ChatCompletionRequest {
|
||
messages: vec![Message::user("hello")],
|
||
temperature: None,
|
||
max_tokens: None,
|
||
tools: None,
|
||
};
|
||
|
||
let body = provider.build_request_body(&request);
|
||
|
||
// supported_content_types 不应该发送到 API
|
||
assert!(body.get("supported_content_types").is_none());
|
||
// custom_param 应该保留
|
||
assert_eq!(body["custom_param"], Value::String("value".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_request_body_strips_tool_calls_when_not_immediately_followed() {
|
||
// [assistant(tool_calls=[A]), user, tool(A)] → should strip tool_calls
|
||
// The tool result exists but a user message interrupts between assistant
|
||
// and tool result. The API would reject this with
|
||
// "insufficient tool messages following tool_calls message".
|
||
let provider = OpenAIProvider::new(
|
||
"test".to_string(),
|
||
"key".to_string(),
|
||
"https://example.com/v1".to_string(),
|
||
HashMap::new(),
|
||
120,
|
||
"gpt-test".to_string(),
|
||
None,
|
||
None,
|
||
HashMap::new(),
|
||
);
|
||
|
||
let request = ChatCompletionRequest {
|
||
messages: vec![
|
||
Message {
|
||
role: "assistant".to_string(),
|
||
content: vec![ContentBlock::text("calling tool")],
|
||
reasoning_content: None,
|
||
tool_call_id: None,
|
||
name: None,
|
||
tool_calls: Some(vec![ToolCall {
|
||
id: "call_A".to_string(),
|
||
name: "search".to_string(),
|
||
arguments: json!({}),
|
||
}]),
|
||
},
|
||
Message {
|
||
role: "user".to_string(),
|
||
content: vec![ContentBlock::text("interrupting message")],
|
||
reasoning_content: None,
|
||
tool_call_id: None,
|
||
name: None,
|
||
tool_calls: None,
|
||
},
|
||
Message {
|
||
role: "tool".to_string(),
|
||
content: vec![ContentBlock::text("result")],
|
||
reasoning_content: None,
|
||
tool_call_id: Some("call_A".to_string()),
|
||
name: Some("search".to_string()),
|
||
tool_calls: None,
|
||
},
|
||
],
|
||
temperature: None,
|
||
max_tokens: None,
|
||
tools: None,
|
||
};
|
||
|
||
let body = provider.build_request_body(&request);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
|
||
// Assistant should NOT have tool_calls (stripped because not immediately followed)
|
||
assert!(
|
||
messages[0].get("tool_calls").is_none(),
|
||
"tool_calls should be stripped when tool results are not immediately following"
|
||
);
|
||
// Tool message should be dropped (orphaned after stripping)
|
||
assert_eq!(
|
||
messages.len(),
|
||
2,
|
||
"tool message should be dropped as orphaned, got {} messages",
|
||
messages.len()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_request_body_preserves_tool_calls_when_immediately_followed() {
|
||
// [assistant(tool_calls=[A]), tool(A)] → should keep tool_calls (valid sequence)
|
||
let provider = OpenAIProvider::new(
|
||
"test".to_string(),
|
||
"key".to_string(),
|
||
"https://example.com/v1".to_string(),
|
||
HashMap::new(),
|
||
120,
|
||
"gpt-test".to_string(),
|
||
None,
|
||
None,
|
||
HashMap::new(),
|
||
);
|
||
|
||
let request = ChatCompletionRequest {
|
||
messages: vec![
|
||
Message {
|
||
role: "assistant".to_string(),
|
||
content: vec![ContentBlock::text("calling tool")],
|
||
reasoning_content: None,
|
||
tool_call_id: None,
|
||
name: None,
|
||
tool_calls: Some(vec![ToolCall {
|
||
id: "call_A".to_string(),
|
||
name: "search".to_string(),
|
||
arguments: json!({}),
|
||
}]),
|
||
},
|
||
Message {
|
||
role: "tool".to_string(),
|
||
content: vec![ContentBlock::text("result")],
|
||
reasoning_content: None,
|
||
tool_call_id: Some("call_A".to_string()),
|
||
name: Some("search".to_string()),
|
||
tool_calls: None,
|
||
},
|
||
],
|
||
temperature: None,
|
||
max_tokens: None,
|
||
tools: None,
|
||
};
|
||
|
||
let body = provider.build_request_body(&request);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
|
||
// Assistant should keep tool_calls (valid immediate sequence)
|
||
let tool_calls = messages[0]
|
||
.get("tool_calls")
|
||
.and_then(|t| t.as_array())
|
||
.expect("tool_calls should be preserved when immediately followed");
|
||
assert_eq!(tool_calls.len(), 1);
|
||
// Tool message should be present
|
||
assert_eq!(messages.len(), 2);
|
||
assert_eq!(messages[1]["role"], "tool");
|
||
}
|
||
}
|