Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 73 additions & 2 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,59 @@ pub fn requested_model_for_provider(provider: ApiProvider, model: &str) -> Optio
}
}

/// Whether the route is an official DeepSeek-family endpoint — the only place
/// the DeepSeek strict tool pipeline may act on the wire.
///
/// The pipeline (`prepare_tools_for_strict_mode` plus
/// `tool_choice: "required"`) encodes DeepSeek's beta strict contract: every
/// property required, `additionalProperties: false`, optional properties
/// removed, `minItems`/`maxItems`/`minLength`/`maxLength`/`dependentRequired`
/// stripped, and schemas carrying undocumented `nullable`/null-type union
/// shapes left non-strict. Applied
/// anywhere else it would strip keywords the other provider's documents
/// explicitly support (OpenAI, for one, documents `minItems`/`maxItems`) and
/// remove optional properties no other provider's contract asks to remove.
/// Provider typing plus the official host keeps a misconfigured
/// `provider`/`base_url` entry from receiving DeepSeek-only reshaping.
///
/// Source: the DeepSeek strict function-calling contract, "`strict` Mode
/// (Beta)" (<https://api-docs.deepseek.com/guides/tool_calls/>, fetched
/// 2026-11-05). `api.deepseeki.com` is not an official host — the docs never
/// mention it, and `docs/REBRAND.md` records it as a legacy typo kept only in
/// URL heuristics for existing configs (#1079); it is accepted here to mirror
/// the compat set the rest of the codebase already recognizes
/// (`client.rs`, `client/chat.rs`, the doctor).
pub fn route_is_official_deepseek_family(provider: ApiProvider, base_url: &str) -> bool {
if !matches!(
provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
) {
return false;
}
// No whitespace trimming: the doctor's URL classifier and the client's
// strict-host sets string-match the same URL without trimming, so
// admitting a padded URL here alone would put the pipeline in scope on
// a route the wire does not canonicalize — the flag would leak past the
// non-beta strip. A padded URL degrades to out-of-scope instead.
let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
let official_host = |prefix: &str| {
normalized == prefix
|| normalized
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/'))
};
official_host("https://api.deepseek.com") || official_host("https://api.deepseeki.com")
}

/// Whether the DeepSeek strict tool pipeline (schema reshaping plus
/// `tool_choice: "required"`) acts on a route: the user flag AND the
/// official-route gate. The engine turn-surface build, the child sub-agent
/// surface, and the doctor all answer from this one composition so the
/// surfaces can never disagree about where strict mode is in scope.
pub fn strict_tool_mode_for_route(flag: bool, provider: ApiProvider, base_url: &str) -> bool {
flag && route_is_official_deepseek_family(provider, base_url)
}

/// Reject a provider/model tuple that we can be confident is invalid *before*
/// it reaches the network (#3227).
///
Expand Down Expand Up @@ -3058,8 +3111,26 @@ pub struct Config {
pub notes_path: Option<String>,
pub memory_path: Option<String>,
/// When true, set `tool_choice: "required"` and opt compatible function
/// schemas into DeepSeek beta strict mode. Schemas with root alternatives
/// stay non-strict to avoid changing optional/one-of tool semantics.
/// schemas into DeepSeek beta strict mode — effective only on official
/// DeepSeek-family routes (provider-typed DeepSeek on the
/// `api.deepseek.com` / legacy `api.deepseeki.com` hosts), because the
/// schema reshaping follows DeepSeek's strict documents and would
/// otherwise strip keywords other providers document (e.g. OpenAI's
/// `minItems`/`maxItems`). On those routes optional parameters are removed
/// from the schema (reported in the tool description), non-numeric
/// `const` values land on `enum`, and `dependentRequired` tables are
/// stripped, per the strict contract. Schemas the strict subset cannot
/// represent faithfully — root alternatives, required nullables
/// (`nullable` / null-type union branches), or tools whose runtime
/// demands schema-optional parameters (`update_goal`, `send_later`,
/// `agents/message`, `agents/followup`) —
/// stay non-strict: they keep their original schema. `tool_choice`
/// follows the route gate on the parent turn surface, not per-tool
/// outcomes: it stays `required` on an in-scope route even when some
/// tools fail closed, and falls back to `auto` only when the route
/// itself is out of scope. (The child sub-agent surface requests with
/// `auto`, and thinking-mode chat requests may drop `tool_choice`
/// entirely — pre-existing surfaces this field does not change.)
pub strict_tool_mode: Option<bool>,
/// Additional user-owned system-prompt sources concatenated in declared
/// order (#454). Paths are expanded via `expand_path` so `~` and env vars
Expand Down
95 changes: 95 additions & 0 deletions crates/tui/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13701,3 +13701,98 @@ fn picker_and_request_path_disagree_when_the_secret_slot_marker_is_missing() ->
println!("CAPTURED first-fix: {:?}", resolution.first_fix());
Ok(())
}

