refactor(deepseek): simplify compatible request handling

This commit is contained in:
Darkatse 2026-09-17 19:06:57 -04:00
parent 9cd47f9e28
commit ad9bc6d796
4 changed files with 347 additions and 447 deletions

View file

@ -58,6 +58,8 @@ Connection Profiles(Connection Manager 扩展):
- `gemini_interactions` → 构造 `/interactions`
- `gemini_generate_content` → 复用 MakerSuite 翻译器与传输链路,Custom 语义见 §4.4
DeepSeek 的原生与 Custom/OpenCode 兼容请求共享后端适配,保留各来源的参数与提示词策略;适配在协议构建后、用户覆盖前执行。
### 2.3 HTTP 调用 + Stream 处理(Rust repository)
仓库层对 `ChatCompletionSource::Custom` 以 **endpoint_path** 再分流(`http_chat_completion_repository/mod.rs`):

View file

@ -1,7 +1,9 @@
use serde_json::{Map, Value};
use tt_ports::repositories::chat_completion_repository::ChatCompletionSource;
use crate::errors::ApplicationError;
use super::super::exchange::ChatCompletionProviderFormat;
use super::super::model_capabilities::{
RequestedReasoningEffort, parse_known_reasoning_effort, unsupported_reasoning_effort,
};
@ -16,24 +18,114 @@ enum DeepSeekThinkingMode {
Disabled,
}
/// Resolved from the TT request, before a wire builder can drop provider controls.
pub(super) struct RequestOptions {
thinking_mode: Option<DeepSeekThinkingMode>,
reasoning_effort: Option<Value>,
strip_sampling: bool,
}
impl RequestOptions {
pub(super) fn from_payload(
source: ChatCompletionSource,
payload: &Map<String, Value>,
) -> Result<Option<Self>, ApplicationError> {
let model = payload
.get("model")
.and_then(Value::as_str)
.unwrap_or_default();
if source == ChatCompletionSource::DeepSeek {
let thinking_mode = resolve_thinking_mode(payload, model);
let reasoning_effort = if thinking_mode == Some(DeepSeekThinkingMode::Enabled) {
normalize_reasoning_effort(
payload
.get("reasoning_effort")
.and_then(Value::as_str)
.unwrap_or_default(),
)?
.map(|effort| Value::String(effort.to_string()))
} else {
None
};
return Ok(Some(Self {
thinking_mode,
reasoning_effort,
strip_sampling: true,
}));
}
if !matches!(
source,
ChatCompletionSource::Custom | ChatCompletionSource::OpenCode
) || ChatCompletionProviderFormat::from_payload(source, payload)?
!= ChatCompletionProviderFormat::OpenAiCompatible
|| !is_deepseek_v4_model(model)
{
return Ok(None);
}
let thinking_mode =
if payload.get("include_reasoning").and_then(Value::as_bool) == Some(false) {
DeepSeekThinkingMode::Disabled
} else {
DeepSeekThinkingMode::Enabled
};
// Compatible gateways own their parameter contract. Preserve explicit values,
// including effort that the OpenCode wire builder would otherwise discard.
Ok(Some(Self {
thinking_mode: Some(thinking_mode),
reasoning_effort: payload.get("reasoning_effort").cloned(),
strip_sampling: false,
}))
}
pub(super) fn apply(self, body: &mut Map<String, Value>) {
if let Some(mode) = self.thinking_mode {
body.insert(
"thinking".to_string(),
serde_json::json!({
"type": match mode {
DeepSeekThinkingMode::Enabled => "enabled",
DeepSeekThinkingMode::Disabled => "disabled",
},
}),
);
}
if let Some(effort) = self.reasoning_effort {
body.insert("reasoning_effort".to_string(), effort);
} else {
body.remove("reasoning_effort");
}
if self.thinking_mode == Some(DeepSeekThinkingMode::Enabled) {
if self.strip_sampling {
for key in [
"temperature",
"top_p",
"presence_penalty",
"frequency_penalty",
] {
body.remove(key);
}
}
let has_tools = body
.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| !tools.is_empty());
if let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) {
fill_missing_tool_reasoning_content(messages, has_tools);
}
}
strip_empty_required_arrays_from_tools(body);
}
}
/// The native source owns its prompt preset; compatible sources keep the user's preset.
pub(super) fn build(mut payload: Map<String, Value>) -> Result<(String, Value), ApplicationError> {
let names = PromptNames::from_payload(&payload);
let model = payload
.get("model")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
let thinking_mode = resolve_thinking_mode(&payload, &model);
let reasoning_effort = match thinking_mode {
Some(DeepSeekThinkingMode::Enabled) => normalize_reasoning_effort(
payload
.get("reasoning_effort")
.and_then(Value::as_str)
.unwrap_or_default(),
)?,
_ => None,
};
let has_tools = payload
.get("tools")
.and_then(Value::as_array)
@ -54,25 +146,12 @@ pub(super) fn build(mut payload: Map<String, Value>) -> Result<(String, Value),
add_assistant_prefix(&mut processed, "prefix");
}
if thinking_mode == Some(DeepSeekThinkingMode::Enabled) {
ensure_tool_context_reasoning_content(&mut processed, has_tools)?;
}
let processed = Value::Array(processed);
tool_calls::validate_openai_chat_tool_transcript(Some(&processed), false)?;
payload.insert("messages".to_string(), processed);
}
strip_empty_required_arrays_from_tools(&mut payload);
let (endpoint, mut upstream_payload) = openai::build(payload)?;
if endpoint == "/chat/completions"
&& let (Some(mode), Some(body)) = (thinking_mode, upstream_payload.as_object_mut())
{
apply_thinking_mode(body, mode, reasoning_effort);
}
Ok((endpoint, upstream_payload))
openai::build(payload)
}
fn resolve_thinking_mode(
@ -101,79 +180,14 @@ fn resolve_thinking_mode(
}
}
/// DeepSeek V4 家族(v4* / flash)判定,用于 OpenAI 兼容源的模型名匹配。
/// 取 `/` 分隔的最后一段再匹配,容忍网关前缀(如 `GO/deepseek-flash`、
/// `OR/deepseek-v4.1-flash`、`newapi/openrouter/deepseek-v4`)。
/// DeepSeek 3.x 不命中。
pub(super) fn is_deepseek_v4_model(model: &str) -> bool {
/// Match the final segment to allow gateway prefixes such as `GO/deepseek-flash`.
fn is_deepseek_v4_model(model: &str) -> bool {
let normalized = model.trim().to_ascii_lowercase();
let last_segment = normalized.rsplit('/').next().unwrap_or_default();
last_segment.starts_with("deepseek-v4") || last_segment.starts_with("deepseek-flash")
}
/// 对 OpenAI 兼容源(Custom/OpenCode OpenAiCompat)构建后的请求体应用
/// DeepSeek V4 语义对齐:当模型命中 V4 家族时,对齐官方 DeepSeek 渠道的
/// thinking 控制、effort 归一化、采样参数清理、工具上下文 reasoning_content
/// 补齐与空 required 剥离。非 V4 模型零改动。`include_reasoning` 由调用方
/// 在 builder 白名单化之前从 TT payload 读出传入(思考开关意图),它本身
/// 不透传给上游。
pub(super) fn apply_deepseek_v4_compat(
upstream_payload: &mut Value,
include_reasoning: Option<bool>,
) -> Result<(), ApplicationError> {
let Some(body) = upstream_payload.as_object_mut() else {
return Ok(());
};
let model = body
.get("model")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if !is_deepseek_v4_model(&model) {
return Ok(());
}
// 未提供时与官方渠道默认一致:V4 家族默认开启思考。
let thinking_mode = match include_reasoning {
Some(true) => DeepSeekThinkingMode::Enabled,
Some(false) => DeepSeekThinkingMode::Disabled,
None => DeepSeekThinkingMode::Enabled,
};
let reasoning_effort = match thinking_mode {
DeepSeekThinkingMode::Enabled => normalize_reasoning_effort(
body.get("reasoning_effort")
.and_then(Value::as_str)
.unwrap_or_default(),
)?,
DeepSeekThinkingMode::Disabled => None,
};
let has_tools = body
.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| !tools.is_empty());
if thinking_mode == DeepSeekThinkingMode::Enabled
&& let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut)
{
ensure_tool_context_reasoning_content(messages, has_tools)?;
}
strip_empty_required_arrays_from_tools(body);
// CUSTOM 源会无条件转发 reasoning_effort;官方渠道语义是 disabled 时
// 不发送 effort,这里对齐(enabled 时 apply_thinking_mode 内部会重写)。
if thinking_mode == DeepSeekThinkingMode::Disabled {
body.remove("reasoning_effort");
}
apply_thinking_mode(body, thinking_mode, reasoning_effort);
Ok(())
}
fn normalize_reasoning_effort(value: &str) -> Result<Option<&'static str>, ApplicationError> {
match parse_known_reasoning_effort(value, "DeepSeek")? {
RequestedReasoningEffort::Auto => Ok(None),
@ -186,44 +200,7 @@ fn normalize_reasoning_effort(value: &str) -> Result<Option<&'static str>, Appli
}
}
fn apply_thinking_mode(
body: &mut Map<String, Value>,
mode: DeepSeekThinkingMode,
reasoning_effort: Option<&str>,
) {
body.insert(
"thinking".to_string(),
serde_json::json!({
"type": match mode {
DeepSeekThinkingMode::Enabled => "enabled",
DeepSeekThinkingMode::Disabled => "disabled",
},
}),
);
if mode == DeepSeekThinkingMode::Enabled {
for key in [
"temperature",
"top_p",
"presence_penalty",
"frequency_penalty",
] {
body.remove(key);
}
if let Some(reasoning_effort) = reasoning_effort {
body.insert(
"reasoning_effort".to_string(),
Value::String(reasoning_effort.to_string()),
);
}
}
}
fn ensure_tool_context_reasoning_content(
messages: &mut [Value],
has_tools: bool,
) -> Result<(), ApplicationError> {
fn fill_missing_tool_reasoning_content(messages: &mut [Value], has_tools: bool) {
let has_tool_context = has_tools
|| messages.iter().any(|message| {
let Some(message_object) = message.as_object() else {
@ -238,7 +215,7 @@ fn ensure_tool_context_reasoning_content(
});
if !has_tool_context {
return Ok(());
return;
}
for message in messages {
@ -250,24 +227,11 @@ fn ensure_tool_context_reasoning_content(
continue;
}
match message_object.get("reasoning_content") {
Some(Value::String(_)) => {}
Some(_) => {
return Err(ApplicationError::ValidationError(
"DeepSeek thinking assistant messages in tool context must have string reasoning_content"
.to_string(),
));
}
None => {
message_object.insert(
"reasoning_content".to_string(),
Value::String(String::new()),
);
}
}
// Preserve explicit values for later body overrides and upstream handling.
message_object
.entry("reasoning_content")
.or_insert_with(|| Value::String(String::new()));
}
Ok(())
}
fn strip_empty_required_arrays_from_tools(payload: &mut Map<String, Value>) {
@ -276,26 +240,16 @@ fn strip_empty_required_arrays_from_tools(payload: &mut Map<String, Value>) {
};
for tool in tools {
let should_remove = tool
.as_object()
.and_then(|tool| tool.get("function"))
.and_then(Value::as_object)
.and_then(|function| function.get("parameters"))
.and_then(Value::as_object)
.and_then(|parameters| parameters.get("required"))
.and_then(Value::as_array)
.is_some_and(|required| required.is_empty());
if !should_remove {
let Some(parameters) = tool
.pointer_mut("/function/parameters")
.and_then(Value::as_object_mut)
else {
continue;
}
if let Some(parameters) = tool
.as_object_mut()
.and_then(|tool| tool.get_mut("function"))
.and_then(Value::as_object_mut)
.and_then(|function| function.get_mut("parameters"))
.and_then(Value::as_object_mut)
};
if parameters
.get("required")
.and_then(Value::as_array)
.is_some_and(|required| required.is_empty())
{
parameters.remove("required");
}
@ -304,9 +258,14 @@ fn strip_empty_required_arrays_from_tools(payload: &mut Map<String, Value>) {
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use tt_ports::repositories::chat_completion_repository::ChatCompletionSource;
use super::build;
use crate::errors::ApplicationError;
fn build(payload: Map<String, Value>) -> Result<(String, Value), ApplicationError> {
super::super::build_payload(ChatCompletionSource::DeepSeek, payload)
}
#[test]
fn deepseek_build_marks_assistant_prefill_as_prefix() {
@ -338,16 +297,16 @@ mod tests {
#[test]
fn deepseek_v4_enables_thinking_and_maps_effort() {
for model in [
"deepseek-v4-flash",
"deepseek-v4-flash-vision-exp",
"deepseek-flash",
"deepseek-v4-pro",
for (model, requested_effort, expected_effort) in [
("deepseek-v4-flash", "medium", Some("high")),
("deepseek-v4-flash-vision-exp", "minimal", Some("low")),
("deepseek-flash", "auto", None),
("deepseek-v4-pro", "max", Some("max")),
] {
let payload = json!({
"model": model,
"messages": [{"role": "user", "content": "hello"}],
"reasoning_effort": "max",
"reasoning_effort": requested_effort,
"temperature": 1.2,
"top_p": 0.7,
"presence_penalty": 0.1,
@ -369,8 +328,8 @@ mod tests {
Some("enabled")
);
assert_eq!(
body.get("reasoning_effort").and_then(Value::as_str),
Some("max")
body.get("reasoning_effort"),
expected_effort.map(|effort| json!(effort)).as_ref()
);
assert!(body.get("temperature").is_none());
assert!(body.get("top_p").is_none());
@ -490,35 +449,6 @@ mod tests {
}
}
#[test]
fn deepseek_thinking_tool_context_rejects_non_string_reasoning_content() {
let payload = json!({
"model": "deepseek-v4-flash",
"messages": [
{"role":"user","content":"weather"},
{
"role":"assistant",
"content":"",
"reasoning_content": {"text":"need weather"},
"tool_calls":[{
"id":"call_1",
"type":"function",
"function":{"name":"weather","arguments":"{}"}
}]
},
{"role":"tool","tool_call_id":"call_1","content":"cloudy"}
],
"include_reasoning": true,
"chat_completion_source": "deepseek"
})
.as_object()
.cloned()
.expect("payload must be object");
let error = build(payload).expect_err("non-string reasoning_content must fail");
assert!(error.to_string().contains("string reasoning_content"));
}
#[test]
fn deepseek_rejects_unknown_reasoning_effort() {
let payload = json!({

View file

@ -38,6 +38,8 @@ pub(super) fn build_payload(
payload: Map<String, Value>,
) -> Result<(String, Value), ApplicationError> {
let mut payload = payload;
// Capture provider controls before wire builders discard internal fields.
let deepseek_options = deepseek::RequestOptions::from_payload(source, &payload)?;
let opencode_format = (source == ChatCompletionSource::OpenCode)
.then(|| opencode::format_from_payload(&payload))
.transpose()?;
@ -58,22 +60,18 @@ pub(super) fn build_payload(
return openai_responses::build(payload);
}
match source {
let (endpoint, mut body) = match source {
ChatCompletionSource::OpenAi
| ChatCompletionSource::Groq
| ChatCompletionSource::SiliconFlow
| ChatCompletionSource::Pollinations => openai::build(payload),
ChatCompletionSource::OpenCode => {
let include_reasoning = payload
.get("include_reasoning")
.and_then(Value::as_bool);
let built = match opencode_format.expect("OpenCode format resolved") {
match opencode_format.expect("OpenCode format resolved") {
OpenCodeApiFormat::OpenAiCompat => openai::build_chat(payload),
OpenCodeApiFormat::OpenAiResponses => openai_responses::build(payload),
OpenCodeApiFormat::ClaudeMessages => claude_messages::build(payload),
OpenCodeApiFormat::Gemini => makersuite::build(payload),
}?;
apply_deepseek_v4_compat_if_chat(include_reasoning, built)
}
}
ChatCompletionSource::DeepSeek => deepseek::build(payload),
ChatCompletionSource::Cohere => Ok(cohere::build(payload)?),
@ -85,33 +83,19 @@ pub(super) fn build_payload(
ChatCompletionSource::OpenRouter => openrouter::build(payload),
ChatCompletionSource::Zai => zai::build(payload),
ChatCompletionSource::MiniMax => Ok(minimax::build(payload)),
ChatCompletionSource::Custom => {
// builder 白名单会丢弃 include_reasoning,先读出思考开关意图。
let include_reasoning = payload
.get("include_reasoning")
.and_then(Value::as_bool);
let built = custom::build(payload)?;
apply_deepseek_v4_compat_if_chat(include_reasoning, built)
}
ChatCompletionSource::Custom => custom::build(payload),
ChatCompletionSource::Claude => Ok(claude::build(payload)?),
ChatCompletionSource::AwsBedrock => Ok(aws_bedrock::build(payload)?),
ChatCompletionSource::Makersuite => Ok(makersuite::build(payload)?),
ChatCompletionSource::VertexAi => Ok(vertexai::build(payload)?),
}
}
}?;
/// OpenAI 兼容源(Custom/OpenCode)经 OpenAI 兼容上游访问 DeepSeek V4 时,
/// 对齐官方 DeepSeek 渠道语义(thinking 控制、effort 归一化等)。
/// 仅处理 /chat/completions 端点;模型不命中时零改动。
fn apply_deepseek_v4_compat_if_chat(
include_reasoning: Option<bool>,
built: (String, Value),
) -> Result<(String, Value), ApplicationError> {
let (endpoint, mut upstream_payload) = built;
if endpoint == "/chat/completions" {
deepseek::apply_deepseek_v4_compat(&mut upstream_payload, include_reasoning)?;
if endpoint == "/chat/completions"
&& let Some(options) = deepseek_options
{
options.apply(body.as_object_mut().expect("wire builders return objects"));
}
Ok((endpoint, upstream_payload))
Ok((endpoint, body))
}
pub(super) fn validate_upstream_tool_transcript(
@ -129,6 +113,7 @@ pub(super) fn validate_upstream_tool_transcript(
mod tests {
use serde_json::{Map, Value, json};
use super::super::additional_parameters::AdditionalParameters;
use super::build_payload;
use tt_ports::repositories::chat_completion_repository::ChatCompletionSource;
@ -339,224 +324,202 @@ mod tests {
);
}
/// 兼容源(Custom/OpenCode OpenAI 兼容格式)命中 DeepSeek V4 模型时,
/// 对齐官方 DeepSeek 渠道语义:显式 thinking 控制、effort 归一化、
/// 关闭思考时保留采样参数、开启思考时清理采样参数、
/// 工具上下文补 reasoning_content、剥离空 required。
fn deepseek_v4_compat_payload(model: &str) -> Map<String, Value> {
fn deepseek_compat_payload(source: ChatCompletionSource, model: &str) -> Map<String, Value> {
json!({
"chat_completion_source": "custom",
"custom_api_format": "openai_compat",
"opencode_endpoint": "zen",
"opencode_api_format": "openai_compat",
"chat_completion_source": source.key(),
"model": model,
"messages": [
{"role":"user","content":"weather"},
{"role":"assistant","content":"I'll check."},
{
"role":"assistant",
"content":"",
"tool_calls":[{
"id":"call_1",
"type":"function",
"function":{"name":"weather","arguments":"{}"}
}]
},
{"role":"tool","tool_call_id":"call_1","content":"cloudy"}
{"role": "user", "content": "weather"},
{"role": "assistant", "content": "I'll check.", "reasoning_content": " original\nreasoning "},
{"role": "assistant", "content": "", "tool_calls": [{
"id": "call_1", "type": "function",
"function": {"name": "weather", "arguments": "{}"}
}]},
{"role": "tool", "tool_call_id": "call_1", "content": "cloudy"}
],
"tools": [{"type":"function","function":{"name":"weather","parameters":{
"type":"object","properties":{"city":{"type":"string"}},"required":[]
"tools": [{"type": "function", "function": {"name": "weather", "parameters": {
"type": "object", "properties": {"city": {"type": "string"}}, "required": []
}}}],
"temperature": 1.2,
"top_p": 0.7,
"presence_penalty": 0.1,
"frequency_penalty": 0.2,
"reasoning_effort": "medium"
})
.as_object()
.cloned()
.expect("payload must be object")
}).as_object().unwrap().clone()
}
#[test]
fn compat_sources_align_deepseek_v4_thinking_when_show_thoughts_on() {
fn compat_deepseek_preserves_parameters_and_repairs_tool_continuations() {
for source in [ChatCompletionSource::Custom, ChatCompletionSource::OpenCode] {
for model in [
" GO/deepseek-flash ",
"OR/DeepSeek-v4.1-flash",
"deepseek-v4-pro",
] {
for include_reasoning in [None, Some(true), Some(false)] {
let mut payload = deepseek_compat_payload(source, model);
if let Some(include_reasoning) = include_reasoning {
payload.insert("include_reasoning".into(), json!(include_reasoning));
}
let (endpoint, body) = build_payload(source, payload).unwrap();
let thinking_enabled = include_reasoning != Some(false);
assert_eq!(endpoint, "/chat/completions");
assert_eq!(
body["thinking"]["type"],
if thinking_enabled {
"enabled"
} else {
"disabled"
}
);
assert_eq!(
body["reasoning_effort"], "medium",
"{source:?}: raw effort must survive the wire builder"
);
assert_eq!(body["temperature"], 1.2);
assert_eq!(body["top_p"], 0.7);
assert_eq!(body["presence_penalty"], 0.1);
assert_eq!(body["frequency_penalty"], 0.2);
assert_eq!(
body["messages"][1]["reasoning_content"],
" original\nreasoning "
);
assert_eq!(
body["messages"][2].get("reasoning_content"),
thinking_enabled.then_some(&json!(""))
);
assert!(
body["tools"][0]["function"]["parameters"]
.get("required")
.is_none()
);
assert!(body.get("include_reasoning").is_none());
}
}
}
}
#[test]
fn deepseek_tool_reasoning_allows_final_body_overrides() {
for source in [
ChatCompletionSource::DeepSeek,
ChatCompletionSource::Custom,
ChatCompletionSource::OpenCode,
] {
for model in [
"GO/deepseek-flash",
"OR/deepseek-v4.1-flash",
"deepseek-v4-pro",
for reasoning in [Value::Null, json!({"text": "need weather"})] {
let mut payload = deepseek_compat_payload(source, "deepseek-v4-flash");
payload.get_mut("messages").unwrap()[2]["reasoning_content"] = reasoning.clone();
let mut repaired_messages = payload["messages"].clone();
repaired_messages[2]["reasoning_content"] = json!("need weather");
payload.insert(
"custom_include_body".into(),
json!({"messages": repaired_messages}),
);
let overrides = AdditionalParameters::from_payload(&payload).unwrap();
let (endpoint, mut body) = build_payload(source, payload)
.expect("reasoning must not block final body overrides");
assert_eq!(
body["messages"][2].get("reasoning_content"),
Some(&reasoning),
"{source:?}"
);
overrides.apply_body_overrides(&mut body).unwrap();
super::validate_upstream_tool_transcript(&endpoint, &body).unwrap();
assert_eq!(body["messages"], repaired_messages, "{source:?}");
}
}
}
#[test]
fn compat_deepseek_does_not_validate_or_guess_explicit_effort() {
for source in [ChatCompletionSource::Custom, ChatCompletionSource::OpenCode] {
// Auto in the UI omits the field; a supplied value belongs to the gateway.
for effort in [
None,
Some(json!("max")),
Some(json!("none")),
Some(json!("provider-specific")),
Some(json!("auto")),
] {
let (endpoint, upstream) = build_payload(
source,
deepseek_v4_compat_payload(model),
)
.unwrap_or_else(|error| panic!("{source:?} {model}: {error}"));
assert_eq!(endpoint, "/chat/completions");
let body = upstream.as_object().expect("body must be object");
assert_eq!(
body.get("thinking")
.and_then(Value::as_object)
.and_then(|thinking| thinking.get("type"))
.and_then(Value::as_str),
Some("enabled"),
"{source:?} {model}: thinking"
);
assert_eq!(
body.get("reasoning_effort").and_then(Value::as_str),
Some("high"),
"{source:?} {model}: effort medium 归一化为 high"
);
assert!(body.get("temperature").is_none(), "{source:?} {model}");
assert!(body.get("top_p").is_none(), "{source:?} {model}");
assert!(body.get("presence_penalty").is_none(), "{source:?} {model}");
assert!(body.get("frequency_penalty").is_none(), "{source:?} {model}");
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages must be array");
for index in [1_usize, 2] {
let assistant = messages
.get(index)
.and_then(Value::as_object)
.unwrap_or_else(|| panic!("{source:?} {model}: message {index}"));
assert_eq!(
assistant.get("reasoning_content").and_then(Value::as_str),
Some(""),
"{source:?} {model}: assistant {index} 补空串"
);
let mut payload = deepseek_compat_payload(source, "deepseek-v4-pro");
payload.remove("reasoning_effort");
if let Some(effort) = &effort {
payload.insert("reasoning_effort".into(), effort.clone());
}
let (_, body) = build_payload(source, payload).unwrap();
assert_eq!(body.get("reasoning_effort"), effort.as_ref(), "{source:?}");
}
}
}
let required = &body["tools"][0]["function"]["parameters"]["required"];
assert!(
required.is_null(),
"{source:?} {model}: 空 required 必须剥离, got {required}"
#[test]
fn deepseek_compat_leaves_other_models_and_sources_unchanged() {
for source in [
ChatCompletionSource::Custom,
ChatCompletionSource::OpenCode,
ChatCompletionSource::OpenRouter,
] {
for model in [
"deepseek-3.2",
"qwen3-max",
"gpt-5.5",
"gateway/deepseek-v4-pro",
] {
if source != ChatCompletionSource::OpenRouter && model.ends_with("deepseek-v4-pro")
{
continue;
}
let payload = deepseek_compat_payload(source, model);
let expected = match source {
ChatCompletionSource::Custom => super::custom::build(payload.clone()),
ChatCompletionSource::OpenCode => super::openai::build_chat(payload.clone()),
_ => super::openrouter::build(payload.clone()),
}
.unwrap();
assert_eq!(
build_payload(source, payload).unwrap(),
expected,
"{source:?}: {model}"
);
}
}
}
#[test]
fn compat_sources_disable_deepseek_v4_thinking_when_show_thoughts_off() {
let mut payload = deepseek_v4_compat_payload("GO/deepseek-flash");
payload.insert("include_reasoning".to_string(), Value::Bool(false));
let (endpoint, upstream) =
build_payload(ChatCompletionSource::Custom, payload).expect("payload should build");
assert_eq!(endpoint, "/chat/completions");
let body = upstream.as_object().expect("body must be object");
assert_eq!(
body.get("thinking")
.and_then(Value::as_object)
.and_then(|thinking| thinking.get("type"))
.and_then(Value::as_str),
Some("disabled")
);
// 关闭思考:不转发 effort,采样参数保留,不补 reasoning_content
assert!(body.get("reasoning_effort").is_none());
assert_eq!(
body.get("temperature").and_then(Value::as_f64),
Some(1.2),
"关闭思考时采样参数必须保留"
);
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages must be array");
for index in [1_usize, 2] {
let assistant = messages
.get(index)
.and_then(Value::as_object)
.unwrap_or_else(|| panic!("message {index}"));
assert!(
assistant.get("reasoning_content").is_none(),
"关闭思考时不补 reasoning_content at {index}"
);
}
}
#[test]
fn compat_sources_keep_non_deepseek_models_untouched() {
for model in ["deepseek-3.2", "qwen3-max", "gpt-5.5"] {
let (endpoint, upstream) =
build_payload(ChatCompletionSource::Custom, deepseek_v4_compat_payload(model))
.expect("payload should build");
assert_eq!(endpoint, "/chat/completions");
let body = upstream.as_object().expect("body must be object");
assert!(
body.get("thinking").is_none(),
"{model}: 不注入 thinking"
);
// CUSTOM 源现状:reasoning_effort 无条件转发,保持不动
fn deepseek_compat_leaves_native_protocols_to_their_builders() {
for (source, format) in [
(ChatCompletionSource::Custom, "openai_responses"),
(ChatCompletionSource::Custom, "claude_messages"),
(ChatCompletionSource::Custom, "gemini_generate_content"),
(ChatCompletionSource::Custom, "gemini_interactions"),
(ChatCompletionSource::OpenCode, "openai_responses"),
(ChatCompletionSource::OpenCode, "claude_messages"),
(ChatCompletionSource::OpenCode, "gemini"),
] {
let mut payload = deepseek_compat_payload(source, "gateway/deepseek-v4-pro");
let format_key = if source == ChatCompletionSource::Custom {
"custom_api_format"
} else {
"opencode_api_format"
};
payload.insert(format_key.into(), json!(format));
// Each selected protocol retains its own thinking/schema representation.
let expected = match source {
ChatCompletionSource::Custom => super::custom::build(payload.clone()),
_ => match format {
"openai_responses" => super::openai_responses::build(payload.clone()),
"claude_messages" => super::claude_messages::build(payload.clone()),
_ => super::makersuite::build(payload.clone()),
},
}
.unwrap();
assert_eq!(
body.get("reasoning_effort").and_then(Value::as_str),
Some("medium"),
"{model}: effort 转发行为保持现状"
build_payload(source, payload).unwrap(),
expected,
"{source:?}: {format}"
);
assert!(body.get("temperature").is_some(), "{model}: 采样参数不动");
assert!(
!body["tools"][0]["function"]["parameters"]["required"].is_null(),
"{model}: required 不动"
);
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages must be array");
for index in [1_usize, 2] {
let assistant = messages
.get(index)
.and_then(Value::as_object)
.unwrap_or_else(|| panic!("message {index}"));
assert!(
assistant.get("reasoning_content").is_none(),
"{model}: 不补 reasoning_content at {index}"
);
}
}
}
#[test]
fn compat_sources_reject_invalid_deepseek_v4_reasoning_effort() {
let mut payload = deepseek_v4_compat_payload("GO/deepseek-flash");
payload.insert("reasoning_effort".to_string(), Value::String("auto-ish".to_string()));
let error = build_payload(ChatCompletionSource::Custom, payload)
.expect_err("非法 effort 必须报错");
assert!(
error
.to_string()
.contains("Unsupported DeepSeek reasoning_effort")
);
}
#[test]
fn openrouter_source_keeps_deepseek_v4_without_thinking_injection() {
// OR 源有自己的 reasoning 方言(include_reasoning/reasoning.effort),
// fix-up 不得往 OR 请求里注入 DeepSeek 的 thinking 字段。
let mut payload = deepseek_v4_compat_payload("deepseek/deepseek-v4.1-flash");
payload.insert("include_reasoning".to_string(), Value::Bool(false));
let (endpoint, upstream) = build_payload(ChatCompletionSource::OpenRouter, payload)
.expect("payload should build");
assert_eq!(endpoint, "/chat/completions");
let body = upstream.as_object().expect("body must be object");
assert!(
body.get("thinking").is_none(),
"OR 源不得注入 thinking 字段"
);
assert_eq!(
body.get("include_reasoning").and_then(Value::as_bool),
Some(false),
"OR 自己的 include_reasoning 方言保持"
);
}
}

View file

@ -1007,6 +1007,8 @@ function setOpenAIMessages(chat, stripOldToolCalls = false) {
const currentApi = oai_settings.chat_completion_source;
const currentModel = getChatCompletionModel();
const requestContext = getChatCompletionRequestContext(oai_settings, currentModel);
const includeReasoningContent = oai_settings.show_thoughts
&& usesDeepSeekReasoningContent(oai_settings, currentModel);
const includeClaudeNative = usesClaudeMessagesSemantics(oai_settings, currentModel);
const includeNative = includeClaudeNative
|| (currentApi === chat_completion_sources.CUSTOM
@ -1077,11 +1079,8 @@ function setOpenAIMessages(chat, stripOldToolCalls = false) {
&& (!includeClaudeNative || hasClaudeToolUse(metadataMessage?.extra?.native))
? metadataMessage?.extra?.native
: null;
const shouldReplayReasoningContent = (currentApi === chat_completion_sources.DEEPSEEK
|| isDeepSeekV4CompatSource(currentApi, currentModel))
&& oai_settings.show_thoughts
&& isSameModel && !isOtherGroupMember;
const reasoningContent = shouldReplayReasoningContent ? metadataMessage?.extra?.tool_reasoning_content : null;
const reasoningContent = includeReasoningContent && isSameModel && !isOtherGroupMember
? metadataMessage?.extra?.tool_reasoning_content : null;
// Remove provider metadata from invocations if the API/model/speaker don't match.
if (Array.isArray(invocations) && invocations.length > 0) {
invocations.forEach((invocation, index) => {
@ -1651,10 +1650,9 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
&& [custom_api_formats.GEMINI_INTERACTIONS, custom_api_formats.GEMINI_GENERATE_CONTENT].includes(settings.custom_api_format));
const canIncludeNative = native => includeNative
&& (!includeClaudeNative || hasClaudeToolUse(native));
// DeepSeek V4 经兼容源走 reasoning_content 方言,明文 reasoning 与之
// 冲突(官方 DeepSeek 渠道同样不发明文 reasoning),这里排除。
// DeepSeek tool turns use reasoning_content instead of reasoning.
const isToolReasoningProvider = interleaved_reasoning_providers.includes(settings.chat_completion_source)
&& !isDeepSeekV4CompatSource(settings.chat_completion_source, getChatCompletionModel(settings));
&& !usesDeepSeekReasoningContent(settings);
const toolReasoningMode = isToolReasoningProvider
? getEffectiveToolReasoningMode(settings)
: tool_reasoning_modes.DISABLED;
@ -1752,6 +1750,7 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
return clone;
});
const toolResultMessages = await Promise.all(invocations.slice().reverse().map((invocation) => Message.createAsync('tool', invocation.result, invocation.id, assemblyTokenHandler)));
chatMessage.reasoningContent = chatPrompt.reasoningContent ?? null;
await chatMessage.setToolCalls(invocations, includeSignature, includeToolReasoning, assemblyTokenHandler);
if (includeSignature && chatPrompt.signature) {
chatMessage.signature = chatPrompt.signature;
@ -1759,9 +1758,6 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
if (canIncludeNative(chatPrompt.native)) {
chatMessage.native = chatPrompt.native;
}
if (chatPrompt.reasoningContent) {
chatMessage.reasoningContent = chatPrompt.reasoningContent;
}
if (Array.isArray(chatPrompt.media) && chatPrompt.media.length) {
if (chatPrompt.mediaDisplay === MEDIA_DISPLAY.LIST) {
for (const media of chatPrompt.media) {
@ -5250,6 +5246,7 @@ class Message {
...(this.name ? { name: this.name } : {}),
tool_calls: JSON.stringify(this.tool_calls),
...(this.reasoning ? { reasoning: this.reasoning } : {}),
...(this.reasoningContent ? { reasoning_content: this.reasoningContent } : {}),
});
}
@ -8474,15 +8471,23 @@ function isCustomGeminiGenerateContent(settings = oai_settings) {
}
/**
* Check whether an OpenAI-compatible source points at a DeepSeek V4 family model.
* Matches the last `/` segment so gateway-prefixed ids (GO/deepseek-flash,
* OR/deepseek-v4.1-flash) resolve; DeepSeek 3.x never matches.
* @param {string} source Chat completion source id
* Check whether the selected connection replays DeepSeek tool reasoning.
* Keep this replay predicate scoped to Chat Completions; native-format continuations
* have their own representation. Request normalization remains in the backend.
* @param {ChatCompletionSettings} settings Settings object to use
* @param {string} model Model id
* @returns {boolean} True when the compat source serves a DeepSeek V4 model
* @returns {boolean} Whether tool turns use DeepSeek reasoning_content
*/
function isDeepSeekV4CompatSource(source, model) {
if (source !== chat_completion_sources.CUSTOM && source !== chat_completion_sources.OPENCODE) {
function usesDeepSeekReasoningContent(settings, model = getChatCompletionModel(settings)) {
const source = settings.chat_completion_source;
if (source === chat_completion_sources.DEEPSEEK) {
return true;
}
const isCompat = (source === chat_completion_sources.CUSTOM
&& (settings.custom_api_format ?? custom_api_formats.OPENAI_COMPAT) === custom_api_formats.OPENAI_COMPAT)
|| (source === chat_completion_sources.OPENCODE
&& (settings.opencode_api_format ?? OPENCODE_API_FORMAT.OPENAI_COMPAT) === OPENCODE_API_FORMAT.OPENAI_COMPAT);
if (!isCompat) {
return false;
}
const lastSegment = String(model ?? '').trim().toLowerCase().split('/').pop() ?? '';