diff --git a/litellm/llms/tinyfish/search/transformation.py b/litellm/llms/tinyfish/search/transformation.py index 1d75a9de459..34583cb57a6 100644 --- a/litellm/llms/tinyfish/search/transformation.py +++ b/litellm/llms/tinyfish/search/transformation.py @@ -65,9 +65,7 @@ class TinyfishSearchConfig(BaseSearchConfig): default_api_base=self.TINYFISH_API_BASE, ) if not resolved_key: - raise ValueError( - "TINYFISH_API_KEY is not set. Set `TINYFISH_API_KEY` environment variable." - ) + raise ValueError("TINYFISH_API_KEY is not set. Set `TINYFISH_API_KEY` environment variable.") return {**headers, "X-API-Key": resolved_key, "Accept": "application/json"} def get_complete_url( @@ -77,13 +75,9 @@ class TinyfishSearchConfig(BaseSearchConfig): data: dict[str, object] | list[dict[str, object]] | None = None, **kwargs: object, ) -> str: - resolved_base = ( - api_base or get_secret_str("TINYFISH_API_BASE") or self.TINYFISH_API_BASE - ) + resolved_base = api_base or get_secret_str("TINYFISH_API_BASE") or self.TINYFISH_API_BASE if isinstance(data, dict) and _TINYFISH_PARAMS_KEY in data: - validated_params = _UrlEncodableParams.validate_python( - data[_TINYFISH_PARAMS_KEY] - ) + validated_params = _UrlEncodableParams.validate_python(data[_TINYFISH_PARAMS_KEY]) return f"{resolved_base}?{urlencode(validated_params, doseq=True)}" return resolved_base @@ -119,9 +113,7 @@ class TinyfishSearchConfig(BaseSearchConfig): resolved_query = " ".join(query) if isinstance(query, list) else query try: - domains = _StrList.validate_python( - optional_params.get("search_domain_filter") - ) + domains = _StrList.validate_python(optional_params.get("search_domain_filter")) except (ValidationError, TypeError): domains = [] if domains: @@ -139,10 +131,9 @@ class TinyfishSearchConfig(BaseSearchConfig): raw_max = optional_params.get("max_results") if isinstance(raw_max, (int, float, str)): try: - self._caller_max_results = max( - 1, min(int(raw_max), _TINYFISH_RESULT_CAP) - ) - except (ValueError, TypeError): + self._caller_max_results = max(1, min(int(raw_max), _TINYFISH_RESULT_CAP)) + except (ValueError, TypeError, OverflowError): + # OverflowError covers int(float('inf')) and similar non-finite floats. verbose_logger.warning( "TinyFish Search: max_results=%r is not a valid integer; ignoring.", raw_max, @@ -235,9 +226,7 @@ class TinyfishSearchConfig(BaseSearchConfig): parsed = SearchResponse.model_validate(raw_json) except ValidationError as e: raise self._wrap_error( - error_message=( - f"Response shape does not match LiteLLM's SearchResponse schema: {e}" - ), + error_message=(f"Response shape does not match LiteLLM's SearchResponse schema: {e}"), status_code=raw_response.status_code, headers=dict(raw_response.headers), ) diff --git a/tests/search_tests/test_tinyfish_search.py b/tests/search_tests/test_tinyfish_search.py index aca28544513..2a9b0028e97 100644 --- a/tests/search_tests/test_tinyfish_search.py +++ b/tests/search_tests/test_tinyfish_search.py @@ -34,9 +34,7 @@ MOCK_TINYFISH_RESPONSE = { } -def _make_mock_response( - json_data: dict, status_code: int = 200, request_url: str | None = None -) -> MagicMock: +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 diff --git a/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py b/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py index 6373d774e45..ef30486e2ab 100644 --- a/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py +++ b/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py @@ -43,6 +43,7 @@ def _make_mock_response( headers: dict | None = None, ) -> MagicMock: import json as _json + mock = MagicMock() mock.status_code = status_code mock.headers = headers or {} @@ -99,53 +100,39 @@ class TestTinyfishSearchConfig: class TestTransformSearchRequest: def test_basic_query(self): config = TinyfishSearchConfig() - result = config.transform_search_request( - query="hello world", optional_params={} - ) + result = config.transform_search_request(query="hello world", optional_params={}) assert result == {"_tinyfish_params": {"query": "hello world"}} def test_list_query_joined(self): config = TinyfishSearchConfig() - result = config.transform_search_request( - query=["hello", "world"], optional_params={} - ) + result = config.transform_search_request(query=["hello", "world"], optional_params={}) assert result["_tinyfish_params"]["query"] == "hello world" def test_country_maps_to_location(self): config = TinyfishSearchConfig() - result = config.transform_search_request( - query="test", optional_params={"country": "US"} - ) + result = config.transform_search_request(query="test", optional_params={"country": "US"}) assert result["_tinyfish_params"]["location"] == "US" def test_max_results_not_sent_on_wire(self): # TinyFish doesn't honor max_results server-side; we apply it client-side # in transform_search_response. The querystring should be free of it. config = TinyfishSearchConfig() - result = config.transform_search_request( - query="test", optional_params={"max_results": 5} - ) + result = config.transform_search_request(query="test", optional_params={"max_results": 5}) assert "max_results" not in result["_tinyfish_params"] def test_max_results_clamped_upper_stored_on_self(self): config = TinyfishSearchConfig() - config.transform_search_request( - query="test", optional_params={"max_results": 100} - ) + config.transform_search_request(query="test", optional_params={"max_results": 100}) assert config._caller_max_results == 10 # TinyFish's natural cap def test_max_results_clamped_lower_stored_on_self(self): config = TinyfishSearchConfig() - config.transform_search_request( - query="test", optional_params={"max_results": 0} - ) + config.transform_search_request(query="test", optional_params={"max_results": 0}) assert config._caller_max_results == 1 def test_max_results_normal_stored_on_self(self): config = TinyfishSearchConfig() - config.transform_search_request( - query="test", optional_params={"max_results": 5} - ) + config.transform_search_request(query="test", optional_params={"max_results": 5}) assert config._caller_max_results == 5 def test_max_results_non_numeric_string_warns_and_skips(self, caplog): @@ -153,14 +140,24 @@ class TestTransformSearchRequest: # via warning and treats the value as if max_results wasn't set. config = TinyfishSearchConfig() with caplog.at_level("WARNING"): - result = config.transform_search_request( - query="test", optional_params={"max_results": "abc"} - ) + result = config.transform_search_request(query="test", optional_params={"max_results": "abc"}) assert config._caller_max_results is None assert "max_results" not in result["_tinyfish_params"] messages = [r.getMessage() for r in caplog.records] assert any("max_results" in m and "abc" in m for m in messages) + def test_max_results_infinity_float_warns_and_skips(self, caplog): + # `int(float('inf'))` raises OverflowError, not ValueError/TypeError. + # Guard must catch it so a caller passing math.inf gets the same + # warn-and-ignore behavior as other malformed values. + config = TinyfishSearchConfig() + with caplog.at_level("WARNING"): + result = config.transform_search_request(query="test", optional_params={"max_results": float("inf")}) + assert config._caller_max_results is None + assert "max_results" not in result["_tinyfish_params"] + messages = [r.getMessage() for r in caplog.records] + assert any("max_results" in m for m in messages) + def test_domain_filter_appends_site_operators(self): config = TinyfishSearchConfig() result = config.transform_search_request( @@ -174,23 +171,17 @@ class TestTransformSearchRequest: def test_domain_filter_empty_list_ignored(self): config = TinyfishSearchConfig() - result = config.transform_search_request( - query="test", optional_params={"search_domain_filter": []} - ) + result = config.transform_search_request(query="test", optional_params={"search_domain_filter": []}) assert result["_tinyfish_params"]["query"] == "test" def test_domain_filter_non_list_ignored(self): config = TinyfishSearchConfig() - result = config.transform_search_request( - query="test", optional_params={"search_domain_filter": "not-a-list"} - ) + result = config.transform_search_request(query="test", optional_params={"search_domain_filter": "not-a-list"}) assert result["_tinyfish_params"]["query"] == "test" def test_unknown_params_passed_through(self): config = TinyfishSearchConfig() - result = config.transform_search_request( - query="test", optional_params={"language": "en", "page": 2} - ) + result = config.transform_search_request(query="test", optional_params={"language": "en", "page": 2}) params = result["_tinyfish_params"] assert params["language"] == "en" assert params["page"] == 2 @@ -200,9 +191,7 @@ class TestTransformSearchRequest: supported = config.get_supported_perplexity_optional_params() if supported: param = next(p for p in supported if p != "max_results" and p != "country") - result = config.transform_search_request( - query="test", optional_params={param: "value"} - ) + result = config.transform_search_request(query="test", optional_params={param: "value"}) assert param not in result["_tinyfish_params"] def test_arbitrary_param_passed_through(self): @@ -210,9 +199,7 @@ class TestTransformSearchRequest: # The passthrough loop should forward it verbatim without LiteLLM needing # to know about it. config = TinyfishSearchConfig() - result = config.transform_search_request( - query="test", optional_params={"fetch": "{}"} - ) + result = config.transform_search_request(query="test", optional_params={"fetch": "{}"}) assert result["_tinyfish_params"]["fetch"] == "{}" def test_dict_param_auto_json_encoded(self): @@ -230,12 +217,8 @@ class TestTransformSearchRequest: # rejects those (e.g. include_thumbnail must be literal "true"/"false"). # Normalize before passing through. config = TinyfishSearchConfig() - true_result = config.transform_search_request( - query="test", optional_params={"include_thumbnail": True} - ) - false_result = config.transform_search_request( - query="test", optional_params={"include_thumbnail": False} - ) + true_result = config.transform_search_request(query="test", optional_params={"include_thumbnail": True}) + false_result = config.transform_search_request(query="test", optional_params={"include_thumbnail": False}) assert true_result["_tinyfish_params"]["include_thumbnail"] == "true" assert false_result["_tinyfish_params"]["include_thumbnail"] == "false" @@ -243,9 +226,7 @@ class TestTransformSearchRequest: # If the caller already JSON-encoded, don't re-encode. config = TinyfishSearchConfig() already = '{"format":"html"}' - result = config.transform_search_request( - query="test", optional_params={"fetch": already} - ) + result = config.transform_search_request(query="test", optional_params={"fetch": already}) assert result["_tinyfish_params"]["fetch"] == already @@ -261,9 +242,7 @@ class TestGetCompleteUrl: def test_custom_api_base(self): config = TinyfishSearchConfig() - url = config.get_complete_url( - api_base="https://custom.api.tinyfish.ai", optional_params={} - ) + url = config.get_complete_url(api_base="https://custom.api.tinyfish.ai", optional_params={}) assert url == "https://custom.api.tinyfish.ai" def test_env_api_base(self): @@ -296,9 +275,7 @@ class TestGetCompleteUrl: "litellm.llms.tinyfish.search.transformation.get_secret_str", return_value=None, ): - url = config.get_complete_url( - api_base=None, optional_params={}, data={"other": "value"} - ) + url = config.get_complete_url(api_base=None, optional_params={}, data={"other": "value"}) assert url == "https://api.search.tinyfish.ai" def test_data_none(self): @@ -315,23 +292,17 @@ class TestTransformSearchResponse: def test_basic_response(self): config = TinyfishSearchConfig() mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE) - result = config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + result = config.transform_search_response(raw_response=mock_response, logging_obj=None) assert result.object == "search" assert len(result.results) == 2 assert result.results[0].title == "TinyFish - AI Web Automation" assert result.results[0].url == "https://tinyfish.ai" - assert ( - result.results[0].snippet == "Automate any website with natural language." - ) + assert result.results[0].snippet == "Automate any website with natural language." def test_empty_results(self): config = TinyfishSearchConfig() mock_response = _make_mock_response({"results": []}) - result = config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + result = config.transform_search_response(raw_response=mock_response, logging_obj=None) assert result.object == "search" assert len(result.results) == 0 @@ -350,9 +321,7 @@ class TestTransformSearchResponse: ] } mock_response = _make_mock_response(many_results) - result = config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + 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" @@ -371,9 +340,7 @@ class TestTransformSearchResponse: ] } mock_response = _make_mock_response(many_results) - result = config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + result = config.transform_search_response(raw_response=mock_response, logging_obj=None) assert len(result.results) == 10 def test_missing_required_fields_default_to_empty_string(self): @@ -382,9 +349,7 @@ class TestTransformSearchResponse: # flows through instead of failing the whole call. config = TinyfishSearchConfig() mock_response = _make_mock_response({"results": [{}, {"title": None, "url": None, "snippet": None}]}) - result = config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + result = config.transform_search_response(raw_response=mock_response, logging_obj=None) assert len(result.results) == 2 for r in result.results: assert r.title == "" @@ -394,9 +359,7 @@ class TestTransformSearchResponse: def test_extra_per_result_fields_surface_as_attributes(self): config = TinyfishSearchConfig() mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE) - result = config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + result = config.transform_search_response(raw_response=mock_response, logging_obj=None) first = result.results[0] assert getattr(first, "position", None) == 1 assert getattr(first, "site_name", None) == "tinyfish.ai" @@ -422,9 +385,7 @@ class TestTransformSearchResponse: ] } mock_response = _make_mock_response(fetched) - result = config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + result = config.transform_search_response(raw_response=mock_response, logging_obj=None) first = result.results[0] fetch_field = getattr(first, "fetch", None) assert isinstance(fetch_field, dict) @@ -433,9 +394,7 @@ class TestTransformSearchResponse: def test_no_request_uses_default_max_results(self): config = TinyfishSearchConfig() mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE) - result = config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + result = config.transform_search_response(raw_response=mock_response, logging_obj=None) assert len(result.results) == 2 def test_parameter_warnings_reader_emits_log_lines(self, caplog): @@ -458,9 +417,7 @@ class TestTransformSearchResponse: } mock_response = _make_mock_response(body) with caplog.at_level("WARNING"): - config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + config.transform_search_response(raw_response=mock_response, logging_obj=None) messages = [r.getMessage() for r in caplog.records] assert any("max_tokens_per_page" in m for m in messages) # The type is included in the message so agents can branch on it. @@ -471,12 +428,8 @@ class TestTransformSearchResponse: config = TinyfishSearchConfig() mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE) with caplog.at_level("WARNING"): - config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) - assert not any( - "TinyFish Search ignored" in r.getMessage() for r in caplog.records - ) + config.transform_search_response(raw_response=mock_response, logging_obj=None) + assert not any("TinyFish Search ignored" in r.getMessage() for r in caplog.records) def test_parameter_warnings_malformed_shapes_never_throw(self): # Every shape that doesn't match {parameter: str, message: str} should @@ -486,33 +439,29 @@ class TestTransformSearchResponse: good_results = [{"title": "x", "url": "https://x", "snippet": "x"}] malformed_field_values = [ - "not a list", # string - 42, # int - {"parameter": "x", "message": "y"}, # dict instead of list - True, # bool + "not a list", # string + 42, # int + {"parameter": "x", "message": "y"}, # dict instead of list + True, # bool ] for bad_value in malformed_field_values: body = {"results": good_results, "parameter_warnings": bad_value} - config.transform_search_response( - raw_response=_make_mock_response(body), logging_obj=None - ) # must not raise + config.transform_search_response(raw_response=_make_mock_response(body), logging_obj=None) # must not raise malformed_entries = [ - "string in list", # non-dict - 42, # int - {}, # missing all - {"type": "unsupported", "parameter": "x"}, # missing message - {"type": "unsupported", "message": "y"}, # missing parameter - {"parameter": "x", "message": "y"}, # missing type - {"type": "unsupported", "parameter": None, "message": "y"}, # null parameter - {"type": "unsupported", "parameter": "x", "message": ""}, # empty message - {"type": "unsupported", "parameter": 42, "message": "y"}, # non-string parameter - {"type": 1, "parameter": "x", "message": "y"}, # non-string type + "string in list", # non-dict + 42, # int + {}, # missing all + {"type": "unsupported", "parameter": "x"}, # missing message + {"type": "unsupported", "message": "y"}, # missing parameter + {"parameter": "x", "message": "y"}, # missing type + {"type": "unsupported", "parameter": None, "message": "y"}, # null parameter + {"type": "unsupported", "parameter": "x", "message": ""}, # empty message + {"type": "unsupported", "parameter": 42, "message": "y"}, # non-string parameter + {"type": 1, "parameter": "x", "message": "y"}, # non-string type ] body = {"results": good_results, "parameter_warnings": malformed_entries} - config.transform_search_response( - raw_response=_make_mock_response(body), logging_obj=None - ) # must not raise + config.transform_search_response(raw_response=_make_mock_response(body), logging_obj=None) # must not raise def test_parameter_warnings_malformed_entries_emit_nothing(self, caplog): config = TinyfishSearchConfig() @@ -525,13 +474,11 @@ class TestTransformSearchResponse: "parameter": "valid_one", "message": "actual msg", }, # ok — emitted - {"parameter": "x", "message": "y"}, # missing type — skipped + {"parameter": "x", "message": "y"}, # missing type — skipped ], } with caplog.at_level("WARNING"): - config.transform_search_response( - raw_response=_make_mock_response(body), logging_obj=None - ) + config.transform_search_response(raw_response=_make_mock_response(body), logging_obj=None) messages = [r.getMessage() for r in caplog.records] assert sum("parameter_warning" in m for m in messages) == 1 assert any("valid_one" in m for m in messages) @@ -550,9 +497,7 @@ class TestErrorHandling: } mock_response = _make_mock_response(body, status_code=400) with pytest.raises(Exception) as exc_info: - config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + config.transform_search_response(raw_response=mock_response, logging_obj=None) msg = str(exc_info.value) assert "TinyFish Search:" in msg assert "query is required" in msg @@ -562,13 +507,9 @@ class TestErrorHandling: def test_429_preserves_status_code_and_headers(self): config = TinyfishSearchConfig() body = {"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "60 rpm"}} - mock_response = _make_mock_response( - body, status_code=429, headers={"Retry-After": "60"} - ) + mock_response = _make_mock_response(body, status_code=429, headers={"Retry-After": "60"}) with pytest.raises(Exception) as exc_info: - config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + config.transform_search_response(raw_response=mock_response, logging_obj=None) assert getattr(exc_info.value, "status_code", None) == 429 headers = getattr(exc_info.value, "headers", {}) or {} assert headers.get("Retry-After") == "60" @@ -579,9 +520,7 @@ class TestErrorHandling: body = {"errors": [{"code": "10000", "message": "Internal"}]} mock_response = _make_mock_response(body, status_code=502) with pytest.raises(Exception) as exc_info: - config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + config.transform_search_response(raw_response=mock_response, logging_obj=None) msg = str(exc_info.value) assert "TinyFish Search:" in msg # The raw JSON body string should appear in the message verbatim. @@ -589,13 +528,9 @@ class TestErrorHandling: def test_non_json_4xx_body_uses_raw_text(self): config = TinyfishSearchConfig() - mock_response = _make_mock_response( - json_data=None, status_code=502, text="Bad Gateway" - ) + mock_response = _make_mock_response(json_data=None, status_code=502, text="Bad Gateway") with pytest.raises(Exception) as exc_info: - config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + config.transform_search_response(raw_response=mock_response, logging_obj=None) msg = str(exc_info.value) assert "TinyFish Search:" in msg assert "Bad Gateway" in msg @@ -603,13 +538,9 @@ class TestErrorHandling: def test_non_json_200_body_routes_through_get_error_class(self): # 200 but the body isn't JSON (degraded backend, CDN-injected page, etc.) config = TinyfishSearchConfig() - mock_response = _make_mock_response( - json_data=None, status_code=200, text="not json" - ) + mock_response = _make_mock_response(json_data=None, status_code=200, text="not json") with pytest.raises(Exception) as exc_info: - config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + config.transform_search_response(raw_response=mock_response, logging_obj=None) msg = str(exc_info.value) assert "TinyFish Search:" in msg assert "Expected JSON response" in msg @@ -620,9 +551,7 @@ class TestErrorHandling: # they hit BaseSearchConfig.get_error_class via LiteLLM core. config = TinyfishSearchConfig() body = '{"error": {"code": "UNAUTHORIZED", "message": "bad key"}}' - exc = config._wrap_error( - error_message=body, status_code=401, headers={"x": "y"} - ) + exc = config._wrap_error(error_message=body, status_code=401, headers={"x": "y"}) msg = str(exc) assert "TinyFish Search:" in msg assert "bad key" in msg @@ -636,9 +565,7 @@ class TestErrorHandling: config = TinyfishSearchConfig() mock_response = _make_mock_response({"query": "x"}) # no `results` key with pytest.raises(Exception) as exc_info: - config.transform_search_response( - raw_response=mock_response, logging_obj=None - ) + config.transform_search_response(raw_response=mock_response, logging_obj=None) msg = str(exc_info.value) assert "TinyFish Search:" in msg assert "schema" in msg.lower()