feat(model-hub): show each deployment's full prices in the model hub (#50145)

* feat(model-hub): show each deployment's full prices in the model hub

The model hub only showed one input and one output price per model, the highest across its
deployments. Each model group now lists every deployment's prices, read from the same entry
billing charges, including cached tokens, long-context tiers, flex, priority, batch and
tiered ranges. Both the logged-in and public hub detail views render them in one table per
deployment, labeled by provider only

* refactor(ui): move model hub pricing breakdown text onto i18n

Route every string the deployment pricing breakdown writes through t() under a new deploymentPricing group in en.json, plus the three hub headings that host it. The English on screen is unchanged

* fix(model-hub): show Vertex regional prices and explicit cached reads as billed

A Vertex deployment outside the global endpoint now shows its token prices with the regional
uplift billing applies at the deployment's location. The explicit cached read price shows per
1M tokens on its own row instead of a raw per-token value. The breakdown also says that
adjustments made per request, like off-peak discounts and regional or data residency
surcharges, aren't included

* fix(model-hub): show prices written as text the way billing reads them

A price written in config.yaml as 3e-7 arrives as text. Billing turns it into a number, but
the hub dropped it, so a deployment showed no price or the cost map's price instead of its own.
The hub now reads numeric text for regular prices and size-based price ranges, and skips text
that isn't a number

* style(ui): format public model hub test

* test(model-hub): name the deployment pricing fixture to stay within the inline object lint budget
This commit is contained in:
ryan-crabbe-berri 2026-11-06 10:45:44 -08:00 • committed by GitHub
parent d4f338ef1e
commit ec46a981fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1367 additions and 5 deletions

View file

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

View file

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

View file

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

View file

@ -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():

View file

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

View file

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

View file

@ -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" });

View file

@ -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<ModelHubTableProps> = ({
</p>
</div>
</div>
<DeploymentPricingBreakdown
className="mt-4"
deployments={selectedModel.deployment_pricing}
heading={<p className="font-medium">{t("aiHub.modelHubTable.modelDialog.pricingByDeployment")}</p>}
/>
</div>
{/* Capabilities */}

View file

@ -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<string, ModelCustomMetadataValue> | null;
deployment_pricing?: DeploymentPricing[] | null;
[key: string]: any;
}

View file

@ -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<string, ModelCustomMetadataValue> | null;
deployment_pricing?: DeploymentPricing[] | null;
[key: string]: any;
}

View file

