From 43c59c2cd713bc6221d2df375a4997cdfb60d54f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:29:49 -0700 Subject: [PATCH] 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. --- litellm/llms/exa_ai/search/cost_calculator.py | 29 ++----------- ...odel_prices_and_context_window_backup.json | 12 +++++- litellm/search/cost_calculator.py | 43 ++++++++++--------- model_prices_and_context_window.json | 12 +++++- .../llms/exa_ai/search/test_transformation.py | 2 +- tests/unit/search/test_main.py | 38 ++++++++++++++++ 6 files changed, 85 insertions(+), 51 deletions(-) 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)