#[test]
fn strict_pipeline_scope_is_official_deepseek_family_routes() {
// The DeepSeek strict pipeline reshapes schemas per DeepSeek's strict
// documents, so only provider-typed DeepSeek family routes on the
// official hosts may act on it — a misconfigured provider or a lookalike
// host must not receive the reshaping.
for provider in [
ApiProvider::Deepseek,
ApiProvider::DeepseekCN,
ApiProvider::DeepseekAnthropic,
] {
for base_url in [
"https://api.deepseek.com",
"https://api.deepseek.com/",
"https://api.deepseek.com/v1",
"https://api.deepseek.com/beta",
"https://api.deepseek.com/anthropic",
"https://api.deepseeki.com",
"https://api.deepseeki.com/beta",
] {
assert!(
super::route_is_official_deepseek_family(provider, base_url),
"{provider:?} on {base_url} is an official family route"
);
}
}

for (provider, base_url, why) in [
(
ApiProvider::Openai,
"https://api.deepseek.com/beta",
"a DeepSeek URL under a non-DeepSeek provider stays out of scope",
),
(
ApiProvider::Deepseek,
"https://llm.example.com/v1",
"a custom endpoint is not an official family route",
),
(
ApiProvider::Deepseek,
"https://api.deepseek.com.evil.example/beta",
"a lookalike host must not pass the host-boundary check",
),
(
ApiProvider::Deepseek,
"http://api.deepseek.com/beta",
"plaintext http is not the official endpoint",
),
(
ApiProvider::Moonshot,
"https://api.moonshot.ai/v1",
"unrelated providers stay out of scope",
),
(
ApiProvider::Deepseek,
" HTTPS://API.DEEPSEEK.COM/BETA ",
"a whitespace-padded URL matches no surface that string-compares the base URL \
(this gate, the doctor classifier, the wire strip set), so admitting it here \
alone would leak the strict flag past the non-beta strip; it stays out of scope",
),
] {
assert!(
!super::route_is_official_deepseek_family(provider, base_url),
"{why}: {provider:?} on {base_url}"
);
}
}

#[test]
fn strict_tool_mode_for_route_combines_the_flag_with_the_official_route_gate() {
// One composition answers for the engine turn surface, the child
// sub-agent surface, and the doctor — the user flag alone must never
// enable the pipeline, and an official URL alone must not either.
assert!(super::strict_tool_mode_for_route(
true,
ApiProvider::Deepseek,
"https://api.deepseek.com/beta"
));
assert!(!super::strict_tool_mode_for_route(
false,
ApiProvider::Deepseek,
"https://api.deepseek.com/beta"
));
assert!(!super::strict_tool_mode_for_route(
true,
ApiProvider::Openai,
"https://api.deepseek.com/beta"
));
assert!(!super::strict_tool_mode_for_route(
true,
ApiProvider::Deepseek,
"https://llm.example.com/v1"
));
}
16 changes: 14 additions & 2 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,8 @@ pub struct EngineConfig {
/// construction; the engine never touches disk for it.
pub locale_tag: String,
/// When true, force `tool_choice: "required"` and opt compatible function
/// schemas into DeepSeek beta strict mode.
/// schemas into DeepSeek beta strict mode — on official DeepSeek-family
/// routes only (`crate::config::strict_tool_mode_for_route`).
pub strict_tool_mode: bool,
/// Workshop / large-tool-output routing (#548). `None` disables routing.
pub workshop: Option<crate::tools::large_output_router::WorkshopConfig>,
Expand Down Expand Up @@ -5720,13 +5721,24 @@ impl Engine {
if self.config.features.enabled(Feature::Mcp) {
apply_registry_first_shell_guidance(&mut catalog);
}
// The strict pipeline encodes DeepSeek's strict-mode contract, so it
// acts only on official DeepSeek-family routes; elsewhere it would
// strip keywords the other provider documents (minItems/maxItems on
// OpenAI) and remove optional properties no other contract asks to
// remove. This gate also drives the turn loop's `tool_choice` and the
// preview surface, which read the flag off this policy.
let strict_tool_mode = crate::config::strict_tool_mode_for_route(
self.config.strict_tool_mode,
route.provider,
&route.api_config.deepseek_base_url(),
);
let surface = ToolSurfacePolicy::new(
tool_registry,
Some(catalog),
input_policy.mode,
&always_load,
&input_policy.dynamic_active_tools,
self.config.strict_tool_mode,
strict_tool_mode,
allowed_tools,
self.config.disallowed_tools.clone(),
self.config.max_tool_calls,
Expand Down
Loading
Loading