Files
Aether/apps/aether-gateway/src/ai_serving/planner/standard/deepseek.rs
T

617 lines
20 KiB
Rust

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;
}
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)
}
pub(crate) fn openai_responses_reasoning_replay_policy(
provider_type: &str,
base_url: &str,
provider_model: &str,
) -> crate::ai_serving::OpenAiResponsesReasoningReplayPolicy {
if is_deepseek_upstream(provider_type, base_url, provider_model) {
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
} else {
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds
}
}
pub(crate) fn apply_deepseek_tool_call_thinking_compat(
provider_request_body: &mut Value,
provider_type: &str,
base_url: &str,
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) {
return;
}
match crate::ai_serving::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" => {
apply_deepseek_openai_chat_thinking_compat(provider_request_body, original_request_body)
}
"claude:messages" => apply_deepseek_claude_messages_thinking_compat(
provider_request_body,
original_request_body,
),
_ => {}
}
}
fn base_url_host(base_url: &str) -> Option<String> {
let base_url = base_url.trim();
if base_url.is_empty() {
return None;
}
// Provider configuration historically accepted both absolute URLs and a
// bare authority/path. Use a real URL parser for both forms: hand-parsing
// userinfo with `rsplit_once('@')` can mistake an `@` in the path or query
// for the authority delimiter and classify an attacker-controlled host as
// `api.deepseek.com`.
if let Ok(parsed) = url::Url::parse(base_url) {
if let Some(host) = parsed
.host_str()
.filter(|_| matches!(parsed.scheme(), "http" | "https" | "ws" | "wss"))
{
return Some(host.to_ascii_lowercase());
}
if base_url.contains("://") {
return None;
}
}
url::Url::parse(&format!("https://{base_url}"))
.ok()?
.host_str()
.map(str::to_ascii_lowercase)
}
fn source_disables_thinking(
original_request_body: Option<&Value>,
provider_request_body: &Value,
) -> bool {
request_explicitly_disables_thinking(provider_request_body)
|| original_request_body.is_some_and(request_explicitly_disables_thinking)
}
fn request_explicitly_disables_thinking(body: &Value) -> bool {
thinking_type(body).is_some_and(|value| value.eq_ignore_ascii_case("disabled"))
|| reasoning_effort(body).is_some_and(|value| value.eq_ignore_ascii_case("none"))
}
fn thinking_type(body: &Value) -> Option<&str> {
body.get("thinking")
.and_then(Value::as_object)
.and_then(|thinking| thinking.get("type"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn reasoning_effort(body: &Value) -> Option<&str> {
body.get("reasoning_effort")
.and_then(Value::as_str)
.or_else(|| {
body.get("reasoning")
.and_then(Value::as_object)
.and_then(|reasoning| reasoning.get("effort"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn set_deepseek_thinking_type(body: &mut Value, thinking_type: &str) {
let Some(object) = body.as_object_mut() else {
return;
};
match object.get_mut("thinking") {
Some(Value::Object(thinking)) => {
thinking.insert("type".to_string(), Value::String(thinking_type.to_string()));
}
_ => {
object.insert(
"thinking".to_string(),
json!({
"type": thinking_type,
}),
);
}
}
}
fn apply_deepseek_openai_chat_thinking_compat(
provider_request_body: &mut Value,
original_request_body: Option<&Value>,
) {
let disabled = source_disables_thinking(original_request_body, provider_request_body);
set_deepseek_thinking_type(
provider_request_body,
if disabled { "disabled" } else { "enabled" },
);
let Some(object) = provider_request_body.as_object_mut() else {
return;
};
if disabled {
if reasoning_effort(&Value::Object(object.clone()))
.is_some_and(|value| value.eq_ignore_ascii_case("none"))
{
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()),
);
}
}
fn apply_deepseek_claude_messages_thinking_compat(
provider_request_body: &mut Value,
original_request_body: Option<&Value>,
) {
if source_disables_thinking(original_request_body, provider_request_body) {
set_deepseek_thinking_type(provider_request_body, "disabled");
return;
}
let Some(messages) = provider_request_body
.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;
}
ensure_claude_assistant_message_has_thinking_block(message_object);
}
}
fn ensure_claude_assistant_message_has_thinking_block(
message: &mut serde_json::Map<String, Value>,
) {
let thinking_block = json!({
"type": "thinking",
"thinking": "",
});
match message.get_mut("content") {
Some(Value::Array(blocks)) => {
if blocks.iter().any(is_claude_thinking_block) {
return;
}
blocks.insert(0, thinking_block);
}
Some(Value::String(text)) => {
let text = std::mem::take(text);
message.insert(
"content".to_string(),
Value::Array(vec![
thinking_block,
json!({
"type": "text",
"text": text,
}),
]),
);
}
Some(Value::Null) | None => {
message.insert("content".to_string(), Value::Array(vec![thinking_block]));
}
Some(other) => {
let existing = std::mem::take(other);
message.insert(
"content".to_string(),
Value::Array(vec![thinking_block, existing]),
);
}
}
}
fn is_claude_thinking_block(block: &Value) -> bool {
block
.get("type")
.and_then(Value::as_str)
.is_some_and(|block_type| block_type.trim().eq_ignore_ascii_case("thinking"))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
apply_deepseek_tool_call_thinking_compat, is_deepseek_provider,
openai_responses_reasoning_replay_policy,
};
#[test]
fn detects_deepseek_provider_by_type_or_host() {
assert!(is_deepseek_provider(
"deepseek",
"https://relay.example.com"
));
assert!(is_deepseek_provider(
"custom",
"https://api.deepseek.com/v1"
));
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://example.com/deepseek"
));
assert!(!is_deepseek_provider(
"custom",
"https://api.deepseek.com.evil.example/v1"
));
assert!(!is_deepseek_provider(
"custom",
"https://[email protected]/v1"
));
assert!(!is_deepseek_provider(
"custom",
"https://evil.example/[email protected]/v1"
));
assert!(!is_deepseek_provider(
"custom",
"https://evil.example/[email protected]"
));
assert!(!is_deepseek_provider("custom", "ftp://api.deepseek.com/v1"));
assert_eq!(
openai_responses_reasoning_replay_policy(
"custom",
"https://api.deepseek.com/v1",
"deepseek-v4-flash",
),
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
);
assert_eq!(
openai_responses_reasoning_replay_policy(
"openai",
"https://api.openai.com/v1",
"gpt-5.6-sol",
),
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds
);
assert_eq!(
openai_responses_reasoning_replay_policy(
"custom",
"https://api.b.ai/v1",
"deepseek-v4-flash",
),
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
);
assert_eq!(
openai_responses_reasoning_replay_policy(
"custom",
"https://api.b.ai/v1",
"not-deepseek-compatible",
),
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds
);
}
#[test]
fn custom_deepseek_host_preserves_production_shaped_opaque_reasoning_replay() {
let reasoning_items = (0..66)
.map(|index| {
json!({
"type": "reasoning",
"encrypted_content": format!("550e8400-e29b-41d4-a716-{index:012}"),
"content": [{
"type": "reasoning_text",
"text": format!("opaque DeepSeek reasoning {index}")
}]
})
})
.collect::<Vec<_>>();
let request = json!({
"model": "deepseek-v4-flash",
"input": reasoning_items.clone(),
"future_request_field": {"preserve": true}
});
let replay_policy = openai_responses_reasoning_replay_policy(
"custom",
"https://api.deepseek.com/v1",
"deepseek-v4-flash",
);
let mut provider_body = crate::ai_serving::build_standard_request_body_with_model_directives_and_request_headers_and_reasoning_replay_policy(
&request,
"openai:responses",
"deepseek-v4-flash",
"custom",
"openai:responses",
"/v1/responses",
false,
None,
None,
None,
false,
replay_policy,
)
.expect("custom DeepSeek Responses body should build");
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy(
&mut provider_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: "openai:responses",
provider_api_format: "openai:responses",
provider_type: "custom",
provider_model: "deepseek-v4-flash",
source_model: "deepseek-v4-flash",
body_rules: None,
upstream_is_stream: false,
require_body_stream_field: false,
},
None,
replay_policy,
)
.expect("custom DeepSeek finalization should accept opaque reasoning replay");
assert_eq!(provider_body["input"].as_array().map(Vec::len), Some(66));
assert_eq!(provider_body["future_request_field"]["preserve"], true);
let mut deepseek = json!({"input": reasoning_items.clone()});
let mut openai = json!({"input": reasoning_items});
assert_eq!(
crate::ai_serving::strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut deepseek,
"openai:responses",
openai_responses_reasoning_replay_policy(
"custom",
"https://api.deepseek.com/v1",
"deepseek-v4-flash",
),
),
0
);
assert_eq!(deepseek["input"].as_array().map(Vec::len), Some(66));
assert_eq!(
crate::ai_serving::strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut openai,
"openai:responses",
openai_responses_reasoning_replay_policy(
"openai",
"https://api.openai.com/v1",
"gpt-5.6-sol",
),
),
66
);
assert_eq!(openai["input"].as_array().map(Vec::len), Some(0));
}
#[test]
fn openai_chat_deepseek_adds_thinking_and_empty_reasoning_content() {
let mut body = json!({
"model": "deepseek-chat",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": null, "tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"}
}]},
{"role": "tool", "tool_call_id": "call_1", "content": "{}"}
]
});
apply_deepseek_tool_call_thinking_compat(
&mut body,
"deepseek",
"https://api.deepseek.com/v1",
"openai:chat",
None,
);
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["messages"][1]["reasoning_content"], "");
}
#[test]
fn custom_relay_deepseek_model_adds_chat_thinking_compat() {
let mut body = json!({
"model": "deepseek-v4-flash",
"messages": [
{"role": "user", "content": "inspect the repository"},
{"role": "assistant", "content": null, "tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "inspect", "arguments": "{}"}
}]},
{"role": "tool", "tool_call_id": "call_1", "content": "done"}
]
});
apply_deepseek_tool_call_thinking_compat(
&mut body,
"custom",
"https://api.b.ai/v1",
"openai:chat",
None,
);
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["messages"][1]["reasoning_content"], "");
}
#[test]
fn custom_relay_non_deepseek_model_is_not_rewritten() {
let original = json!({
"model": "not-deepseek-compatible",
"messages": [{"role": "assistant", "content": "done"}]
});
let mut body = original.clone();
apply_deepseek_tool_call_thinking_compat(
&mut body,
"custom",
"https://api.b.ai/v1",
"openai:chat",
None,
);
assert_eq!(body, original);
}
#[test]
fn openai_chat_deepseek_honors_disabled_thinking() {
let original = json!({"reasoning_effort": "none"});
let mut body = json!({
"model": "deepseek-chat",
"reasoning_effort": "none",
"messages": [
{"role": "assistant", "content": "hi"}
]
});
apply_deepseek_tool_call_thinking_compat(
&mut body,
"deepseek",
"https://api.deepseek.com/v1",
"openai:chat",
Some(&original),
);
assert_eq!(body["thinking"]["type"], "disabled");
assert!(body.get("reasoning_effort").is_none());
assert!(body["messages"][0].get("reasoning_content").is_none());
}
#[test]
fn claude_messages_deepseek_prepends_empty_thinking_block() {
let mut body = json!({
"model": "deepseek-3.2",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": [
{"type": "tool_use", "id": "call_1", "name": "lookup", "input": {}}
]}
]
});
apply_deepseek_tool_call_thinking_compat(
&mut body,
"deepseek",
"https://api.deepseek.com",
"claude:messages",
None,
);
assert_eq!(body["messages"][1]["content"][0]["type"], "thinking");
assert_eq!(body["messages"][1]["content"][0]["thinking"], "");
assert_eq!(body["messages"][1]["content"][1]["type"], "tool_use");
}
#[test]
fn claude_messages_deepseek_converts_string_assistant_content_to_blocks() {
let mut body = json!({
"model": "deepseek-3.2",
"messages": [{
"role": "assistant",
"content": "done"
}]
});
apply_deepseek_tool_call_thinking_compat(
&mut body,
"deepseek",
"https://api.deepseek.com",
"claude:messages",
None,
);
assert_eq!(body["messages"][0]["content"][0]["type"], "thinking");
assert_eq!(body["messages"][0]["content"][1]["type"], "text");
assert_eq!(body["messages"][0]["content"][1]["text"], "done");
}
#[test]
fn claude_messages_deepseek_preserves_existing_thinking_block() {
let mut body = json!({
"model": "deepseek-3.2",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "plan", "signature": "sig"},
{"type": "text", "text": "answer"}
]
}]
});
apply_deepseek_tool_call_thinking_compat(
&mut body,
"deepseek",
"https://api.deepseek.com",
"claude:messages",
None,
);
assert_eq!(body["messages"][0]["content"].as_array().unwrap().len(), 2);
assert_eq!(body["messages"][0]["content"][0]["thinking"], "plan");
assert_eq!(body["messages"][0]["content"][0]["signature"], "sig");
}
}