修复: DeepSeek reasoning_content 全链路透传(跨6 crate补字段+映射+agentic loop累积回传)
This commit is contained in:
@@ -36,6 +36,9 @@ pub struct CompletionRequest {
|
||||
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
/// DeepSeek thinking 模式:需把上一轮 response 的 reasoning_content 回传
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。
|
||||
@@ -115,29 +118,32 @@ pub struct ChatMessage {
|
||||
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
/// DeepSeek thinking 模式的推理内容(多轮需回传)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None }
|
||||
Self { role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None }
|
||||
}
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None }
|
||||
Self { role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None }
|
||||
}
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None }
|
||||
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None }
|
||||
}
|
||||
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None }
|
||||
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None }
|
||||
}
|
||||
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None }
|
||||
Self { role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None }
|
||||
}
|
||||
|
||||
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。
|
||||
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
|
||||
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
|
||||
Self { role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None }
|
||||
Self { role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None }
|
||||
}
|
||||
|
||||
/// 是否含图片片(供 provider 判定走多模态分支)。
|
||||
@@ -249,6 +255,9 @@ pub struct CompletionResponse {
|
||||
/// AI 发起的工具调用(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
/// DeepSeek thinking 模式响应中的推理内容(需回传到下一轮请求)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// Token 用量
|
||||
@@ -276,6 +285,9 @@ pub struct StreamChunk {
|
||||
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
|
||||
#[serde(skip)]
|
||||
pub error: Option<String>,
|
||||
/// DeepSeek thinking 模式推理内容增量
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// 工具调用增量(流式中的片段)
|
||||
@@ -449,8 +461,98 @@ mod tests {
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
assert_eq!(m.content, "字面量构造");
|
||||
assert!(m.parts.is_none());
|
||||
}
|
||||
|
||||
// ---------- DeepSeek reasoning_content ----------
|
||||
|
||||
/// 有 reasoning_content 时应序列化出来;None 时不出现
|
||||
#[test]
|
||||
fn completion_request_reasoning_content_serialization() {
|
||||
let req = CompletionRequest {
|
||||
model: "deepseek-r1".to_string(),
|
||||
messages: vec![ChatMessage::user("hello")],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: Some("let me think...".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("reasoning_content"), "reasoning_content 应出现在 JSON 中, got: {}", json);
|
||||
|
||||
// None 时不应出现(skip_serializing_if)
|
||||
let req_none = CompletionRequest { reasoning_content: None, ..req };
|
||||
let json_none = serde_json::to_string(&req_none).unwrap();
|
||||
assert!(!json_none.contains("reasoning_content"), "None 的 reasoning_content 不应序列化, got: {}", json_none);
|
||||
}
|
||||
|
||||
/// 所有便捷构造函数默认 reasoning_content 为 None
|
||||
#[test]
|
||||
fn chat_message_reasoning_content_constructors() {
|
||||
assert!(ChatMessage::system("sys").reasoning_content.is_none());
|
||||
assert!(ChatMessage::user("hi").reasoning_content.is_none());
|
||||
assert!(ChatMessage::assistant("resp").reasoning_content.is_none());
|
||||
assert!(ChatMessage::tool_result("call_1", "result").reasoning_content.is_none());
|
||||
}
|
||||
|
||||
/// ChatMessage reasoning_content 序列化 round-trip
|
||||
#[test]
|
||||
fn chat_message_reasoning_content_roundtrip() {
|
||||
let m = ChatMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: "answer".to_string(),
|
||||
parts: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: Some("thinking process".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
let deserialized: ChatMessage = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
|
||||
}
|
||||
|
||||
/// StreamChunk reasoning_content 序列化
|
||||
#[test]
|
||||
fn stream_chunk_reasoning_content() {
|
||||
let chunk = StreamChunk {
|
||||
delta: "text".to_string(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: Some("thought".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&chunk).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
let chunk_none = StreamChunk { reasoning_content: None, ..chunk };
|
||||
let json_none = serde_json::to_string(&chunk_none).unwrap();
|
||||
assert!(!json_none.contains("reasoning_content"));
|
||||
}
|
||||
|
||||
/// CompletionResponse reasoning_content 序列化
|
||||
#[test]
|
||||
fn completion_response_reasoning_content() {
|
||||
let resp = CompletionResponse {
|
||||
text: "ok".to_string(),
|
||||
model: "r1".to_string(),
|
||||
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
tool_calls: None,
|
||||
reasoning_content: Some("r1 thought".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
let v: serde_json::Value = match serde_json::from_str(data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
|
||||
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
}
|
||||
};
|
||||
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
@@ -128,7 +128,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
total_tokens: inp as u32,
|
||||
});
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
}
|
||||
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
|
||||
"message_delta" => {
|
||||
@@ -138,14 +138,14 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
acc.completion_tokens = out as u32;
|
||||
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
}
|
||||
// 文本增量
|
||||
"content_block_delta" => {
|
||||
if let Some(delta) = v.get("delta") {
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
|
||||
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None };
|
||||
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None };
|
||||
}
|
||||
// 工具入参增量
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
|
||||
@@ -162,10 +162,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
}]),
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
}
|
||||
// 工具块开始:带 id + name
|
||||
"content_block_start" => {
|
||||
@@ -194,10 +195,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
}]),
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
}
|
||||
// 消息结束:带出累积 usage
|
||||
"message_stop" => StreamChunk {
|
||||
@@ -206,16 +208,17 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
tool_calls: None,
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
},
|
||||
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
|
||||
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
|
||||
"error" => {
|
||||
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string();
|
||||
error!(%msg, "Anthropic 流式错误事件");
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg) }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None }
|
||||
}
|
||||
// content_block_stop / ping 等不产出 chunk
|
||||
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None },
|
||||
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,6 +544,7 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
model: resp.model,
|
||||
usage,
|
||||
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
|
||||
reasoning_content: None,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -808,6 +812,7 @@ mod tests {
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
// user 消息 content 应为数组形态
|
||||
@@ -839,6 +844,7 @@ mod tests {
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
let user_msg = body
|
||||
|
||||
@@ -44,6 +44,9 @@ struct OpenAiRequest {
|
||||
/// 流式时请求末 chunk 携带 usage(OpenAI 官方 + DeepSeek/GLM 兼容)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream_options: Option<serde_json::Value>,
|
||||
/// DeepSeek thinking 模式
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 消息格式
|
||||
@@ -58,6 +61,9 @@ struct OpenAiMessage {
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<serde_json::Value>>,
|
||||
/// DeepSeek thinking 模式推理内容
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 同步响应
|
||||
@@ -78,6 +84,9 @@ struct OpenAiChoice {
|
||||
struct OpenAiMessageResp {
|
||||
content: Option<String>,
|
||||
tool_calls: Option<Vec<OpenAiToolCallResp>>,
|
||||
/// DeepSeek thinking 模式响应中的推理内容
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -120,6 +129,9 @@ struct OpenAiStreamChoice {
|
||||
struct OpenAiStreamDelta {
|
||||
content: Option<String>,
|
||||
tool_calls: Option<Vec<OpenAiStreamToolCall>>,
|
||||
/// DeepSeek thinking 模式流式推理内容
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -158,6 +170,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
tool_calls: None,
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,6 +208,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
tool_calls,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: choice.delta.reasoning_content,
|
||||
}
|
||||
} else {
|
||||
// choices 为空 = usage-only chunk,不输出文本(usage 已累积)
|
||||
@@ -204,6 +218,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,6 +230,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -371,6 +387,7 @@ impl OpenAICompatProvider {
|
||||
content,
|
||||
tool_call_id: m.tool_call_id,
|
||||
tool_calls,
|
||||
reasoning_content: m.reasoning_content,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -389,6 +406,7 @@ impl OpenAICompatProvider {
|
||||
stream: req.stream,
|
||||
tools,
|
||||
tool_choice: req.tool_choice,
|
||||
reasoning_content: req.reasoning_content,
|
||||
// 流式请求末 chunk 带 usage(同步调用 complete 不需要)
|
||||
stream_options: if req.stream {
|
||||
Some(serde_json::json!({ "include_usage": true }))
|
||||
@@ -509,6 +527,7 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
model: body.model,
|
||||
usage,
|
||||
tool_calls,
|
||||
reasoning_content: choice.message.reasoning_content,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -755,6 +774,7 @@ mod tests {
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let out = provider.convert_request(req);
|
||||
let msg = &out.messages[0];
|
||||
@@ -783,6 +803,7 @@ mod tests {
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let out = provider.convert_request(req);
|
||||
let msg = &out.messages[0];
|
||||
|
||||
@@ -283,6 +283,7 @@ impl Node for AiNode {
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
@@ -513,6 +514,7 @@ impl Node for AiSelfReviewNode {
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
@@ -924,6 +926,7 @@ mod tests {
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let response = provider.complete(request).await.expect("GLM 调用失败");
|
||||
assert!(!response.text.is_empty(), "GLM 返回空文本");
|
||||
|
||||
Reference in New Issue
Block a user