From 2c0ca4fcd3356ea8218b748bfdaa0c11d1a4755a Mon Sep 17 00:00:00 2001 From: Ilya Bogin Date: Thu, 22 Oct 2026 08:36:12 +0300 Subject: [PATCH] fix(keenable): run the Keenable tests in the llms-providers shard and pass the LIT gate - Name tests/unit/llms/keenable in the llms-providers shard, which the new shard-coverage check requires (it also left the tests out of coverage). - Give the logging_obj keyless-flag write a `# rebind-ok` reason, as other providers do for the same cost-routing write. - Restore upstream formatting in the shared base search test; keep only the Keenable import and PROVIDERS row. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test-unit.yml | 1 + .../llms/keenable/search/transformation.py | 2 +- .../search/test_base_search_transformation.py | 42 ++++++++++++++----- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 2276efc4e34..b5527c31000 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -284,6 +284,7 @@ jobs: tests/unit/llms/inception tests/unit/llms/infinity tests/unit/llms/jina_ai + tests/unit/llms/keenable tests/unit/llms/lambda_ai tests/unit/llms/langflow tests/unit/llms/langgraph diff --git a/litellm/llms/keenable/search/transformation.py b/litellm/llms/keenable/search/transformation.py index 41e6f2a45e9..8b1e1e34709 100644 --- a/litellm/llms/keenable/search/transformation.py +++ b/litellm/llms/keenable/search/transformation.py @@ -211,7 +211,7 @@ class KeenableSearchConfig(BaseSearchConfig): headers=dict(raw_response.headers), ) - logging_obj.optional_params = { + logging_obj.optional_params = { # rebind-ok: hands the keyless flag to the search cost calculator **logging_obj.optional_params, KEENABLE_KEYLESS_PARAM: _answered_keyless(raw_response), } diff --git a/tests/unit/llms/base_llm/search/test_base_search_transformation.py b/tests/unit/llms/base_llm/search/test_base_search_transformation.py index 122bfa109de..f29b626b3f6 100644 --- a/tests/unit/llms/base_llm/search/test_base_search_transformation.py +++ b/tests/unit/llms/base_llm/search/test_base_search_transformation.py @@ -25,9 +25,9 @@ from litellm.llms.brave.search.transformation import BraveSearchConfig from litellm.llms.dataforseo.search.transformation import DataForSEOSearchConfig from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig from litellm.llms.fastcrw.search.transformation import FastCRWSearchConfig -from litellm.llms.keenable.search.transformation import KeenableSearchConfig from litellm.llms.firecrawl.search.transformation import FirecrawlSearchConfig from litellm.llms.google_pse.search.transformation import GooglePSESearchConfig +from litellm.llms.keenable.search.transformation import KeenableSearchConfig from litellm.llms.linkup.search.transformation import LinkupSearchConfig from litellm.llms.nimble.search.transformation import NimbleSearchConfig from litellm.llms.parallel_ai.search.transformation import ParallelAISearchConfig @@ -109,7 +109,9 @@ PROVIDERS: Tuple[ProviderSpec, ...] = ( _IDS = tuple(spec[0].__name__ for spec in PROVIDERS) -@pytest.mark.parametrize("config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS) +@pytest.mark.parametrize( + "config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS +) def test_server_secret_refused_for_caller_api_base( config_cls: Type[BaseSearchConfig], server_env: Dict[str, str], @@ -124,7 +126,9 @@ def test_server_secret_refused_for_caller_api_base( config_cls().validate_environment(headers={}, api_base=ATTACKER_BASE) -@pytest.mark.parametrize("config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS) +@pytest.mark.parametrize( + "config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS +) def test_caller_supplied_key_is_honored_for_custom_api_base( config_cls: Type[BaseSearchConfig], server_env: Dict[str, str], @@ -137,10 +141,14 @@ def test_caller_supplied_key_is_honored_for_custom_api_base( # An explicit caller key is the caller's own credential, so pointing it at # the caller's own host must be allowed. - config_cls().validate_environment(headers={}, api_key=caller_key, api_base=ATTACKER_BASE) + config_cls().validate_environment( + headers={}, api_key=caller_key, api_base=ATTACKER_BASE + ) -@pytest.mark.parametrize("config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS) +@pytest.mark.parametrize( + "config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS +) def test_server_secret_used_without_caller_api_base( config_cls: Type[BaseSearchConfig], server_env: Dict[str, str], @@ -161,7 +169,9 @@ def test_keyless_provider_allows_caller_api_base( ) -> None: monkeypatch.delenv("SEARXNG_API_KEY", raising=False) - headers = SearXNGSearchConfig().validate_environment(headers={}, api_base="https://my-searxng.internal") + headers = SearXNGSearchConfig().validate_environment( + headers={}, api_base="https://my-searxng.internal" + ) assert "Authorization" not in headers @@ -174,7 +184,9 @@ def test_operator_env_base_override_is_trusted( # Mirrors the second validate_environment call in the search handler, which # receives the already-resolved operator base as api_base. - headers = SerperSearchConfig().validate_environment(headers={}, api_base="https://serper.internal.corp/search") + headers = SerperSearchConfig().validate_environment( + headers={}, api_base="https://serper.internal.corp/search" + ) assert headers["X-API-KEY"] == "srv" @@ -190,7 +202,9 @@ class TestResolveServerApiKey: ) assert result == "mine" - def test_returns_none_when_no_server_secret(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_returns_none_when_no_server_secret( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.delenv("SEARXNG_API_KEY", raising=False) result = BaseSearchConfig().resolve_server_api_key( caller_api_key=None, @@ -216,10 +230,14 @@ class TestResolveServerApiKey: class TestIsTrustedSearchApiBase: def test_matches_default_host(self) -> None: - assert _is_trusted_search_api_base("https://google.serper.dev/search", "https://google.serper.dev", None) + assert _is_trusted_search_api_base( + "https://google.serper.dev/search", "https://google.serper.dev", None + ) def test_foreign_host_untrusted(self) -> None: - assert not _is_trusted_search_api_base(ATTACKER_BASE, "https://google.serper.dev", None) + assert not _is_trusted_search_api_base( + ATTACKER_BASE, "https://google.serper.dev", None + ) def test_env_override_host_trusted(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("SERPER_API_BASE", "https://serper.internal.corp") @@ -232,7 +250,9 @@ class TestIsTrustedSearchApiBase: def test_schemeless_candidate_untrusted(self) -> None: # Without a scheme urlsplit puts the value in the path, leaving an empty # netloc; an unparseable host must never be treated as trusted. - assert not _is_trusted_search_api_base("attacker.example.com", "https://google.serper.dev", None) + assert not _is_trusted_search_api_base( + "attacker.example.com", "https://google.serper.dev", None + ) @pytest.mark.asyncio