diff --git a/libs/oci/langchain_oci/chat_models/oci_generative_ai.py b/libs/oci/langchain_oci/chat_models/oci_generative_ai.py index b3e79ac..11ed892 100644 --- a/libs/oci/langchain_oci/chat_models/oci_generative_ai.py +++ b/libs/oci/langchain_oci/chat_models/oci_generative_ai.py @@ -94,9 +94,19 @@ def remove_signature_from_tool_description(name: str, description: str) -> str: @staticmethod def convert_oci_tool_call_to_langchain(tool_call: Any) -> ToolCall: """Convert an OCI tool call to a LangChain ToolCall.""" + parsed = json.loads(tool_call.arguments) + + # If the parsed result is a string, it means the JSON was escaped, so parse again + if isinstance(parsed, str): + try: + parsed = json.loads(parsed) + except json.JSONDecodeError: + # If it's not valid JSON, keep it as a string + pass + return ToolCall( name=tool_call.name, - args=json.loads(tool_call.arguments) + args=parsed if "arguments" in tool_call.attribute_map else tool_call.parameters, id=tool_call.id if "id" in tool_call.attribute_map else uuid.uuid4().hex[:], diff --git a/libs/oci/tests/unit_tests/chat_models/test_oci_generative_ai.py b/libs/oci/tests/unit_tests/chat_models/test_oci_generative_ai.py index f81d2e9..b571d88 100644 --- a/libs/oci/tests/unit_tests/chat_models/test_oci_generative_ai.py +++ b/libs/oci/tests/unit_tests/chat_models/test_oci_generative_ai.py @@ -14,7 +14,7 @@ class MockResponseDict(dict): def __getattr__(self, val): # type: ignore[no-untyped-def] - return self[val] + return self.get(val) class MockToolCall(dict): @@ -253,6 +253,67 @@ def get_weather(location: str) -> str: assert tool_call["type"] == "function" assert tool_call["function"]["name"] == "get_weather" + # Test escaped JSON arguments (issue #52) + def mocked_response_escaped(*args, **kwargs): # type: ignore[no-untyped-def] + """Mock response with escaped JSON arguments.""" + return MockResponseDict( + { + "status": 200, + "data": MockResponseDict( + { + "chat_response": MockResponseDict( + { + "choices": [ + MockResponseDict( + { + "message": MockResponseDict( + { + "content": [ + MockResponseDict({"text": ""}) + ], + "tool_calls": [ + MockResponseDict( + { + "type": "FUNCTION", + "id": "call_escaped", + "name": "get_weather", + # Escaped JSON (the bug scenario) + "arguments": '"{\\\"location\\\": \\\"San Francisco\\\"}"', + "attribute_map": { + "id": "id", + "type": "type", + "name": "name", + "arguments": "arguments", + }, + } + ) + ], + } + ), + "finish_reason": "tool_calls", + } + ) + ], + "time_created": "2025-10-22T19:48:12.726000+00:00", + } + ), + "model_id": "meta.llama-3-70b-instruct", + "model_version": "1.0.0", + } + ), + "request_id": "test_escaped", + "headers": MockResponseDict({"content-length": "366"}), + } + ) + + monkeypatch.setattr(llm.client, "chat", mocked_response_escaped) + response_escaped = llm.bind_tools(tools=[get_weather]).invoke(messages) + + # Verify escaped JSON was correctly parsed to a dict + assert len(response_escaped.tool_calls) == 1 + assert response_escaped.tool_calls[0]["name"] == "get_weather" + assert response_escaped.tool_calls[0]["args"] == {"location": "San Francisco"} + @pytest.mark.requires("oci") def test_cohere_tool_choice_validation(monkeypatch: MonkeyPatch) -> None: