mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
* feat(tinyfish): make search provider permissive, attribute errors
Reshapes the TinyFish search provider so LiteLLM mirrors the TinyFish
Search API surface instead of maintaining a parallel cherry-pick.
Request side:
- Drop misleading request TypedDict
- Stop sending max_results on wire (TinyFish ignores it); clamp to [1,10]
client-side via self-threaded state
- Guard non-numeric max_results from bare ValueError
- Auto-JSON-encode dict params; lowercase bool serialization for ux-labs
Response side:
- Drop both Pydantic response models; parse directly into SearchResponse
so per-result extras flow through via extra="allow"
- Default missing title/url/snippet to "" instead of failing the call
- Read top-level parameter_warnings and re-fire as verbose_logger.warning
(pre-wired for upcoming TinyFish-side rollout; no-op today)
Error handling:
- Attributed _wrap_error helper at 3 call sites in transform_search_response
("TinyFish Search: <msg>. See https://docs.tinyfish.ai/search-api for
details.")
- Dispatch non-2xx responses through _wrap_error (fixes pre-existing bug
where 4xx/5xx silently returned empty SearchResponse)
- Wrap json.JSONDecodeError on 200 bodies
- Wrap pydantic.ValidationError for envelope-shape mismatches
Bug fix worth flagging: 4xx/5xx responses now raise an attributed
BaseLLMException instead of silently returning SearchResponse(results=[]).
Follow-up to #30634.
* fix(tinyfish): apply ruff format; guard OverflowError in max_results clamp
- Run ruff format on the touched files (CI lint job rejected the prior
commit's formatting).
- Add OverflowError to the except clause in the max_results clamp so
callers passing math.inf (or other non-finite floats) get the same
warn-and-ignore behavior as other malformed values. Greptile spotted
this in the first-pass review.
- Add test_max_results_infinity_float_warns_and_skips covering the
inf case.
* fix(tinyfish): apply --line-length 88 ruff format to match CI
CI uses 'ruff format --check --line-length 88'; my prior format pass
used the default line length, leaving several lines unwrapped. No
behavior change — purely whitespace.
* fix(tinyfish): reduce transform_search_response complexity; sort imports
CI's ruff strict-rule budget rejected the prior commit with:
- C901: transform_search_response complexity 16 > 10 (cap exceeded by 1)
- I001: import sort violation (cap exceeded by 1)
Extract two module-level helpers from transform_search_response to drop
its cyclomatic complexity:
- _default_missing_result_fields: in-place title/url/snippet defaulting
- _emit_parameter_warnings: defensive parameter_warnings reader
Auto-fix the import sort via ruff --fix.
No behavior change; the 59 existing tests still pass.
* test(tinyfish): cover defensive branches in _default_missing_result_fields
Codecov flagged 97.61% patch coverage (2 lines missing). The uncovered
lines were the non-dict raw_json and non-dict per-result item early-exits
in _default_missing_result_fields. Add two unit tests on the helper
directly to bring patch coverage to 100%.
* chore(tinyfish): apply ruff format to fix lint after staging merge
---------
Co-authored-by: Chenlu Ji <jichenlulu@gmail.com>
271 lines
8.9 KiB
Python
271 lines
8.9 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
|
|
) -> MagicMock:
|
|
mock = MagicMock()
|
|
mock.status_code = status_code
|
|
mock.json.return_value = json_data
|
|
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 tf-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_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={})
|