Skip to content
Closed
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
7 changes: 6 additions & 1 deletion bindings/golang/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,12 @@ pub unsafe extern "C" fn sgl_client_chat_completion_stream(
chat_request.tools.as_ref(),
chat_request.tool_choice.as_ref(),
) {
match registry.generate_tool_constraint(None, tools, tool_choice) {
match registry.generate_tool_constraint(
None,
tools,
tool_choice,
chat_request.parallel_tool_calls,
) {
Ok(Some(c)) => Some(c.to_tuple()),
Ok(None) => None,
Err(e) => {
Expand Down
7 changes: 6 additions & 1 deletion bindings/golang/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,12 @@ pub unsafe extern "C" fn sgl_multi_client_chat_completion_stream(
chat_request.tools.as_ref(),
chat_request.tool_choice.as_ref(),
) {
match registry.generate_tool_constraint(None, tools, tool_choice) {
match registry.generate_tool_constraint(
None,
tools,
tool_choice,
chat_request.parallel_tool_calls,
) {
Ok(Some(c)) => Some(c.to_tuple()),
Ok(None) => None,
Err(e) => {
Expand Down
7 changes: 6 additions & 1 deletion bindings/golang/src/preprocessor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ fn preprocess_impl(
chat_request.tools.as_ref(),
chat_request.tool_choice.as_ref(),
) {
match registry.generate_tool_constraint(None, tools, tool_choice) {
match registry.generate_tool_constraint(
None,
tools,
tool_choice,
chat_request.parallel_tool_calls,
) {
Ok(Some(c)) => {
let json_str = serde_json::to_string(&c.to_tuple()).map_err(|e| {
(
Expand Down
23 changes: 20 additions & 3 deletions crates/tool_parser/src/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,22 @@ impl ParserRegistry {
/// If `configured_parser` supports structural tags → `StructuralTag(json)`.
/// Otherwise → `JsonSchema(schema)` for required/function tool_choice.
/// Returns `Ok(None)` for auto/none tool_choice.
/// `parallel_tool_calls` is the request's setting (OpenAI
/// `parallel_tool_calls`, or the inverse of Anthropic
/// `disable_parallel_tool_use`); `Some(false)` bounds the JSON-schema
/// fallback to a single call. Named-function constraints are single-call
/// by construction. Registry structural-tag builders do not currently set
/// a repeat bound (`stop_after_first`), so the setting is unenforced on
/// that path; the Harmony preparation stage builds its own tag and does
/// honor it.
pub fn generate_tool_constraint(
&self,
configured_parser: Option<&str>,
tools: &[Tool],
tool_choice: &ToolChoice,
parallel_tool_calls: Option<bool>,
) -> Result<Option<ToolConstraint>, String> {
let single_tool_call = parallel_tool_calls == Some(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if tools.is_empty() {
return Ok(None);
}
Expand Down Expand Up @@ -226,7 +236,7 @@ impl ParserRegistry {
Ok(Some(ToolConstraint::JsonSchema(params_schema)))
}
_ => {
let schema = build_required_array_schema(tools)?;
let schema = build_required_array_schema(tools, single_tool_call)?;
Ok(Some(ToolConstraint::JsonSchema(schema)))
}
}
Expand Down Expand Up @@ -539,8 +549,9 @@ impl Default for ParserFactory {
}
}

/// Build JSON schema for required tool calls (array with minItems: 1).
fn build_required_array_schema(tools: &[Tool]) -> Result<String, String> {
/// Build JSON schema for required tool calls (array with minItems: 1; also
/// maxItems: 1 when the request disables parallel tool calls).
fn build_required_array_schema(tools: &[Tool], single_call: bool) -> Result<String, String> {
let mut any_of_schemas = Vec::with_capacity(tools.len());
for tool in tools {
let tool_schema = json!({
Expand Down Expand Up @@ -585,6 +596,12 @@ fn build_required_array_schema(tools: &[Tool]) -> Result<String, String> {
}
});

if single_call {
if let serde_json::Value::Object(ref mut obj) = array_schema {
obj.insert("maxItems".to_string(), json!(1));
}
}

if !all_defs.is_empty() {
if let serde_json::Value::Object(ref mut obj) = array_schema {
obj.insert("$defs".to_string(), serde_json::Value::Object(all_defs));
Expand Down
72 changes: 72 additions & 0 deletions crates/tool_parser/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -818,3 +818,75 @@ mod minimax_mapping_tests {
}
}
}

mod constraint_limit_tests {
use openai_protocol::common::{Function, FunctionChoice, Tool, ToolChoice, ToolChoiceValue};

use crate::{factory::ParserFactory, ToolConstraint};

fn tools() -> Vec<Tool> {
vec![Tool {
tool_type: "function".to_string(),
function: Function {
name: "get_weather".to_string(),
description: Some("Get weather information".to_string()),
parameters: serde_json::json!({
"type": "object",
"properties": {
"city": {"type": "string"}
}
}),
strict: None,
},
}]
}

fn schema_for(choice: &ToolChoice, parallel_tool_calls: Option<bool>) -> serde_json::Value {
let factory = ParserFactory::new();
let constraint = factory
.registry()
.generate_tool_constraint(None, &tools(), choice, parallel_tool_calls)
.unwrap()
.expect("constraint expected");
match constraint {
ToolConstraint::JsonSchema(s) => serde_json::from_str(&s).unwrap(),
ToolConstraint::StructuralTag(_) => panic!("expected JSON schema constraint"),
}
}

#[test]
fn required_bounds_to_single_call_when_parallel_disabled() {
let schema = schema_for(&ToolChoice::Value(ToolChoiceValue::Required), Some(false));
assert_eq!(schema["minItems"], 1);
assert_eq!(schema["maxItems"], 1);
}

#[test]
fn required_stays_unbounded_unless_parallel_disabled() {
// Unspecified and explicitly-true behave identically: no upper bound.
for parallel in [None, Some(true)] {
let schema = schema_for(&ToolChoice::Value(ToolChoiceValue::Required), parallel);
assert_eq!(schema["minItems"], 1);
assert!(
schema.get("maxItems").is_none(),
"schema must not carry maxItems for parallel_tool_calls={parallel:?}"
);
}
}

#[test]
fn named_choice_is_unaffected_by_parallel_setting() {
let named = ToolChoice::Function {
tool_type: "function".to_string(),
function: FunctionChoice {
name: "get_weather".to_string(),
},
};
let bounded = schema_for(&named, Some(false));
let unbounded = schema_for(&named, None);
// Named choice constrains to the single tool's parameters object —
// single-call by construction, so the setting must be a no-op.
assert_eq!(bounded, unbounded);
assert_eq!(bounded["type"], "object");
}
}
132 changes: 124 additions & 8 deletions model_gateway/src/routers/grpc/harmony/stages/preparation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,12 @@ impl HarmonyPreparationStage {

// Step 2: Build structural tag constraint
let tool_constraint = if let Some(tools) = body_ref.tools.as_ref() {
Self::generate_tool_call_constraint(tools, body_ref.tool_choice.as_ref())
.map_err(|e| *e)?
Self::generate_tool_call_constraint(
tools,
body_ref.tool_choice.as_ref(),
body_ref.parallel_tool_calls,
)
.map_err(|e| *e)?
} else {
None
};
Expand Down Expand Up @@ -183,8 +187,12 @@ impl HarmonyPreparationStage {
let tool_constraint = if function_tools.is_empty() {
None
} else {
Self::generate_tool_call_constraint(&function_tools, chat_tool_choice.as_ref())
.map_err(|e| *e)?
Self::generate_tool_call_constraint(
&function_tools,
chat_tool_choice.as_ref(),
request.parallel_tool_calls,
)
.map_err(|e| *e)?
};

let text_constraint = if let Some(text_config) = &request.text {
Expand Down Expand Up @@ -303,26 +311,36 @@ impl HarmonyPreparationStage {
///
/// Uses structural tags with `triggered_tags` format to force Harmony format output.
/// This ensures the model outputs in Harmony format (with channels) even when constrained.
///
/// `parallel_tool_calls` is the request's setting; `Some(false)` bounds a
/// `required`-style constraint to a single call. Named-function constraints
/// are single-call regardless.
fn generate_tool_call_constraint(
tools: &[Tool],
tool_choice: Option<&ToolChoice>,
parallel_tool_calls: Option<bool>,
) -> Result<Option<(String, String)>, Box<Response>> {
let Some(choice) = tool_choice else {
return Ok(None);
};
let single_tool_call = parallel_tool_calls == Some(false);

match choice {
ToolChoice::Function { function, .. } => {
let tag = Self::build_tool_call_structural_tag(tools, Some(&function.name))?;
let tag = Self::build_tool_call_structural_tag(
tools,
Some(&function.name),
single_tool_call,
)?;
Ok(Some(("structural_tag".to_string(), tag)))
}
ToolChoice::Value(ToolChoiceValue::Required) => {
let tag = Self::build_tool_call_structural_tag(tools, None)?;
let tag = Self::build_tool_call_structural_tag(tools, None, single_tool_call)?;
Ok(Some(("structural_tag".to_string(), tag)))
}
ToolChoice::AllowedTools { mode, .. } => {
if mode == "required" {
let tag = Self::build_tool_call_structural_tag(tools, None)?;
let tag = Self::build_tool_call_structural_tag(tools, None, single_tool_call)?;
Ok(Some(("structural_tag".to_string(), tag)))
} else {
Ok(None)
Expand All @@ -337,9 +355,15 @@ impl HarmonyPreparationStage {
/// Supports both reasoning-enabled and reasoning-disabled modes:
/// - With reasoning: triggers on `<|start|>assistant<|channel|>commentary` (waits for analysis)
/// - Without reasoning: triggers on `<|channel|>commentary` (goes directly to commentary)
///
/// `single_tool_call` requests a repeat bound: the grammar stops accepting
/// further tool-call tags after the first one completes. A named function
/// is always single-call; a `required` constraint is single-call only when
/// the request disabled parallel tool calls.
fn build_tool_call_structural_tag(
tools: &[Tool],
specific_function: Option<&str>,
single_tool_call: bool,
) -> Result<String, Box<Response>> {
let mut tags = Vec::new();

Expand Down Expand Up @@ -395,7 +419,9 @@ impl HarmonyPreparationStage {
}));
}

let stop_after_first = specific_function.is_some();
// Each tag is exactly one call, so `stop_after_first` is the repeat
// bound: set for a named function and for parallel_tool_calls=false.
let stop_after_first = specific_function.is_some() || single_tool_call;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: The PR description is now stale with respect to this push. The ## Changes list doesn't mention model_gateway/src/routers/grpc/harmony/stages/preparation.rs (the whole of 3a69e5bc), and the ## Test Plan doesn't cover the new harmony::stages::preparation::tests module or a cargo test -p smg run that exercises it. It also still says "structural-tag grammars carry no repeat bound — documented limitation, unchanged", which the Harmony path now contradicts — worth aligning with the reworded doc comment in factory.rs.

The code change itself looks right: each Harmony tag is exactly one call, so stop_after_first is the correct repeat bound here, and all three build_tool_call_structural_tag call sites plus both generate_tool_call_constraint callers thread the request value through.


let structural_tag = json!({
"format": {
Expand Down Expand Up @@ -464,3 +490,93 @@ pub(crate) fn build_text_format_structural_tag(
serde_json::to_string(&structural_tag)
.map_err(|e| format!("Failed to serialize structural tag for structured output: {e}"))
}

#[cfg(test)]
mod tests {
use openai_protocol::common::{Function, FunctionChoice, Tool, ToolChoice, ToolChoiceValue};

use super::HarmonyPreparationStage;

fn tools() -> Vec<Tool> {
["get_weather", "get_time"]
.into_iter()
.map(|name| Tool {
tool_type: "function".to_string(),
function: Function {
name: name.to_string(),
description: None,
parameters: serde_json::json!({"type": "object", "properties": {}}),
strict: None,
},
})
.collect()
}

fn stop_after_first(
tool_choice: &ToolChoice,
parallel_tool_calls: Option<bool>,
) -> (bool, usize) {
let (_, tag) = HarmonyPreparationStage::generate_tool_call_constraint(
&tools(),
Some(tool_choice),
parallel_tool_calls,
)
.unwrap()
.expect("constraint expected");
let tag: serde_json::Value = serde_json::from_str(&tag).unwrap();
let format = &tag["format"];
(
format["stop_after_first"].as_bool().unwrap(),
format["tags"].as_array().unwrap().len(),
)
}

fn required() -> ToolChoice {
ToolChoice::Value(ToolChoiceValue::Required)
}

#[test]
fn required_bounds_to_single_call_when_parallel_disabled() {
let (stop, tag_count) = stop_after_first(&required(), Some(false));
assert!(stop, "parallel_tool_calls=false must set the repeat bound");
// Both tools stay eligible; only the repeat count is bounded.
assert_eq!(tag_count, 4);
}

#[test]
fn required_stays_unbounded_unless_parallel_disabled() {
for parallel in [None, Some(true)] {
let (stop, _) = stop_after_first(&required(), parallel);
assert!(
!stop,
"required must not carry a repeat bound for parallel_tool_calls={parallel:?}"
);
}
}

#[test]
fn allowed_tools_required_mode_honors_parallel_setting() {
let choice = ToolChoice::AllowedTools {
tool_type: "allowed_tools".to_string(),
mode: "required".to_string(),
tools: vec![],
};
assert!(stop_after_first(&choice, Some(false)).0);
assert!(!stop_after_first(&choice, None).0);
}

#[test]
fn named_function_is_single_call_regardless_of_parallel_setting() {
let named = ToolChoice::Function {
tool_type: "function".to_string(),
function: FunctionChoice {
name: "get_weather".to_string(),
},
};
for parallel in [None, Some(true), Some(false)] {
let (stop, tag_count) = stop_after_first(&named, parallel);
assert!(stop, "named function must always be single-call");
assert_eq!(tag_count, 2, "named choice keeps only the named tool");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ pub(crate) async fn prepare_chat_like(
.as_deref(),
tools,
tool_choice,
request.parallel_tool_calls,
)
.map_err(|e| {
error!(function = "ChatPreparationStage::execute", error = %e, "Invalid tool configuration");
Expand Down
Loading
Loading