From 168f6148dd4bdbae7cf2161822423f27e613da6c Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Thu, 17 Sep 2026 20:15:00 +0200 Subject: [PATCH 1/7] feat(tags): add spend field to TagUpdateRequest and TagConfig --- .../tag_management_endpoints.py | 1 + litellm/types/tag_management.py | 3 ++ tests/unit/types/test_tag_management.py | 32 +++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 tests/unit/types/test_tag_management.py diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index 5bf16379d05..25169c66b55 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -64,6 +64,7 @@ class _TagRecord(Protocol): description: str | None models: Sequence[str] model_info: object + spend: float budget_id: str | None created_at: datetime updated_at: datetime diff --git a/litellm/types/tag_management.py b/litellm/types/tag_management.py index f121f5bc562..0d2b1265391 100644 --- a/litellm/types/tag_management.py +++ b/litellm/types/tag_management.py @@ -12,6 +12,7 @@ class TagConfig(TagBase): created_at: str updated_at: str created_by: str | None = None + spend: float | None = None class TagNewRequest(TagBase): @@ -36,6 +37,8 @@ class TagUpdateRequest(TagBase): rpm_limit: int | None = None model_max_budget: dict | None = None budget_duration: str | None = None + # Set to reset the tag's accumulated spend (e.g. 0 to clear it). None leaves it unchanged. + spend: float | None = None class TagDeleteRequest(BaseModel): diff --git a/tests/unit/types/test_tag_management.py b/tests/unit/types/test_tag_management.py new file mode 100644 index 00000000000..a496191346c --- /dev/null +++ b/tests/unit/types/test_tag_management.py @@ -0,0 +1,32 @@ +from litellm.types.tag_management import TagConfig, TagUpdateRequest + + +def test_tag_update_request_accepts_spend(): + request = TagUpdateRequest(name="batch-jobs", spend=0.0) + assert request.spend == 0.0 + assert "spend" in request.model_fields_set + + +def test_tag_update_request_spend_defaults_to_none(): + request = TagUpdateRequest(name="batch-jobs") + assert request.spend is None + assert "spend" not in request.model_fields_set + + +def test_tag_config_accepts_spend(): + config = TagConfig( + name="batch-jobs", + created_at="2026-01-01T00:00:00", + updated_at="2026-01-01T00:00:00", + spend=42.5, + ) + assert config.spend == 42.5 + + +def test_tag_config_spend_defaults_to_none(): + config = TagConfig( + name="batch-jobs", + created_at="2026-01-01T00:00:00", + updated_at="2026-01-01T00:00:00", + ) + assert config.spend is None From ecae3aa8ba0c1ba8569b659bf9619d1169ec3e49 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Thu, 17 Sep 2026 20:20:27 +0200 Subject: [PATCH 2/7] feat(tags): reset spend and refresh the spend counter on /tag/update --- .../tag_management_endpoints.py | 30 ++++ .../test_tag_management_endpoints.py | 164 ++++++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index 25169c66b55..ad994a8a0cd 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -340,6 +340,7 @@ async def new_tag( created_at=new_tag_record.created_at.isoformat(), updated_at=new_tag_record.updated_at.isoformat(), created_by=new_tag_record.created_by, + spend=new_tag_record.spend, ) return { @@ -419,11 +420,15 @@ async def update_tag( - model_max_budget: Optional[dict] - Max budget for a specific model - budget_duration: Optional[str] - Frequency of resetting tag budget """ + from litellm.proxy.management_endpoints.common_utils import validate_finite_spend from litellm.proxy.proxy_server import prisma_client if prisma_client is None: raise HTTPException(status_code=500, detail="Database not connected") + # Reject NaN/±inf spend before it can reach the DB / spend counter. + validate_finite_spend(tag.spend) + try: # Check if tag exists existing_tag: Final = await _table(TagRepository(prisma_client)).find_unique(where={"tag_name": tag.name}) @@ -456,6 +461,11 @@ async def update_tag( if budget_id != existing_tag.budget_id: update_data["budget_id"] = budget_id + # Reset spend if explicitly provided (None means "leave unchanged"), mirroring + # /key/update's spend field. + if tag.spend is not None: + update_data["spend"] = tag.spend + # Update tag in database updated_tag_record: Final = await _table(TagRepository(prisma_client)).update( where={"tag_name": tag.name}, @@ -464,6 +474,25 @@ async def update_tag( await _evict_tag_cache_keys((tag_cache_key(tag.name),)) + if tag.spend is not None: + # Refresh the live spend counter immediately, the same way /key/update does for + # spend:key: - otherwise the tag stays blocked on the stale cached value + # until the counter's TTL expires. + from litellm.proxy.proxy_server import spend_counter_cache + + counter_key: Final = f"spend:tag:{tag.name}" + spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=tag.spend, ttl=60) + if spend_counter_cache.redis_cache is not None: + try: + await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=tag.spend, ttl=60) + except Exception as redis_err: + verbose_proxy_logger.warning( + "Failed to update spend counter %s in Redis after tag spend update: %s. " + "Budget checks may use stale value until counter expires.", + counter_key, + redis_err, + ) + # Build response tag_config: Final = TagConfig( name=updated_tag_record.tag_name, @@ -473,6 +502,7 @@ async def update_tag( created_at=updated_tag_record.created_at.isoformat(), updated_at=updated_tag_record.updated_at.isoformat(), created_by=updated_tag_record.created_by, + spend=updated_tag_record.spend, ) return { diff --git a/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py index 3cfdd345a45..71340b017a7 100644 --- a/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py @@ -242,6 +242,7 @@ async def test_new_tag_persists_a_budget(): tag_name="budget-tag", description=None, models=[], + spend=0.0, created_at=datetime(2024, 1, 1), updated_at=datetime(2024, 1, 1), created_by="admin", @@ -311,6 +312,7 @@ async def test_update_tag_explicit_null_preserves_general_budget_fields(field): tag_name="budget-tag", description=None, models=[], + spend=0.0, created_at=datetime(2024, 1, 1), updated_at=datetime(2024, 1, 1), created_by="admin", @@ -365,6 +367,7 @@ async def test_update_tag_explicit_null_clears_budget_duration(): tag_name="budget-tag", description=None, models=[], + spend=0.0, created_at=datetime(2024, 1, 1), updated_at=datetime(2024, 1, 1), created_by="admin", @@ -600,6 +603,167 @@ async def test_update_tag_invalidates_only_the_tag_cache(): app.dependency_overrides.clear() +@pytest.mark.asyncio +async def test_update_tag_resets_spend(): + """POST /tag/update with spend writes it to the DB and returns it in the response.""" + from datetime import datetime + from unittest.mock import AsyncMock, Mock + + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + try: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch( + "litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id" + ), + patch("litellm.proxy.proxy_server.spend_counter_cache") as mock_spend_counter_cache, + ): + mock_spend_counter_cache.redis_cache = None + + mock_db = Mock() + mock_prisma.db = mock_db + + existing_tag = Mock() + existing_tag.tag_name = "batch-jobs" + existing_tag.description = "nightly batch jobs" + existing_tag.models = [] + existing_tag.budget_id = None + mock_db.litellm_tagtable.find_unique = AsyncMock(return_value=existing_tag) + mock_db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) + + updated_tag = Mock() + updated_tag.tag_name = "batch-jobs" + updated_tag.description = "nightly batch jobs" + updated_tag.models = [] + updated_tag.model_info = {} + updated_tag.spend = 0.0 + updated_tag.budget_id = None + updated_tag.created_at = datetime.now() + updated_tag.updated_at = datetime.now() + updated_tag.created_by = "test-user-123" + mock_db.litellm_tagtable.update = AsyncMock(return_value=updated_tag) + + response = client.post( + "/tag/update", + json={"name": "batch-jobs", "spend": 0}, + headers={"Authorization": "Bearer sk-1234"}, + ) + assert response.status_code == 200 + result = response.json() + assert result["tag"]["spend"] == 0.0 + + update_call_kwargs = mock_db.litellm_tagtable.update.call_args.kwargs + assert update_call_kwargs["data"]["spend"] == 0.0 + + mock_spend_counter_cache.in_memory_cache.set_cache.assert_called_once_with( + key="spend:tag:batch-jobs", value=0.0, ttl=60 + ) + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_update_tag_without_spend_does_not_touch_counter_cache(): + """A description-only update must not write to LiteLLM_TagTable.spend or the spend counter.""" + from datetime import datetime + from unittest.mock import AsyncMock, Mock + + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + try: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch( + "litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id" + ), + patch("litellm.proxy.proxy_server.spend_counter_cache") as mock_spend_counter_cache, + ): + mock_db = Mock() + mock_prisma.db = mock_db + + existing_tag = Mock() + existing_tag.tag_name = "batch-jobs" + existing_tag.description = "old description" + existing_tag.models = [] + existing_tag.budget_id = None + mock_db.litellm_tagtable.find_unique = AsyncMock(return_value=existing_tag) + mock_db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) + + updated_tag = Mock() + updated_tag.tag_name = "batch-jobs" + updated_tag.description = "new description" + updated_tag.models = [] + updated_tag.model_info = {} + updated_tag.spend = 5.0 + updated_tag.budget_id = None + updated_tag.created_at = datetime.now() + updated_tag.updated_at = datetime.now() + updated_tag.created_by = "test-user-123" + mock_db.litellm_tagtable.update = AsyncMock(return_value=updated_tag) + + response = client.post( + "/tag/update", + json={"name": "batch-jobs", "description": "new description"}, + headers={"Authorization": "Bearer sk-1234"}, + ) + assert response.status_code == 200 + result = response.json() + assert result["tag"]["spend"] == 5.0 + + update_call_kwargs = mock_db.litellm_tagtable.update.call_args.kwargs + assert "spend" not in update_call_kwargs["data"] + + mock_spend_counter_cache.in_memory_cache.set_cache.assert_not_called() + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_update_tag_rejects_non_finite_spend(): + """NaN/inf must 400, not reach the DB or the spend counter.""" + from unittest.mock import AsyncMock, Mock + + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + try: + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + mock_db = Mock() + mock_prisma.db = mock_db + mock_db.litellm_tagtable.update = AsyncMock() + + # httpx's `json=` kwarg encodes with allow_nan=False and raises client-side + # before a request is even sent, so NaN must go over the wire as raw content + # (stdlib json.dumps allows NaN by default) to exercise the server's own check. + response = client.post( + "/tag/update", + content=json.dumps({"name": "batch-jobs", "spend": float("nan")}), + headers={ + "Authorization": "Bearer sk-1234", + "Content-Type": "application/json", + }, + ) + assert response.status_code == 400 + mock_db.litellm_tagtable.update.assert_not_called() + finally: + app.dependency_overrides.clear() + + @pytest.mark.asyncio async def test_delete_tag_invalidates_tag_and_registry_caches(): """Without this a deleted tag keeps its cached budget enforced until the TTL expires.""" From a0ea47556922ad5954d681e919925525ba7f19e5 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Thu, 17 Sep 2026 20:31:39 +0200 Subject: [PATCH 3/7] fix(tags): address final review findings (docstring, typing, redis test coverage, comment trim) - Document the spend field in update_tag's docstring - Widen update_data's inferred type so basedpyright accepts the float write - Add tests covering the Redis counter-refresh success and failure paths - Trim two low-value comments that just restated the adjacent code --- .../tag_management_endpoints.py | 5 +- litellm/types/tag_management.py | 1 - .../test_tag_management_endpoints.py | 124 ++++++++++++++++++ 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index ad994a8a0cd..622a54fde28 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -410,6 +410,7 @@ async def update_tag( - description: Optional[str] - Updated description - models: List[str] - Updated list of allowed LLM models - budget_id: Optional[str] - The id for a budget to associate with the tag + - spend: Optional[float] - Set the tag's accumulated spend (e.g. 0 to reset it). Omit to leave unchanged ### BUDGET UPDATE PARAMS ### - max_budget: Optional[float] - Max budget for tag @@ -451,7 +452,7 @@ async def update_tag( model_info: Final = await _get_model_names(prisma_client, tag.models or []) # Prepare update data - update_data: Final = { + update_data: Final[dict[str, object]] = { "description": tag.description, "models": tag.models or [], "model_info": json.dumps(model_info), @@ -461,8 +462,6 @@ async def update_tag( if budget_id != existing_tag.budget_id: update_data["budget_id"] = budget_id - # Reset spend if explicitly provided (None means "leave unchanged"), mirroring - # /key/update's spend field. if tag.spend is not None: update_data["spend"] = tag.spend diff --git a/litellm/types/tag_management.py b/litellm/types/tag_management.py index 0d2b1265391..db1069aafd3 100644 --- a/litellm/types/tag_management.py +++ b/litellm/types/tag_management.py @@ -37,7 +37,6 @@ class TagUpdateRequest(TagBase): rpm_limit: int | None = None model_max_budget: dict | None = None budget_duration: str | None = None - # Set to reset the tag's accumulated spend (e.g. 0 to clear it). None leaves it unchanged. spend: float | None = None diff --git a/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py index 71340b017a7..332e4d16797 100644 --- a/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py @@ -668,6 +668,130 @@ async def test_update_tag_resets_spend(): app.dependency_overrides.clear() +@pytest.mark.asyncio +async def test_update_tag_resets_spend_in_redis(): + """POST /tag/update with spend also refreshes the counter in Redis when configured.""" + from datetime import datetime + from unittest.mock import AsyncMock, MagicMock, Mock + + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + try: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch( + "litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id" + ), + patch("litellm.proxy.proxy_server.spend_counter_cache") as mock_spend_counter_cache, + ): + mock_spend_counter_cache.redis_cache = MagicMock() + mock_spend_counter_cache.redis_cache.async_set_cache = AsyncMock() + + mock_db = Mock() + mock_prisma.db = mock_db + + existing_tag = Mock() + existing_tag.tag_name = "batch-jobs" + existing_tag.description = "nightly batch jobs" + existing_tag.models = [] + existing_tag.budget_id = None + mock_db.litellm_tagtable.find_unique = AsyncMock(return_value=existing_tag) + mock_db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) + + updated_tag = Mock() + updated_tag.tag_name = "batch-jobs" + updated_tag.description = "nightly batch jobs" + updated_tag.models = [] + updated_tag.model_info = {} + updated_tag.spend = 0.0 + updated_tag.budget_id = None + updated_tag.created_at = datetime.now() + updated_tag.updated_at = datetime.now() + updated_tag.created_by = "test-user-123" + mock_db.litellm_tagtable.update = AsyncMock(return_value=updated_tag) + + response = client.post( + "/tag/update", + json={"name": "batch-jobs", "spend": 0}, + headers={"Authorization": "Bearer sk-1234"}, + ) + assert response.status_code == 200 + result = response.json() + assert result["tag"]["spend"] == 0.0 + + mock_spend_counter_cache.redis_cache.async_set_cache.assert_awaited_once_with( + key="spend:tag:batch-jobs", value=0.0, ttl=60 + ) + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_update_tag_resets_spend_redis_failure_does_not_fail_request(): + """A Redis error while refreshing the counter must not fail the /tag/update request (best-effort).""" + from datetime import datetime + from unittest.mock import AsyncMock, MagicMock, Mock + + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + try: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch( + "litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id" + ), + patch("litellm.proxy.proxy_server.spend_counter_cache") as mock_spend_counter_cache, + ): + mock_spend_counter_cache.redis_cache = MagicMock() + mock_spend_counter_cache.redis_cache.async_set_cache = AsyncMock( + side_effect=Exception("boom") + ) + + mock_db = Mock() + mock_prisma.db = mock_db + + existing_tag = Mock() + existing_tag.tag_name = "batch-jobs" + existing_tag.description = "nightly batch jobs" + existing_tag.models = [] + existing_tag.budget_id = None + mock_db.litellm_tagtable.find_unique = AsyncMock(return_value=existing_tag) + mock_db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) + + updated_tag = Mock() + updated_tag.tag_name = "batch-jobs" + updated_tag.description = "nightly batch jobs" + updated_tag.models = [] + updated_tag.model_info = {} + updated_tag.spend = 0.0 + updated_tag.budget_id = None + updated_tag.created_at = datetime.now() + updated_tag.updated_at = datetime.now() + updated_tag.created_by = "test-user-123" + mock_db.litellm_tagtable.update = AsyncMock(return_value=updated_tag) + + response = client.post( + "/tag/update", + json={"name": "batch-jobs", "spend": 0}, + headers={"Authorization": "Bearer sk-1234"}, + ) + assert response.status_code == 200 + result = response.json() + assert result["tag"]["spend"] == 0.0 + finally: + app.dependency_overrides.clear() + + @pytest.mark.asyncio async def test_update_tag_without_spend_does_not_touch_counter_cache(): """A description-only update must not write to LiteLLM_TagTable.spend or the spend counter.""" From 89f80f197601375ee0d45f0f635758eae00d7ff6 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Thu, 17 Sep 2026 21:01:17 +0200 Subject: [PATCH 4/7] docs(tags): clarify spend reset leaves budget_reset_at unchanged --- litellm/proxy/management_endpoints/tag_management_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index 622a54fde28..7c9c892b4dc 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -410,7 +410,7 @@ async def update_tag( - description: Optional[str] - Updated description - models: List[str] - Updated list of allowed LLM models - budget_id: Optional[str] - The id for a budget to associate with the tag - - spend: Optional[float] - Set the tag's accumulated spend (e.g. 0 to reset it). Omit to leave unchanged + - spend: Optional[float] - Set the tag's accumulated spend (e.g. 0 to reset it). Omit to leave unchanged. Does not change budget_reset_at; the current budget window is left as-is ### BUDGET UPDATE PARAMS ### - max_budget: Optional[float] - Max budget for tag From b902bf210e7579006019751320690ad8cbfa09f2 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Fri, 18 Sep 2026 09:21:20 +0200 Subject: [PATCH 5/7] fix(tags): resolve CI failures on tag spend reset PR - suppress BLE001 for the Redis best-effort catch, matching the sanctioned pattern used elsewhere for intentional broad catches - regenerate schema.d.ts so the dashboard types include the new spend field on TagUpdateRequest --- litellm/proxy/management_endpoints/tag_management_endpoints.py | 2 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index 7c9c892b4dc..f10f13d6663 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -484,7 +484,7 @@ async def update_tag( if spend_counter_cache.redis_cache is not None: try: await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=tag.spend, ttl=60) - except Exception as redis_err: + except Exception as redis_err: # noqa: BLE001 # best-effort refresh: a Redis failure must not fail the request verbose_proxy_logger.warning( "Failed to update spend counter %s in Redis after tag spend update: %s. " "Budget checks may use stale value until counter expires.", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index b72f2503e5d..f6973a6d7b4 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16344,6 +16344,7 @@ export interface paths { * - description: Optional[str] - Updated description * - models: List[str] - Updated list of allowed LLM models * - budget_id: Optional[str] - The id for a budget to associate with the tag + * - spend: Optional[float] - Set the tag's accumulated spend (e.g. 0 to reset it). Omit to leave unchanged. Does not change budget_reset_at; the current budget window is left as-is * * ### BUDGET UPDATE PARAMS ### * - max_budget: Optional[float] - Max budget for tag @@ -45505,6 +45506,8 @@ export interface components { rpm_limit?: number | null; /** Soft Budget */ soft_budget?: number | null; + /** Spend */ + spend?: number | null; /** Tpm Limit */ tpm_limit?: number | null; }; From 38eace0342b9e3ebf3a50a4652918871434637cb Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Fri, 18 Sep 2026 09:23:08 +0200 Subject: [PATCH 6/7] fix(tags): strip CR/LF from tag name before logging Redis failure CodeQL flagged this as a log-injection vector: the raw, admin-supplied tag name was interpolated into a warning log line. /key/update's equivalent path logs a hashed key, not raw user input, so this pattern did not exist there. --- .../proxy/management_endpoints/tag_management_endpoints.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index f10f13d6663..e8091c1937e 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -485,10 +485,13 @@ async def update_tag( try: await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=tag.spend, ttl=60) except Exception as redis_err: # noqa: BLE001 # best-effort refresh: a Redis failure must not fail the request + # tag.name is admin-supplied; strip CR/LF before it reaches the logs so it + # cannot forge additional log lines. + safe_counter_key: Final = counter_key.replace("\r", "").replace("\n", "") verbose_proxy_logger.warning( "Failed to update spend counter %s in Redis after tag spend update: %s. " "Budget checks may use stale value until counter expires.", - counter_key, + safe_counter_key, redis_err, ) From 19a854aeaa8155ce819bae681de1c994bcde7914 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Fri, 18 Sep 2026 09:37:54 +0200 Subject: [PATCH 7/7] test(tags): suppress TQ008 on the new tests' patch calls These patch litellm.proxy.proxy_server internals the endpoint imports directly, the same unavoidable pattern every other test in this file already uses; the new occurrences pushed the file's TQ008 count over the CI budget. --- .../test_tag_management_endpoints.py | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py index 332e4d16797..fb65e792538 100644 --- a/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py @@ -618,11 +618,9 @@ async def test_update_tag_resets_spend(): try: with ( - patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, - patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id" - ), - patch("litellm.proxy.proxy_server.spend_counter_cache") as mock_spend_counter_cache, + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, # test-quality-ok: the endpoint imports proxy_server.prisma_client itself; no parameter to inject + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id"), # test-quality-ok: the endpoint imports proxy_server.litellm_proxy_admin_name itself; no parameter to inject + patch("litellm.proxy.proxy_server.spend_counter_cache") as mock_spend_counter_cache, # test-quality-ok: the endpoint imports proxy_server.spend_counter_cache itself; no parameter to inject ): mock_spend_counter_cache.redis_cache = None @@ -683,11 +681,9 @@ async def test_update_tag_resets_spend_in_redis(): try: with ( - patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, - patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id" - ), - patch("litellm.proxy.proxy_server.spend_counter_cache") as mock_spend_counter_cache, + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, # test-quality-ok: the endpoint imports proxy_server.prisma_client itself; no parameter to inject + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id"), # test-quality-ok: the endpoint imports proxy_server.litellm_proxy_admin_name itself; no parameter to inject + patch("litellm.proxy.proxy_server.spend_counter_cache") as mock_spend_counter_cache, # test-quality-ok: the endpoint imports proxy_server.spend_counter_cache itself; no parameter to inject ): mock_spend_counter_cache.redis_cache = MagicMock() mock_spend_counter_cache.redis_cache.async_set_cache = AsyncMock() @@ -746,11 +742,9 @@ async def test_update_tag_resets_spend_redis_failure_does_not_fail_request(): try: with ( - patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, - patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id" - ), - patch("litellm.proxy.proxy_server.spend_counter_cache") as mock_spend_counter_cache, + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, # test-quality-ok: the endpoint imports proxy_server.prisma_client itself; no parameter to inject + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id"), # test-quality-ok: the endpoint imports proxy_server.litellm_proxy_admin_name itself; no parameter to inject + patch("litellm.proxy.proxy_server.spend_counter_cache") as mock_spend_counter_cache, # test-quality-ok: the endpoint imports proxy_server.spend_counter_cache itself; no parameter to inject ): mock_spend_counter_cache.redis_cache = MagicMock() mock_spend_counter_cache.redis_cache.async_set_cache = AsyncMock( @@ -807,11 +801,9 @@ async def test_update_tag_without_spend_does_not_touch_counter_cache(): try: with ( - patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, - patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id" - ), - patch("litellm.proxy.proxy_server.spend_counter_cache") as mock_spend_counter_cache, + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, # test-quality-ok: the endpoint imports proxy_server.prisma_client itself; no parameter to inject + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id"), # test-quality-ok: the endpoint imports proxy_server.litellm_proxy_admin_name itself; no parameter to inject + patch("litellm.proxy.proxy_server.spend_counter_cache") as mock_spend_counter_cache, # test-quality-ok: the endpoint imports proxy_server.spend_counter_cache itself; no parameter to inject ): mock_db = Mock() mock_prisma.db = mock_db @@ -866,7 +858,7 @@ async def test_update_tag_rejects_non_finite_spend(): ) try: - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: # test-quality-ok: the endpoint imports proxy_server.prisma_client itself; no parameter to inject mock_db = Mock() mock_prisma.db = mock_db mock_db.litellm_tagtable.update = AsyncMock()