mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-07 07:58:09 +00:00
fix(exa): keep registered search prices ahead of Exa's reported cost
A price set with litellm.register_model() for an Exa search entry now bills the same way main does, instead of being skipped for the costDollars.total Exa returns. Exa search types resolve to their catalog entries through the shared search mode lookup, and deep-lite gets its own entry.
This commit is contained in:
parent
642d5d242a
commit
43c59c2cd7
6 changed files with 85 additions and 51 deletions
|
|
@ -1,21 +1,8 @@
|
|||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from litellm.utils import get_model_info
|
||||
|
||||
EXA_AI_INCLUDED_RESULTS: Final = 10
|
||||
EXA_AI_ADDITIONAL_RESULT_COST: Final = 0.001
|
||||
EXA_AI_STANDARD_SEARCH_MODEL: Final = "exa_ai/search"
|
||||
EXA_AI_PRICING_MODEL_BY_TYPE: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"lite": "exa_ai/search-lite",
|
||||
"instant": "exa_ai/search-instant",
|
||||
"deep-lite": "exa_ai/search-deep",
|
||||
"deep": "exa_ai/search-deep",
|
||||
"deep-reasoning": "exa_ai/search-deep-reasoning",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _non_negative_int(value: object) -> int | None:
|
||||
|
|
@ -32,22 +19,12 @@ def _requested_results(optional_params: Mapping[str, object]) -> int:
|
|||
return EXA_AI_INCLUDED_RESULTS
|
||||
|
||||
|
||||
def _base_request_cost(search_type: object) -> float:
|
||||
pricing_model: Final = (
|
||||
EXA_AI_PRICING_MODEL_BY_TYPE.get(search_type, EXA_AI_STANDARD_SEARCH_MODEL)
|
||||
if isinstance(search_type, str)
|
||||
else EXA_AI_STANDARD_SEARCH_MODEL
|
||||
)
|
||||
model_info: Final = get_model_info(model=pricing_model, custom_llm_provider="exa_ai")
|
||||
return float(model_info.get("input_cost_per_query") or 0.0)
|
||||
|
||||
|
||||
def exa_ai_list_price(optional_params: Mapping[str, object]) -> float:
|
||||
def exa_ai_list_price(base_price: float, optional_params: Mapping[str, object]) -> float:
|
||||
"""
|
||||
List price of one Exa /search request, for responses that carry no `costDollars.total`.
|
||||
|
||||
Multi-query searches are joined into a single request, so this prices one request:
|
||||
the per-type base price plus a per-result charge above the results that price includes.
|
||||
the search type's base price plus a per-result charge above the results that price includes.
|
||||
"""
|
||||
additional_results: Final = max(_requested_results(optional_params) - EXA_AI_INCLUDED_RESULTS, 0)
|
||||
return _base_request_cost(optional_params.get("type")) + additional_results * EXA_AI_ADDITIONAL_RESULT_COST
|
||||
return base_price + additional_results * EXA_AI_ADDITIONAL_RESULT_COST
|
||||
|
|
|
|||
|
|
@ -23627,7 +23627,7 @@
|
|||
"input_cost_per_query": 0.007,
|
||||
"litellm_provider": "exa_ai",
|
||||
"metadata": {
|
||||
"notes": "Base price per /search request, covering up to 10 results. Each result above 10 adds $0.001. Exa's reported costDollars.total takes precedence when present. Source: https://exa.ai/pricing (2026-10-15)"
|
||||
"notes": "Base price per /search request, covering up to 10 results. Each result above 10 adds $0.001. Exa's reported costDollars.total takes precedence when present, unless this price is registered at runtime. Source: https://exa.ai/pricing (2026-10-15)"
|
||||
},
|
||||
"mode": "search"
|
||||
},
|
||||
|
|
@ -23651,7 +23651,15 @@
|
|||
"input_cost_per_query": 0.012,
|
||||
"litellm_provider": "exa_ai",
|
||||
"metadata": {
|
||||
"notes": "Base price per /search request with type deep or deep-lite, covering up to 10 results. Source: https://exa.ai/pricing (2026-10-15)"
|
||||
"notes": "Base price per /search request with type deep, covering up to 10 results. Source: https://exa.ai/pricing (2026-10-15)"
|
||||
},
|
||||
"mode": "search"
|
||||
},
|
||||
"exa_ai/search-deep-lite": {
|
||||
"input_cost_per_query": 0.012,
|
||||
"litellm_provider": "exa_ai",
|
||||
"metadata": {
|
||||
"notes": "Base price per /search request with type deep-lite, covering up to 10 results. Source: https://exa.ai/docs/reference/pricing (2026-10-27)"
|
||||
},
|
||||
"mode": "search"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ PROVIDER_USAGE_ADAPTER: Final[TypeAdapter[tuple[Mapping[str, object], ...]]] = T
|
|||
PROVIDER_COST_ADAPTER: Final = TypeAdapter(Mapping[str, object])
|
||||
EMPTY_OPTIONAL_PARAMS: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
SEARCH_MODE_PARAM_BY_PROVIDER: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{"tavily": "search_depth", "linkup": "depth"}
|
||||
{"tavily": "search_depth", "linkup": "depth", "exa_ai": "type"}
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -73,11 +73,9 @@ def search_provider_cost_per_query(
|
|||
Calculate cost for search-only providers.
|
||||
|
||||
Returns (input_cost, output_cost) where input_cost = queries * cost_per_query
|
||||
Uses Exa's reported cost when available, otherwise its list price; other providers use tiered pricing based on max_results.
|
||||
|
||||
|
||||
Supports tiered pricing based on max_results parameter, and per-mode price entries selected by the
|
||||
provider's mode parameter (Tavily `search_depth`, Linkup `depth`).
|
||||
provider's mode parameter (Tavily `search_depth`, Linkup `depth`, Exa `type`). Exa bills one request at the
|
||||
`costDollars.total` it reports, else at its list price, unless the price entry was registered at runtime.
|
||||
|
||||
Args:
|
||||
model: Model name (e.g., "exa_ai/search", "tavily/search")
|
||||
|
|
@ -89,21 +87,6 @@ def search_provider_cost_per_query(
|
|||
Returns:
|
||||
Tuple of (input_cost, output_cost) where output_cost is always 0.0
|
||||
"""
|
||||
if custom_llm_provider == "exa_ai":
|
||||
payload: Final = response.model_extra if isinstance(response, SearchResponse) else response
|
||||
reported: Final = (
|
||||
PROVIDER_COST_ADAPTER.validate_python(payload).get("costDollars") if isinstance(payload, Mapping) else None
|
||||
)
|
||||
total: Final = (
|
||||
PROVIDER_COST_ADAPTER.validate_python(reported).get("total") if isinstance(reported, Mapping) else None
|
||||
)
|
||||
if isinstance(total, (int, float)) and not isinstance(total, bool) and math.isfinite(total) and total >= 0:
|
||||
return (float(total), 0.0)
|
||||
|
||||
from litellm.llms.exa_ai.search.cost_calculator import exa_ai_list_price
|
||||
|
||||
return (exa_ai_list_price(optional_params if optional_params is not None else EMPTY_OPTIONAL_PARAMS), 0.0)
|
||||
|
||||
if custom_llm_provider == "parallel_ai":
|
||||
from litellm.llms.parallel_ai.search.cost_calculator import (
|
||||
PARALLEL_AI_USAGE_PARAM,
|
||||
|
|
@ -120,6 +103,26 @@ def search_provider_cost_per_query(
|
|||
model=_pricing_model(model, custom_llm_provider, optional_params),
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
if custom_llm_provider == "exa_ai" and not _registered_at_runtime(model_info["key"]):
|
||||
payload: Final = response.model_extra if isinstance(response, SearchResponse) else response
|
||||
reported: Final = (
|
||||
PROVIDER_COST_ADAPTER.validate_python(payload).get("costDollars") if isinstance(payload, Mapping) else None
|
||||
)
|
||||
total: Final = (
|
||||
PROVIDER_COST_ADAPTER.validate_python(reported).get("total") if isinstance(reported, Mapping) else None
|
||||
)
|
||||
if isinstance(total, (int, float)) and not isinstance(total, bool) and math.isfinite(total) and total >= 0:
|
||||
return (float(total), 0.0)
|
||||
|
||||
from litellm.llms.exa_ai.search.cost_calculator import exa_ai_list_price
|
||||
|
||||
return (
|
||||
exa_ai_list_price(
|
||||
base_price=float(model_info.get("input_cost_per_query") or 0.0),
|
||||
optional_params=optional_params if optional_params is not None else EMPTY_OPTIONAL_PARAMS,
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
|
||||
# Check for tiered pricing based on max_results
|
||||
tiered_pricing: Final = model_info.get("tiered_pricing")
|
||||
|
|
|
|||
|
|
@ -23627,7 +23627,7 @@
|
|||
"input_cost_per_query": 0.007,
|
||||
"litellm_provider": "exa_ai",
|
||||
"metadata": {
|
||||
"notes": "Base price per /search request, covering up to 10 results. Each result above 10 adds $0.001. Exa's reported costDollars.total takes precedence when present. Source: https://exa.ai/pricing (2026-10-15)"
|
||||
"notes": "Base price per /search request, covering up to 10 results. Each result above 10 adds $0.001. Exa's reported costDollars.total takes precedence when present, unless this price is registered at runtime. Source: https://exa.ai/pricing (2026-10-15)"
|
||||
},
|
||||
"mode": "search"
|
||||
},
|
||||
|
|
@ -23651,7 +23651,15 @@
|
|||
"input_cost_per_query": 0.012,
|
||||
"litellm_provider": "exa_ai",
|
||||
"metadata": {
|
||||
"notes": "Base price per /search request with type deep or deep-lite, covering up to 10 results. Source: https://exa.ai/pricing (2026-10-15)"
|
||||
"notes": "Base price per /search request with type deep, covering up to 10 results. Source: https://exa.ai/pricing (2026-10-15)"
|
||||
},
|
||||
"mode": "search"
|
||||
},
|
||||
"exa_ai/search-deep-lite": {
|
||||
"input_cost_per_query": 0.012,
|
||||
"litellm_provider": "exa_ai",
|
||||
"metadata": {
|
||||
"notes": "Base price per /search request with type deep-lite, covering up to 10 results. Source: https://exa.ai/docs/reference/pricing (2026-10-27)"
|
||||
},
|
||||
"mode": "search"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -523,7 +523,7 @@ def test_exa_estimate_prices_one_request_however_many_queries_it_joins() -> None
|
|||
("instant", "exa_ai/search-instant"),
|
||||
("auto", "exa_ai/search"),
|
||||
("fast", "exa_ai/search"),
|
||||
("deep-lite", "exa_ai/search-deep"),
|
||||
("deep-lite", "exa_ai/search-deep-lite"),
|
||||
("deep", "exa_ai/search-deep"),
|
||||
("deep-reasoning", "exa_ai/search-deep-reasoning"),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -279,3 +279,41 @@ async def test_async_linkup_deep_search_reports_the_deep_price(
|
|||
)
|
||||
assert json.loads(respx_mock.calls.last.request.content)["depth"] == "deep"
|
||||
assert response.hidden_params["response_cost"] == pytest.approx(0.011)
|
||||
|
||||
|
||||
EXA_REGISTERED_PRICE: Final = 0.123
|
||||
EXA_REPORTED_TOTAL: Final = 0.0137
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_loaded_catalog_prices")
|
||||
@pytest.mark.parametrize(
|
||||
("registered_entry", "search_type", "expected"),
|
||||
[
|
||||
pytest.param("exa_ai/search", None, EXA_REGISTERED_PRICE, id="generic-entry"),
|
||||
pytest.param("exa_ai/search", "deep", EXA_REGISTERED_PRICE, id="generic-entry-covers-unregistered-mode"),
|
||||
pytest.param("exa_ai/search-deep", "deep", EXA_REGISTERED_PRICE, id="mode-entry"),
|
||||
pytest.param("exa_ai/search-deep", "auto", EXA_REPORTED_TOTAL, id="other-mode-keeps-reported-total"),
|
||||
pytest.param(None, "deep", EXA_REPORTED_TOTAL, id="nothing-registered"),
|
||||
],
|
||||
)
|
||||
def test_a_price_registered_for_exa_wins_over_the_cost_exa_reports(
|
||||
respx_mock: respx.MockRouter, registered_entry: str | None, search_type: str | None, expected: float
|
||||
) -> None:
|
||||
respx_mock.post(EXA_SEARCH_URL).respond(json={"results": [], "costDollars": {"total": EXA_REPORTED_TOTAL}})
|
||||
if registered_entry is not None:
|
||||
litellm.register_model(
|
||||
{
|
||||
registered_entry: {
|
||||
"input_cost_per_query": EXA_REGISTERED_PRICE,
|
||||
"litellm_provider": "exa_ai",
|
||||
"mode": "search",
|
||||
}
|
||||
}
|
||||
)
|
||||
response: Final = litellm.search(
|
||||
query="litellm release notes",
|
||||
search_provider="exa_ai",
|
||||
api_key="test-exa-key",
|
||||
**({"type": search_type} if search_type is not None else {}),
|
||||
)
|
||||
assert response._hidden_params["response_cost"] == pytest.approx(expected)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue