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