mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
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:
parent
e0e920d80e
commit
b86411f845
3 changed files with 614 additions and 96 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue