Merge pull request #37112 from mubashir1osmani/litellm_add_perplexity_agent_api_models

feat(perplexity): add Agent API third-party models
This commit is contained in:
Mateo Wang 2026-08-20 14:49:12 -07:00 committed by GitHub
commit d556fac56b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 351 additions and 21 deletions

View file

@ -25,6 +25,11 @@ class ResponsesToCompletionBridgeHandlerInputKwargs(TypedDict):
encoding: object
def _restore_routing_prefix(model: str, custom_llm_provider: str) -> str:
"""`responses()` runs `get_llm_provider()` itself, so hand back the prefixed model `completion()` started from."""
return f"{custom_llm_provider}/{model}"
class ResponsesToCompletionBridgeHandler:
def __init__(self):
from .transformation import LiteLLMResponsesTransformationHandler
@ -184,14 +189,11 @@ class ResponsesToCompletionBridgeHandler:
client=kwargs.get("client"),
)
# Pin the resolved provider so `responses()` doesn't re-run
# `get_llm_provider()` on the model string and strip a second
# provider prefix (see GitHub issue #28505). request_data already
# carries `custom_llm_provider` via the spread of
# `sanitized_litellm_params`; overwriting it on the dict (rather
# than adding an explicit kwarg) avoids the duplicate-keyword
# TypeError that would otherwise fire on the real bridge path.
# Set on request_data rather than passed as explicit kwargs: the spread of
# `sanitized_litellm_params` already carries both, so passing them again
# would raise a duplicate-keyword TypeError.
request_data["custom_llm_provider"] = custom_llm_provider
request_data["model"] = _restore_routing_prefix(model, custom_llm_provider)
result: Final = responses(
**request_data,
)
@ -282,13 +284,11 @@ class ResponsesToCompletionBridgeHandler:
except Exception as e:
raise e
# Pin the resolved provider so `aresponses()` doesn't re-run
# `get_llm_provider()` on the model string and strip a second
# provider prefix (see GitHub issue #28505). Set on request_data
# rather than passed as a separate kwarg to avoid the duplicate-
# keyword TypeError when `sanitized_litellm_params` already
# carries `custom_llm_provider`.
# Set on request_data rather than passed as explicit kwargs: the spread of
# `sanitized_litellm_params` already carries both, so passing them again
# would raise a duplicate-keyword TypeError.
request_data["custom_llm_provider"] = custom_llm_provider
request_data["model"] = _restore_routing_prefix(model, custom_llm_provider)
result: Final = await aresponses(
**request_data,
aresponses=True,

View file

@ -21,14 +21,19 @@ def cost_per_token(model: str, usage: Usage) -> tuple[float, float]:
Tuple[float, float] - prompt_cost_in_usd, completion_cost_in_usd
"""
## USE PRE-CALCULATED COST FROM PERPLEXITY IF AVAILABLE
## Perplexity returns accurate cost in usage.cost.total_cost including request fees
## Perplexity returns accurate cost in usage.cost.total_cost including request fees.
## By the time it reaches here, ResponseAPIUsage.parse_cost has already flattened
## that dict down to a float, so both shapes must be accepted.
cost_info: Final = getattr(usage, "cost", None)
if cost_info is not None and isinstance(cost_info, dict):
total_cost: Final = cost_info.get("total_cost")
if total_cost is not None:
# Return total cost as completion_cost (prompt_cost=0) since Perplexity
# doesn't break down by input/output in their cost object
return (0.0, float(total_cost))
total_cost: float | None = None
if isinstance(cost_info, dict):
total_cost = cost_info.get("total_cost")
elif isinstance(cost_info, (int, float)) and not isinstance(cost_info, bool):
total_cost = float(cost_info)
if total_cost is not None:
# Return total cost as completion_cost (prompt_cost=0) since Perplexity
# doesn't break down by input/output in their cost object
return (0.0, float(total_cost))
## FALLBACK: Calculate cost manually if Perplexity doesn't provide it
## GET MODEL INFO

View file

@ -34758,6 +34758,50 @@
"supports_reasoning": false,
"supports_function_calling": true
},
"perplexity/perplexity/deepseek-v4-flash-0731": {
"cache_read_input_token_cost": 2.8e-08,
"input_cost_per_token": 1.3e-07,
"litellm_provider": "perplexity",
"mode": "responses",
"output_cost_per_token": 2.6e-07,
"source": "https://docs.perplexity.ai/docs/agent-api/models",
"supports_web_search": true,
"supports_reasoning": true,
"supports_function_calling": true
},
"perplexity/perplexity/glm-5.2": {
"cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 1.4e-06,
"litellm_provider": "perplexity",
"mode": "responses",
"output_cost_per_token": 4.4e-06,
"source": "https://docs.perplexity.ai/docs/agent-api/models",
"supports_web_search": true,
"supports_reasoning": true,
"supports_function_calling": true
},
"perplexity/perplexity/kimi-k3": {
"cache_read_input_token_cost": 3e-07,
"input_cost_per_token": 3e-06,
"litellm_provider": "perplexity",
"mode": "responses",
"output_cost_per_token": 1.5e-05,
"source": "https://docs.perplexity.ai/docs/agent-api/models",
"supports_web_search": true,
"supports_reasoning": true,
"supports_function_calling": true
},
"perplexity/perplexity/kimi-k2.7-code": {
"cache_read_input_token_cost": 1.9e-07,
"input_cost_per_token": 9.5e-07,
"litellm_provider": "perplexity",
"mode": "responses",
"output_cost_per_token": 4e-06,
"source": "https://docs.perplexity.ai/docs/agent-api/models",
"supports_web_search": true,
"supports_reasoning": false,
"supports_function_calling": true
},
"perplexity/pplx-embed-v1-0.6b": {
"input_cost_per_token": 4e-09,
"litellm_provider": "perplexity",

View file

@ -5245,7 +5245,7 @@ def _check_provider_match(model_info: dict, custom_llm_provider: str | None) ->
return True
from typing_extensions import TypedDict
from typing_extensions import ReadOnly, TypedDict
class PotentialModelNamesAndCustomLLMProvider(TypedDict):
@ -5253,6 +5253,7 @@ class PotentialModelNamesAndCustomLLMProvider(TypedDict):
combined_model_name: str
stripped_model_name: str
combined_stripped_model_name: str
provider_prefixed_model_name: ReadOnly[str]
custom_llm_provider: str
@ -5280,6 +5281,7 @@ def _get_model_info_from_generalization(
potential_model_names["split_model"],
potential_model_names["combined_stripped_model_name"],
potential_model_names["stripped_model_name"],
potential_model_names["provider_prefixed_model_name"],
)
if any(_get_model_cost_key(candidate) is not None for candidate in candidates):
return None
@ -5304,6 +5306,7 @@ def _get_potential_model_names(model: str, custom_llm_provider: str | None) -> P
combined_model_name = model
stripped_model_name = _strip_model_name(model=model, custom_llm_provider=custom_llm_provider)
combined_stripped_model_name = stripped_model_name
provider_prefixed_model_name = model
elif custom_llm_provider and model.startswith(
custom_llm_provider + "/"
): # handle case where custom_llm_provider is provided and model starts with custom_llm_provider
@ -5311,11 +5314,13 @@ def _get_potential_model_names(model: str, custom_llm_provider: str | None) -> P
combined_model_name = model
stripped_model_name = _strip_model_name(model=split_model, custom_llm_provider=custom_llm_provider)
combined_stripped_model_name = f"{custom_llm_provider}/{stripped_model_name}"
provider_prefixed_model_name = f"{custom_llm_provider}/{model}"
else:
split_model = model
combined_model_name = f"{custom_llm_provider}/{model}"
stripped_model_name = _strip_model_name(model=model, custom_llm_provider=custom_llm_provider)
combined_stripped_model_name = f"{custom_llm_provider}/{stripped_model_name}"
provider_prefixed_model_name = combined_model_name
if custom_llm_provider in ("bedrock", "bedrock_converse"):
from litellm.llms.bedrock.common_utils import strip_bedrock_routing_prefix
@ -5327,6 +5332,7 @@ def _get_potential_model_names(model: str, custom_llm_provider: str | None) -> P
combined_model_name=combined_model_name,
stripped_model_name=stripped_model_name,
combined_stripped_model_name=combined_stripped_model_name,
provider_prefixed_model_name=provider_prefixed_model_name,
custom_llm_provider=cast(str, custom_llm_provider),
)
@ -5435,6 +5441,7 @@ def _get_model_info_helper(
combined_model_name: Final = potential_model_names["combined_model_name"]
stripped_model_name: Final = potential_model_names["stripped_model_name"]
combined_stripped_model_name: Final = potential_model_names["combined_stripped_model_name"]
provider_prefixed_model_name: Final = potential_model_names["provider_prefixed_model_name"]
split_model: Final = potential_model_names["split_model"]
custom_llm_provider = potential_model_names["custom_llm_provider"]
model_cost_custom_llm_provider: Final = custom_llm_provider
@ -5493,6 +5500,10 @@ def _get_model_info_helper(
3. 'split_model' in litellm.model_cost. Checks "au.anthropic.claude-opus-4-8" in litellm.model_cost if model="bedrock/au.anthropic.claude-opus-4-8"
4. 'combined_stripped_model_name' in litellm.model_cost. Checks if 'gemini/gemini-1.5-flash' in model map, if 'gemini/gemini-1.5-flash-001' given.
5. 'stripped_model_name' in litellm.model_cost. Checks if 'ft:gpt-3.5-turbo' in model map, if 'ft:gpt-3.5-turbo:my-org:custom_suffix:id' given.
6. 'provider_prefixed_model_name' in litellm.model_cost, for providers whose own model ids repeat the
litellm provider name. Checks "perplexity/perplexity/glm-5.2" if model="perplexity/glm-5.2" and
custom_llm_provider="perplexity", where 1-5 all read the leading "perplexity/" as the litellm prefix
and strip it. Tried last so no model that already resolves through 1-5 can change.
"""
_model_info: dict[str, Any] | None = None
@ -5548,6 +5559,16 @@ def _get_model_info_helper(
custom_llm_provider=model_cost_custom_llm_provider,
):
_model_info = None
if _model_info is None:
_matched_key = _get_model_cost_key(provider_prefixed_model_name)
if _matched_key is not None:
key = _matched_key
_model_info = _get_model_info_from_model_cost(key=cast(str, key))
if not _check_provider_match(
model_info=_model_info,
custom_llm_provider=model_cost_custom_llm_provider,
):
_model_info = None
if _model_info is None:
generalization: Final = _get_model_info_from_generalization(

View file

@ -34758,6 +34758,50 @@
"supports_reasoning": false,
"supports_function_calling": true
},
"perplexity/perplexity/deepseek-v4-flash-0731": {
"cache_read_input_token_cost": 2.8e-08,
"input_cost_per_token": 1.3e-07,
"litellm_provider": "perplexity",
"mode": "responses",
"output_cost_per_token": 2.6e-07,
"source": "https://docs.perplexity.ai/docs/agent-api/models",
"supports_web_search": true,
"supports_reasoning": true,
"supports_function_calling": true
},
"perplexity/perplexity/glm-5.2": {
"cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 1.4e-06,
"litellm_provider": "perplexity",
"mode": "responses",
"output_cost_per_token": 4.4e-06,
"source": "https://docs.perplexity.ai/docs/agent-api/models",
"supports_web_search": true,
"supports_reasoning": true,
"supports_function_calling": true
},
"perplexity/perplexity/kimi-k3": {
"cache_read_input_token_cost": 3e-07,
"input_cost_per_token": 3e-06,
"litellm_provider": "perplexity",
"mode": "responses",
"output_cost_per_token": 1.5e-05,
"source": "https://docs.perplexity.ai/docs/agent-api/models",
"supports_web_search": true,
"supports_reasoning": true,
"supports_function_calling": true
},
"perplexity/perplexity/kimi-k2.7-code": {
"cache_read_input_token_cost": 1.9e-07,
"input_cost_per_token": 9.5e-07,
"litellm_provider": "perplexity",
"mode": "responses",
"output_cost_per_token": 4e-06,
"source": "https://docs.perplexity.ai/docs/agent-api/models",
"supports_web_search": true,
"supports_reasoning": false,
"supports_function_calling": true
},
"perplexity/pplx-embed-v1-0.6b": {
"input_cost_per_token": 4e-09,
"litellm_provider": "perplexity",

View file

@ -7,11 +7,13 @@ import pytest
sys.path.insert(0, os.path.abspath("../../.."))
import litellm
from litellm.completion_extras.litellm_responses_transformation.handler import (
ResponsesToCompletionBridgeHandler,
)
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import ModelResponse
@ -265,3 +267,74 @@ def test_completion_streams_completed_model_response():
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == "pong", (
f"completed response did not stream its content: {chunks}"
)
_PROVIDER_NATIVE_MODEL_CASES = [
("perplexity", "perplexity/kimi-k3", "perplexity/kimi-k3"),
("perplexity", "openai/gpt-5.2", "openai/gpt-5.2"),
("openai", "gpt-5.4", "gpt-5.4"),
]
def _upstream_model_for(handed_model: str, custom_llm_provider: str) -> str:
upstream_model, _, _, _ = litellm.get_llm_provider(
model=handed_model,
litellm_params=GenericLiteLLMParams(custom_llm_provider=custom_llm_provider),
)
return upstream_model
@pytest.mark.parametrize(
"custom_llm_provider, bridge_model, expected_upstream_model",
_PROVIDER_NATIVE_MODEL_CASES,
)
def test_completion_keeps_provider_native_model_id_through_responses(
custom_llm_provider, bridge_model, expected_upstream_model
):
"""responses() resolves the provider itself, so the bridge must not hand it an already-stripped model."""
cached = ModelResponse(id="chatcmpl-cached", model=bridge_model)
bridge = ResponsesToCompletionBridgeHandler()
kwargs = _bridge_kwargs(stream=False)
kwargs["model"] = bridge_model
kwargs["custom_llm_provider"] = custom_llm_provider
with (
patch.object(
bridge.transformation_handler,
"transform_request",
return_value={"model": bridge_model, "input": "hi"},
),
patch("litellm.responses", return_value=cached) as responses_call,
):
bridge.completion(**kwargs)
handed_model = responses_call.call_args.kwargs["model"]
assert _upstream_model_for(handed_model, custom_llm_provider) == expected_upstream_model
@pytest.mark.asyncio
@pytest.mark.parametrize(
"custom_llm_provider, bridge_model, expected_upstream_model",
_PROVIDER_NATIVE_MODEL_CASES,
)
async def test_acompletion_keeps_provider_native_model_id_through_responses(
custom_llm_provider, bridge_model, expected_upstream_model
):
cached = ModelResponse(id="chatcmpl-cached-async", model=bridge_model)
bridge = ResponsesToCompletionBridgeHandler()
kwargs = _bridge_kwargs(stream=False)
kwargs["model"] = bridge_model
kwargs["custom_llm_provider"] = custom_llm_provider
with (
patch.object(
bridge.transformation_handler,
"transform_request",
return_value={"model": bridge_model, "input": "hi"},
),
patch("litellm.aresponses", new=AsyncMock(return_value=cached)) as responses_call,
):
await bridge.acompletion(**kwargs)
handed_model = responses_call.call_args.kwargs["model"]
assert _upstream_model_for(handed_model, custom_llm_provider) == expected_upstream_model

View file

@ -400,6 +400,31 @@ class TestPerplexityCostCalculator:
assert completion_cost == 0.008
assert prompt_cost + completion_cost == 0.008
def test_uses_perplexity_provided_cost_when_normalized_to_float(self):
"""
Regression: for Responses API / Agent API models, `ResponseAPIUsage.parse_cost`
(litellm/types/llms/openai.py) already flattens Perplexity's
`usage.cost.total_cost` dict down to a plain float before
`_transform_response_api_usage_to_chat_usage` (litellm/responses/utils.py) copies
it onto the chat `Usage` object. So `usage.cost` arrives here as a float, not a
dict, on that path.
Pre-fix, the `isinstance(cost_info, dict)` check was always False for a float,
so the pre-calculated cost branch was dead code for every Responses-mode
Perplexity model and it silently fell back to manual token-rate calculation,
recording $0 for any model missing static per-token rates (e.g.
perplexity/openai/gpt-5.2 before rates existed).
"""
usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150)
usage.cost = 0.008
prompt_cost, completion_cost = perplexity_cost_per_token(
model="sonar-pro", usage=usage
)
assert prompt_cost == 0.0
assert completion_cost == 0.008
def test_falls_back_to_manual_calculation_when_no_cost_provided(self):
"""
Test that manual cost calculation is used when Perplexity doesn't
@ -451,3 +476,52 @@ class TestPerplexityCostCalculator:
assert math.isclose(prompt_cost, expected_prompt, rel_tol=1e-9)
assert math.isclose(completion_cost, expected_completion, rel_tol=1e-9)
@pytest.mark.parametrize(
"model_id, usd_per_1m_input, usd_per_1m_output, usd_per_1m_cache_read",
[
("deepseek-v4-flash-0731", 0.13, 0.26, 0.028),
("glm-5.2", 1.4, 4.4, 0.14),
("kimi-k3", 3.0, 15.0, 0.3),
("kimi-k2.7-code", 0.95, 4.0, 0.19),
],
)
def test_agent_api_entries_carry_perplexity_published_rates(
self, model_id, usd_per_1m_input, usd_per_1m_output, usd_per_1m_cache_read
):
"""The Agent API third-party models are priced from Perplexity's own catalog
(GET https://api.perplexity.ai/v1/models, `pricing` in usd_per_1m_tokens).
Perplexity's model id already starts with `perplexity/`, so the cost-map key
doubles the prefix. Regression: glm-5.2 shipped glm-5.3's 0.26 cache-read rate,
copied from the neighbouring catalog row, an 86% overcharge on cached input.
"""
info = get_model_info(
model=f"perplexity/{model_id}", custom_llm_provider="perplexity"
)
assert info["key"] == f"perplexity/perplexity/{model_id}"
assert info["litellm_provider"] == "perplexity"
assert info["mode"] == "responses"
assert math.isclose(info["input_cost_per_token"], usd_per_1m_input / 1e6, rel_tol=1e-9)
assert math.isclose(info["output_cost_per_token"], usd_per_1m_output / 1e6, rel_tol=1e-9)
assert math.isclose(
info["cache_read_input_token_cost"], usd_per_1m_cache_read / 1e6, rel_tol=1e-9
)
def test_agent_api_fallback_rates_price_a_response_without_metered_cost(self):
"""Perplexity meters cost on the response, but when `usage.cost` is absent the
calculator falls back to the mapped per-token rates. Regression: that fallback
raised "This model isn't mapped yet" for every Agent API third-party model,
because the doubled cost-map key was unreachable from the resolution ladder.
"""
from litellm import ModelResponse
response = ModelResponse()
response.usage = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500)
response.model = "perplexity/perplexity/glm-5.2"
total_cost = completion_cost(
completion_response=response, custom_llm_provider="perplexity"
)
assert math.isclose(total_cost, 1000 * 1.4e-06 + 500 * 4.4e-06, rel_tol=1e-9)

View file

@ -36,6 +36,7 @@ from litellm.utils import (
ProviderConfigManager,
TextCompletionStreamWrapper,
_check_provider_match,
_get_potential_model_names,
_is_streaming_request,
get_api_key,
get_llm_provider,
@ -129,6 +130,74 @@ def test_get_model_info_surfaces_supports_adaptive_thinking(local_model_cost_map
assert generalized["supports_adaptive_thinking"] is True
def test_potential_model_names_keeps_provider_prefixed_candidate():
"""A provider whose own model ids repeat the litellm provider name (Perplexity's
Agent API serves `perplexity/glm-5.2`, mapped as `perplexity/perplexity/glm-5.2`)
needs the un-stripped `<provider>/<model>` candidate. Every other candidate reads
the leading `perplexity/` as the litellm prefix and strips it away."""
already_prefixed = _get_potential_model_names(
model="perplexity/glm-5.2", custom_llm_provider="perplexity"
)
assert already_prefixed["provider_prefixed_model_name"] == "perplexity/perplexity/glm-5.2"
assert already_prefixed["split_model"] == "glm-5.2"
assert already_prefixed["combined_model_name"] == "perplexity/glm-5.2"
assert already_prefixed["combined_stripped_model_name"] == "perplexity/glm-5.2"
bare = _get_potential_model_names(model="glm-5.2", custom_llm_provider="perplexity")
assert bare["provider_prefixed_model_name"] == bare["combined_model_name"] == "perplexity/glm-5.2"
def test_get_model_info_resolves_provider_prefixed_model_ids(local_model_cost_map):
"""Perplexity's Agent API third-party models are keyed `perplexity/perplexity/<id>`
because Perplexity's own id already starts with `perplexity/`. Callers run
`get_llm_provider` first, which hands `_get_potential_model_names` model
`perplexity/glm-5.2` with provider `perplexity`, and every candidate but the
provider-prefixed one strips that second `perplexity/` off. Regression: the
entries were unreachable from `supports_reasoning` and from the cost calculator's
per-token fallback, so a mapped model reported no reasoning support and raised
"This model isn't mapped yet" on the only path where its rates are ever used."""
for model, reasoning in (
("perplexity/perplexity/glm-5.2", True),
("perplexity/perplexity/kimi-k3", True),
("perplexity/perplexity/deepseek-v4-flash-0731", True),
("perplexity/perplexity/kimi-k2.7-code", False),
):
assert litellm.supports_reasoning(model=model) is reasoning, model
via_provider = litellm.get_model_info(
model="perplexity/glm-5.2", custom_llm_provider="perplexity"
)
assert via_provider["key"] == "perplexity/perplexity/glm-5.2"
assert via_provider["input_cost_per_token"] == 1.4e-06
assert via_provider["output_cost_per_token"] == 4.4e-06
assert via_provider["mode"] == "responses"
def test_provider_prefixed_lookup_never_outranks_an_existing_row(local_model_cost_map):
"""The provider-prefixed candidate is tried last, after every candidate that
already existed, so no model that resolves today can change answer. `perplexity/sonar`
is the case that proves it: both `perplexity/sonar` and `perplexity/perplexity/sonar`
are cost-map keys, and the shorter one must keep winning."""
sonar = litellm.get_model_info(model="sonar", custom_llm_provider="perplexity")
assert sonar["key"] == "perplexity/sonar"
assert sonar["mode"] == "chat"
assert sonar["input_cost_per_token"] == 1e-06
still_sonar = litellm.get_model_info(
model="perplexity/sonar", custom_llm_provider="perplexity"
)
assert still_sonar["key"] == "perplexity/sonar"
assert still_sonar["mode"] == "chat"
for model, provider, expected_key in (
("claude-sonnet-4-5", "anthropic", "claude-sonnet-4-5"),
("anthropic/claude-sonnet-4-5", "anthropic", "claude-sonnet-4-5"),
("gemini/gemini-2.0-flash", "gemini", "gemini/gemini-2.0-flash"),
("openrouter/openai/gpt-4o", "openrouter", "openrouter/openai/gpt-4o"),
):
assert litellm.get_model_info(model=model, custom_llm_provider=provider)["key"] == expected_key
def test_check_provider_match_azure_ai_allows_openai_and_azure():
"""
Test that azure_ai provider can match openai and azure models.