mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-23 05:34:41 +00:00
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:
parent
12f6731cef
commit
7f08afa1cc
5 changed files with 84 additions and 16 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue