From 7e1f44f0cc15e639d8fe95fc8831c5a0b25cc76b Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 30 Jul 2026 02:38:28 +0000 Subject: [PATCH 01/12] feat(proxy): add admin toggle to block requests for models without pricing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/__init__.py | 1 + litellm/proxy/_types.py | 2 + litellm/proxy/auth/auth_checks.py | 33 +++++ .../cost_tracking_settings.py | 65 ++++++++ .../proxy/auth/test_auth_checks.py | 139 +++++++++++++++++- .../test_cost_tracking_settings.py | 56 +++++++ .../_components/cost_tracking_settings.tsx | 47 +++++- .../_components/use_block_unpriced_config.ts | 63 ++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 81 ++++++++++ 9 files changed, 482 insertions(+), 5 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts diff --git a/litellm/__init__.py b/litellm/__init__.py index 3f8c742c5a2..d14f41ad49c 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -443,6 +443,7 @@ max_end_user_budget_id: Optional[str] = None # backwards compatibility — arbitrary client-supplied identifiers still # pass through unchanged. validate_end_user_id_in_db: bool = False +block_requests_for_models_without_pricing: bool = False disable_end_user_cost_tracking: Optional[bool] = None disable_end_user_cost_tracking_prometheus_only: Optional[bool] = None enable_end_user_cost_tracking_prometheus_only: Optional[bool] = None diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c6d4ee1120a..450868edf06 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3540,6 +3540,8 @@ class ProxyErrorTypes(str, enum.Enum): Project does not have access to the model """ + model_cost_map_missing = "model_cost_map_missing" + expired_key = "expired_key" """ Key has expired diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index c46bc110ca8..11a45e78410 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -274,6 +274,22 @@ def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: return False +def model_has_no_cost_mapping(model: Optional[str], llm_router: Optional[Router]) -> bool: + if not model or llm_router is None: + return False + + model_group_info = llm_router.get_model_group_info(model_group=model) + if model_group_info is None: + return False + + input_cost = model_group_info.input_cost_per_token or 0 + output_cost = model_group_info.output_cost_per_token or 0 + if input_cost > 0 or output_cost > 0: + return False + + return not _is_cost_explicitly_configured(model, llm_router) + + async def _run_project_checks( project_object: Optional[LiteLLM_ProjectTableCachedObj], _model: Optional[Union[str, List[str]]], @@ -534,6 +550,23 @@ async def common_checks( if route in MODEL_DISCOVERY_ROUTES: skip_budget_checks = True + 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) + ): + 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." + ), + type=ProxyErrorTypes.model_cost_map_missing, + param="model", + code=status.HTTP_403_FORBIDDEN, + ) + # 1. If team is blocked if team_object is not None and team_object.blocked is True: raise Exception(f"Team={team_object.team_id} is blocked. Update via `/team/unblock` if you're an admin.") diff --git a/litellm/proxy/management_endpoints/cost_tracking_settings.py b/litellm/proxy/management_endpoints/cost_tracking_settings.py index cd2c5704778..2c18de6b903 100644 --- a/litellm/proxy/management_endpoints/cost_tracking_settings.py +++ b/litellm/proxy/management_endpoints/cost_tracking_settings.py @@ -13,6 +13,7 @@ POST /cost/estimate - Estimate cost for a given model and token counts from typing import Dict, Optional, Tuple, Union from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel import litellm from litellm._logging import verbose_proxy_logger @@ -407,6 +408,70 @@ async def update_cost_margin_config( ) +class BlockUnpricedModelsRequest(BaseModel): + enabled: bool + + +class BlockUnpricedModelsResponse(BaseModel): + enabled: bool + + +@router.get( + "/config/block_requests_for_models_without_pricing", + tags=["Cost Tracking"], + dependencies=[Depends(user_api_key_auth)], + response_model=BlockUnpricedModelsResponse, +) +async def get_block_requests_for_models_without_pricing() -> BlockUnpricedModelsResponse: + return BlockUnpricedModelsResponse(enabled=bool(litellm.block_requests_for_models_without_pricing)) + + +@router.patch( + "/config/block_requests_for_models_without_pricing", + tags=["Cost Tracking"], + dependencies=[Depends(user_api_key_auth)], + response_model=BlockUnpricedModelsResponse, +) +async def update_block_requests_for_models_without_pricing( + request: BlockUnpricedModelsRequest, +) -> BlockUnpricedModelsResponse: + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_config, + store_model_in_db, + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + if store_model_in_db is not True: + raise HTTPException( + status_code=500, + detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."}, + ) + + try: + config = await proxy_config.get_config() + if "litellm_settings" not in config: + config["litellm_settings"] = {} + config["litellm_settings"]["block_requests_for_models_without_pricing"] = request.enabled + await proxy_config.save_config(new_config=config) + + litellm.block_requests_for_models_without_pricing = request.enabled + verbose_proxy_logger.info(f"Updated block_requests_for_models_without_pricing: {request.enabled}") + + return BlockUnpricedModelsResponse(enabled=request.enabled) + except Exception as e: + verbose_proxy_logger.error(f"Error updating block_requests_for_models_without_pricing: {str(e)}") + raise HTTPException( + status_code=500, + detail={"error": f"Failed to update setting: {str(e)}"}, + ) + + @router.post( "/cost/estimate", tags=["Cost Tracking"], diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 34a353966bf..aec0ddc55f8 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -2,6 +2,7 @@ import asyncio import json import os import sys +from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch sys.path.insert( @@ -5164,4 +5165,140 @@ async def test_get_project_object_db_fetch_returns_cached_obj(): assert isinstance(result, LiteLLM_ProjectTableCachedObj) assert result.project_id == "p-1" - assert result.project_alias == "proj" + + +UNPRICED_UNDERLYING_MODEL = "openai/unpriced-model-lit4984-xyz" + + +def _router_with_priced_and_unpriced_models() -> "Router": + from litellm.router import Router + + return Router( + model_list=[ + { + "model_name": "priced-group", + "litellm_params": {"model": "gpt-3.5-turbo", "api_key": "sk-test"}, + }, + { + "model_name": "unpriced-group", + "litellm_params": {"model": UNPRICED_UNDERLYING_MODEL, "api_key": "sk-test"}, + }, + ] + ) + + +def test_model_has_no_cost_mapping_priced_model_is_false(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + + router = _router_with_priced_and_unpriced_models() + + assert model_has_no_cost_mapping(model="priced-group", llm_router=router) is False + + +def test_model_has_no_cost_mapping_unpriced_model_is_true(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + + router = _router_with_priced_and_unpriced_models() + + assert model_has_no_cost_mapping(model="unpriced-group", llm_router=router) is True + + +def test_model_has_no_cost_mapping_no_model_or_router_is_false(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + + router = _router_with_priced_and_unpriced_models() + + assert model_has_no_cost_mapping(model=None, llm_router=router) is False + assert model_has_no_cost_mapping(model="unpriced-group", llm_router=None) is False + + +async def _run_common_checks( + model: Optional[str], llm_router: Optional["Router"], route: str = "/chat/completions" +) -> bool: + from fastapi import Request + + from litellm.proxy.auth.auth_checks import common_checks + + return await common_checks( + request_body={"model": model, "messages": [{"role": "user", "content": "hi"}]}, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route=route, + llm_router=llm_router, + proxy_logging_obj=MagicMock(), + valid_token=UserAPIKeyAuth(token="test-token"), + request=MagicMock(spec=Request), + ) + + +@pytest.mark.asyncio +async def test_common_checks_blocks_unpriced_model_when_enabled(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="unpriced-group", llm_router=router) + + assert exc_info.value.code == "403" + assert exc_info.value.type == ProxyErrorTypes.model_cost_map_missing + assert exc_info.value.param == "model" + assert "unpriced-group" in exc_info.value.message + assert "pricing" in exc_info.value.message.lower() + + +@pytest.mark.asyncio +async def test_common_checks_allows_unpriced_model_when_disabled(monkeypatch): + monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", False) + router = _router_with_priced_and_unpriced_models() + + result = await _run_common_checks(model="unpriced-group", llm_router=router) + + assert result is True + + +@pytest.mark.asyncio +async def test_common_checks_allows_priced_model_when_enabled(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", llm_router=router) + + assert result is True + + +@pytest.mark.asyncio +async def test_common_checks_ignores_non_llm_route_when_enabled(monkeypatch): + monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True) + router = _router_with_priced_and_unpriced_models() + + result = await _run_common_checks( + model="unpriced-group", llm_router=router, route="/model/new" + ) + + assert result is True + + +@pytest.mark.asyncio +async def test_common_checks_blocks_alias_resolving_to_unpriced_model(monkeypatch): + from litellm.router import Router + + monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True) + router = Router( + model_list=[ + { + "model_name": "billed-underlying-group", + "litellm_params": {"model": UNPRICED_UNDERLYING_MODEL, "api_key": "sk-test"}, + } + ], + model_group_alias={"public-alias": "billed-underlying-group"}, + ) + + with pytest.raises(ProxyException) as exc_info: + await _run_common_checks(model="public-alias", llm_router=router) + + assert exc_info.value.code == "403" + assert exc_info.value.type == ProxyErrorTypes.model_cost_map_missing + assert "public-alias" in exc_info.value.message diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py index bc463d5e75d..4fb90e9fb2d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -500,3 +500,59 @@ class TestResolveModelForCostLookup: assert resolved_model == "openai/gpt-4" assert provider is None + + +class TestBlockRequestsForModelsWithoutPricing: + """Test suite for the block_requests_for_models_without_pricing toggle endpoints""" + + @pytest.mark.asyncio + async def test_get_reflects_in_memory_flag(self): + with patch.object(litellm, "block_requests_for_models_without_pricing", True): + response = client.get( + "/config/block_requests_for_models_without_pricing", + headers={"Authorization": "Bearer sk-1234"}, + ) + + assert response.status_code == 200 + assert response.json() == {"enabled": True} + + @pytest.mark.asyncio + async def test_patch_persists_and_updates_flag(self): + mock_proxy_config = AsyncMock() + mock_proxy_config.get_config = AsyncMock(return_value={"litellm_settings": {}}) + mock_proxy_config.save_config = AsyncMock() + + with ( + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config), + patch("litellm.proxy.proxy_server.store_model_in_db", True), + patch.object(litellm, "block_requests_for_models_without_pricing", False), + ): + response = client.patch( + "/config/block_requests_for_models_without_pricing", + headers={"Authorization": "Bearer sk-1234"}, + json={"enabled": True}, + ) + + assert response.status_code == 200 + assert response.json() == {"enabled": True} + assert litellm.block_requests_for_models_without_pricing is True + + saved_config = mock_proxy_config.save_config.call_args.kwargs["new_config"] + assert saved_config["litellm_settings"]["block_requests_for_models_without_pricing"] is True + + @pytest.mark.asyncio + async def test_patch_requires_store_model_in_db(self): + with ( + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.proxy_config", AsyncMock()), + patch("litellm.proxy.proxy_server.store_model_in_db", False), + ): + response = client.patch( + "/config/block_requests_for_models_without_pricing", + headers={"Authorization": "Bearer sk-1234"}, + json={"enabled": True}, + ) + + assert response.status_code == 500 + assert "error" in response.json()["detail"] diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx index b32e7afd756..f6a3d487ade 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx @@ -12,7 +12,7 @@ import { TabPanels, TabPanel, } from "@tremor/react"; -import { Modal, Form } from "antd"; +import { Modal, Form, Switch } from "antd"; import { CostTrackingSettingsProps } from "./types"; import ProviderDiscountTable from "./provider_discount_table"; import AddProviderForm from "./add_provider_form"; @@ -24,6 +24,7 @@ import { DocsMenu } from "@/components/HelpLink"; import HowItWorks from "./how_it_works"; import { useDiscountConfig } from "./use_discount_config"; import { useMarginConfig } from "./use_margin_config"; +import { useBlockUnpricedConfig } from "./use_block_unpriced_config"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; const DOCS_LINKS = [ @@ -65,9 +66,16 @@ const CostTrackingSettings: React.FC = ({ userID, use handleMarginChange, } = useMarginConfig({ accessToken }); + const { + blockUnpriced, + isUpdating: isUpdatingBlockUnpriced, + fetchBlockUnpriced, + setBlockUnpriced, + } = useBlockUnpricedConfig({ accessToken }); + useEffect(() => { if (accessToken) { - Promise.all([fetchDiscountConfig(), fetchMarginConfig()]).finally(() => { + Promise.all([fetchDiscountConfig(), fetchMarginConfig(), fetchBlockUnpriced()]).finally(() => { setIsFetching(false); }); @@ -82,7 +90,7 @@ const CostTrackingSettings: React.FC = ({ userID, use }; loadModels(); } - }, [accessToken, fetchDiscountConfig, fetchMarginConfig]); + }, [accessToken, fetchDiscountConfig, fetchMarginConfig, fetchBlockUnpriced]); const handleAddProvider = async () => { const success = await addProvider(selectedProvider, newDiscount); @@ -293,7 +301,38 @@ const CostTrackingSettings: React.FC = ({ userID, use )} - {/* Accordion 3: Pricing Calculator - Available to all roles */} + {isProxyAdmin && ( + + +
+ Block Unpriced Models + + Reject requests for models that have no pricing in the cost map instead of logging them as $0 spend + +
+
+ +
+
+
+ Block requests for models without pricing + + When enabled, a request whose resolved model has no cost mapping is rejected with a 403 so an + admin can add pricing for it. Off by default + +
+ setBlockUnpriced(checked)} + /> +
+
+
+
+ )} + + {/* Accordion 4: Pricing Calculator - Available to all roles */}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts new file mode 100644 index 00000000000..f4a110c7878 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts @@ -0,0 +1,63 @@ +import { useState, useCallback } from "react"; +import { apiClient } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +export interface UseBlockUnpricedConfigProps { + accessToken: string | null; +} + +export interface UseBlockUnpricedConfigReturn { + blockUnpriced: boolean; + isUpdating: boolean; + fetchBlockUnpriced: () => Promise; + setBlockUnpriced: (enabled: boolean) => Promise; +} + +interface BlockUnpricedResponse { + enabled: boolean; +} + +const ENDPOINT = "/config/block_requests_for_models_without_pricing"; + +export function useBlockUnpricedConfig({ accessToken }: UseBlockUnpricedConfigProps): UseBlockUnpricedConfigReturn { + const [blockUnpriced, setBlockUnpricedState] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + + const fetchBlockUnpriced = useCallback(async () => { + if (!accessToken) return; + try { + const data = await apiClient.get(ENDPOINT, { accessToken }); + setBlockUnpricedState(Boolean(data?.enabled)); + } catch (error) { + console.error("Error fetching block-unpriced-models setting:", error); + } + }, [accessToken]); + + const setBlockUnpriced = useCallback( + async (enabled: boolean) => { + if (!accessToken) return; + setIsUpdating(true); + try { + const data = await apiClient.patch(ENDPOINT, { accessToken, body: { enabled } }); + setBlockUnpricedState(Boolean(data?.enabled)); + NotificationsManager.success( + enabled + ? "Requests for models without pricing will now be blocked" + : "Requests for models without pricing are now allowed", + ); + } catch (error) { + console.error("Error updating block-unpriced-models setting:", error); + } finally { + setIsUpdating(false); + } + }, + [accessToken], + ); + + return { + blockUnpriced, + isUpdating, + fetchBlockUnpriced, + setBlockUnpriced, + }; +} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index ed975c6be0a..5abe3b4d587 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -1844,6 +1844,24 @@ export interface paths { patch?: never; trace?: never; }; + "/config/block_requests_for_models_without_pricing": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Block Requests For Models Without Pricing */ + get: operations["get_block_requests_for_models_without_pricing_config_block_requests_for_models_without_pricing_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update Block Requests For Models Without Pricing */ + patch: operations["update_block_requests_for_models_without_pricing_config_block_requests_for_models_without_pricing_patch"]; + trace?: never; + }; "/config/callback/delete": { parameters: { query?: never; @@ -21247,6 +21265,16 @@ export interface components { /** Team Id */ team_id: string; }; + /** BlockUnpricedModelsRequest */ + BlockUnpricedModelsRequest: { + /** Enabled */ + enabled: boolean; + }; + /** BlockUnpricedModelsResponse */ + BlockUnpricedModelsResponse: { + /** Enabled */ + enabled: boolean; + }; /** BlockUsers */ BlockUsers: { /** User Ids */ @@ -37097,6 +37125,59 @@ export interface operations { }; }; }; + get_block_requests_for_models_without_pricing_config_block_requests_for_models_without_pricing_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BlockUnpricedModelsResponse"]; + }; + }; + }; + }; + update_block_requests_for_models_without_pricing_config_block_requests_for_models_without_pricing_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BlockUnpricedModelsRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BlockUnpricedModelsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_callback_config_callback_delete_post: { parameters: { query?: never; From 84c41dcdc3e55c180255433538f6df8969a56297 Mon Sep 17 00:00:00 2001 From: mateo Date: Fri, 31 Jul 2026 03:02:10 +0000 Subject: [PATCH 02/12] fix(proxy): treat non-token pricing as priced and propagate the unpriced-model toggle across workers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/constants.py | 1 + litellm/proxy/auth/auth_checks.py | 58 +++++++++++++++++-- .../proxy/auth/test_auth_checks.py | 45 ++++++++++++++ .../test_cost_tracking_settings.py | 14 +++++ 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 1014b472c61..b0ff992931f 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1525,6 +1525,7 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [ "public_model_groups_links", "cost_discount_config", "cost_margin_config", + "block_requests_for_models_without_pricing", "budget_exceeded_throttle_percentage", # Every field editable from the Admin UI (proxy_server._GENERAL_SETTINGS_UI_LITELLM_FIELDS) # must be listed here so a DB write from one worker overrides the live litellm attribute on diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 11a45e78410..136b8d19c04 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -13,7 +13,18 @@ import asyncio import math import re import time -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Type, Union, cast +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Mapping, + Optional, + Type, + Union, + cast, +) from fastapi import HTTPException, Request, status from pydantic import BaseModel @@ -274,17 +285,52 @@ def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: return False +def _has_positive_cost(value: object) -> bool: + if isinstance(value, bool): + return False + if isinstance(value, (int, float)): + return value > 0 + if isinstance(value, dict): + return any(_has_positive_cost(nested) for nested in value.values()) + return False + + +def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool: + return any("cost_per" in key and _has_positive_cost(value) for key, value in entry.items()) + + +def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: + """ + Check every deployment behind a model group for a positive price on any billed + metric (tokens, characters, seconds, pages, images, queries, ...), so models that + are billed by a non-token metric are not treated as unpriced. + """ + for deployment in llm_router.get_model_list(model_name=model) or []: + litellm_params = deployment.get("litellm_params") or {} + if _entry_has_priced_metric(litellm_params): + return True + + model_id = (deployment.get("model_info") or {}).get("id") + if model_id is None: + continue + + model_info = llm_router.get_deployment_model_info( + model_id=model_id, model_name=litellm_params.get("model") or "" + ) + if model_info is not None and _entry_has_priced_metric(model_info): + return True + + return False + + def model_has_no_cost_mapping(model: Optional[str], llm_router: Optional[Router]) -> bool: if not model or llm_router is None: return False - model_group_info = llm_router.get_model_group_info(model_group=model) - if model_group_info is None: + if llm_router.get_model_group_info(model_group=model) is None: return False - input_cost = model_group_info.input_cost_per_token or 0 - output_cost = model_group_info.output_cost_per_token or 0 - if input_cost > 0 or output_cost > 0: + if _model_group_has_pricing(model=model, llm_router=llm_router): return False return not _is_cost_explicitly_configured(model, llm_router) diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index aec0ddc55f8..a078e041aa7 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -5165,6 +5165,7 @@ async def test_get_project_object_db_fetch_returns_cached_obj(): assert isinstance(result, LiteLLM_ProjectTableCachedObj) assert result.project_id == "p-1" + assert result.project_alias == "proj" UNPRICED_UNDERLYING_MODEL = "openai/unpriced-model-lit4984-xyz" @@ -5212,6 +5213,50 @@ def test_model_has_no_cost_mapping_no_model_or_router_is_false(): assert model_has_no_cost_mapping(model="unpriced-group", llm_router=None) is False +@pytest.mark.parametrize( + "underlying_model", + [ + "azure/speech/azure-tts", + "mistral/mistral-ocr-latest", + "vertex_ai/imagen-3.0-generate-001", + ], +) +def test_model_has_no_cost_mapping_non_token_priced_model_is_false(underlying_model): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "non-token-priced-group", + "litellm_params": {"model": underlying_model, "api_key": "sk-test"}, + } + ] + ) + + assert model_has_no_cost_mapping(model="non-token-priced-group", llm_router=router) is False + + +def test_model_has_no_cost_mapping_non_token_price_from_litellm_params_is_false(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "custom-tts", + "litellm_params": { + "model": UNPRICED_UNDERLYING_MODEL, + "api_key": "sk-test", + "input_cost_per_second": 0.0001, + }, + } + ] + ) + + assert model_has_no_cost_mapping(model="custom-tts", llm_router=router) is False + + async def _run_common_checks( model: Optional[str], llm_router: Optional["Router"], route: str = "/chat/completions" ) -> bool: diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py index 4fb90e9fb2d..1124a4a31d4 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -541,6 +541,20 @@ class TestBlockRequestsForModelsWithoutPricing: saved_config = mock_proxy_config.save_config.call_args.kwargs["new_config"] assert saved_config["litellm_settings"]["block_requests_for_models_without_pricing"] is True + def test_peer_workers_pick_up_persisted_flag_on_config_reload(self): + """A PATCH only mutates the flag on the worker that served it; peer workers must pick the + persisted value up when they reload litellm_settings from the DB.""" + from litellm.proxy.proxy_server import ProxyConfig + + with patch.object(litellm, "block_requests_for_models_without_pricing", False): + ProxyConfig()._update_config_fields( + current_config={}, + param_name="litellm_settings", + db_param_value={"block_requests_for_models_without_pricing": True}, + ) + + assert litellm.block_requests_for_models_without_pricing is True + @pytest.mark.asyncio async def test_patch_requires_store_model_in_db(self): with ( From 074b37b4f987f239725a165565be16ff5e1f9686 Mon Sep 17 00:00:00 2001 From: mateo Date: Fri, 31 Jul 2026 03:08:29 +0000 Subject: [PATCH 03/12] refactor(proxy): flatten the pricing-metric check to avoid recursion Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/auth_checks.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 136b8d19c04..3639ef245cf 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -285,18 +285,19 @@ def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: return False -def _has_positive_cost(value: object) -> bool: - if isinstance(value, bool): - return False - if isinstance(value, (int, float)): - return value > 0 - if isinstance(value, dict): - return any(_has_positive_cost(nested) for nested in value.values()) - return False +def _is_positive_cost(value: object) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool: - return any("cost_per" in key and _has_positive_cost(value) for key, value in entry.items()) + for key, value in entry.items(): + if "cost_per" not in key: + continue + if _is_positive_cost(value): + return True + if isinstance(value, dict) and any(_is_positive_cost(nested) for nested in value.values()): + return True + return False def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: From c551a5c44abacea6d777cdd7bedde075a9dd75c9 Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 20 Aug 2026 20:59:38 +0000 Subject: [PATCH 04/12] fix(proxy): treat explicit zero non-token prices as priced A deployment that overrides any cost_per field, including at zero, now counts as priced so it is not blocked as unpriced Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/auth_checks.py | 13 +++++++---- .../proxy/auth/test_auth_checks.py | 23 ++++++++++++++++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 3639ef245cf..5ad61f93d4f 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -300,15 +300,20 @@ def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool: return False +def _entry_declares_price(entry: Mapping[str, object]) -> bool: + return any("cost_per" in key for key in entry) + + def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: """ - Check every deployment behind a model group for a positive price on any billed - metric (tokens, characters, seconds, pages, images, queries, ...), so models that - are billed by a non-token metric are not treated as unpriced. + A model group counts as priced when a deployment overrides any *cost_per* field in its + litellm_params, even at zero, or when its resolved model info carries a positive price on + any billed metric (tokens, characters, seconds, pages, images, queries, ...), so models + billed by a non-token metric are not treated as unpriced. """ for deployment in llm_router.get_model_list(model_name=model) or []: litellm_params = deployment.get("litellm_params") or {} - if _entry_has_priced_metric(litellm_params): + if _entry_declares_price(litellm_params): return True model_id = (deployment.get("model_info") or {}).get("id") diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index a078e041aa7..fbdd9a42750 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -5246,7 +5246,7 @@ def test_model_has_no_cost_mapping_non_token_price_from_litellm_params_is_false( { "model_name": "custom-tts", "litellm_params": { - "model": UNPRICED_UNDERLYING_MODEL, + "model": f"{UNPRICED_UNDERLYING_MODEL}-per-second", "api_key": "sk-test", "input_cost_per_second": 0.0001, }, @@ -5257,6 +5257,27 @@ def test_model_has_no_cost_mapping_non_token_price_from_litellm_params_is_false( assert model_has_no_cost_mapping(model="custom-tts", llm_router=router) is False +@pytest.mark.parametrize("cost_field", ["input_cost_per_second", "input_cost_per_token"]) +def test_model_has_no_cost_mapping_explicit_zero_price_is_false(cost_field): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "free-group", + "litellm_params": { + "model": f"{UNPRICED_UNDERLYING_MODEL}-{cost_field}", + "api_key": "sk-test", + cost_field: 0, + }, + } + ] + ) + + assert model_has_no_cost_mapping(model="free-group", llm_router=router) is False + + async def _run_common_checks( model: Optional[str], llm_router: Optional["Router"], route: str = "/chat/completions" ) -> bool: From 21891b44837b17427f4a54067aad9f4f756ea6b0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:32:54 -0700 Subject: [PATCH 05/12] fix(proxy): count explicit zero prices on any billed metric as configured pricing --- litellm/proxy/auth/auth_checks.py | 24 +++++++++++++---- .../cost_tracking_settings.py | 26 ++++++++++++------- .../proxy/auth/test_auth_checks.py | 21 +++++++++++++++ 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 5de3f9624aa..6f41fde4b88 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -445,7 +445,9 @@ def _has_ptu_flat_cost(model: str, llm_router: "Router") -> bool: def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: """ Check if any deployment in the model group has cost fields explicitly - set in its litellm.model_cost entry. + set in its litellm_params or its litellm.model_cost entry, on any billed + metric. An explicit zero counts: pricing a model at 0 is a deliberate + admin choice, distinct from a model missing from the cost map. When Router._create_deployment() registers a model not in the global cost map, it creates a sparse entry like {"id": ""} with no cost @@ -455,6 +457,8 @@ def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: for deployment in llm_router.model_list: if deployment.get("model_name") != model: continue + if _entry_has_explicit_cost_key(deployment.get("litellm_params") or _EMPTY_COST_ENTRY): + return True model_id = deployment.get("model_info", {}).get("id") if model_id is None: continue @@ -464,10 +468,20 @@ def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: return False +_EMPTY_COST_ENTRY: Final[Mapping[str, object]] = MappingProxyType({}) + + def _is_positive_cost(value: object) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 +def _entry_has_explicit_cost_key(entry: Mapping[str, object]) -> bool: + return any( + "cost_per" in key and isinstance(value, (int, float)) and not isinstance(value, bool) + for key, value in entry.items() + ) + + def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool: for key, value in entry.items(): if "cost_per" not in key: @@ -485,12 +499,12 @@ def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: metric (tokens, characters, seconds, pages, images, queries, ...), so models that are billed by a non-token metric are not treated as unpriced. """ - for deployment in llm_router.get_model_list(model_name=model) or []: - litellm_params = deployment.get("litellm_params") or {} + for deployment in llm_router.get_model_list(model_name=model) or (): + litellm_params = deployment.get("litellm_params") or _EMPTY_COST_ENTRY if _entry_has_priced_metric(litellm_params): return True - model_id = (deployment.get("model_info") or {}).get("id") + model_id = (deployment.get("model_info") or _EMPTY_COST_ENTRY).get("id") if model_id is None: continue @@ -503,7 +517,7 @@ def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: return False -def model_has_no_cost_mapping(model: Optional[str], llm_router: Optional[Router]) -> bool: +def model_has_no_cost_mapping(model: str | None, llm_router: Router | None) -> bool: if not model or llm_router is None: return False diff --git a/litellm/proxy/management_endpoints/cost_tracking_settings.py b/litellm/proxy/management_endpoints/cost_tracking_settings.py index 444ffa434b3..842a6c54f33 100644 --- a/litellm/proxy/management_endpoints/cost_tracking_settings.py +++ b/litellm/proxy/management_endpoints/cost_tracking_settings.py @@ -450,8 +450,8 @@ class BlockUnpricedModelsResponse(BaseModel): @router.get( "/config/block_requests_for_models_without_pricing", - tags=["Cost Tracking"], - dependencies=[Depends(user_api_key_auth)], + tags=("Cost Tracking",), + dependencies=(Depends(user_api_key_auth),), response_model=BlockUnpricedModelsResponse, ) async def get_block_requests_for_models_without_pricing() -> BlockUnpricedModelsResponse: @@ -460,8 +460,8 @@ async def get_block_requests_for_models_without_pricing() -> BlockUnpricedModels @router.patch( "/config/block_requests_for_models_without_pricing", - tags=["Cost Tracking"], - dependencies=[Depends(user_api_key_auth)], + tags=("Cost Tracking",), + dependencies=(Depends(user_api_key_auth),), response_model=BlockUnpricedModelsResponse, ) async def update_block_requests_for_models_without_pricing( @@ -476,19 +476,23 @@ async def update_block_requests_for_models_without_pricing( if prisma_client is None: raise HTTPException( status_code=500, - detail={"error": CommonProxyErrors.db_not_connected_error.value}, + detail={ # mutable-ok: HTTPException detail must be a plain mapping + "error": CommonProxyErrors.db_not_connected_error.value + }, ) if store_model_in_db is not True: raise HTTPException( status_code=500, - detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."}, + detail={ # mutable-ok: HTTPException detail must be a plain mapping + "error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature." + }, ) try: config = await proxy_config.get_config() if "litellm_settings" not in config: - config["litellm_settings"] = {} + config["litellm_settings"] = {} # mutable-ok: config is a plain-dict payload for save_config config["litellm_settings"]["block_requests_for_models_without_pricing"] = request.enabled await proxy_config.save_config(new_config=config) @@ -496,11 +500,13 @@ async def update_block_requests_for_models_without_pricing( verbose_proxy_logger.info(f"Updated block_requests_for_models_without_pricing: {request.enabled}") return BlockUnpricedModelsResponse(enabled=request.enabled) - except Exception as e: - verbose_proxy_logger.error(f"Error updating block_requests_for_models_without_pricing: {str(e)}") + except Exception as e: # noqa: BLE001 # any config persistence failure must surface as a 500 response, not a crash + verbose_proxy_logger.error(f"Error updating block_requests_for_models_without_pricing: {e!s}") raise HTTPException( status_code=500, - detail={"error": f"Failed to update setting: {str(e)}"}, + detail={ # mutable-ok: HTTPException detail must be a plain mapping + "error": f"Failed to update setting: {e!s}" + }, ) diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index ce633699748..11a982472a8 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -6646,6 +6646,27 @@ def test_model_has_no_cost_mapping_non_token_price_from_litellm_params_is_false( assert model_has_no_cost_mapping(model="custom-tts", llm_router=router) is False +@pytest.mark.parametrize("zero_cost_key", ["input_cost_per_second", "input_cost_per_token"]) +def test_model_has_no_cost_mapping_explicit_zero_price_is_false(zero_cost_key): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "free-group", + "litellm_params": { + "model": UNPRICED_UNDERLYING_MODEL, + "api_key": "sk-test", + zero_cost_key: 0.0, + }, + } + ] + ) + + assert model_has_no_cost_mapping(model="free-group", llm_router=router) is False + + async def _run_common_checks( model: Optional[str], llm_router: Optional["Router"], route: str = "/chat/completions" ) -> bool: From ab79b8dcb6a027dbb93b33b424e1b6b3a5814c4d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:52:37 -0700 Subject: [PATCH 06/12] fix: count tiered_pricing as a cost mapping when blocking unpriced models --- litellm/proxy/auth/auth_checks.py | 12 ++++---- .../proxy/auth/test_auth_checks.py | 28 ++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index ede1b1a0a03..0bf419ca1de 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -472,6 +472,8 @@ def _is_positive_cost(value: object) -> bool: def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool: + if entry.get("tiered_pricing") is not None: + return True for key, value in entry.items(): if "cost_per" not in key: continue @@ -483,15 +485,15 @@ def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool: def _entry_declares_price(entry: Mapping[str, object]) -> bool: - return any("cost_per" in key for key in entry) + return any("cost_per" in key or key == "tiered_pricing" for key in entry) def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: """ - A model group counts as priced when a deployment overrides any *cost_per* field in its - litellm_params, even at zero, or when its resolved model info carries a positive price on - any billed metric (tokens, characters, seconds, pages, images, queries, ...), so models - billed by a non-token metric are not treated as unpriced. + A model group counts as priced when a deployment overrides any *cost_per* field or + tiered_pricing in its litellm_params, even at zero, or when its resolved model info carries + tiered_pricing or a positive price on any billed metric (tokens, characters, seconds, pages, + images, queries, ...), so models billed by a non-token metric are not treated as unpriced. """ for deployment in llm_router.get_model_list(model_name=model) or (): litellm_params = deployment.get("litellm_params") or _EMPTY_COST_ENTRY diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index a1b8f2efaae..12ab090fe47 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -3,9 +3,12 @@ import json import os import sys from types import SimpleNamespace -from typing import Optional +from typing import TYPE_CHECKING, Optional from unittest.mock import AsyncMock, MagicMock, patch +if TYPE_CHECKING: + from litellm.router import Router + sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system path @@ -6667,6 +6670,29 @@ def test_model_has_no_cost_mapping_explicit_zero_price_is_false(cost_field): assert model_has_no_cost_mapping(model="free-group", llm_router=router) is False +def test_model_has_no_cost_mapping_tiered_pricing_only_is_false(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "tiered-group", + "litellm_params": { + "model": f"{UNPRICED_UNDERLYING_MODEL}-tiered", + "api_key": "sk-test", + "tiered_pricing": [ + {"range": [0, 128000], "input_cost_per_token": 2e-7, "output_cost_per_token": 6e-7}, + {"range": [128000, 256000], "input_cost_per_token": 4e-7, "output_cost_per_token": 12e-7}, + ], + }, + } + ] + ) + + assert model_has_no_cost_mapping(model="tiered-group", llm_router=router) is False + + async def _run_common_checks( model: Optional[str], llm_router: Optional["Router"], route: str = "/chat/completions" ) -> bool: From eb8d4021873382a64f911abb7ce560780068607d Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 20 Aug 2026 21:55:43 +0000 Subject: [PATCH 07/12] test(proxy): cover a registry model priced only via tiered_pricing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/proxy/auth/test_auth_checks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 12ab090fe47..b8b50cddb1e 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -6611,6 +6611,7 @@ def test_model_has_no_cost_mapping_no_model_or_router_is_false(): "azure/speech/azure-tts", "mistral/mistral-ocr-latest", "vertex_ai/imagen-3.0-generate-001", + "dashscope/qwen-flash", ], ) def test_model_has_no_cost_mapping_non_token_priced_model_is_false(underlying_model): From 2b2d6d7aad8b8fc4b62a685171a1a38cfbf810ed Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:35:20 -0700 Subject: [PATCH 08/12] fix(proxy): apply DB-persisted safe litellm settings on every worker's config reload Peer workers previously kept their startup value for block_requests_for_models_without_pricing until a restart, so a toggle from the UI only took effect on the worker that served the request. --- litellm/proxy/proxy_server.py | 13 ++++++++++ .../test_cost_tracking_settings.py | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 12d0f13ebdd..36033f0ff30 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -6823,6 +6823,19 @@ class ProxyConfig: if self._should_load_db_object(object_type="config_overrides"): await self._init_hashicorp_vault_config_override(prisma_client=prisma_client) + await self._apply_safe_litellm_settings_overrides_from_db(prisma_client=prisma_client) + + async def _apply_safe_litellm_settings_overrides_from_db(self, prisma_client: PrismaClient) -> None: + config_record: Final = await get_config_param(prisma_client, "litellm_settings") + if config_record is None or config_record.param_value is None: + return + raw_settings: Final = config_record.param_value + litellm_settings: Final = json.loads(raw_settings) if isinstance(raw_settings, str) else raw_settings + if not isinstance(litellm_settings, dict): + return + for key, value in litellm_settings.items(): + if key in LITELLM_SETTINGS_SAFE_DB_OVERRIDES: + setattr(litellm, key, value) async def _init_semantic_filter_settings_in_db(self, prisma_client: PrismaClient): """ diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py index ff80bbe4938..8dfc83760b7 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -740,6 +740,32 @@ class TestBlockRequestsForModelsWithoutPricing: assert litellm.block_requests_for_models_without_pricing is True + @pytest.mark.asyncio + async def test_periodic_db_sync_applies_flag_to_peer_worker(self): + """The ~10s reconcile loop runs _init_non_llm_objects_in_db on every worker; it must apply + the persisted flag so peers converge without a restart.""" + from types import SimpleNamespace + + from litellm.proxy.proxy_server import ProxyConfig + + config_record = SimpleNamespace( + param_value={"block_requests_for_models_without_pricing": True, "unsafe_key": "x"} + ) + with ( + patch.object(litellm, "block_requests_for_models_without_pricing", False), + patch.object( + ProxyConfig, + "_should_load_db_object", + side_effect=lambda object_type: object_type == "config_overrides", + ), + patch.object(ProxyConfig, "_init_hashicorp_vault_config_override", AsyncMock()), + patch("litellm.proxy.proxy_server.get_config_param", AsyncMock(return_value=config_record)), + ): + await ProxyConfig()._init_non_llm_objects_in_db(prisma_client=MagicMock()) + + assert litellm.block_requests_for_models_without_pricing is True + assert not hasattr(litellm, "unsafe_key") + @pytest.mark.asyncio async def test_patch_requires_store_model_in_db(self): with ( From 3672fa9fb5a212809edb0660009a5e1f8180c840 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:38:27 -0700 Subject: [PATCH 09/12] fix(proxy): log block_requests_for_models_without_pricing updates lazily The eager f-strings tripped tests/test_litellm/test_logging.py::test_logging_calls_do_not_build_their_message_eagerly. --- litellm/proxy/management_endpoints/cost_tracking_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_endpoints/cost_tracking_settings.py b/litellm/proxy/management_endpoints/cost_tracking_settings.py index 842a6c54f33..204051c3715 100644 --- a/litellm/proxy/management_endpoints/cost_tracking_settings.py +++ b/litellm/proxy/management_endpoints/cost_tracking_settings.py @@ -497,11 +497,11 @@ async def update_block_requests_for_models_without_pricing( await proxy_config.save_config(new_config=config) litellm.block_requests_for_models_without_pricing = request.enabled - verbose_proxy_logger.info(f"Updated block_requests_for_models_without_pricing: {request.enabled}") + verbose_proxy_logger.info("Updated block_requests_for_models_without_pricing: %s", request.enabled) return BlockUnpricedModelsResponse(enabled=request.enabled) except Exception as e: # noqa: BLE001 # any config persistence failure must surface as a 500 response, not a crash - verbose_proxy_logger.error(f"Error updating block_requests_for_models_without_pricing: {e!s}") + verbose_proxy_logger.error("Error updating block_requests_for_models_without_pricing: %s", e) raise HTTPException( status_code=500, detail={ # mutable-ok: HTTPException detail must be a plain mapping From 3c44f8d9269600b913256e2372a456888abe6f4e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:47:13 -0700 Subject: [PATCH 10/12] fix(ui): surface toggle failures on the block-unpriced-models setting The hook swallowed errors into the console, so an admin flipping the switch without STORE_MODEL_IN_DB saw nothing happen and got no reason why. Adds the missing hook tests. --- .../use_block_unpriced_config.test.ts | 108 ++++++++++++++++++ .../_components/use_block_unpriced_config.ts | 2 + 2 files changed, 110 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.test.ts new file mode 100644 index 00000000000..1daf7583b4b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useBlockUnpricedConfig } from "./use_block_unpriced_config"; +import { apiClient } from "@/components/networking"; +import { toast } from "@/lib/toast"; + +vi.mock("@/components/networking", () => ({ + apiClient: { + get: vi.fn(), + patch: vi.fn(), + }, +})); + +const ENDPOINT = "/config/block_requests_for_models_without_pricing"; + +describe("useBlockUnpricedConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("fetchBlockUnpriced", () => { + it("reflects the enabled flag returned by the proxy", async () => { + vi.mocked(apiClient.get).mockResolvedValueOnce({ enabled: true }); + + const { result } = renderHook(() => useBlockUnpricedConfig({ accessToken: "test-token" })); + + await act(async () => { + await result.current.fetchBlockUnpriced(); + }); + + expect(apiClient.get).toHaveBeenCalledWith(ENDPOINT, { accessToken: "test-token" }); + expect(result.current.blockUnpriced).toBe(true); + }); + + it("surfaces a toast when the fetch throws", async () => { + const error = new Error("Network error"); + vi.mocked(apiClient.get).mockRejectedValueOnce(error); + + const { result } = renderHook(() => useBlockUnpricedConfig({ accessToken: "test-token" })); + + await act(async () => { + await result.current.fetchBlockUnpriced(); + }); + + expect(toast.fromError).toHaveBeenCalledWith(error); + expect(result.current.blockUnpriced).toBe(false); + }); + + it("does nothing without an access token", async () => { + const { result } = renderHook(() => useBlockUnpricedConfig({ accessToken: null })); + + await act(async () => { + await result.current.fetchBlockUnpriced(); + }); + + expect(apiClient.get).not.toHaveBeenCalled(); + }); + }); + + describe("setBlockUnpriced", () => { + it("persists the new value and confirms it with a toast", async () => { + vi.mocked(apiClient.patch).mockResolvedValueOnce({ enabled: true }); + + const { result } = renderHook(() => useBlockUnpricedConfig({ accessToken: "test-token" })); + + await act(async () => { + await result.current.setBlockUnpriced(true); + }); + + expect(apiClient.patch).toHaveBeenCalledWith(ENDPOINT, { + accessToken: "test-token", + body: { enabled: true }, + }); + expect(result.current.blockUnpriced).toBe(true); + expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/will now be blocked/i)); + expect(result.current.isUpdating).toBe(false); + }); + + it("confirms turning the block back off", async () => { + vi.mocked(apiClient.patch).mockResolvedValueOnce({ enabled: false }); + + const { result } = renderHook(() => useBlockUnpricedConfig({ accessToken: "test-token" })); + + await act(async () => { + await result.current.setBlockUnpriced(false); + }); + + expect(result.current.blockUnpriced).toBe(false); + expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/now allowed/i)); + }); + + it("surfaces the proxy error and leaves the flag unchanged when the update fails", async () => { + const error = new Error("Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."); + vi.mocked(apiClient.patch).mockRejectedValueOnce(error); + + const { result } = renderHook(() => useBlockUnpricedConfig({ accessToken: "test-token" })); + + await act(async () => { + await result.current.setBlockUnpriced(true); + }); + + expect(toast.fromError).toHaveBeenCalledWith(error); + expect(toast.success).not.toHaveBeenCalled(); + expect(result.current.blockUnpriced).toBe(false); + expect(result.current.isUpdating).toBe(false); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts index 5383f7350ad..4bf9d5ddacc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts @@ -30,6 +30,7 @@ export function useBlockUnpricedConfig({ accessToken }: UseBlockUnpricedConfigPr setBlockUnpricedState(Boolean(data?.enabled)); } catch (error) { console.error("Error fetching block-unpriced-models setting:", error); + toast.fromError(error); } }, [accessToken]); @@ -47,6 +48,7 @@ export function useBlockUnpricedConfig({ accessToken }: UseBlockUnpricedConfigPr ); } catch (error) { console.error("Error updating block-unpriced-models setting:", error); + toast.fromError(error); } finally { setIsUpdating(false); } From df00c334d156d0aee8dbb381eac1a8caa12fe7ab Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 20 Aug 2026 22:53:34 +0000 Subject: [PATCH 11/12] fix(proxy): reload the unpriced-model toggle regardless of supported_db_objects Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/proxy_server.py | 3 ++- .../management_endpoints/test_cost_tracking_settings.py | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 36033f0ff30..db3bb52984a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -6823,7 +6823,8 @@ class ProxyConfig: if self._should_load_db_object(object_type="config_overrides"): await self._init_hashicorp_vault_config_override(prisma_client=prisma_client) - await self._apply_safe_litellm_settings_overrides_from_db(prisma_client=prisma_client) + + await self._apply_safe_litellm_settings_overrides_from_db(prisma_client=prisma_client) async def _apply_safe_litellm_settings_overrides_from_db(self, prisma_client: PrismaClient) -> None: config_record: Final = await get_config_param(prisma_client, "litellm_settings") diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py index 8dfc83760b7..ea86731eba4 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -741,9 +741,11 @@ class TestBlockRequestsForModelsWithoutPricing: assert litellm.block_requests_for_models_without_pricing is True @pytest.mark.asyncio - async def test_periodic_db_sync_applies_flag_to_peer_worker(self): + @pytest.mark.parametrize("loads_config_overrides", [True, False]) + async def test_periodic_db_sync_applies_flag_to_peer_worker(self, loads_config_overrides): """The ~10s reconcile loop runs _init_non_llm_objects_in_db on every worker; it must apply - the persisted flag so peers converge without a restart.""" + the persisted flag so peers converge without a restart, including when supported_db_objects + leaves config_overrides out.""" from types import SimpleNamespace from litellm.proxy.proxy_server import ProxyConfig @@ -756,7 +758,7 @@ class TestBlockRequestsForModelsWithoutPricing: patch.object( ProxyConfig, "_should_load_db_object", - side_effect=lambda object_type: object_type == "config_overrides", + side_effect=lambda object_type: loads_config_overrides and object_type == "config_overrides", ), patch.object(ProxyConfig, "_init_hashicorp_vault_config_override", AsyncMock()), patch("litellm.proxy.proxy_server.get_config_param", AsyncMock(return_value=config_record)), From c73480c65301986225aac553d638cd546f2dbfa1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:37:51 -0700 Subject: [PATCH 12/12] 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 --- litellm/proxy/auth/auth_checks.py | 55 +++++++++++++++---- .../proxy/auth/test_auth_checks.py | 55 +++++++++++++++++++ 2 files changed, 98 insertions(+), 12 deletions(-) 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