From 7a14be3a19cddc4b16dcf901d7de3212acc5fa2a Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Thu, 17 Sep 2026 20:15:00 +0200 Subject: [PATCH 01/11] 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 9a1cf701142..9dcd5a4a409 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -67,6 +67,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 e59b02af71a..3f2aee1683b 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(LiteLLMBaseModel): 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 fb1a24ae8b484bf9f94edb71418e483202f66ccc Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Thu, 17 Sep 2026 20:20:27 +0200 Subject: [PATCH 02/11] 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 9dcd5a4a409..241a2de380d 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -343,6 +343,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 { @@ -422,11 +423,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}) @@ -459,6 +464,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}, @@ -467,6 +477,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, @@ -476,6 +505,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 1dfababae55..acd8b131abc 100644 --- a/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py @@ -241,6 +241,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", @@ -307,6 +308,7 @@ async def test_update_tag_clears_or_sets_only_provided_budget_fields( 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", @@ -374,6 +376,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", @@ -609,6 +612,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 3f4b0e1deb63aa981993657edc3b626cb87b81e6 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Thu, 17 Sep 2026 20:31:39 +0200 Subject: [PATCH 03/11] 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 241a2de380d..87d84bacf89 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -413,6 +413,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 @@ -454,7 +455,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), @@ -464,8 +465,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 3f2aee1683b..584550ef0c9 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 acd8b131abc..32c5495d0de 100644 --- a/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py @@ -677,6 +677,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 f221884029cf3e9ad704149e652d507df5dd2c8f Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Thu, 17 Sep 2026 21:01:17 +0200 Subject: [PATCH 04/11] 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 87d84bacf89..ec6ee88f12f 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -413,7 +413,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 83b83fd1fa07b7f20842ed10b039de969d84e867 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Fri, 18 Sep 2026 09:21:20 +0200 Subject: [PATCH 05/11] 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 ec6ee88f12f..701e43d0f8f 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -487,7 +487,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 fa90213a486..2ee426b2429 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16694,6 +16694,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 @@ -46823,6 +46824,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 21d54309d946c197aebbc57075f27300a6be4922 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Fri, 18 Sep 2026 09:23:08 +0200 Subject: [PATCH 06/11] 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 701e43d0f8f..c2c0597a637 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -488,10 +488,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 85435d64e0924b123b49b3cad1354f241958de6c Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Fri, 18 Sep 2026 09:37:54 +0200 Subject: [PATCH 07/11] 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 32c5495d0de..b683a70b7f8 100644 --- a/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py @@ -627,11 +627,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 @@ -692,11 +690,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() @@ -755,11 +751,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( @@ -816,11 +810,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 @@ -875,7 +867,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() From 0ab1a3b1f16c8194315311a662712f1904782f77 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Fri, 9 Oct 2026 19:33:04 +0200 Subject: [PATCH 08/11] fix(tags): suppress LIT001 on the update_data prisma input --- 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 c2c0597a637..b899ada9ce8 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -455,7 +455,7 @@ async def update_tag( model_info: Final = await _get_model_names(prisma_client, tag.models or []) # Prepare update data - update_data: Final[dict[str, object]] = { + update_data: Final[dict[str, object]] = { # mutable-ok: prisma update input, amended below "description": tag.description, "models": tag.models or [], "model_info": json.dumps(model_info), From b3833217dfebd63ea2cf7db9e834a55865e475bc Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Fri, 9 Oct 2026 19:34:17 +0200 Subject: [PATCH 09/11] fix(tags): declare the spend counter key family around the Redis write --- .../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 b899ada9ce8..9af98734c7f 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Final, Protocol, TypedDict, overload from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import TypeAdapter +from litellm._internal_context import service_target from litellm._logging import verbose_proxy_logger from litellm.proxy._types import UserAPIKeyAuth, user_api_key_has_admin_view from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -31,6 +32,7 @@ from litellm.proxy.management_endpoints.common_daily_activity import ( get_daily_activity, ) from litellm.proxy.management_helpers.utils import handle_budget_for_entity +from litellm.proxy.spend_tracking.spend_counter_batch import SPEND_COUNTERS_TARGET from litellm.repositories.model_repository import ModelRepository from litellm.repositories.table_repositories import ( DailyTagSpendRepository, @@ -486,7 +488,8 @@ async def update_tag( 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) + with service_target(SPEND_COUNTERS_TARGET): + 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. From 668255da06a7e4b55b7bff56006a5187e311b974 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Fri, 9 Oct 2026 19:36:11 +0200 Subject: [PATCH 10/11] test(tags): use a placeholder key instead of the publicly known master key prefix --- .../test_tag_management_endpoints.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 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 b683a70b7f8..026b53fd2c3 100644 --- a/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_tag_management_endpoints.py @@ -659,7 +659,7 @@ async def test_update_tag_resets_spend(): response = client.post( "/tag/update", json={"name": "batch-jobs", "spend": 0}, - headers={"Authorization": "Bearer sk-1234"}, + headers={"Authorization": "Bearer sk-test-tag-spend"}, ) assert response.status_code == 200 result = response.json() @@ -723,7 +723,7 @@ async def test_update_tag_resets_spend_in_redis(): response = client.post( "/tag/update", json={"name": "batch-jobs", "spend": 0}, - headers={"Authorization": "Bearer sk-1234"}, + headers={"Authorization": "Bearer sk-test-tag-spend"}, ) assert response.status_code == 200 result = response.json() @@ -786,7 +786,7 @@ async def test_update_tag_resets_spend_redis_failure_does_not_fail_request(): response = client.post( "/tag/update", json={"name": "batch-jobs", "spend": 0}, - headers={"Authorization": "Bearer sk-1234"}, + headers={"Authorization": "Bearer sk-test-tag-spend"}, ) assert response.status_code == 200 result = response.json() @@ -840,7 +840,7 @@ async def test_update_tag_without_spend_does_not_touch_counter_cache(): response = client.post( "/tag/update", json={"name": "batch-jobs", "description": "new description"}, - headers={"Authorization": "Bearer sk-1234"}, + headers={"Authorization": "Bearer sk-test-tag-spend"}, ) assert response.status_code == 200 result = response.json() @@ -879,7 +879,7 @@ async def test_update_tag_rejects_non_finite_spend(): "/tag/update", content=json.dumps({"name": "batch-jobs", "spend": float("nan")}), headers={ - "Authorization": "Bearer sk-1234", + "Authorization": "Bearer sk-test-tag-spend", "Content-Type": "application/json", }, ) From cadb9c5d52983517d356e525f091df742c3ea9b3 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Fri, 9 Oct 2026 20:15:55 +0200 Subject: [PATCH 11/11] fix(tags): suppress reportUnknownMemberType on the spend counter cache writes --- .../proxy/management_endpoints/tag_management_endpoints.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index 9af98734c7f..9634a71ac04 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -485,11 +485,11 @@ async def update_tag( 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) + spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=tag.spend, ttl=60) # pyright: ignore[reportUnknownMemberType] # DualCache cache methods are untyped if spend_counter_cache.redis_cache is not None: try: with service_target(SPEND_COUNTERS_TARGET): - await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=tag.spend, ttl=60) + await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=tag.spend, ttl=60) # pyright: ignore[reportUnknownMemberType] # DualCache cache methods are untyped 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.