Skip to content

Commit 02e67cd

Browse files
authored
Merge pull request #35181 from BerriAI/litellm_block_unpriced_models
feat(proxy): add admin toggle to block requests for models without pricing
2 parents 66a89f5 + c73480c commit 02e67cd

12 files changed

Lines changed: 878 additions & 3 deletions

File tree

litellm/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,7 @@ def _dev_env_hot_reload_enabled() -> bool:
453453
# backwards compatibility — arbitrary client-supplied identifiers still
454454
# pass through unchanged.
455455
validate_end_user_id_in_db: bool = False
456+
block_requests_for_models_without_pricing: bool = False
456457
disable_end_user_cost_tracking: Optional[bool] = None
457458
disable_end_user_cost_tracking_prometheus_only: Optional[bool] = None
458459
enable_end_user_cost_tracking_prometheus_only: Optional[bool] = None

litellm/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,6 +1606,7 @@
16061606
"public_model_groups_links",
16071607
"cost_discount_config",
16081608
"cost_margin_config",
1609+
"block_requests_for_models_without_pricing",
16091610
"budget_exceeded_throttle_percentage",
16101611
# Every field editable from the Admin UI (proxy_server._GENERAL_SETTINGS_UI_LITELLM_FIELDS)
16111612
# must be listed here so a DB write from one worker overrides the live litellm attribute on

litellm/proxy/_types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3746,6 +3746,8 @@ class ProxyErrorTypes(str, enum.Enum):
37463746
Project does not have access to the model
37473747
"""
37483748

3749+
model_cost_map_missing = "model_cost_map_missing"
3750+
37493751
expired_key = "expired_key"
37503752
"""
37513753
Key has expired

litellm/proxy/auth/auth_checks.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,103 @@ def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool:
456456
return False
457457

458458

459+
_EMPTY_COST_ENTRY: Final[Mapping[str, object]] = MappingProxyType({})
460+
461+
462+
def _is_positive_cost(value: object) -> bool:
463+
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
464+
465+
466+
def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool:
467+
if entry.get("tiered_pricing") is not None:
468+
return True
469+
for key, value in entry.items():
470+
if "cost_per" not in key:
471+
continue
472+
if _is_positive_cost(value):
473+
return True
474+
if isinstance(value, dict) and any(_is_positive_cost(nested) for nested in value.values()):
475+
return True
476+
return False
477+
478+
479+
def _entry_declares_price(entry: Mapping[str, object]) -> bool:
480+
return any("cost_per" in key or key == "tiered_pricing" for key in entry)
481+
482+
483+
def _model_group_has_pricing(model: str, llm_router: "Router") -> bool:
484+
"""
485+
A model group counts as priced when a deployment overrides any *cost_per* field or
486+
tiered_pricing in its litellm_params, even at zero, or when its resolved model info carries
487+
tiered_pricing or a positive price on any billed metric (tokens, characters, seconds, pages,
488+
images, queries, ...), so models billed by a non-token metric are not treated as unpriced.
489+
"""
490+
for deployment in llm_router.get_model_list(model_name=model) or ():
491+
litellm_params = deployment.get("litellm_params") or _EMPTY_COST_ENTRY
492+
if _entry_declares_price(litellm_params):
493+
return True
494+
495+
model_id = (deployment.get("model_info") or _EMPTY_COST_ENTRY).get("id")
496+
if model_id is None:
497+
continue
498+
499+
model_info = llm_router.get_deployment_model_info(
500+
model_id=model_id, model_name=litellm_params.get("model") or ""
501+
)
502+
if model_info is not None and _entry_has_priced_metric(model_info):
503+
return True
504+
505+
return False
506+
507+
508+
def _group_declares_explicit_cost(model: str, llm_router: "Router") -> bool:
509+
"""
510+
Alias-aware counterpart to ``_is_cost_explicitly_configured``, which resolves the model group
511+
the same way ``_model_group_has_pricing`` does. A deployment that prices itself through its
512+
``model_info`` block lands in the cost map under its deployment id rather than in its
513+
litellm_params, and reaching that entry through the router's own resolution keeps an alias
514+
pointing at such a group from being read as unpriced.
515+
"""
516+
for deployment in llm_router.get_model_list(model_name=model) or ():
517+
model_id = (deployment.get("model_info") or _EMPTY_COST_ENTRY).get("id")
518+
if model_id is None:
519+
continue
520+
raw_entry = litellm.model_cost.get(model_id, _EMPTY_COST_ENTRY)
521+
if "input_cost_per_token" in raw_entry or "output_cost_per_token" in raw_entry:
522+
return True
523+
return False
524+
525+
526+
def model_has_no_cost_mapping(model: str | None, llm_router: Router | None) -> bool:
527+
if not model or llm_router is None:
528+
return False
529+
530+
if llm_router.get_model_group_info(model_group=model) is None:
531+
return False
532+
533+
if _model_group_has_pricing(model=model, llm_router=llm_router):
534+
return False
535+
536+
return not _group_declares_explicit_cost(model=model, llm_router=llm_router)
537+
538+
539+
def _unpriced_models_in_request(model: str | list[str] | None, llm_router: Router | None) -> tuple[str, ...]:
540+
candidates: Final = (model,) if isinstance(model, str) else tuple(model or ())
541+
return tuple(
542+
candidate for candidate in candidates if model_has_no_cost_mapping(model=candidate, llm_router=llm_router)
543+
)
544+
545+
546+
def _unpriced_models_block_message(models: tuple[str, ...]) -> str:
547+
names: Final = ", ".join(f"'{model}'" for model in models)
548+
subject: Final = f"Model {names} has" if len(models) == 1 else f"Models {names} have"
549+
return (
550+
f"{subject} no pricing in the cost map, so litellm cannot price the request. "
551+
"Requests for unpriced models are blocked because 'block_requests_for_models_without_pricing' "
552+
"is enabled. Add pricing (input_cost_per_token/output_cost_per_token) to allow the request."
553+
)
554+
555+
459556
async def _run_project_checks(
460557
project_object: LiteLLM_ProjectTableCachedObj | None,
461558
_model: str | list[str] | None,
@@ -726,6 +823,19 @@ async def common_checks(
726823
and (route in MODEL_DISCOVERY_ROUTES or not RouteChecks.is_llm_api_route(route=route))
727824
)
728825

826+
unpriced_models: Final = (
827+
_unpriced_models_in_request(model=_model, llm_router=llm_router)
828+
if litellm.block_requests_for_models_without_pricing and RouteChecks.is_llm_api_route(route=route)
829+
else ()
830+
)
831+
if unpriced_models:
832+
raise ProxyException(
833+
message=_unpriced_models_block_message(unpriced_models),
834+
type=ProxyErrorTypes.model_cost_map_missing,
835+
param="model",
836+
code=status.HTTP_403_FORBIDDEN,
837+
)
838+
729839
# 1. If team is blocked
730840
if team_object is not None and team_object.blocked is True:
731841
raise Exception(f"Team={team_object.team_id} is blocked. Update via `/team/unblock` if you're an admin.")

litellm/proxy/management_endpoints/cost_tracking_settings.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing import Final
1616

1717
from fastapi import APIRouter, Depends, HTTPException
18+
from pydantic import BaseModel
1819

1920
import litellm
2021
from litellm._logging import verbose_proxy_logger
@@ -439,6 +440,76 @@ async def update_cost_margin_config(
439440
)
440441

441442

443+
class BlockUnpricedModelsRequest(BaseModel):
444+
enabled: bool
445+
446+
447+
class BlockUnpricedModelsResponse(BaseModel):
448+
enabled: bool
449+
450+
451+
@router.get(
452+
"/config/block_requests_for_models_without_pricing",
453+
tags=("Cost Tracking",),
454+
dependencies=(Depends(user_api_key_auth),),
455+
response_model=BlockUnpricedModelsResponse,
456+
)
457+
async def get_block_requests_for_models_without_pricing() -> BlockUnpricedModelsResponse:
458+
return BlockUnpricedModelsResponse(enabled=bool(litellm.block_requests_for_models_without_pricing))
459+
460+
461+
@router.patch(
462+
"/config/block_requests_for_models_without_pricing",
463+
tags=("Cost Tracking",),
464+
dependencies=(Depends(user_api_key_auth),),
465+
response_model=BlockUnpricedModelsResponse,
466+
)
467+
async def update_block_requests_for_models_without_pricing(
468+
request: BlockUnpricedModelsRequest,
469+
) -> BlockUnpricedModelsResponse:
470+
from litellm.proxy.proxy_server import (
471+
prisma_client,
472+
proxy_config,
473+
store_model_in_db,
474+
)
475+
476+
if prisma_client is None:
477+
raise HTTPException(
478+
status_code=500,
479+
detail={ # mutable-ok: HTTPException detail must be a plain mapping
480+
"error": CommonProxyErrors.db_not_connected_error.value
481+
},
482+
)
483+
484+
if store_model_in_db is not True:
485+
raise HTTPException(
486+
status_code=500,
487+
detail={ # mutable-ok: HTTPException detail must be a plain mapping
488+
"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."
489+
},
490+
)
491+
492+
try:
493+
config = await proxy_config.get_config()
494+
if "litellm_settings" not in config:
495+
config["litellm_settings"] = {} # mutable-ok: config is a plain-dict payload for save_config
496+
config["litellm_settings"]["block_requests_for_models_without_pricing"] = request.enabled
497+
await proxy_config.save_config(new_config=config)
498+
499+
litellm.block_requests_for_models_without_pricing = request.enabled
500+
verbose_proxy_logger.info("Updated block_requests_for_models_without_pricing: %s", request.enabled)
501+
502+
return BlockUnpricedModelsResponse(enabled=request.enabled)
503+
except Exception as e: # noqa: BLE001 # any config persistence failure must surface as a 500 response, not a crash
504+
verbose_proxy_logger.error("Error updating block_requests_for_models_without_pricing: %s", e)
505+
raise HTTPException(
506+
status_code=500,
507+
detail={ # mutable-ok: HTTPException detail must be a plain mapping
508+
"error": f"Failed to update setting: {e!s}"
509+
},
510+
)
511+
512+
442513
@router.post(
443514
"/cost/estimate",
444515
tags=["Cost Tracking"],

litellm/proxy/proxy_server.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6840,6 +6840,20 @@ async def _init_non_llm_objects_in_db(self, prisma_client: PrismaClient):
68406840
if self._should_load_db_object(object_type="config_overrides"):
68416841
await self._init_hashicorp_vault_config_override(prisma_client=prisma_client)
68426842

6843+
await self._apply_safe_litellm_settings_overrides_from_db(prisma_client=prisma_client)
6844+
6845+
async def _apply_safe_litellm_settings_overrides_from_db(self, prisma_client: PrismaClient) -> None:
6846+
config_record: Final = await get_config_param(prisma_client, "litellm_settings")
6847+
if config_record is None or config_record.param_value is None:
6848+
return
6849+
raw_settings: Final = config_record.param_value
6850+
litellm_settings: Final = json.loads(raw_settings) if isinstance(raw_settings, str) else raw_settings
6851+
if not isinstance(litellm_settings, dict):
6852+
return
6853+
for key, value in litellm_settings.items():
6854+
if key in LITELLM_SETTINGS_SAFE_DB_OVERRIDES:
6855+
setattr(litellm, key, value)
6856+
68436857
async def _init_semantic_filter_settings_in_db(self, prisma_client: PrismaClient):
68446858
"""
68456859
Initialize MCP semantic filter settings from database.

0 commit comments

Comments
 (0)