From b86411f84530b425de1f8ae1cbbb09fc483a2d07 Mon Sep 17 00:00:00 2001 From: Chenlu Ji Date: Thu, 25 Jun 2026 23:06:51 -0700 Subject: [PATCH 1/6] 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: . 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. --- .../llms/tinyfish/search/transformation.py | 261 +++++++++--- tests/search_tests/test_tinyfish_search.py | 55 ++- .../llms/tinyfish/test_tinyfish_search.py | 378 ++++++++++++++++-- 3 files changed, 606 insertions(+), 88 deletions(-) diff --git a/litellm/llms/tinyfish/search/transformation.py b/litellm/llms/tinyfish/search/transformation.py index 4a95f5196465..1d75a9de4595 100644 --- a/litellm/llms/tinyfish/search/transformation.py +++ b/litellm/llms/tinyfish/search/transformation.py @@ -6,53 +6,43 @@ from __future__ import annotations -from typing import Literal, TypedDict +import json +from typing import Literal from urllib.parse import urlencode import httpx -from pydantic import BaseModel, TypeAdapter, ValidationError +from pydantic import TypeAdapter, ValidationError +from litellm._logging import verbose_logger from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.search.transformation import ( BaseSearchConfig, SearchResponse, - SearchResult, ) from litellm.secret_managers.main import get_secret_str -class _TinyfishSearchRequestRequired(TypedDict): - query: str - - -class TinyfishSearchRequest(_TinyfishSearchRequestRequired, total=False): - location: str - language: str - page: int - include_thumbnail: bool - max_results: int - - -class _TinyfishResultItem(BaseModel, frozen=True): - title: str = "" - url: str = "" - snippet: str = "" - - -class _TinyfishApiResponse(BaseModel, frozen=True): - results: tuple[_TinyfishResultItem, ...] = () - - _UrlEncodableParams = TypeAdapter(dict[str, str | int | bool]) _StrList = TypeAdapter(list[str]) _StrFrozenSet = TypeAdapter(frozenset[str]) _TINYFISH_PARAMS_KEY = "_tinyfish_params" +_TINYFISH_DOCS_URL = "https://docs.tinyfish.ai/search-api" +_TINYFISH_RESULT_CAP = 10 # TinyFish's natural per-page SERP ceiling class TinyfishSearchConfig(BaseSearchConfig): TINYFISH_API_BASE = "https://api.search.tinyfish.ai" + def __init__(self) -> None: + super().__init__() + # Threaded from transform_search_request → transform_search_response so the + # response slice honors the caller's max_results without re-sending it on + # the wire (TinyFish doesn't honor it server-side). Safe because the + # config is instantiated per-call via ProviderConfigManager. + self._caller_max_results: int | None = None + @staticmethod def ui_friendly_name() -> str: return "TinyFish" @@ -103,18 +93,31 @@ def transform_search_request( optional_params: dict[str, object], **kwargs: object, ) -> dict[str, object]: + """ + Transform a LiteLLM search request to TinyFish's querystring format. + + Maps LiteLLM's unified-spec params (see + ``BaseSearchConfig.get_supported_perplexity_optional_params``) to + TinyFish equivalents: + - ``query`` (str or list[str]) → ``query`` (list joined by spaces) + - ``country`` → ``location`` + - ``search_domain_filter`` (list[str]) → folded into the query as + ``() (site:a OR site:b ...)`` (TinyFish has no first-class + field today; see ML-2084 for the planned ``include_domains``) + - ``max_results`` → not sent on the wire; stashed on + ``self._caller_max_results`` for client-side response truncation + (TinyFish doesn't honor it server-side) + - ``max_tokens_per_page`` → silently dropped (no TinyFish equivalent) + + Any other ``optional_params`` keys are forwarded to TinyFish as-is. + dict/list values are JSON-encoded so they survive ``urlencode``. + + Returns: + ``{_TINYFISH_PARAMS_KEY: }``. + ``get_complete_url`` reads this back to build the final URL. + """ resolved_query = " ".join(query) if isinstance(query, list) else query - request_data: TinyfishSearchRequest = {"query": resolved_query} - - country = optional_params.get("country") - if isinstance(country, str): - request_data["location"] = country - - raw_max = optional_params.get("max_results") - if isinstance(raw_max, (int, float, str)): - request_data["max_results"] = max(1, min(int(raw_max), 20)) - try: domains = _StrList.validate_python( optional_params.get("search_domain_filter") @@ -122,21 +125,48 @@ def transform_search_request( except (ValidationError, TypeError): domains = [] if domains: - request_data["query"] = _append_domain_filters( - request_data["query"], domains - ) + resolved_query = _append_domain_filters(resolved_query, domains) - result_data: dict[str, object] = dict(request_data) + request_data: dict[str, object] = {"query": resolved_query} + + country = optional_params.get("country") + if isinstance(country, str): + request_data["location"] = country + + # max_results is enforced client-side on the response (TinyFish ignores + # the param and always returns ~10). Clamp to [1, 10] and stash on self + # so transform_search_response can slice without re-reading the URL. + 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): + verbose_logger.warning( + "TinyFish Search: max_results=%r is not a valid integer; ignoring.", + raw_max, + ) raw_supported: object = ( self.get_supported_perplexity_optional_params() # any-ok: base class returns bare set ) supported_perplexity = _StrFrozenSet.validate_python(raw_supported) for param, value in optional_params.items(): - if param not in supported_perplexity and param not in result_data: - result_data[param] = value - - return {_TINYFISH_PARAMS_KEY: result_data} + if param not in supported_perplexity and param not in request_data: + # `fetch` expects a JSON-encoded object on the wire; accept the + # natural Python dict form and serialize here so callers don't + # have to pre-stringify. + if isinstance(value, dict): + value = json.dumps(value, separators=(",", ":")) + # `urlencode` would render Python bool as "True"/"False" + # (capitalized). ux-labs validators require lowercase + # "true"/"false" (e.g. `include_thumbnail`); normalize here. + elif isinstance(value, bool): + value = "true" if value else "false" + request_data[param] = value + + return {_TINYFISH_PARAMS_KEY: request_data} def transform_search_response( self, @@ -144,23 +174,142 @@ def transform_search_response( logging_obj: LiteLLMLoggingObj | None, **kwargs: object, ) -> SearchResponse: - raw_json: object = raw_response.json() # any-ok: httpx Response.json() -> Any - parsed = _TinyfishApiResponse.model_validate(raw_json) + """ + Transform a TinyFish response to LiteLLM's unified ``SearchResponse``. + + Mappings (per-result): + - ``title`` → ``SearchResult.title`` (defaults to ``""`` if missing/null) + - ``url`` → ``SearchResult.url`` (defaults to ``""``) + - ``snippet`` → ``SearchResult.snippet`` (defaults to ``""``) + - all other per-result fields (``position``, ``site_name``, + ``thumbnail_url``, ``fetch``, ``fetch_error``, ...) ride through as + extras on ``SearchResult`` via its ``extra="allow"`` config. + + Top-level ``parameter_warnings`` (see ML-2085) is read when present and + each entry is re-fired via ``verbose_logger.warning``. Absent or + malformed entries are silently skipped — never throws. + + Error paths routed through ``self._wrap_error`` for uniform + ``"TinyFish Search: . See for details."`` wrapping: + - non-2xx HTTP status (caught here because ``AsyncHTTPHandler.get`` + does not call ``raise_for_status``) + - 200 with non-JSON body + - 200 with valid JSON whose shape doesn't satisfy ``SearchResponse`` + + Returns: + ``SearchResponse`` truncated to ``self._caller_max_results`` (or + ``_TINYFISH_RESULT_CAP`` when the caller didn't set ``max_results``). + """ + # AsyncHTTPHandler.get does not call raise_for_status, so non-2xx + # responses arrive here looking successful. Dispatch through + # get_error_class so callers see a uniform attributed error. + if not (200 <= raw_response.status_code < 300): + raise self._wrap_error( + error_message=raw_response.text, + status_code=raw_response.status_code, + headers=dict(raw_response.headers), + ) - max_results_str: str = "20" - if raw_response.request: - raw_param: object = raw_response.request.url.params.get( # any-ok: httpx QueryParams.get() -> Any - "max_results", "20" + try: + raw_json: object = raw_response.json() # any-ok: httpx Response.json() -> Any + except json.JSONDecodeError: + raise self._wrap_error( + error_message=f"Expected JSON response, got: {raw_response.text[:200]}", + status_code=raw_response.status_code, + headers=dict(raw_response.headers), ) - max_results_str = str(raw_param) - max_results: int = min(int(max_results_str), 20) - results = [ - SearchResult(title=item.title, url=item.url, snippet=item.snippet) - for item in parsed.results[:max_results] - ] + # SearchResult requires title/url/snippet. Default missing/null values to "" + # rather than raise, so a degraded result (e.g. one TinyFish couldn't parse + # fully) flows through with empty strings instead of failing the whole call. + if isinstance(raw_json, dict): + results_in = raw_json.get("results") + if isinstance(results_in, list): + for item in results_in: + if isinstance(item, dict): + for field in ("title", "url", "snippet"): + if not isinstance(item.get(field), str): + item[field] = "" - return SearchResponse(results=results, object="search") + try: + 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}" + ), + status_code=raw_response.status_code, + headers=dict(raw_response.headers), + ) + + # Re-fire any TinyFish-side `parameter_warnings` as verbose_logger.warning + # lines. Schema per entry: {type, parameter, message, docs_url?}. See ML-2085. + # Defensive: skip silently on any shape we don't recognize so a malformed + # entry (or an early/partial rollout of the field) never throws. + warnings_field: object = ( + getattr(parsed, "parameter_warnings", None) # any-ok: extras=allow field + ) + if isinstance(warnings_field, list): + for entry in warnings_field: + if not isinstance(entry, dict): + continue + warning_type: object = entry.get("type") # any-ok: untyped dict + parameter: object = entry.get("parameter") # any-ok: untyped dict + message: object = entry.get("message") # any-ok: untyped dict + if not isinstance(warning_type, str) or not warning_type: + continue + if not isinstance(parameter, str) or not parameter: + continue + if not isinstance(message, str) or not message: + continue + verbose_logger.warning( + "TinyFish Search parameter_warning (%s) `%s`: %s", + warning_type, + parameter, + message, + ) + + max_results = self._caller_max_results or _TINYFISH_RESULT_CAP + return SearchResponse(results=list(parsed.results[:max_results])) + + def _wrap_error( + self, + error_message: str, + status_code: int, + headers: dict[str, str], + ) -> Exception: + """ + Build an attributed ``BaseLLMException`` from a TinyFish error body. + + Used only at the call sites we control inside + ``transform_search_response`` (non-2xx, JSONDecodeError, ValidationError). + Not an override of ``BaseSearchConfig.get_error_class``: that path is + left to inherit from the base so it auto-picks-up any future LiteLLM + improvements. Trade-off: network failures (routed through LiteLLM + core's ``_handle_error`` → ``BaseSearchConfig.get_error_class``) won't + carry the ``TinyFish Search:`` prefix — the bare error already names + the host in the URL, so attribution is implicit there. + """ + # ux-labs frontend wraps every error body as {"error": {"code", "message", "details"?}}. + # Best-effort unwrap to surface the inner message; fall back to the raw body + # for non-ux-labs responses (CDN HTML pages, other JSON envelopes, plain text). + inner_message = error_message + try: + body: object = json.loads(error_message) # any-ok: json.loads -> Any + if isinstance(body, dict): + error_obj: object = body.get("error") # any-ok: untyped dict + if isinstance(error_obj, dict): + candidate: object = error_obj.get("message") # any-ok: untyped dict + if isinstance(candidate, str) and candidate: + inner_message = candidate + except (json.JSONDecodeError, TypeError): + pass + + return BaseLLMException( + status_code=status_code, + message=f"TinyFish Search: {inner_message}. See {_TINYFISH_DOCS_URL} for details.", + headers=headers, + ) def _append_domain_filters(query: str, domains: list[str]) -> str: diff --git a/tests/search_tests/test_tinyfish_search.py b/tests/search_tests/test_tinyfish_search.py index 337a7d5b115b..aca28544513d 100644 --- a/tests/search_tests/test_tinyfish_search.py +++ b/tests/search_tests/test_tinyfish_search.py @@ -161,10 +161,60 @@ async def test_language_passthrough(self): 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": [ { @@ -175,10 +225,7 @@ def test_max_results_truncates_response(self): for i in range(10) ] } - mock_response = _make_mock_response( - many_results, - request_url="https://api.search.tinyfish.ai?query=test&max_results=3", - ) + mock_response = _make_mock_response(many_results) result = config.transform_search_response( raw_response=mock_response, diff --git a/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py b/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py index 9870d30d4886..6373d774e456 100644 --- a/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py +++ b/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py @@ -2,7 +2,6 @@ Tests for TinyFish Search API integration. """ -import os from unittest.mock import MagicMock, patch import httpx @@ -37,11 +36,23 @@ def _make_mock_response( - json_data: dict, status_code: int = 200, request_url: str | None = None + json_data: dict | None = None, + status_code: int = 200, + request_url: str | None = None, + text: str | None = None, + headers: dict | None = None, ) -> MagicMock: + import json as _json mock = MagicMock() mock.status_code = status_code - mock.json.return_value = json_data + mock.headers = headers or {} + if json_data is not None: + mock.json.return_value = json_data + mock.text = text if text is not None else _json.dumps(json_data) + else: + # Force .json() to raise as httpx.Response does for non-JSON bodies. + mock.json.side_effect = _json.JSONDecodeError("Expecting value", text or "", 0) + mock.text = text or "" if request_url: mock.request = MagicMock() mock.request.url = httpx.URL(request_url) @@ -107,26 +118,48 @@ def test_country_maps_to_location(self): ) assert result["_tinyfish_params"]["location"] == "US" - def test_max_results_clamped_upper(self): + 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} + ) + 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} ) - assert result["_tinyfish_params"]["max_results"] == 20 + assert config._caller_max_results == 10 # TinyFish's natural cap - def test_max_results_clamped_lower(self): + def test_max_results_clamped_lower_stored_on_self(self): config = TinyfishSearchConfig() - result = config.transform_search_request( + config.transform_search_request( query="test", optional_params={"max_results": 0} ) - assert result["_tinyfish_params"]["max_results"] == 1 + assert config._caller_max_results == 1 - def test_max_results_normal(self): + def test_max_results_normal_stored_on_self(self): config = TinyfishSearchConfig() - result = config.transform_search_request( + config.transform_search_request( query="test", optional_params={"max_results": 5} ) - assert result["_tinyfish_params"]["max_results"] == 5 + assert config._caller_max_results == 5 + + def test_max_results_non_numeric_string_warns_and_skips(self, caplog): + # `int("abc")` would raise ValueError; guard makes the failure visible + # 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"} + ) + 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_domain_filter_appends_site_operators(self): config = TinyfishSearchConfig() @@ -172,6 +205,49 @@ def test_perplexity_params_not_passed_through(self): ) assert param not in result["_tinyfish_params"] + def test_arbitrary_param_passed_through(self): + # `fetch` is a TinyFish-specific param (JSON-encoded tf-fetch config). + # 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": "{}"} + ) + assert result["_tinyfish_params"]["fetch"] == "{}" + + def test_dict_param_auto_json_encoded(self): + # Callers naturally pass dict-shaped params; we serialize so the + # downstream urlencode step (which only accepts str|int|bool) doesn't reject. + config = TinyfishSearchConfig() + result = config.transform_search_request( + query="test", + optional_params={"fetch": {"format": "html", "fetch_path": "fast"}}, + ) + assert result["_tinyfish_params"]["fetch"] == '{"format":"html","fetch_path":"fast"}' + + def test_bool_param_serialized_as_lowercase(self): + # urlencode renders Python bool as capitalized "True"/"False"; ux-labs + # 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} + ) + assert true_result["_tinyfish_params"]["include_thumbnail"] == "true" + assert false_result["_tinyfish_params"]["include_thumbnail"] == "false" + + def test_pre_stringified_param_passed_unchanged(self): + # 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} + ) + assert result["_tinyfish_params"]["fetch"] == already + class TestGetCompleteUrl: def test_default_api_base(self): @@ -259,8 +335,10 @@ def test_empty_results(self): assert result.object == "search" assert len(result.results) == 0 - def test_max_results_truncates(self): + def test_max_results_truncates_from_self_state(self): config = TinyfishSearchConfig() + # Simulate transform_search_request having set the threaded value. + config._caller_max_results = 3 many_results = { "results": [ { @@ -271,10 +349,7 @@ def test_max_results_truncates(self): for i in range(10) ] } - mock_response = _make_mock_response( - many_results, - request_url="https://api.search.tinyfish.ai?query=test&max_results=3", - ) + mock_response = _make_mock_response(many_results) result = config.transform_search_response( raw_response=mock_response, logging_obj=None ) @@ -282,7 +357,8 @@ def test_max_results_truncates(self): assert result.results[0].title == "Result 0" assert result.results[2].title == "Result 2" - def test_max_results_default_is_20(self): + def test_max_results_default_is_tinyfish_cap(self): + # No caller value → fall back to TinyFish's natural ceiling (10). config = TinyfishSearchConfig() many_results = { "results": [ @@ -291,28 +367,68 @@ def test_max_results_default_is_20(self): "url": f"https://example.com/{i}", "snippet": f"Snippet {i}", } - for i in range(25) + for i in range(15) ] } - mock_response = _make_mock_response( - many_results, - request_url="https://api.search.tinyfish.ai?query=test", + mock_response = _make_mock_response(many_results) + 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): + # title/url/snippet are required by LiteLLM's SearchResult schema. + # We default missing/null values to "" so a degraded TinyFish result + # 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 ) - assert len(result.results) == 20 + assert len(result.results) == 2 + for r in result.results: + assert r.title == "" + assert r.url == "" + assert r.snippet == "" - def test_missing_fields_default_to_empty_string(self): + 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 + ) + first = result.results[0] + assert getattr(first, "position", None) == 1 + assert getattr(first, "site_name", None) == "tinyfish.ai" + + def test_fetch_field_rides_through_to_search_result(self): + # Mirrors browser-search's per-result `fetch` nested object (see + # api/src/parser.rs SearchResult.fetch). Confirms `fetch=...` requests + # surface their content to LiteLLM callers without provider changes. config = TinyfishSearchConfig() - mock_response = _make_mock_response({"results": [{}]}) + fetched = { + "results": [ + { + "title": "TinyFish", + "url": "https://tinyfish.ai", + "snippet": "Web automation.", + "fetch": { + "url": "https://tinyfish.ai", + "title": "TinyFish", + "text": "Body text", + "cached": False, + }, + } + ] + } + mock_response = _make_mock_response(fetched) result = config.transform_search_response( raw_response=mock_response, logging_obj=None ) - assert len(result.results) == 1 - assert result.results[0].title == "" - assert result.results[0].url == "" - assert result.results[0].snippet == "" + first = result.results[0] + fetch_field = getattr(first, "fetch", None) + assert isinstance(fetch_field, dict) + assert fetch_field["text"] == "Body text" def test_no_request_uses_default_max_results(self): config = TinyfishSearchConfig() @@ -322,6 +438,212 @@ def test_no_request_uses_default_max_results(self): ) assert len(result.results) == 2 + def test_parameter_warnings_reader_emits_log_lines(self, caplog): + # When TinyFish responds with a top-level `parameter_warnings` array + # (post-rollout of that contract), each entry is re-fired as a + # verbose_logger.warning so callers see what was ignored. + config = TinyfishSearchConfig() + body = { + "results": [ + {"title": "x", "url": "https://x", "snippet": "x"}, + ], + "parameter_warnings": [ + { + "type": "unsupported", + "parameter": "max_tokens_per_page", + "message": "Parameter not supported by TinyFish Search.", + "docs_url": "https://docs.tinyfish.ai/search-api", + }, + ], + } + mock_response = _make_mock_response(body) + with caplog.at_level("WARNING"): + 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. + assert any("unsupported" in m for m in messages) + + def test_parameter_warnings_absent_no_log(self, caplog): + # Absence of the field is silent — most responses won't carry it. + 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 + ) + + def test_parameter_warnings_malformed_shapes_never_throw(self): + # Every shape that doesn't match {parameter: str, message: str} should + # silently no-op. None of these should raise an exception. + config = TinyfishSearchConfig() + + 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 + ] + 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 + + 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 + ] + body = {"results": good_results, "parameter_warnings": malformed_entries} + 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() + body = { + "results": [{"title": "x", "url": "https://x", "snippet": "x"}], + "parameter_warnings": [ + {"type": "unsupported", "parameter": "x"}, # missing message — skipped + { + "type": "unsupported", + "parameter": "valid_one", + "message": "actual msg", + }, # ok — emitted + {"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 + ) + 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) + + +class TestErrorHandling: + def test_4xx_response_raises_with_attribution_and_unwrapped_message(self): + # Reproduces ux-labs' error envelope shape for an INVALID_INPUT response. + config = TinyfishSearchConfig() + body = { + "error": { + "code": "INVALID_INPUT", + "message": "query is required", + "details": [{"field": "query"}], + } + } + 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 + ) + msg = str(exc_info.value) + assert "TinyFish Search:" in msg + assert "query is required" in msg + assert "docs.tinyfish.ai/search-api" in msg + assert getattr(exc_info.value, "status_code", None) == 400 + + 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"} + ) + with pytest.raises(Exception) as exc_info: + 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" + + def test_5xx_with_non_ux_labs_body_falls_back_to_raw_text(self): + # Cloudflare-style JSON or any other envelope: unwrap fails, fall back to raw. + config = TinyfishSearchConfig() + 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 + ) + msg = str(exc_info.value) + assert "TinyFish Search:" in msg + # The raw JSON body string should appear in the message verbatim. + assert "10000" in msg + + 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" + ) + with pytest.raises(Exception) as exc_info: + 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 + + 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" + ) + with pytest.raises(Exception) as exc_info: + 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 + + def test_wrap_error_returns_attributed_baselm_exception_directly(self): + # Direct unit test of the private _wrap_error helper used by + # transform_search_response. Network failures don't go through this; + # 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"} + ) + msg = str(exc) + assert "TinyFish Search:" in msg + assert "bad key" in msg + assert exc.status_code == 401 + + def test_schema_mismatch_wraps_with_attribution(self): + # When TinyFish returns a 200 with a body shape that doesn't match + # LiteLLM's SearchResponse contract (e.g. missing top-level `results`), + # raise with TinyFish attribution + docs link so the caller knows to + # check TinyFish's schema, not their own input. + 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 + ) + msg = str(exc_info.value) + assert "TinyFish Search:" in msg + assert "schema" in msg.lower() + assert "docs.tinyfish.ai/search-api" in msg + class TestAppendDomainFilters: def test_single_domain(self): From b9923a7cb719e7986cd0786c087ba082c065c4e6 Mon Sep 17 00:00:00 2001 From: Chenlu Ji Date: Thu, 25 Jun 2026 23:24:22 -0700 Subject: [PATCH 2/6] 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. --- .../llms/tinyfish/search/transformation.py | 27 +-- tests/search_tests/test_tinyfish_search.py | 4 +- .../llms/tinyfish/test_tinyfish_search.py | 215 ++++++------------ 3 files changed, 80 insertions(+), 166 deletions(-) diff --git a/litellm/llms/tinyfish/search/transformation.py b/litellm/llms/tinyfish/search/transformation.py index 1d75a9de4595..34583cb57a60 100644 --- a/litellm/llms/tinyfish/search/transformation.py +++ b/litellm/llms/tinyfish/search/transformation.py @@ -65,9 +65,7 @@ def validate_environment( 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 @@ def get_complete_url( 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 @@ def transform_search_request( 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 @@ def transform_search_request( 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 @@ def transform_search_response( 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 aca28544513d..2a9b0028e97a 100644 --- a/tests/search_tests/test_tinyfish_search.py +++ b/tests/search_tests/test_tinyfish_search.py @@ -34,9 +34,7 @@ } -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 6373d774e456..ef30486e2abd 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 @@ def test_validate_environment_uses_api_base_kwarg(self): 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 @@ def test_max_results_non_numeric_string_warns_and_skips(self, caplog): # 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 @@ def test_domain_filter_appends_site_operators(self): 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 @@ def test_perplexity_params_not_passed_through(self): 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 @@ def test_arbitrary_param_passed_through(self): # 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 @@ def test_bool_param_serialized_as_lowercase(self): # 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 @@ def test_pre_stringified_param_passed_unchanged(self): # 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 @@ def test_default_api_base(self): 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 @@ def test_without_tinyfish_params_key(self): "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 @@ def test_max_results_truncates_from_self_state(self): ] } 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 @@ def test_max_results_default_is_tinyfish_cap(self): ] } 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 @@ def test_missing_required_fields_default_to_empty_string(self): # 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 @@ def test_missing_required_fields_default_to_empty_string(self): 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 @@ def test_fetch_field_rides_through_to_search_result(self): ] } 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 @@ def test_fetch_field_rides_through_to_search_result(self): 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 @@ def test_parameter_warnings_reader_emits_log_lines(self, caplog): } 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 @@ def test_parameter_warnings_absent_no_log(self, caplog): 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 @@ def test_parameter_warnings_malformed_shapes_never_throw(self): 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 @@ def test_parameter_warnings_malformed_entries_emit_nothing(self, caplog): "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 @@ def test_4xx_response_raises_with_attribution_and_unwrapped_message(self): } 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 @@ def test_4xx_response_raises_with_attribution_and_unwrapped_message(self): 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 @@ def test_5xx_with_non_ux_labs_body_falls_back_to_raw_text(self): 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 @@ def test_5xx_with_non_ux_labs_body_falls_back_to_raw_text(self): 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 @@ def test_non_json_4xx_body_uses_raw_text(self): 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 @@ def test_wrap_error_returns_attributed_baselm_exception_directly(self): # 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 @@ def test_schema_mismatch_wraps_with_attribution(self): 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() From 656cdcb60d70300855dcbd41f61de709fd7dddaf Mon Sep 17 00:00:00 2001 From: Chenlu Ji Date: Thu, 25 Jun 2026 23:31:39 -0700 Subject: [PATCH 3/6] fix(tinyfish): apply --line-length 88 ruff format to match CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../llms/tinyfish/search/transformation.py | 28 ++- tests/search_tests/test_tinyfish_search.py | 4 +- .../llms/tinyfish/test_tinyfish_search.py | 197 +++++++++++++----- 3 files changed, 173 insertions(+), 56 deletions(-) diff --git a/litellm/llms/tinyfish/search/transformation.py b/litellm/llms/tinyfish/search/transformation.py index 34583cb57a60..5eff1b6abc6a 100644 --- a/litellm/llms/tinyfish/search/transformation.py +++ b/litellm/llms/tinyfish/search/transformation.py @@ -65,7 +65,9 @@ def validate_environment( 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( @@ -75,9 +77,13 @@ def get_complete_url( 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 @@ -113,7 +119,9 @@ def transform_search_request( 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: @@ -131,7 +139,9 @@ def transform_search_request( 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)) + 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( @@ -202,7 +212,9 @@ def transform_search_response( ) try: - raw_json: object = raw_response.json() # any-ok: httpx Response.json() -> Any + raw_json: object = ( + raw_response.json() + ) # any-ok: httpx Response.json() -> Any except json.JSONDecodeError: raise self._wrap_error( error_message=f"Expected JSON response, got: {raw_response.text[:200]}", @@ -226,7 +238,9 @@ def transform_search_response( 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 2a9b0028e97a..aca28544513d 100644 --- a/tests/search_tests/test_tinyfish_search.py +++ b/tests/search_tests/test_tinyfish_search.py @@ -34,7 +34,9 @@ } -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 ef30486e2abd..dc5f1a579058 100644 --- a/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py +++ b/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py @@ -100,39 +100,53 @@ def test_validate_environment_uses_api_base_kwarg(self): 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): @@ -140,7 +154,9 @@ def test_max_results_non_numeric_string_warns_and_skips(self, caplog): # 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] @@ -152,7 +168,9 @@ def test_max_results_infinity_float_warns_and_skips(self, caplog): # 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")}) + 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] @@ -171,17 +189,23 @@ def test_domain_filter_appends_site_operators(self): 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 @@ -191,7 +215,9 @@ def test_perplexity_params_not_passed_through(self): 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): @@ -199,7 +225,9 @@ def test_arbitrary_param_passed_through(self): # 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): @@ -210,15 +238,22 @@ def test_dict_param_auto_json_encoded(self): query="test", optional_params={"fetch": {"format": "html", "fetch_path": "fast"}}, ) - assert result["_tinyfish_params"]["fetch"] == '{"format":"html","fetch_path":"fast"}' + assert ( + result["_tinyfish_params"]["fetch"] + == '{"format":"html","fetch_path":"fast"}' + ) def test_bool_param_serialized_as_lowercase(self): # urlencode renders Python bool as capitalized "True"/"False"; ux-labs # 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" @@ -226,7 +261,9 @@ def test_pre_stringified_param_passed_unchanged(self): # 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 @@ -242,7 +279,9 @@ def test_default_api_base(self): 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): @@ -275,7 +314,9 @@ def test_without_tinyfish_params_key(self): "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): @@ -292,17 +333,23 @@ 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 @@ -321,7 +368,9 @@ def test_max_results_truncates_from_self_state(self): ] } 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" @@ -340,7 +389,9 @@ def test_max_results_default_is_tinyfish_cap(self): ] } 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): @@ -348,8 +399,12 @@ def test_missing_required_fields_default_to_empty_string(self): # We default missing/null values to "" so a degraded TinyFish result # 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) + mock_response = _make_mock_response( + {"results": [{}, {"title": None, "url": None, "snippet": 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 == "" @@ -359,7 +414,9 @@ def test_missing_required_fields_default_to_empty_string(self): 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" @@ -385,7 +442,9 @@ def test_fetch_field_rides_through_to_search_result(self): ] } 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) @@ -394,7 +453,9 @@ def test_fetch_field_rides_through_to_search_result(self): 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): @@ -417,7 +478,9 @@ def test_parameter_warnings_reader_emits_log_lines(self, caplog): } 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. @@ -428,8 +491,12 @@ def test_parameter_warnings_absent_no_log(self, caplog): 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 @@ -446,7 +513,9 @@ def test_parameter_warnings_malformed_shapes_never_throw(self): ] 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 @@ -455,13 +524,23 @@ def test_parameter_warnings_malformed_shapes_never_throw(self): {"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": None, + "message": "y", + }, # null parameter {"type": "unsupported", "parameter": "x", "message": ""}, # empty message - {"type": "unsupported", "parameter": 42, "message": "y"}, # non-string parameter + { + "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() @@ -478,7 +557,9 @@ def test_parameter_warnings_malformed_entries_emit_nothing(self, caplog): ], } 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) @@ -497,7 +578,9 @@ def test_4xx_response_raises_with_attribution_and_unwrapped_message(self): } 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 @@ -507,9 +590,13 @@ def test_4xx_response_raises_with_attribution_and_unwrapped_message(self): 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" @@ -520,7 +607,9 @@ def test_5xx_with_non_ux_labs_body_falls_back_to_raw_text(self): 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. @@ -528,9 +617,13 @@ def test_5xx_with_non_ux_labs_body_falls_back_to_raw_text(self): 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 @@ -538,9 +631,13 @@ def test_non_json_4xx_body_uses_raw_text(self): 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 @@ -551,7 +648,9 @@ def test_wrap_error_returns_attributed_baselm_exception_directly(self): # 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 @@ -565,7 +664,9 @@ def test_schema_mismatch_wraps_with_attribution(self): 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() From 0127240dd8d4b6526e2e399fcfe7c7b878884ad3 Mon Sep 17 00:00:00 2001 From: Chenlu Ji Date: Thu, 25 Jun 2026 23:37:29 -0700 Subject: [PATCH 4/6] 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. --- .../llms/tinyfish/search/transformation.py | 91 +++++++++++-------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/litellm/llms/tinyfish/search/transformation.py b/litellm/llms/tinyfish/search/transformation.py index 5eff1b6abc6a..3fcde9a57b09 100644 --- a/litellm/llms/tinyfish/search/transformation.py +++ b/litellm/llms/tinyfish/search/transformation.py @@ -22,7 +22,6 @@ ) from litellm.secret_managers.main import get_secret_str - _UrlEncodableParams = TypeAdapter(dict[str, str | int | bool]) _StrList = TypeAdapter(list[str]) _StrFrozenSet = TypeAdapter(frozenset[str]) @@ -222,17 +221,7 @@ def transform_search_response( headers=dict(raw_response.headers), ) - # SearchResult requires title/url/snippet. Default missing/null values to "" - # rather than raise, so a degraded result (e.g. one TinyFish couldn't parse - # fully) flows through with empty strings instead of failing the whole call. - if isinstance(raw_json, dict): - results_in = raw_json.get("results") - if isinstance(results_in, list): - for item in results_in: - if isinstance(item, dict): - for field in ("title", "url", "snippet"): - if not isinstance(item.get(field), str): - item[field] = "" + _default_missing_result_fields(raw_json) try: parsed = SearchResponse.model_validate(raw_json) @@ -245,32 +234,7 @@ def transform_search_response( headers=dict(raw_response.headers), ) - # Re-fire any TinyFish-side `parameter_warnings` as verbose_logger.warning - # lines. Schema per entry: {type, parameter, message, docs_url?}. See ML-2085. - # Defensive: skip silently on any shape we don't recognize so a malformed - # entry (or an early/partial rollout of the field) never throws. - warnings_field: object = ( - getattr(parsed, "parameter_warnings", None) # any-ok: extras=allow field - ) - if isinstance(warnings_field, list): - for entry in warnings_field: - if not isinstance(entry, dict): - continue - warning_type: object = entry.get("type") # any-ok: untyped dict - parameter: object = entry.get("parameter") # any-ok: untyped dict - message: object = entry.get("message") # any-ok: untyped dict - if not isinstance(warning_type, str) or not warning_type: - continue - if not isinstance(parameter, str) or not parameter: - continue - if not isinstance(message, str) or not message: - continue - verbose_logger.warning( - "TinyFish Search parameter_warning (%s) `%s`: %s", - warning_type, - parameter, - message, - ) + _emit_parameter_warnings(parsed) max_results = self._caller_max_results or _TINYFISH_RESULT_CAP return SearchResponse(results=list(parsed.results[:max_results])) @@ -318,3 +282,54 @@ def _wrap_error( def _append_domain_filters(query: str, domains: list[str]) -> str: domain_clauses = " OR ".join(f"site:{d}" for d in domains) return f"({query}) ({domain_clauses})" + + +def _default_missing_result_fields(raw_json: object) -> None: + """Default missing/null title/url/snippet to "" on each result item in place. + + SearchResult requires these three fields; a degraded TinyFish result flows + through with empty strings instead of failing the whole call. + """ + if not isinstance(raw_json, dict): + return + results_in = raw_json.get("results") + if not isinstance(results_in, list): + return + for item in results_in: + if not isinstance(item, dict): + continue + for field in ("title", "url", "snippet"): + if not isinstance(item.get(field), str): + item[field] = "" + + +def _emit_parameter_warnings(parsed: SearchResponse) -> None: + """Re-fire TinyFish-side ``parameter_warnings`` (see ML-2085) as warnings. + + Defensive: skip silently on any shape we don't recognize so a malformed + entry (or an early/partial rollout of the field) never throws. + Schema per entry: ``{type, parameter, message, docs_url?}``. + """ + warnings_field: object = ( + getattr(parsed, "parameter_warnings", None) # any-ok: extras=allow field + ) + if not isinstance(warnings_field, list): + return + for entry in warnings_field: + if not isinstance(entry, dict): + continue + warning_type: object = entry.get("type") # any-ok: untyped dict + parameter: object = entry.get("parameter") # any-ok: untyped dict + message: object = entry.get("message") # any-ok: untyped dict + if not isinstance(warning_type, str) or not warning_type: + continue + if not isinstance(parameter, str) or not parameter: + continue + if not isinstance(message, str) or not message: + continue + verbose_logger.warning( + "TinyFish Search parameter_warning (%s) `%s`: %s", + warning_type, + parameter, + message, + ) From 3a8cef1ebdb2baed6401a39faf104e463c033af8 Mon Sep 17 00:00:00 2001 From: Chenlu Ji Date: Fri, 26 Jun 2026 11:39:03 -0700 Subject: [PATCH 5/6] 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%. --- .../llms/tinyfish/test_tinyfish_search.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py b/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py index dc5f1a579058..58363e3baea7 100644 --- a/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py +++ b/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py @@ -10,6 +10,7 @@ from litellm.llms.tinyfish.search.transformation import ( TinyfishSearchConfig, _append_domain_filters, + _default_missing_result_fields, ) MOCK_TINYFISH_RESPONSE = { @@ -681,3 +682,21 @@ def test_single_domain(self): def test_multiple_domains(self): result = _append_domain_filters("query", ["a.com", "b.com", "c.com"]) assert result == "(query) (site:a.com OR site:b.com OR site:c.com)" + + +class TestDefaultMissingResultFields: + def test_non_dict_raw_json_is_noop(self): + # raw_json could be a string/list/None if TinyFish ever returns a + # non-envelope shape; the helper just returns without mutating. + for payload in ("not a dict", ["list"], None, 42): + _default_missing_result_fields(payload) # must not raise + + def test_non_dict_results_item_skipped(self): + # If `results` contains a non-dict entry (string, int, etc.), the helper + # skips it; SearchResponse.model_validate will reject it later. + raw_json = {"results": ["string item", 42, {"title": "ok"}]} + _default_missing_result_fields(raw_json) + # Only the dict item gets defaulted; the others are unchanged. + assert raw_json["results"][0] == "string item" + assert raw_json["results"][1] == 42 + assert raw_json["results"][2] == {"title": "ok", "url": "", "snippet": ""} From 2af27aa3df0a603d0a5837ba64f9778dce0fb265 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 2 Jul 2026 15:54:41 -0700 Subject: [PATCH 6/6] chore(tinyfish): apply ruff format to fix lint after staging merge --- litellm/llms/tinyfish/search/transformation.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/litellm/llms/tinyfish/search/transformation.py b/litellm/llms/tinyfish/search/transformation.py index 86d8b2ad0341..cef5f9cd02e4 100644 --- a/litellm/llms/tinyfish/search/transformation.py +++ b/litellm/llms/tinyfish/search/transformation.py @@ -130,9 +130,7 @@ def transform_search_request( 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) - ) + 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( @@ -203,9 +201,7 @@ def transform_search_response( ) try: - raw_json: object = ( - raw_response.json() - ) # any-ok: httpx Response.json() -> Any + raw_json: object = raw_response.json() # any-ok: httpx Response.json() -> Any except json.JSONDecodeError: raise self._wrap_error( error_message=f"Expected JSON response, got: {raw_response.text[:200]}", @@ -219,9 +215,7 @@ def transform_search_response( 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), )