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:
mateo-berri 2026-10-27 23:29:49 -07:00
parent 642d5d242a
commit 43c59c2cd7
6 changed files with 85 additions and 51 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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"),
],

View file

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