mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge 71fa660243 into 18b02380e5
This commit is contained in:
commit
83ac95e4fd
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,
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
31
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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"];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue