diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 8407dd1c8..b7b632a59 100755 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -526,6 +526,7 @@ struct Router { worker_overload_token_usage: Option, worker_overload_protection: bool, disable_load_monitoring: bool, + tool_choice_none_ban: bool, } impl Router { @@ -900,6 +901,7 @@ impl Router { .maybe_redis(redis_config) .maybe_reasoning_parser(self.reasoning_parser.as_ref()) .maybe_tool_call_parser(self.tool_call_parser.as_ref()) + .tool_choice_none_ban(self.tool_choice_none_ban) .maybe_mcp_config_path(self.mcp_config_path.as_ref()) .maybe_storage_hook_wasm_path(self.storage_hook_wasm_path.as_deref()) .enable_wasm(self.enable_wasm) @@ -1083,6 +1085,7 @@ impl Router { worker_overload_token_usage = None, worker_overload_protection = false, disable_load_monitoring = false, + tool_choice_none_ban = false, ))] #[expect(clippy::too_many_arguments)] #[expect( @@ -1233,6 +1236,7 @@ impl Router { worker_overload_token_usage: Option, worker_overload_protection: bool, disable_load_monitoring: bool, + tool_choice_none_ban: bool, ) -> PyResult { let mut all_urls = worker_urls.clone(); @@ -1397,6 +1401,7 @@ impl Router { worker_overload_token_usage, worker_overload_protection, disable_load_monitoring, + tool_choice_none_ban, }) } diff --git a/bindings/python/src/smg/router_args.py b/bindings/python/src/smg/router_args.py index 94f9c4190..17e99f726 100644 --- a/bindings/python/src/smg/router_args.py +++ b/bindings/python/src/smg/router_args.py @@ -242,6 +242,7 @@ class RouterArgs: worker_overload_protection: bool = False # Restore the conditional load-monitor poll gate (default: poll always) disable_load_monitoring: bool = False + tool_choice_none_ban: bool = False @staticmethod def add_cli_args( @@ -1280,6 +1281,16 @@ def add_cli_args( choices=tool_call_parser_choices, help="Specify the parser for tool-call interactions (e.g., json, qwen)", ) + parser_group.add_argument( + f"--{prefix}tool-choice-none-ban", + action="store_true", + help=( + "With tools present but tool_choice 'none', ban the resolved" + " parser's tool-call opener strings at decode time (requires" + " engine support for the any_text/excludes structural-tag" + " format)" + ), + ) parser_group.add_argument( f"--{prefix}mcp-config-path", type=str, diff --git a/bindings/python/tests/test_arg_parser.py b/bindings/python/tests/test_arg_parser.py index 5459ac617..f7ffc815c 100644 --- a/bindings/python/tests/test_arg_parser.py +++ b/bindings/python/tests/test_arg_parser.py @@ -1401,6 +1401,7 @@ class TestRouterArgsFieldOrder: "worker_overload_token_usage", "worker_overload_protection", "disable_load_monitoring", + "tool_choice_none_ban", ] def test_complete_field_sequence_is_frozen(self): diff --git a/crates/tool_parser/src/factory.rs b/crates/tool_parser/src/factory.rs index 18c22f507..917cd52da 100644 --- a/crates/tool_parser/src/factory.rs +++ b/crates/tool_parser/src/factory.rs @@ -60,6 +60,12 @@ impl ToolConstraint { struct ParserEntry { creator: ParserCreator, build_structural_tag: Option, + /// Strings that exclusively open this parser's native tool-call syntax, + /// used to build the `tool_choice: "none"` suppression constraint. Empty + /// for parsers without one (no suppression possible). Curated per parser: + /// this is NOT the structural-tag trigger list — triggers may include + /// markers ordinary generation must emit. + tool_call_ban_strings: &'static [&'static str], } /// Registry for model-specific tool parsers with pooling support. @@ -97,6 +103,7 @@ impl ParserRegistry { Arc::new(ParserEntry { creator: Arc::new(creator), build_structural_tag: None, + tool_call_ban_strings: &[], }), ); } @@ -105,11 +112,16 @@ impl ParserRegistry { /// /// The `build_structural_tag` function takes `(&[Tool], at_least_one)` and returns /// the full xgrammar structural tag JSON for this parser's native tool-call format. + /// `tool_call_ban_strings` is the parser's curated `tool_choice: "none"` + /// suppression inventory: strings that exclusively open its native + /// tool-call syntax (NOT the trigger list — triggers may include markers + /// ordinary generation must emit). Pass an empty slice to opt out. pub fn register_parser_with_structural_tag( &self, name: &str, creator: F, build_structural_tag: fn(&[Tool], bool) -> serde_json::Value, + tool_call_ban_strings: &'static [&'static str], ) where F: Fn() -> Box + Send + Sync + 'static, { @@ -119,6 +131,7 @@ impl ParserRegistry { Arc::new(ParserEntry { creator: Arc::new(creator), build_structural_tag: Some(Arc::new(build_structural_tag)), + tool_call_ban_strings, }), ); } @@ -183,6 +196,29 @@ impl ParserRegistry { configured.is_some_and(|p| self.has_structural_tag(p)) } + /// Build the decode-time suppression constraint for `tool_choice: "none"`: + /// free-form output that may never contain the strings opening the + /// configured parser's native tool-call syntax, expressed as an xgrammar + /// structural tag (`any_text` with `excludes`). + /// + /// Returns `None` when no parser is configured, the name is unknown, or + /// the parser has no curated ban inventory — callers fall back to today's + /// behavior (no constraint; parsing is separately disabled for `"none"`). + pub fn tool_call_ban_constraint(&self, configured: Option<&str>) -> Option { + let entries = self.entries.read(); + let entry = entries.get(configured?)?; + if entry.tool_call_ban_strings.is_empty() { + return None; + } + let tag = serde_json::json!({ + "format": { + "type": "any_text", + "excludes": entry.tool_call_ban_strings, + } + }); + Some(ToolConstraint::StructuralTag(tag.to_string())) + } + /// Generate tool call constraint. /// /// If `configured_parser` supports structural tags → `StructuralTag(json)`. @@ -314,6 +350,7 @@ impl ParserFactory { "mistral", || Box::new(MistralParser::new()), MistralParser::build_structural_tag, + MistralParser::TOOL_CALL_BAN_STRINGS, ); registry.register_parser("qwen", || Box::new(QwenParser::new())); registry.register_parser("qwen_xml", || Box::new(QwenXmlParser::new())); @@ -335,16 +372,19 @@ impl ParserFactory { "kimik2", || Box::new(KimiK2Parser::new()), KimiK2Parser::build_structural_tag, + KimiK2Parser::TOOL_CALL_BAN_STRINGS, ); registry.register_parser_with_structural_tag( "kimi_k3", || Box::new(KimiK3Parser::new()), KimiK3Parser::build_structural_tag, + KimiK3Parser::TOOL_CALL_BAN_STRINGS, ); registry.register_parser_with_structural_tag( "inkling", || Box::new(InklingParser::new()), InklingParser::build_structural_tag, + InklingParser::TOOL_CALL_BAN_STRINGS, ); registry.register_parser("minimax_m2", || Box::new(MinimaxM2Parser::new())); registry.register_parser("cohere", || Box::new(CohereParser::new())); diff --git a/crates/tool_parser/src/parsers/inkling.rs b/crates/tool_parser/src/parsers/inkling.rs index 0de34ad3d..e3d951f8c 100644 --- a/crates/tool_parser/src/parsers/inkling.rs +++ b/crates/tool_parser/src/parsers/inkling.rs @@ -76,6 +76,12 @@ impl InklingParser { } } + /// Strings that only ever open Inkling's native tool-call syntax; banning + /// them makes tool calls unreachable when `tool_choice` is `"none"`. Both + /// invocation modes are covered: JSON-arguments and TML text-mode. + pub const TOOL_CALL_BAN_STRINGS: &'static [&'static str] = + &[TOOL_CALL_JSON_START, TOOL_CALL_TEXT_START]; + /// Build an xgrammar structural tag that constrains the JSON arguments for /// each declared tool while retaining Inkling's native TML framing. pub fn build_structural_tag(tools: &[Tool], at_least_one: bool) -> Value { diff --git a/crates/tool_parser/src/parsers/kimi_k3.rs b/crates/tool_parser/src/parsers/kimi_k3.rs index 0bb7efaaf..485a84539 100644 --- a/crates/tool_parser/src/parsers/kimi_k3.rs +++ b/crates/tool_parser/src/parsers/kimi_k3.rs @@ -123,6 +123,14 @@ pub struct KimiK3Parser { } impl KimiK3Parser { + /// Strings that only ever open K3's native tool-call syntax; banning them + /// makes tool calls unreachable when `tool_choice` is `"none"`. ONLY the + /// tools-section opener qualifies: the structural-tag triggers also list + /// `<|close|>think<|sep|>` / `<|close|>response<|sep|>` (as tag-begin + /// prefixes), but those close sections ordinary generation must emit, so + /// excluding them would corrupt normal output. + pub const TOOL_CALL_BAN_STRINGS: &'static [&'static str] = &[TOOLS_OPEN]; + /// Build an xgrammar structural tag that constrains Kimi-K3 XTML tool /// calls to the declared `tools`. /// diff --git a/crates/tool_parser/src/parsers/kimik2.rs b/crates/tool_parser/src/parsers/kimik2.rs index 01859336c..320187384 100644 --- a/crates/tool_parser/src/parsers/kimik2.rs +++ b/crates/tool_parser/src/parsers/kimik2.rs @@ -51,6 +51,13 @@ pub struct KimiK2Parser { } impl KimiK2Parser { + /// Strings that only ever open K2's native tool-call syntax; banning them + /// makes tool calls unreachable when `tool_choice` is `"none"`. Both the + /// section opener and the per-call opener appear exclusively in tool-call + /// output, so both are safe to exclude. + pub const TOOL_CALL_BAN_STRINGS: &'static [&'static str] = + &["<|tool_calls_section_begin|>", "<|tool_call_begin|>"]; + /// Build structural tag for Kimi K2 tool call format. /// /// Uses dual triggers following sglang's approach: diff --git a/crates/tool_parser/src/parsers/mistral.rs b/crates/tool_parser/src/parsers/mistral.rs index 1d74b9220..de559dac5 100644 --- a/crates/tool_parser/src/parsers/mistral.rs +++ b/crates/tool_parser/src/parsers/mistral.rs @@ -45,6 +45,10 @@ pub struct MistralParser { } impl MistralParser { + /// Strings that only ever open Mistral's native tool-call syntax; banning + /// them makes tool calls unreachable when `tool_choice` is `"none"`. + pub const TOOL_CALL_BAN_STRINGS: &'static [&'static str] = &["[TOOL_CALLS]"]; + /// Build structural tag for Mistral tool call format. /// /// Mistral outputs tool calls as a JSON array after `[TOOL_CALLS]`: diff --git a/crates/tool_parser/tests/tool_constraint_ban.rs b/crates/tool_parser/tests/tool_constraint_ban.rs new file mode 100644 index 000000000..81d8798db --- /dev/null +++ b/crates/tool_parser/tests/tool_constraint_ban.rs @@ -0,0 +1,126 @@ +#![expect( + clippy::expect_used, + reason = "test-only helpers outside #[test] fns; failures are test failures" +)] + +//! Suppression-constraint coverage: the `tool_choice: "none"` ban tag. +//! +//! The ban is a structural tag whose format is free text excluding the +//! strings that open a parser's native tool-call syntax. Only parsers with +//! model-native framing (and a curated opener inventory) produce one. + +use serde_json::Value; +use tool_parser::ParserFactory; + +fn ban_tag_json(factory: &ParserFactory, parser: &str) -> Value { + let constraint = factory + .registry() + .tool_call_ban_constraint(Some(parser)) + .expect("parser should produce a ban constraint"); + let (kind, json) = constraint.to_tuple(); + assert_eq!(kind, "structural_tag", "ban constraint for {parser}"); + serde_json::from_str(&json).expect("ban tag must be valid JSON") +} + +fn excludes(tag: &Value) -> Vec<&str> { + tag["format"]["excludes"] + .as_array() + .expect("excludes must be an array") + .iter() + .map(|v| v.as_str().expect("excludes entries must be strings")) + .collect() +} + +#[test] +fn ban_tag_shape_is_any_text_with_excludes() { + let factory = ParserFactory::new(); + let tag = ban_tag_json(&factory, "mistral"); + assert_eq!(tag["format"]["type"], "any_text"); + assert!(tag["format"]["excludes"].is_array()); + // Top-level envelope matches the positive structural tags: one "format" key. + assert!(tag.get("format").is_some()); + assert_eq!(tag.as_object().map(serde_json::Map::len), Some(1)); +} + +#[test] +fn curated_ban_inventories_per_parser() { + let factory = ParserFactory::new(); + assert_eq!( + excludes(&ban_tag_json(&factory, "mistral")), + vec!["[TOOL_CALLS]"] + ); + assert_eq!( + excludes(&ban_tag_json(&factory, "kimik2")), + vec!["<|tool_calls_section_begin|>", "<|tool_call_begin|>"] + ); + // K3 bans ONLY the tools-section opener: its structural-tag triggers also + // include think/response section closers that ordinary generation must + // emit, and excluding those would corrupt normal output. + assert_eq!( + excludes(&ban_tag_json(&factory, "kimi_k3")), + vec!["<|open|>tools<|sep|>"] + ); + // Both Inkling invocation modes: JSON-arguments and TML text-mode. + assert_eq!( + excludes(&ban_tag_json(&factory, "inkling")), + vec![ + "<|content_invoke_tool_json|>", + "<|content_invoke_tool_text|>" + ] + ); +} + +#[test] +fn parsers_without_native_framing_produce_no_ban() { + let factory = ParserFactory::new(); + for parser in ["json", "qwen", "qwen_xml", "pythonic", "llama", "deepseek"] { + assert!( + factory + .registry() + .tool_call_ban_constraint(Some(parser)) + .is_none(), + "{parser} has no curated ban inventory" + ); + } +} + +#[test] +fn unknown_or_absent_parser_produces_no_ban() { + let factory = ParserFactory::new(); + assert!(factory + .registry() + .tool_call_ban_constraint(Some("no-such-parser")) + .is_none()); + assert!(factory.registry().tool_call_ban_constraint(None).is_none()); +} + +#[test] +fn generate_tool_constraint_still_skips_none_choice() { + use openai_protocol::common::{Function, Tool, ToolChoice, ToolChoiceValue}; + + let factory = ParserFactory::new(); + let tools = vec![Tool { + tool_type: "function".to_string(), + function: Function { + name: "get_weather".to_string(), + description: None, + parameters: serde_json::json!({"type": "object"}), + strict: None, + }, + }]; + // The ban is a separate opt-in surface; the standard constraint generator + // keeps returning no constraint for tool_choice "none" and "auto". + for choice in [ + ToolChoice::Value(ToolChoiceValue::None), + ToolChoice::Value(ToolChoiceValue::Auto), + ] { + let constraint = factory + .registry() + .generate_tool_constraint(Some("mistral"), &tools, &choice) + .expect("constraint generation must not error"); + assert!( + constraint.is_none(), + "no constraint expected for {choice:?}" + ); + } +} diff --git a/model_gateway/src/config/builder.rs b/model_gateway/src/config/builder.rs index b7321eede..aafaa8887 100644 --- a/model_gateway/src/config/builder.rs +++ b/model_gateway/src/config/builder.rs @@ -613,6 +613,11 @@ impl RouterConfigBuilder { self } + pub fn tool_choice_none_ban(mut self, enable: bool) -> Self { + self.config.tool_choice_none_ban = enable; + self + } + // ==================== Tokenizer Cache ==================== pub fn tokenizer_cache(mut self, cache: TokenizerCacheConfig) -> Self { diff --git a/model_gateway/src/config/types.rs b/model_gateway/src/config/types.rs index fc17c72b2..2734e930f 100755 --- a/model_gateway/src/config/types.rs +++ b/model_gateway/src/config/types.rs @@ -245,6 +245,13 @@ pub struct RouterConfig { pub reasoning_parser: Option, /// For tool-call interactions pub tool_call_parser: Option, + /// When tools are present but `tool_choice` is `"none"`, emit a + /// decode-time constraint banning the resolved parser's tool-call opener + /// strings, so the model cannot start native tool-call syntax at all. + /// Off by default: requires engines whose grammar backend understands the + /// `any_text`/`excludes` structural-tag format. + #[serde(default)] + pub tool_choice_none_ban: bool, #[serde(default)] pub tokenizer_cache: TokenizerCacheConfig, /// Server TLS certificate (PEM) @@ -1109,6 +1116,7 @@ impl Default for RouterConfig { redis: None, reasoning_parser: None, tool_call_parser: None, + tool_choice_none_ban: false, tokenizer_cache: TokenizerCacheConfig::default(), client_identity: None, ca_certificates: vec![], @@ -1222,6 +1230,21 @@ mod tests { config.tenant_resolution.tenant_header_name, DEFAULT_TENANT_HEADER_NAME ); + assert!(!config.tool_choice_none_ban); + } + + #[test] + fn test_tool_choice_none_ban_absent_from_config_defaults_off() { + // Existing config files predate the field; they must keep parsing + // with the ban disabled. + let mut value = serde_json::to_value(RouterConfig::default()).unwrap(); + value + .as_object_mut() + .unwrap() + .remove("tool_choice_none_ban") + .expect("field is serialized"); + let config: RouterConfig = serde_json::from_value(value).unwrap(); + assert!(!config.tool_choice_none_ban); } #[test] diff --git a/model_gateway/src/main.rs b/model_gateway/src/main.rs index 29aea9f88..fdfd71dad 100644 --- a/model_gateway/src/main.rs +++ b/model_gateway/src/main.rs @@ -886,6 +886,12 @@ struct CliArgs { #[arg(long, help_heading = "Parsers")] tool_call_parser: Option, + /// With tools present but tool_choice "none", ban the resolved parser's + /// tool-call opener strings at decode time (requires engine support for + /// the any_text/excludes structural-tag format) + #[arg(long, default_value_t = false, help_heading = "Parsers")] + tool_choice_none_ban: bool, + /// Path to MCP server configuration file #[arg(long, help_heading = "Parsers")] mcp_config_path: Option, @@ -1860,6 +1866,7 @@ impl CliArgs { .maybe_redis(redis) .maybe_reasoning_parser(self.reasoning_parser.as_ref()) .maybe_tool_call_parser(self.tool_call_parser.as_ref()) + .tool_choice_none_ban(self.tool_choice_none_ban) .maybe_mcp_config_path(self.mcp_config_path.as_ref()) .dp_aware(self.dp_aware) .routing_key_override(RoutingKeyOverrideConfig { @@ -2375,6 +2382,33 @@ mod tests { ); } + /// `--tool-choice-none-ban` must flow into `RouterConfig` and survive + /// nesting into `ServerConfig.router_config` — the consumers (the gRPC + /// preparation stages) read it off `RouterConfig`. Two-path + /// config-plumbing guard, plus the off-by-default contract. + #[test] + fn tool_choice_none_ban_flows_into_both_configs() { + let cli = cli_args_from(&["--tool-choice-none-ban"]); + + let router_config = cli.to_router_config(vec![], vec![]).unwrap(); + assert!( + router_config.tool_choice_none_ban, + "tool_choice_none_ban must reach RouterConfig via to_router_config" + ); + + let server_config = cli.to_server_config(router_config).unwrap(); + assert!( + server_config.router_config.tool_choice_none_ban, + "tool_choice_none_ban must survive into ServerConfig via to_server_config" + ); + + let defaults = cli_args_from(&[]).to_router_config(vec![], vec![]).unwrap(); + assert!( + !defaults.tool_choice_none_ban, + "tool_choice_none_ban must default off" + ); + } + /// The overload thresholds must reach `RouterConfig` and survive nesting /// into `ServerConfig.router_config` — the consumer (load monitor) reads /// them off `RouterConfig`. Two-path config-plumbing guard. diff --git a/model_gateway/src/routers/grpc/regular/stages/chat/preparation.rs b/model_gateway/src/routers/grpc/regular/stages/chat/preparation.rs index 9f6bc0648..d0715df44 100644 --- a/model_gateway/src/routers/grpc/regular/stages/chat/preparation.rs +++ b/model_gateway/src/routers/grpc/regular/stages/chat/preparation.rs @@ -235,24 +235,31 @@ impl ChatPreparationStage { let tool_call_constraint = if let (Some(tools), Some(tool_choice)) = (body_ref.tools.as_ref(), request.tool_choice.as_ref()) { - ctx.components - .tool_parser_factory - .registry() - .generate_tool_constraint( - ctx.components - .parser_resolver - .tool_parser(&request.model) - .as_deref(), - tools, - tool_choice, - ) - .map_err(|e| { - error!(function = "ChatPreparationStage::execute", error = %e, "Invalid tool configuration"); - error::bad_request( - "invalid_tool_configuration", - format!("Invalid tool configuration: {e}"), - ) - })? + let resolved_parser = ctx.components.parser_resolver.tool_parser(&request.model); + if !tools.is_empty() + && ctx.components.parser_resolver.tool_choice_none_ban() + && matches!(tool_choice, ToolChoice::Value(ToolChoiceValue::None)) + { + // Opt-in: ban the parser's tool-call opener strings so the + // model cannot start native tool-call syntax at all (the + // prompt still advertises the tools; parsing stays disabled). + ctx.components + .tool_parser_factory + .registry() + .tool_call_ban_constraint(resolved_parser.as_deref()) + } else { + ctx.components + .tool_parser_factory + .registry() + .generate_tool_constraint(resolved_parser.as_deref(), tools, tool_choice) + .map_err(|e| { + error!(function = "ChatPreparationStage::execute", error = %e, "Invalid tool configuration"); + error::bad_request( + "invalid_tool_configuration", + format!("Invalid tool configuration: {e}"), + ) + })? + } } else { None }; diff --git a/model_gateway/src/routers/grpc/regular/stages/messages/preparation.rs b/model_gateway/src/routers/grpc/regular/stages/messages/preparation.rs index 701d544b4..f0ddc8f23 100644 --- a/model_gateway/src/routers/grpc/regular/stages/messages/preparation.rs +++ b/model_gateway/src/routers/grpc/regular/stages/messages/preparation.rs @@ -263,24 +263,30 @@ impl MessagePreparationStage { let tool_call_constraint = if let (false, Some(tool_choice)) = (filtered_tools.is_empty(), chat_tool_choice.as_ref()) { - ctx.components - .tool_parser_factory - .registry() - .generate_tool_constraint( - ctx.components - .parser_resolver - .tool_parser(&request.model) - .as_deref(), - &filtered_tools, - tool_choice, - ) - .map_err(|e| { - error!(function = "MessagePreparationStage::execute", error = %e, "Invalid tool configuration"); - error::bad_request( - "invalid_tool_configuration", - format!("Invalid tool configuration: {e}"), - ) - })? + let resolved_parser = ctx.components.parser_resolver.tool_parser(&request.model); + if ctx.components.parser_resolver.tool_choice_none_ban() + && matches!(tool_choice, ToolChoice::Value(ToolChoiceValue::None)) + { + // Opt-in: ban the parser's tool-call opener strings so the + // model cannot start native tool-call syntax at all (the + // prompt still advertises the tools; parsing stays disabled). + ctx.components + .tool_parser_factory + .registry() + .tool_call_ban_constraint(resolved_parser.as_deref()) + } else { + ctx.components + .tool_parser_factory + .registry() + .generate_tool_constraint(resolved_parser.as_deref(), &filtered_tools, tool_choice) + .map_err(|e| { + error!(function = "MessagePreparationStage::execute", error = %e, "Invalid tool configuration"); + error::bad_request( + "invalid_tool_configuration", + format!("Invalid tool configuration: {e}"), + ) + })? + } } else { None }; diff --git a/model_gateway/src/routers/grpc/router.rs b/model_gateway/src/routers/grpc/router.rs index 3e3a27ff5..aedf9e59f 100644 --- a/model_gateway/src/routers/grpc/router.rs +++ b/model_gateway/src/routers/grpc/router.rs @@ -359,7 +359,8 @@ impl GrpcRouter { worker_registry.clone(), ctx.configured_tool_parser.clone(), ctx.configured_reasoning_parser.clone(), - ), + ) + .with_tool_choice_none_ban(ctx.router_config.tool_choice_none_ban), multimodal, }); diff --git a/model_gateway/src/routers/grpc/utils/parsers.rs b/model_gateway/src/routers/grpc/utils/parsers.rs index 51ea413ed..81ee1d83f 100644 --- a/model_gateway/src/routers/grpc/utils/parsers.rs +++ b/model_gateway/src/routers/grpc/utils/parsers.rs @@ -32,6 +32,9 @@ pub(crate) struct ParserResolver { worker_registry: Option>, configured_tool_parser: Option, configured_reasoning_parser: Option, + /// Emit the tool-call suppression constraint when tools are present but + /// `tool_choice` is `"none"` (RouterConfig `tool_choice_none_ban`). + tool_choice_none_ban: bool, } impl ParserResolver { @@ -44,6 +47,7 @@ impl ParserResolver { worker_registry: Some(worker_registry), configured_tool_parser, configured_reasoning_parser, + tool_choice_none_ban: false, } } @@ -54,9 +58,21 @@ impl ParserResolver { worker_registry: None, configured_tool_parser: None, configured_reasoning_parser: None, + tool_choice_none_ban: false, } } + #[must_use] + pub(crate) fn with_tool_choice_none_ban(mut self, enabled: bool) -> Self { + self.tool_choice_none_ban = enabled; + self + } + + /// Whether the `tool_choice: "none"` suppression constraint is enabled. + pub(crate) fn tool_choice_none_ban(&self) -> bool { + self.tool_choice_none_ban + } + /// Effective tool-parser name for `model`, if any. pub(crate) fn tool_parser(&self, model: &str) -> Option { self.card_parser(model, |card| card.tool_parser.as_ref()) @@ -515,6 +531,16 @@ mod parser_resolver_tests { assert_eq!(resolver.reasoning_parser("m"), None); } + #[test] + fn tool_choice_none_ban_defaults_off_and_round_trips() { + let registry = registry_with_card(ModelCard::new("m")); + let resolver = ParserResolver::new(registry, None, None); + assert!(!resolver.tool_choice_none_ban()); + assert!(!ParserResolver::disabled().tool_choice_none_ban()); + let enabled = resolver.with_tool_choice_none_ban(true); + assert!(enabled.tool_choice_none_ban()); + } + #[test] fn conflicting_overrides_resolve_deterministically() { // Two same-model workers with different overrides: resolution must