From 0802051d0754c81e720690d067e5634b234a2917 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:12:25 +0000 Subject: [PATCH] fix(ollama_chat): surface structured Ollama errors instead of KeyError/APIConnectionError --- .../exception_mapping_utils.py | 51 ++++++ litellm/llms/ollama/chat/transformation.py | 31 +++- .../test_exception_mapping_utils.py | 41 +++++ .../ollama/test_ollama_chat_transformation.py | 157 ++++++++++++++++++ 4 files changed, 276 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index fdab3d5b9d4..7670162f1f3 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -1827,6 +1827,57 @@ def _map_ollama_exception( llm_provider="ollama", model=model, ) + elif hasattr(original_exception, "status_code"): + status_code = original_exception.status_code + response = getattr(original_exception, "response", None) + if status_code == 400 or status_code == 422: + raise BadRequestError( + message=f"OllamaException: {original_exception}", + llm_provider="ollama", + model=model, + response=response, + ) + elif status_code == 401: + raise AuthenticationError( + message=f"OllamaException: {original_exception}", + llm_provider="ollama", + model=model, + response=response, + ) + elif status_code == 404: + raise NotFoundError( + message=f"OllamaException: {original_exception}", + llm_provider="ollama", + model=model, + response=response, + ) + elif status_code == 408: + raise Timeout( + message=f"OllamaException: {original_exception}", + llm_provider="ollama", + model=model, + ) + elif status_code == 429: + raise RateLimitError( + message=f"OllamaException: {original_exception}", + llm_provider="ollama", + model=model, + response=response, + ) + elif status_code == 503: + raise ServiceUnavailableError( + message=f"OllamaException: {original_exception}", + llm_provider="ollama", + model=model, + response=response, + ) + elif status_code >= 500: + raise InternalServerError( + message=f"OllamaException: {original_exception}", + llm_provider="ollama", + model=model, + response=response, + ) def _map_vllm_exception( diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index 694f8cdd6c2..2eb6c9e8add 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -46,6 +46,16 @@ else: LiteLLMLoggingObj = Any +def _raise_on_ollama_structured_error(response: dict) -> None: + error = response.get("error") + if error is not None: + raise OllamaError( + message=str(error), + status_code=500, + headers={"Content-Type": "application/json"}, + ) + + class OllamaChatConfig(BaseConfig): """ Reference: https://github.com/ollama/ollama/blob/main/docs/api.md#parameters @@ -343,11 +353,18 @@ class OllamaChatConfig(BaseConfig): ) response_json = raw_response.json() + _raise_on_ollama_structured_error(response_json) ## RESPONSE OBJECT _done_reason = map_finish_reason(response_json.get("done_reason") or "stop") model_response.choices[0].finish_reason = _done_reason response_json_message = response_json.get("message") + if response_json_message is None: + raise OllamaError( + message=f"Got unexpected response from Ollama: {response_json}", + status_code=500, + headers={"Content-Type": "application/json"}, + ) if response_json_message is not None: if "thinking" in response_json_message: # remap 'thinking' to 'reasoning_content' @@ -394,10 +411,15 @@ class OllamaChatConfig(BaseConfig): model_response.choices[0].finish_reason = "tool_calls" model_response.created = int(time.time()) model_response.model = "ollama_chat/" + model - prompt_tokens = response_json.get("prompt_eval_count", litellm.token_counter(messages=messages)) # type: ignore - completion_tokens = response_json.get( - "eval_count", - litellm.token_counter(text=response_json["message"]["content"]), + prompt_eval_count = response_json.get("prompt_eval_count") + prompt_tokens = ( + prompt_eval_count if prompt_eval_count is not None else litellm.token_counter(messages=messages) # type: ignore + ) + eval_count = response_json.get("eval_count") + completion_tokens = ( + eval_count + if eval_count is not None + else litellm.token_counter(text=response_json_message.get("content") or "") ) setattr( model_response, @@ -440,6 +462,7 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator): return False def chunk_parser(self, chunk: dict) -> ModelResponseStream: + _raise_on_ollama_structured_error(chunk) try: """ Expected chunk format: diff --git a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py index 1fcee1b1c42..4d80a82b3f3 100644 --- a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py @@ -680,3 +680,44 @@ def test_azure_404_with_invalid_request_error_type_maps_to_not_found(): assert excinfo.value.status_code == 404 assert "Response with id 'resp_abc' not found." in excinfo.value.message + + +ollama_status_code_test_cases = [ + (400, litellm.BadRequestError), + (401, litellm.AuthenticationError), + (404, litellm.NotFoundError), + (429, litellm.RateLimitError), + (500, litellm.InternalServerError), + (503, litellm.ServiceUnavailableError), +] + + +@pytest.mark.parametrize("status_code, expected_exception", ollama_status_code_test_cases) +@pytest.mark.parametrize("custom_llm_provider", ["ollama", "ollama_chat"]) +def test_ollama_status_code_maps_to_typed_exception( + status_code, expected_exception, custom_llm_provider +): + """A structured Ollama error carrying a status_code must map to the matching + typed exception instead of falling through to a generic APIConnectionError. + + Regression: https://github.com/BerriAI/litellm/issues/33622 - Ollama returning + {"error": "error parsing tool call: ..."} was surfaced as APIConnectionError, + mislabeling a model error as a connection/timeout failure.""" + from litellm.llms.ollama.common_utils import OllamaError + + original_exception = OllamaError( + status_code=status_code, + message="error parsing tool call: invalid character", + headers={}, + ) + + with pytest.raises(expected_exception) as excinfo: + exception_type( + model=f"{custom_llm_provider}/llama3.1", + original_exception=original_exception, + custom_llm_provider=custom_llm_provider, + ) + + assert not isinstance(excinfo.value, litellm.APIConnectionError) + assert excinfo.value.llm_provider == "ollama" + assert "error parsing tool call" in str(excinfo.value) diff --git a/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py b/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py index 906c51d8064..b0ab23ffeb9 100644 --- a/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py @@ -906,3 +906,160 @@ class TestOllamaToolCallTransformation: assert tool_msg["content"] == "Sunny, 72°F" assert "tool_call_id" in tool_msg, "tool_call_id must be forwarded to Ollama" assert tool_msg["tool_call_id"] == "call_abc123" + + +class TestOllamaStructuredError: + """Regression tests for structured Ollama error responses. + + When Ollama returns a structured error such as + {"error": "error parsing tool call: ..."} the handler used to assume every + payload was success-shaped, subscript chunk["message"], and raise + KeyError: 'message' which surfaced to callers as APIConnectionError. + Regression: https://github.com/BerriAI/litellm/issues/33622 + """ + + def test_streaming_structured_error_raises_ollama_error(self): + from litellm.llms.ollama.common_utils import OllamaError + + iterator = OllamaChatCompletionResponseIterator( + streaming_response=iter([]), + sync_stream=True, + ) + + with pytest.raises(OllamaError) as exc_info: + iterator.chunk_parser({"error": "error parsing tool call: invalid character"}) + + assert not isinstance(exc_info.value, KeyError) + assert "error parsing tool call" in exc_info.value.message + assert "KeyError" not in exc_info.value.message + + def test_streaming_structured_error_maps_to_internal_server_error(self): + """The mapped exception must not be APIConnectionError (the mislabel).""" + from litellm.litellm_core_utils.exception_mapping_utils import exception_type + from litellm.llms.ollama.common_utils import OllamaError + + iterator = OllamaChatCompletionResponseIterator( + streaming_response=iter([]), + sync_stream=True, + ) + + try: + iterator.chunk_parser({"error": "error parsing tool call: invalid character"}) + pytest.fail("expected OllamaError") + except OllamaError as raw_error: + with pytest.raises(litellm.InternalServerError) as exc_info: + exception_type( + model="ollama_chat/llama3.1", + original_exception=raw_error, + custom_llm_provider="ollama_chat", + completion_kwargs={}, + extra_kwargs={}, + ) + assert not isinstance(exc_info.value, litellm.APIConnectionError) + assert "error parsing tool call" in str(exc_info.value) + + def test_non_streaming_structured_error_raises_ollama_error(self): + from litellm.llms.ollama.common_utils import OllamaError + + config = OllamaChatConfig() + + ollama_response = {"error": "error parsing tool call: invalid character"} + mock_response = MagicMock() + mock_response.json.return_value = ollama_response + mock_response.text = json.dumps(ollama_response) + + model_response = ModelResponse() + model_response.choices = [Choices(message=Message(content=""), index=0)] + + with pytest.raises(OllamaError) as exc_info: + config.transform_response( + model="llama3.1", + raw_response=mock_response, + model_response=model_response, + logging_obj=MagicMock(), + request_data={}, + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + encoding=None, + api_key=None, + json_mode=False, + ) + + assert "error parsing tool call" in exc_info.value.message + + def test_non_streaming_missing_message_raises_ollama_error(self): + """A payload with neither 'message' nor 'error' must not TypeError on Message(**None).""" + from litellm.llms.ollama.common_utils import OllamaError + + config = OllamaChatConfig() + + ollama_response = {"done": True, "done_reason": "stop"} + mock_response = MagicMock() + mock_response.json.return_value = ollama_response + mock_response.text = json.dumps(ollama_response) + + model_response = ModelResponse() + model_response.choices = [Choices(message=Message(content=""), index=0)] + + with pytest.raises(OllamaError): + config.transform_response( + model="llama3.1", + raw_response=mock_response, + model_response=model_response, + logging_obj=MagicMock(), + request_data={}, + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + encoding=None, + api_key=None, + json_mode=False, + ) + + def test_non_streaming_tool_calls_only_completion_tokens(self): + """eval_count default must not be evaluated eagerly against a null content. + + When Ollama returns tool_calls with content=None and omits eval_count, the + old eager default computed token_counter(text=response_json["message"]["content"]) + against None. It must fall back to counting an empty string instead. + """ + config = OllamaChatConfig() + + ollama_response = { + "model": "llama3.1", + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + {"function": {"name": "get_weather", "arguments": {"city": "SF"}}} + ], + }, + "done": True, + "done_reason": "stop", + "prompt_eval_count": 5, + } + mock_response = MagicMock() + mock_response.json.return_value = ollama_response + mock_response.text = json.dumps(ollama_response) + + model_response = ModelResponse() + model_response.choices = [Choices(message=Message(content=""), index=0)] + + result = config.transform_response( + model="llama3.1", + raw_response=mock_response, + model_response=model_response, + logging_obj=MagicMock(), + request_data={}, + messages=[{"role": "user", "content": "Weather?"}], + optional_params={}, + litellm_params={}, + encoding=None, + api_key=None, + json_mode=False, + ) + + assert result.choices[0].finish_reason == "tool_calls" + assert result.usage.completion_tokens == 0 + assert result.usage.prompt_tokens == 5