From 7f08afa1ccaf4667006823a017748c33df74ff0d Mon Sep 17 00:00:00 2001 From: Ilya Bogin Date: Wed, 7 Oct 2026 07:52:44 +0300 Subject: [PATCH] fix(keenable): bill keyed searches only Keyless requests to /v1/search/public are free, keyed requests to /v1/search are billed per query. The response transform records which endpoint answered, and the search cost calculator prices a keyless search at $0 and a keyed one at the $4 per 1,000 list rate, so keyed spend counts against proxy budgets again. --- .../llms/keenable/search/transformation.py | 27 ++++++++- ...odel_prices_and_context_window_backup.json | 4 +- litellm/search/cost_calculator.py | 7 +++ model_prices_and_context_window.json | 4 +- .../test_keenable_search_transformation.py | 58 +++++++++++++++---- 5 files changed, 84 insertions(+), 16 deletions(-) diff --git a/litellm/llms/keenable/search/transformation.py b/litellm/llms/keenable/search/transformation.py index b68c3460fe3..41e6f2a45e9 100644 --- a/litellm/llms/keenable/search/transformation.py +++ b/litellm/llms/keenable/search/transformation.py @@ -71,6 +71,10 @@ class _ErrorEnvelope(LiteLLMBaseModel): _DomainListAdapter: Final = TypeAdapter(tuple[str, ...]) +# Set on the logging object's optional_params from the endpoint that answered, and read by the +# search cost calculator: keyless searches are free, keyed ones are billed per query. +KEENABLE_KEYLESS_PARAM: Final = "_keenable_keyless" + _NOTHING: Final[Mapping[str, object]] = MappingProxyType({}) @@ -161,8 +165,13 @@ class KeenableSearchConfig(BaseSearchConfig): site: Final = include[0] if len(include) == 1 else None # Spread after the derived `site` so an explicitly supplied one wins. + # The keyless flag is reserved for pricing and never sent to Keenable. passthrough: Final = MappingProxyType( - {param: value for param, value in optional_params.items() if param not in unified_params} + { + param: value + for param, value in optional_params.items() + if param not in unified_params and param != KEENABLE_KEYLESS_PARAM + } ) return { @@ -189,6 +198,9 @@ class KeenableSearchConfig(BaseSearchConfig): first. `date` is the page's `published_at` and `last_updated` is `acquired_at`, when Keenable last fetched the page. A body that does not match the documented schema raises an attributed error rather than being reported as a successful empty search. + + Records whether the keyless endpoint answered, so the cost calculator prices only keyed + searches. Written unconditionally, so a caller-supplied value never sets its own cost. """ try: parsed: Final = _KeenableSearchResponse.model_validate_json(raw_response.content) @@ -199,6 +211,11 @@ class KeenableSearchConfig(BaseSearchConfig): headers=dict(raw_response.headers), ) + logging_obj.optional_params = { + **logging_obj.optional_params, + KEENABLE_KEYLESS_PARAM: _answered_keyless(raw_response), + } + return SearchResponse( results=[ SearchResult( @@ -233,6 +250,14 @@ class KeenableSearchConfig(BaseSearchConfig): ) +def _answered_keyless(raw_response: httpx.Response) -> bool: + """True when /search/public answered. An unknown endpoint counts as keyed, so it is billed.""" + try: + return raw_response.request.url.path.endswith("/search/public") + except RuntimeError: # a response built without its request + return False + + def _unwrap_error_detail(error_message: str) -> str: """ Surface the human-readable message inside Keenable's error envelope. diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index f7ba43b8476..db29a22bb77 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -22866,11 +22866,11 @@ } }, "keenable/search": { - "input_cost_per_query": 0.0, + "input_cost_per_query": 0.004, "litellm_provider": "keenable", "mode": "search", "metadata": { - "notes": "Keyless requests to /v1/search/public are free and rate limited per IP, and keyed accounts get 100,000 free requests a month. Usage past that is billed by Keenable at $4 per 1,000 requests and is not tracked here." + "notes": "Keyed requests to /v1/search are priced at Keenable's $4 per 1,000 list rate; the 100,000 free requests a month per organization are not tracked here. Keyless requests to /v1/search/public are free and priced at $0 by the search cost calculator." } }, "elevenlabs/scribe_v1": { diff --git a/litellm/search/cost_calculator.py b/litellm/search/cost_calculator.py index 21f27075e0f..398cd192733 100644 --- a/litellm/search/cost_calculator.py +++ b/litellm/search/cost_calculator.py @@ -61,6 +61,13 @@ def search_provider_cost_per_query( ) return (input_cost, 0.0) + if custom_llm_provider == "keenable": + from litellm.llms.keenable.search.transformation import KEENABLE_KEYLESS_PARAM + + # /v1/search/public is free; only keyed searches carry the per-query price. + if (optional_params or EMPTY_OPTIONAL_PARAMS).get(KEENABLE_KEYLESS_PARAM) is True: + return (0.0, 0.0) + model_info: Final = get_model_info(model=model, custom_llm_provider=custom_llm_provider) # Check for tiered pricing (e.g., Exa AI based on max_results) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f7ba43b8476..db29a22bb77 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -22866,11 +22866,11 @@ } }, "keenable/search": { - "input_cost_per_query": 0.0, + "input_cost_per_query": 0.004, "litellm_provider": "keenable", "mode": "search", "metadata": { - "notes": "Keyless requests to /v1/search/public are free and rate limited per IP, and keyed accounts get 100,000 free requests a month. Usage past that is billed by Keenable at $4 per 1,000 requests and is not tracked here." + "notes": "Keyed requests to /v1/search are priced at Keenable's $4 per 1,000 list rate; the 100,000 free requests a month per organization are not tracked here. Keyless requests to /v1/search/public are free and priced at $0 by the search cost calculator." } }, "elevenlabs/scribe_v1": { diff --git a/tests/unit/llms/keenable/search/test_keenable_search_transformation.py b/tests/unit/llms/keenable/search/test_keenable_search_transformation.py index b29408cbe0a..665bb8e52b0 100644 --- a/tests/unit/llms/keenable/search/test_keenable_search_transformation.py +++ b/tests/unit/llms/keenable/search/test_keenable_search_transformation.py @@ -6,7 +6,7 @@ import pytest import litellm from litellm.llms.base_llm.chat.transformation import BaseLLMException -from litellm.llms.keenable.search.transformation import KeenableSearchConfig +from litellm.llms.keenable.search.transformation import KEENABLE_KEYLESS_PARAM, KeenableSearchConfig DEFAULT_ROOT = "https://api.keenable.ai/v1" @@ -20,12 +20,12 @@ def _config() -> KeenableSearchConfig: return KeenableSearchConfig() -def _resp(payload, status_code: int = 200): - r = Mock() - r.status_code = status_code - r.headers = {} - r.content = (payload if isinstance(payload, str) else json.dumps(payload)).encode() - return r +def _resp(payload, status_code: int = 200, path: str = "/search/public"): + return httpx.Response( + status_code, + content=(payload if isinstance(payload, str) else json.dumps(payload)).encode(), + request=httpx.Request("POST", f"{DEFAULT_ROOT}{path}"), + ) def _result(**overrides): @@ -162,7 +162,7 @@ def test_malformed_domain_filter_is_ignored(): def test_response_maps_snippet_and_dates(): response = _config().transform_search_response( _resp({"query": "q", "results": [_result(), _result(snippet="", description="Only a description")]}), - logging_obj=Mock(), + logging_obj=Mock(optional_params={}), ) first, second = response.results @@ -173,20 +173,22 @@ def test_response_maps_snippet_and_dates(): def test_degraded_result_maps_to_empty_strings(): - response = _config().transform_search_response(_resp({"results": [{}]}), logging_obj=Mock()) + response = _config().transform_search_response(_resp({"results": [{}]}), logging_obj=Mock(optional_params={})) only = response.results[0] assert (only.title, only.url, only.snippet, only.date, only.last_updated) == ("", "", "", None, None) def test_empty_result_list_is_a_successful_search(): - assert _config().transform_search_response(_resp({"results": []}), logging_obj=Mock()).results == [] + assert ( + _config().transform_search_response(_resp({"results": []}), logging_obj=Mock(optional_params={})).results == [] + ) @pytest.mark.parametrize("payload", [{}, {"results": None}, "bad gateway"]) def test_body_that_is_not_a_search_response_raises(payload): with pytest.raises(BaseLLMException, match="Keenable Search: response does not match"): - _config().transform_search_response(_resp(payload, status_code=200), logging_obj=Mock()) + _config().transform_search_response(_resp(payload, status_code=200), logging_obj=Mock(optional_params={})) def test_error_class_surfaces_the_api_message(): @@ -259,3 +261,37 @@ def test_litellm_search_surfaces_the_api_error_message(): assert "Public API hourly limit reached" in str(excinfo.value) assert "set KEENABLE_API_KEY" in str(excinfo.value) assert "does not match" not in str(excinfo.value) + + +@pytest.mark.parametrize("path, keyless", [("/search/public", True), ("/search", False)]) +def test_response_records_which_endpoint_answered(path: str, keyless: bool): + logging_obj = Mock(optional_params={"max_results": 3, KEENABLE_KEYLESS_PARAM: not keyless}) + _config().transform_search_response(_resp({"results": []}, path=path), logging_obj=logging_obj) + assert logging_obj.optional_params == {"max_results": 3, KEENABLE_KEYLESS_PARAM: keyless} + + +def test_response_without_its_request_counts_as_keyed(): + logging_obj = Mock(optional_params={}) + _config().transform_search_response(httpx.Response(200, json={"results": []}), logging_obj=logging_obj) + assert logging_obj.optional_params == {KEENABLE_KEYLESS_PARAM: False} + + +def test_the_keyless_flag_is_never_sent(): + body = _config().transform_search_request(query="q", optional_params={KEENABLE_KEYLESS_PARAM: True}) + assert body == {"query": "q"} + + +@pytest.mark.parametrize( + "optional_params, cost", + [({KEENABLE_KEYLESS_PARAM: True}, 0.0), ({KEENABLE_KEYLESS_PARAM: False}, 0.004), (None, 0.004), ({}, 0.004)], +) +def test_only_keyed_searches_are_billed(monkeypatch: pytest.MonkeyPatch, optional_params, cost: float): + """Assert against the map in this checkout: the remote cost map litellm loads by + default only carries providers already released.""" + from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap + from litellm.search.cost_calculator import search_provider_cost_per_query + + monkeypatch.setattr(litellm, "model_cost", GetModelCostMap.load_local_model_cost_map()) + assert search_provider_cost_per_query( + model="keenable/search", custom_llm_provider="keenable", optional_params=optional_params + ) == (cost, 0.0)