Skip to content

Commit 175bb7c

Browse files
authored
Merge pull request #605 from wsp1911/main
feat(ai): support DeepSeek reasoning effort and preserve empty reasoning replay
2 parents 252ad87 + 7510e64 commit 175bb7c

25 files changed

Lines changed: 660 additions & 55 deletions

File tree

BitFun-Installer/src/data/modelProviders.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export const PROVIDER_TEMPLATES: Record<string, ProviderTemplate> = {
9999
descriptionKey: 'model.providers.deepseek.description',
100100
baseUrl: 'https://api.deepseek.com/v1',
101101
format: 'openai',
102-
models: ['deepseek-chat', 'deepseek-reasoner'],
102+
models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
103103
helpUrl: 'https://platform.deepseek.com/api_keys',
104104
},
105105
zhipu: {

BitFun-Installer/src/i18n/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"installDone": "Installation complete",
5252
"provider": "Provider",
5353
"config": "Connection",
54-
"modelName": "Model name (e.g. deepseek-chat)",
54+
"modelName": "Model name (e.g. deepseek-v4-flash)",
5555
"apiKey": "API key",
5656
"back": "Back",
5757
"skip": "Skip for now",

BitFun-Installer/src/i18n/locales/zh-TW.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"installDone": "安裝完成",
5252
"provider": "服務商",
5353
"config": "連接信息",
54-
"modelName": "模型名稱(如 deepseek-chat",
54+
"modelName": "模型名稱(如 deepseek-v4-flash",
5555
"apiKey": "API Key",
5656
"back": "返回",
5757
"skip": "稍後配置",

BitFun-Installer/src/i18n/locales/zh.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"installDone": "安装完成",
5252
"provider": "服务商",
5353
"config": "连接信息",
54-
"modelName": "模型名称(如 deepseek-chat",
54+
"modelName": "模型名称(如 deepseek-v4-flash",
5555
"apiKey": "API Key",
5656
"back": "返回",
5757
"skip": "稍后配置",

src/crates/ai-adapters/src/client.rs

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,9 +422,82 @@ mod tests {
422422

423423
assert_eq!(request_body["thinking"]["type"], "enabled");
424424
assert!(request_body.get("enable_thinking").is_none());
425+
assert!(request_body.get("reasoning_effort").is_none());
425426
assert!(request_body.get("reasoning_split").is_none());
426427
}
427428

429+
#[test]
430+
fn build_openai_request_body_adds_deepseek_reasoning_effort() {
431+
let client = AIClient::new(AIConfig {
432+
name: "deepseek".to_string(),
433+
base_url: "https://api.deepseek.com/v1".to_string(),
434+
request_url: "https://api.deepseek.com/v1/chat/completions".to_string(),
435+
api_key: "test-key".to_string(),
436+
model: "deepseek-v4-pro".to_string(),
437+
format: "openai".to_string(),
438+
context_window: 128000,
439+
max_tokens: Some(4096),
440+
temperature: None,
441+
top_p: None,
442+
reasoning_mode: ReasoningMode::Enabled,
443+
inline_think_in_text: false,
444+
custom_headers: None,
445+
custom_headers_mode: None,
446+
skip_ssl_verify: false,
447+
reasoning_effort: Some("xhigh".to_string()),
448+
thinking_budget_tokens: None,
449+
custom_request_body: None,
450+
custom_request_body_mode: None,
451+
});
452+
453+
let request_body = openai::chat::build_request_body(
454+
&client,
455+
&client.config.request_url,
456+
vec![json!({ "role": "user", "content": "hello" })],
457+
None,
458+
None,
459+
);
460+
461+
assert_eq!(request_body["thinking"]["type"], "enabled");
462+
assert_eq!(request_body["reasoning_effort"], "max");
463+
}
464+
465+
#[test]
466+
fn build_openai_request_body_omits_deepseek_reasoning_effort_when_disabled() {
467+
let client = AIClient::new(AIConfig {
468+
name: "deepseek".to_string(),
469+
base_url: "https://api.deepseek.com/v1".to_string(),
470+
request_url: "https://api.deepseek.com/v1/chat/completions".to_string(),
471+
api_key: "test-key".to_string(),
472+
model: "deepseek-v4-flash".to_string(),
473+
format: "openai".to_string(),
474+
context_window: 128000,
475+
max_tokens: Some(4096),
476+
temperature: None,
477+
top_p: None,
478+
reasoning_mode: ReasoningMode::Disabled,
479+
inline_think_in_text: false,
480+
custom_headers: None,
481+
custom_headers_mode: None,
482+
skip_ssl_verify: false,
483+
reasoning_effort: Some("max".to_string()),
484+
thinking_budget_tokens: None,
485+
custom_request_body: None,
486+
custom_request_body_mode: None,
487+
});
488+
489+
let request_body = openai::chat::build_request_body(
490+
&client,
491+
&client.config.request_url,
492+
vec![json!({ "role": "user", "content": "hello" })],
493+
None,
494+
None,
495+
);
496+
497+
assert_eq!(request_body["thinking"]["type"], "disabled");
498+
assert!(request_body.get("reasoning_effort").is_none());
499+
}
500+
428501
#[test]
429502
fn build_openai_request_body_uses_enable_thinking_for_siliconflow() {
430503
let client = AIClient::new(AIConfig {
@@ -536,10 +609,52 @@ mod tests {
536609
assert_eq!(request_body["output_config"]["effort"], "high");
537610
}
538611

612+
#[test]
613+
fn build_anthropic_request_body_adds_deepseek_reasoning_effort() {
614+
let client = AIClient::new(AIConfig {
615+
name: "deepseek".to_string(),
616+
base_url: "https://api.deepseek.com/anthropic".to_string(),
617+
request_url: "https://api.deepseek.com/anthropic/v1/messages".to_string(),
618+
api_key: "test-key".to_string(),
619+
model: "deepseek-v4-pro".to_string(),
620+
format: "anthropic".to_string(),
621+
context_window: 200000,
622+
max_tokens: Some(8192),
623+
temperature: None,
624+
top_p: None,
625+
reasoning_mode: ReasoningMode::Enabled,
626+
inline_think_in_text: false,
627+
custom_headers: None,
628+
custom_headers_mode: None,
629+
skip_ssl_verify: false,
630+
reasoning_effort: Some("xhigh".to_string()),
631+
thinking_budget_tokens: None,
632+
custom_request_body: None,
633+
custom_request_body_mode: None,
634+
});
635+
636+
let request_body = anthropic::request::build_request_body(
637+
&client,
638+
&client.config.request_url,
639+
None,
640+
vec![json!({ "role": "user", "content": [{ "type": "text", "text": "hello" }] })],
641+
None,
642+
None,
643+
);
644+
645+
assert_eq!(request_body["thinking"]["type"], "enabled");
646+
assert_eq!(request_body["output_config"]["effort"], "max");
647+
}
648+
539649
#[test]
540650
fn build_openai_request_body_trim_mode_preserves_essential_fields() {
541651
let mut client = make_trim_test_client("openai");
652+
client.config.base_url = "https://api.deepseek.com/v1".to_string();
653+
client.config.request_url = "https://api.deepseek.com/v1/chat/completions".to_string();
654+
client.config.model = "deepseek-v4-pro".to_string();
542655
client.config.max_tokens = Some(8192);
656+
client.config.reasoning_mode = ReasoningMode::Enabled;
657+
client.config.reasoning_effort = Some("high".to_string());
543658
let messages = vec![json!({ "role": "user", "content": "hello" })];
544659

545660
let request_body = openai::chat::build_request_body(
@@ -557,13 +672,14 @@ mod tests {
557672
})),
558673
);
559674

560-
assert_eq!(request_body["model"], "test-model");
675+
assert_eq!(request_body["model"], "deepseek-v4-pro");
561676
assert_eq!(request_body["messages"], json!(messages));
562677
assert_eq!(request_body["stream"], true);
563678
assert_eq!(request_body["max_tokens"], 8192);
564679
assert_eq!(request_body["temperature"], 0.7);
565680
assert_eq!(request_body["response_format"]["type"], "json_object");
566681
assert!(request_body.get("thinking").is_none());
682+
assert!(request_body.get("reasoning_effort").is_none());
567683
}
568684

569685
#[test]

src/crates/ai-adapters/src/client/quirks.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,29 @@ pub(crate) fn is_siliconflow_url(url: &str) -> bool {
88
url.contains("api.siliconflow.cn")
99
}
1010

11+
pub(crate) fn is_deepseek_url(url: &str) -> bool {
12+
url.contains("api.deepseek.com")
13+
}
14+
15+
pub(crate) fn is_deepseek_reasoning_effort_model(model_name: &str) -> bool {
16+
matches!(
17+
model_name.trim().to_ascii_lowercase().as_str(),
18+
"deepseek-v4-flash" | "deepseek-v4-pro"
19+
)
20+
}
21+
22+
pub(crate) fn normalize_deepseek_reasoning_effort(effort: &str) -> Option<&'static str> {
23+
match effort.trim().to_ascii_lowercase().as_str() {
24+
"" => None,
25+
"high" => Some("high"),
26+
"max" => Some("max"),
27+
"low" | "medium" => Some("high"),
28+
"xhigh" => Some("max"),
29+
"none" | "minimal" => None,
30+
_ => Some("high"),
31+
}
32+
}
33+
1134
pub(crate) fn parse_glm_major_minor(model_name: &str) -> Option<(u32, u32)> {
1235
let lower = model_name.to_ascii_lowercase();
1336
let tail = lower.strip_prefix("glm-")?;
@@ -40,7 +63,9 @@ pub(crate) fn should_append_tool_stream(url: &str, model_name: &str) -> bool {
4063
pub(crate) fn apply_openai_compatible_reasoning_fields(
4164
request_body: &mut serde_json::Value,
4265
mode: ReasoningMode,
66+
reasoning_effort: Option<&str>,
4367
url: &str,
68+
model_name: &str,
4469
) {
4570
let normalized_mode = if mode == ReasoningMode::Adaptive {
4671
ReasoningMode::Enabled
@@ -66,4 +91,16 @@ pub(crate) fn apply_openai_compatible_reasoning_fields(
6691
}
6792
ReasoningMode::Adaptive => unreachable!("adaptive mode is normalized above"),
6893
}
94+
95+
if normalized_mode == ReasoningMode::Disabled {
96+
return;
97+
}
98+
99+
if !(is_deepseek_url(url) || is_deepseek_reasoning_effort_model(model_name)) {
100+
return;
101+
}
102+
103+
if let Some(effort) = reasoning_effort.and_then(normalize_deepseek_reasoning_effort) {
104+
request_body["reasoning_effort"] = serde_json::json!(effort);
105+
}
69106
}

src/crates/ai-adapters/src/providers/anthropic/message_converter.rs

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,15 @@ impl AnthropicMessageConverter {
129129
fn convert_assistant_message(msg: Message) -> Option<Value> {
130130
let mut content = Vec::new();
131131

132-
if let Some(thinking) = msg.reasoning_content.as_ref() {
133-
if !thinking.is_empty() {
134-
let mut thinking_block = json!({
135-
"type": "thinking",
136-
"thinking": thinking
137-
});
132+
if msg.reasoning_content.is_some() || msg.thinking_signature.is_some() {
133+
let mut thinking_block = json!({
134+
"type": "thinking",
135+
"thinking": msg.reasoning_content.as_deref().unwrap_or("")
136+
});
138137

139-
thinking_block["signature"] =
140-
json!(msg.thinking_signature.as_deref().unwrap_or(""));
138+
thinking_block["signature"] = json!(msg.thinking_signature.as_deref().unwrap_or(""));
141139

142-
content.push(thinking_block);
143-
}
140+
content.push(thinking_block);
144141
}
145142

146143
if let Some(text) = msg.content {
@@ -230,3 +227,34 @@ impl AnthropicMessageConverter {
230227
})
231228
}
232229
}
230+
231+
#[cfg(test)]
232+
mod tests {
233+
use super::AnthropicMessageConverter;
234+
use crate::types::Message;
235+
use serde_json::json;
236+
237+
#[test]
238+
fn preserves_empty_thinking_block_when_signature_exists() {
239+
let msg = Message {
240+
role: "assistant".to_string(),
241+
content: Some("Answer".to_string()),
242+
reasoning_content: Some(String::new()),
243+
thinking_signature: Some("sig_1".to_string()),
244+
tool_calls: None,
245+
tool_call_id: None,
246+
name: None,
247+
is_error: None,
248+
tool_image_attachments: None,
249+
};
250+
251+
let (_, messages) = AnthropicMessageConverter::convert_messages(vec![msg]);
252+
let content = messages[0]["content"]
253+
.as_array()
254+
.expect("assistant content");
255+
256+
assert_eq!(content[0]["type"], json!("thinking"));
257+
assert_eq!(content[0]["thinking"], json!(""));
258+
assert_eq!(content[0]["signature"], json!("sig_1"));
259+
}
260+
}

src/crates/ai-adapters/src/providers/anthropic/request.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
use super::AnthropicMessageConverter;
2-
use crate::client::quirks::should_append_tool_stream;
2+
use crate::client::quirks::{
3+
is_deepseek_reasoning_effort_model, is_deepseek_url, normalize_deepseek_reasoning_effort,
4+
should_append_tool_stream,
5+
};
36
use crate::client::sse::execute_sse_request;
47
use crate::client::{AIClient, StreamResponse};
58
use crate::providers::shared;
@@ -54,13 +57,26 @@ fn default_anthropic_budget_tokens(max_tokens: Option<u32>) -> Option<u32> {
5457
fn apply_reasoning_fields(
5558
request_body: &mut serde_json::Value,
5659
mode: ReasoningMode,
60+
url: &str,
5761
model_name: &str,
5862
max_tokens: Option<u32>,
5963
reasoning_effort: Option<&str>,
6064
thinking_budget_tokens: Option<u32>,
6165
) {
66+
let is_deepseek_reasoning_target =
67+
is_deepseek_url(url) || is_deepseek_reasoning_effort_model(model_name);
68+
6269
match mode {
63-
ReasoningMode::Default => {}
70+
ReasoningMode::Default => {
71+
if is_deepseek_reasoning_target {
72+
if let Some(effort) = reasoning_effort.and_then(normalize_deepseek_reasoning_effort)
73+
{
74+
request_body["output_config"] = serde_json::json!({
75+
"effort": effort
76+
});
77+
}
78+
}
79+
}
6480
ReasoningMode::Disabled => {
6581
request_body["thinking"] = serde_json::json!({ "type": "disabled" });
6682
}
@@ -74,6 +90,14 @@ fn apply_reasoning_fields(
7490
}
7591
}
7692
request_body["thinking"] = thinking;
93+
if is_deepseek_reasoning_target {
94+
if let Some(effort) = reasoning_effort.and_then(normalize_deepseek_reasoning_effort)
95+
{
96+
request_body["output_config"] = serde_json::json!({
97+
"effort": effort
98+
});
99+
}
100+
}
77101
}
78102
ReasoningMode::Adaptive => {
79103
if anthropic_supports_adaptive_reasoning(model_name) {
@@ -92,6 +116,7 @@ fn apply_reasoning_fields(
92116
apply_reasoning_fields(
93117
request_body,
94118
ReasoningMode::Enabled,
119+
url,
95120
model_name,
96121
max_tokens,
97122
None,
@@ -138,6 +163,7 @@ pub(crate) fn build_request_body(
138163
apply_reasoning_fields(
139164
&mut request_body,
140165
client.config.reasoning_mode,
166+
url,
141167
&model_name,
142168
Some(max_tokens),
143169
client.config.reasoning_effort.as_deref(),

src/crates/ai-adapters/src/providers/openai/common.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@ pub(crate) fn apply_reasoning_fields(
3636
client: &AIClient,
3737
url: &str,
3838
) {
39-
apply_openai_compatible_reasoning_fields(request_body, client.config.reasoning_mode, url);
39+
apply_openai_compatible_reasoning_fields(
40+
request_body,
41+
client.config.reasoning_mode,
42+
client.config.reasoning_effort.as_deref(),
43+
url,
44+
&client.config.model,
45+
);
4046
}
4147

4248
pub(crate) fn resolve_models_url(client: &AIClient) -> String {

0 commit comments

Comments
 (0)