diff --git a/lmdeploy/serve/parsers/_openai_harmony.py b/lmdeploy/serve/parsers/_openai_harmony.py index d15b1f3203..d8a145d55e 100644 --- a/lmdeploy/serve/parsers/_openai_harmony.py +++ b/lmdeploy/serve/parsers/_openai_harmony.py @@ -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 @@ -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. @@ -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. diff --git a/tests/test_lmdeploy/serve/parsers/test_gpt_oss_parser.py b/tests/test_lmdeploy/serve/parsers/test_gpt_oss_parser.py index 533efb9f43..e924c30467 100644 --- a/tests/test_lmdeploy/serve/parsers/test_gpt_oss_parser.py +++ b/tests/test_lmdeploy/serve/parsers/test_gpt_oss_parser.py @@ -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, @@ -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