mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-16 16:07:45 +08:00
Merge pull request #813 from AAEE86/fix-deepseek-reasoning-replay
fix(deepseek): 仅对官方地址启用思考兼容并保留历史内容
This commit is contained in:
@@ -1,39 +1,19 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub(crate) fn is_deepseek_provider(provider_type: &str, base_url: &str) -> bool {
|
||||
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||||
if matches!(
|
||||
provider_type.as_str(),
|
||||
"deepseek" | "deepseek_openai" | "deepseek_anthropic" | "deepseek_compatible"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
pub(crate) fn is_deepseek_provider(_provider_type: &str, base_url: &str) -> bool {
|
||||
let Some(host) = base_url_host(base_url) else {
|
||||
return false;
|
||||
};
|
||||
host == "deepseek.com" || host.ends_with(".deepseek.com")
|
||||
}
|
||||
|
||||
fn is_deepseek_model(provider_model: &str) -> bool {
|
||||
let provider_model = provider_model.trim().to_ascii_lowercase();
|
||||
let leaf = provider_model
|
||||
.rsplit(['/', ':'])
|
||||
.next()
|
||||
.unwrap_or(provider_model.as_str());
|
||||
leaf == "deepseek" || leaf.starts_with("deepseek-") || leaf.starts_with("deepseek_")
|
||||
}
|
||||
|
||||
fn is_deepseek_upstream(provider_type: &str, base_url: &str, provider_model: &str) -> bool {
|
||||
is_deepseek_provider(provider_type, base_url) || is_deepseek_model(provider_model)
|
||||
// 仅官方接口启用专用兼容;供应商类型和模型名称不能代表第三方接口的行为。
|
||||
host == "api.deepseek.com"
|
||||
}
|
||||
|
||||
pub(crate) fn openai_responses_reasoning_replay_policy(
|
||||
provider_type: &str,
|
||||
base_url: &str,
|
||||
provider_model: &str,
|
||||
_provider_model: &str,
|
||||
) -> crate::ai_serving::OpenAiResponsesReasoningReplayPolicy {
|
||||
if is_deepseek_upstream(provider_type, base_url, provider_model) {
|
||||
if is_deepseek_provider(provider_type, base_url) {
|
||||
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
|
||||
} else {
|
||||
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds
|
||||
@@ -47,11 +27,7 @@ pub(crate) fn apply_deepseek_tool_call_thinking_compat(
|
||||
provider_api_format: &str,
|
||||
original_request_body: Option<&Value>,
|
||||
) {
|
||||
let provider_model = provider_request_body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if !is_deepseek_upstream(provider_type, base_url, provider_model) {
|
||||
if !is_deepseek_provider(provider_type, base_url) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,6 +131,9 @@ fn apply_deepseek_openai_chat_thinking_compat(
|
||||
provider_request_body: &mut Value,
|
||||
original_request_body: Option<&Value>,
|
||||
) {
|
||||
// 携带 tools 时,所有历史 reasoning_content 都须完整回传,包括未调用工具的轮次。
|
||||
// 无 tools 时允许回传,且 prefix 续写需要保留输入;因此原样保留 messages,
|
||||
// 不删除思考内容,也不以空字符串冒充缺失内容,由上游校验请求是否完整。
|
||||
let disabled = source_disables_thinking(original_request_body, provider_request_body);
|
||||
set_deepseek_thinking_type(
|
||||
provider_request_body,
|
||||
@@ -170,33 +149,6 @@ fn apply_deepseek_openai_chat_thinking_compat(
|
||||
{
|
||||
object.remove("reasoning_effort");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(messages) = object.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
for message in messages {
|
||||
let Some(message_object) = message.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
let is_assistant = message_object
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|role| role.trim().eq_ignore_ascii_case("assistant"));
|
||||
if !is_assistant {
|
||||
continue;
|
||||
}
|
||||
if message_object
|
||||
.get("reasoning_content")
|
||||
.is_some_and(|value| !value.is_null())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
message_object.insert(
|
||||
"reasoning_content".to_string(),
|
||||
Value::String(String::new()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,8 +239,8 @@ mod tests {
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn detects_deepseek_provider_by_type_or_host() {
|
||||
assert!(is_deepseek_provider(
|
||||
fn detects_deepseek_provider_only_by_official_host() {
|
||||
assert!(!is_deepseek_provider(
|
||||
"deepseek",
|
||||
"https://relay.example.com"
|
||||
));
|
||||
@@ -298,6 +250,16 @@ mod tests {
|
||||
));
|
||||
assert!(is_deepseek_provider("custom", "api.deepseek.com/v1"));
|
||||
assert!(is_deepseek_provider("custom", "api.deepseek.com:443/v1"));
|
||||
assert!(!is_deepseek_provider("custom", "https://deepseek.com"));
|
||||
assert!(!is_deepseek_provider("custom", "deepseek.com/v1"));
|
||||
assert!(is_deepseek_provider(
|
||||
"custom",
|
||||
" HTTPS://API.DEEPSEEK.COM:443/beta "
|
||||
));
|
||||
assert!(!is_deepseek_provider(
|
||||
"deepseek",
|
||||
"https://other.deepseek.com/v1"
|
||||
));
|
||||
assert!(!is_deepseek_provider(
|
||||
"custom",
|
||||
"https://example.com/deepseek"
|
||||
@@ -319,6 +281,15 @@ mod tests {
|
||||
"https://evil.example/?relay=@api.deepseek.com"
|
||||
));
|
||||
assert!(!is_deepseek_provider("custom", "ftp://api.deepseek.com/v1"));
|
||||
assert!(!is_deepseek_provider("deepseek", ""));
|
||||
assert_eq!(
|
||||
openai_responses_reasoning_replay_policy(
|
||||
"deepseek",
|
||||
"https://deepseek.com/v1",
|
||||
"deepseek-chat",
|
||||
),
|
||||
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds
|
||||
);
|
||||
assert_eq!(
|
||||
openai_responses_reasoning_replay_policy(
|
||||
"custom",
|
||||
@@ -341,7 +312,7 @@ mod tests {
|
||||
"https://api.b.ai/v1",
|
||||
"deepseek-v4-flash",
|
||||
),
|
||||
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
|
||||
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds
|
||||
);
|
||||
assert_eq!(
|
||||
openai_responses_reasoning_replay_policy(
|
||||
@@ -353,6 +324,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_deepseek_host_enables_compat_without_type_or_model_hints() {
|
||||
for base_url in [
|
||||
"https://api.deepseek.com/v1",
|
||||
"https://api.deepseek.com/beta",
|
||||
] {
|
||||
let mut body = json!({
|
||||
"model": "mapped-model",
|
||||
"messages": [{"role": "assistant", "content": "answer"}]
|
||||
});
|
||||
|
||||
apply_deepseek_tool_call_thinking_compat(
|
||||
&mut body,
|
||||
"custom",
|
||||
base_url,
|
||||
"openai:chat",
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(
|
||||
openai_responses_reasoning_replay_policy("custom", base_url, "mapped-model"),
|
||||
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_deepseek_host_preserves_production_shaped_opaque_reasoning_replay() {
|
||||
let reasoning_items = (0..66)
|
||||
@@ -444,7 +442,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_deepseek_adds_thinking_and_empty_reasoning_content() {
|
||||
fn openai_chat_deepseek_enables_thinking_without_fabricating_reasoning() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
@@ -467,11 +465,11 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["messages"][1]["reasoning_content"], "");
|
||||
assert!(body["messages"][1].get("reasoning_content").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_relay_deepseek_model_adds_chat_thinking_compat() {
|
||||
fn custom_relay_deepseek_model_preserves_chat_request() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"messages": [
|
||||
@@ -484,6 +482,7 @@ mod tests {
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "done"}
|
||||
]
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
apply_deepseek_tool_call_thinking_compat(
|
||||
&mut body,
|
||||
@@ -493,8 +492,211 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn third_party_hosts_ignore_deepseek_type_and_model_hints() {
|
||||
for provider_type in [
|
||||
"custom",
|
||||
"deepseek",
|
||||
"deepseek_openai",
|
||||
"deepseek_anthropic",
|
||||
"deepseek_compatible",
|
||||
] {
|
||||
for provider_model in [
|
||||
"other-model",
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
"deepseek-v3",
|
||||
"deepseek-v4-flash",
|
||||
"vendor/deepseek-chat",
|
||||
"vendor:deepseek-reasoner",
|
||||
] {
|
||||
let base_url = "https://relay.example.com/v1";
|
||||
assert!(!is_deepseek_provider(provider_type, base_url));
|
||||
assert_eq!(
|
||||
openai_responses_reasoning_replay_policy(
|
||||
provider_type,
|
||||
base_url,
|
||||
provider_model
|
||||
),
|
||||
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds
|
||||
);
|
||||
|
||||
for api_format in ["openai:chat", "claude:messages"] {
|
||||
let original = json!({
|
||||
"model": provider_model,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": "answer",
|
||||
"reasoning_content": "original plan"
|
||||
}]
|
||||
});
|
||||
let mut body = original.clone();
|
||||
|
||||
apply_deepseek_tool_call_thinking_compat(
|
||||
&mut body,
|
||||
provider_type,
|
||||
base_url,
|
||||
api_format,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
body, original,
|
||||
"{provider_type} / {provider_model} / {api_format}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_deepseek_preserves_history_without_tools() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Compare 9.11 and 9.8"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "9.8 is greater",
|
||||
"reasoning_content": "Compare the decimal places.\n9.80 > 9.11."
|
||||
},
|
||||
{"role": "user", "content": "Explain again"},
|
||||
{"role": "assistant", "content": "Compare 9.80 with 9.11"}
|
||||
]
|
||||
});
|
||||
let messages = body["messages"].clone();
|
||||
|
||||
apply_deepseek_tool_call_thinking_compat(
|
||||
&mut body,
|
||||
"deepseek",
|
||||
"https://api.deepseek.com/v1",
|
||||
"openai:chat",
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(body["messages"], messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_deepseek_preserves_reasoning_across_all_tool_turns() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-chat",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
}],
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is the weather?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"reasoning_content": "Check the weather before answering.\nKeep this full plan.",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": "{}"}
|
||||
}]
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "Cloudy"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "It is cloudy",
|
||||
"reasoning_content": "The weather result is available; summarize it."
|
||||
},
|
||||
{"role": "user", "content": "Should I take an umbrella?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "An umbrella may be useful",
|
||||
"reasoning_content": "Use the previous weather result without another tool call."
|
||||
},
|
||||
{"role": "user", "content": "Why?"}
|
||||
]
|
||||
});
|
||||
let messages = body["messages"].clone();
|
||||
let tools = body["tools"].clone();
|
||||
|
||||
apply_deepseek_tool_call_thinking_compat(
|
||||
&mut body,
|
||||
"deepseek",
|
||||
"https://api.deepseek.com/v1",
|
||||
"openai:chat",
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(body["messages"], messages);
|
||||
assert_eq!(body["tools"], tools);
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_deepseek_does_not_fabricate_missing_tool_reasoning() {
|
||||
for tools in [
|
||||
json!([]),
|
||||
json!([{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
}]),
|
||||
] {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-chat",
|
||||
"tools": tools,
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "missing"},
|
||||
{"role": "assistant", "content": "null", "reasoning_content": null},
|
||||
{"role": "assistant", "content": "empty", "reasoning_content": ""},
|
||||
{"role": "assistant", "content": "answer", "reasoning_content": "original plan"}
|
||||
]
|
||||
});
|
||||
let messages = body["messages"].clone();
|
||||
|
||||
apply_deepseek_tool_call_thinking_compat(
|
||||
&mut body,
|
||||
"deepseek",
|
||||
"https://api.deepseek.com/v1",
|
||||
"openai:chat",
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(body["messages"], messages);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_deepseek_preserves_reasoning_prefix_without_tools() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is 1 + 1?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"prefix": true,
|
||||
"content": "",
|
||||
"reasoning_content": "Start by adding one to one."
|
||||
}
|
||||
]
|
||||
});
|
||||
let messages = body["messages"].clone();
|
||||
|
||||
apply_deepseek_tool_call_thinking_compat(
|
||||
&mut body,
|
||||
"deepseek",
|
||||
"https://api.deepseek.com/beta",
|
||||
"openai:chat",
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(body["messages"], messages);
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["messages"][1]["reasoning_content"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user