mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-07 07:58:09 +00:00
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:
parent
d4f338ef1e
commit
ec46a981fc
20 changed files with 1367 additions and 5 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
69
litellm/router_utils/model_group_pricing.py
Normal file
69
litellm/router_utils/model_group_pricing.py
Normal 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)
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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(" / "),
|
||||
});
|
||||
};
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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: "" } });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
31
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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"];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue