55
66import json
77import re
8- from typing import TYPE_CHECKING
8+ from typing import TYPE_CHECKING , Any
99
1010import shortuuid
1111from openai_harmony import HarmonyEncodingName , Role , StreamableParser , load_harmony_encoding
1515 DeltaMessage ,
1616 DeltaToolCall ,
1717 FunctionCall ,
18+ ResponseFormat ,
1819 ToolCall ,
1920)
2021from lmdeploy .utils import get_logger
@@ -47,18 +48,20 @@ def __init__(self, request: ChatCompletionRequest):
4748 # GPT-OSS templates expect full tool wrappers.
4849 if request .tools is None or request .tool_choice == 'none' :
4950 rendered_tools = None
50- elif not isinstance (request .tool_choice , str ):
51+ elif getattr (request .tool_choice , 'type' , None ) == 'function' :
52+ # ToolChoice (type='function'): keep only the selected tool.
5153 rendered_tools = [
5254 item .model_dump () for item in request .tools
5355 if item .function .name == request .tool_choice .function .name
5456 ]
5557 else :
58+ # auto/required/allowed_tools: keep all tools.
5659 rendered_tools = [item .model_dump () for item in request .tools ]
5760 self .request = request .model_copy (update = {'tools' : rendered_tools })
5861 else :
5962 # Unit tests may inject a lightweight sentinel request object.
6063 self .request = request
61- self ._convert_response_format_to_harmony ()
64+ self ._maybe_inject_tool_grammar ()
6265 self .request = normalize_chat_request (self .request )
6366 self .parser = StreamableParser (get_encoding (), role = Role .ASSISTANT )
6467 self ._seen_any = False
@@ -69,14 +72,168 @@ def __init__(self, request: ChatCompletionRequest):
6972 self .tool_parser = object () # API server checks `is not None` for tool support.
7073 self .reasoning_tokens = 0
7174
72- def _convert_response_format_to_harmony (self ):
73- """Convert response_format to Harmony-native mode for GPT-OSS.
75+ def _maybe_inject_tool_grammar (self ) -> None :
76+ """Convert any non-text ``response_format`` into a Harmony-compatible
77+ xgrammar structural_tag and keep it on the request so the engine can
78+ enforce it during generation.
7479
75- GPT-OSS uses Harmony mode for structured output, which conflicts with
76- the engine's built-in JSON/response-format mode. This method injects
77- the response_format schema into the system prompt as a
78- ``# Response Formats`` section and clears ``response_format`` on the
79- request so that only the Harmony-native instructions are used.
80+ Two cases:
81+
82+ - **Tool calling** (``tools`` present, ``tool_choice != 'none'``):
83+ delegates to :meth:`_build_tool_grammar` which uses
84+ ``xgrammar.get_model_structural_tag("harmony", ...)``.
85+ - **Plain structured output** (``json_schema``, ``regex_schema``,
86+ ``json_object``): builds a structural_tag that wraps the schema in
87+ the Harmony final channel (``<|channel|>final<|message|> ...
88+ <|end|>``), optionally preceded by an analysis block.
89+
90+ If grammar construction fails the original ``response_format`` is
91+ injected into the system prompt as a ``# Response Formats`` section
92+ and then cleared, matching the previous Harmony-native fallback.
93+ """
94+ fmt = getattr (self .request , 'response_format' , None )
95+ tools = getattr (self .request , 'tools' , None )
96+ tool_choice = getattr (self .request , 'tool_choice' , 'auto' )
97+ has_tools = tools and tool_choice != 'none'
98+
99+ if has_tools :
100+ grammar = self ._build_tool_grammar (tools , tool_choice )
101+ if grammar is not None :
102+ self ._set_response_format (grammar )
103+ else :
104+ # tool grammar failed — fall back to prompt injection so
105+ # the original response_format (if any) is not left intact
106+ # to conflict with Harmony tool-call constraints downstream.
107+ if fmt is not None and getattr (fmt , 'type' , 'text' ) != 'text' :
108+ self ._convert_response_format_to_harmony ()
109+ return
110+ if fmt is not None and getattr (fmt , 'type' , 'text' ) != 'text' :
111+ grammar = self ._build_response_format_grammar (fmt )
112+ if grammar is not None :
113+ self ._set_response_format (grammar )
114+ return
115+ # grammar construction failed — fall back to prompt injection
116+ self ._convert_response_format_to_harmony ()
117+
118+ @staticmethod
119+ def _build_tool_grammar (tools : list , tool_choice : Any ) -> dict | None :
120+ """Construct a Harmony-compatible structural_tag for tool calling.
121+
122+ Returns a dict suitable for ``response_format`` or ``None`` on
123+ failure.
124+ """
125+ try :
126+ from xgrammar .builtin_structural_tag import get_model_structural_tag
127+ except ImportError :
128+ logger .warning ('xgrammar builtin_structural_tag not available; '
129+ 'falling back to prompt-only tool calling for GPT-OSS.' )
130+ return None
131+
132+ # Normalize tool_choice to xgrammar's expected format.
133+ xg_tool_choice = tool_choice
134+ if hasattr (tool_choice , 'model_dump' ):
135+ xg_tool_choice = tool_choice .model_dump (mode = 'json' )
136+ elif not isinstance (tool_choice , str ): # duck-typed without model_dump
137+ xg_tool_choice = {'type' : 'function' ,
138+ 'function' : {'name' : tool_choice .function .name }}
139+
140+ # Tools are usually model_dump'd dicts, but sentinel requests may
141+ # pass Tool objects.
142+ dumped_tools = [t if isinstance (t , dict ) else t .model_dump () for t in tools ]
143+
144+ try :
145+ st = get_model_structural_tag (
146+ 'harmony' ,
147+ tools = dumped_tools ,
148+ tool_choice = xg_tool_choice ,
149+ reasoning = True ,
150+ )
151+ return {
152+ 'type' : 'structural_tag' ,
153+ 'structural_tag' : json .loads (st .model_dump_json ()),
154+ }
155+ except Exception as e : # xgrammar may raise ValueError/ValidationError
156+ logger .warning (f'Failed to build harmony structural tag for tool '
157+ f'calling: { e } ; falling back to prompt-only.' )
158+ return None
159+
160+ @staticmethod
161+ def _build_response_format_grammar (fmt : ResponseFormat ) -> dict | None :
162+ """Convert a plain ``response_format`` (json_schema / regex_schema /
163+ json_object) into a Harmony-compatible structural_tag.
164+
165+ The schema is wrapped in the Harmony final channel::
166+
167+ [<|channel|>analysis<|message|> ... <|end|><|start|>assistant]?
168+ <|channel|>final<|message|> <schema> <|end|>
169+
170+ Returns a dict or ``None`` on failure.
171+ """
172+ try :
173+ from xgrammar .structural_tag import (
174+ AnyTextFormat ,
175+ ConstStringFormat ,
176+ JSONSchemaFormat ,
177+ OptionalFormat ,
178+ RegexFormat ,
179+ SequenceFormat ,
180+ StructuralTag ,
181+ TagFormat ,
182+ )
183+ except ImportError :
184+ logger .warning ('xgrammar structural_tag not available; '
185+ 'clearing response_format for GPT-OSS.' )
186+ return None
187+
188+ fmt_type = getattr (fmt , 'type' , 'text' )
189+ analysis_end = ['<|end|>' , '<|return|>' ]
190+ final_begin = '<|channel|>final<|message|>'
191+ final_end = ['<|end|>' , '<|return|>' ]
192+
193+ if fmt_type == 'json_schema' :
194+ schema = fmt .json_schema
195+ if schema is not None and schema .json_schema is not None :
196+ raw = schema .json_schema
197+ else :
198+ raw = {'type' : 'object' }
199+ content = JSONSchemaFormat (json_schema = raw )
200+ elif fmt_type == 'regex_schema' :
201+ content = RegexFormat (pattern = fmt .regex_schema or '.*' )
202+ elif fmt_type == 'json_object' :
203+ content = JSONSchemaFormat (json_schema = {'type' : 'object' })
204+ else :
205+ return None
206+
207+ analysis_tag = OptionalFormat (
208+ content = SequenceFormat (elements = [
209+ TagFormat (begin = '<|channel|>analysis<|message|>' ,
210+ content = AnyTextFormat (), end = analysis_end ),
211+ ConstStringFormat (value = '<|start|>assistant' ),
212+ ]))
213+ final_tag = TagFormat (begin = final_begin , content = content , end = final_end )
214+ st = StructuralTag (format = SequenceFormat (elements = [analysis_tag , final_tag ]))
215+ return {
216+ 'type' : 'structural_tag' ,
217+ 'structural_tag' : json .loads (st .model_dump_json ()),
218+ }
219+
220+ def _set_response_format (self , grammar : dict ) -> None :
221+ """Set response_format on the request, handling both Pydantic and plain
222+ objects."""
223+ if hasattr (self .request , 'model_copy' ):
224+ self .request = self .request .model_copy (
225+ update = {'response_format' : ResponseFormat (** grammar )})
226+ else :
227+ self .request .response_format = ResponseFormat (** grammar )
228+
229+ def _convert_response_format_to_harmony (self ) -> None :
230+ """Fall back to Harmony-native prompt injection when grammar
231+ construction is unavailable.
232+
233+ Injects the ``response_format`` schema into the system prompt as a
234+ ``# Response Formats`` section and clears ``response_format`` so only
235+ the Harmony-native instructions are used. This is the legacy path
236+ used when xgrammar structural_tag construction fails.
80237 """
81238 fmt = getattr (self .request , 'response_format' , None )
82239 if fmt is None or getattr (fmt , 'type' , 'text' ) == 'text' :
@@ -100,14 +257,14 @@ def _convert_response_format_to_harmony(self):
100257
101258 new_messages = list (messages )
102259 system_idx = next (
103- (i for i , msg in enumerate (new_messages ) if isinstance (msg , dict ) and msg .get ('role' ) == 'system' ),
260+ (i for i , msg in enumerate (new_messages )
261+ if isinstance (msg , dict ) and msg .get ('role' ) == 'system' ),
104262 None ,
105263 )
106264
107265 if system_idx is not None :
108266 content = new_messages [system_idx ].get ('content' )
109267 if isinstance (content , list ):
110- # Multimodal content blocks — append a text block.
111268 new_messages [system_idx ] = {
112269 ** new_messages [system_idx ],
113270 'content' : content + [{'type' : 'text' , 'text' : format_body }],
@@ -125,12 +282,11 @@ def _convert_response_format_to_harmony(self):
125282 new_messages .insert (0 , {'role' : 'system' , 'content' : format_body })
126283
127284 self ._clear_response_format (messages = new_messages )
128- except Exception :
285+ except Exception : # fmt.model_dump() or message manipulation may fail
129286 logger .exception ('Failed to convert response_format to Harmony-native mode for GPT-OSS' )
130- # Still clear response_format to avoid the Harmony/JSON mode conflict
131287 self ._clear_response_format ()
132288
133- def _clear_response_format (self , messages = None ):
289+ def _clear_response_format (self , messages : list | str | None = None ) -> None :
134290 """Clear response_format on the request, handling both Pydantic and
135291 plain objects."""
136292 if hasattr (self .request , 'model_copy' ):
0 commit comments