From a5939c8521ba084da3deb9e2764e9dbd8639fab8 Mon Sep 17 00:00:00 2001 From: Terrajlz Date: Fri, 22 May 2026 09:45:35 -0400 Subject: [PATCH 1/6] fix(thinking): handle None thinking param in is_thinking_enabled (#28598) Squash-merged by litellm-agent from Terrajlz's PR. --- litellm/llms/base_llm/chat/transformation.py | 2 +- tests/test_litellm/test_thinking_enabled.py | 74 ++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/test_thinking_enabled.py diff --git a/litellm/llms/base_llm/chat/transformation.py b/litellm/llms/base_llm/chat/transformation.py index bec25916c4b0..ebd6a89a498a 100644 --- a/litellm/llms/base_llm/chat/transformation.py +++ b/litellm/llms/base_llm/chat/transformation.py @@ -109,7 +109,7 @@ def get_json_schema_from_pydantic_object( def is_thinking_enabled(self, non_default_params: dict) -> bool: return ( - non_default_params.get("thinking", {}).get("type") == "enabled" + (non_default_params.get("thinking") or {}).get("type") == "enabled" or non_default_params.get("reasoning_effort") is not None ) diff --git a/tests/test_litellm/test_thinking_enabled.py b/tests/test_litellm/test_thinking_enabled.py new file mode 100644 index 000000000000..8ba406c395a0 --- /dev/null +++ b/tests/test_litellm/test_thinking_enabled.py @@ -0,0 +1,74 @@ +""" +Unit tests for is_thinking_enabled method in BaseConfig. + +Tests the fix for issue #28576: handle None thinking param without crashing. +""" + +import pytest +from litellm.llms.base_llm.chat.transformation import BaseConfig + + +class TestIsThinkingEnabled: + """Test is_thinking_enabled handles various thinking parameter values.""" + + @pytest.fixture + def transformer(self): + """Create a BaseConfig instance for testing.""" + # BaseConfig is abstract, so we create a minimal concrete subclass + class ConcreteConfig(BaseConfig): + def __init__(self): + pass + + def get_complete_url(self, *args, **kwargs): + return "" + + def validate_environment(self, *args, **kwargs): + return {} + + def transform_request(self, *args, **kwargs): + return {}, {} + + def transform_response(self, *args, **kwargs): + return None + + def get_supported_openai_params(self, model: str): + return [] + + def map_openai_params(self, *args, **kwargs): + return {} + + def get_error_class(self, *args, **kwargs): + from litellm.llms.base_llm.chat.transformation import BaseLLMException + return BaseLLMException(500, "test error") + + return ConcreteConfig() + + @pytest.mark.parametrize( + "non_default_params,expected", + [ + # thinking=None should not crash, returns False + ({"thinking": None}, False), + # thinking={'type': 'enabled'} returns True + ({"thinking": {"type": "enabled"}}, True), + # thinking key missing returns False + ({}, False), + # thinking={} returns False + ({"thinking": {}}, False), + # thinking with different type returns False + ({"thinking": {"type": "disabled"}}, False), + # reasoning_effort present returns True + ({"reasoning_effort": "medium"}, True), + # both thinking enabled and reasoning_effort returns True + ({"thinking": {"type": "enabled"}, "reasoning_effort": "high"}, True), + # falsy thinking values should not crash + ({"thinking": False}, False), + ({"thinking": 0}, False), + ({"thinking": ""}, False), + ], + ) + def test_is_thinking_enabled(self, transformer, non_default_params, expected): + """Test is_thinking_enabled with various parameter combinations.""" + result = transformer.is_thinking_enabled(non_default_params) + assert result == expected, ( + f"Expected {expected} for params {non_default_params}, got {result}" + ) From 2c144fcfbcaf2f27a393688864a0c381bd29f563 Mon Sep 17 00:00:00 2001 From: Bruno Devaux Date: Fri, 22 May 2026 17:00:04 +0200 Subject: [PATCH 2/6] feat(helm): support tpl rendering in podAnnotations (#28609) Squash-merged by litellm-agent from devauxbr's PR. --- .../litellm-helm/templates/deployment.yaml | 2 +- .../litellm-helm/tests/deployment_tests.yaml | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/deploy/charts/litellm-helm/templates/deployment.yaml b/deploy/charts/litellm-helm/templates/deployment.yaml index 25f690808789..a75cd0c7526f 100644 --- a/deploy/charts/litellm-helm/templates/deployment.yaml +++ b/deploy/charts/litellm-helm/templates/deployment.yaml @@ -30,7 +30,7 @@ spec: checksum/config: {{ include (print $.Template.BasePath "/configmap-litellm.yaml") . | sha256sum }} {{- end }} {{- with .Values.podAnnotations }} - {{- toYaml . | nindent 8 }} + {{- tpl (toYaml .) $ | nindent 8 }} {{- end }} labels: {{- include "litellm.labels" . | nindent 8 }} diff --git a/deploy/charts/litellm-helm/tests/deployment_tests.yaml b/deploy/charts/litellm-helm/tests/deployment_tests.yaml index df6d1345644e..f3d62651d8f7 100644 --- a/deploy/charts/litellm-helm/tests/deployment_tests.yaml +++ b/deploy/charts/litellm-helm/tests/deployment_tests.yaml @@ -377,3 +377,28 @@ tests: content: name: sidecar-tpl image: "ghcr.io/berriai/litellm-database:test" + - it: should support tpl in podAnnotations + template: deployment.yaml + set: + image: + repository: ghcr.io/berriai/litellm-database + tag: test + # Mirrors the real-world scenario this feature unblocks: + # user disables the built-in ConfigMap (and its built-in checksum/config + # annotation) and re-implements checksum/config themselves via tpl. + proxyConfigMap: + create: false + podAnnotations: + checksum/config: "{{ .Values.image.tag }}" + example.com/some-key: "{{ .Values.image.repository }}" + example.com/literal: "plain-string-value" + asserts: + - equal: + path: spec.template.metadata.annotations["checksum/config"] + value: "test" + - equal: + path: spec.template.metadata.annotations["example.com/some-key"] + value: "ghcr.io/berriai/litellm-database" + - equal: + path: spec.template.metadata.annotations["example.com/literal"] + value: "plain-string-value" From a4bb402cf30d324da8fd1ae531a937c6c6d6e191 Mon Sep 17 00:00:00 2001 From: Filippo Menghi <113345637+Cyberfilo@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:02:17 +0200 Subject: [PATCH 3/6] Forward custom_llm_provider through the Responses API bridge (Fixes #28505) (#28575) * Forward custom_llm_provider through the Responses API bridge (Fixes #28505) When a Chat Completions request to a GPT-5.4+ model contains both `tools` and `reasoning_effort`, `completion()` auto-routes through `responses_api_bridge`. The bridge handler called `litellm.responses()` / `litellm.aresponses()` without forwarding the already-resolved `custom_llm_provider`, so the downstream call re-invoked `get_llm_provider()` with `custom_llm_provider=None` and stripped a second provider prefix from a `provider/provider/model` deployment string. For a deployment configured as `openai/openai/openai/gpt-5.5`, the bridge flow sent `openai/gpt-5.5` to the upstream API instead of the correct `openai/openai/gpt-5.5`. Upstream APIs that enforce model-name allow-lists rejected this as `key_model_access_denied`. Fix: pass the locally-resolved `custom_llm_provider` into both the sync `responses()` and async `aresponses()` calls so the downstream `_resolve_model_provider_for_responses` sees an explicit provider and skips the second prefix-strip. New regression test `tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py` pins both call sites: each must forward `custom_llm_provider`. * fix(28505): set custom_llm_provider on request_data instead of as duplicate kwarg Greptile flagged that the previous patch passed custom_llm_provider as an explicit kwarg to responses()/aresponses() while request_data already carried it via the spread of sanitized_litellm_params, which would raise TypeError: got multiple values for keyword argument on every real bridge call. Switches to assigning request_data['custom_llm_provider'] before the call so the resolved provider wins over whatever sanitized_litellm_params spread in, without duplicating the kwarg. Updates the regression test to seed request_data with a sentinel custom_llm_provider so it actually exercises the overwrite path (the previous test mocked transform_request with a minimal dict and never hit the conflict). * chore: trigger shin-agent re-eval on retargeted staging base * chore: trigger shin-agent re-eval against updated Greptile state --- .../handler.py | 15 +++ .../completion_extras/__init__.py | 0 ...t_responses_bridge_provider_propagation.py | 116 ++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 tests/test_litellm/completion_extras/__init__.py create mode 100644 tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py diff --git a/litellm/completion_extras/litellm_responses_transformation/handler.py b/litellm/completion_extras/litellm_responses_transformation/handler.py index 2de7bda64674..87c26b776e80 100644 --- a/litellm/completion_extras/litellm_responses_transformation/handler.py +++ b/litellm/completion_extras/litellm_responses_transformation/handler.py @@ -182,6 +182,14 @@ def completion(self, *args, **kwargs) -> Union[ client=kwargs.get("client"), ) + # Pin the resolved provider so `responses()` doesn't re-run + # `get_llm_provider()` on the model string and strip a second + # provider prefix (see GitHub issue #28505). request_data already + # carries `custom_llm_provider` via the spread of + # `sanitized_litellm_params`; overwriting it on the dict (rather + # than adding an explicit kwarg) avoids the duplicate-keyword + # TypeError that would otherwise fire on the real bridge path. + request_data["custom_llm_provider"] = custom_llm_provider result = responses( **request_data, ) @@ -268,6 +276,13 @@ async def acompletion( except Exception as e: raise e + # Pin the resolved provider so `aresponses()` doesn't re-run + # `get_llm_provider()` on the model string and strip a second + # provider prefix (see GitHub issue #28505). Set on request_data + # rather than passed as a separate kwarg to avoid the duplicate- + # keyword TypeError when `sanitized_litellm_params` already + # carries `custom_llm_provider`. + request_data["custom_llm_provider"] = custom_llm_provider result = await aresponses( **request_data, aresponses=True, diff --git a/tests/test_litellm/completion_extras/__init__.py b/tests/test_litellm/completion_extras/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py b/tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py new file mode 100644 index 000000000000..b41dbd54b856 --- /dev/null +++ b/tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py @@ -0,0 +1,116 @@ +""" +Regression test for https://github.com/BerriAI/litellm/issues/28505 - +the Responses API bridge double-strips the provider prefix from the +model name when a Chat Completions request has both `tools` and +`reasoning_effort`. + +Root cause: the bridge handler called `litellm.responses()` / +`litellm.aresponses()` without passing the already-resolved +`custom_llm_provider`. The downstream call then re-invoked +`get_llm_provider()` with `custom_llm_provider=None`, which stripped +a second provider prefix from a `provider/provider/model` deployment +string. + +This test pins both the sync and async bridge handler call sites: +the resolved `custom_llm_provider` must be forwarded to the underlying +`responses` / `aresponses` call so the provider isn't re-detected. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from litellm.completion_extras.litellm_responses_transformation.handler import ( + ResponsesToCompletionBridgeHandler, +) + + +def _validated_kwargs(): + return { + "model": "openai/openai/openai/gpt-5.5", + "messages": [{"role": "user", "content": "hi"}], + "optional_params": {}, + "litellm_params": {}, + "headers": {}, + "model_response": MagicMock(), + "logging_obj": MagicMock(), + "custom_llm_provider": "openai", + } + + +def test_sync_completion_forwards_custom_llm_provider(): + handler = ResponsesToCompletionBridgeHandler() + handler.transformation_handler = MagicMock() + handler.transformation_handler.transform_request.return_value = { + "model": "openai/openai/openai/gpt-5.5", + "input": [], + # `_build_sanitized_litellm_params` spreads `custom_llm_provider` from + # `litellm_params` into request_data on the real bridge path. Seed + # it here so the test exercises the overwrite (not an explicit kwarg + # that would TypeError against an already-present key). + "custom_llm_provider": "should-be-overwritten", + } + handler.transformation_handler.transform_response.return_value = ( + _validated_kwargs()["model_response"] + ) + with ( + patch.object( + handler, "validate_input_kwargs", return_value=_validated_kwargs() + ), + patch( + "litellm.responses", + return_value=MagicMock(spec=[]), + ) as mock_responses, + ): + # The handler routes ResponsesAPIResponse through transform_response. + # We just want to verify the kwargs going INTO responses(). + try: + handler.completion(acompletion=False) + except Exception: + # Downstream handling (transform_response, type checks) is not + # the subject of this test. + pass + assert mock_responses.called + kwargs = mock_responses.call_args.kwargs + assert kwargs.get("custom_llm_provider") == "openai", ( + "sync bridge must forward custom_llm_provider to litellm.responses() " + "so the downstream get_llm_provider() call does not re-strip the " + "provider prefix on a provider/provider/model deployment string" + ) + + +@pytest.mark.asyncio +async def test_async_completion_forwards_custom_llm_provider(): + handler = ResponsesToCompletionBridgeHandler() + handler.transformation_handler = MagicMock() + handler.transformation_handler.transform_request.return_value = { + "model": "openai/openai/openai/gpt-5.5", + "input": [], + # `_build_sanitized_litellm_params` spreads `custom_llm_provider` from + # `litellm_params` into request_data on the real bridge path. Seed + # it here so the test exercises the overwrite (not an explicit kwarg + # that would TypeError against an already-present key). + "custom_llm_provider": "should-be-overwritten", + } + + async def _fake_aresponses(**kwargs): + _fake_aresponses.kwargs = kwargs + return MagicMock(spec=[]) + + _fake_aresponses.kwargs = {} + + with ( + patch.object( + handler, "validate_input_kwargs", return_value=_validated_kwargs() + ), + patch("litellm.aresponses", _fake_aresponses), + ): + try: + await handler.acompletion() + except Exception: + pass + assert _fake_aresponses.kwargs.get("custom_llm_provider") == "openai", ( + "async bridge must forward custom_llm_provider to litellm.aresponses() " + "so the downstream get_llm_provider() call does not re-strip the " + "provider prefix on a provider/provider/model deployment string" + ) From 3aa537685573f21a26cd62ac19138f5411be2df0 Mon Sep 17 00:00:00 2001 From: Simantak Dabhade <67303107+simantak-dabhade@users.noreply.github.com> Date: Thu, 14 May 2026 15:32:49 -0700 Subject: [PATCH 4/6] feat(search): add TinyFish as search provider Add TinyFish (https://tinyfish.ai) as a new search provider for the LiteLLM Search API. TinyFish provides web search results via GET https://api.search.tinyfish.ai with X-API-Key authentication. Supports unified params (query, country, search_domain_filter) and TinyFish-specific passthrough params (language, page, include_thumbnail). Co-Authored-By: Claude Opus 4.6 --- litellm/llms/tinyfish/search/__init__.py | 3 + .../llms/tinyfish/search/transformation.py | 130 ++++++++++++ litellm/types/utils.py | 1 + litellm/utils.py | 2 + model_prices_and_context_window.json | 8 + provider_endpoints_support.json | 7 + .../enforce_llms_folder_style.py | 1 + tests/search_tests/test_tinyfish_search.py | 189 ++++++++++++++++++ 8 files changed, 341 insertions(+) create mode 100644 litellm/llms/tinyfish/search/__init__.py create mode 100644 litellm/llms/tinyfish/search/transformation.py create mode 100644 tests/search_tests/test_tinyfish_search.py diff --git a/litellm/llms/tinyfish/search/__init__.py b/litellm/llms/tinyfish/search/__init__.py new file mode 100644 index 000000000000..9777e735aac6 --- /dev/null +++ b/litellm/llms/tinyfish/search/__init__.py @@ -0,0 +1,3 @@ +from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig + +__all__ = ["TinyfishSearchConfig"] diff --git a/litellm/llms/tinyfish/search/transformation.py b/litellm/llms/tinyfish/search/transformation.py new file mode 100644 index 000000000000..ef90ed4d76eb --- /dev/null +++ b/litellm/llms/tinyfish/search/transformation.py @@ -0,0 +1,130 @@ +""" +TinyFish Search API. +Endpoint: GET https://api.search.tinyfish.ai +Docs: https://docs.tinyfish.ai/search-api +""" + +from __future__ import annotations + +from typing import Dict, List, Literal, Optional, TypedDict, Union +from urllib.parse import urlencode + +import httpx + +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +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: str + + +class TinyfishSearchConfig(BaseSearchConfig): + TINYFISH_API_BASE = "https://api.search.tinyfish.ai" + + @staticmethod + def ui_friendly_name() -> str: + return "TinyFish" + + def get_http_method(self) -> Literal["GET", "POST"]: + return "GET" + + def validate_environment( + self, + headers: Dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, + ) -> Dict: + api_key = api_key or get_secret_str("TINYFISH_API_KEY") + if not api_key: + raise ValueError( + "TINYFISH_API_KEY is not set. Set `TINYFISH_API_KEY` environment variable." + ) + headers["X-API-Key"] = api_key + headers["Accept"] = "application/json" + return headers + + def get_complete_url( + self, + api_base: Optional[str], + optional_params: dict, + data: Optional[Union[Dict, List[Dict]]] = None, + **kwargs, + ) -> str: + api_base = ( + api_base or get_secret_str("TINYFISH_API_BASE") or self.TINYFISH_API_BASE + ) + if data and isinstance(data, dict) and "_tinyfish_params" in data: + query_string = urlencode(data["_tinyfish_params"], doseq=True) + return f"{api_base}?{query_string}" + return api_base + + def transform_search_request( + self, + query: Union[str, List[str]], + optional_params: dict, + **kwargs, + ) -> Dict: + if isinstance(query, list): + query = " ".join(query) + + request_data: TinyfishSearchRequest = {"query": query} + + if "country" in optional_params: + request_data["location"] = optional_params["country"] + + if "search_domain_filter" in optional_params: + domains = optional_params["search_domain_filter"] + if isinstance(domains, list) and len(domains) > 0: + request_data["query"] = self._append_domain_filters( + request_data["query"], domains + ) + + result_data = dict(request_data) + + for param, value in optional_params.items(): + if ( + param not in self.get_supported_perplexity_optional_params() + and param not in result_data + ): + result_data[param] = value + + return {"_tinyfish_params": result_data} + + @staticmethod + 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 transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: Optional[LiteLLMLoggingObj], + **kwargs, + ) -> SearchResponse: + response_json = raw_response.json() + + results: List[SearchResult] = [] + for item in response_json.get("results", []): + results.append( + SearchResult( + title=item.get("title", ""), + url=item.get("url", ""), + snippet=item.get("snippet", ""), + ) + ) + + return SearchResponse(results=results, object="search") diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 282baff07fe3..e503a3fec645 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3395,6 +3395,7 @@ class SearchProviders(str, Enum): DUCKDUCKGO = "duckduckgo" SEARCHAPI = "searchapi" SERPER = "serper" + TINYFISH = "tinyfish" # Create a set of all search provider values for quick lookup diff --git a/litellm/utils.py b/litellm/utils.py index 2487d39bd0de..95f8e5697c01 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9296,6 +9296,7 @@ def get_provider_search_config( from litellm.llms.searxng.search.transformation import SearXNGSearchConfig from litellm.llms.serper.search.transformation import SerperSearchConfig from litellm.llms.tavily.search.transformation import TavilySearchConfig + from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig PROVIDER_TO_CONFIG_MAP = { SearchProviders.PERPLEXITY: PerplexitySearchConfig, @@ -9311,6 +9312,7 @@ def get_provider_search_config( SearchProviders.DUCKDUCKGO: DuckDuckGoSearchConfig, SearchProviders.SEARCHAPI: SearchAPIConfig, SearchProviders.SERPER: SerperSearchConfig, + SearchProviders.TINYFISH: TinyfishSearchConfig, } config_class = PROVIDER_TO_CONFIG_MAP.get(provider, None) if config_class is None: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 31a5993a240e..21ecdac51fcd 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -13208,6 +13208,14 @@ "notes": "Serper Google Search API. Pricing: $1.00/1k queries (Starter), $0.75/1k (Standard), $0.50/1k (Scale), $0.30/1k (Ultimate)." } }, + "tinyfish/search": { + "input_cost_per_query": 0.0, + "litellm_provider": "tinyfish", + "mode": "search", + "metadata": { + "notes": "TinyFish Search API. Search is included with subscription, no per-query cost." + } + }, "elevenlabs/scribe_v1": { "input_cost_per_second": 6.11e-05, "litellm_provider": "elevenlabs", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 388752b032e5..a378a21b44e0 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -2154,6 +2154,13 @@ "search": true } }, + "tinyfish": { + "display_name": "TinyFish (`tinyfish`)", + "url": "https://docs.litellm.ai/docs/search/tinyfish", + "endpoints": { + "search": true + } + }, "triton": { "display_name": "Triton (`triton`)", "url": "https://docs.litellm.ai/docs/providers/triton-inference-server", diff --git a/tests/code_coverage_tests/enforce_llms_folder_style.py b/tests/code_coverage_tests/enforce_llms_folder_style.py index 370ff13e0298..6960754ceb71 100644 --- a/tests/code_coverage_tests/enforce_llms_folder_style.py +++ b/tests/code_coverage_tests/enforce_llms_folder_style.py @@ -19,6 +19,7 @@ "duckduckgo", "searchapi", "serper", + "tinyfish", ] ALLOWED_FILES_IN_LLMS_FOLDER = [ diff --git a/tests/search_tests/test_tinyfish_search.py b/tests/search_tests/test_tinyfish_search.py new file mode 100644 index 000000000000..6c56adfbb259 --- /dev/null +++ b/tests/search_tests/test_tinyfish_search.py @@ -0,0 +1,189 @@ +""" +Tests for TinyFish Search API integration. +""" + +import os +import pytest +from urllib.parse import urlparse, parse_qs +from unittest.mock import AsyncMock, MagicMock, patch + +import litellm + + +MOCK_TINYFISH_RESPONSE = { + "query": "web automation tools", + "results": [ + { + "position": 1, + "site_name": "tinyfish.ai", + "title": "TinyFish — AI Web Automation", + "snippet": "Automate any website with natural language.", + "url": "https://tinyfish.ai", + }, + { + "position": 2, + "site_name": "github.com", + "title": "Top Web Automation Tools", + "snippet": "A curated list of browser automation frameworks.", + "url": "https://github.com/example/web-automation", + }, + ], + "total_results": 2, + "page": 0, +} + + +def _make_mock_response(json_data: dict, status_code: int = 200) -> MagicMock: + mock = MagicMock() + mock.status_code = status_code + mock.json.return_value = json_data + return mock + + +class TestTinyfishSearch: + @pytest.mark.asyncio + async def test_basic_search(self): + os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test" + + mock_response = _make_mock_response(MOCK_TINYFISH_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="web automation tools", + search_provider="tinyfish", + ) + + assert mock_get.call_count == 1 + + call_args = mock_get.call_args + parsed_url = urlparse(call_args.kwargs["url"]) + assert parsed_url.scheme == "https" + assert parsed_url.netloc == "api.search.tinyfish.ai" + assert parsed_url.path == "" + + query_params = parse_qs(parsed_url.query) + assert query_params["query"] == ["web automation tools"] + + headers = call_args.kwargs.get("headers", {}) + assert headers["X-API-Key"] == "sk-tinyfish-test" + + assert hasattr(response, "results") + assert response.object == "search" + assert len(response.results) == 2 + + first = response.results[0] + assert first.title == "TinyFish — AI Web Automation" + assert first.url == "https://tinyfish.ai" + assert first.snippet == "Automate any website with natural language." + + @pytest.mark.asyncio + async def test_country_maps_to_location(self): + os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test" + + mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE) + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = mock_response + + await litellm.asearch( + query="test", + search_provider="tinyfish", + country="US", + ) + + call_args = mock_get.call_args + parsed_url = urlparse(call_args.kwargs["url"]) + query_params = parse_qs(parsed_url.query) + assert query_params["location"] == ["US"] + + @pytest.mark.asyncio + async def test_domain_filter_injection(self): + os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test" + + mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE) + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = mock_response + + await litellm.asearch( + query="python tutorials", + search_provider="tinyfish", + search_domain_filter=["arxiv.org", "github.com"], + ) + + call_args = mock_get.call_args + parsed_url = urlparse(call_args.kwargs["url"]) + query_params = parse_qs(parsed_url.query) + query_value = query_params["query"][0] + assert "site:arxiv.org" in query_value + assert "site:github.com" in query_value + assert "python tutorials" in query_value + + @pytest.mark.asyncio + async def test_language_passthrough(self): + os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test" + + mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE) + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = mock_response + + await litellm.asearch( + query="test", + search_provider="tinyfish", + language="en", + ) + + call_args = mock_get.call_args + parsed_url = urlparse(call_args.kwargs["url"]) + query_params = parse_qs(parsed_url.query) + assert query_params["language"] == ["en"] + + @pytest.mark.asyncio + async def test_empty_results(self): + os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test" + + empty_response = { + "query": "xyznonexistent", + "results": [], + "total_results": 0, + "page": 0, + } + mock_response = _make_mock_response(empty_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="xyznonexistent", + search_provider="tinyfish", + ) + + assert response.object == "search" + assert len(response.results) == 0 + + def test_missing_api_key(self): + os.environ.pop("TINYFISH_API_KEY", None) + + from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig + + config = TinyfishSearchConfig() + with pytest.raises(ValueError, match="TINYFISH_API_KEY"): + config.validate_environment(headers={}) From 1ba5baf6e795def704b025c2bb04d21827dddf7a Mon Sep 17 00:00:00 2001 From: Simantak Dabhade <67303107+simantak-dabhade@users.noreply.github.com> Date: Wed, 20 May 2026 12:58:07 -0700 Subject: [PATCH 5/6] fix(search/tinyfish): handle max_results client-side and fix include_thumbnail type Address Greptile review feedback: - Map max_results to query param and truncate results client-side (TinyFish API has no count param) - Fix include_thumbnail type annotation from str to bool - Add test for max_results truncation Co-Authored-By: Claude Opus 4.6 --- .../llms/tinyfish/search/transformation.py | 11 +++++- tests/search_tests/test_tinyfish_search.py | 39 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/litellm/llms/tinyfish/search/transformation.py b/litellm/llms/tinyfish/search/transformation.py index ef90ed4d76eb..e36dcd39d2d3 100644 --- a/litellm/llms/tinyfish/search/transformation.py +++ b/litellm/llms/tinyfish/search/transformation.py @@ -28,7 +28,8 @@ class TinyfishSearchRequest(_TinyfishSearchRequestRequired, total=False): location: str language: str page: int - include_thumbnail: str + include_thumbnail: bool + max_results: int class TinyfishSearchConfig(BaseSearchConfig): @@ -86,6 +87,9 @@ def transform_search_request( if "country" in optional_params: request_data["location"] = optional_params["country"] + if "max_results" in optional_params: + request_data["max_results"] = min(int(optional_params["max_results"]), 20) + if "search_domain_filter" in optional_params: domains = optional_params["search_domain_filter"] if isinstance(domains, list) and len(domains) > 0: @@ -117,8 +121,13 @@ def transform_search_response( ) -> SearchResponse: response_json = raw_response.json() + query_params = raw_response.request.url.params if raw_response.request else {} + max_results = max(1, min(int(query_params.get("max_results", 20)), 20)) + results: List[SearchResult] = [] for item in response_json.get("results", []): + if len(results) >= max_results: + break results.append( SearchResult( title=item.get("title", ""), diff --git a/tests/search_tests/test_tinyfish_search.py b/tests/search_tests/test_tinyfish_search.py index 6c56adfbb259..10c4a2fc8dfe 100644 --- a/tests/search_tests/test_tinyfish_search.py +++ b/tests/search_tests/test_tinyfish_search.py @@ -7,6 +7,8 @@ from urllib.parse import urlparse, parse_qs from unittest.mock import AsyncMock, MagicMock, patch +import httpx + import litellm @@ -33,10 +35,17 @@ } -def _make_mock_response(json_data: dict, status_code: int = 200) -> 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 + if request_url: + mock.request = MagicMock() + mock.request.url = httpx.URL(request_url) + else: + mock.request = None return mock @@ -153,6 +162,34 @@ async def test_language_passthrough(self): query_params = parse_qs(parsed_url.query) assert query_params["language"] == ["en"] + def test_max_results_truncates_response(self): + """max_results is sent as a query param and used to truncate results client-side.""" + from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig + + config = TinyfishSearchConfig() + many_results = { + "results": [ + { + "title": f"Result {i}", + "url": f"https://example.com/{i}", + "snippet": f"Snippet {i}", + } + for i in range(10) + ] + } + mock_response = _make_mock_response( + many_results, + request_url="https://api.search.tinyfish.ai?query=test&max_results=3", + ) + + 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" + @pytest.mark.asyncio async def test_empty_results(self): os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test" From 00a7a43cce1999ee357fa768e0ad4a4d6a72ba03 Mon Sep 17 00:00:00 2001 From: Simantak Dabhade <67303107+simantak-dabhade@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:06:59 -0700 Subject: [PATCH 6/6] fix(search/tinyfish): clamp max_results lower bound in transform_search_request Address Greptile P2 feedback: apply max(1, ...) in transform_search_request so the API receives the same clamped value that transform_search_response uses. Co-Authored-By: Claude Opus 4.6 --- litellm/llms/tinyfish/search/transformation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/llms/tinyfish/search/transformation.py b/litellm/llms/tinyfish/search/transformation.py index e36dcd39d2d3..41a003bb3f9e 100644 --- a/litellm/llms/tinyfish/search/transformation.py +++ b/litellm/llms/tinyfish/search/transformation.py @@ -88,7 +88,9 @@ def transform_search_request( request_data["location"] = optional_params["country"] if "max_results" in optional_params: - request_data["max_results"] = min(int(optional_params["max_results"]), 20) + request_data["max_results"] = max( + 1, min(int(optional_params["max_results"]), 20) + ) if "search_domain_filter" in optional_params: domains = optional_params["search_domain_filter"]