mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Follow-up to #31411 (superseded and merged as #31997). Two related fixes so LiteLLM callers see what TinyFish actually returns, plus small correctness cleanups. ## Response headers surfaced on _hidden_params TinyFish sets useful response headers (x-request-id on every response, retry-after and x-ratelimit-limit on 429s). Previously these were only accessible via BaseLLMException.headers on error paths; on the success path they were dropped entirely. Fix: stash headers on both LiteLLM-conventional channels, matching the pattern used by Gemini / Volcengine / Manus / ChatGPT / OpenAI-responses providers. - `_hidden_params["headers"]` -- raw dict from httpx, all keys lowercased. - `_hidden_params["additional_headers"]` -- passed through process_response_headers, which prefixes any x-litellm-* provider header with `llm_provider-` so downstream LiteLLM code that trusts bare x-litellm-* markers can't be spoofed (values still survive under the prefixed key for observability). ## Top-level response extras (query, total_results, page, future fields) transform_search_response was building a fresh SearchResponse from just `results`, silently dropping every top-level field TinyFish's response carries beyond `results` / `object`. Fix: mutate parsed.results to its truncated slice and return the same SearchResponse instance rather than reconstructing. Every field pydantic populated during model_validate -- declared attributes AND extras (query, total_results, page, parameter_warnings, and any future TinyFish additions) -- survives regardless of which storage bucket holds it. Robust against upstream schema evolution: if LiteLLM later promotes a field from extras to declared, this code needs no change. ## Code cleanup - List-valued custom params JSON-encoded on the wire (matching the existing dict handling), so callers can pass a natural Python list for JSON-array wire params. - URL-encodable-params adapter accepts float in addition to str / int / bool; server-side rejection of a wrong-typed float now surfaces cleanly with `TinyFish Search:` attribution + docs link. - Assorted comment / docstring / test-fixture hygiene (no logic changes). ## Tests 70 unit + integration tests pass locally. Live-tested against production TinyFish with 6 diverse queries (basic / max_results / country=US / language=ja / domain filter / fetch={"format":"html"}) -- all 6 pass every expected-behavior check.
328 lines
11 KiB
Python
328 lines
11 KiB
Python
"""
|
|
Tests for TinyFish Search API integration.
|
|
"""
|
|
|
|
import os
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
import litellm
|
|
|
|
MOCK_TINYFISH_RESPONSE = {
|
|
"query": "web automation tools",
|
|
"results": [
|
|
{
|
|
"position": 1,
|
|
"site_name": "tinyfish.ai",
|
|
"title": "TinyFish - AI Web Automation",
|
|
"snippet": "Automate any website with natural language.",
|
|
"url": "https://tinyfish.ai",
|
|
},
|
|
{
|
|
"position": 2,
|
|
"site_name": "github.com",
|
|
"title": "Top Web Automation Tools",
|
|
"snippet": "A curated list of browser automation frameworks.",
|
|
"url": "https://github.com/example/web-automation",
|
|
},
|
|
],
|
|
"total_results": 2,
|
|
"page": 0,
|
|
}
|
|
|
|
|
|
def _make_mock_response(
|
|
json_data: dict,
|
|
status_code: int = 200,
|
|
request_url: str | None = None,
|
|
headers: dict | None = None,
|
|
) -> MagicMock:
|
|
mock = MagicMock()
|
|
mock.status_code = status_code
|
|
mock.json.return_value = json_data
|
|
# httpx.Headers normalizes keys to lowercase — mirror production behavior.
|
|
mock.headers = httpx.Headers(headers or {})
|
|
if request_url:
|
|
mock.request = MagicMock()
|
|
mock.request.url = httpx.URL(request_url)
|
|
else:
|
|
mock.request = None
|
|
return mock
|
|
|
|
|
|
class TestTinyfishSearch:
|
|
@pytest.mark.asyncio
|
|
async def test_basic_search(self):
|
|
os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test"
|
|
|
|
mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
|
new_callable=AsyncMock,
|
|
) as mock_get:
|
|
mock_get.return_value = mock_response
|
|
|
|
response = await litellm.asearch(
|
|
query="web automation tools",
|
|
search_provider="tinyfish",
|
|
)
|
|
|
|
assert mock_get.call_count == 1
|
|
|
|
call_args = mock_get.call_args
|
|
parsed_url = urlparse(call_args.kwargs["url"])
|
|
assert parsed_url.scheme == "https"
|
|
assert parsed_url.netloc == "api.search.tinyfish.ai"
|
|
assert parsed_url.path == ""
|
|
|
|
query_params = parse_qs(parsed_url.query)
|
|
assert query_params["query"] == ["web automation tools"]
|
|
|
|
headers = call_args.kwargs.get("headers", {})
|
|
assert headers["X-API-Key"] == "sk-tinyfish-test"
|
|
|
|
assert hasattr(response, "results")
|
|
assert response.object == "search"
|
|
assert len(response.results) == 2
|
|
|
|
first = response.results[0]
|
|
assert first.title == "TinyFish - AI Web Automation"
|
|
assert first.url == "https://tinyfish.ai"
|
|
assert first.snippet == "Automate any website with natural language."
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_country_maps_to_location(self):
|
|
os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test"
|
|
|
|
mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
|
new_callable=AsyncMock,
|
|
) as mock_get:
|
|
mock_get.return_value = mock_response
|
|
|
|
await litellm.asearch(
|
|
query="test",
|
|
search_provider="tinyfish",
|
|
country="US",
|
|
)
|
|
|
|
call_args = mock_get.call_args
|
|
parsed_url = urlparse(call_args.kwargs["url"])
|
|
query_params = parse_qs(parsed_url.query)
|
|
assert query_params["location"] == ["US"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_domain_filter_injection(self):
|
|
os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test"
|
|
|
|
mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
|
new_callable=AsyncMock,
|
|
) as mock_get:
|
|
mock_get.return_value = mock_response
|
|
|
|
await litellm.asearch(
|
|
query="python tutorials",
|
|
search_provider="tinyfish",
|
|
search_domain_filter=["arxiv.org", "github.com"],
|
|
)
|
|
|
|
call_args = mock_get.call_args
|
|
parsed_url = urlparse(call_args.kwargs["url"])
|
|
query_params = parse_qs(parsed_url.query)
|
|
query_value = query_params["query"][0]
|
|
assert "site:arxiv.org" in query_value
|
|
assert "site:github.com" in query_value
|
|
assert "python tutorials" in query_value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_language_passthrough(self):
|
|
os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test"
|
|
|
|
mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
|
new_callable=AsyncMock,
|
|
) as mock_get:
|
|
mock_get.return_value = mock_response
|
|
|
|
await litellm.asearch(
|
|
query="test",
|
|
search_provider="tinyfish",
|
|
language="en",
|
|
)
|
|
|
|
call_args = mock_get.call_args
|
|
parsed_url = urlparse(call_args.kwargs["url"])
|
|
query_params = parse_qs(parsed_url.query)
|
|
assert query_params["language"] == ["en"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_param_round_trip(self):
|
|
# End-to-end check: caller passes `fetch=...` (JSON-encoded fetch
|
|
# config); param reaches TinyFish on the request side and the nested
|
|
# `fetch` object on each result surfaces back to the SearchResult on the
|
|
# response side. No LiteLLM-side support code is required.
|
|
os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test"
|
|
|
|
fetched_response = {
|
|
"results": [
|
|
{
|
|
"title": "TinyFish",
|
|
"url": "https://tinyfish.ai",
|
|
"snippet": "Web automation.",
|
|
"fetch": {
|
|
"url": "https://tinyfish.ai",
|
|
"title": "TinyFish",
|
|
"text": "Page body text.",
|
|
"cached": False,
|
|
},
|
|
}
|
|
]
|
|
}
|
|
mock_response = _make_mock_response(fetched_response)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
|
new_callable=AsyncMock,
|
|
) as mock_get:
|
|
mock_get.return_value = mock_response
|
|
|
|
response = await litellm.asearch(
|
|
query="tinyfish",
|
|
search_provider="tinyfish",
|
|
fetch="{}",
|
|
)
|
|
|
|
call_args = mock_get.call_args
|
|
parsed_url = urlparse(call_args.kwargs["url"])
|
|
query_params = parse_qs(parsed_url.query)
|
|
assert query_params["fetch"] == ["{}"]
|
|
|
|
first = response.results[0]
|
|
fetch_field = getattr(first, "fetch", None)
|
|
assert isinstance(fetch_field, dict)
|
|
assert fetch_field["text"] == "Page body text."
|
|
|
|
def test_max_results_truncates_response(self):
|
|
from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig
|
|
|
|
config = TinyfishSearchConfig()
|
|
# max_results is threaded through self by transform_search_request;
|
|
# simulate that for this direct response-side test.
|
|
config._caller_max_results = 3
|
|
many_results = {
|
|
"results": [
|
|
{
|
|
"title": f"Result {i}",
|
|
"url": f"https://example.com/{i}",
|
|
"snippet": f"Snippet {i}",
|
|
}
|
|
for i in range(10)
|
|
]
|
|
}
|
|
mock_response = _make_mock_response(many_results)
|
|
|
|
result = config.transform_search_response(
|
|
raw_response=mock_response,
|
|
logging_obj=None,
|
|
)
|
|
assert len(result.results) == 3
|
|
assert result.results[0].title == "Result 0"
|
|
assert result.results[2].title == "Result 2"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_top_level_extras_surface_end_to_end(self):
|
|
# Envelope extras (`query`, `total_results`, `page`) must survive the
|
|
# full asearch dispatch — proves LiteLLM's entry-point plumbing outside
|
|
# our transformer doesn't accidentally strip them.
|
|
os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test"
|
|
|
|
mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
|
new_callable=AsyncMock,
|
|
) as mock_get:
|
|
mock_get.return_value = mock_response
|
|
|
|
response = await litellm.asearch(
|
|
query="web automation tools",
|
|
search_provider="tinyfish",
|
|
)
|
|
|
|
assert getattr(response, "query", None) == "web automation tools"
|
|
assert getattr(response, "total_results", None) == 2
|
|
assert getattr(response, "page", None) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_response_headers_surface_end_to_end(self):
|
|
# Response headers must land on `_hidden_params` after the full
|
|
# asearch dispatch (both raw and sanitized channels).
|
|
os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test"
|
|
|
|
mock_response = _make_mock_response(
|
|
MOCK_TINYFISH_RESPONSE,
|
|
headers={"X-Request-ID": "req-e2e-1"},
|
|
)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
|
new_callable=AsyncMock,
|
|
) as mock_get:
|
|
mock_get.return_value = mock_response
|
|
|
|
response = await litellm.asearch(
|
|
query="test",
|
|
search_provider="tinyfish",
|
|
)
|
|
|
|
raw = response._hidden_params["headers"]
|
|
add = response._hidden_params["additional_headers"]
|
|
# httpx lowercases; both channels agree on the value.
|
|
assert raw["x-request-id"] == "req-e2e-1"
|
|
assert add["llm_provider-x-request-id"] == "req-e2e-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_results(self):
|
|
os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test"
|
|
|
|
empty_response = {
|
|
"query": "xyznonexistent",
|
|
"results": [],
|
|
"total_results": 0,
|
|
"page": 0,
|
|
}
|
|
mock_response = _make_mock_response(empty_response)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
|
new_callable=AsyncMock,
|
|
) as mock_get:
|
|
mock_get.return_value = mock_response
|
|
|
|
response = await litellm.asearch(
|
|
query="xyznonexistent",
|
|
search_provider="tinyfish",
|
|
)
|
|
|
|
assert response.object == "search"
|
|
assert len(response.results) == 0
|
|
|
|
def test_missing_api_key(self):
|
|
os.environ.pop("TINYFISH_API_KEY", None)
|
|
|
|
from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig
|
|
|
|
config = TinyfishSearchConfig()
|
|
with pytest.raises(ValueError, match="TINYFISH_API_KEY"):
|
|
config.validate_environment(headers={})
|