fix(ollama_chat): surface structured Ollama errors instead of KeyError/APIConnectionError

This commit is contained in:
Devin AI 2026-07-17 01:12:25 +00:00
parent 224fe67f10
commit 0802051d07
4 changed files with 276 additions and 4 deletions

View file

@ -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(

View file

@ -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:

View file

@ -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)

View file

@ -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