mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
fix(search): propagate GET provider HTTP errors (#40779)
This commit is contained in:
parent
09b694894d
commit
d51a7af655
4 changed files with 114 additions and 2 deletions
|
|
@ -264,6 +264,13 @@ class BaseSearchConfig:
|
|||
"""
|
||||
raise NotImplementedError("transform_search_response must be implemented by provider")
|
||||
|
||||
def get_http_error_class(self, error: httpx.HTTPStatusError) -> Exception:
|
||||
return self.get_error_class(
|
||||
error_message=error.response.text,
|
||||
status_code=error.response.status_code,
|
||||
headers=dict(error.response.headers), # mutable-ok: provider error factories require dict headers
|
||||
)
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
|
|
|
|||
|
|
@ -1918,6 +1918,7 @@ class BaseLLMHTTPHandler:
|
|||
url=complete_url,
|
||||
headers=signed_headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
else:
|
||||
# A signed body must be sent verbatim, re-serializing it would break the signature
|
||||
response = client.post(
|
||||
|
|
@ -1927,6 +1928,8 @@ class BaseLLMHTTPHandler:
|
|||
json=data if signed_json_body is None else None,
|
||||
timeout=timeout,
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise provider_config.get_http_error_class(e)
|
||||
except Exception as e:
|
||||
raise self._handle_error(e=e, provider_config=provider_config)
|
||||
|
||||
|
|
@ -2019,6 +2022,7 @@ class BaseLLMHTTPHandler:
|
|||
url=complete_url,
|
||||
headers=signed_headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
else:
|
||||
# A signed body must be sent verbatim, re-serializing it would break the signature
|
||||
response = await async_httpx_client.post(
|
||||
|
|
@ -2028,6 +2032,8 @@ class BaseLLMHTTPHandler:
|
|||
json=data if signed_json_body is None else None,
|
||||
timeout=timeout,
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise provider_config.get_http_error_class(e)
|
||||
except Exception as e:
|
||||
raise self._handle_error(e=e, provider_config=provider_config)
|
||||
|
||||
|
|
|
|||
|
|
@ -247,6 +247,13 @@ class TinyfishSearchConfig(BaseSearchConfig):
|
|||
hidden["additional_headers"] = process_response_headers(raw_headers)
|
||||
return parsed
|
||||
|
||||
def get_http_error_class(self, error: httpx.HTTPStatusError) -> Exception:
|
||||
return self._wrap_error(
|
||||
error_message=error.response.text,
|
||||
status_code=error.response.status_code,
|
||||
headers=dict(error.response.headers), # mutable-ok: existing error wrapper requires dict headers
|
||||
)
|
||||
|
||||
def _wrap_error(
|
||||
self,
|
||||
error_message: str,
|
||||
|
|
@ -256,8 +263,7 @@ class TinyfishSearchConfig(BaseSearchConfig):
|
|||
"""
|
||||
Build an attributed ``BaseLLMException`` from a TinyFish error body.
|
||||
|
||||
Used only at the call sites we control inside
|
||||
``transform_search_response`` (non-2xx, JSONDecodeError, ValidationError).
|
||||
Used for HTTP status errors and response transformation errors.
|
||||
Not an override of ``BaseSearchConfig.get_error_class``: that path is
|
||||
left to inherit from the base so it auto-picks-up any future LiteLLM
|
||||
improvements. Trade-off: network failures (routed through LiteLLM
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import json
|
|||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
|
|
@ -20,7 +21,9 @@ from litellm.llms.base_llm.audio_transcription.transformation import (
|
|||
BaseAudioTranscriptionConfig,
|
||||
)
|
||||
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
|
||||
from litellm.llms.base_llm.search.transformation import BaseSearchConfig, SearchResponse
|
||||
from litellm.llms.bedrock.base_aws_llm import SignsRequestsWithAWS
|
||||
from litellm.llms.brave.search.transformation import BraveSearchConfig
|
||||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.llms.custom_httpx.llm_http_handler import (
|
||||
|
|
@ -36,6 +39,7 @@ from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_tran
|
|||
)
|
||||
from litellm.llms.mistral.ocr.transformation import MistralOCRConfig
|
||||
from litellm.llms.openai.videos.transformation import OpenAIVideoConfig
|
||||
from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import ImageObject, ImageResponse, ModelResponse, TranscriptionResponse
|
||||
|
|
@ -44,6 +48,95 @@ from tests.test_litellm.llms.bedrock.event_loop_probe import EventLoopProbe
|
|||
_ACTIVE_KEY = "_code_interpreter_interception_active"
|
||||
_SANDBOX_KEY = "_code_interpreter_interception_sandbox_key"
|
||||
|
||||
|
||||
async def _get_search_with_client(
|
||||
client: HTTPHandler | AsyncHTTPHandler, provider_config: BaseSearchConfig | None = None
|
||||
) -> SearchResponse:
|
||||
result: Final = BaseLLMHTTPHandler().search(
|
||||
query="test",
|
||||
optional_params={},
|
||||
timeout=5,
|
||||
logging_obj=Mock(),
|
||||
api_key="test-key",
|
||||
api_base="https://search.example.test/",
|
||||
custom_llm_provider="tinyfish" if isinstance(provider_config, TinyfishSearchConfig) else "brave",
|
||||
client=client,
|
||||
asearch=isinstance(client, AsyncHTTPHandler),
|
||||
provider_config=provider_config or BraveSearchConfig(),
|
||||
)
|
||||
return await result if asyncio.iscoroutine(result) else result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("is_async", (False, True))
|
||||
@pytest.mark.parametrize("status_code", (400, 401, 403, 422, 429, 500))
|
||||
async def test_get_search_raises_provider_http_errors(is_async: bool, status_code: int) -> None:
|
||||
upstream_response: Final = httpx.Response(
|
||||
status_code, json={"error": "rejected request"}, headers={"retry-after": "7"}
|
||||
)
|
||||
transport: Final = httpx.MockTransport(lambda request: upstream_response)
|
||||
async with httpx.AsyncClient(transport=transport) as async_client:
|
||||
with httpx.Client(transport=transport) as sync_client:
|
||||
client: Final = AsyncHTTPHandler() if is_async else HTTPHandler(client=sync_client)
|
||||
if isinstance(client, AsyncHTTPHandler):
|
||||
await client.close()
|
||||
client.client = async_client
|
||||
with pytest.raises(BaseLLMException) as error:
|
||||
await _get_search_with_client(client)
|
||||
assert error.value.status_code == status_code
|
||||
assert "rejected request" in error.value.message
|
||||
assert error.value.headers is not None
|
||||
assert error.value.headers["retry-after"] == "7"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("is_async", (False, True))
|
||||
@pytest.mark.parametrize("has_results", (False, True))
|
||||
async def test_get_search_preserves_successful_results(is_async: bool, has_results: bool) -> None:
|
||||
results: Final = (
|
||||
[{"title": "Example", "url": "https://example.com", "description": "Example snippet"}] if has_results else []
|
||||
)
|
||||
transport: Final = httpx.MockTransport(lambda request: httpx.Response(200, json={"web": {"results": results}}))
|
||||
async with httpx.AsyncClient(transport=transport) as async_client:
|
||||
with httpx.Client(transport=transport) as sync_client:
|
||||
client: Final = AsyncHTTPHandler() if is_async else HTTPHandler(client=sync_client)
|
||||
if isinstance(client, AsyncHTTPHandler):
|
||||
await client.close()
|
||||
client.client = async_client
|
||||
response: Final = await _get_search_with_client(client)
|
||||
assert response.object == "search"
|
||||
assert len(response.results) == int(has_results)
|
||||
if has_results:
|
||||
assert response.results[0].title == "Example"
|
||||
assert response.results[0].url == "https://example.com"
|
||||
assert response.results[0].snippet == "Example snippet"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("is_async", (False, True))
|
||||
async def test_get_search_preserves_tinyfish_http_error_formatting(is_async: bool) -> None:
|
||||
upstream_response: Final = httpx.Response(
|
||||
429,
|
||||
json={"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "rate limit exceeded"}},
|
||||
headers={"retry-after": "7"},
|
||||
)
|
||||
transport: Final = httpx.MockTransport(lambda request: upstream_response)
|
||||
async with httpx.AsyncClient(transport=transport) as async_client:
|
||||
with httpx.Client(transport=transport) as sync_client:
|
||||
client: Final = AsyncHTTPHandler() if is_async else HTTPHandler(client=sync_client)
|
||||
if isinstance(client, AsyncHTTPHandler):
|
||||
await client.close()
|
||||
client.client = async_client
|
||||
with pytest.raises(BaseLLMException) as error:
|
||||
await _get_search_with_client(client, TinyfishSearchConfig())
|
||||
assert error.value.status_code == 429
|
||||
assert error.value.message == (
|
||||
"TinyFish Search: rate limit exceeded. See https://docs.tinyfish.ai/search-api for details."
|
||||
)
|
||||
assert error.value.headers is not None
|
||||
assert error.value.headers["retry-after"] == "7"
|
||||
|
||||
|
||||
OCR_RESPONSE = {
|
||||
"pages": [{"index": 0, "markdown": "OCR output", "images": []}],
|
||||
"model": "mistral-ocr-latest",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue