diff --git a/litellm/llms/exa_ai/search/cost_calculator.py b/litellm/llms/exa_ai/search/cost_calculator.py new file mode 100644 index 00000000000..067c8d44e99 --- /dev/null +++ b/litellm/llms/exa_ai/search/cost_calculator.py @@ -0,0 +1,51 @@ +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( + { + "deep-lite": "exa_ai/search-deep", + "deep": "exa_ai/search-deep", + "deep-reasoning": "exa_ai/search-deep-reasoning", + } +) + + +def _non_negative_number(value: object) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0: + return None + return float(value) + + +def _requested_results(optional_params: Mapping[str, object]) -> int: + for key in ("numResults", "max_results"): + requested = _non_negative_number(optional_params.get(key)) + if requested is not None: + return int(requested) + 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: + """ + 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. + """ + 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 diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d0606578658..86f8840551e 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -23885,24 +23885,28 @@ "output_vector_size": 2560 }, "exa_ai/search": { + "input_cost_per_query": 0.007, "litellm_provider": "exa_ai", - "mode": "search", - "tiered_pricing": [ - { - "input_cost_per_query": 0.005, - "max_results_range": [ - 0, - 25 - ] - }, - { - "input_cost_per_query": 0.025, - "max_results_range": [ - 26, - 100 - ] - } - ] + "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)" + }, + "mode": "search" + }, + "exa_ai/search-deep": { + "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)" + }, + "mode": "search" + }, + "exa_ai/search-deep-reasoning": { + "input_cost_per_query": 0.015, + "litellm_provider": "exa_ai", + "metadata": { + "notes": "Base price per /search request with type deep-reasoning, covering up to 10 results. Source: https://exa.ai/pricing (2026-10-15)" + }, + "mode": "search" }, "firecrawl/search": { "litellm_provider": "firecrawl", diff --git a/litellm/search/cost_calculator.py b/litellm/search/cost_calculator.py index c8289a3c835..5c58dc1e4a1 100644 --- a/litellm/search/cost_calculator.py +++ b/litellm/search/cost_calculator.py @@ -42,7 +42,7 @@ 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 response cost when available, otherwise tiered pricing based on max_results. + Uses Exa's reported cost when available, otherwise its list price; other providers use tiered pricing based on max_results. Args: model: Model name (e.g., "exa_ai/search", "tavily/search") @@ -65,6 +65,10 @@ def search_provider_cost_per_query( 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, @@ -79,7 +83,7 @@ def search_provider_cost_per_query( 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) + # Check for tiered pricing based on max_results tiered_pricing: Final = model_info.get("tiered_pricing") if tiered_pricing and isinstance(tiered_pricing, list): max_results: Final = (optional_params or {}).get("max_results", 10) # default 10 results diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d0606578658..86f8840551e 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -23885,24 +23885,28 @@ "output_vector_size": 2560 }, "exa_ai/search": { + "input_cost_per_query": 0.007, "litellm_provider": "exa_ai", - "mode": "search", - "tiered_pricing": [ - { - "input_cost_per_query": 0.005, - "max_results_range": [ - 0, - 25 - ] - }, - { - "input_cost_per_query": 0.025, - "max_results_range": [ - 26, - 100 - ] - } - ] + "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)" + }, + "mode": "search" + }, + "exa_ai/search-deep": { + "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)" + }, + "mode": "search" + }, + "exa_ai/search-deep-reasoning": { + "input_cost_per_query": 0.015, + "litellm_provider": "exa_ai", + "metadata": { + "notes": "Base price per /search request with type deep-reasoning, covering up to 10 results. Source: https://exa.ai/pricing (2026-10-15)" + }, + "mode": "search" }, "firecrawl/search": { "litellm_provider": "firecrawl", diff --git a/tests/unit/llms/exa_ai/search/test_transformation.py b/tests/unit/llms/exa_ai/search/test_transformation.py index 50f308bf141..ed4c1af4c05 100644 --- a/tests/unit/llms/exa_ai/search/test_transformation.py +++ b/tests/unit/llms/exa_ai/search/test_transformation.py @@ -437,3 +437,51 @@ def test_exa_missing_or_invalid_total_uses_estimate(reported: object) -> None: ) == estimate ) + + +@pytest.mark.usefixtures("local_model_cost_map") +def test_exa_estimate_adds_each_result_above_the_included_ten() -> None: + from litellm.llms.exa_ai.search.cost_calculator import EXA_AI_ADDITIONAL_RESULT_COST + from litellm.search.cost_calculator import search_provider_cost_per_query + + base, _ = search_provider_cost_per_query("exa_ai/search", "exa_ai") + + assert base > 0.0 + assert search_provider_cost_per_query("exa_ai/search", "exa_ai", optional_params={"max_results": 5}) == (base, 0.0) + assert search_provider_cost_per_query( + "exa_ai/search", "exa_ai", optional_params={"max_results": 25} + ) == pytest.approx((base + 15 * EXA_AI_ADDITIONAL_RESULT_COST, 0.0)) + assert search_provider_cost_per_query( + "exa_ai/search", "exa_ai", optional_params={"numResults": 12} + ) == pytest.approx((base + 2 * EXA_AI_ADDITIONAL_RESULT_COST, 0.0)) + + +@pytest.mark.usefixtures("local_model_cost_map") +def test_exa_estimate_prices_one_request_however_many_queries_it_joins() -> None: + from litellm.search.cost_calculator import search_provider_cost_per_query + + assert search_provider_cost_per_query( + "exa_ai/search", "exa_ai", number_of_queries=3 + ) == search_provider_cost_per_query("exa_ai/search", "exa_ai") + + +@pytest.mark.usefixtures("local_model_cost_map") +@pytest.mark.parametrize( + ("search_type", "pricing_model"), + [ + ("auto", "exa_ai/search"), + ("fast", "exa_ai/search"), + ("deep-lite", "exa_ai/search-deep"), + ("deep", "exa_ai/search-deep"), + ("deep-reasoning", "exa_ai/search-deep-reasoning"), + ], +) +def test_exa_estimate_uses_the_price_of_its_search_type(search_type: str, pricing_model: str) -> None: + from litellm.search.cost_calculator import search_provider_cost_per_query + + model_info: Final = litellm.get_model_info(model=pricing_model, custom_llm_provider="exa_ai") + + assert search_provider_cost_per_query("exa_ai/search", "exa_ai", optional_params={"type": search_type}) == ( + model_info["input_cost_per_query"], + 0.0, + )