use std::borrow::Cow;
use crate::preprocessor::structural_tag::requires_native_tool_call_format;
use crate::preprocessor::{OpenAIPreprocessor, PreprocessedRequest};
use crate::protocols::openai::chat_completions::NvCreateChatCompletionRequest;
use crate::protocols::openai::tools::{
ToolChoiceGuidance, get_tool_choice_guidance_from_tools, validate_openai_tool_choice,
};
use crate::protocols::openai::{GuidedToolConstraint, validate};
use dynamo_parsers::tool_calling::{ToolChoice, ToolDefinition};
use dynamo_protocols::types::{
ChatCompletionTool, ChatCompletionToolChoiceOption, CreateChatCompletionRequest, ResponseFormat,
};
use dynamo_runtime::error::{DynamoError, ErrorType};
fn invalid_argument(message: impl Into<String>, public_message: Option<&str>) -> DynamoError {
let mut error = DynamoError::builder()
.error_type(ErrorType::InvalidArgument)
.message(message);
if let Some(public_message) = public_message {
error = error.public_message(public_message);
}
error.build()
}
impl OpenAIPreprocessor {
pub(crate) fn tool_call_parsing_enabled(request: &NvCreateChatCompletionRequest) -> bool {
if !request.inner.has_effective_tools() {
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 = effective_tools(&request.inner)?;
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);
let parser_tool_choice = convert_tool_choice(tool_choice);
self.validate_structured_output_composition(request, tool_choice, tools.as_ref())?;
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.",
),
None,
));
}
if !is_forced_tool_choice && has_explicit_guided_decoding {
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(
&parser_tool_choice,
&convert_tools(tools.as_ref()),
request.inner.parallel_tool_calls,
prompt_injected_reasoning,
request.inner.response_format.as_ref(),
common_request,
)? {
return Ok(GuidedToolConstraint::StructuralTag);
}
if !is_forced_tool_choice && has_response_format_constraint {
return Ok(GuidedToolConstraint::None);
}
let requires_native_format =
requires_native_tool_call_format(self.tool_call_parser.as_deref(), &parser_tool_choice)
|| requires_native_tool_call_format(
self.runtime_config.reasoning_parser.as_deref(),
&parser_tool_choice,
);
if is_forced_tool_choice && requires_native_format {
tracing::debug!(
tool_choice = ?parser_tool_choice,
"Structural tag was not applied and the parser uses a native tool-call format; using prompt/parser compatibility fallback"
);
return Ok(GuidedToolConstraint::None);
}
match get_tool_choice_guidance_from_tools(
Some(tool_choice),
Some(tools.as_ref()),
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(), None));
}
}
Ok(GuidedToolConstraint::None)
}
fn validate_structured_output_composition(
&self,
request: &NvCreateChatCompletionRequest,
tool_choice: &ChatCompletionToolChoiceOption,
tools: &[ChatCompletionTool],
) -> Result<(), DynamoError> {
let allow_tool_calls_with_structured_output = self
.runtime_config
.structural_tag
.as_ref()
.is_some_and(|config| config.allow_tool_calls_with_structured_output);
if matches!(tool_choice, ChatCompletionToolChoiceOption::Auto)
&& !tools.is_empty()
&& allow_tool_calls_with_structured_output
{
validate::validate_response_format_conflicts(
&request.inner.response_format,
&request.common,
)
.map_err(|error| {
let message = error.to_string();
invalid_argument(&message, Some(&message))
})?;
}
Ok(())
}
}
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 effective_tools(
request: &CreateChatCompletionRequest,
) -> Result<Cow<'_, [ChatCompletionTool]>, DynamoError> {
validate::validated_effective_tools(request)
.map_err(|error| invalid_argument(error.to_string(), None))
}
pub(crate) fn effective_tool_definitions(
request: &CreateChatCompletionRequest,
) -> Result<Vec<ToolDefinition>, DynamoError> {
effective_tools(request).map(|tools| convert_tools(tools.as_ref()))
}
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 tools = effective_tools(&request.inner)?;
guided_tool_constraint_with_effective_tools(
request,
tool_call_parser,
reasoning_parser,
false,
tools.as_ref(),
)
}
pub(crate) fn guided_tool_constraint_with_effective_tools(
request: &NvCreateChatCompletionRequest,
tool_call_parser: Option<&str>,
reasoning_parser: Option<&str>,
uses_structural_tag: bool,
tools: &[ChatCompletionTool],
) -> 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), Some(tools))
.map_err(|error| invalid_argument(error.to_string(), None))?;
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);
}
let parser_tool_choice = convert_tool_choice(tool_choice);
if requires_native_tool_call_format(tool_call_parser, &parser_tool_choice)
|| requires_native_tool_call_format(reasoning_parser, &parser_tool_choice)
{
return Ok(GuidedToolConstraint::None);
}
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(), None)),
}
}
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 std::{path::PathBuf, sync::Arc};
use super::*;
use crate::local_model::runtime_config::StructuralTagMode;
use crate::model_card::ModelDeploymentCard;
use crate::protocols::common::{OutputOptions, SamplingOptions, StopConditions};
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"]
}
}
}])
}
fn preprocessor(parser: &str, mode: StructuralTagMode) -> Arc<OpenAIPreprocessor> {
let model_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/data/sample-models/mock-llama-3.1-8b-instruct");
let mut mdc = ModelDeploymentCard::load_from_disk(model_path, None).unwrap();
mdc.runtime_config.structural_tag = (mode == StructuralTagMode::On)
.then(crate::local_model::runtime_config::StructuralTagConfig::default);
mdc.runtime_config.tool_call_parser = Some(parser.to_string());
OpenAIPreprocessor::new(mdc).unwrap()
}
fn preprocessed_request() -> PreprocessedRequest {
PreprocessedRequest::builder()
.model("test-model".to_string())
.token_ids(Vec::new())
.stop_conditions(StopConditions::default())
.sampling_options(SamplingOptions::default())
.output_options(OutputOptions::default())
.build()
.unwrap()
}
#[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 effective_tools_borrows_top_level_tools_without_dynamic_declarations() {
let request = request(json!({"tools": tools()}));
let top_level = request.inner.tools.as_deref().unwrap();
let effective = effective_tools(&request.inner).expect("top-level tools must validate");
assert!(matches!(&effective, Cow::Borrowed(_)));
assert!(std::ptr::eq(effective.as_ptr(), top_level.as_ptr()));
}
#[test]
fn dynamic_system_tools_enable_parsing_and_honor_tool_choice_none() {
for (tool_choice, expected) in [(None, true), (Some(json!("none")), false)] {
let mut extra = json!({
"messages": [
{
"role": "system",
"content": "",
"tools": [{"name": "lookup", "parameters": {"type": "object"}}]
},
{"role": "user", "content": "look it up"}
]
});
if let Some(tool_choice) = tool_choice {
extra["tool_choice"] = tool_choice;
}
assert_eq!(
OpenAIPreprocessor::tool_call_parsing_enabled(&request(extra)),
expected,
);
}
}
#[test]
fn effective_tools_preserve_order_and_normalize_wrapped_and_bare_shapes() {
let request = request(json!({
"tools": [{
"type": "function",
"function": {"name": "static_tool", "parameters": {"type": "object"}}
}],
"messages": [
{"role": "user", "content": "start"},
{
"role": "system",
"content": "",
"tools": [{
"type": "function",
"function": {
"name": "wrapped_dynamic",
"parameters": {
"type": "object",
"properties": {"x": {"type": "integer"}},
"required": ["x"],
"additionalProperties": false
},
"strict": true
}
}]
},
{
"role": "system",
"content": "",
"tools": [{
"name": "bare_dynamic",
"description": "bare form",
"parameters": {"type": "object", "properties": {}},
"strict": false,
"vendor_hint": "kept only in the original message"
}]
},
{"role": "user", "content": "continue"}
]
}));
let normalized = effective_tools(&request.inner).expect("dynamic tools must normalize");
assert!(matches!(&normalized, Cow::Owned(_)));
assert_eq!(
normalized
.iter()
.map(|tool| tool.function.name.as_str())
.collect::<Vec<_>>(),
["static_tool", "wrapped_dynamic", "bare_dynamic"]
);
assert_eq!(normalized[1].function.strict, Some(true));
assert_eq!(normalized[2].function.strict, Some(false));
assert_eq!(
normalized[2].function.description.as_deref(),
Some("bare form")
);
let original = serde_json::to_value(&request.inner.messages).unwrap();
assert_eq!(
original[2]["tools"][0]["vendor_hint"], "kept only in the original message",
"normalization must not rewrite dynamic message declarations"
);
}
#[test]
fn effective_tools_reject_invalid_dynamic_metadata() {
for (field, value) in [
("strict", json!("yes")),
("parameters", json!([])),
("description", json!(7)),
] {
let mut tool = json!({"name": "lookup"});
tool[field] = value;
let request = request(json!({
"messages": [
{"role": "system", "content": "", "tools": [tool]},
{"role": "user", "content": "go"}
]
}));
let error = effective_tools(&request.inner).expect_err("invalid field must fail");
assert!(error.to_string().contains(field), "{field}: {error}");
}
let request = request(json!({
"messages": [
{"role": "system", "content": "", "tools": [{"name": "bad name"}]},
{"role": "user", "content": "go"}
]
}));
let error = effective_tools(&request.inner).expect_err("invalid name must fail");
assert_eq!(error.error_type(), ErrorType::InvalidArgument);
assert!(error.to_string().contains("has an invalid name"));
}
#[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 native_format_forced_choices_install_no_compatibility_json_constraint() {
let requests = [
request(json!({
"tools": tools(),
"tool_choice": "required"
})),
request(json!({
"tools": tools(),
"tool_choice": {
"type": "function",
"function": {"name": "get_weather"}
}
})),
];
for request in &requests {
for (tool_call_parser, reasoning_parser) in [
(Some("kimi_k2"), None),
(None, Some("kimi_k2")),
(Some("kimi_k3"), None),
(None, Some("kimi-k3")),
] {
assert_eq!(
guided_tool_constraint(request, tool_call_parser, reasoning_parser, false,)
.expect("native-format choice is valid"),
GuidedToolConstraint::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"
);
}
}
#[test]
fn structural_tag_off_preserves_kimi_k2_required_native_tag() {
let request = request(json!({
"tools": tools(),
"tool_choice": "required"
}));
let preprocessor = preprocessor("kimi_k2", StructuralTagMode::Off);
let mut common_request = preprocessed_request();
let applied = preprocessor
.apply_tool_choice_guided_decoding(&request, &mut common_request, false)
.unwrap();
assert_eq!(applied, GuidedToolConstraint::StructuralTag);
assert!(
common_request
.sampling_options
.guided_decoding
.as_ref()
.and_then(|guided| guided.structural_tag.as_ref())
.is_some()
);
}
#[test]
fn structural_tag_off_preserves_kimi_k3_named_native_tag() {
let request = request(json!({
"tools": tools(),
"tool_choice": {
"type": "function",
"function": {"name": "get_weather"}
}
}));
let preprocessor = preprocessor("kimi_k3", StructuralTagMode::Off);
let mut common_request = preprocessed_request();
let applied = preprocessor
.apply_tool_choice_guided_decoding(&request, &mut common_request, false)
.unwrap();
assert_eq!(applied, GuidedToolConstraint::StructuralTag);
assert!(
common_request
.sampling_options
.guided_decoding
.as_ref()
.and_then(|guided| guided.structural_tag.as_ref())
.is_some()
);
}
#[test]
fn structural_tag_off_preserves_generic_forced_tool_fallback() {
let request = request(json!({
"tools": tools(),
"tool_choice": "required"
}));
let preprocessor = preprocessor("hermes", StructuralTagMode::Off);
let mut common_request = preprocessed_request();
let applied = preprocessor
.apply_tool_choice_guided_decoding(&request, &mut common_request, false)
.unwrap();
assert_eq!(applied, GuidedToolConstraint::GuidedJsonRequired);
assert!(
common_request
.sampling_options
.guided_decoding
.as_ref()
.and_then(|guided| guided.json.as_ref())
.is_some()
);
}
#[test]
fn kimi_k3_builder_error_uses_native_fallback() {
let request = request(json!({
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {
"value": {
"type": "string",
"$id": "urn:example:scoped",
"$defs": {
"allowed": {"type": "string", "enum": ["safe"]}
},
"allOf": [{"$ref": "#/$defs/allowed"}]
}
},
"required": ["value"]
}
}
}],
"tool_choice": "auto"
}));
let preprocessor = preprocessor("kimi_k3", StructuralTagMode::On);
let tools = convert_tools(request.inner.tools.as_deref().unwrap());
let ctx = dynamo_parsers::tool_calling::ToolCallFormatBuildContext {
tool_choice: &ToolChoice::Auto,
tools: &tools,
parallel_tool_calls: request.inner.parallel_tool_calls,
schema_mode: preprocessor.runtime_config.structural_tag_schema,
starts_in_reasoning: false,
};
let error = dynamo_parsers::tool_calling::StructuralTagBuilder::KimiK3
.build_tool_call_format(&ctx)
.expect_err("scoped string reference must reach the builder-error fallback");
let message = format!("{error:#}");
assert!(
message.contains("value") && message.contains("string"),
"{message}"
);
let mut common_request = preprocessed_request();
let applied = preprocessor
.apply_tool_choice_guided_decoding(&request, &mut common_request, false)
.expect("builder errors must allow the request to continue without a structural tag");
assert_eq!(applied, GuidedToolConstraint::None);
assert!(common_request.sampling_options.guided_decoding.is_none());
}
#[test]
fn kimi_k3_forced_choices_accept_dynamic_system_tools() {
for tool_choice in [
json!("required"),
json!({"type": "function", "function": {"name": "lookup"}}),
] {
let request = request(json!({
"messages": [
{
"role": "system",
"content": "",
"tools": [{"name": "lookup", "parameters": {"type": "object"}}]
},
{"role": "user", "content": "go"}
],
"tool_choice": tool_choice
}));
assert_eq!(
guided_tool_constraint(&request, Some("kimi_k3"), None, false)
.expect("dynamic forced choice must validate"),
GuidedToolConstraint::None,
"K3 forced choices use native XTML rather than guided JSON"
);
}
}
}