mirror of
https://github.com/hwchase17/langchain.git
synced 2026-07-11 01:01:41 +00:00
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:
@@ -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__"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from langchain_groq import __all__
|
||||
|
||||
EXPECTED_ALL = ["ChatGroq", "__version__"]
|
||||
EXPECTED_ALL = ["ChatGroq", "ToolChoiceNotHonoredError", "__version__"]
|
||||
|
||||
|
||||
def test_all_imports() -> None:
|
||||
|
||||
Reference in New Issue
Block a user