Skip to content

Commit 389ec5b

Browse files
committed
feat(gpt-oss): enforce tool calling and structured output via xgrammar structural_tag
Integrate xgrammar structural_tag guided generation with the GPT-OSS Harmony response parser so tool-calling and structured outputs are enforced by hard grammar constraints instead of Harmony-native prompt injection. A prompt-injection fallback is retained for when xgrammar is unavailable. Protocol: - Extend ResponseFormat with type='structural_tag' and a structural_tag payload field. GptOssResponseParser: - Add _maybe_inject_tool_grammar() called from __init__ that converts non-text response_format into a Harmony-compatible structural_tag: - Tool calling: delegates to xgrammar.get_model_structural_tag. - Structured output (json_schema/regex_schema/json_object): wraps the schema in the Harmony final channel with an optional analysis block. - On grammar construction failure, fall back to _convert_response_format_to_harmony() which injects the schema into the system prompt and clears response_format (legacy path). - Fix AllowedToolChoice crash in __init__ tool filtering: use getattr(tool_choice, 'type') == 'function' instead of not isinstance(tool_choice, str) so AllowedToolChoice (type= 'allowed_tools') no longer hits .function.name AttributeError. - Fix dead elif branch in _build_response_format_grammar that picked up the deprecated BaseModel.schema method when json_schema had no inner schema; now defaults to {'type': 'object'}. - Fix tool grammar failure path leaving non-text response_format intact, which could re-enable engine-side json/regex modes and conflict with Harmony tool-call constraints downstream. Tests: - Add regression tests for all three bug fixes and for grammar construction success/fallback paths. - Consolidate the parser test suite: merge redundant fixtures, remove dead helpers, parametrize overlapping cases, and drop tests that cover code paths unchanged by this PR.
1 parent 2928f47 commit 389ec5b

3 files changed

Lines changed: 398 additions & 245 deletions

File tree

lmdeploy/serve/openai/protocol.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,11 @@ class JsonSchema(BaseModel):
158158

159159
class ResponseFormat(BaseModel):
160160
# regex_schema is extended by lmdeploy to support regex output
161-
type: Literal['text', 'json_object', 'json_schema', 'regex_schema']
161+
# structural_tag is extended by lmdeploy to support xgrammar structural tags
162+
type: Literal['text', 'json_object', 'json_schema', 'regex_schema', 'structural_tag']
162163
json_schema: JsonSchema | None = None
163164
regex_schema: str | None = None
164-
165+
structural_tag: dict[str, Any] | None = None
165166

166167
# str for url/base64, base64 should be data:image/jpeg;base64, dict should be {'url': url/base64, 'options': ...}
167168
ImageDataInputItem = str | dict

lmdeploy/serve/parsers/_openai_harmony.py

Lines changed: 171 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import json
77
import re
8-
from typing import TYPE_CHECKING
8+
from typing import TYPE_CHECKING, Any
99

1010
import shortuuid
1111
from openai_harmony import HarmonyEncodingName, Role, StreamableParser, load_harmony_encoding
@@ -15,6 +15,7 @@
1515
DeltaMessage,
1616
DeltaToolCall,
1717
FunctionCall,
18+
ResponseFormat,
1819
ToolCall,
1920
)
2021
from 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

Comments
 (0)