litellm/tests/search_tests/test_tinyfish_search.py
tin-berri d0e785140c
feat(tinyfish): make search provider permissive, attribute errors (#31997)
* 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>
2026-07-03 10:17:11 -07:00

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={})