From a547ed0a94bdd574ae6dde28ad241cdcf0a02973 Mon Sep 17 00:00:00 2001 From: Ted Swinyar <8061019+tedswinyar@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:19:53 -0800 Subject: [PATCH 1/7] fix(ollama_chat): give each streamed tool call its own index Ollama streams parallel tool calls one per chunk with no top-level index, and the delta numbers the calls of each chunk from zero, so every call left the proxy as index 0 and OpenAI-style clients appended the arguments of each later call onto the first one. Number the calls with a counter that lives for the whole stream instead. Fixes #33678 Backport of #49183 to lts/1.108.x. Cherry-picked from df6126edaa046acf018cf1b25a6b60835eba6d31 (fix-ollama-chat-stream-tool-call-index). --- litellm/llms/ollama/chat/transformation.py | 3 + .../ollama/test_ollama_chat_transformation.py | 66 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index 46b4caad43f..439321864c1 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -440,6 +440,7 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator): started_reasoning_content: bool = False finished_reasoning_content: bool = False seen_tool_calls: bool = False + _next_tool_call_index: int = 0 def _is_function_call_complete(self, function_args: str | dict) -> bool: if isinstance(function_args, dict): @@ -487,6 +488,8 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator): if tool_calls is not None: self.seen_tool_calls = True for tool_call in tool_calls: + tool_call["index"] = self._next_tool_call_index + self._next_tool_call_index += 1 function_args = tool_call.get("function").get("arguments") if function_args is not None and len(function_args) > 0: is_function_call_complete = self._is_function_call_complete(function_args) diff --git a/tests/unit/llms/ollama/test_ollama_chat_transformation.py b/tests/unit/llms/ollama/test_ollama_chat_transformation.py index ae75c40d32a..4d487bc85ac 100644 --- a/tests/unit/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_chat_transformation.py @@ -949,6 +949,72 @@ class TestOllamaToolCallTransformation: assert tool_msg["tool_call_id"] == "call_abc123" +class TestOllamaStreamingParallelToolCallIndex: + """Regression for https://github.com/BerriAI/litellm/issues/33678. + + Ollama streams each parallel tool call in its own chunk without an OpenAI-style top-level + index. Every delta used to carry index 0, so index-accumulating clients appended the + arguments of every later call onto the first one. + """ + + @staticmethod + def _chunk(*tool_calls: dict) -> dict: + return { + "model": "qwen3:8b", + "message": {"role": "assistant", "content": "", "tool_calls": list(tool_calls)}, + "done": False, + } + + @staticmethod + def _parsed_tool_calls(iterator: OllamaChatCompletionResponseIterator, *chunks: dict) -> list: + return [ + tool_call + for chunk in chunks + for tool_call in (iterator.chunk_parser(chunk).choices[0].delta.tool_calls or []) + ] + + def test_tool_calls_in_separate_chunks_get_distinct_sequential_indices(self): + iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) + chunks = [ + self._chunk({"function": {"name": "bash", "arguments": {"command": command}}}) + for command in ("date", "uname -a", "whoami") + ] + + tool_calls = self._parsed_tool_calls(iterator, *chunks) + + assert [tool_call.index for tool_call in tool_calls] == [0, 1, 2] + assert [json.loads(tool_call.function.arguments) for tool_call in tool_calls] == [ + {"command": "date"}, + {"command": "uname -a"}, + {"command": "whoami"}, + ] + + def test_tool_calls_in_one_chunk_get_distinct_indices(self): + iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) + chunk = self._chunk( + {"function": {"name": "bash", "arguments": {"command": "date"}}}, + {"function": {"name": "bash", "arguments": {"command": "whoami"}}}, + ) + + tool_calls = self._parsed_tool_calls(iterator, chunk) + + assert [tool_call.index for tool_call in tool_calls] == [0, 1] + + def test_index_counter_continues_across_chunks(self): + iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) + chunks = [ + self._chunk( + {"function": {"name": "bash", "arguments": {"command": "date"}}}, + {"function": {"name": "bash", "arguments": {"command": "uname -a"}}}, + ), + self._chunk({"function": {"name": "bash", "arguments": {"command": "whoami"}}}), + ] + + tool_calls = self._parsed_tool_calls(iterator, *chunks) + + assert [tool_call.index for tool_call in tool_calls] == [0, 1, 2] + + class TestOllamaStreamingUsage: @staticmethod def _parse(chunk: dict) -> ModelResponseStream: From 9dd965188fbe5fb3b633592e6cbb7dc2f431a8ab Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:20:02 -0800 Subject: [PATCH 2/7] fix(ollama_chat): answer 400 when replayed tool call arguments are not JSON A chat history that already holds a merged tool call from an older stream used to fail with a 500 APIConnectionError. It now answers 400 naming the tool call. The streaming tests use Ollama's real tool call shape and drop a case the merge base already passed. Backport of #49183 to lts/1.108.x. Cherry-picked from 84154df9e98cf68aff95c7c56fcc9e5593a9fe5a (fix-ollama-chat-stream-tool-call-index). --- litellm/llms/ollama/chat/transformation.py | 27 +++--- .../ollama/test_ollama_chat_transformation.py | 82 +++++++++++++------ 2 files changed, 73 insertions(+), 36 deletions(-) diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index 439321864c1..1fb50fe71c8 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -42,6 +42,22 @@ else: _REQUEST_PARAMS: Final = TypeAdapter(dict[str, object]) +def _to_ollama_tool_call_function(model: str, tool_call: ChatCompletionAssistantToolCall) -> OllamaToolCallFunction: + function: Final = tool_call["function"] + name: Final = function.get("name") or "" + if "arguments" not in function: + return OllamaToolCallFunction(name=name, arguments={}) + try: + arguments: Final = json.loads(function["arguments"]) + except json.JSONDecodeError as e: + raise litellm.BadRequestError( + message=f"OllamaException - arguments of assistant tool call {tool_call.get('id')!r} are not valid JSON: {e}", + model=model, + llm_provider="ollama_chat", + ) from e + return OllamaToolCallFunction(name=name, arguments=arguments) + + class OllamaChatConfig(BaseConfig): """ Reference: https://github.com/ollama/ollama/blob/main/docs/api.md#parameters @@ -261,16 +277,7 @@ class OllamaChatConfig(BaseConfig): for tool in tool_calls: typed_tool = ChatCompletionAssistantToolCall(**tool) if typed_tool["type"] == "function": - arguments = {} - if "arguments" in typed_tool["function"]: - arguments = json.loads(typed_tool["function"]["arguments"]) - ollama_tool_call = OllamaToolCall( - function=OllamaToolCallFunction( - name=typed_tool["function"].get("name") or "", - arguments=arguments, - ) - ) - new_tools.append(ollama_tool_call) + new_tools.append(OllamaToolCall(function=_to_ollama_tool_call_function(model, typed_tool))) reasoning_content, parsed_content = extract_reasoning_content(cast(dict, m)) content_str = convert_content_list_to_str(cast(AllMessageValues, m)) images = extract_images_from_message(cast(AllMessageValues, m)) diff --git a/tests/unit/llms/ollama/test_ollama_chat_transformation.py b/tests/unit/llms/ollama/test_ollama_chat_transformation.py index 4d487bc85ac..9d5452526f5 100644 --- a/tests/unit/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_chat_transformation.py @@ -1,5 +1,6 @@ import asyncio import inspect +import itertools import os import sys from typing import Final, cast @@ -24,7 +25,14 @@ import json from unittest.mock import ANY, MagicMock, patch import litellm -from litellm.types.utils import Choices, EmbeddingResponse, Message, ModelResponse, ModelResponseStream +from litellm.types.utils import ( + ChatCompletionDeltaToolCall, + Choices, + EmbeddingResponse, + Message, + ModelResponse, + ModelResponseStream, +) from unittest import mock @@ -949,6 +957,33 @@ class TestOllamaToolCallTransformation: assert tool_msg["tool_call_id"] == "call_abc123" + def test_malformed_tool_call_arguments_in_history_answer_400(self): + messages = cast( + list[AllMessageValues], + [ + {"role": "user", "content": "Read a.rs and b.rs"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": {"name": "read_file", "arguments": '{"path": "a.rs"}{"path": "b.rs"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_abc123", "content": "fn main() {}"}, + ], + ) + + with pytest.raises(litellm.BadRequestError) as exc_info: + litellm.completion(model="ollama_chat/qwen3:8b", messages=messages, api_base="http://127.0.0.1:9") + + assert exc_info.value.status_code == 400 + assert "call_abc123" in str(exc_info.value) + + class TestOllamaStreamingParallelToolCallIndex: """Regression for https://github.com/BerriAI/litellm/issues/33678. @@ -958,7 +993,14 @@ class TestOllamaStreamingParallelToolCallIndex: """ @staticmethod - def _chunk(*tool_calls: dict) -> dict: + def _tool_call(index: int, command: str) -> dict[str, object]: + return { + "id": f"call_{index}", + "function": {"index": index, "name": "bash", "arguments": {"command": command}}, + } + + @staticmethod + def _chunk(*tool_calls: dict[str, object]) -> dict[str, object]: return { "model": "qwen3:8b", "message": {"role": "assistant", "content": "", "tool_calls": list(tool_calls)}, @@ -966,18 +1008,20 @@ class TestOllamaStreamingParallelToolCallIndex: } @staticmethod - def _parsed_tool_calls(iterator: OllamaChatCompletionResponseIterator, *chunks: dict) -> list: - return [ - tool_call - for chunk in chunks - for tool_call in (iterator.chunk_parser(chunk).choices[0].delta.tool_calls or []) - ] + def _parsed_tool_calls( + iterator: OllamaChatCompletionResponseIterator, *chunks: dict[str, object] + ) -> tuple[ChatCompletionDeltaToolCall, ...]: + return tuple( + itertools.chain.from_iterable( + iterator.chunk_parser(chunk).choices[0].delta.tool_calls or () for chunk in chunks + ) + ) def test_tool_calls_in_separate_chunks_get_distinct_sequential_indices(self): iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) chunks = [ - self._chunk({"function": {"name": "bash", "arguments": {"command": command}}}) - for command in ("date", "uname -a", "whoami") + self._chunk(self._tool_call(index, command)) + for index, command in enumerate(("date", "uname -a", "whoami")) ] tool_calls = self._parsed_tool_calls(iterator, *chunks) @@ -989,25 +1033,11 @@ class TestOllamaStreamingParallelToolCallIndex: {"command": "whoami"}, ] - def test_tool_calls_in_one_chunk_get_distinct_indices(self): - iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) - chunk = self._chunk( - {"function": {"name": "bash", "arguments": {"command": "date"}}}, - {"function": {"name": "bash", "arguments": {"command": "whoami"}}}, - ) - - tool_calls = self._parsed_tool_calls(iterator, chunk) - - assert [tool_call.index for tool_call in tool_calls] == [0, 1] - def test_index_counter_continues_across_chunks(self): iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) chunks = [ - self._chunk( - {"function": {"name": "bash", "arguments": {"command": "date"}}}, - {"function": {"name": "bash", "arguments": {"command": "uname -a"}}}, - ), - self._chunk({"function": {"name": "bash", "arguments": {"command": "whoami"}}}), + self._chunk(self._tool_call(0, "date"), self._tool_call(1, "uname -a")), + self._chunk(self._tool_call(2, "whoami")), ] tool_calls = self._parsed_tool_calls(iterator, *chunks) From 8f84442124db803ca69580ea313540e66c41a6f3 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:57:02 -0800 Subject: [PATCH 3/7] fix(ollama_chat): parse replayed tool call arguments with the shared repair parser Empty, null, and truncated arguments in a replayed assistant tool call now go through parse_tool_call_arguments, so they are forwarded as an object instead of answering 500. Arguments that still are not a JSON object answer 400 naming the call id. Backport of #49183 to lts/1.108.x. Cherry-picked from 0ae806cce402069b813dc5942b4c168940aeaaf6 (fix-ollama-chat-stream-tool-call-index). Adapted to the line. parse_tool_call_arguments is imported from common_utils, its home here, and the forwarded-object test expects no tool call id, since the line does not forward ids. --- litellm/llms/ollama/chat/transformation.py | 15 ++++-- .../ollama/test_ollama_chat_transformation.py | 54 ++++++++++++++----- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index 1fb50fe71c8..a53d7c52166 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -13,6 +13,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( convert_content_list_to_str, extract_images_from_message, extract_reasoning_content, + parse_tool_call_arguments, ) from litellm.litellm_core_utils.tool_fields import tool_params_without_hosted_mcp from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator @@ -40,18 +41,22 @@ else: LiteLLMLoggingObj = Any _REQUEST_PARAMS: Final = TypeAdapter(dict[str, object]) +_TOOL_CALL_ARGUMENTS: Final = TypeAdapter(dict[str, object]) def _to_ollama_tool_call_function(model: str, tool_call: ChatCompletionAssistantToolCall) -> OllamaToolCallFunction: function: Final = tool_call["function"] name: Final = function.get("name") or "" - if "arguments" not in function: - return OllamaToolCallFunction(name=name, arguments={}) + raw_arguments: Final[object] = function.get("arguments") try: - arguments: Final = json.loads(function["arguments"]) - except json.JSONDecodeError as e: + arguments: Final = _TOOL_CALL_ARGUMENTS.validate_python( + parse_tool_call_arguments(raw_arguments, tool_name=name, context="Ollama chat request") + if isinstance(raw_arguments, str) or raw_arguments is None + else raw_arguments + ) + except ValueError as e: raise litellm.BadRequestError( - message=f"OllamaException - arguments of assistant tool call {tool_call.get('id')!r} are not valid JSON: {e}", + message=f"OllamaException - arguments of assistant tool call {tool_call.get('id')!r} are not a JSON object", model=model, llm_provider="ollama_chat", ) from e diff --git a/tests/unit/llms/ollama/test_ollama_chat_transformation.py b/tests/unit/llms/ollama/test_ollama_chat_transformation.py index 9d5452526f5..7591498178c 100644 --- a/tests/unit/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_chat_transformation.py @@ -956,9 +956,9 @@ class TestOllamaToolCallTransformation: assert "tool_call_id" in tool_msg, "tool_call_id must be forwarded to Ollama" assert tool_msg["tool_call_id"] == "call_abc123" - - def test_malformed_tool_call_arguments_in_history_answer_400(self): - messages = cast( + @staticmethod + def _replayed_history(arguments: object) -> list[AllMessageValues]: + return cast( list[AllMessageValues], [ {"role": "user", "content": "Read a.rs and b.rs"}, @@ -969,7 +969,7 @@ class TestOllamaToolCallTransformation: { "id": "call_abc123", "type": "function", - "function": {"name": "read_file", "arguments": '{"path": "a.rs"}{"path": "b.rs"}'}, + "function": {"name": "read_file", "arguments": arguments}, } ], }, @@ -977,13 +977,43 @@ class TestOllamaToolCallTransformation: ], ) + @pytest.mark.parametrize( + ("arguments", "expected"), + [ + ("", {}), + (None, {}), + ('{"path": "a.rs"', {"path": "a.rs"}), + ({"path": "a.rs"}, {"path": "a.rs"}), + ], + ids=["empty", "null", "truncated", "object"], + ) + def test_replayed_tool_call_arguments_are_forwarded_as_an_object(self, arguments: object, expected: object): + result: Final = OllamaChatConfig().transform_request( + model="qwen3:8b", + messages=self._replayed_history(arguments), + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert result["messages"][1]["tool_calls"] == [{"function": {"name": "read_file", "arguments": expected}}] + + @pytest.mark.parametrize( + "arguments", + ['{"path": "a.rs"}{"path": "b.rs"}', '["a.rs", "b.rs"]'], + ids=["glued", "array"], + ) + def test_replayed_tool_call_arguments_that_are_not_an_object_answer_400(self, arguments: str): with pytest.raises(litellm.BadRequestError) as exc_info: - litellm.completion(model="ollama_chat/qwen3:8b", messages=messages, api_base="http://127.0.0.1:9") + litellm.completion( + model="ollama_chat/qwen3:8b", + messages=self._replayed_history(arguments), + api_base="http://127.0.0.1:9", + ) assert exc_info.value.status_code == 400 assert "call_abc123" in str(exc_info.value) - class TestOllamaStreamingParallelToolCallIndex: """Regression for https://github.com/BerriAI/litellm/issues/33678. @@ -1018,13 +1048,13 @@ class TestOllamaStreamingParallelToolCallIndex: ) def test_tool_calls_in_separate_chunks_get_distinct_sequential_indices(self): - iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) - chunks = [ + iterator: Final = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) + chunks: Final = [ self._chunk(self._tool_call(index, command)) for index, command in enumerate(("date", "uname -a", "whoami")) ] - tool_calls = self._parsed_tool_calls(iterator, *chunks) + tool_calls: Final = self._parsed_tool_calls(iterator, *chunks) assert [tool_call.index for tool_call in tool_calls] == [0, 1, 2] assert [json.loads(tool_call.function.arguments) for tool_call in tool_calls] == [ @@ -1034,13 +1064,13 @@ class TestOllamaStreamingParallelToolCallIndex: ] def test_index_counter_continues_across_chunks(self): - iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) - chunks = [ + iterator: Final = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True) + chunks: Final = [ self._chunk(self._tool_call(0, "date"), self._tool_call(1, "uname -a")), self._chunk(self._tool_call(2, "whoami")), ] - tool_calls = self._parsed_tool_calls(iterator, *chunks) + tool_calls: Final = self._parsed_tool_calls(iterator, *chunks) assert [tool_call.index for tool_call in tool_calls] == [0, 1, 2] From 8421785a043f3308d9812c5b29d7c68665e5decd Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:13:17 -0800 Subject: [PATCH 4/7] fix(ollama_chat): send {} for null replayed arguments and keep argument text out of the repair warning Replayed tool call arguments of the JSON string "null" used to reach Ollama and return 200. The shared parser turns them into None, so they now go out as {} like empty or missing arguments instead of answering 400. The parser's repair warning logged up to 200 characters of the arguments. It now logs only the tool name, context, and length. Backport of #49183 to lts/1.108.x. Cherry-picked from e6044318cc22982874cc10cfb4aaed89ede964f4 (fix-ollama-chat-stream-tool-call-index). Adapted to the line. The repair warning change goes into common_utils.py, where parse_tool_call_arguments lives here, and its test goes into that module's test file. --- .../prompt_templates/common_utils.py | 4 +--- litellm/llms/ollama/chat/transformation.py | 3 ++- ...tellm_core_utils_prompt_templates_common_utils.py | 12 ++++++++++++ .../llms/ollama/test_ollama_chat_transformation.py | 9 ++++++--- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index ac5791957b9..5ad87ab5f9f 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -2757,12 +2757,10 @@ def parse_tool_call_arguments( repaired: Final = _attempt_json_repair(arguments) if repaired is not None: verbose_logger.warning( - "Repaired truncated tool call arguments for tool '%s' (%s). Original (%d chars): %.200s%s", + "Repaired truncated tool call arguments for tool '%s' (%s). Original length: %d chars", tool_name or "", context or "unknown context", len(arguments), - arguments, - "..." if len(arguments) > 200 else "", ) return repaired diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index a53d7c52166..e12294b3e0a 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -49,11 +49,12 @@ def _to_ollama_tool_call_function(model: str, tool_call: ChatCompletionAssistant name: Final = function.get("name") or "" raw_arguments: Final[object] = function.get("arguments") try: - arguments: Final = _TOOL_CALL_ARGUMENTS.validate_python( + parsed: Final = ( parse_tool_call_arguments(raw_arguments, tool_name=name, context="Ollama chat request") if isinstance(raw_arguments, str) or raw_arguments is None else raw_arguments ) + arguments: Final = _TOOL_CALL_ARGUMENTS.validate_python({} if parsed is None else parsed) except ValueError as e: raise litellm.BadRequestError( message=f"OllamaException - arguments of assistant tool call {tool_call.get('id')!r} are not a JSON object", diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index c08eeea4bb9..674d39438cf 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -2,6 +2,7 @@ import base64 import copy import functools import json +import logging import os import sys from collections.abc import Mapping @@ -2439,3 +2440,14 @@ def test_schema_has_unresolvable_ref_flags_refs_that_cannot_be_inlined(schema, u def test_schema_size_counts_every_written_object_once(schema, properties, branches): assert schema_property_count(schema) == properties assert schema_grammar_branch_count(schema) == branches + + +def test_parse_tool_call_arguments_repair_warning_leaves_out_argument_content( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + result: Final = parse_tool_call_arguments('{"patient": "Jane Doe"', tool_name="lookup", context="test") + + assert result == {"patient": "Jane Doe"} + assert "Repaired truncated tool call arguments for tool 'lookup'" in caplog.text + assert "Jane Doe" not in caplog.text diff --git a/tests/unit/llms/ollama/test_ollama_chat_transformation.py b/tests/unit/llms/ollama/test_ollama_chat_transformation.py index 7591498178c..14d76bd61ad 100644 --- a/tests/unit/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_chat_transformation.py @@ -982,10 +982,12 @@ class TestOllamaToolCallTransformation: [ ("", {}), (None, {}), + ("null", {}), ('{"path": "a.rs"', {"path": "a.rs"}), + ('{"path": "a.rs", "limit": 2}', {"path": "a.rs", "limit": 2}), ({"path": "a.rs"}, {"path": "a.rs"}), ], - ids=["empty", "null", "truncated", "object"], + ids=["empty", "null", "null_string", "truncated", "non_string_value", "object"], ) def test_replayed_tool_call_arguments_are_forwarded_as_an_object(self, arguments: object, expected: object): result: Final = OllamaChatConfig().transform_request( @@ -1000,8 +1002,8 @@ class TestOllamaToolCallTransformation: @pytest.mark.parametrize( "arguments", - ['{"path": "a.rs"}{"path": "b.rs"}', '["a.rs", "b.rs"]'], - ids=["glued", "array"], + ['{"path": "a.rs"}{"path": "b.rs"}', '["a.rs", "b.rs"]', '[["path", "a.rs"]]'], + ids=["glued", "array", "pairs"], ) def test_replayed_tool_call_arguments_that_are_not_an_object_answer_400(self, arguments: str): with pytest.raises(litellm.BadRequestError) as exc_info: @@ -1014,6 +1016,7 @@ class TestOllamaToolCallTransformation: assert exc_info.value.status_code == 400 assert "call_abc123" in str(exc_info.value) + class TestOllamaStreamingParallelToolCallIndex: """Regression for https://github.com/BerriAI/litellm/issues/33678. From 23603928924549e42a9c5399f887e7fcb77a8657 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:13:18 -0800 Subject: [PATCH 5/7] fix(ollama): keep replayed tool arguments out of a rejected replay's error chain Backport of #49183 to lts/1.108.x. Cherry-picked from 0f7003a2aeb84651230b0fcbcb8403e4ed957af8 (fix-ollama-chat-stream-tool-call-index). --- litellm/llms/ollama/chat/transformation.py | 6 +++--- .../llms/ollama/test_ollama_chat_transformation.py | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index e12294b3e0a..f10f56ebd92 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -49,18 +49,18 @@ def _to_ollama_tool_call_function(model: str, tool_call: ChatCompletionAssistant name: Final = function.get("name") or "" raw_arguments: Final[object] = function.get("arguments") try: - parsed: Final = ( + parsed: Final[object] = ( parse_tool_call_arguments(raw_arguments, tool_name=name, context="Ollama chat request") if isinstance(raw_arguments, str) or raw_arguments is None else raw_arguments ) arguments: Final = _TOOL_CALL_ARGUMENTS.validate_python({} if parsed is None else parsed) - except ValueError as e: + except ValueError: raise litellm.BadRequestError( message=f"OllamaException - arguments of assistant tool call {tool_call.get('id')!r} are not a JSON object", model=model, llm_provider="ollama_chat", - ) from e + ) from None return OllamaToolCallFunction(name=name, arguments=arguments) diff --git a/tests/unit/llms/ollama/test_ollama_chat_transformation.py b/tests/unit/llms/ollama/test_ollama_chat_transformation.py index 14d76bd61ad..552ae778409 100644 --- a/tests/unit/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_chat_transformation.py @@ -3,6 +3,7 @@ import inspect import itertools import os import sys +import traceback from typing import Final, cast import pytest @@ -1002,10 +1003,12 @@ class TestOllamaToolCallTransformation: @pytest.mark.parametrize( "arguments", - ['{"path": "a.rs"}{"path": "b.rs"}', '["a.rs", "b.rs"]', '[["path", "a.rs"]]'], - ids=["glued", "array", "pairs"], + ['{"path": "a.rs"}{"path": "b.rs"}', '["a.rs", "b.rs"]', '[["path", "a.rs"]]', "[]"], + ids=["glued", "array", "pairs", "empty_array"], ) - def test_replayed_tool_call_arguments_that_are_not_an_object_answer_400(self, arguments: str): + def test_replayed_tool_call_arguments_that_are_not_an_object_answer_400_without_echoing_them( + self, arguments: str + ): with pytest.raises(litellm.BadRequestError) as exc_info: litellm.completion( model="ollama_chat/qwen3:8b", @@ -1015,6 +1018,7 @@ class TestOllamaToolCallTransformation: assert exc_info.value.status_code == 400 assert "call_abc123" in str(exc_info.value) + assert "a.rs" not in "".join(traceback.format_exception(exc_info.value)) class TestOllamaStreamingParallelToolCallIndex: From 2280aa224b9a184280f60bbff76da6e2c06ff91c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:19:18 -0800 Subject: [PATCH 6/7] fix(ollama): keep the repair warning's argument preview unless message logging is off Backport of #49183 to lts/1.108.x. Cherry-picked from 5ef6d62cca7e0a9a9e6db2609ca435e9b957de5e (fix-ollama-chat-stream-tool-call-index). Adapted to the line. The repair warning lives in common_utils.py here. --- .../prompt_templates/common_utils.py | 13 +++++++++- ...ore_utils_prompt_templates_common_utils.py | 25 ++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 5ad87ab5f9f..66e943401fa 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -20,6 +20,7 @@ from pydantic import TypeAdapter import litellm from litellm import verbose_logger +from litellm.constants import REDACTED_BY_LITELLM from litellm.router_utils.batch_utils import InMemoryFile from litellm.types.llms.openai import ( AllMessageValues, @@ -2660,6 +2661,15 @@ def merge_consecutive_system_messages( _DECODED_JSON: Final = TypeAdapter(object) +_ARGUMENT_PREVIEW_CHARS: Final = 200 + + +def _logged_arguments(arguments: str) -> str: + if litellm.turn_off_message_logging: + return REDACTED_BY_LITELLM + if len(arguments) <= _ARGUMENT_PREVIEW_CHARS: + return arguments + return f"{arguments[:_ARGUMENT_PREVIEW_CHARS]}..." def _attempt_json_repair(s: str) -> object | None: @@ -2757,10 +2767,11 @@ def parse_tool_call_arguments( repaired: Final = _attempt_json_repair(arguments) if repaired is not None: verbose_logger.warning( - "Repaired truncated tool call arguments for tool '%s' (%s). Original length: %d chars", + "Repaired truncated tool call arguments for tool '%s' (%s). Original (%d chars): %s", tool_name or "", context or "unknown context", len(arguments), + _logged_arguments(arguments), ) return repaired diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 674d39438cf..5d19f3f6b04 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -10,6 +10,8 @@ from typing import Final, cast import pytest +import litellm +from litellm.constants import REDACTED_BY_LITELLM from litellm.litellm_core_utils.prompt_templates.common_utils import ( ENCRYPTED_REASONING_SIGNATURE_PREFIX, TOOL_RESULT_IMAGE_BOUNDARY, @@ -2442,12 +2444,29 @@ def test_schema_size_counts_every_written_object_once(schema, properties, branch assert schema_grammar_branch_count(schema) == branches -def test_parse_tool_call_arguments_repair_warning_leaves_out_argument_content( +def test_parse_tool_call_arguments_repair_warning_previews_the_first_200_characters( caplog: pytest.LogCaptureFixture, ) -> None: + arguments: Final = '{"patient": "Jane Doe", "note": "' + "x" * 300 + '"' with caplog.at_level(logging.WARNING, logger="LiteLLM"): - result: Final = parse_tool_call_arguments('{"patient": "Jane Doe"', tool_name="lookup", context="test") + result: Final = parse_tool_call_arguments(arguments, tool_name="lookup", context="test") + + assert result == {"patient": "Jane Doe", "note": "x" * 300} + assert ( + f"Repaired truncated tool call arguments for tool 'lookup' (test). Original ({len(arguments)} chars): " + f"{arguments[:200]}..." in caplog.text + ) + assert arguments not in caplog.text + + +def test_parse_tool_call_arguments_repair_warning_redacts_arguments_when_message_logging_is_off( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "turn_off_message_logging", True) + arguments: Final = '{"patient": "Jane Doe"' + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + result: Final = parse_tool_call_arguments(arguments, tool_name="lookup", context="test") assert result == {"patient": "Jane Doe"} - assert "Repaired truncated tool call arguments for tool 'lookup'" in caplog.text + assert f"Original ({len(arguments)} chars): {REDACTED_BY_LITELLM}" in caplog.text assert "Jane Doe" not in caplog.text From d9011b1593f8ff6b054cccf311a7381291643e19 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:59:16 -0800 Subject: [PATCH 7/7] test(ollama): cover short and boundary-length repair warning previews Backport of #49183 to lts/1.108.x. Cherry-picked from 1f71ed377f459792b383bcd2bd1e5e4f4b2137ae (fix-ollama-chat-stream-tool-call-index). Adapted to the line. The repair warning tests live in test_litellm_core_utils_prompt_templates_common_utils.py here. --- ...ore_utils_prompt_templates_common_utils.py | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 5d19f3f6b04..b8c9888e8c8 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -2445,8 +2445,9 @@ def test_schema_size_counts_every_written_object_once(schema, properties, branch def test_parse_tool_call_arguments_repair_warning_previews_the_first_200_characters( - caplog: pytest.LogCaptureFixture, + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: + monkeypatch.setattr(litellm, "turn_off_message_logging", False) arguments: Final = '{"patient": "Jane Doe", "note": "' + "x" * 300 + '"' with caplog.at_level(logging.WARNING, logger="LiteLLM"): result: Final = parse_tool_call_arguments(arguments, tool_name="lookup", context="test") @@ -2459,6 +2460,27 @@ def test_parse_tool_call_arguments_repair_warning_previews_the_first_200_charact assert arguments not in caplog.text +@pytest.mark.parametrize( + ("arguments", "expected"), + ( + ('{"patient": "Jane Doe"', {"patient": "Jane Doe"}), + ('{"note": "' + "x" * 189 + '"', {"note": "x" * 189}), + ), +) +def test_parse_tool_call_arguments_repair_warning_logs_arguments_up_to_200_characters_whole( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + arguments: str, + expected: dict[str, str], +) -> None: + monkeypatch.setattr(litellm, "turn_off_message_logging", False) + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + result: Final = parse_tool_call_arguments(arguments, tool_name="lookup", context="test") + + assert result == expected + assert f"Original ({len(arguments)} chars): {arguments}\n" in caplog.text + + def test_parse_tool_call_arguments_repair_warning_redacts_arguments_when_message_logging_is_off( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: