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.
This commit is contained in:
Ilya Bogin 2026-10-07 07:52:44 +03:00
parent 12f6731cef
commit 7f08afa1cc
5 changed files with 84 additions and 16 deletions

View file

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

View file

@ -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": {

View file

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

View file

@ -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": {

View file

@ -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}, "<html>bad gateway</html>"])
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)