diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py
index 9ac4f9d5ee8..5eb9cdb11ce 100644
--- a/litellm/litellm_core_utils/llm_cost_calc/utils.py
+++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py
@@ -1464,7 +1464,7 @@ def get_fedramp_uplift_multiplier(model_info: ModelInfo, data_residency: str | N
return 1.0
-def get_vertex_regional_endpoint_uplift(model_info: ModelInfo, vertex_location: str | None) -> float:
+def get_vertex_regional_endpoint_uplift(model_info: Mapping[str, object], vertex_location: str | None) -> float:
"""
Resolve the per-model uplift multiplier for Vertex AI non-global (regional and
multi-region) endpoints.
diff --git a/litellm/router_rust/surface/model_info.py b/litellm/router_rust/surface/model_info.py
index b1649617557..7fd4c22df6d 100644
--- a/litellm/router_rust/surface/model_info.py
+++ b/litellm/router_rust/surface/model_info.py
@@ -46,6 +46,7 @@ from litellm.litellm_core_utils.add_retry_fallback_headers import (
from litellm.litellm_core_utils.batch_utils import deployment_supported_endpoints
from litellm.litellm_core_utils.core_helpers import as_str_mapping, coerce_token_limit
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
+from litellm.litellm_core_utils.litellm_logging import resolve_vertex_location_for_cost
from litellm.litellm_core_utils.reasoning_effort_capability import (
deployment_is_catalog_mapped,
intersect_supported_reasoning_efforts,
@@ -56,11 +57,13 @@ from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.llms.bedrock.common_utils import get_bedrock_base_model
from litellm.redis.stores.router_usage import ROUTER_USAGE_TARGET, RouterUsage, UsageCounter
from litellm.router_python_host import python_host
+from litellm.router_utils.model_group_pricing import deployment_pricing
from litellm.router_utils.pre_call_checks.io_token_rate_limit_check import build_io_token_rate_limit_headers
from litellm.router_utils.router_callbacks.track_deployment_metrics import find_deployment_metadata
from litellm.types.router import (
Deployment,
DeploymentModelListingInfo,
+ DeploymentPricing,
DeploymentTypedDict,
DiscoveredDeploymentModelInfo,
LiteLLM_Params,
@@ -140,6 +143,11 @@ def _configured_model_info(deployment: Mapping[str, object]) -> Mapping[str, obj
return _MODEL_INFO_ADAPTER.validate_python(deployment.get("model_info") or {})
+def _configured_deployment_id(deployment: Mapping[str, object]) -> str | None:
+ deployment_id: Final = _configured_model_info(deployment).get("id")
+ return deployment_id if isinstance(deployment_id, str) else None
+
+
def _operator_max_input_tokens(deployment: Mapping[str, object]) -> int | None:
"""``_operator_max_input_tokens``: the deployment's own limit, skipping one a saved
``/model/info`` response copied from the cost map."""
@@ -187,6 +195,7 @@ class _GroupBuild:
# Filled as the lookups arrive.
params: dict[int, LiteLLM_Params] = dataclasses.field(default_factory=dict) # mutable-ok: see above
info: ModelGroupInfo | None = None
+ pricing: tuple[DeploymentPricing, ...] = ()
def _usage_total(values: Sequence[object]) -> int | None:
@@ -358,6 +367,7 @@ class HostModelInfo:
return None
for field, value in (as_str_mapping(answer["assignments"]) or {}).items():
setattr(group.info, field, _group_field(field, wire.from_wire(value)))
+ group.info.deployment_pricing = group.pricing
return group.info
def _answer_group_lookup(self, step: Mapping[str, object], group: "_GroupBuild", wire: "_Wire") -> object:
@@ -384,6 +394,17 @@ class HostModelInfo:
efforts: Final = resolve_supported_reasoning_efforts(
model_info, deployment_is_mapped=step["mapped"] is True
)
+ pricing: Final = deployment_pricing(
+ llm_provider,
+ _configured_deployment_id(deployment),
+ group.params[int(str(step["index"]))].model,
+ model_info,
+ resolve_vertex_location_for_cost(
+ llm_provider, deployment.get("litellm_params"), None, litellm_model
+ ),
+ )
+ if pricing is not None and pricing not in group.pricing:
+ group.pricing = (*group.pricing, pricing) # rebind-ok: the merge's state
return {
"supported_endpoints": wire.to_wire(
list(
diff --git a/litellm/router_utils/model_group_pricing.py b/litellm/router_utils/model_group_pricing.py
new file mode 100644
index 00000000000..afadbc3eb38
--- /dev/null
+++ b/litellm/router_utils/model_group_pricing.py
@@ -0,0 +1,69 @@
+import math
+from collections.abc import Mapping
+from typing import Final
+
+from pydantic import TypeAdapter, ValidationError
+
+from litellm.litellm_core_utils.litellm_logging import deployment_pricing_model_info
+from litellm.litellm_core_utils.llm_cost_calc.utils import get_vertex_regional_endpoint_uplift
+from litellm.types.router import DeploymentPricing, PriceTier
+
+_RAW_PRICE_TIERS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
+
+
+def _numeric_text(value: str) -> float | None:
+ try:
+ parsed: Final = float(value)
+ except ValueError:
+ return None
+ return parsed if math.isfinite(parsed) else None
+
+
+def _cost_value(value: object) -> float | None:
+ match value:
+ case bool():
+ return None
+ case int() | float():
+ return float(value)
+ case str():
+ return _numeric_text(value)
+ case _:
+ return None
+
+
+def _cost_fields(fields: Mapping[str, object], token_uplift: float) -> Mapping[str, float]:
+ return {
+ name: cost * token_uplift if "token" in name else cost
+ for name, value in fields.items()
+ if "cost" in name and (cost := _cost_value(value)) is not None
+ }
+
+
+def _price_tiers(tiered_pricing: object, token_uplift: float) -> tuple[PriceTier, ...]:
+ try:
+ return tuple(
+ PriceTier.model_validate({"range": tier.get("range"), "costs": _cost_fields(tier, token_uplift)})
+ for tier in _RAW_PRICE_TIERS.validate_python(tiered_pricing)
+ )
+ except ValidationError:
+ return ()
+
+
+def deployment_pricing(
+ provider: str,
+ deployment_id: str | None,
+ deployment_model: str,
+ resolved_model_info: Mapping[str, object],
+ vertex_location: str | None,
+) -> DeploymentPricing | None:
+ """Prices from the entry billing charges this deployment at, so a cost-map variant its own pricing shadows
+ is never shown. A deployment that declares no pricing is billed at its resolved cost-map entry. Token prices
+ carry the Vertex regional-endpoint uplift billing applies at the deployment's location."""
+ billed_model_info: Final = deployment_pricing_model_info(model_id=deployment_id, deployment_model=deployment_model)
+ model_info: Final = resolved_model_info if billed_model_info is None else billed_model_info
+ token_uplift: Final = get_vertex_regional_endpoint_uplift(model_info, vertex_location)
+ costs: Final = _cost_fields(model_info, token_uplift)
+ tiers: Final = _price_tiers(model_info.get("tiered_pricing"), token_uplift)
+ if not costs and not tiers:
+ return None
+ return DeploymentPricing(provider=provider, costs=costs, tiered_pricing=tiers)
diff --git a/litellm/types/router.py b/litellm/types/router.py
index a9f28fbc673..3dcfc694003 100644
--- a/litellm/types/router.py
+++ b/litellm/types/router.py
@@ -1041,6 +1041,21 @@ def _resolved_annotations(model_class: type[object]) -> Mapping[str, object]:
return MappingProxyType(get_type_hints(model_class))
+class PriceTier(LiteLLMBaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ range: tuple[float, float]
+ costs: Mapping[str, float]
+
+
+class DeploymentPricing(LiteLLMBaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ provider: str
+ costs: Mapping[str, float]
+ tiered_pricing: tuple[PriceTier, ...] = ()
+
+
class ModelGroupInfo(LiteLLMBaseModel):
model_group: str
providers: list[str]
@@ -1071,6 +1086,7 @@ class ModelGroupInfo(LiteLLMBaseModel):
configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None
description: str | None = None
supported_endpoints: tuple[str, ...] | None = None
+ deployment_pricing: tuple[DeploymentPricing, ...] = ()
def __init__(self, **data) -> None:
for field_name, field_type in _resolved_annotations(self.__class__).items():
diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py
index d1afcf80ec9..27ba8968200 100644
--- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py
+++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py
@@ -987,6 +987,57 @@ def test_public_model_hub_reports_the_newest_check_of_a_group_not_the_last_row_r
app.dependency_overrides.clear()
+def test_public_model_hub_lists_each_deployments_prices_without_exposing_the_deployment() -> None:
+ llm_router: Final = litellm.Router(
+ model_list=[
+ {
+ "model_name": "hub-public",
+ "litellm_params": {
+ "model": "openai/hub-secret-backend-a",
+ "api_base": "https://secret-a.internal",
+ "api_key": "sk-secret-a",
+ "input_cost_per_token": 1e-06,
+ "output_cost_per_token": 2e-06,
+ },
+ "model_info": {"id": "hub-secret-id-a"},
+ },
+ {
+ "model_name": "hub-public",
+ "litellm_params": {
+ "model": "openai/hub-secret-backend-b",
+ "api_base": "https://secret-b.internal",
+ "api_key": "sk-secret-b",
+ "input_cost_per_token": 3e-06,
+ "output_cost_per_token": 4e-06,
+ },
+ "model_info": {"id": "hub-secret-id-b"},
+ },
+ ]
+ )
+ app: Final = FastAPI()
+ app.include_router(router)
+
+ with (
+ patch("litellm.public_model_groups", ["hub-public"]),
+ patch("litellm.proxy.proxy_server.llm_router", llm_router),
+ patch("litellm.proxy.proxy_server.prisma_client", None),
+ ):
+ response: Final = TestClient(app).get("/public/model_hub")
+
+ assert response.status_code == 200, response.text
+ groups: Final = TypeAdapter(list[ModelGroupInfoProxy]).validate_json(response.text)
+ assert [dict(p.costs) for p in groups[0].deployment_pricing] == [
+ {"input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06},
+ {"input_cost_per_token": 3e-06, "output_cost_per_token": 4e-06},
+ ]
+ leaked: Final = [
+ secret
+ for secret in ("secret-a.internal", "secret-b.internal", "sk-secret", "hub-secret-backend", "hub-secret-id")
+ if secret in response.text
+ ]
+ assert leaked == [], response.text
+
+
# ---------------------------------------------------------------------------
# /public/agent_hub
# ---------------------------------------------------------------------------
diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py
index 271615b18cf..8f8676103b2 100644
--- a/tests/unit/test_router/test_router.py
+++ b/tests/unit/test_router/test_router.py
@@ -1889,6 +1889,281 @@ def test_model_group_info_cost_none_for_unpriced_deployment_but_zero_when_declar
assert priced.output_cost_per_token is not None and priced.output_cost_per_token > 0
+_HUB_PRICES: Final = MappingProxyType(
+ {
+ "input_cost_per_token": 1e-06,
+ "output_cost_per_token": 4e-06,
+ "cache_read_input_token_cost": 1e-07,
+ "cache_creation_input_token_cost": 1.25e-06,
+ "input_cost_per_token_above_200k_tokens": 2e-06,
+ "output_cost_per_token_above_200k_tokens": 8e-06,
+ "input_cost_per_token_priority": 3e-06,
+ "input_cost_per_token_flex": 5e-07,
+ "input_cost_per_token_batches": 5e-07,
+ }
+)
+
+
+def _cost_map_model(key: str, entry: Mapping[str, object]) -> str:
+ litellm.register_model(
+ {key: {"litellm_provider": "openai", "mode": "chat", **entry}},
+ persist_across_reloads=False,
+ add_to_provider_model_sets=False,
+ )
+ return f"openai/{key}"
+
+
+def test_model_group_info_lists_every_cost_map_price_of_a_deployment(local_model_cost_map: None) -> None:
+ model: Final = _cost_map_model("hub-pricing-model", {**_HUB_PRICES, "max_input_tokens": 400000})
+ router: Final = litellm.Router(model_list=[{"model_name": "hub", "litellm_params": {"model": model}}])
+
+ group: Final = router.registry().group("hub")
+
+ assert group is not None
+ assert [(p.provider, dict(p.costs), p.tiered_pricing) for p in group.deployment_pricing] == [
+ ("openai", dict(_HUB_PRICES), ())
+ ]
+
+
+def test_model_group_info_lists_each_deployments_price_once_next_to_the_unchanged_max(
+ local_model_cost_map: None,
+) -> None:
+ model: Final = _cost_map_model("hub-pricing-model", _HUB_PRICES)
+ override: Final = 3 * _HUB_PRICES["input_cost_per_token"]
+ router: Final = litellm.Router(
+ model_list=[
+ {"model_name": "hub", "litellm_params": {"model": model, "api_base": "https://a.example"}},
+ {"model_name": "hub", "litellm_params": {"model": model, "api_base": "https://b.example"}},
+ {"model_name": "hub", "litellm_params": {"model": model, "input_cost_per_token": override}},
+ ]
+ )
+
+ group: Final = router.registry().group("hub")
+
+ assert group is not None
+ assert [p.costs["input_cost_per_token"] for p in group.deployment_pricing] == [
+ _HUB_PRICES["input_cost_per_token"],
+ override,
+ ]
+ assert group.input_cost_per_token == override
+ assert group.output_cost_per_token == _HUB_PRICES["output_cost_per_token"]
+
+
+def _usage_billed_at(cost_key: str, prompt_tokens: int = 1000) -> tuple[Usage, str | None]:
+ match cost_key:
+ case "input_cost_per_token":
+ return Usage(prompt_tokens=prompt_tokens, completion_tokens=0, total_tokens=prompt_tokens), None
+ case "output_cost_per_token":
+ return Usage(prompt_tokens=0, completion_tokens=1000, total_tokens=1000), None
+ case "cache_read_input_token_cost":
+ return Usage(
+ prompt_tokens=1000,
+ completion_tokens=0,
+ total_tokens=1000,
+ prompt_tokens_details={"cached_tokens": 1000},
+ ), None
+ case "cache_creation_input_token_cost":
+ return Usage(
+ prompt_tokens=1000, completion_tokens=0, total_tokens=1000, cache_creation_input_tokens=1000
+ ), None
+ case "input_cost_per_token_priority":
+ return Usage(prompt_tokens=1000, completion_tokens=0, total_tokens=1000), "priority"
+ case "input_cost_per_token_flex":
+ return Usage(prompt_tokens=1000, completion_tokens=0, total_tokens=1000), "flex"
+ case _:
+ raise AssertionError(f"no billing scenario for {cost_key}")
+
+
+def _billed_rate(model: str, deployment_id: str, usage: Usage, service_tier: str | None) -> float:
+ cost: Final = litellm.completion_cost(
+ completion_response=litellm.ModelResponse(usage=usage),
+ model=model,
+ custom_llm_provider="openai",
+ custom_pricing=True,
+ router_model_id=deployment_id,
+ service_tier=service_tier,
+ )
+ return cost / max(usage.prompt_tokens, usage.completion_tokens)
+
+
+def test_model_group_info_shows_an_overridden_deployment_only_the_prices_billing_charges(
+ local_model_cost_map: None,
+) -> None:
+ model: Final = _cost_map_model("hub-pricing-model", _HUB_PRICES)
+ router: Final = litellm.Router(
+ model_list=[
+ {
+ "model_name": "hub",
+ "litellm_params": {
+ "model": model,
+ "input_cost_per_token": 3 * _HUB_PRICES["input_cost_per_token"],
+ "output_cost_per_token": 3 * _HUB_PRICES["output_cost_per_token"],
+ },
+ "model_info": {"id": "hub-override"},
+ }
+ ]
+ )
+
+ group: Final = router.registry().group("hub")
+
+ assert group is not None
+ costs: Final = group.deployment_pricing[0].costs
+ assert {key: _billed_rate(model, "hub-override", *_usage_billed_at(key)) for key in costs} == pytest.approx(
+ dict(costs)
+ )
+ long_prompt: Final = _usage_billed_at("input_cost_per_token", prompt_tokens=250_000)
+ assert _billed_rate(model, "hub-override", *long_prompt) == pytest.approx(costs["input_cost_per_token"])
+ shadowed: Final = {
+ "input_cost_per_token_above_200k_tokens",
+ "output_cost_per_token_above_200k_tokens",
+ "input_cost_per_token_batches",
+ }
+ assert shadowed.isdisjoint(costs), dict(costs)
+
+
+@pytest.mark.parametrize("vertex_location", ["us-east5", "global"])
+def test_model_group_info_shows_a_vertex_deployment_the_token_prices_billing_charges_at_its_location(
+ local_model_cost_map: None, vertex_location: str
+) -> None:
+ model: Final = "vertex_ai/hub-vertex-model"
+ litellm.register_model(
+ {
+ model: {
+ "litellm_provider": "vertex_ai-language-models",
+ "mode": "chat",
+ "input_cost_per_token": 1e-06,
+ "output_cost_per_token": 4e-06,
+ "cache_read_input_token_cost": 1e-07,
+ "regional_endpoint_uplift_multiplier": 1.1,
+ }
+ },
+ persist_across_reloads=False,
+ add_to_provider_model_sets=False,
+ )
+ router: Final = litellm.Router(
+ model_list=[
+ {
+ "model_name": "hub",
+ "litellm_params": {"model": model, "vertex_project": "p", "vertex_location": vertex_location},
+ "model_info": {"id": "hub-vertex"},
+ }
+ ]
+ )
+
+ group: Final = router.registry().group("hub")
+
+ assert group is not None
+ costs: Final = group.deployment_pricing[0].costs
+
+ def billed(cost_key: str) -> float:
+ usage, _ = _usage_billed_at(cost_key)
+ cost: Final = litellm.completion_cost(
+ completion_response=litellm.ModelResponse(usage=usage),
+ model=model,
+ custom_llm_provider="vertex_ai",
+ router_model_id="hub-vertex",
+ vertex_location=vertex_location,
+ )
+ return cost / max(usage.prompt_tokens, usage.completion_tokens)
+
+ shown: Final = ("input_cost_per_token", "output_cost_per_token", "cache_read_input_token_cost")
+ assert {key: costs[key] for key in shown} == pytest.approx({key: billed(key) for key in shown})
+
+
+def test_model_group_info_lists_tiered_pricing_ranges(local_model_cost_map: None) -> None:
+ tiers: Final = (
+ {"range": [0, 256000], "input_cost_per_token": 5e-08, "output_cost_per_token": 4e-07},
+ {"range": [256000, 1000000], "input_cost_per_token": 2.5e-07, "output_cost_per_token": 2e-06},
+ )
+ model: Final = _cost_map_model("hub-tiered-model", {"tiered_pricing": list(tiers)})
+ router: Final = litellm.Router(
+ model_list=[
+ {"model_name": "tiered", "litellm_params": {"model": model}},
+ {
+ "model_name": "tiered",
+ "litellm_params": {"model": model, "input_cost_per_token": 1e-06, "output_cost_per_token": 4e-06},
+ },
+ ]
+ )
+
+ group: Final = router.registry().group("tiered")
+
+ assert group is not None
+ assert [
+ [(tier.range, dict(tier.costs)) for tier in pricing.tiered_pricing] for pricing in group.deployment_pricing
+ ] == [
+ [
+ ((0.0, 256000.0), {"input_cost_per_token": 5e-08, "output_cost_per_token": 4e-07}),
+ ((256000.0, 1000000.0), {"input_cost_per_token": 2.5e-07, "output_cost_per_token": 2e-06}),
+ ],
+ [],
+ ]
+
+
+def test_model_group_info_reads_prices_written_as_text_the_way_billing_does(local_model_cost_map: None) -> None:
+ tier: Final = {"range": [0, 256000], "input_cost_per_token": "5e-08", "output_cost_per_token": "4e-07"}
+ model: Final = _cost_map_model(
+ "hub-text-priced-model", {"tiered_pricing": [tier], "cache_read_input_token_cost": "N/A"}
+ )
+ router: Final = litellm.Router(
+ model_list=[
+ {"model_name": "tiered", "litellm_params": {"model": model}},
+ {
+ "model_name": "flat",
+ "litellm_params": {"model": model, "input_cost_per_token": "3e-7", "output_cost_per_token": "1.2e-6"},
+ "model_info": {"id": "hub-text-override"},
+ },
+ ]
+ )
+
+ tiered: Final = router.registry().group("tiered")
+ flat: Final = router.registry().group("flat")
+
+ assert tiered is not None
+ assert flat is not None
+ assert [dict(t.costs) for t in tiered.deployment_pricing[0].tiered_pricing] == [
+ {"input_cost_per_token": 5e-08, "output_cost_per_token": 4e-07}
+ ]
+ assert "cache_read_input_token_cost" not in tiered.deployment_pricing[0].costs
+ flat_costs: Final = flat.deployment_pricing[0].costs
+ shown: Final = ("input_cost_per_token", "output_cost_per_token")
+ assert {key: flat_costs[key] for key in shown} == pytest.approx(
+ {key: _billed_rate(model, "hub-text-override", *_usage_billed_at(key)) for key in shown}
+ )
+
+
+def test_model_group_info_lists_custom_prices_of_an_unmapped_deployment_and_skips_an_unpriced_one() -> None:
+ router: Final = litellm.Router(
+ model_list=[
+ {
+ "model_name": "self-hosted",
+ "litellm_params": {"model": "openai/hub-unpriced", "api_base": "http://localhost:8000/v1"},
+ "model_info": {"cost_tracking_exempt": True},
+ },
+ {
+ "model_name": "self-hosted",
+ "litellm_params": {
+ "model": "openai/hub-priced",
+ "api_base": "http://localhost:8001/v1",
+ "input_cost_per_token": 1e-06,
+ "output_cost_per_token": 2e-06,
+ },
+ "model_info": {"cache_read_input_token_cost": 1e-07},
+ },
+ ]
+ )
+
+ group: Final = router.registry().group("self-hosted")
+
+ assert group is not None
+ assert [(p.provider, dict(p.costs)) for p in group.deployment_pricing] == [
+ (
+ "openai",
+ {"input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06, "cache_read_input_token_cost": 1e-07},
+ )
+ ]
+
+
def test_model_group_info_description_from_model_info():
"""
model_info.description set in the config should surface on ModelGroupInfo,
diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx
index 123c7431732..1b30b060ab0 100644
--- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx
+++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx
@@ -2,6 +2,7 @@ import * as networking from "@/components/networking";
import userEvent from "@testing-library/user-event";
import { act } from "@testing-library/react";
import type { MCPServerData } from "./MCPHubTableColumns";
+import type { DeploymentPricing } from "@/components/molecules/models/deploymentPricing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, testQueryClient, waitFor, within } from "../../../tests/test-utils";
import ModelHubTable from "./ModelHubTable";
@@ -374,7 +375,7 @@ describe("ModelHubTable", () => {
describe("model details usage example", () => {
const openDetails = async (
- model: { model_group: string; providers: string[]; mode: string },
+ model: { model_group: string; providers: string[]; mode: string; deployment_pricing?: DeploymentPricing[] },
canOpenPlayground = true,
) => {
vi.mocked(networking.modelHubCall).mockResolvedValue({ data: [model] });
@@ -425,6 +426,34 @@ describe("ModelHubTable", () => {
expect(screen.queryByRole("link", { name: "Try it in the Playground" })).not.toBeInTheDocument();
});
+ it("shows each deployment's own prices, by service tier, in the model details", async () => {
+ const gpt5OnOpenAIAndAzure = {
+ model_group: "gpt-5",
+ providers: ["openai", "azure"],
+ mode: "chat",
+ deployment_pricing: [
+ {
+ provider: "openai",
+ costs: { input_cost_per_token: 2.5e-6, input_cost_per_token_priority: 5e-6 },
+ tiered_pricing: [],
+ },
+ { provider: "azure", costs: { input_cost_per_token: 2.75e-6 }, tiered_pricing: [] },
+ ],
+ };
+ const dialog = await openDetails(gpt5OnOpenAIAndAzure);
+
+ const openai = within(dialog).getByRole("region", { name: "OpenAI pricing" });
+ expect(within(openai).getByRole("row", { name: /Input \/ 1M tokens/ })).toHaveTextContent("$2.50$5.00");
+ expect(within(dialog).getByRole("region", { name: "Azure pricing" })).toHaveTextContent("$2.75");
+ });
+
+ it("leaves the pricing breakdown out when the proxy sends no deployment prices", async () => {
+ const dialog = await openDetails({ model_group: "gpt-5", providers: ["openai"], mode: "chat" });
+
+ expect(within(dialog).getByText("Input Cost per 1M Tokens:")).toBeInTheDocument();
+ expect(within(dialog).queryByText("Pricing by Deployment:")).not.toBeInTheDocument();
+ });
+
it("keeps the chat completions example for a chat model", async () => {
const dialog = await openDetails({ model_group: "claude-opus-4-8", providers: ["anthropic"], mode: "chat" });
diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx
index 4d44d0bb809..60259b1f7f0 100644
--- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx
+++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx
@@ -57,6 +57,7 @@ import {
isDecisionMode,
} from "@/lib/decisionModels";
import DecisionModelGarden from "@/components/AIHub/DecisionModelGarden";
+import { DeploymentPricingBreakdown } from "@/components/molecules/models/DeploymentPricingBreakdown";
import { ModelCustomMetadata } from "./ModelCustomMetadata";
interface ModelHubTableProps {
@@ -819,6 +820,11 @@ const ModelHubTable: React.FC = ({
+ {t("aiHub.modelHubTable.modelDialog.pricingByDeployment")}}
+ />
{/* Capabilities */}
diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx
index 2e4af18455f..7f2f3a49d44 100644
--- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx
+++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx
@@ -22,6 +22,7 @@ import { copyToClipboard } from "@/utils/dataUtils";
import { ModelCustomTags, type ModelCustomMetadataValue } from "./ModelCustomMetadata";
import { formatModelModeLabel, ProviderLogo } from "./ModelHubProviders";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
+import type { DeploymentPricing } from "@/components/molecules/models/deploymentPricing";
export interface ModelHubData {
model_group: string;
@@ -41,6 +42,7 @@ export interface ModelHubData {
description?: string;
custom_tags?: string[] | null;
custom_metadata?: Record | null;
+ deployment_pricing?: DeploymentPricing[] | null;
[key: string]: any;
}
diff --git a/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx b/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx
index 08d6b973b87..eb89af22f17 100644
--- a/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx
+++ b/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx
@@ -11,6 +11,7 @@ import i18n from "@/i18n/i18n";
import { displayMode } from "@/lib/decisionModels";
import type { ModelCustomMetadataValue } from "@/components/AIHub/ModelCustomMetadata";
import { formatModelModeLabel, ProviderLogo } from "@/components/AIHub/ModelHubProviders";
+import type { DeploymentPricing } from "@/components/molecules/models/deploymentPricing";
export interface ModelGroupInfo {
model_group: string;
@@ -32,6 +33,7 @@ export interface ModelGroupInfo {
description?: string;
custom_tags?: string[] | null;
custom_metadata?: Record | null;
+ deployment_pricing?: DeploymentPricing[] | null;
[key: string]: any;
}
diff --git a/ui/litellm-dashboard/src/components/molecules/models/DeploymentPricingBreakdown.test.tsx b/ui/litellm-dashboard/src/components/molecules/models/DeploymentPricingBreakdown.test.tsx
new file mode 100644
index 00000000000..08ee05aeb04
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/molecules/models/DeploymentPricingBreakdown.test.tsx
@@ -0,0 +1,97 @@
+import { render, screen, within } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { DeploymentPricingBreakdown } from "./DeploymentPricingBreakdown";
+import type { DeploymentPricing } from "./deploymentPricing";
+
+const OPENAI: DeploymentPricing = {
+ provider: "openai",
+ costs: {
+ input_cost_per_token: 2.5e-6,
+ output_cost_per_token: 1e-5,
+ input_cost_per_token_priority: 5e-6,
+ cache_read_input_token_cost_above_272k_tokens_priority: 1e-6,
+ output_cost_per_second_720p: 0.05,
+ },
+ tiered_pricing: [{ range: [0, 256_000], costs: { input_cost_per_token: 5e-8, output_cost_per_token: 4e-7 } }],
+};
+
+const AZURE: DeploymentPricing = { provider: "azure", costs: { input_cost_per_token: 2.75e-6 }, tiered_pricing: [] };
+
+const pricesOf = (region: HTMLElement, group: string, charge: string) => {
+ const rows = within(within(region).getByRole("rowgroup", { name: group })).getAllByRole("row");
+ const row = rows.find((candidate) => within(candidate).queryByRole("rowheader", { name: charge }));
+ expect(row).toBeDefined();
+ return within(row as HTMLElement)
+ .getAllByRole("cell")
+ .map((cell) => cell.textContent);
+};
+
+describe("DeploymentPricingBreakdown", () => {
+ it("renders one block per deployment, headed by its provider, with that deployment's own prices", () => {
+ render(Pricing} />);
+
+ const openai = screen.getByRole("region", { name: "OpenAI pricing" });
+ const azure = screen.getByRole("region", { name: "Azure pricing" });
+ expect(within(openai).getByRole("heading", { name: /OpenAI/ })).toBeInTheDocument();
+ expect(pricesOf(openai, "Base prices", "Input / 1M tokens")).toEqual(["$2.50", "$5.00"]);
+ expect(pricesOf(azure, "Base prices", "Input / 1M tokens")).toEqual(["$2.75"]);
+ expect(within(azure).queryByText(/Blank cells/)).not.toBeInTheDocument();
+ expect(screen.getByText(/This model has 2 deployments/)).toBeInTheDocument();
+ expect(screen.getAllByText(/Adjustments made per request/)).toHaveLength(1);
+ });
+
+ it("lays prices out by service tier column and context threshold group", () => {
+ render();
+
+ const openai = screen.getByRole("region", { name: "OpenAI pricing" });
+ const header = within(openai).getAllByRole("rowgroup")[0];
+ expect(
+ within(header)
+ .getAllByRole("columnheader")
+ .map((cell) => cell.textContent),
+ ).toEqual(["Charge", "Standard", "Priority"]);
+ expect(pricesOf(openai, "Base prices", "Output / 1M tokens")).toEqual(["$10.00", ""]);
+ expect(pricesOf(openai, "Above 272K tokens", "Cache read / 1M tokens")).toEqual(["", "$1.00"]);
+ expect(within(openai).getByText("Blank cells aren't priced separately for this deployment.")).toBeInTheDocument();
+ expect(screen.queryByText(/This model has/)).not.toBeInTheDocument();
+ });
+
+ it("shows tiered pricing ranges and keys it could not read", () => {
+ render();
+
+ expect(screen.getByText("0–256K tokens: $0.05 input / $0.40 output")).toBeInTheDocument();
+ expect(screen.getByText("Output / second 720p")).toBeInTheDocument();
+ expect(screen.queryByText("output_cost_per_second_720p")).not.toBeInTheDocument();
+ expect(screen.getByText("$0.05")).toBeInTheDocument();
+ });
+
+ it("shows only the request-size ranges for a deployment priced purely by tiers", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("0–256K tokens: $0.05 input")).toBeInTheDocument();
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["missing", undefined],
+ ["null", null],
+ ["empty", []],
+ ])("renders nothing, heading included, when deployment pricing is %s", (_, deployments) => {
+ const { container } = render(
+ Pricing by deployment} />,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/molecules/models/DeploymentPricingBreakdown.tsx b/ui/litellm-dashboard/src/components/molecules/models/DeploymentPricingBreakdown.tsx
new file mode 100644
index 00000000000..3d967e1d43e
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/molecules/models/DeploymentPricingBreakdown.tsx
@@ -0,0 +1,139 @@
+import type * as React from "react";
+import { useTranslation } from "react-i18next";
+
+import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
+import { getProviderLogoAndName } from "@/components/provider_info_helpers";
+import { cn } from "@/lib/cva.config";
+import {
+ NUMERIC_CELL_CLASS,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+import {
+ buildPricingTable,
+ type DeploymentPricing,
+ describePricingTier,
+ formatTokenCount,
+ type PriceGroup,
+ SERVICE_TIER_LABELS,
+ type ServiceTier,
+} from "./deploymentPricing";
+
+function PriceGroupRows({ group, tiers }: { group: PriceGroup; tiers: readonly ServiceTier[] }) {
+ const { t } = useTranslation();
+ const name =
+ group.threshold === null
+ ? t("deploymentPricing.basePrices")
+ : t("deploymentPricing.aboveThreshold", { threshold: formatTokenCount(group.threshold) });
+ return (
+
+ {group.threshold !== null && (
+
+
+ {name}
+
+
+ )}
+ {group.rows.map((row) => (
+
+
+ {row.label}
+
+ {tiers.map((tier) => (
+
+ {row.prices[tier]}
+
+ ))}
+
+ ))}
+
+ );
+}
+
+function DeploymentPrices({ deployment }: { deployment: DeploymentPricing }) {
+ const { t } = useTranslation();
+ const table = buildPricingTable(deployment.costs);
+ const tiers = deployment.tiered_pricing;
+ const providerName = getProviderLogoAndName(deployment.provider).displayName;
+ return (
+
+
+
+ {providerName}
+
+ {table.groups.length > 0 && (
+
+
+
+ {t("deploymentPricing.chargeColumn")}
+ {table.tiers.map((tier) => (
+
+ {t(SERVICE_TIER_LABELS[tier])}
+
+ ))}
+
+
+ {table.groups.map((group) => (
+
+ ))}
+
+ )}
+ {table.hasBlankCells && {t("deploymentPricing.blankCells")}
}
+ {tiers.length > 0 && (
+
+
{t("deploymentPricing.tieredPricing")}
+
+ {tiers.map((tier) => (
+ - {describePricingTier(tier)}
+ ))}
+
+
+ )}
+ {table.unknown.length > 0 && (
+
+ {table.unknown.map(({ key, label, price }) => (
+
+
- {label}
+ - {price}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function DeploymentPricingBreakdown({
+ deployments,
+ heading,
+ className,
+}: {
+ deployments: readonly DeploymentPricing[] | null | undefined;
+ heading?: React.ReactNode;
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ if (!deployments || deployments.length === 0) return null;
+ return (
+
+ {heading}
+ {deployments.length > 1 && (
+
+ {t("deploymentPricing.deploymentCount", { count: deployments.length })}
+
+ )}
+ {deployments.map((deployment, index) => (
+
+ ))}
+
{t("deploymentPricing.requestAdjustments")}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/molecules/models/deploymentPricing.test.ts b/ui/litellm-dashboard/src/components/molecules/models/deploymentPricing.test.ts
new file mode 100644
index 00000000000..4e38f55d1b3
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/molecules/models/deploymentPricing.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, it } from "vitest";
+
+import { buildPricingTable, describePricingTier, fallbackCostLabel, parseCostKey } from "./deploymentPricing";
+
+describe("parseCostKey", () => {
+ it.each([
+ ["input_cost_per_token", { charge: "input", modality: "", unit: "token", threshold: null, tier: "standard" }],
+ ["output_cost_per_token_flex", { charge: "output", unit: "token", threshold: null, tier: "flex" }],
+ ["input_cost_per_token_batches", { charge: "input", tier: "batches" }],
+ ["cache_read_input_token_cost", { charge: "cache_read", unit: "token", tier: "standard" }],
+ ["explicit_cache_read_input_token_cost", { charge: "explicit_cache_read", unit: "token", tier: "standard" }],
+ [
+ "cache_read_input_token_cost_above_272k_tokens_priority",
+ { charge: "cache_read", threshold: 272_000, tier: "priority" },
+ ],
+ [
+ "cache_creation_input_token_cost_above_12hr_above_272k_tokens_flex",
+ { charge: "cache_write", cacheDuration: "12hr", threshold: 272_000, tier: "flex" },
+ ],
+ ["cache_creation_input_token_cost_above_1hr", { charge: "cache_write", cacheDuration: "1hr", threshold: null }],
+ ["cache_read_input_audio_token_cost_priority", { charge: "cache_read", modality: "audio", tier: "priority" }],
+ ["input_cost_per_audio_token", { charge: "input", modality: "audio", unit: "token" }],
+ ["output_cost_per_reasoning_token", { charge: "output", modality: "reasoning", unit: "token" }],
+ [
+ "input_cost_per_audio_per_second_above_128k_tokens",
+ { charge: "input", modality: "audio", unit: "second", threshold: 128_000 },
+ ],
+ ["output_cost_per_second", { charge: "output", modality: "", unit: "second" }],
+ ["input_cost_per_image", { charge: "input", modality: "", unit: "image" }],
+ ["input_cost_per_image_token", { charge: "input", modality: "image", unit: "token" }],
+ ["output_cost_per_pixel", { unit: "pixel" }],
+ ["input_cost_per_character", { unit: "character" }],
+ ["input_cost_per_query", { unit: "query" }],
+ ["input_cost_per_token_above_200k_tokens_ultrafast", { threshold: 200_000, tier: "ultrafast" }],
+ ["output_cost_per_token_balanced", { tier: "balanced" }],
+ ])("reads %s", (key, expected) => {
+ expect(parseCostKey(key)).toMatchObject({ kind: "price", ...expected });
+ });
+
+ it.each([
+ "output_cost_per_second_720p",
+ "output_cost_per_image_1024",
+ "input_cost_per_token_cache_hit",
+ "input_cost_per_token_above_272k_tokens_decisions",
+ "input_cost_per_video_per_second_above_8s_interval",
+ "ocr_cost_per_page",
+ "input_dbu_cost_per_token",
+ "input_cost_per_text_message",
+ ])("keeps %s as an unknown key rather than guessing", (key) => {
+ expect(parseCostKey(key)).toEqual({ kind: "unknown", key });
+ });
+});
+
+const CONTRACT_COSTS = {
+ input_cost_per_token: 2.5e-6,
+ output_cost_per_token: 1e-5,
+ cache_read_input_token_cost: 1.25e-6,
+ cache_creation_input_token_cost: 3e-6,
+ input_cost_per_token_priority: 5e-6,
+ input_cost_per_token_flex: 1.25e-6,
+ input_cost_per_token_above_200k_tokens: 5e-6,
+ cache_read_input_token_cost_above_272k_tokens_priority: 1e-6,
+ input_cost_per_token_batches: 1.25e-6,
+ output_cost_per_second: 0.001,
+ input_cost_per_image: 0.002,
+};
+
+describe("buildPricingTable", () => {
+ const table = buildPricingTable(CONTRACT_COSTS);
+
+ it("offers a column per service tier the deployment prices, in a fixed order", () => {
+ expect(table.tiers).toEqual(["standard", "flex", "priority", "batches"]);
+ });
+
+ it("groups prices by context threshold, base prices first", () => {
+ expect(table.groups.map((group) => group.threshold)).toEqual([null, 200_000, 272_000]);
+ });
+
+ it("puts every tier of one charge on one row, token prices scaled to 1M tokens", () => {
+ expect(table.groups[0].rows).toEqual([
+ { label: "Input / 1M tokens", prices: { standard: "$2.50", flex: "$1.25", priority: "$5.00", batches: "$1.25" } },
+ { label: "Input / image", prices: { standard: "$0.002" } },
+ { label: "Cache read / 1M tokens", prices: { standard: "$1.25" } },
+ { label: "Cache write / 1M tokens", prices: { standard: "$3.00" } },
+ { label: "Output / 1M tokens", prices: { standard: "$10.00" } },
+ { label: "Output / second", prices: { standard: "$0.001" } },
+ ]);
+ });
+
+ it("shows an explicit cached read per 1M tokens on its own row", () => {
+ const { groups, unknown } = buildPricingTable({
+ cache_read_input_token_cost: 1e-7,
+ explicit_cache_read_input_token_cost: 5e-8,
+ });
+ expect(unknown).toEqual([]);
+ expect(groups[0].rows).toEqual([
+ { label: "Cache read / 1M tokens", prices: { standard: "$0.10" } },
+ { label: "Explicit cache read / 1M tokens", prices: { standard: "$0.05" } },
+ ]);
+ });
+
+ it("answers a cached read on priority above a threshold from its own group", () => {
+ expect(table.groups[2].rows).toEqual([{ label: "Cache read / 1M tokens", prices: { priority: "$1.00" } }]);
+ });
+
+ it("lists keys it cannot read under their raw name with the unscaled price", () => {
+ const { groups, unknown } = buildPricingTable({ input_cost_per_token: 1e-6, output_cost_per_second_720p: 0.05 });
+ expect(unknown).toEqual([{ key: "output_cost_per_second_720p", label: "Output / second 720p", price: "$0.05" }]);
+ expect(groups.flatMap((group) => group.rows.map((row) => row.label))).toEqual(["Input / 1M tokens"]);
+ });
+
+ it("shows a zero price as free and keeps precision for tiny per-unit prices", () => {
+ const { groups } = buildPricingTable({ input_cost_per_token: 0, output_cost_per_pixel: 1e-8 });
+ expect(groups[0].rows).toEqual([
+ { label: "Input / 1M tokens", prices: { standard: "Free" } },
+ { label: "Output / pixel", prices: { standard: "$0.00000001" } },
+ ]);
+ });
+
+ it("labels modality and cache duration in the row", () => {
+ const { groups } = buildPricingTable({
+ cache_creation_input_token_cost_above_1hr: 6e-6,
+ input_cost_per_audio_token: 4e-5,
+ output_cost_per_reasoning_token: 2e-5,
+ });
+ expect(groups[0].rows.map((row) => row.label)).toEqual([
+ "Audio input / 1M tokens",
+ "Cache write (1hr) / 1M tokens",
+ "Reasoning output / 1M tokens",
+ ]);
+ });
+
+ it("returns an empty table for a deployment with no prices", () => {
+ const empty = { tiers: [], groups: [], hasBlankCells: false, unknown: [] };
+ expect(buildPricingTable({})).toEqual(empty);
+ });
+
+ it("flags blank cells only when some charge lacks a price in a tier the deployment offers", () => {
+ const everyTierPriced = {
+ input_cost_per_token: 1e-6,
+ input_cost_per_token_priority: 2e-6,
+ output_cost_per_token: 3e-6,
+ output_cost_per_token_priority: 6e-6,
+ };
+ expect(buildPricingTable(CONTRACT_COSTS).hasBlankCells).toBe(true);
+ expect(buildPricingTable(everyTierPriced).hasBlankCells).toBe(false);
+ });
+});
+
+describe("fallbackCostLabel", () => {
+ it.each([
+ ["google_maps_grounding_cost_per_query", "Google maps grounding / query"],
+ ["file_search_cost_per_gb_per_day", "File search / gb per day"],
+ ["cost_per_second", "Cost / second"],
+ ["output_cost_per_second_720p", "Output / second 720p"],
+ ["explicit_cache_read_input_token_cost", "Explicit cache read input token"],
+ ["some_new_price", "Some new price"],
+ ])("names %s as %s", (key, label) => {
+ expect(fallbackCostLabel(key)).toBe(label);
+ });
+});
+
+describe("describePricingTier", () => {
+ it("states the token range and each price per 1M tokens, input before output", () => {
+ expect(
+ describePricingTier({
+ range: [0, 256_000],
+ costs: { output_cost_per_token: 4e-7, input_cost_per_token: 5e-8 },
+ }),
+ ).toBe("0–256K tokens: $0.05 input / $0.40 output");
+ });
+
+ it("names extra charges and non-token units", () => {
+ expect(
+ describePricingTier({
+ range: [256_000, 1_000_000],
+ costs: {
+ input_cost_per_token: 2.5e-7,
+ output_cost_per_reasoning_token: 1.2e-5,
+ input_cost_per_query: 0.01,
+ web_search_cost_per_call: 0.02,
+ },
+ }),
+ ).toBe("256K–1M tokens: $0.25 input / $0.01 input per query / $12.00 reasoning output / $0.02 web search / call");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/molecules/models/deploymentPricing.ts b/ui/litellm-dashboard/src/components/molecules/models/deploymentPricing.ts
new file mode 100644
index 00000000000..337a8900b7d
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/molecules/models/deploymentPricing.ts
@@ -0,0 +1,305 @@
+import type { ParseKeys } from "i18next";
+
+import i18n from "@/i18n/i18n";
+import type { components } from "@/lib/http/schema";
+
+export type PricingTier = components["schemas"]["PriceTier"];
+export type DeploymentPricing = components["schemas"]["DeploymentPricing"];
+
+export const SERVICE_TIERS = ["standard", "flex", "balanced", "priority", "ultrafast", "batches"] as const;
+export type ServiceTier = (typeof SERVICE_TIERS)[number];
+
+export const SERVICE_TIER_LABELS: Readonly> = {
+ standard: "deploymentPricing.tiers.standard",
+ flex: "deploymentPricing.tiers.flex",
+ balanced: "deploymentPricing.tiers.balanced",
+ priority: "deploymentPricing.tiers.priority",
+ ultrafast: "deploymentPricing.tiers.ultrafast",
+ batches: "deploymentPricing.tiers.batches",
+};
+
+const UNITS = ["token", "second", "image", "pixel", "character", "query", "request"] as const;
+type Unit = (typeof UNITS)[number];
+
+const CHARGES = ["input", "cache_read", "explicit_cache_read", "cache_write", "output"] as const;
+type Charge = (typeof CHARGES)[number];
+
+const MODALITIES = ["", "text", "image", "audio", "video", "reasoning"] as const;
+type Modality = (typeof MODALITIES)[number];
+
+export type CostKey =
+ | {
+ readonly kind: "price";
+ readonly charge: Charge;
+ readonly modality: Modality;
+ readonly cacheDuration: string;
+ readonly unit: Unit;
+ readonly threshold: number | null;
+ readonly tier: ServiceTier;
+ }
+ | { readonly kind: "unknown"; readonly key: string };
+
+const TIER_SUFFIX = /^(.+)_(flex|balanced|priority|ultrafast|batches)$/;
+const THRESHOLD_SUFFIX = /^(.+)_above_(\d+)k_tokens$/;
+const CACHE_DURATION_SUFFIX = /^(.+)_above_(\d+hr)$/;
+const DIRECT_PRICE = /^(input|output)_cost_per_(?:(text|image|audio|video|reasoning)_(?:per_)?)?([a-z]+)$/;
+const CACHE_PRICE = /^(explicit_)?cache_(read|creation)_input_(?:(image|audio)_)?token_cost$/;
+
+const splitSuffix = (key: string, pattern: RegExp): readonly [string, string | null] => {
+ const match = pattern.exec(key);
+ return match ? [match[1], match[2]] : [key, null];
+};
+
+const isUnit = (value: string): value is Unit => (UNITS as readonly string[]).includes(value);
+
+const parseBase = (base: string): Pick, "charge" | "modality" | "unit"> | null => {
+ const direct = DIRECT_PRICE.exec(base);
+ if (direct) {
+ const [, charge, modality = "", unit] = direct;
+ return isUnit(unit) ? { charge: charge as Charge, modality: modality as Modality, unit } : null;
+ }
+ const cache = CACHE_PRICE.exec(base);
+ if (cache) {
+ const [, explicit, kind, modality = ""] = cache;
+ if (kind === "creation")
+ return explicit ? null : { charge: "cache_write", modality: modality as Modality, unit: "token" };
+ return { charge: explicit ? "explicit_cache_read" : "cache_read", modality: modality as Modality, unit: "token" };
+ }
+ return null;
+};
+
+export const parseCostKey = (key: string): CostKey => {
+ const [withoutTier, tier] = splitSuffix(key, TIER_SUFFIX);
+ const [withoutThreshold, thousands] = splitSuffix(withoutTier, THRESHOLD_SUFFIX);
+ const [base, cacheDuration] = splitSuffix(withoutThreshold, CACHE_DURATION_SUFFIX);
+ const parsed = parseBase(base);
+ if (!parsed) return { kind: "unknown", key };
+ return {
+ kind: "price",
+ ...parsed,
+ cacheDuration: cacheDuration ?? "",
+ threshold: thousands === null ? null : Number(thousands) * 1000,
+ tier: (tier ?? "standard") as ServiceTier,
+ };
+};
+
+type Price = Extract;
+
+type LabelForm = "start" | "inline";
+
+const CHARGE_LABELS: Readonly>>> = {
+ start: {
+ input: "deploymentPricing.charges.input",
+ cache_read: "deploymentPricing.charges.cacheRead",
+ explicit_cache_read: "deploymentPricing.charges.explicitCacheRead",
+ cache_write: "deploymentPricing.charges.cacheWrite",
+ output: "deploymentPricing.charges.output",
+ },
+ inline: {
+ input: "deploymentPricing.chargesInline.input",
+ cache_read: "deploymentPricing.chargesInline.cacheRead",
+ explicit_cache_read: "deploymentPricing.chargesInline.explicitCacheRead",
+ cache_write: "deploymentPricing.chargesInline.cacheWrite",
+ output: "deploymentPricing.chargesInline.output",
+ },
+};
+
+const MODALITY_LABELS: Readonly, ParseKeys>>>> = {
+ start: {
+ text: "deploymentPricing.modalities.text",
+ image: "deploymentPricing.modalities.image",
+ audio: "deploymentPricing.modalities.audio",
+ video: "deploymentPricing.modalities.video",
+ reasoning: "deploymentPricing.modalities.reasoning",
+ },
+ inline: {
+ text: "deploymentPricing.modalitiesInline.text",
+ image: "deploymentPricing.modalitiesInline.image",
+ audio: "deploymentPricing.modalitiesInline.audio",
+ video: "deploymentPricing.modalitiesInline.video",
+ reasoning: "deploymentPricing.modalitiesInline.reasoning",
+ },
+};
+
+const UNIT_LABELS: Readonly> = {
+ token: "deploymentPricing.units.token",
+ second: "deploymentPricing.units.second",
+ image: "deploymentPricing.units.image",
+ pixel: "deploymentPricing.units.pixel",
+ character: "deploymentPricing.units.character",
+ query: "deploymentPricing.units.query",
+ request: "deploymentPricing.units.request",
+};
+
+const chargeName = ({ charge, modality }: Price, form: LabelForm): string =>
+ modality
+ ? i18n.t("deploymentPricing.withModality", {
+ modality: i18n.t(MODALITY_LABELS[form][modality]),
+ charge: i18n.t(CHARGE_LABELS.inline[charge]),
+ })
+ : i18n.t(CHARGE_LABELS[form][charge]);
+
+const chargeLabel = (price: Price, form: LabelForm): string =>
+ price.cacheDuration
+ ? i18n.t("deploymentPricing.withCacheDuration", { charge: chargeName(price, form), duration: price.cacheDuration })
+ : chargeName(price, form);
+
+const unitLabel = (unit: Unit): string => i18n.t(UNIT_LABELS[unit]);
+
+const rowLabel = (price: Price): string =>
+ i18n.t("deploymentPricing.rowLabel", { charge: chargeLabel(price, "start"), unit: unitLabel(price.unit) });
+
+const rowRank = (price: Price): readonly number[] => [
+ CHARGES.indexOf(price.charge),
+ UNITS.indexOf(price.unit),
+ MODALITIES.indexOf(price.modality),
+ parseInt(price.cacheDuration || "0", 10),
+];
+
+const compareRanks = (a: readonly number[], b: readonly number[]): number =>
+ a.reduce((result, value, index) => result || value - b[index], 0);
+
+const PRICE_FORMAT_OPTIONS: Intl.NumberFormatOptions = {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 10,
+ useGrouping: false,
+};
+
+const PRICE_FORMAT = new Intl.NumberFormat("en-US", PRICE_FORMAT_OPTIONS);
+
+export const formatRawPrice = (value: number): string =>
+ value === 0 ? i18n.t("deploymentPricing.free") : PRICE_FORMAT.format(value);
+
+const formatUnitPrice = (value: number, unit: Unit): string =>
+ formatRawPrice(unit === "token" ? value * 1_000_000 : value);
+
+export interface PriceRow {
+ readonly label: string;
+ readonly prices: Readonly>>;
+}
+
+export interface PriceGroup {
+ readonly threshold: number | null;
+ readonly rows: readonly PriceRow[];
+}
+
+export interface UnknownPrice {
+ readonly key: string;
+ readonly label: string;
+ readonly price: string;
+}
+
+export interface PricingTable {
+ readonly tiers: readonly ServiceTier[];
+ readonly groups: readonly PriceGroup[];
+ readonly hasBlankCells: boolean;
+ readonly unknown: readonly UnknownPrice[];
+}
+
+const PER_UNIT_KEY = /^(.*?)_?cost_per_(.+)$/;
+const COST_SUFFIX = /^(.+?)_cost(?:_(.+))?$/;
+
+const sentenceCase = (text: string): string => {
+ const words = text.replaceAll("_", " ").trim();
+ return words.charAt(0).toUpperCase() + words.slice(1);
+};
+
+export const fallbackCostLabel = (key: string): string => {
+ const perUnit = PER_UNIT_KEY.exec(key);
+ if (perUnit) {
+ return i18n.t("deploymentPricing.rowLabel", {
+ charge: perUnit[1] ? sentenceCase(perUnit[1]) : i18n.t("deploymentPricing.fallbackCost"),
+ unit: perUnit[2].replaceAll("_", " "),
+ });
+ }
+ const suffixed = COST_SUFFIX.exec(key);
+ if (suffixed) return sentenceCase([suffixed[1], suffixed[2]].filter(Boolean).join(" "));
+ return sentenceCase(key);
+};
+
+interface ParsedEntry {
+ readonly key: string;
+ readonly value: number;
+ readonly cost: CostKey;
+}
+
+interface PriceEntry extends ParsedEntry {
+ readonly cost: Price;
+}
+
+const isPriceEntry = (entry: ParsedEntry): entry is PriceEntry => entry.cost.kind === "price";
+
+const parseCosts = (costs: Readonly>): readonly ParsedEntry[] =>
+ Object.entries(costs).map(([key, value]) => ({ key, value, cost: parseCostKey(key) }));
+
+const uniqueBy = (items: readonly T[], keyOf: (item: T) => K): readonly T[] =>
+ items.filter((item, index) => items.findIndex((other) => keyOf(other) === keyOf(item)) === index);
+
+const byRowRank = (a: PriceEntry, b: PriceEntry): number => compareRanks(rowRank(a.cost), rowRank(b.cost));
+
+const groupRows = (entries: readonly PriceEntry[]): readonly PriceRow[] =>
+ [...uniqueBy(entries, ({ cost }) => rowLabel(cost))].sort(byRowRank).map(({ cost }) => ({
+ label: rowLabel(cost),
+ prices: Object.fromEntries(
+ entries
+ .filter((other) => rowLabel(other.cost) === rowLabel(cost))
+ .map((other) => [other.cost.tier, formatUnitPrice(other.value, other.cost.unit)]),
+ ),
+ }));
+
+export const buildPricingTable = (costs: Readonly>): PricingTable => {
+ const parsed = parseCosts(costs);
+ const prices = parsed.filter(isPriceEntry);
+ const thresholds = [...uniqueBy(prices, ({ cost }) => cost.threshold)]
+ .map(({ cost }) => cost.threshold)
+ .sort((a, b) => (a ?? 0) - (b ?? 0));
+ const tiers = SERVICE_TIERS.filter((tier) => prices.some(({ cost }) => cost.tier === tier));
+ const groups = thresholds.map((threshold) => ({
+ threshold,
+ rows: groupRows(prices.filter(({ cost }) => cost.threshold === threshold)),
+ }));
+ return {
+ tiers,
+ groups,
+ hasBlankCells: groups.some(({ rows }) => rows.some(({ prices: row }) => tiers.some((tier) => !(tier in row)))),
+ unknown: parsed
+ .filter((entry) => !isPriceEntry(entry))
+ .map(({ key, value }) => ({ key, label: fallbackCostLabel(key), price: formatRawPrice(value) })),
+ };
+};
+
+export const formatTokenCount = (tokens: number): string => {
+ if (tokens >= 1_000_000) return `${+(tokens / 1_000_000).toFixed(1)}M`;
+ if (tokens >= 1_000) return `${+(tokens / 1_000).toFixed(1)}K`;
+ return String(tokens);
+};
+
+const tierPart = ({ cost, value }: PriceEntry): string => {
+ const charge = chargeLabel(cost, "inline");
+ const price = formatUnitPrice(value, cost.unit);
+ return cost.unit === "token"
+ ? i18n.t("deploymentPricing.tierPrice", { price, charge })
+ : i18n.t("deploymentPricing.tierPricePerUnit", { price, charge, unit: unitLabel(cost.unit) });
+};
+
+export const describePricingTier = ({ range: [low, high], costs }: PricingTier): string => {
+ const parsed = parseCosts(costs);
+ const parts = [
+ ...[...parsed.filter(isPriceEntry)].sort(byRowRank).map(tierPart),
+ ...parsed
+ .filter((entry) => !isPriceEntry(entry))
+ .map(({ key, value }) =>
+ i18n.t("deploymentPricing.tierPrice", {
+ price: formatRawPrice(value),
+ charge: fallbackCostLabel(key).toLowerCase(),
+ }),
+ ),
+ ];
+ return i18n.t("deploymentPricing.tierRange", {
+ low: formatTokenCount(low),
+ high: formatTokenCount(high),
+ prices: parts.join(" / "),
+ });
+};
diff --git a/ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx b/ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx
index 95901f2bf1d..39495baeea3 100644
--- a/ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx
+++ b/ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx
@@ -4,6 +4,7 @@ import { ChevronDown } from "lucide-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
+import { DeploymentPricingBreakdown } from "@/components/molecules/models/DeploymentPricingBreakdown";
import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@@ -17,6 +18,7 @@ import {
modeLabel,
modelCapabilities,
providerName,
+ SECTION_TITLE,
wildcardExample,
} from "./hubFormat";
import { Badges, CodeBlock, type Fact, Facts, PaneSection, ProviderAvatar, ProviderName } from "./HubParts";
@@ -175,6 +177,10 @@ export function ModelInspector({ model }: { model: ModelGroupInfo }) {
)}
+ {t("publicModelHub.modelInspector.pricingByDeployment")}}
+ />
{capabilities.length > 0 && (
diff --git a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx
index e966836fa3a..cf40a685d76 100644
--- a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx
@@ -77,6 +77,13 @@ const MODELS = [
output_cost_per_token: 0,
max_input_tokens: 0,
description: "General purpose",
+ deployment_pricing: [
+ {
+ provider: "azure",
+ costs: { output_cost_per_token: 1e-5, output_cost_per_token_priority: 2e-5 },
+ tiered_pricing: [],
+ },
+ ],
},
];
@@ -362,6 +369,24 @@ describe("PublicAiHub", () => {
expect(within(pricedRow).queryByRole("cell", { name: "Free" })).not.toBeInTheDocument();
});
+ it("shows the open model's prices for each of its deployments", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.click(await screen.findByRole("link", { name: /gpt-5/ }));
+
+ expect(await screen.findByRole("heading", { name: "Pricing by deployment" })).toBeInTheDocument();
+ const azure = screen.getByRole("region", { name: "Azure pricing" });
+ expect(within(azure).getByRole("row", { name: /Output \/ 1M tokens/ })).toHaveTextContent("$10.00$20.00");
+ });
+
+ it("leaves the pricing breakdown out for a model without deployment prices", async () => {
+ renderWithProviders(, { searchParams: { open: "claude-sonnet-4-5" } });
+
+ expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "Pricing by deployment" })).not.toBeInTheDocument();
+ });
+
it("treats an empty open param as nothing opened", async () => {
renderWithProviders(, { searchParams: { open: "" } });
diff --git a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx b/ui/litellm-dashboard/src/components/public_model_hub.test.tsx
index 515b8230d5b..4081751ca73 100644
--- a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx
+++ b/ui/litellm-dashboard/src/components/public_model_hub.test.tsx
@@ -327,6 +327,33 @@ describe("PublicModelHub", () => {
expect(example).toHaveTextContent('model="gpt-4"');
});
+ it("shows each deployment's full pricing in the model details", async () => {
+ respondWith([
+ model({
+ model_group: "gpt-4",
+ input_cost_per_token: 2.5e-6,
+ deployment_pricing: [
+ {
+ provider: "openai",
+ costs: { input_cost_per_token: 2.5e-6, cache_read_input_token_cost_above_272k_tokens_priority: 1e-6 },
+ tiered_pricing: [{ range: [0, 256_000], costs: { input_cost_per_token: 5e-8 } }],
+ },
+ ],
+ }),
+ ]);
+ renderHub();
+
+ fireEvent.click(await screen.findByRole("button", { name: "gpt-4" }));
+
+ const openai = await screen.findByRole("region", { name: "OpenAI pricing" });
+ const aboveThreshold = within(openai).getByRole("rowgroup", { name: "Above 272K tokens" });
+ expect(within(aboveThreshold).getByRole("row", { name: /Cache read/ })).toHaveTextContent(
+ "Cache read / 1M tokens$1.00",
+ );
+ expect(within(openai).getByText("0–256K tokens: $0.05 input")).toBeInTheDocument();
+ expect(within(screen.getByRole("dialog")).getByText("$2.5000")).toBeInTheDocument();
+ });
+
it("filters by several providers at once, and returns to the first page", async () => {
const user = userEvent.setup();
respondWith(DEFAULT_MODELS, 300);
diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx
index a80bf939165..552ed6fc490 100644
--- a/ui/litellm-dashboard/src/components/public_model_hub.tsx
+++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx
@@ -56,6 +56,7 @@ import { displayMode, isDecisionMode } from "@/lib/decisionModels";
import { modelUsageExample } from "@/components/AIHub/modelUsageExample";
import { filterBySearchTerm, rankBySearchRelevance } from "@/utils/searchUtils";
import { ModelCustomMetadata } from "@/components/AIHub/ModelCustomMetadata";
+import { DeploymentPricingBreakdown } from "@/components/molecules/models/DeploymentPricingBreakdown";
import ModelHubProviders, { formatModelModeLabel } from "@/components/AIHub/ModelHubProviders";
interface PublicModelHubProps {
@@ -954,6 +955,13 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded
+ {t("aiHub.publicModelHub.modelDialog.pricingByDeployment")}
+ }
+ />
{/* Capabilities */}
diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json
index 919d6cd25cc..3c9dbc99a6b 100644
--- a/ui/litellm-dashboard/src/i18n/locales/en.json
+++ b/ui/litellm-dashboard/src/i18n/locales/en.json
@@ -7217,7 +7217,8 @@
"rpm": "Requests per Minute:",
"openaiParams": "Supported OpenAI Parameters",
"usageExample": "Usage Example",
- "copyToClipboard": "Copy to clipboard"
+ "copyToClipboard": "Copy to clipboard",
+ "pricingByDeployment": "Pricing by Deployment:"
},
"agentDialog": {
"fallbackTitle": "Agent Details",
@@ -7370,7 +7371,8 @@
"usageExample": "Usage Example",
"decisionNote": "Decision models answer on /v1/decisions and /v1/systemone, not /chat/completions.",
"howToCall": "How to call decision models",
- "tryPlayground": "Try it in the Playground"
+ "tryPlayground": "Try it in the Playground",
+ "pricingByDeployment": "Pricing by Deployment:"
},
"agentDialog": {
"fallbackTitle": "Agent Details",
@@ -7905,7 +7907,8 @@
"title": "Wildcard model",
"description": "Call it as , with any model name this provider offers"
},
- "capabilities": "Capabilities"
+ "capabilities": "Capabilities",
+ "pricingByDeployment": "Pricing by deployment"
},
"agentInspector": {
"copyName": "Copy agent name",
@@ -20300,5 +20303,69 @@
"save": "Save Changes"
}
}
+ },
+ "deploymentPricing": {
+ "deploymentCount_one": "This model has {{count}} deployment. Each request is billed at the prices of the deployment that served it.",
+ "deploymentCount_other": "This model has {{count}} deployments. Each request is billed at the prices of the deployment that served it.",
+ "requestAdjustments": "These are list prices. Adjustments made per request, like off-peak discounts, regional or data residency surcharges, and some providers' own pricing rules, aren't included.",
+ "providerPricing": "{{provider}} pricing",
+ "chargeColumn": "Charge",
+ "basePrices": "Base prices",
+ "aboveThreshold": "Above {{threshold}} tokens",
+ "blankCells": "Blank cells aren't priced separately for this deployment.",
+ "tieredPricing": "Prices by request size, per 1M tokens",
+ "tierRange": "{{low}}–{{high}} tokens: {{prices}}",
+ "tierPrice": "{{price}} {{charge}}",
+ "tierPricePerUnit": "{{price}} {{charge}} per {{unit}}",
+ "free": "Free",
+ "rowLabel": "{{charge}} / {{unit}}",
+ "withModality": "{{modality}} {{charge}}",
+ "withCacheDuration": "{{charge}} ({{duration}})",
+ "fallbackCost": "Cost",
+ "tiers": {
+ "standard": "Standard",
+ "flex": "Flex",
+ "balanced": "Balanced",
+ "priority": "Priority",
+ "ultrafast": "Ultrafast",
+ "batches": "Batch"
+ },
+ "charges": {
+ "input": "Input",
+ "cacheRead": "Cache read",
+ "explicitCacheRead": "Explicit cache read",
+ "cacheWrite": "Cache write",
+ "output": "Output"
+ },
+ "chargesInline": {
+ "input": "input",
+ "cacheRead": "cache read",
+ "explicitCacheRead": "explicit cache read",
+ "cacheWrite": "cache write",
+ "output": "output"
+ },
+ "modalities": {
+ "text": "Text",
+ "image": "Image",
+ "audio": "Audio",
+ "video": "Video",
+ "reasoning": "Reasoning"
+ },
+ "modalitiesInline": {
+ "text": "text",
+ "image": "image",
+ "audio": "audio",
+ "video": "video",
+ "reasoning": "reasoning"
+ },
+ "units": {
+ "token": "1M tokens",
+ "second": "second",
+ "image": "image",
+ "pixel": "pixel",
+ "character": "character",
+ "query": "query",
+ "request": "request"
+ }
}
}
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index c351e3b1b90..ab1f3c253ea 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -32837,6 +32837,20 @@ export interface components {
} & {
[key: string]: unknown;
};
+ /** DeploymentPricing */
+ DeploymentPricing: {
+ /** Costs */
+ costs: {
+ [key: string]: number;
+ };
+ /** Provider */
+ provider: string;
+ /**
+ * Tiered Pricing
+ * @default []
+ */
+ tiered_pricing: components["schemas"]["PriceTier"][];
+ };
/** DeploymentWarning */
DeploymentWarning: {
/** Id */
@@ -39687,6 +39701,11 @@ export interface components {
} | null;
/** Custom Tags */
custom_tags?: string[] | null;
+ /**
+ * Deployment Pricing
+ * @default []
+ */
+ deployment_pricing: components["schemas"]["DeploymentPricing"][];
/** Description */
description?: string | null;
/** Health Checked At */
@@ -42891,6 +42910,18 @@ export interface components {
*/
version_status: string;
};
+ /** PriceTier */
+ PriceTier: {
+ /** Costs */
+ costs: {
+ [key: string]: number;
+ };
+ /** Range */
+ range: [
+ number,
+ number
+ ];
+ };
/** Prompt */
Prompt: {
litellm_params: components["schemas"]["PromptLiteLLMParams"];