This commit is contained in:
ryan-crabbe-berri 2026-11-05 19:57:47 -08:00 • committed by GitHub
commit 83ac95e4fd
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,
@ -55,12 +56,14 @@ from litellm.litellm_core_utils.routing_common_utils import team_may_use_deploym
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.llms.bedrock.common_utils import get_bedrock_base_model
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.router_utils.routing_read_batch import ROUTER_USAGE_TARGET
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, ...] = ()
class HostModelInfo:
@ -304,6 +313,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:
@ -330,6 +340,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

@ -1897,6 +1897,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

@ -7194,7 +7194,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",
@ -7347,7 +7348,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",
@ -7882,7 +7884,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",
@ -20046,5 +20049,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

@ -32831,6 +32831,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 */
@ -39681,6 +39695,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 */
@ -42885,6 +42904,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"];