Skip to content
71 changes: 69 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,22 @@ 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`) —
/// stay non-strict: they keep their original schema. `tool_choice`
/// follows the route gate, 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.
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 @@ -5718,13 +5719,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
136 changes: 136 additions & 0 deletions crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36355,3 +36355,139 @@ fn run_completion_real_factories_observe_shutdown_and_direct_handles_do_not() {
);
});
}

#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn turn_surface_strict_tool_mode_follows_the_official_route_gate() {
// The turn surface must receive the strict flag only through the
// official-route gate. Dropping that gate would re-expose the pre-gate
// behavior where any provider with `strict_tool_mode` on received the
// DeepSeek reshaping and `tool_choice:"required"` on every route.
let _env_lock = lock_test_env();
let tmp = tempdir().expect("tempdir");
let home = tmp.path().join("home");
fs::create_dir_all(&home).expect("create isolated home");
let _home = EnvVarGuard::set("HOME", &home);
let _userprofile = EnvVarGuard::set("USERPROFILE", &home);
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).expect("create workspace");

let strict_api_config = Config {
api_key: Some("strict-gate-fixture".to_string()),
default_text_model: Some(DEFAULT_TEXT_MODEL.to_string()),
strict_tool_mode: Some(true),
..Config::default()
};
let (mut engine, _engine_handle) = Engine::new(
EngineConfig {
workspace,
allow_shell: true,
strict_tool_mode: true,
..EngineConfig::default()
},
&strict_api_config,
);
engine.config.features.disable(Feature::Mcp);
let client = engine.deepseek_client.clone();
let locale_tag = engine.config.locale_tag.clone();
let role_models = engine.subagent_role_models();
let authority = crate::core::authority::TurnAuthority::from_effective_fields(
AppMode::Agent,
true,
false,
false,
crate::tui::approval::ApprovalMode::Suggest,
);
let route_with = |provider: ApiProvider, api_config: Config| TurnRouteContext {
provider,
model: DEFAULT_TEXT_MODEL.to_string(),
capabilities: codewhale_config::route::RouteCapabilities::default(),
limits: None,
client: client.clone(),
api_config: Box::new(api_config),
locale_tag: locale_tag.clone(),
role_models: role_models.clone(),
auto_model: false,
reasoning_effort: None,
reasoning_effort_auto: false,
};

// Family provider on the official endpoint: in scope.
let build = engine
.build_turn_tool_registry_and_catalog(
&authority,
&[],
None,
SubAgentWiring::Inert,
McpAccess::PassiveSnapshot,
route_with(ApiProvider::Deepseek, strict_api_config.clone()),
"",
)
.await;
assert!(
build.surface.strict_tool_mode,
"the official family route is in scope"
);

// The same family provider on a custom endpoint: the gate keeps the
// pipeline off — no reshaping, no `tool_choice:"required"`.
let custom = Config {
api_key: Some("strict-gate-fixture".to_string()),
default_text_model: Some(DEFAULT_TEXT_MODEL.to_string()),
strict_tool_mode: Some(true),
providers: Some(crate::config::ProvidersConfig {
deepseek: crate::config::ProviderConfig {
base_url: Some("https://llm.example.com/v1".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let build = engine
.build_turn_tool_registry_and_catalog(
&authority,
&[],
None,
SubAgentWiring::Inert,
McpAccess::PassiveSnapshot,
route_with(ApiProvider::Deepseek, custom),
"",
)
.await;
assert!(
!build.surface.strict_tool_mode,
"a family provider on a custom endpoint stays out of scope"
);

// An official DeepSeek URL under a non-family provider: out of scope.
let openai_on_official = Config {
api_key: Some("strict-gate-fixture".to_string()),
default_text_model: Some(DEFAULT_TEXT_MODEL.to_string()),
strict_tool_mode: Some(true),
providers: Some(crate::config::ProvidersConfig {
openai: crate::config::ProviderConfig {
base_url: Some("https://api.deepseek.com/beta".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let build = engine
.build_turn_tool_registry_and_catalog(
&authority,
&[],
None,
SubAgentWiring::Inert,
McpAccess::PassiveSnapshot,
route_with(ApiProvider::Openai, openai_on_official),
"",
)
.await;
assert!(
!build.surface.strict_tool_mode,
"a non-family provider on an official URL stays out of scope"
);
}
11 changes: 11 additions & 0 deletions crates/tui/src/core/engine/tool_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,17 @@ pub(crate) fn active_tools_for_request(
}
let mut tools = active_tools_for_step(catalog, active);
if strict_tool_mode {
// `prepare_tools_for_strict_mode` returns whether EVERY tool shipped
// `strict: true`. The return is intentionally not consulted: a
// per-tool fail-closed outcome (a schema that needs an undocumented
// keyword, or a runtime demanding parameters the strict removal
// takes away) ships that tool unchanged without the flag beside
// `strict: true` siblings, while `tool_choice` follows the route
// gate rather than per-tool results. The documents describe strict
// mode with every function flagged and leave the server's mixed-set
// behavior undocumented; if a probe ever shows the endpoint
// rejecting or silently downgrading mixed sets, this discarded
// boolean is the lever for an all-or-nothing policy.
crate::tools::schema_sanitize::prepare_tools_for_strict_mode(&mut tools);
}
Some(tools)
Expand Down
Loading
Loading