Skip to content

Commit c73480c

Browse files
committed
fix(proxy): block every unpriced model a request names
A request can name more than one model, through a comma-separated model or target_model_names on the batch and fine-tuning routes, and the gate only looked at the string case, so an unpriced model riding alongside a priced one went through and billed. Check every candidate and name the unpriced ones in the 403 Aliases had the same problem on the other side: a group that prices itself through its model_info block lands in the cost map under its deployment id, and the explicit-cost check walked the raw model list by group name, so an alias pointing at that group read as unpriced. Resolve the group through the router the way the pricing check already does Also correct the 403 copy. Providers that return their own usage cost still bill for these models, so the accurate claim is that litellm has no pricing of its own for them
1 parent df00c33 commit c73480c

2 files changed

Lines changed: 98 additions & 12 deletions

File tree

litellm/proxy/auth/auth_checks.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,24 @@ def _model_group_has_pricing(model: str, llm_router: "Router") -> bool:
513513
return False
514514

515515

516+
def _group_declares_explicit_cost(model: str, llm_router: "Router") -> bool:
517+
"""
518+
Alias-aware counterpart to ``_is_cost_explicitly_configured``, which resolves the model group
519+
the same way ``_model_group_has_pricing`` does. A deployment that prices itself through its
520+
``model_info`` block lands in the cost map under its deployment id rather than in its
521+
litellm_params, and reaching that entry through the router's own resolution keeps an alias
522+
pointing at such a group from being read as unpriced.
523+
"""
524+
for deployment in llm_router.get_model_list(model_name=model) or ():
525+
model_id = (deployment.get("model_info") or _EMPTY_COST_ENTRY).get("id")
526+
if model_id is None:
527+
continue
528+
raw_entry = litellm.model_cost.get(model_id, _EMPTY_COST_ENTRY)
529+
if "input_cost_per_token" in raw_entry or "output_cost_per_token" in raw_entry:
530+
return True
531+
return False
532+
533+
516534
def model_has_no_cost_mapping(model: str | None, llm_router: Router | None) -> bool:
517535
if not model or llm_router is None:
518536
return False
@@ -523,7 +541,24 @@ def model_has_no_cost_mapping(model: str | None, llm_router: Router | None) -> b
523541
if _model_group_has_pricing(model=model, llm_router=llm_router):
524542
return False
525543

526-
return not _is_cost_explicitly_configured(model, llm_router)
544+
return not _group_declares_explicit_cost(model=model, llm_router=llm_router)
545+
546+
547+
def _unpriced_models_in_request(model: str | list[str] | None, llm_router: Router | None) -> tuple[str, ...]:
548+
candidates: Final = (model,) if isinstance(model, str) else tuple(model or ())
549+
return tuple(
550+
candidate for candidate in candidates if model_has_no_cost_mapping(model=candidate, llm_router=llm_router)
551+
)
552+
553+
554+
def _unpriced_models_block_message(models: tuple[str, ...]) -> str:
555+
names: Final = ", ".join(f"'{model}'" for model in models)
556+
subject: Final = f"Model {names} has" if len(models) == 1 else f"Models {names} have"
557+
return (
558+
f"{subject} no pricing in the cost map, so litellm cannot price the request. "
559+
"Requests for unpriced models are blocked because 'block_requests_for_models_without_pricing' "
560+
"is enabled. Add pricing (input_cost_per_token/output_cost_per_token) to allow the request."
561+
)
527562

528563

529564
async def _run_project_checks(
@@ -796,18 +831,14 @@ async def common_checks(
796831
and (route in MODEL_DISCOVERY_ROUTES or not RouteChecks.is_llm_api_route(route=route))
797832
)
798833

799-
if (
800-
litellm.block_requests_for_models_without_pricing
801-
and isinstance(_model, str)
802-
and RouteChecks.is_llm_api_route(route=route)
803-
and model_has_no_cost_mapping(model=_model, llm_router=llm_router)
804-
):
834+
unpriced_models: Final = (
835+
_unpriced_models_in_request(model=_model, llm_router=llm_router)
836+
if litellm.block_requests_for_models_without_pricing and RouteChecks.is_llm_api_route(route=route)
837+
else ()
838+
)
839+
if unpriced_models:
805840
raise ProxyException(
806-
message=(
807-
f"Model '{_model}' has no pricing in the cost map, so its spend would be tracked as $0. "
808-
"Requests for unpriced models are blocked because 'block_requests_for_models_without_pricing' "
809-
"is enabled. Add pricing for this model (input_cost_per_token/output_cost_per_token) to allow it."
810-
),
841+
message=_unpriced_models_block_message(unpriced_models),
811842
type=ProxyErrorTypes.model_cost_map_missing,
812843
param="model",
813844
code=status.HTTP_403_FORBIDDEN,

tests/test_litellm/proxy/auth/test_auth_checks.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6784,3 +6784,58 @@ async def test_common_checks_blocks_alias_resolving_to_unpriced_model(monkeypatc
67846784
assert exc_info.value.code == "403"
67856785
assert exc_info.value.type == ProxyErrorTypes.model_cost_map_missing
67866786
assert "public-alias" in exc_info.value.message
6787+
6788+
6789+
@pytest.mark.asyncio
6790+
async def test_common_checks_blocks_comma_separated_request_carrying_an_unpriced_model(monkeypatch):
6791+
monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True)
6792+
router = _router_with_priced_and_unpriced_models()
6793+
6794+
with pytest.raises(ProxyException) as exc_info:
6795+
await _run_common_checks(model="priced-group,unpriced-group", llm_router=router)
6796+
6797+
assert exc_info.value.code == "403"
6798+
assert exc_info.value.type == ProxyErrorTypes.model_cost_map_missing
6799+
assert "'unpriced-group'" in exc_info.value.message
6800+
assert "'priced-group'" not in exc_info.value.message
6801+
6802+
6803+
@pytest.mark.asyncio
6804+
async def test_common_checks_allows_comma_separated_request_when_every_model_is_priced(monkeypatch):
6805+
monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True)
6806+
router = _router_with_priced_and_unpriced_models()
6807+
6808+
result = await _run_common_checks(model="priced-group,priced-group", llm_router=router)
6809+
6810+
assert result is True
6811+
6812+
6813+
def _router_with_a_group_priced_through_model_info() -> "Router":
6814+
from litellm.router import Router
6815+
6816+
return Router(
6817+
model_list=[
6818+
{
6819+
"model_name": "model-info-priced-group",
6820+
"litellm_params": {"model": f"{UNPRICED_UNDERLYING_MODEL}-model-info", "api_key": "sk-test"},
6821+
"model_info": {"input_cost_per_token": 0, "output_cost_per_token": 0},
6822+
}
6823+
],
6824+
model_group_alias={"model-info-priced-alias": "model-info-priced-group"},
6825+
)
6826+
6827+
6828+
def test_model_has_no_cost_mapping_group_priced_through_model_info_is_false():
6829+
from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping
6830+
6831+
router = _router_with_a_group_priced_through_model_info()
6832+
6833+
assert model_has_no_cost_mapping(model="model-info-priced-group", llm_router=router) is False
6834+
6835+
6836+
def test_model_has_no_cost_mapping_alias_to_a_group_priced_through_model_info_is_false():
6837+
from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping
6838+
6839+
router = _router_with_a_group_priced_through_model_info()
6840+
6841+
assert model_has_no_cost_mapping(model="model-info-priced-alias", llm_router=router) is False

0 commit comments

Comments
 (0)