Skip to content

Commit 8e4396b

Browse files
authored
fix(ollama): robustly parse single-quoted JSON in tool calls (#32109)
**Description:** This PR makes argument parsing for Ollama tool calls more robust. Some LLMs—including Ollama—may return arguments as Python-style dictionaries with single quotes (e.g., `{'a': 1}`), which are not valid JSON and previously caused parsing to fail. The updated `_parse_json_string` method in `langchain_ollama.chat_models` now attempts standard JSON parsing and, if that fails, falls back to `ast.literal_eval` for safe evaluation of Python-style dictionaries. This improves interoperability with LLMs and fixes a common usability issue for tool-based agents. **Issue:** Closes #30910 **Dependencies:** None **Tests:** - Added new unit tests for double-quoted JSON, single-quoted dicts, mixed quoting, and malformed/failure cases. - All tests pass locally, including new coverage for single-quoted inputs. **Notes:** - No breaking changes. - No new dependencies introduced. - Code is formatted and linted (`ruff format`, `ruff check`). - If maintainers have suggestions for further improvements, I’m happy to revise! Thank you for maintaining LangChain! Looking forward to your feedback.
1 parent 6794422 commit 8e4396b

File tree

3 files changed

+88
-18
lines changed

3 files changed

+88
-18
lines changed

libs/langchain/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "pdm.backend"
55
[project]
66
authors = []
77
license = { text = "MIT" }
8-
requires-python = ">=3.9"
8+
requires-python = ">=3.9, <4.0"
99
dependencies = [
1010
"langchain-core<1.0.0,>=0.3.66",
1111
"langchain-text-splitters<1.0.0,>=0.3.8",

libs/partners/ollama/langchain_ollama/chat_models.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import ast
56
import json
67
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
78
from operator import itemgetter
@@ -77,33 +78,45 @@ def _get_usage_metadata_from_generation_info(
7778

7879
def _parse_json_string(
7980
json_string: str,
81+
*,
8082
raw_tool_call: dict[str, Any],
81-
skip: bool, # noqa: FBT001
83+
skip: bool,
8284
) -> Any:
8385
"""Attempt to parse a JSON string for tool calling.
8486
87+
It first tries to use the standard json.loads. If that fails, it falls
88+
back to ast.literal_eval to safely parse Python literals, which is more
89+
robust against models using single quotes or containing apostrophes.
90+
8591
Args:
8692
json_string: JSON string to parse.
87-
skip: Whether to ignore parsing errors and return the value anyways.
8893
raw_tool_call: Raw tool call to include in error message.
94+
skip: Whether to ignore parsing errors and return the value anyways.
8995
9096
Returns:
91-
The parsed JSON string.
97+
The parsed JSON string or Python literal.
9298
9399
Raises:
94-
OutputParserException: If the JSON string wrong invalid and skip=False.
100+
OutputParserException: If the string is invalid and skip=False.
95101
"""
96102
try:
97103
return json.loads(json_string)
98-
except json.JSONDecodeError as e:
99-
if skip:
100-
return json_string
101-
msg = (
102-
f"Function {raw_tool_call['function']['name']} arguments:\n\n"
103-
f"{raw_tool_call['function']['arguments']}\n\nare not valid JSON. "
104-
f"Received JSONDecodeError {e}"
105-
)
106-
raise OutputParserException(msg) from e
104+
except json.JSONDecodeError:
105+
try:
106+
# Use ast.literal_eval to safely parse Python-style dicts
107+
# (e.g. with single quotes)
108+
return ast.literal_eval(json_string)
109+
except (SyntaxError, ValueError) as e:
110+
# If both fail, and we're not skipping, raise an informative error.
111+
if skip:
112+
return json_string
113+
msg = (
114+
f"Function {raw_tool_call['function']['name']} arguments:\n\n"
115+
f"{raw_tool_call['function']['arguments']}"
116+
"\n\nare not valid JSON or a Python literal. "
117+
f"Received error: {e}"
118+
)
119+
raise OutputParserException(msg) from e
107120
except TypeError as e:
108121
if skip:
109122
return json_string

libs/partners/ollama/tests/unit_tests/test_chat_models.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@
88

99
import pytest
1010
from httpx import Client, Request, Response
11+
from langchain_core.exceptions import OutputParserException
1112
from langchain_core.messages import ChatMessage
1213
from langchain_tests.unit_tests import ChatModelUnitTests
1314

14-
from langchain_ollama.chat_models import ChatOllama, _parse_arguments_from_tool_call
15+
from langchain_ollama.chat_models import (
16+
ChatOllama,
17+
_parse_arguments_from_tool_call,
18+
_parse_json_string,
19+
)
1520

1621
MODEL_NAME = "llama3.1"
1722

@@ -49,13 +54,11 @@ def test_arbitrary_roles_accepted_in_chatmessages(
4954
monkeypatch: pytest.MonkeyPatch,
5055
) -> None:
5156
monkeypatch.setattr(Client, "stream", _mock_httpx_client_stream)
52-
5357
llm = ChatOllama(
5458
model=MODEL_NAME,
5559
verbose=True,
5660
format=None,
5761
)
58-
5962
messages = [
6063
ChatMessage(
6164
role="somerandomrole",
@@ -64,7 +67,6 @@ def test_arbitrary_roles_accepted_in_chatmessages(
6467
ChatMessage(role="control", content="thinking"),
6568
ChatMessage(role="user", content="What is the meaning of life?"),
6669
]
67-
6870
llm.invoke(messages)
6971

7072

@@ -83,3 +85,58 @@ def test_validate_model_on_init(mock_validate_model: Any) -> None:
8385
# Test that validate_model is NOT called by default
8486
ChatOllama(model=MODEL_NAME)
8587
mock_validate_model.assert_not_called()
88+
89+
90+
# Define a dummy raw_tool_call for the function signature
91+
dummy_raw_tool_call = {
92+
"function": {"name": "test_func", "arguments": ""},
93+
}
94+
95+
96+
# --- Regression tests for tool-call argument parsing (see #30910) ---
97+
98+
99+
@pytest.mark.parametrize(
100+
"input_string, expected_output",
101+
[
102+
# Case 1: Standard double-quoted JSON
103+
('{"key": "value", "number": 123}', {"key": "value", "number": 123}),
104+
# Case 2: Single-quoted string (the original bug)
105+
("{'key': 'value', 'number': 123}", {"key": "value", "number": 123}),
106+
# Case 3: String with an internal apostrophe
107+
('{"text": "It\'s a great test!"}', {"text": "It's a great test!"}),
108+
# Case 4: Mixed quotes that ast can handle
109+
("{'text': \"It's a great test!\"}", {"text": "It's a great test!"}),
110+
],
111+
)
112+
def test_parse_json_string_success_cases(
113+
input_string: str, expected_output: Any
114+
) -> None:
115+
"""Tests that _parse_json_string correctly parses valid and fixable strings."""
116+
raw_tool_call = {"function": {"name": "test_func", "arguments": input_string}}
117+
result = _parse_json_string(input_string, raw_tool_call=raw_tool_call, skip=False)
118+
assert result == expected_output
119+
120+
121+
def test_parse_json_string_failure_case_raises_exception() -> None:
122+
"""Tests that _parse_json_string raises an exception for truly malformed strings."""
123+
malformed_string = "{'key': 'value',,}"
124+
raw_tool_call = {"function": {"name": "test_func", "arguments": malformed_string}}
125+
with pytest.raises(OutputParserException):
126+
_parse_json_string(
127+
malformed_string,
128+
raw_tool_call=raw_tool_call,
129+
skip=False,
130+
)
131+
132+
133+
def test_parse_json_string_skip_returns_input_on_failure() -> None:
134+
"""Tests that skip=True returns the original string on parse failure."""
135+
malformed_string = "{'not': valid,,,}"
136+
raw_tool_call = {"function": {"name": "test_func", "arguments": malformed_string}}
137+
result = _parse_json_string(
138+
malformed_string,
139+
raw_tool_call=raw_tool_call,
140+
skip=True,
141+
)
142+
assert result == malformed_string

0 commit comments

Comments
 (0)