@ -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(<DeploymentPricingBreakdown deployments={[OPENAI, AZURE]} heading={<h3>Pricing</h3>} />);
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(<DeploymentPricingBreakdown deployments={[OPENAI]} />);
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(<DeploymentPricingBreakdown deployments={[OPENAI]} />);
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(
<DeploymentPricingBreakdown
deployments={[
{
provider: "dashscope",
costs: {},
tiered_pricing: [{ range: [0, 256_000], costs: { input_cost_per_token: 5e-8 } }],
},
]}
/>,
);
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(
<DeploymentPricingBreakdown deployments={deployments} heading={<h3>Pricing by deployment</h3>} />,
);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -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 (
<TableBody aria-label={name}>
{group.threshold !== null && (
<TableRow className="hover:bg-transparent">
<TableHead colSpan={tiers.length + 1} scope="colgroup" className="h-8 text-xs text-muted-foreground">
{name}
</TableHead>
</TableRow>
)}
{group.rows.map((row) => (
<TableRow key={row.label}>
<TableHead scope="row" className="font-normal">
{row.label}
</TableHead>
{tiers.map((tier) => (
<TableCell key={tier} className={NUMERIC_CELL_CLASS}>
{row.prices[tier]}
</TableCell>
))}
</TableRow>
))}
</TableBody>
);
}
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 (
<section
aria-label={t("deploymentPricing.providerPricing", { provider: providerName })}
className="flex flex-col gap-2 rounded-lg border border-border p-3"
>
<h4 className="flex items-center gap-2 text-sm font-medium">
<ProviderLogo provider={deployment.provider} />
{providerName}
</h4>
{table.groups.length > 0 && (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>{t("deploymentPricing.chargeColumn")}</TableHead>
{table.tiers.map((tier) => (
<TableHead key={tier} className={NUMERIC_CELL_CLASS}>
{t(SERVICE_TIER_LABELS[tier])}
</TableHead>
))}
</TableRow>
</TableHeader>
{table.groups.map((group) => (
<PriceGroupRows key={group.threshold ?? "base"} group={group} tiers={table.tiers} />
))}
</Table>
)}
{table.hasBlankCells && <p className="text-xs text-muted-foreground">{t("deploymentPricing.blankCells")}</p>}
{tiers.length > 0 && (
<div className="flex flex-col gap-1 text-sm">
<p className="text-xs font-medium text-muted-foreground">{t("deploymentPricing.tieredPricing")}</p>
<ul className="flex flex-col gap-0.5 tabular-nums">
{tiers.map((tier) => (
<li key={tier.range.join("-")}>{describePricingTier(tier)}</li>
))}
</ul>
</div>
)}
{table.unknown.length > 0 && (
<dl className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 text-sm">
{table.unknown.map(({ key, label, price }) => (
<div key={key} className="contents">
<dt className="truncate">{label}</dt>
<dd className="text-right tabular-nums">{price}</dd>
</div>
))}
</dl>
)}
</section>
);
}
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 (
<div className={cn("flex flex-col gap-3", className)}>
{heading}
{deployments.length > 1 && (
<p className="text-xs text-muted-foreground">
{t("deploymentPricing.deploymentCount", { count: deployments.length })}
</p>
)}
{deployments.map((deployment, index) => (
<DeploymentPrices key={`${deployment.provider}-${index}`} deployment={deployment} />
))}
<p className="text-xs text-muted-foreground">{t("deploymentPricing.requestAdjustments")}</p>
</div>
);
}

View file

@ -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");
});
});

View file

@ -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<Record<ServiceTier, ParseKeys>> = {
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<Extract<CostKey, { kind: "price" }>, "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<CostKey, { kind: "price" }>;
type LabelForm = "start" | "inline";
const CHARGE_LABELS: Readonly<Record<LabelForm, Readonly<Record<Charge, ParseKeys>>>> = {
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<Record<LabelForm, Readonly<Record<Exclude<Modality, "">, 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<Record<Unit, ParseKeys>> = {
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<Partial<Record<ServiceTier, string>>>;
}
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<Record<string, number>>): readonly ParsedEntry[] =>
Object.entries(costs).map(([key, value]) => ({ key, value, cost: parseCostKey(key) }));
const uniqueBy = <T, K>(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<Record<string, number>>): 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(" / "),
});
};

View file

@ -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 }) {
</Alert>
)}
<Facts groups={modelFacts(model)} />
<DeploymentPricingBreakdown
deployments={model.deployment_pricing}
heading={<h3 className={SECTION_TITLE}>{t("publicModelHub.modelInspector.pricingByDeployment")}</h3>}
/>
{capabilities.length > 0 && (
<PaneSection title={t("publicModelHub.modelInspector.capabilities")}>
<Badges items={capabilities} />

View file

@ -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(<PublicAiHub accessToken={null} />);
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(<PublicAiHub accessToken={null} />, { 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(<PublicAiHub accessToken={null} />, { searchParams: { open: "" } });

View file

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

View file

@ -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<PublicModelHubProps> = ({ accessToken, isEmbedded
</p>
</div>
</div>
<DeploymentPricingBreakdown
className="mt-4"
deployments={selectedModel.deployment_pricing}
heading={
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.pricingByDeployment")}</p>
}
/>
</div>
{/* Capabilities */}

View file

@ -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 <code>/v1/decisions</code> and <code>/v1/systemone</code>, not <code>/chat/completions</code>.",
"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 <example/>, 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"
}
}
}

View file

@ -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"];