feat(tinyfish): make search provider permissive, attribute errors

Reshapes the TinyFish search provider so LiteLLM mirrors the TinyFish
Search API surface instead of maintaining a parallel cherry-pick.

Request side:
- Drop misleading request TypedDict
- Stop sending max_results on wire (TinyFish ignores it); clamp to [1,10]
  client-side via self-threaded state
- Guard non-numeric max_results from bare ValueError
- Auto-JSON-encode dict params; lowercase bool serialization for ux-labs

Response side:
- Drop both Pydantic response models; parse directly into SearchResponse
  so per-result extras flow through via extra="allow"
- Default missing title/url/snippet to "" instead of failing the call
- Read top-level parameter_warnings and re-fire as verbose_logger.warning
  (pre-wired for upcoming TinyFish-side rollout; no-op today)

Error handling:
- Attributed _wrap_error helper at 3 call sites in transform_search_response
  ("TinyFish Search: <msg>. See https://docs.tinyfish.ai/search-api for
  details.")
- Dispatch non-2xx responses through _wrap_error (fixes pre-existing bug
  where 4xx/5xx silently returned empty SearchResponse)
- Wrap json.JSONDecodeError on 200 bodies
- Wrap pydantic.ValidationError for envelope-shape mismatches

Bug fix worth flagging: 4xx/5xx responses now raise an attributed
BaseLLMException instead of silently returning SearchResponse(results=[]).

Follow-up to #30634.
This commit is contained in:
Chenlu Ji 2026-06-25 23:06:51 -07:00
parent e0e920d80e
commit b86411f845
3 changed files with 614 additions and 96 deletions

View file

@ -6,53 +6,43 @@ Docs: https://docs.tinyfish.ai/search-api
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 @@ class TinyfishSearchConfig(BaseSearchConfig):
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
``(<query>) (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: <dict of querystring entries>}``.
``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 @@ class TinyfishSearchConfig(BaseSearchConfig):
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
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: result_data}
return {_TINYFISH_PARAMS_KEY: request_data}
def transform_search_response(
self,
@ -144,23 +174,142 @@ class TinyfishSearchConfig(BaseSearchConfig):
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``.
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"
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: <msg>. See <docs> 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(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]
]
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),
)
return SearchResponse(results=results, object="search")
# 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] = ""
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:

View file

@ -161,10 +161,60 @@ class TestTinyfishSearch:
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 @@ class TestTinyfishSearch:
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,

View file

@ -2,7 +2,6 @@
Tests for TinyFish Search API integration.
"""
import os
from unittest.mock import MagicMock, patch
import httpx
@ -37,11 +36,23 @@ MOCK_TINYFISH_RESPONSE = {
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 @@ class TestTransformSearchRequest:
)
assert result["_tinyfish_params"]["location"] == "US"
def test_max_results_clamped_upper(self):
config = TinyfishSearchConfig()
result = config.transform_search_request(
query="test", optional_params={"max_results": 100}
)
assert result["_tinyfish_params"]["max_results"] == 20
def test_max_results_clamped_lower(self):
config = TinyfishSearchConfig()
result = config.transform_search_request(
query="test", optional_params={"max_results": 0}
)
assert result["_tinyfish_params"]["max_results"] == 1
def test_max_results_normal(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 result["_tinyfish_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 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}
)
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}
)
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 @@ class TestTransformSearchRequest:
)
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 @@ class TestTransformSearchResponse:
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 @@ class TestTransformSearchResponse:
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 @@ class TestTransformSearchResponse:
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 @@ class TestTransformSearchResponse:
"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) == 20
assert len(result.results) == 10
def test_missing_fields_default_to_empty_string(self):
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": [{}]})
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) == 1
assert result.results[0].title == ""
assert result.results[0].url == ""
assert result.results[0].snippet == ""
assert len(result.results) == 2
for r in result.results:
assert r.title == ""
assert r.url == ""
assert r.snippet == ""
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()
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
)
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 @@ class TestTransformSearchResponse:
)
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="<html>Bad Gateway</html>"
)
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):