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 <noreply@anthropic.com>
This commit is contained in:
Ilya Bogin 2026-10-22 08:36:12 +03:00
parent 2a7dda9331
commit 2c0ca4fcd3
3 changed files with 33 additions and 12 deletions

View file

@ -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

View file

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

View file

@ -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