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.
This commit is contained in:
issues-agent
2026-05-29 17:57:56 +00:00
parent 3893b9e344
commit 3fdd5b6caa
4 changed files with 168 additions and 9 deletions

View File

@@ -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__"]

View File

@@ -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:

View File

@@ -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,<data>"}},
]
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")

View File

@@ -1,6 +1,6 @@
from langchain_groq import __all__
EXPECTED_ALL = ["ChatGroq", "__version__"]
EXPECTED_ALL = ["ChatGroq", "ToolChoiceNotHonoredError", "__version__"]
def test_all_imports() -> None: