Skip to content
270 changes: 217 additions & 53 deletions litellm/llms/tinyfish/search/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,53 +6,42 @@

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"
Expand Down Expand Up @@ -97,61 +86,236 @@ 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
``(<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}
try:
domains = _StrList.validate_python(optional_params.get("search_domain_filter"))
except (ValidationError, TypeError):
domains = []
if domains:
resolved_query = _append_domain_filters(resolved_query, domains)

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)):
request_data["max_results"] = max(1, min(int(raw_max), 20))

try:
domains = _StrList.validate_python(optional_params.get("search_domain_filter"))
except (ValidationError, TypeError):
domains = []
if domains:
request_data["query"] = _append_domain_filters(request_data["query"], domains)

result_data: dict[str, object] = dict(request_data)
try:
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,
)

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,
raw_response: httpx.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: <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 = "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]
]
_default_missing_result_fields(raw_json)

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),
)

return SearchResponse(results=results, object="search")
_emit_parameter_warnings(parsed)

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:
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,
)
Loading
Loading