Skip to content
Merged
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
10 changes: 7 additions & 3 deletions lmdeploy/serve/parsers/_openai_harmony.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
from openai_harmony import HarmonyEncodingName, Role, StreamableParser, load_harmony_encoding

from lmdeploy.serve.openai.protocol import (
AllowedToolChoice,
DeltaFunctionCall,
DeltaMessage,
DeltaToolCall,
FunctionCall,
ToolCall,
ToolChoice,
)
from lmdeploy.utils import get_logger

Expand Down Expand Up @@ -93,7 +95,8 @@ def _maybe_inject_tool_grammar(self) -> None:
the Harmony final channel (``<|channel|>final<|message|> ...
<|end|>``), optionally preceded by an analysis block.

Required tool calls reject the request if grammar construction fails.
Required tool calls, named functions, and required allowed-tool choices
reject the request if grammar construction fails.
For other modes, a failed grammar construction injects the
``response_format`` into the system prompt as a ``# Response Formats``
section and clears it, matching the Harmony-native fallback.
Expand All @@ -109,8 +112,9 @@ def _maybe_inject_tool_grammar(self) -> None:
if grammar is not None:
self._set_response_format(grammar)
return
if tool_choice == 'required':
raise ValueError('Failed to build the grammar for `tool_choice="required"` in GPT-OSS.')
if (tool_choice == 'required' or isinstance(tool_choice, ToolChoice)
or (isinstance(tool_choice, AllowedToolChoice) and tool_choice.allowed_tools.mode == 'required')):
raise ValueError('Failed to build the grammar for the required tool choice in GPT-OSS.')
# tool grammar failed — fall back to prompt injection so the
# original response_format (if any) is not left intact to
# conflict with Harmony tool-call constraints downstream.
Expand Down
59 changes: 46 additions & 13 deletions tests/test_lmdeploy/serve/parsers/test_gpt_oss_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,14 +528,46 @@ def test_tools_priority_over_response_format(self):
# Tool grammar wins — must contain tool call, not plain json_schema
assert 'functions.get_weather' in st_json

@pytest.mark.parametrize('tool_choice', ['auto', 'required'])
def test_tool_grammar_failure_clears_response_format(self, monkeypatch, tool_choice):
"""Tool grammar failure must clear a non-text response_format so it
cannot conflict with Harmony tool-call constraints downstream.

The monkeypatch is required because ``__init__`` filters tools to the
selected function before grammar construction, so no real input can
keep ``has_tools`` true while making ``_build_tool_grammar`` fail.
@pytest.mark.parametrize(('tool_choice', 'requires_call'), [
pytest.param('auto', False, id='auto'),
pytest.param('required', True, id='required'),
pytest.param(
{'type': 'function', 'function': {'name': 'get_weather'}},
True,
id='named-function',
),
pytest.param(
{
'type': 'allowed_tools',
'allowed_tools': {
'mode': 'required',
'tools': [{'type': 'function', 'function': {'name': 'get_weather'}}],
},
},
True,
id='allowed-required',
),
pytest.param(
{
'type': 'allowed_tools',
'allowed_tools': {
'mode': 'auto',
'tools': [{'type': 'function', 'function': {'name': 'get_weather'}}],
},
},
False,
id='allowed-auto',
),
])
@pytest.mark.parametrize('response_format', [
None,
ResponseFormat(type='json_schema', json_schema=JsonSchema(name='test', schema={'type': 'object'})),
], ids=['plain', 'structured'])
def test_tool_grammar_failure_respects_tool_choice(self, monkeypatch, tool_choice, requires_call, response_format):
"""Required tool choices reject grammar failure; automatic choices
retain prompt-only fallback and clear a structured response format.

Simulate unavailable or failing XGrammar with a valid tool request.
"""
monkeypatch.setattr(
gpt_oss_mod.GptOssResponseParser,
Expand All @@ -554,15 +586,16 @@ def test_tool_grammar_failure_clears_response_format(self, monkeypatch, tool_cho
},
}],
tool_choice=tool_choice,
response_format=ResponseFormat(
type='json_schema',
json_schema=JsonSchema(name='test', schema={'type': 'object'}),
),
response_format=response_format,
)
if tool_choice == 'required':
if requires_call:
with pytest.raises(ValueError, match='required'):
gpt_oss_mod.GptOssResponseParser(request=request)
return
parser = gpt_oss_mod.GptOssResponseParser(request=request)

assert parser.request.response_format is None
if response_format is not None:
assert '# Response Formats' in parser.request.messages[0]['content']
else:
assert parser.request.messages == request.messages
Loading