fix: treat deepseek-chat / deepseek-reasoner aliases as reasoning models

`requires_reasoning_content()` only matched literal `deepseek-v4*` model
IDs, but `deepseek-chat` and `deepseek-reasoner` are DeepSeek's public
API aliases that resolve server-side to `deepseek-v4-flash` and
`deepseek-v4-pro` respectively. Both have thinking mode enabled by
default, so when a user sets `default_text_model = "deepseek-chat"` (the
value `deepseek auth` / onboarding writes), the thinking-mode sanitizer
is skipped and tool-call assistant messages are sent without
`reasoning_content`. DeepSeek then rejects the second turn with:

  HTTP 400: The `reasoning_content` in the thinking mode must be passed
  back to the API.

Extend `requires_reasoning_content()` to recognise the `deepseek-chat`
and `deepseek-reasoner` alias prefixes (covering suffixed variants like
`deepseek-chat:free` used by proxied deployments). The explicit
`reasoning_effort = "off"` escape hatch still disables replay via the
unchanged `should_replay_reasoning_content()` check.

Adds `alias_thinking_detection_tests` covering the aliases, explicit V4
IDs (regression guard), excluded non-thinking models, suffixed variants,
and the reasoning-off override.

Refs: https://api-docs.deepseek.com/guides/thinking_mode
(cherry picked from commit 46941142123827fa16fc9a1fb41b78c293e935ce)
This commit is contained in:
Beltran12138
2026-05-11 13:52:32 +08:00
committed by Hunter Bown
parent 9279d92f4e
commit ca284d1fc0
+84
View File
@@ -1506,7 +1506,16 @@ fn log_thinking_mode_violations(body: &Value) {
fn requires_reasoning_content(model: &str) -> bool {
let lower = model.to_lowercase();
// V4-family direct model IDs.
lower.contains("deepseek-v4")
// Public DeepSeek API aliases routed server-side to the V4 family.
// `deepseek-chat` resolves to `deepseek-v4-flash` and `deepseek-reasoner`
// resolves to `deepseek-v4-pro`; both have thinking mode enabled by
// default, so any assistant message carrying tool_calls must replay
// `reasoning_content` on subsequent turns or the API returns 400.
|| lower.starts_with("deepseek-chat")
|| lower.starts_with("deepseek-reasoner")
// Generic reasoning markers used by custom/proxied deployments.
|| lower.contains("reasoner")
|| lower.contains("-reasoning")
|| lower.contains("-thinking")
@@ -2615,3 +2624,78 @@ mod stream_decoder_tests {
assert!(tool_layers[1].deduplicated);
}
}
#[cfg(test)]
mod alias_thinking_detection_tests {
//! Regression coverage for the DeepSeek public model aliases.
//!
//! `deepseek-chat` and `deepseek-reasoner` are the canonical alias names
//! published in DeepSeek's API docs. Server-side they resolve to V4-flash
//! and V4-pro respectively, both of which have thinking mode enabled by
//! default. If the TUI does not classify those aliases as reasoning
//! models, the sanitizer skips replaying `reasoning_content` on tool-call
//! assistant messages and DeepSeek returns a 400 ("the `reasoning_content`
//! in the thinking mode must be passed back to the API") on the second
//! turn. See upstream API docs:
//! https://api-docs.deepseek.com/guides/thinking_mode
use super::{requires_reasoning_content, should_replay_reasoning_content};
#[test]
fn aliases_routed_to_v4_require_reasoning_content() {
// Documented public aliases.
assert!(requires_reasoning_content("deepseek-chat"));
assert!(requires_reasoning_content("deepseek-reasoner"));
// Case-insensitive: users sometimes copy/paste with capitalisation.
assert!(requires_reasoning_content("DeepSeek-Chat"));
assert!(requires_reasoning_content("DEEPSEEK-REASONER"));
}
#[test]
fn explicit_v4_ids_still_require_reasoning_content() {
// Direct V4 IDs continue to match (regression guard for the existing
// `lower.contains("deepseek-v4")` branch).
assert!(requires_reasoning_content("deepseek-v4-flash"));
assert!(requires_reasoning_content("deepseek-v4-pro"));
}
#[test]
fn non_thinking_aliases_remain_excluded() {
// Legacy non-thinking IDs and unrelated provider models must not be
// misclassified, otherwise we would force a placeholder
// `reasoning_content` on providers that reject the field.
assert!(!requires_reasoning_content("deepseek-v3"));
assert!(!requires_reasoning_content("deepseek-coder"));
assert!(!requires_reasoning_content("gpt-4o"));
assert!(!requires_reasoning_content("claude-sonnet-4-6"));
}
#[test]
fn alias_prefix_handles_suffixed_variants() {
// OpenRouter / proxy deployments occasionally suffix the canonical
// alias (e.g. `deepseek-chat:free`). Those routes still hit V4
// server-side, so they must continue to require reasoning_content.
assert!(requires_reasoning_content("deepseek-chat:free"));
assert!(requires_reasoning_content("deepseek-reasoner-2025-05"));
}
#[test]
fn explicit_reasoning_off_overrides_alias_detection() {
// `reasoning_effort = "off"` is the documented escape hatch: even when
// the model is in the thinking family, the user can opt out and the
// sanitizer must respect that choice.
assert!(!should_replay_reasoning_content(
"deepseek-chat",
Some("off")
));
assert!(!should_replay_reasoning_content(
"deepseek-reasoner",
Some("disabled")
));
// Without an explicit override, alias models still trigger replay.
assert!(should_replay_reasoning_content("deepseek-chat", None));
assert!(should_replay_reasoning_content(
"deepseek-reasoner",
Some("medium")
));
}
}