[router] Support structured model output for openai and grpc router (#12431)

This commit is contained in:
Keyang Ru
2025-11-07 10:16:45 -08:00
committed by GitHub
parent e316bcacb1
commit 5c9273c032
13 changed files with 469 additions and 58 deletions
@@ -6,6 +6,7 @@ Run with:
python3 -m unittest e2e_response_api.backends.test_grpc_backend.TestGrpcBackend python3 -m unittest e2e_response_api.backends.test_grpc_backend.TestGrpcBackend
""" """
import json
import sys import sys
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -18,11 +19,12 @@ sys.path.insert(0, str(_TEST_DIR))
from mixins.function_call import FunctionCallingBaseTest from mixins.function_call import FunctionCallingBaseTest
from mixins.mcp import MCPTests from mixins.mcp import MCPTests
from mixins.state_management import StateManagementTests from mixins.state_management import StateManagementTests
from mixins.structured_output import StructuredOutputBaseTest
from router_fixtures import popen_launch_workers_and_router from router_fixtures import popen_launch_workers_and_router
from util import kill_process_tree from util import kill_process_tree
class TestGrpcBackend(StateManagementTests, MCPTests): class TestGrpcBackend(StateManagementTests, MCPTests, StructuredOutputBaseTest):
"""End to end tests for gRPC backend (Regular backend with Llama).""" """End to end tests for gRPC backend (Regular backend with Llama)."""
@classmethod @classmethod
@@ -37,7 +39,15 @@ class TestGrpcBackend(StateManagementTests, MCPTests):
num_workers=1, num_workers=1,
tp_size=2, tp_size=2,
policy="round_robin", policy="round_robin",
router_args=["--history-backend", "memory", "--tool-call-parser", "llama"], worker_args=[
"--context-length=1000",
],
router_args=[
"--history-backend",
"memory",
"--tool-call-parser",
"llama",
],
) )
cls.base_url = cls.cluster["base_url"] cls.base_url = cls.cluster["base_url"]
@@ -62,14 +72,82 @@ class TestGrpcBackend(StateManagementTests, MCPTests):
def test_mcp_basic_tool_call_streaming(self): def test_mcp_basic_tool_call_streaming(self):
return super().test_mcp_basic_tool_call_streaming() return super().test_mcp_basic_tool_call_streaming()
# Inherited from MCPTests: def test_structured_output_json_schema(self):
# - test_mcp_basic_tool_call """Override with simpler schema for Llama model (complex schemas not well supported)."""
# - test_mcp_basic_tool_call_streaming data = {
# - test_mixed_mcp_and_function_tools (requires external MCP server) "model": self.model,
# - test_mixed_mcp_and_function_tools_streaming (requires external MCP server) "input": [
{
"role": "system",
"content": "You are a math solver. Return ONLY a JSON object that matches the schema—no extra text.",
},
{
"role": "user",
"content": "What is 1 + 1?",
},
],
"text": {
"format": {
"type": "json_schema",
"name": "math_answer",
"schema": {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
},
}
},
}
create_resp = self.make_request("/v1/responses", "POST", data)
self.assertEqual(create_resp.status_code, 200)
create_data = create_resp.json()
self.assertIn("id", create_data)
self.assertIn("output", create_data)
self.assertIn("text", create_data)
# Verify text format was echoed back correctly
self.assertIn("format", create_data["text"])
self.assertEqual(create_data["text"]["format"]["type"], "json_schema")
self.assertEqual(create_data["text"]["format"]["name"], "math_answer")
self.assertIn("schema", create_data["text"]["format"])
# Find the message output
output_text = next(
(
content.get("text", "")
for item in create_data.get("output", [])
if item.get("type") == "message"
for content in item.get("content", [])
if content.get("type") == "output_text"
),
None,
)
self.assertIsNotNone(output_text, "No output_text found in response")
self.assertTrue(output_text.strip(), "output_text is empty")
# Parse JSON output
output_json = json.loads(output_text)
# Verify simple schema structure (just answer field)
self.assertIn("answer", output_json)
self.assertIsInstance(output_json["answer"], str)
self.assertTrue(output_json["answer"], "Answer is empty")
@unittest.skip("TODO: Temporary skip since deepwiki might hit rate limit")
def test_mcp_basic_tool_call(self):
return super().test_mcp_basic_tool_call()
@unittest.skip("Temporary skip since deepwiki might hit rate limit")
def test_mcp_basic_tool_call_streaming(self):
return super().test_mcp_basic_tool_call_streaming()
class TestGrpcHarmonyBackend(StateManagementTests, MCPTests, FunctionCallingBaseTest): class TestGrpcHarmonyBackend(
StateManagementTests, MCPTests, FunctionCallingBaseTest, StructuredOutputBaseTest
):
"""End to end tests for Harmony backend.""" """End to end tests for Harmony backend."""
@classmethod @classmethod
@@ -84,7 +162,13 @@ class TestGrpcHarmonyBackend(StateManagementTests, MCPTests, FunctionCallingBase
num_workers=1, num_workers=1,
tp_size=2, tp_size=2,
policy="round_robin", policy="round_robin",
router_args=["--history-backend", "memory"], worker_args=[
"--reasoning-parser=gpt-oss",
],
router_args=[
"--history-backend",
"memory",
],
) )
cls.base_url = cls.cluster["base_url"] cls.base_url = cls.cluster["base_url"]
@@ -22,6 +22,7 @@ from mixins.basic_crud import ConversationCRUDBaseTest, ResponseCRUDBaseTest
from mixins.function_call import FunctionCallingBaseTest from mixins.function_call import FunctionCallingBaseTest
from mixins.mcp import MCPTests from mixins.mcp import MCPTests
from mixins.state_management import StateManagementTests from mixins.state_management import StateManagementTests
from mixins.structured_output import StructuredOutputBaseTest
from router_fixtures import popen_launch_openai_xai_router from router_fixtures import popen_launch_openai_xai_router
from util import kill_process_tree from util import kill_process_tree
@@ -32,6 +33,7 @@ class TestOpenaiBackend(
StateManagementTests, StateManagementTests,
MCPTests, MCPTests,
FunctionCallingBaseTest, FunctionCallingBaseTest,
StructuredOutputBaseTest,
): ):
"""End to end tests for OpenAI backend.""" """End to end tests for OpenAI backend."""
@@ -73,6 +75,14 @@ class TestOpenaiBackend(
def test_mixed_mcp_and_function_tools_streaming(self): def test_mixed_mcp_and_function_tools_streaming(self):
super().test_mixed_mcp_and_function_tools_streaming() super().test_mixed_mcp_and_function_tools_streaming()
@unittest.skip("Temporary skip since deepwiki might hit rate limit")
def test_mcp_basic_tool_call(self):
super().test_mcp_basic_tool_call()
@unittest.skip("Temporary skip since deepwiki might hit rate limit")
def test_mcp_basic_tool_call_streaming(self):
super().test_mcp_basic_tool_call_streaming()
class TestXaiBackend(StateManagementTests): class TestXaiBackend(StateManagementTests):
"""End to end tests for XAI backend.""" """End to end tests for XAI backend."""
@@ -19,6 +19,7 @@ def pytest_collection_modifyitems(config, items):
- MCPTests - MCPTests
- StateManagementTests - StateManagementTests
- FunctionCallingBaseTest - FunctionCallingBaseTest
- StructuredOutputBaseTest
""" """
base_class_names = { base_class_names = {
"StateManagementBaseTest", "StateManagementBaseTest",
@@ -27,6 +28,7 @@ def pytest_collection_modifyitems(config, items):
"MCPTests", "MCPTests",
"StateManagementTests", "StateManagementTests",
"FunctionCallingBaseTest", "FunctionCallingBaseTest",
"StructuredOutputBaseTest",
} }
# Filter out tests from base classes # Filter out tests from base classes
@@ -0,0 +1,129 @@
"""
Structured output tests for Response API.
Tests for text.format field with json_object and json_schema formats.
"""
import json
import sys
from pathlib import Path
# Add current directory for local imports
_TEST_DIR = Path(__file__).parent
sys.path.insert(0, str(_TEST_DIR))
from util import CustomTestCase
class StructuredOutputBaseTest(CustomTestCase):
"""Base class for structured output tests with common utilities."""
# To be set by subclasses
base_url: str = None
api_key: str = None
model: str = None
def make_request(self, endpoint, method="GET", data=None):
"""Make HTTP request to the API."""
url = f"{self.base_url}{endpoint}"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
if method == "GET":
response = self.session.get(url, headers=headers)
elif method == "POST":
response = self.session.post(url, headers=headers, json=data)
elif method == "DELETE":
response = self.session.delete(url, headers=headers)
else:
raise ValueError(f"Unsupported method: {method}")
return response
def test_structured_output_json_schema(self):
"""Test structured output with json_schema format."""
# Create response with structured output
data = {
"model": self.model,
"input": [
{
"role": "system",
"content": "You are a helpful math tutor. Guide the user through the solution step by step.",
},
{"role": "user", "content": "how can I solve 8x + 7 = -23"},
],
"text": {
"format": {
"type": "json_schema",
"name": "math_reasoning",
"schema": {
"type": "object",
"properties": {
"steps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"explanation": {"type": "string"},
"output": {"type": "string"},
},
"required": ["explanation", "output"],
"additionalProperties": False,
},
},
"final_answer": {"type": "string"},
},
"required": ["steps", "final_answer"],
"additionalProperties": False,
},
"strict": True,
}
},
}
create_resp = self.make_request("/v1/responses", "POST", data)
self.assertEqual(create_resp.status_code, 200)
create_data = create_resp.json()
self.assertIn("id", create_data)
self.assertIn("output", create_data)
self.assertIn("text", create_data)
# Verify text format was echoed back correctly
self.assertIn("format", create_data["text"])
self.assertEqual(create_data["text"]["format"]["type"], "json_schema")
self.assertEqual(create_data["text"]["format"]["name"], "math_reasoning")
self.assertIn("schema", create_data["text"]["format"])
self.assertEqual(create_data["text"]["format"]["strict"], True)
# Find the message output (output[0] may be reasoning, output[1] is message)
output_text = next(
(
content.get("text", "")
for item in create_data.get("output", [])
if item.get("type") == "message"
for content in item.get("content", [])
if content.get("type") == "output_text"
),
None,
)
self.assertIsNotNone(output_text, "No output_text found in response")
self.assertTrue(output_text.strip(), "output_text is empty")
# Parse JSON output
output_json = json.loads(output_text)
# Verify schema structure
self.assertIn("steps", output_json)
self.assertIn("final_answer", output_json)
self.assertIsInstance(output_json["steps"], list)
self.assertGreater(len(output_json["steps"]), 0)
# Verify each step has required fields
for step in output_json["steps"]:
self.assertIn("explanation", step)
self.assertIn("output", step)
+38 -21
View File
@@ -240,7 +240,7 @@ impl SglangSchedulerClient {
} }
/// Build a single SGLang GenerateRequest from OpenAI ChatCompletionRequest /// Build a single SGLang GenerateRequest from OpenAI ChatCompletionRequest
pub fn build_generate_request( pub fn build_generate_request_from_chat(
&self, &self,
request_id: String, request_id: String,
body: &ChatCompletionRequest, body: &ChatCompletionRequest,
@@ -250,7 +250,8 @@ impl SglangSchedulerClient {
tool_call_constraint: Option<(String, String)>, // (constraint_type, constraint_value) tool_call_constraint: Option<(String, String)>, // (constraint_type, constraint_value)
) -> Result<proto::GenerateRequest, String> { ) -> Result<proto::GenerateRequest, String> {
// Build sampling params // Build sampling params
let sampling_params = self.build_grpc_sampling_params(body, tool_call_constraint)?; let sampling_params =
self.build_grpc_sampling_params_from_chat(body, tool_call_constraint)?;
let grpc_request = proto::GenerateRequest { let grpc_request = proto::GenerateRequest {
request_id, request_id,
@@ -313,11 +314,11 @@ impl SglangSchedulerClient {
processed_text: String, processed_text: String,
token_ids: Vec<u32>, token_ids: Vec<u32>,
harmony_stop_ids: Option<Vec<u32>>, harmony_stop_ids: Option<Vec<u32>>,
tool_call_constraint: Option<(String, String)>, constraint: Option<(String, String)>,
) -> Result<proto::GenerateRequest, String> { ) -> Result<proto::GenerateRequest, String> {
// Build sampling params from ResponsesRequest // Build sampling params from ResponsesRequest
let mut sampling_params = let mut sampling_params =
self.build_grpc_sampling_params_from_responses(body, tool_call_constraint)?; self.build_grpc_sampling_params_from_responses(body, constraint)?;
// Inject Harmony stop token IDs if provided // Inject Harmony stop token IDs if provided
if let Some(stop_ids) = harmony_stop_ids { if let Some(stop_ids) = harmony_stop_ids {
@@ -343,8 +344,8 @@ impl SglangSchedulerClient {
Ok(grpc_request) Ok(grpc_request)
} }
/// Build gRPC SamplingParams from OpenAI request /// Build gRPC SamplingParams from ChatCompletionRequest
fn build_grpc_sampling_params( fn build_grpc_sampling_params_from_chat(
&self, &self,
request: &ChatCompletionRequest, request: &ChatCompletionRequest,
tool_call_constraint: Option<(String, String)>, tool_call_constraint: Option<(String, String)>,
@@ -380,7 +381,7 @@ impl SglangSchedulerClient {
ignore_eos: request.ignore_eos, ignore_eos: request.ignore_eos,
no_stop_trim: request.no_stop_trim, no_stop_trim: request.no_stop_trim,
n: request.n.unwrap_or(1) as i32, n: request.n.unwrap_or(1) as i32,
constraint: self.build_constraint(request, tool_call_constraint)?, constraint: self.build_constraint_for_chat(request, tool_call_constraint)?,
..Default::default() ..Default::default()
}) })
} }
@@ -395,18 +396,31 @@ impl SglangSchedulerClient {
} }
/// Build constraint for structured generation /// Build constraint for structured generation
fn build_constraint( fn build_constraint_for_chat(
&self, &self,
request: &ChatCompletionRequest, request: &ChatCompletionRequest,
tool_call_constraint: Option<(String, String)>, tool_call_constraint: Option<(String, String)>,
) -> Result<Option<proto::sampling_params::Constraint>, String> { ) -> Result<Option<proto::sampling_params::Constraint>, String> {
let mut constraints = Vec::new(); let mut constraints = Vec::new();
if let Some(ResponseFormat::JsonSchema { json_schema }) = &request.response_format { // Handle response_format constraints
match &request.response_format {
Some(ResponseFormat::JsonObject) => {
// json_object mode - constrain to valid JSON object
let schema = serde_json::json!({"type": "object"});
let schema_str = serde_json::to_string(&schema)
.map_err(|e| format!("Failed to serialize JSON schema: {}", e))?;
constraints.push(proto::sampling_params::Constraint::JsonSchema(schema_str));
}
Some(ResponseFormat::JsonSchema { json_schema }) => {
let schema_str = serde_json::to_string(&json_schema.schema) let schema_str = serde_json::to_string(&json_schema.schema)
.map_err(|e| format!("Failed to serialize JSON schema: {}", e))?; .map_err(|e| format!("Failed to serialize JSON schema: {}", e))?;
constraints.push(proto::sampling_params::Constraint::JsonSchema(schema_str)); constraints.push(proto::sampling_params::Constraint::JsonSchema(schema_str));
} }
Some(ResponseFormat::Text) | None => {
// No constraint for text format
}
}
if let Some(ebnf) = &request.ebnf { if let Some(ebnf) = &request.ebnf {
constraints.push(proto::sampling_params::Constraint::EbnfGrammar( constraints.push(proto::sampling_params::Constraint::EbnfGrammar(
@@ -418,7 +432,7 @@ impl SglangSchedulerClient {
constraints.push(proto::sampling_params::Constraint::Regex(regex.clone())); constraints.push(proto::sampling_params::Constraint::Regex(regex.clone()));
} }
// Handle tool call constraint // Handle tool call constraint from preparation stage
if let Some((constraint_type, constraint_value)) = tool_call_constraint { if let Some((constraint_type, constraint_value)) = tool_call_constraint {
if !constraints.is_empty() { if !constraints.is_empty() {
return Err("Constrained decoding is not compatible with tool calls.".to_string()); return Err("Constrained decoding is not compatible with tool calls.".to_string());
@@ -446,10 +460,10 @@ impl SglangSchedulerClient {
fn build_grpc_sampling_params_from_responses( fn build_grpc_sampling_params_from_responses(
&self, &self,
request: &ResponsesRequest, request: &ResponsesRequest,
tool_call_constraint: Option<(String, String)>, constraint: Option<(String, String)>,
) -> Result<proto::SamplingParams, String> { ) -> Result<proto::SamplingParams, String> {
// ResponsesRequest doesn't have stop sequences in the same way // Used by Harmony models only. Regular models use Chat API path.
// For Harmony router: Tools are handled via structural_tag constraints // Constraints come from Harmony preparation stage (structural_tag) or tool handling.
let max_new_tokens = request.max_output_tokens.map(|v| v as i32); let max_new_tokens = request.max_output_tokens.map(|v| v as i32);
@@ -469,22 +483,25 @@ impl SglangSchedulerClient {
ignore_eos: false, ignore_eos: false,
no_stop_trim: false, no_stop_trim: false,
n: 1, // Responses API doesn't support n>1 n: 1, // Responses API doesn't support n>1
constraint: self.build_constraint_for_responses(tool_call_constraint)?, constraint: self.build_constraint_for_responses(constraint)?,
..Default::default() ..Default::default()
}) })
} }
/// Build constraint for Responses API (simpler than Chat API's build_constraint) /// Build constraint for Responses API
/// ///
/// Responses API doesn't support response_format, ebnf, or regex constraints, /// Handles constraints from Harmony preparation stage (structural_tag for Harmony models,
/// so this only handles tool_call_constraint. /// structured output via text field, or tool call constraints).
///
/// Note: Regular gRPC models use Chat API path with response_format, not this function.
fn build_constraint_for_responses( fn build_constraint_for_responses(
&self, &self,
tool_call_constraint: Option<(String, String)>, constraint: Option<(String, String)>,
) -> Result<Option<proto::sampling_params::Constraint>, String> { ) -> Result<Option<proto::sampling_params::Constraint>, String> {
if let Some((constraint_type, constraint_value)) = tool_call_constraint { if let Some((constraint_type, constraint_value)) = constraint {
let tool_constraint = match constraint_type.as_str() { let parsed_constraint = match constraint_type.as_str() {
"structural_tag" => { "structural_tag" => {
// Harmony models: structural tag from preparation stage
proto::sampling_params::Constraint::StructuralTag(constraint_value) proto::sampling_params::Constraint::StructuralTag(constraint_value)
} }
"json_schema" => proto::sampling_params::Constraint::JsonSchema(constraint_value), "json_schema" => proto::sampling_params::Constraint::JsonSchema(constraint_value),
@@ -492,7 +509,7 @@ impl SglangSchedulerClient {
"regex" => proto::sampling_params::Constraint::Regex(constraint_value), "regex" => proto::sampling_params::Constraint::Regex(constraint_value),
_ => return Err(format!("Unknown constraint type: {}", constraint_type)), _ => return Err(format!("Unknown constraint type: {}", constraint_type)),
}; };
Ok(Some(tool_constraint)) Ok(Some(parsed_constraint))
} else { } else {
Ok(None) Ok(None)
} }
+32 -6
View File
@@ -291,15 +291,36 @@ pub struct ReasoningInfo {
pub summary: Option<String>, pub summary: Option<String>,
} }
// ============================================================================
// Text Format (structured outputs)
// ============================================================================
/// Text configuration for structured output requests
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ResponseTextFormat { pub struct TextConfig {
pub format: TextFormatType, #[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<TextFormat>,
} }
/// Text format: text (default), json_object (legacy), or json_schema (recommended)
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TextFormatType { #[serde(tag = "type")]
#[serde(rename = "type")] pub enum TextFormat {
pub format_type: String, #[serde(rename = "text")]
Text,
#[serde(rename = "json_object")]
JsonObject,
#[serde(rename = "json_schema")]
JsonSchema {
name: String,
schema: Value,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
strict: Option<bool>,
},
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -539,6 +560,10 @@ pub struct ResponsesRequest {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub truncation: Option<Truncation>, pub truncation: Option<Truncation>,
/// Text format for structured outputs (text, json_object, json_schema)
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<TextConfig>,
/// User identifier /// User identifier
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>, pub user: Option<String>,
@@ -607,6 +632,7 @@ impl Default for ResponsesRequest {
top_logprobs: None, top_logprobs: None,
top_p: None, top_p: None,
truncation: None, truncation: None,
text: None,
user: None, user: None,
request_id: None, request_id: None,
priority: 0, priority: 0,
@@ -906,7 +932,7 @@ pub struct ResponsesResponse {
/// Text format settings /// Text format settings
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<ResponseTextFormat>, pub text: Option<TextConfig>,
/// Tool choice setting /// Tool choice setting
#[serde(default = "default_tool_choice")] #[serde(default = "default_tool_choice")]
@@ -269,7 +269,7 @@ impl HarmonyResponseProcessor {
reasoning: None, // Set by caller if needed reasoning: None, // Set by caller if needed
store: responses_request.store.unwrap_or(true), store: responses_request.store.unwrap_or(true),
temperature: responses_request.temperature, temperature: responses_request.temperature,
text: None, text: responses_request.text.clone(),
tool_choice: responses_request tool_choice: responses_request
.tool_choice .tool_choice
.as_ref() .as_ref()
@@ -88,7 +88,7 @@ impl HarmonyPreparationStage {
// Step 2: Build tool constraints // Step 2: Build tool constraints
let tool_constraints = if let Some(tools) = body_ref.tools.as_ref() { let tool_constraints = if let Some(tools) = body_ref.tools.as_ref() {
Self::generate_harmony_structural_tag(tools, &body_ref.tool_choice).map_err(|e| *e)? Self::generate_tool_call_constraint(tools, &body_ref.tool_choice).map_err(|e| *e)?
} else { } else {
None None
}; };
@@ -139,26 +139,40 @@ impl HarmonyPreparationStage {
function_tools = filtered; function_tools = filtered;
} }
// Step 3: Generate Harmony structural tags from filtered tools // Step 3: Generate Harmony structural tags
let tool_constraints = if !function_tools.is_empty() { let tool_constraint = if !function_tools.is_empty() {
Self::generate_harmony_structural_tag(&function_tools, &request.tool_choice) Self::generate_tool_call_constraint(&function_tools, &request.tool_choice)
.map_err(|e| *e)? .map_err(|e| *e)?
} else { } else {
None None
}; };
let text_constraint = if let Some(text_config) = &request.text {
Self::generate_text_format_constraint(text_config).map_err(|e| *e)?
} else {
None
};
if tool_constraint.is_some() && text_constraint.is_some() {
return Err(error::bad_request(
"Cannot use both tool_choice (required/function) and text format (json_object/json_schema) simultaneously".to_string(),
));
}
let constraint = tool_constraint.or(text_constraint);
// Step 3: Build via Harmony from responses API request // Step 3: Build via Harmony from responses API request
let build_output = self let build_output = self
.builder .builder
.build_from_responses(request) .build_from_responses(request)
.map_err(|e| error::bad_request(format!("Harmony build failed: {}", e)))?; .map_err(|e| error::bad_request(format!("Harmony build failed: {}", e)))?;
// Step 4: Store results with tool_constraints // Step 4: Store results with constraint
ctx.state.preparation = Some(PreparationOutput { ctx.state.preparation = Some(PreparationOutput {
original_text: None, original_text: None,
token_ids: build_output.input_ids, token_ids: build_output.input_ids,
processed_messages: None, processed_messages: None,
tool_constraints, tool_constraints: constraint,
filtered_request: None, filtered_request: None,
harmony_mode: true, harmony_mode: true,
selection_text: Some(build_output.selection_text), selection_text: Some(build_output.selection_text),
@@ -169,11 +183,39 @@ impl HarmonyPreparationStage {
Ok(None) Ok(None)
} }
/// Generate Harmony structural tag for structured output (text field)
///
/// Converts text.format to structural tag that constrains the final channel.
/// Returns None if text.format is not specified or is "text".
fn generate_text_format_constraint(
text_config: &crate::protocols::responses::TextConfig,
) -> Result<Option<(String, String)>, Box<Response>> {
use crate::protocols::responses::TextFormat;
let Some(format) = &text_config.format else {
return Ok(None);
};
match format {
TextFormat::Text => Ok(None),
TextFormat::JsonObject => {
let tag = build_text_format_structural_tag(&serde_json::json!({"type": "object"}))
.map_err(|e| Box::new(error::internal_error(e)))?;
Ok(Some(("structural_tag".to_string(), tag)))
}
TextFormat::JsonSchema { schema, .. } => {
let tag = build_text_format_structural_tag(schema)
.map_err(|e| Box::new(error::internal_error(e)))?;
Ok(Some(("structural_tag".to_string(), tag)))
}
}
}
/// Generate Harmony structural tag for tool constraints /// Generate Harmony structural tag for tool constraints
/// ///
/// Uses structural tags with `triggered_tags` format to force Harmony format output. /// 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. /// This ensures the model outputs in Harmony format (with channels) even when constrained.
fn generate_harmony_structural_tag( fn generate_tool_call_constraint(
tools: &[Tool], tools: &[Tool],
tool_choice: &Option<ToolChoice>, tool_choice: &Option<ToolChoice>,
) -> Result<Option<(String, String)>, Box<Response>> { ) -> Result<Option<(String, String)>, Box<Response>> {
@@ -183,16 +225,16 @@ impl HarmonyPreparationStage {
match choice { match choice {
ToolChoice::Function { function, .. } => { ToolChoice::Function { function, .. } => {
let tag = Self::build_harmony_structural_tag(tools, Some(&function.name))?; let tag = Self::build_tool_call_structural_tag(tools, Some(&function.name))?;
Ok(Some(("structural_tag".to_string(), tag))) Ok(Some(("structural_tag".to_string(), tag)))
} }
ToolChoice::Value(ToolChoiceValue::Required) => { ToolChoice::Value(ToolChoiceValue::Required) => {
let tag = Self::build_harmony_structural_tag(tools, None)?; let tag = Self::build_tool_call_structural_tag(tools, None)?;
Ok(Some(("structural_tag".to_string(), tag))) Ok(Some(("structural_tag".to_string(), tag)))
} }
ToolChoice::AllowedTools { mode, .. } => { ToolChoice::AllowedTools { mode, .. } => {
if mode == "required" { if mode == "required" {
let tag = Self::build_harmony_structural_tag(tools, None)?; let tag = Self::build_tool_call_structural_tag(tools, None)?;
Ok(Some(("structural_tag".to_string(), tag))) Ok(Some(("structural_tag".to_string(), tag)))
} else { } else {
Ok(None) Ok(None)
@@ -203,7 +245,11 @@ impl HarmonyPreparationStage {
} }
/// Build Harmony structural tag for tool calling constraints /// Build Harmony structural tag for tool calling constraints
fn build_harmony_structural_tag( ///
/// 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)
fn build_tool_call_structural_tag(
tools: &[Tool], tools: &[Tool],
specific_function: Option<&str>, specific_function: Option<&str>,
) -> Result<String, Box<Response>> { ) -> Result<String, Box<Response>> {
@@ -227,11 +273,12 @@ impl HarmonyPreparationStage {
)))); ))));
} }
// Build tags for each tool // Build tags for each tool - need two patterns per tool for reasoning on/off
for tool in tools_to_use { for tool in tools_to_use {
let tool_name = &tool.function.name; let tool_name = &tool.function.name;
let params_schema = &tool.function.parameters; let params_schema = &tool.function.parameters;
// Pattern 1: For reasoning-enabled mode (with analysis channel before commentary)
tags.push(json!({ tags.push(json!({
"begin": format!("<|start|>assistant<|channel|>commentary to=functions.{}<|constrain|>json<|message|>", tool_name), "begin": format!("<|start|>assistant<|channel|>commentary to=functions.{}<|constrain|>json<|message|>", tool_name),
"content": { "content": {
@@ -240,6 +287,16 @@ impl HarmonyPreparationStage {
}, },
"end": "" // `end` is empty because <|call|> comes naturally from Harmony stop tokens "end": "" // `end` is empty because <|call|> comes naturally from Harmony stop tokens
})); }));
// Pattern 2: For reasoning-disabled mode (goes directly to commentary channel)
tags.push(json!({
"begin": format!("<|channel|>commentary to=functions.{}<|constrain|>json<|message|>", tool_name),
"content": {
"type": "json_schema",
"json_schema": params_schema
},
"end": ""
}));
} }
let stop_after_first = specific_function.is_some(); let stop_after_first = specific_function.is_some();
@@ -247,7 +304,7 @@ impl HarmonyPreparationStage {
let structural_tag = json!({ let structural_tag = json!({
"format": { "format": {
"type": "triggered_tags", "type": "triggered_tags",
"triggers": ["<|start|>assistant"], "triggers": ["<|start|>assistant<|channel|>commentary", "<|channel|>commentary"],
"tags": tags, "tags": tags,
"at_least_one": true, "at_least_one": true,
"stop_after_first": stop_after_first "stop_after_first": stop_after_first
@@ -262,3 +319,49 @@ impl HarmonyPreparationStage {
}) })
} }
} }
/// Build Harmony structural tag for structured output (JSON schema constraint)
///
/// Creates a structural tag that applies JSON schema constraint to the final channel,
/// supporting both reasoning-enabled and reasoning-disabled modes:
/// - With reasoning: triggers on `<|start|>assistant<|channel|>final` (waits for analysis to complete)
/// - Without reasoning: triggers on `<|channel|>final` (goes directly to final channel)
///
/// This is used for the Responses API text.format field (json_object or json_schema).
pub fn build_text_format_structural_tag(schema: &serde_json::Value) -> Result<String, String> {
let structural_tag = json!({
"format": {
"type": "triggered_tags",
"triggers": ["<|start|>assistant<|channel|>final", "<|channel|>final"],
"tags": [
{
// Pattern 1: For reasoning-enabled mode (with analysis channel before final)
"begin": "<|start|>assistant<|channel|>final<|constrain|>json<|message|>",
"content": {
"type": "json_schema",
"json_schema": schema
},
"end": ""
},
{
// Pattern 2: For reasoning-disabled mode (goes directly to final channel)
"begin": "<|channel|>final<|constrain|>json<|message|>",
"content": {
"type": "json_schema",
"json_schema": schema
},
"end": ""
}
],
"at_least_one": true,
"stop_after_first": true
}
});
serde_json::to_string(&structural_tag).map_err(|e| {
format!(
"Failed to serialize structural tag for structured output: {}",
e
)
})
}
@@ -67,7 +67,7 @@ impl PipelineStage for HarmonyRequestBuildingStage {
let body = prep.filtered_request.as_ref().unwrap_or(request.as_ref()); let body = prep.filtered_request.as_ref().unwrap_or(request.as_ref());
builder_client builder_client
.build_generate_request( .build_generate_request_from_chat(
request_id, request_id,
body, body,
placeholder_processed_text, placeholder_processed_text,
@@ -10,11 +10,14 @@
use crate::{ use crate::{
protocols::{ protocols::{
chat::{ChatCompletionRequest, ChatCompletionResponse, ChatMessage, UserMessageContent}, chat::{ChatCompletionRequest, ChatCompletionResponse, ChatMessage, UserMessageContent},
common::{FunctionCallResponse, StreamOptions, ToolCall, ToolChoice, UsageInfo}, common::{
FunctionCallResponse, JsonSchemaFormat, ResponseFormat, StreamOptions, ToolCall,
ToolChoice, UsageInfo,
},
responses::{ responses::{
ResponseContentPart, ResponseInput, ResponseInputOutputItem, ResponseOutputItem, ResponseContentPart, ResponseInput, ResponseInputOutputItem, ResponseOutputItem,
ResponseReasoningContent::ReasoningText, ResponseStatus, ResponsesRequest, ResponseReasoningContent::ReasoningText, ResponseStatus, ResponsesRequest,
ResponsesResponse, ResponsesUsage, StringOrContentParts, ResponsesResponse, ResponsesUsage, StringOrContentParts, TextConfig, TextFormat,
}, },
}, },
routers::grpc::common::responses::utils::extract_tools_from_response_tools, routers::grpc::common::responses::utils::extract_tools_from_response_tools,
@@ -188,6 +191,7 @@ pub fn responses_to_chat(req: &ResponsesRequest) -> Result<ChatCompletionRequest
skip_special_tokens: true, skip_special_tokens: true,
tools, tools,
tool_choice: req.tool_choice.clone(), tool_choice: req.tool_choice.clone(),
response_format: map_text_to_response_format(&req.text),
..Default::default() ..Default::default()
}) })
} }
@@ -232,6 +236,32 @@ fn role_to_chat_message(role: &str, text: String) -> ChatMessage {
} }
} }
/// Map TextConfig from Responses API to ResponseFormat for Chat API
///
/// Converts the structured output configuration from the Responses API format
/// to the Chat API format for non-Harmony models.
fn map_text_to_response_format(text: &Option<TextConfig>) -> Option<ResponseFormat> {
let text_config = text.as_ref()?;
let format = text_config.format.as_ref()?;
match format {
TextFormat::Text => Some(ResponseFormat::Text),
TextFormat::JsonObject => Some(ResponseFormat::JsonObject),
TextFormat::JsonSchema {
name,
schema,
description: _,
strict,
} => Some(ResponseFormat::JsonSchema {
json_schema: JsonSchemaFormat {
name: name.clone(),
schema: schema.clone(),
strict: *strict,
},
}),
}
}
/// Convert a ChatCompletionResponse to ResponsesResponse /// Convert a ChatCompletionResponse to ResponsesResponse
/// ///
/// # Conversion Logic /// # Conversion Logic
@@ -338,7 +368,7 @@ pub fn chat_to_responses(
reasoning: None, // TODO: Map reasoning effort if needed reasoning: None, // TODO: Map reasoning effort if needed
store: original_req.store.unwrap_or(true), store: original_req.store.unwrap_or(true),
temperature: original_req.temperature, temperature: original_req.temperature,
text: None, text: original_req.text.clone(),
tool_choice: ToolChoice::serialize_to_string(&original_req.tool_choice), tool_choice: ToolChoice::serialize_to_string(&original_req.tool_choice),
tools: original_req.tools.clone().unwrap_or_default(), tools: original_req.tools.clone().unwrap_or_default(),
top_p: original_req.top_p, top_p: original_req.top_p,
@@ -427,6 +427,7 @@ pub(super) async fn execute_tool_loop(
service_tier: current_request.service_tier.clone(), service_tier: current_request.service_tier.clone(),
top_logprobs: current_request.top_logprobs, top_logprobs: current_request.top_logprobs,
truncation: current_request.truncation.clone(), truncation: current_request.truncation.clone(),
text: current_request.text.clone(),
request_id: None, request_id: None,
priority: current_request.priority, priority: current_request.priority,
frequency_penalty: current_request.frequency_penalty, frequency_penalty: current_request.frequency_penalty,
@@ -971,6 +972,7 @@ async fn execute_tool_loop_streaming_internal(
service_tier: current_request.service_tier.clone(), service_tier: current_request.service_tier.clone(),
top_logprobs: current_request.top_logprobs, top_logprobs: current_request.top_logprobs,
truncation: current_request.truncation.clone(), truncation: current_request.truncation.clone(),
text: current_request.text.clone(),
request_id: None, request_id: None,
priority: current_request.priority, priority: current_request.priority,
frequency_penalty: current_request.frequency_penalty, frequency_penalty: current_request.frequency_penalty,
@@ -51,7 +51,7 @@ impl PipelineStage for ChatRequestBuildingStage {
let body_ref = prep.filtered_request.as_ref().unwrap_or(&chat_request); let body_ref = prep.filtered_request.as_ref().unwrap_or(&chat_request);
let mut proto_request = builder_client let mut proto_request = builder_client
.build_generate_request( .build_generate_request_from_chat(
request_id, request_id,
body_ref, body_ref,
prep.processed_messages.as_ref().unwrap().text.clone(), prep.processed_messages.as_ref().unwrap().text.clone(),
+8
View File
@@ -91,6 +91,7 @@ async fn test_non_streaming_mcp_minimal_e2e_with_persistence() {
top_logprobs: Some(0), top_logprobs: Some(0),
top_p: None, top_p: None,
truncation: Some(Truncation::Disabled), truncation: Some(Truncation::Disabled),
text: None,
user: None, user: None,
request_id: Some("resp_test_mcp_e2e".to_string()), request_id: Some("resp_test_mcp_e2e".to_string()),
priority: 0, priority: 0,
@@ -312,6 +313,7 @@ fn test_responses_request_creation() {
top_logprobs: Some(5), top_logprobs: Some(5),
top_p: Some(0.9), top_p: Some(0.9),
truncation: Some(Truncation::Disabled), truncation: Some(Truncation::Disabled),
text: None,
user: Some("test-user".to_string()), user: Some("test-user".to_string()),
request_id: Some("resp_test123".to_string()), request_id: Some("resp_test123".to_string()),
priority: 0, priority: 0,
@@ -354,6 +356,7 @@ fn test_responses_request_sglang_extensions() {
top_logprobs: Some(0), top_logprobs: Some(0),
top_p: Some(0.95), top_p: Some(0.95),
truncation: Some(Truncation::Auto), truncation: Some(Truncation::Auto),
text: None,
user: None, user: None,
request_id: Some("resp_test456".to_string()), request_id: Some("resp_test456".to_string()),
priority: 0, priority: 0,
@@ -469,6 +472,7 @@ fn test_json_serialization() {
top_logprobs: Some(10), top_logprobs: Some(10),
top_p: Some(0.8), top_p: Some(0.8),
truncation: Some(Truncation::Auto), truncation: Some(Truncation::Auto),
text: None,
user: Some("test_user".to_string()), user: Some("test_user".to_string()),
request_id: Some("resp_comprehensive_test".to_string()), request_id: Some("resp_comprehensive_test".to_string()),
priority: 1, priority: 1,
@@ -574,6 +578,7 @@ async fn test_multi_turn_loop_with_mcp() {
top_logprobs: Some(0), top_logprobs: Some(0),
top_p: Some(1.0), top_p: Some(1.0),
truncation: Some(Truncation::Disabled), truncation: Some(Truncation::Disabled),
text: None,
user: None, user: None,
request_id: Some("resp_multi_turn_test".to_string()), request_id: Some("resp_multi_turn_test".to_string()),
priority: 0, priority: 0,
@@ -722,6 +727,7 @@ async fn test_max_tool_calls_limit() {
top_logprobs: Some(0), top_logprobs: Some(0),
top_p: Some(1.0), top_p: Some(1.0),
truncation: Some(Truncation::Disabled), truncation: Some(Truncation::Disabled),
text: None,
user: None, user: None,
request_id: Some("resp_max_calls_test".to_string()), request_id: Some("resp_max_calls_test".to_string()),
priority: 0, priority: 0,
@@ -893,6 +899,7 @@ async fn test_streaming_with_mcp_tool_calls() {
top_logprobs: Some(0), top_logprobs: Some(0),
top_p: Some(1.0), top_p: Some(1.0),
truncation: Some(Truncation::Disabled), truncation: Some(Truncation::Disabled),
text: None,
user: None, user: None,
request_id: Some("resp_streaming_mcp_test".to_string()), request_id: Some("resp_streaming_mcp_test".to_string()),
priority: 0, priority: 0,
@@ -1172,6 +1179,7 @@ async fn test_streaming_multi_turn_with_mcp() {
top_logprobs: Some(0), top_logprobs: Some(0),
top_p: Some(1.0), top_p: Some(1.0),
truncation: Some(Truncation::Disabled), truncation: Some(Truncation::Disabled),
text: None,
user: None, user: None,
request_id: Some("resp_streaming_multiturn_test".to_string()), request_id: Some("resp_streaming_multiturn_test".to_string()),
priority: 0, priority: 0,