From 3fdd5b6caa47f67603521fd936161fc215b7d18d Mon Sep 17 00:00:00 2001 From: issues-agent Date: Fri, 29 May 2026 17:57:56 +0000 Subject: [PATCH] fix(3e5ce175-02e3-4760-ad3e-30c3709b5433): surface ChatGroq forced tool_choice noncompliance as ToolChoiceNotHonoredError Groq's API rejects forced tool_choice calls with 'Tool choice is required, but model did not call a tool' when models like openai/gpt-oss-20b emit plain text. Wrap that specific provider error in a typed exception that carries the model id and the configured tool_choice so callers can branch without parsing provider error strings. --- libs/partners/groq/langchain_groq/__init__.py | 4 +- .../groq/langchain_groq/chat_models.py | 98 +++++++++++++++++-- .../groq/tests/unit_tests/test_chat_models.py | 73 ++++++++++++++ .../groq/tests/unit_tests/test_imports.py | 2 +- 4 files changed, 168 insertions(+), 9 deletions(-) diff --git a/libs/partners/groq/langchain_groq/__init__.py b/libs/partners/groq/langchain_groq/__init__.py index 47336d474b2..dabf765af8a 100644 --- a/libs/partners/groq/langchain_groq/__init__.py +++ b/libs/partners/groq/langchain_groq/__init__.py @@ -1,6 +1,6 @@ """Groq integration for LangChain.""" -from langchain_groq.chat_models import ChatGroq +from langchain_groq.chat_models import ChatGroq, ToolChoiceNotHonoredError from langchain_groq.version import __version__ -__all__ = ["ChatGroq", "__version__"] +__all__ = ["ChatGroq", "ToolChoiceNotHonoredError", "__version__"] diff --git a/libs/partners/groq/langchain_groq/chat_models.py b/libs/partners/groq/langchain_groq/chat_models.py index 0ab2fa8a139..983a7609653 100644 --- a/libs/partners/groq/langchain_groq/chat_models.py +++ b/libs/partners/groq/langchain_groq/chat_models.py @@ -81,6 +81,47 @@ _STRICT_STRUCTURED_OUTPUT_MODELS = frozenset( "openai/gpt-oss-120b", } ) +_TOOL_CHOICE_NOT_HONORED_MARKER = ( + "Tool choice is required, but model did not call a tool" +) + + +class ToolChoiceNotHonoredError(Exception): + """Raised when Groq rejects a forced tool_choice the model declined to honor.""" + + def __init__( + self, + *, + model_name: str, + tool_choice: Any, + provider_message: str, + ) -> None: + """Build the error with model id, tool_choice, and original provider message.""" + self.model_name = model_name + self.tool_choice = tool_choice + self.provider_message = provider_message + super().__init__( + f"Model {model_name!r} did not emit a tool call for the forced " + f"tool_choice={tool_choice!r}. Provider message: {provider_message}" + ) + + +def _maybe_raise_tool_choice_not_honored( + exc: BaseException, *, model_name: str, params: dict +) -> None: + """Re-raise forced tool_choice noncompliance as ToolChoiceNotHonoredError.""" + import groq # noqa: PLC0415 + + if not isinstance(exc, groq.APIError): + return + message = str(getattr(exc, "message", "") or "") or str(exc) + if _TOOL_CHOICE_NOT_HONORED_MARKER not in message: + return + raise ToolChoiceNotHonoredError( + model_name=model_name, + tool_choice=params.get("tool_choice"), + provider_message=message, + ) from exc def _get_default_model_profile(model_name: str) -> ModelProfile: @@ -624,7 +665,13 @@ class ChatGroq(BaseChatModel): **params, **kwargs, } - response = self.client.create(messages=message_dicts, **params) + try: + response = self.client.create(messages=message_dicts, **params) + except Exception as exc: + _maybe_raise_tool_choice_not_honored( + exc, model_name=self.model_name, params=params + ) + raise return self._create_chat_result(response, params) async def _agenerate( @@ -645,7 +692,13 @@ class ChatGroq(BaseChatModel): **params, **kwargs, } - response = await self.async_client.create(messages=message_dicts, **params) + try: + response = await self.async_client.create(messages=message_dicts, **params) + except Exception as exc: + _maybe_raise_tool_choice_not_honored( + exc, model_name=self.model_name, params=params + ) + raise return self._create_chat_result(response, params) def _stream( @@ -660,7 +713,24 @@ class ChatGroq(BaseChatModel): params = {**params, **kwargs, "stream": True} default_chunk_class: type[BaseMessageChunk] = AIMessageChunk - for chunk in self.client.create(messages=message_dicts, **params): + try: + stream = self.client.create(messages=message_dicts, **params) + except Exception as exc: + _maybe_raise_tool_choice_not_honored( + exc, model_name=self.model_name, params=params + ) + raise + + def _iter_stream() -> Iterator[Any]: + try: + yield from stream + except Exception as exc: + _maybe_raise_tool_choice_not_honored( + exc, model_name=self.model_name, params=params + ) + raise + + for chunk in _iter_stream(): if not isinstance(chunk, dict): chunk = chunk.model_dump() # noqa: PLW2901 if len(chunk["choices"]) == 0: @@ -712,9 +782,25 @@ class ChatGroq(BaseChatModel): params = {**params, **kwargs, "stream": True} default_chunk_class: type[BaseMessageChunk] = AIMessageChunk - async for chunk in await self.async_client.create( - messages=message_dicts, **params - ): + try: + stream = await self.async_client.create(messages=message_dicts, **params) + except Exception as exc: + _maybe_raise_tool_choice_not_honored( + exc, model_name=self.model_name, params=params + ) + raise + + async def _aiter_stream() -> AsyncIterator[Any]: + try: + async for item in stream: + yield item + except Exception as exc: + _maybe_raise_tool_choice_not_honored( + exc, model_name=self.model_name, params=params + ) + raise + + async for chunk in _aiter_stream(): if not isinstance(chunk, dict): chunk = chunk.model_dump() # noqa: PLW2901 if len(chunk["choices"]) == 0: diff --git a/libs/partners/groq/tests/unit_tests/test_chat_models.py b/libs/partners/groq/tests/unit_tests/test_chat_models.py index 33f4448bd5e..1c172ffc99a 100644 --- a/libs/partners/groq/tests/unit_tests/test_chat_models.py +++ b/libs/partners/groq/tests/unit_tests/test_chat_models.py @@ -21,6 +21,7 @@ from pydantic import BaseModel from langchain_groq.chat_models import ( ChatGroq, + ToolChoiceNotHonoredError, _convert_chunk_to_message_chunk, _convert_dict_to_message, _create_usage_metadata, @@ -1095,3 +1096,75 @@ def test_format_message_content_mixed() -> None: {"type": "image_url", "image_url": {"url": "data:image/png;base64,"}}, ] assert expected == _format_message_content(content) + + +def _make_tool_choice_api_error() -> Exception: + """Construct the Groq APIError raised on forced tool_choice noncompliance.""" + import groq # noqa: PLC0415 + + return groq.APIError( + "Tool choice is required, but model did not call a tool", + request=MagicMock(), + body=None, + ) + + +def test_invoke_raises_tool_choice_not_honored_error() -> None: + """ChatGroq.invoke wraps forced tool_choice noncompliance APIErrors.""" + llm = ChatGroq(model="openai/gpt-oss-20b") + mock_client = MagicMock() + + def mock_create(*args: Any, **kwargs: Any) -> Any: + raise _make_tool_choice_api_error() + + mock_client.create = mock_create + with ( + patch.object(llm, "client", mock_client), + pytest.raises(ToolChoiceNotHonoredError) as exc_info, + ): + llm.invoke( + "hi", + tool_choice={"type": "function", "function": {"name": "MyTool"}}, + ) + assert "openai/gpt-oss-20b" in str(exc_info.value) + assert exc_info.value.model_name == "openai/gpt-oss-20b" + + +def test_stream_raises_tool_choice_not_honored_error() -> None: + """ChatGroq.stream wraps forced tool_choice noncompliance APIErrors.""" + llm = ChatGroq(model="openai/gpt-oss-20b") + mock_client = MagicMock() + + def mock_create(*args: Any, **kwargs: Any) -> Any: + raise _make_tool_choice_api_error() + + mock_client.create = mock_create + with ( + patch.object(llm, "client", mock_client), + pytest.raises(ToolChoiceNotHonoredError) as exc_info, + ): + list( + llm.stream( + "hi", + tool_choice={"type": "function", "function": {"name": "MyTool"}}, + ) + ) + assert "openai/gpt-oss-20b" in str(exc_info.value) + assert exc_info.value.model_name == "openai/gpt-oss-20b" + + +def test_invoke_passes_through_unrelated_api_errors() -> None: + """Unrelated Groq APIErrors are not rewrapped.""" + import groq # noqa: PLC0415 + + llm = ChatGroq(model="openai/gpt-oss-20b") + mock_client = MagicMock() + + msg = "some other failure" + + def mock_create(*args: Any, **kwargs: Any) -> Any: + raise groq.APIError(msg, request=MagicMock(), body=None) + + mock_client.create = mock_create + with patch.object(llm, "client", mock_client), pytest.raises(groq.APIError): + llm.invoke("hi") diff --git a/libs/partners/groq/tests/unit_tests/test_imports.py b/libs/partners/groq/tests/unit_tests/test_imports.py index 5c362ebcee7..479ac9981e7 100644 --- a/libs/partners/groq/tests/unit_tests/test_imports.py +++ b/libs/partners/groq/tests/unit_tests/test_imports.py @@ -1,6 +1,6 @@ from langchain_groq import __all__ -EXPECTED_ALL = ["ChatGroq", "__version__"] +EXPECTED_ALL = ["ChatGroq", "ToolChoiceNotHonoredError", "__version__"] def test_all_imports() -> None: