diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 0bf419ca1de..7d3246aef0a 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -513,6 +513,24 @@ def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: return False +def _group_declares_explicit_cost(model: str, llm_router: "Router") -> bool: + """ + Alias-aware counterpart to ``_is_cost_explicitly_configured``, which resolves the model group + the same way ``_model_group_has_pricing`` does. A deployment that prices itself through its + ``model_info`` block lands in the cost map under its deployment id rather than in its + litellm_params, and reaching that entry through the router's own resolution keeps an alias + pointing at such a group from being read as unpriced. + """ + for deployment in llm_router.get_model_list(model_name=model) or (): + model_id = (deployment.get("model_info") or _EMPTY_COST_ENTRY).get("id") + if model_id is None: + continue + raw_entry = litellm.model_cost.get(model_id, _EMPTY_COST_ENTRY) + if "input_cost_per_token" in raw_entry or "output_cost_per_token" in raw_entry: + return True + return False + + def model_has_no_cost_mapping(model: str | None, llm_router: Router | None) -> bool: if not model or llm_router is None: return False @@ -523,7 +541,24 @@ def model_has_no_cost_mapping(model: str | None, llm_router: Router | None) -> b if _model_group_has_pricing(model=model, llm_router=llm_router): return False - return not _is_cost_explicitly_configured(model, llm_router) + return not _group_declares_explicit_cost(model=model, llm_router=llm_router) + + +def _unpriced_models_in_request(model: str | list[str] | None, llm_router: Router | None) -> tuple[str, ...]: + candidates: Final = (model,) if isinstance(model, str) else tuple(model or ()) + return tuple( + candidate for candidate in candidates if model_has_no_cost_mapping(model=candidate, llm_router=llm_router) + ) + + +def _unpriced_models_block_message(models: tuple[str, ...]) -> str: + names: Final = ", ".join(f"'{model}'" for model in models) + subject: Final = f"Model {names} has" if len(models) == 1 else f"Models {names} have" + return ( + f"{subject} no pricing in the cost map, so litellm cannot price the request. " + "Requests for unpriced models are blocked because 'block_requests_for_models_without_pricing' " + "is enabled. Add pricing (input_cost_per_token/output_cost_per_token) to allow the request." + ) async def _run_project_checks( @@ -796,18 +831,14 @@ async def common_checks( and (route in MODEL_DISCOVERY_ROUTES or not RouteChecks.is_llm_api_route(route=route)) ) - if ( - litellm.block_requests_for_models_without_pricing - and isinstance(_model, str) - and RouteChecks.is_llm_api_route(route=route) - and model_has_no_cost_mapping(model=_model, llm_router=llm_router) - ): + unpriced_models: Final = ( + _unpriced_models_in_request(model=_model, llm_router=llm_router) + if litellm.block_requests_for_models_without_pricing and RouteChecks.is_llm_api_route(route=route) + else () + ) + if unpriced_models: raise ProxyException( - message=( - f"Model '{_model}' has no pricing in the cost map, so its spend would be tracked as $0. " - "Requests for unpriced models are blocked because 'block_requests_for_models_without_pricing' " - "is enabled. Add pricing for this model (input_cost_per_token/output_cost_per_token) to allow it." - ), + message=_unpriced_models_block_message(unpriced_models), type=ProxyErrorTypes.model_cost_map_missing, param="model", code=status.HTTP_403_FORBIDDEN, diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index b8b50cddb1e..840899220ea 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -6784,3 +6784,58 @@ async def test_common_checks_blocks_alias_resolving_to_unpriced_model(monkeypatc assert exc_info.value.code == "403" assert exc_info.value.type == ProxyErrorTypes.model_cost_map_missing assert "public-alias" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_common_checks_blocks_comma_separated_request_carrying_an_unpriced_model(monkeypatch): + monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True) + router = _router_with_priced_and_unpriced_models() + + with pytest.raises(ProxyException) as exc_info: + await _run_common_checks(model="priced-group,unpriced-group", llm_router=router) + + assert exc_info.value.code == "403" + assert exc_info.value.type == ProxyErrorTypes.model_cost_map_missing + assert "'unpriced-group'" in exc_info.value.message + assert "'priced-group'" not in exc_info.value.message + + +@pytest.mark.asyncio +async def test_common_checks_allows_comma_separated_request_when_every_model_is_priced(monkeypatch): + monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True) + router = _router_with_priced_and_unpriced_models() + + result = await _run_common_checks(model="priced-group,priced-group", llm_router=router) + + assert result is True + + +def _router_with_a_group_priced_through_model_info() -> "Router": + from litellm.router import Router + + return Router( + model_list=[ + { + "model_name": "model-info-priced-group", + "litellm_params": {"model": f"{UNPRICED_UNDERLYING_MODEL}-model-info", "api_key": "sk-test"}, + "model_info": {"input_cost_per_token": 0, "output_cost_per_token": 0}, + } + ], + model_group_alias={"model-info-priced-alias": "model-info-priced-group"}, + ) + + +def test_model_has_no_cost_mapping_group_priced_through_model_info_is_false(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + + router = _router_with_a_group_priced_through_model_info() + + assert model_has_no_cost_mapping(model="model-info-priced-group", llm_router=router) is False + + +def test_model_has_no_cost_mapping_alias_to_a_group_priced_through_model_info_is_false(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + + router = _router_with_a_group_priced_through_model_info() + + assert model_has_no_cost_mapping(model="model-info-priced-alias", llm_router=router) is False