diff --git a/litellm/llms/exa_ai/search/cost_calculator.py b/litellm/llms/exa_ai/search/cost_calculator.py index 4b1d561cf47..936033a4008 100644 --- a/litellm/llms/exa_ai/search/cost_calculator.py +++ b/litellm/llms/exa_ai/search/cost_calculator.py @@ -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 diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index a8c74168629..16bb6a7e219 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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" }, diff --git a/litellm/search/cost_calculator.py b/litellm/search/cost_calculator.py index 746f470a8ba..6f4048b5654 100644 --- a/litellm/search/cost_calculator.py +++ b/litellm/search/cost_calculator.py @@ -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") diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index a8c74168629..16bb6a7e219 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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" }, diff --git a/tests/unit/llms/exa_ai/search/test_transformation.py b/tests/unit/llms/exa_ai/search/test_transformation.py index 0e65832d22f..12c21502536 100644 --- a/tests/unit/llms/exa_ai/search/test_transformation.py +++ b/tests/unit/llms/exa_ai/search/test_transformation.py @@ -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"), ], diff --git a/tests/unit/search/test_main.py b/tests/unit/search/test_main.py index e30fd2eb6f8..5bd24738414 100644 --- a/tests/unit/search/test_main.py +++ b/tests/unit/search/test_main.py @@ -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)