fix(exa_ai): estimate unreported search cost from Exa list price

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Rodrigues 2026-10-15 17:35:48 -07:00 • committed by kesku
parent a955a0ef2a
commit d0a37501fc
5 changed files with 147 additions and 36 deletions

View file

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

View file

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

View file

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

View file

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

View file

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