diff --git a/libs/partners/groq/langchain_groq/chat_models.py b/libs/partners/groq/langchain_groq/chat_models.py index 0ab2fa8a139..4377bbc38d7 100644 --- a/libs/partners/groq/langchain_groq/chat_models.py +++ b/libs/partners/groq/langchain_groq/chat_models.py @@ -12,6 +12,7 @@ from langchain_core.callbacks import ( AsyncCallbackManagerForLLMRun, CallbackManagerForLLMRun, ) +from langchain_core.exceptions import OutputParserException from langchain_core.language_models import ( LanguageModelInput, ModelProfile, @@ -1473,6 +1474,81 @@ def _convert_chunk_to_message_chunk( return default_class(content=content) # type: ignore[call-arg] +def _parse_legacy_function_tool_calls( + text: str, + allowed_tool_names: set[str] | None = None, +) -> list[dict]: + """Parse legacy ``...`` tool-call tags. + + The tool name is taken as everything after ````) + is parsed as JSON arguments. + """ + if not text or "", header_start) + if header_end == -1: + break + header = text[header_start:header_end] + brace_idx = header.find("{") + ws_idx = -1 + for i, ch in enumerate(header): + if ch.isspace(): + ws_idx = i + break + split_candidates = [i for i in (brace_idx, ws_idx) if i != -1] + if split_candidates: + split_idx = min(split_candidates) + name = header[:split_idx].strip() + args_blob = header[split_idx:].strip() + else: + name = header.strip() + args_blob = "" + if not name: + cursor = header_end + 1 + continue + if allowed_tool_names is not None and name not in allowed_tool_names: + msg = ( + f"Model emitted legacy tool call for " + f"'{name}', which is not in the configured tools " + f"({sorted(allowed_tool_names)})." + ) + raise OutputParserException(msg) + if args_blob: + try: + args = json.loads(args_blob) + except json.JSONDecodeError as e: + msg = ( + f"Failed to JSON-decode arguments for legacy " + f" tool call: {e}. " + f"Arguments blob was: {args_blob!r}" + ) + raise OutputParserException(msg) from e + else: + args = {} + tool_calls.append( + { + "type": "function", + "id": None, + "function": { + "name": name, + "arguments": json.dumps(args, ensure_ascii=False), + }, + } + ) + close = text.find("", header_end) + cursor = (close + len("")) if close != -1 else (header_end + 1) + return tool_calls + + def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage: """Convert a dictionary to a LangChain message. @@ -1515,6 +1591,23 @@ def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage: invalid_tool_calls.append( make_invalid_tool_call(raw_tool_call, str(e)) ) + elif isinstance(content, str) and "...`` text format + # rather than as structured ``tool_calls``. Extract them so the + # bare tool name (not ``NAME{...args...}``) is forwarded. + legacy_raw_tool_calls = _parse_legacy_function_tool_calls(content) + if legacy_raw_tool_calls: + additional_kwargs["tool_calls"] = legacy_raw_tool_calls + for raw_tool_call in legacy_raw_tool_calls: + try: + tool_calls.append( + parse_tool_call(raw_tool_call, return_id=True) + ) + except Exception as e: # pylint: disable=broad-except + invalid_tool_calls.append( + make_invalid_tool_call(raw_tool_call, str(e)) + ) return AIMessage( content=content, id=id_, 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..f0497c49f4d 100644 --- a/libs/partners/groq/tests/unit_tests/test_chat_models.py +++ b/libs/partners/groq/tests/unit_tests/test_chat_models.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import langchain_core.load as lc_load import pytest +from langchain_core.exceptions import OutputParserException from langchain_core.messages import ( AIMessage, AIMessageChunk, @@ -25,6 +26,7 @@ from langchain_groq.chat_models import ( _convert_dict_to_message, _create_usage_metadata, _format_message_content, + _parse_legacy_function_tool_calls, ) if "GROQ_API_KEY" not in os.environ: @@ -143,6 +145,45 @@ def test__convert_dict_to_message_tool_call() -> None: assert result == expected_output +def test__parse_legacy_function_tool_calls_strips_args_from_name() -> None: + text = '' + result = _parse_legacy_function_tool_calls(text) + assert len(result) == 1 + assert result[0]["function"]["name"] == "my_tool" + assert json.loads(result[0]["function"]["arguments"]) == {"x": 1} + + +def test__parse_legacy_function_tool_calls_no_whitespace() -> None: + result = _parse_legacy_function_tool_calls( + '' + "" + ) + assert len(result) == 1 + assert result[0]["function"]["name"] == "generator_function" + assert json.loads(result[0]["function"]["arguments"]) == { + "type": "greeting", + "style": "Pirate", + } + + +def test__parse_legacy_function_tool_calls_unknown_tool_raises() -> None: + text = '' + with pytest.raises(OutputParserException, match="bogus_tool"): + _parse_legacy_function_tool_calls(text, allowed_tool_names={"my_tool"}) + + +def test__convert_dict_to_message_legacy_function_tag() -> None: + message = { + "role": "assistant", + "content": '', + } + result = _convert_dict_to_message(message) + assert isinstance(result, AIMessage) + assert len(result.tool_calls) == 1 + assert result.tool_calls[0]["name"] == "my_tool" + assert result.tool_calls[0]["args"] == {"x": 1} + + def test__convert_dict_to_message_system() -> None: message = {"role": "system", "content": "foo"} result = _convert_dict_to_message(message)