use crate::preprocessor::{OpenAIPreprocessor, PreprocessedRequest};
use crate::protocols::openai::GuidedToolConstraint;
use crate::protocols::openai::chat_completions::NvCreateChatCompletionRequest;
use crate::protocols::openai::tools::{
ToolChoiceGuidance, get_tool_choice_guidance_from_tools, validate_openai_tool_choice,
};
use dynamo_parsers::tool_calling::{ToolChoice, ToolDefinition};
use dynamo_protocols::types::{ChatCompletionTool, ChatCompletionToolChoiceOption, ResponseFormat};
use dynamo_runtime::error::{DynamoError, ErrorType};
fn invalid_argument(message: impl Into<String>) -> DynamoError {
DynamoError::builder()
.error_type(ErrorType::InvalidArgument)
.message(message)
.build()
}
impl OpenAIPreprocessor {
pub(crate) fn tool_call_parsing_enabled(request: &NvCreateChatCompletionRequest) -> bool {
if request.inner.tools.as_ref().is_none_or(Vec::is_empty) {
return false;
}
match request
.inner
.tool_choice
.as_ref()
.unwrap_or(&ChatCompletionToolChoiceOption::Auto)
{
ChatCompletionToolChoiceOption::None => false,
ChatCompletionToolChoiceOption::Required | ChatCompletionToolChoiceOption::Named(_) => {
true
}
ChatCompletionToolChoiceOption::Auto => true,
}
}
pub(super) fn apply_tool_choice_guided_decoding(
&self,
request: &NvCreateChatCompletionRequest,
common_request: &mut PreprocessedRequest,
prompt_injected_reasoning: bool,
) -> Result<GuidedToolConstraint, DynamoError> {
let tool_choice = request
.inner
.tool_choice
.as_ref()
.unwrap_or(&ChatCompletionToolChoiceOption::Auto);
let tools = request.inner.tools.as_deref().unwrap_or(&[]);
let is_forced_tool_choice = matches!(
tool_choice,
ChatCompletionToolChoiceOption::Required | ChatCompletionToolChoiceOption::Named(_)
);
let has_explicit_guided_decoding = has_explicit_guided_decoding(request);
let has_response_format_constraint = has_response_format_constraint(request);
if is_forced_tool_choice && has_explicit_guided_decoding {
return Err(invalid_argument(concat!(
"guided decoding cannot be used in the same request as ",
"tool_choice=\"required\" or a named tool_choice.",
)));
}
let has_assistant_constraint =
has_explicit_guided_decoding || has_response_format_constraint;
if !is_forced_tool_choice && has_assistant_constraint {
return Ok(GuidedToolConstraint::None);
}
if is_forced_tool_choice
&& has_response_format_constraint
&& let Some(gd) = common_request.sampling_options.guided_decoding.as_mut()
{
gd.json = None;
}
if self.apply_tool_choice_structural_tag(
&convert_tool_choice(tool_choice),
&convert_tools(tools),
request.inner.parallel_tool_calls,
prompt_injected_reasoning,
common_request,
)? {
return Ok(GuidedToolConstraint::StructuralTag);
}
let uses_kimi_k3_parser = uses_kimi_k3_parser(
self.tool_call_parser.as_deref(),
self.runtime_config.reasoning_parser.as_deref(),
);
if is_forced_tool_choice && uses_kimi_k3_parser {
if matches!(tool_choice, ChatCompletionToolChoiceOption::Named(_)) {
return Err(invalid_argument(
"named tool choice for Kimi K3 requires --dyn-tool-call-parser kimi_k3 \
with XTML structural-tag support",
));
}
return Ok(GuidedToolConstraint::None);
}
match get_tool_choice_guidance_from_tools(
Some(tool_choice),
Some(tools),
request.inner.parallel_tool_calls,
) {
Ok(Some(guidance)) => {
let gd = common_request
.sampling_options
.guided_decoding
.get_or_insert_default();
match guidance {
ToolChoiceGuidance::Json(schema) => gd.json = Some(schema),
ToolChoiceGuidance::Regex(regex) => gd.regex = Some(regex),
}
return Ok(installed_json_constraint(tool_choice));
}
Ok(None) => {}
Err(err) => {
return Err(invalid_argument(err.to_string()));
}
}
Ok(GuidedToolConstraint::None)
}
}
fn has_explicit_guided_decoding(request: &NvCreateChatCompletionRequest) -> bool {
request.common.guided_json.is_some()
|| request.common.guided_regex.is_some()
|| request
.common
.guided_choice
.as_ref()
.is_some_and(|v| !v.is_empty())
|| request.common.guided_grammar.is_some()
}
fn has_response_format_constraint(request: &NvCreateChatCompletionRequest) -> bool {
request
.inner
.response_format
.as_ref()
.is_some_and(|format| !matches!(format, ResponseFormat::Text))
}
pub(crate) fn convert_tool_choice(tool_choice: &ChatCompletionToolChoiceOption) -> ToolChoice {
match tool_choice {
ChatCompletionToolChoiceOption::None => ToolChoice::None,
ChatCompletionToolChoiceOption::Auto => ToolChoice::Auto,
ChatCompletionToolChoiceOption::Required => ToolChoice::Required,
ChatCompletionToolChoiceOption::Named(named) => {
ToolChoice::Named(named.function.name.clone())
}
}
}
pub(crate) fn convert_tools(tools: &[ChatCompletionTool]) -> Vec<ToolDefinition> {
tools
.iter()
.map(|tool| ToolDefinition {
name: tool.function.name.clone(),
parameters: tool.function.parameters.clone(),
strict: tool.function.strict,
})
.collect()
}
pub(crate) fn guided_tool_constraint(
request: &NvCreateChatCompletionRequest,
tool_call_parser: Option<&str>,
reasoning_parser: Option<&str>,
uses_structural_tag: bool,
) -> Result<GuidedToolConstraint, DynamoError> {
if uses_structural_tag {
return Ok(GuidedToolConstraint::StructuralTag);
}
let tool_choice = request
.inner
.tool_choice
.as_ref()
.unwrap_or(&ChatCompletionToolChoiceOption::Auto);
validate_openai_tool_choice(Some(tool_choice), request.inner.tools.as_deref())
.map_err(|error| invalid_argument(error.to_string()))?;
let is_forced_tool_choice = matches!(
tool_choice,
ChatCompletionToolChoiceOption::Required | ChatCompletionToolChoiceOption::Named(_)
);
if !is_forced_tool_choice {
return Ok(GuidedToolConstraint::None);
}
if has_explicit_guided_decoding(request) {
return Ok(GuidedToolConstraint::None);
}
if uses_kimi_k3_parser(tool_call_parser, reasoning_parser) {
return Ok(GuidedToolConstraint::None);
}
let tools = request.inner.tools.as_deref().unwrap_or(&[]);
match get_tool_choice_guidance_from_tools(
Some(tool_choice),
Some(tools),
request.inner.parallel_tool_calls,
) {
Ok(Some(_)) => Ok(installed_json_constraint(tool_choice)),
Ok(None) => Ok(GuidedToolConstraint::None),
Err(e) => Err(invalid_argument(e.to_string())),
}
}
fn uses_kimi_k3_parser(tool_call_parser: Option<&str>, reasoning_parser: Option<&str>) -> bool {
let is_k3 = |parser: &str| matches!(parser, "kimi_k3" | "kimi-k3");
tool_call_parser.is_some_and(is_k3) || reasoning_parser.is_some_and(is_k3)
}
fn installed_json_constraint(tool_choice: &ChatCompletionToolChoiceOption) -> GuidedToolConstraint {
match tool_choice {
ChatCompletionToolChoiceOption::Named(named) => GuidedToolConstraint::GuidedJsonNamed {
tool_name: named.function.name.clone(),
},
ChatCompletionToolChoiceOption::Required => GuidedToolConstraint::GuidedJsonRequired,
ChatCompletionToolChoiceOption::Auto | ChatCompletionToolChoiceOption::None => {
GuidedToolConstraint::None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use dynamo_protocols::types::{ChatCompletionNamedToolChoice, FunctionName};
use serde_json::{Value, json};
fn request(extra: Value) -> NvCreateChatCompletionRequest {
let mut value = json!({
"model": "test-model",
"messages": [{"role": "user", "content": "test"}]
});
value
.as_object_mut()
.expect("base request is an object")
.extend(extra.as_object().expect("extra is an object").clone());
serde_json::from_value(value).expect("request must deserialize")
}
fn tools() -> Value {
json!([{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"]
}
}
}])
}
#[test]
fn tool_call_parsing_requires_tools() {
assert!(!OpenAIPreprocessor::tool_call_parsing_enabled(&request(
json!({})
)));
assert!(!OpenAIPreprocessor::tool_call_parsing_enabled(&request(
json!({"tool_choice": "required"})
)));
}
#[test]
fn tool_call_parsing_honors_each_tool_choice() {
for (tool_choice, expected) in [
(json!(null), true),
(json!("none"), false),
(json!("auto"), true),
(json!("required"), true),
(
json!({"type": "function", "function": {"name": "get_weather"}}),
true,
),
] {
let mut extra = json!({"tools": tools()});
if !tool_choice.is_null() {
extra["tool_choice"] = tool_choice;
}
assert_eq!(
OpenAIPreprocessor::tool_call_parsing_enabled(&request(extra)),
expected,
);
}
}
#[test]
fn assistant_constraints_do_not_revoke_auto_tool_permission() {
let constraints = [
json!({"response_format": {"type": "json_object"}}),
json!({
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "event",
"schema": {"type": "object"}
}
}
}),
json!({"guided_json": {"type": "object"}}),
json!({"guided_regex": "[a-z]+"}),
json!({"guided_choice": ["a", "b"]}),
json!({"guided_grammar": "root ::= 'a'"}),
];
for constraint in constraints {
let mut auto = json!({"tools": tools(), "tool_choice": "auto"});
auto.as_object_mut()
.unwrap()
.extend(constraint.as_object().unwrap().clone());
assert!(OpenAIPreprocessor::tool_call_parsing_enabled(&request(
auto
)));
let mut required = json!({"tools": tools(), "tool_choice": "required"});
required
.as_object_mut()
.unwrap()
.extend(constraint.as_object().unwrap().clone());
assert!(OpenAIPreprocessor::tool_call_parsing_enabled(&request(
required
)));
}
}
#[test]
fn text_response_format_does_not_disable_auto_tool_parsing() {
assert!(OpenAIPreprocessor::tool_call_parsing_enabled(&request(
json!({
"tools": tools(),
"tool_choice": "auto",
"response_format": {"type": "text"}
})
)));
}
fn named(name: &str) -> ChatCompletionToolChoiceOption {
ChatCompletionToolChoiceOption::Named(ChatCompletionNamedToolChoice {
r#type: dynamo_protocols::types::ChatCompletionToolType::Function,
function: FunctionName {
name: name.to_string(),
},
})
}
#[test]
fn only_structural_tag_reports_a_structural_tag() {
assert!(GuidedToolConstraint::StructuralTag.uses_structural_tag());
assert!(!GuidedToolConstraint::None.uses_structural_tag());
assert!(!GuidedToolConstraint::GuidedJsonRequired.uses_structural_tag());
assert!(
!GuidedToolConstraint::GuidedJsonNamed {
tool_name: "get_weather".to_string(),
}
.uses_structural_tag()
);
}
#[test]
fn required_reports_the_array_shape() {
assert_eq!(
installed_json_constraint(&ChatCompletionToolChoiceOption::Required),
GuidedToolConstraint::GuidedJsonRequired
);
}
#[test]
fn named_carries_the_tool_name_from_the_request() {
assert_eq!(
installed_json_constraint(&named("get_weather")),
GuidedToolConstraint::GuidedJsonNamed {
tool_name: "get_weather".to_string(),
}
);
}
#[test]
fn named_closed_zero_arg_tool_keeps_the_named_parser_constraint() {
let request = request(json!({
"tools": [{
"type": "function",
"function": {
"name": "get_server_time",
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}
}],
"tool_choice": {
"type": "function",
"function": {"name": "get_server_time"}
}
}));
assert_eq!(
guided_tool_constraint(&request, None, None, false).expect("constraint is valid"),
GuidedToolConstraint::GuidedJsonNamed {
tool_name: "get_server_time".to_string(),
}
);
}
#[test]
fn unforced_choices_install_no_tool_constraint() {
assert_eq!(
installed_json_constraint(&ChatCompletionToolChoiceOption::Auto),
GuidedToolConstraint::None
);
assert_eq!(
installed_json_constraint(&ChatCompletionToolChoiceOption::None),
GuidedToolConstraint::None
);
}
#[test]
fn kimi_k3_is_detected_from_either_parser_slot() {
assert!(uses_kimi_k3_parser(Some("kimi_k3"), None));
assert!(uses_kimi_k3_parser(Some("kimi-k3"), None));
assert!(uses_kimi_k3_parser(None, Some("kimi_k3")));
assert!(uses_kimi_k3_parser(None, Some("kimi-k3")));
}
#[test]
fn non_k3_parsers_are_not_mistaken_for_k3() {
assert!(!uses_kimi_k3_parser(None, None));
assert!(!uses_kimi_k3_parser(Some("kimi_k2"), Some("qwen3")));
assert!(!uses_kimi_k3_parser(Some("qwen3_coder"), None));
}
#[test]
fn kimi_k3_required_without_tools_is_rejected_before_the_xtml_return() {
for extra in [
json!({"tool_choice": "required"}),
json!({"tool_choice": "required", "tools": []}),
] {
let request = request(extra);
let result = guided_tool_constraint(&request, Some("kimi_k3"), None, false);
assert!(
result.is_err(),
"Kimi K3 required must reject both missing and empty tools"
);
}
}
}