mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
fix(ollama_chat): surface structured Ollama errors instead of KeyError/APIConnectionError
This commit is contained in:
parent
224fe67f10
commit
0802051d07
4 changed files with 276 additions and 4 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue