From dff4bfd735946e1006d33adef0286e557b6f0b62 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 21 Apr 2026 14:54:18 +0530 Subject: [PATCH 01/11] fix(image_edit): forward litellm_params to validate_environment for Vertex AI credentials When aimage_edit or image_edit was called with Vertex AI Gemini/Imagen models via YAML-style config (vertex_project / vertex_credentials in proxy YAML), the credentials were dropped during handler-to-config plumbing, causing fallback to Application Default Credentials and DefaultCredentialsError. Root cause: image_edit_handler and async_image_edit_handler did not pass litellm_params to validate_environment, unlike image_generation_handler. Fixes: 1. Widen BaseImageEditConfig.validate_environment signature to accept litellm_params and api_base (optional kwargs). 2. Forward dict(litellm_params) and litellm_params.api_base from both sync and async image_edit handlers to validate_environment. 3. Update VertexAIImagenImageEditConfig.validate_environment to read vertex_ai_project/vertex_ai_credentials from litellm_params first, matching Gemini config pattern (secondary latent bug fix). 4. Widen all image-edit config override signatures to match base. Made-with: Cursor --- .../llms/azure/image_edit/transformation.py | 2 ++ .../image_edit/flux2_transformation.py | 2 ++ .../llms/azure_ai/image_edit/transformation.py | 2 ++ .../llms/base_llm/image_edit/transformation.py | 2 ++ ...on_nova_canvas_image_edit_transformation.py | 2 ++ .../image_edit/stability_transformation.py | 2 ++ .../image_edit/transformation.py | 2 ++ litellm/llms/custom_httpx/llm_http_handler.py | 4 ++++ .../llms/gemini/image_edit/transformation.py | 2 ++ .../litellm_proxy/image_edit/transformation.py | 7 ++++++- .../llms/openai/image_edit/transformation.py | 2 ++ .../openrouter/image_edit/transformation.py | 2 ++ .../llms/recraft/image_edit/transformation.py | 2 ++ .../stability/image_edit/transformations.py | 2 ++ .../image_edit/vertex_imagen_transformation.py | 18 ++++++++++++++++-- .../images/test_image_edit_utils.py | 9 +++++++-- 16 files changed, 57 insertions(+), 5 deletions(-) diff --git a/litellm/llms/azure/image_edit/transformation.py b/litellm/llms/azure/image_edit/transformation.py index f476d6a94e..dffa1c9eea 100644 --- a/litellm/llms/azure/image_edit/transformation.py +++ b/litellm/llms/azure/image_edit/transformation.py @@ -14,6 +14,8 @@ class AzureImageEditConfig(OpenAIImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: api_key = ( api_key diff --git a/litellm/llms/azure_ai/image_edit/flux2_transformation.py b/litellm/llms/azure_ai/image_edit/flux2_transformation.py index 0de163a771..1bc3bdcddc 100644 --- a/litellm/llms/azure_ai/image_edit/flux2_transformation.py +++ b/litellm/llms/azure_ai/image_edit/flux2_transformation.py @@ -65,6 +65,8 @@ class AzureFoundryFlux2ImageEditConfig(OpenAIImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate Azure AI Foundry environment and set up authentication diff --git a/litellm/llms/azure_ai/image_edit/transformation.py b/litellm/llms/azure_ai/image_edit/transformation.py index 930b6d4db9..e778348c75 100644 --- a/litellm/llms/azure_ai/image_edit/transformation.py +++ b/litellm/llms/azure_ai/image_edit/transformation.py @@ -25,6 +25,8 @@ class AzureFoundryFluxImageEditConfig(OpenAIImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate Azure AI Foundry environment and set up authentication diff --git a/litellm/llms/base_llm/image_edit/transformation.py b/litellm/llms/base_llm/image_edit/transformation.py index b088cdf37f..cea96bde74 100644 --- a/litellm/llms/base_llm/image_edit/transformation.py +++ b/litellm/llms/base_llm/image_edit/transformation.py @@ -67,6 +67,8 @@ class BaseImageEditConfig(ABC): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: return {} diff --git a/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py b/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py index f806cd2a81..836a3c606e 100644 --- a/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py +++ b/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py @@ -483,6 +483,8 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: if headers is None: headers = {} diff --git a/litellm/llms/bedrock/image_edit/stability_transformation.py b/litellm/llms/bedrock/image_edit/stability_transformation.py index 6a8b95e7e3..2d73e47003 100644 --- a/litellm/llms/bedrock/image_edit/stability_transformation.py +++ b/litellm/llms/bedrock/image_edit/stability_transformation.py @@ -372,6 +372,8 @@ class BedrockStabilityImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate environment for Bedrock Stability image edit. diff --git a/litellm/llms/black_forest_labs/image_edit/transformation.py b/litellm/llms/black_forest_labs/image_edit/transformation.py index c6d8e8298e..4d19885aac 100644 --- a/litellm/llms/black_forest_labs/image_edit/transformation.py +++ b/litellm/llms/black_forest_labs/image_edit/transformation.py @@ -123,6 +123,8 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate environment and set up headers for Black Forest Labs. diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index ea0c05e765..de215b9ae5 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -5216,6 +5216,8 @@ class BaseLLMHTTPHandler: api_key=litellm_params.api_key, headers=image_edit_optional_request_params.get("extra_headers", {}) or {}, model=model, + litellm_params=dict(litellm_params), + api_base=litellm_params.api_base, ) if extra_headers: @@ -5312,6 +5314,8 @@ class BaseLLMHTTPHandler: api_key=litellm_params.api_key, headers=image_edit_optional_request_params.get("extra_headers", {}) or {}, model=model, + litellm_params=dict(litellm_params), + api_base=litellm_params.api_base, ) if extra_headers: diff --git a/litellm/llms/gemini/image_edit/transformation.py b/litellm/llms/gemini/image_edit/transformation.py index d46733e04b..c8aaab0e14 100644 --- a/litellm/llms/gemini/image_edit/transformation.py +++ b/litellm/llms/gemini/image_edit/transformation.py @@ -54,6 +54,8 @@ class GeminiImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: final_api_key: Optional[str] = api_key or get_secret_str("GEMINI_API_KEY") if not final_api_key: diff --git a/litellm/llms/litellm_proxy/image_edit/transformation.py b/litellm/llms/litellm_proxy/image_edit/transformation.py index 5f5e2bdb24..79cd6e15c6 100644 --- a/litellm/llms/litellm_proxy/image_edit/transformation.py +++ b/litellm/llms/litellm_proxy/image_edit/transformation.py @@ -8,7 +8,12 @@ class LiteLLMProxyImageEditConfig(OpenAIImageEditConfig): """Configuration for image edit requests routed through LiteLLM Proxy.""" def validate_environment( - self, headers: dict, model: str, api_key: Optional[str] = None + self, + headers: dict, + model: str, + api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: api_key = api_key or get_secret_str("LITELLM_PROXY_API_KEY") headers.update({"Authorization": f"Bearer {api_key}"}) diff --git a/litellm/llms/openai/image_edit/transformation.py b/litellm/llms/openai/image_edit/transformation.py index 6917e8d799..9c0daca802 100644 --- a/litellm/llms/openai/image_edit/transformation.py +++ b/litellm/llms/openai/image_edit/transformation.py @@ -165,6 +165,8 @@ class OpenAIImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: api_key = ( api_key diff --git a/litellm/llms/openrouter/image_edit/transformation.py b/litellm/llms/openrouter/image_edit/transformation.py index fcf066dd5a..0d96b62425 100644 --- a/litellm/llms/openrouter/image_edit/transformation.py +++ b/litellm/llms/openrouter/image_edit/transformation.py @@ -116,6 +116,8 @@ class OpenRouterImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: api_key = api_key or litellm.api_key or get_secret_str("OPENROUTER_API_KEY") if not api_key: diff --git a/litellm/llms/recraft/image_edit/transformation.py b/litellm/llms/recraft/image_edit/transformation.py index 4c199bc78d..1dccd40605 100644 --- a/litellm/llms/recraft/image_edit/transformation.py +++ b/litellm/llms/recraft/image_edit/transformation.py @@ -81,6 +81,8 @@ class RecraftImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: final_api_key: Optional[str] = api_key or get_secret_str("RECRAFT_API_KEY") if not final_api_key: diff --git a/litellm/llms/stability/image_edit/transformations.py b/litellm/llms/stability/image_edit/transformations.py index eb400a2526..522858b8c2 100644 --- a/litellm/llms/stability/image_edit/transformations.py +++ b/litellm/llms/stability/image_edit/transformations.py @@ -149,6 +149,8 @@ class StabilityImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate environment and set up headers for Stability AI. diff --git a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py index 7979e0e790..11126b2a3e 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py @@ -103,10 +103,24 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: headers = headers or {} - vertex_project = self._resolve_vertex_project() - vertex_credentials = self._resolve_vertex_credentials() + litellm_params = litellm_params or {} + + _api_base = litellm_params.get("api_base") or api_base + if _api_base is not None: + return headers + + vertex_project = ( + self.safe_get_vertex_ai_project(litellm_params) + or self._resolve_vertex_project() + ) + vertex_credentials = ( + self.safe_get_vertex_ai_credentials(litellm_params) + or self._resolve_vertex_credentials() + ) access_token, _ = self._ensure_access_token( credentials=vertex_credentials, project_id=vertex_project, diff --git a/tests/test_litellm/images/test_image_edit_utils.py b/tests/test_litellm/images/test_image_edit_utils.py index e0584afb81..186a085bdc 100644 --- a/tests/test_litellm/images/test_image_edit_utils.py +++ b/tests/test_litellm/images/test_image_edit_utils.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from unittest.mock import MagicMock, patch import pytest @@ -26,7 +26,12 @@ class MockImageEditConfig(BaseImageEditConfig): return "https://example.com/api" def validate_environment( - self, headers: dict, model: str, api_key: str = None + self, + headers: dict, + model: str, + api_key: str = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: return headers From a7512764af462bf2ed95135074df2819be8ca2de Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 21 Apr 2026 14:58:47 +0530 Subject: [PATCH 02/11] test(image_edit): add regression tests for credentials forwarding Adds three test cases to prevent regression of the Vertex AI image_edit credentials bug: 1. test_validate_environment_signature_includes_litellm_params: ensures all image-edit configs accept litellm_params (contract for the handler) 2. test_vertex_gemini_image_edit_reads_credentials_from_litellm_params: verifies Gemini config reads from litellm_params first 3. test_vertex_imagen_image_edit_reads_credentials_from_litellm_params: verifies Imagen config reads from litellm_params first These tests catch if the fix is accidentally reverted or if new image-edit configs are added without the litellm_params parameter. Made-with: Cursor --- .../images/test_image_edit_utils.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/test_litellm/images/test_image_edit_utils.py b/tests/test_litellm/images/test_image_edit_utils.py index 186a085bdc..1a13dd0671 100644 --- a/tests/test_litellm/images/test_image_edit_utils.py +++ b/tests/test_litellm/images/test_image_edit_utils.py @@ -267,3 +267,113 @@ class TestImageEditCustomPricing: def test_custom_pricing_not_detected_without_model_info(self): litellm_params = {"litellm_call_id": "test-call-id"} assert use_custom_pricing_for_model(litellm_params) is False + + +class TestImageEditHandlerCredentialsForwarding: + """ + Regression tests for Vertex AI image_edit credentials bug. + + image_edit handler must forward litellm_params to validate_environment, + so that credentials passed via YAML config (vertex_ai_project, + vertex_ai_credentials, etc.) reach the auth layer instead of falling + through to Application Default Credentials. + """ + + def test_vertex_gemini_image_edit_reads_credentials_from_litellm_params(self): + """ + VertexAIGeminiImageEditConfig.validate_environment should read + vertex_ai_project/vertex_ai_credentials from litellm_params first. + """ + from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import ( + VertexAIGeminiImageEditConfig, + ) + + config = VertexAIGeminiImageEditConfig() + + litellm_params = { + "vertex_ai_project": "test-project-from-params", + "vertex_ai_credentials": "/path/to/creds.json", + } + + with patch.object( + config, "_ensure_access_token", return_value=("token", "project") + ) as mock_ensure: + config.validate_environment( + headers={}, + model="test-model", + litellm_params=litellm_params, + ) + + mock_ensure.assert_called_once() + call_kwargs = mock_ensure.call_args[1] + + assert call_kwargs["credentials"] == "/path/to/creds.json" + assert call_kwargs["project_id"] == "test-project-from-params" + + def test_vertex_imagen_image_edit_reads_credentials_from_litellm_params(self): + """ + VertexAIImagenImageEditConfig.validate_environment should read + vertex_ai_project/vertex_ai_credentials from litellm_params first. + """ + from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import ( + VertexAIImagenImageEditConfig, + ) + + config = VertexAIImagenImageEditConfig() + + litellm_params = { + "vertex_ai_project": "test-project-from-params", + "vertex_ai_credentials": "/path/to/creds.json", + } + + with patch.object( + config, "_ensure_access_token", return_value=("token", "project") + ) as mock_ensure: + config.validate_environment( + headers={}, + model="test-model", + litellm_params=litellm_params, + ) + + mock_ensure.assert_called_once() + call_kwargs = mock_ensure.call_args[1] + + assert call_kwargs["credentials"] == "/path/to/creds.json" + assert call_kwargs["project_id"] == "test-project-from-params" + + def test_validate_environment_signature_includes_litellm_params(self): + """ + All image_edit config validate_environment methods should accept + litellm_params to allow credentials to be forwarded from the handler. + """ + import inspect + + from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import ( + VertexAIGeminiImageEditConfig, + ) + from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import ( + VertexAIImagenImageEditConfig, + ) + from litellm.llms.openai.image_edit.transformation import ( + OpenAIImageEditConfig, + ) + + configs = [ + VertexAIGeminiImageEditConfig(), + VertexAIImagenImageEditConfig(), + OpenAIImageEditConfig(), + MockImageEditConfig(), + ] + + for config in configs: + sig = inspect.signature(config.validate_environment) + params = list(sig.parameters.keys()) + + assert "litellm_params" in params, ( + f"{config.__class__.__name__}.validate_environment " + "missing litellm_params parameter" + ) + assert "api_base" in params, ( + f"{config.__class__.__name__}.validate_environment " + "missing api_base parameter" + ) From 447502b409ebd68c10f4c46f3dcafa0c8763683d Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 21 Apr 2026 15:03:40 +0530 Subject: [PATCH 03/11] fix(image_edit): read vertex_project/location from litellm_params in Imagen get_complete_url VertexAIImagenImageEditConfig.get_complete_url was resolving vertex_project and vertex_location only from env vars and global settings, ignoring litellm_params. Users supplying project/location exclusively via YAML config would get a ValueError or wrong URL even after auth headers were fixed. Mirrors the pattern already used by VertexAIGeminiImageEditConfig and image_generation counterpart (safe_get_vertex_ai_project/location). Also fixes api_key type hint in MockImageEditConfig (str -> Optional[str]) and adds a test covering get_complete_url credential resolution. Made-with: Cursor --- .../vertex_imagen_transformation.py | 10 +++++-- .../images/test_image_edit_utils.py | 30 ++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py index 11126b2a3e..9c0b07b827 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py @@ -137,8 +137,14 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): """ Get the complete URL for Vertex AI Imagen predict API """ - vertex_project = self._resolve_vertex_project() - vertex_location = self._resolve_vertex_location() + vertex_project = ( + self.safe_get_vertex_ai_project(litellm_params) + or self._resolve_vertex_project() + ) + vertex_location = ( + self.safe_get_vertex_ai_location(litellm_params) + or self._resolve_vertex_location() + ) if not vertex_project or not vertex_location: raise ValueError( diff --git a/tests/test_litellm/images/test_image_edit_utils.py b/tests/test_litellm/images/test_image_edit_utils.py index 1a13dd0671..2146c1fab0 100644 --- a/tests/test_litellm/images/test_image_edit_utils.py +++ b/tests/test_litellm/images/test_image_edit_utils.py @@ -29,7 +29,7 @@ class MockImageEditConfig(BaseImageEditConfig): self, headers: dict, model: str, - api_key: str = None, + api_key: Optional[str] = None, litellm_params: Optional[dict] = None, api_base: Optional[str] = None, ) -> dict: @@ -341,6 +341,34 @@ class TestImageEditHandlerCredentialsForwarding: assert call_kwargs["credentials"] == "/path/to/creds.json" assert call_kwargs["project_id"] == "test-project-from-params" + def test_vertex_imagen_get_complete_url_reads_project_and_location_from_litellm_params( + self, + ): + """ + VertexAIImagenImageEditConfig.get_complete_url should read + vertex_ai_project and vertex_ai_location from litellm_params, + not only from env vars / global settings. + """ + from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import ( + VertexAIImagenImageEditConfig, + ) + + config = VertexAIImagenImageEditConfig() + + litellm_params = { + "vertex_ai_project": "param-project", + "vertex_ai_location": "us-east1", + } + + url = config.get_complete_url( + model="vertex_ai/imagegeneration@002", + api_base=None, + litellm_params=litellm_params, + ) + + assert "param-project" in url + assert "us-east1" in url + def test_validate_environment_signature_includes_litellm_params(self): """ All image_edit config validate_environment methods should accept From e5f3e1596902ac2841a0fff2d1caff6c95d79c52 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 21 Apr 2026 13:56:44 -0700 Subject: [PATCH 04/11] Track per-member total spend on team memberships Adds total_spend column to LiteLLM_TeamMembership that accumulates continuously and is not zeroed by the budget cycle reset job. This enables UI surfaces to distinguish current-cycle spend (the existing spend column, which resets) from lifetime spend per team member. Also exposes budget_reset_at on LiteLLM_BudgetTable so /team/info callers can see when a member's budget window next resets. The field was already stored in the DB but stripped by the response Pydantic model. Includes regression tests that: - Guard the reset job against ever writing total_spend: 0 - Verify the spend writer increments both spend and total_spend in one UPDATE statement. --- .../migration.sql | 3 + .../litellm_proxy_extras/schema.prisma | 1 + litellm/proxy/_types.py | 3 +- litellm/proxy/db/db_spend_update_writer.py | 5 +- litellm/proxy/schema.prisma | 1 + schema.prisma | 1 + .../common_utils/test_reset_budget_job.py | 35 +++++++++ .../proxy/db/test_db_spend_update_writer.py | 75 +++++++++++++++++++ 8 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260421135425_add_team_membership_total_spend/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260421135425_add_team_membership_total_spend/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260421135425_add_team_membership_total_spend/migration.sql new file mode 100644 index 0000000000..049bd513cd --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260421135425_add_team_membership_total_spend/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "LiteLLM_TeamMembership" ADD COLUMN "total_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; + diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 08aa564525..e18662b572 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -616,6 +616,7 @@ model LiteLLM_TeamMembership { user_id String team_id String spend Float @default(0.0) + total_spend Float @default(0.0) budget_id String? litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) @@id([user_id, team_id]) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 85d3df7189..819a38eec1 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2007,6 +2007,7 @@ class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase): rpm_limit: Optional[int] = None model_max_budget: Optional[dict] = None budget_duration: Optional[str] = None + budget_reset_at: Optional[datetime] = None allowed_models: Optional[List[str]] = ( None # per-member model scope; empty = inherit team models ) @@ -2017,7 +2018,6 @@ class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase): class LiteLLM_BudgetTableFull(LiteLLM_BudgetTable): """Represents all params for a LiteLLM_BudgetTable record""" - budget_reset_at: Optional[datetime] = None created_at: datetime @@ -3695,6 +3695,7 @@ class LiteLLM_TeamMembership(LiteLLMPydanticObjectBase): team_id: str budget_id: Optional[str] = None spend: Optional[float] = 0.0 + total_spend: Optional[float] = 0.0 litellm_budget_table: Optional[LiteLLM_BudgetTable] def safe_get_team_member_rpm_limit(self) -> Optional[int]: diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index 8017448ae1..c06e1850d9 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -1300,7 +1300,10 @@ class DBSpendUpdateWriter: batcher.litellm_teammembership.update_many( # 'update_many' prevents error from being raised if no row exists where={"team_id": team_id, "user_id": user_id}, - data={"spend": {"increment": response_cost}}, + data={ + "spend": {"increment": response_cost}, + "total_spend": {"increment": response_cost}, + }, ) # Transaction succeeded, break out of retry loop break diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 08aa564525..e18662b572 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -616,6 +616,7 @@ model LiteLLM_TeamMembership { user_id String team_id String spend Float @default(0.0) + total_spend Float @default(0.0) budget_id String? litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) @@id([user_id, team_id]) diff --git a/schema.prisma b/schema.prisma index 08aa564525..e18662b572 100644 --- a/schema.prisma +++ b/schema.prisma @@ -616,6 +616,7 @@ model LiteLLM_TeamMembership { user_id String team_id String spend Float @default(0.0) + total_spend Float @default(0.0) budget_id String? litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) @@id([user_id, team_id]) diff --git a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py index 8206079cb8..32f043be5b 100644 --- a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py +++ b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py @@ -4,6 +4,7 @@ import sys import time from datetime import datetime, timedelta, timezone from typing import Any, Dict, List +from unittest.mock import AsyncMock, MagicMock import pytest @@ -784,3 +785,37 @@ def test_reset_budget_skips_null_budget_id_endusers_when_default_not_in_reset_li assert len(find_many_calls) == 0 litellm.max_end_user_budget_id = None + + +def test_reset_budget_for_team_members_preserves_total_spend(): + """Regression guard: reset_budget_for_litellm_team_members must zero `spend` + but leave `total_spend` untouched. + + The reset writes `data={"spend": 0}` explicitly. If a future refactor adds + `"total_spend": 0` to that dict, this test fails immediately. + """ + expired_budget = type( + "LiteLLM_BudgetTableFull", + (), + {"budget_id": "budget-1"}, + ) + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_teammembership.find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_teammembership.update_many = AsyncMock( + return_value={"count": 1} + ) + + job = ResetBudgetJob( + proxy_logging_obj=MagicMock(), prisma_client=mock_prisma_client + ) + + asyncio.run(job.reset_budget_for_litellm_team_members([expired_budget])) + + mock_prisma_client.db.litellm_teammembership.update_many.assert_called_once() + call_kwargs = ( + mock_prisma_client.db.litellm_teammembership.update_many.call_args.kwargs + ) + assert call_kwargs["where"]["budget_id"]["in"] == ["budget-1"] + assert call_kwargs["data"] == {"spend": 0} + assert "total_spend" not in call_kwargs["data"] diff --git a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py index b98b9a8ad6..4d58434934 100644 --- a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py +++ b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py @@ -642,6 +642,81 @@ async def test_commit_spend_updates_to_db_increments_agent_spend(): assert call_kwargs["data"] == {"spend": {"increment": response_cost}} +@pytest.mark.asyncio +async def test_commit_spend_updates_to_db_increments_team_member_spend_and_total_spend(): + """ + Verify that _commit_spend_updates_to_db increments BOTH spend (cycle-scoped) + and total_spend (non-resetting) on LiteLLM_TeamMembership in a single + update_many call, using the same response_cost. + """ + db_writer = DBSpendUpdateWriter() + + mock_batcher = MagicMock() + mock_batcher.litellm_verificationtoken = MagicMock() + mock_batcher.litellm_verificationtoken.update_many = MagicMock() + mock_batcher.litellm_usertable = MagicMock() + mock_batcher.litellm_usertable.update_many = MagicMock() + mock_batcher.litellm_teamtable = MagicMock() + mock_batcher.litellm_teamtable.update_many = MagicMock() + mock_batcher.litellm_teammembership = MagicMock() + mock_batcher.litellm_teammembership.update_many = MagicMock() + mock_batcher.litellm_organizationtable = MagicMock() + mock_batcher.litellm_organizationtable.update_many = MagicMock() + mock_batcher.litellm_tagtable = MagicMock() + mock_batcher.litellm_tagtable.update_many = MagicMock() + mock_batcher.litellm_agentstable = MagicMock() + mock_batcher.litellm_agentstable.update_many = MagicMock() + + mock_transaction = AsyncMock() + mock_transaction.__aenter__ = AsyncMock(return_value=mock_transaction) + mock_transaction.__aexit__ = AsyncMock(return_value=False) + mock_transaction.batch_ = MagicMock( + return_value=AsyncMock( + __aenter__=AsyncMock(return_value=mock_batcher), + __aexit__=AsyncMock(return_value=False), + ) + ) + + mock_prisma_client = MagicMock() + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.tx = MagicMock(return_value=mock_transaction) + + mock_proxy_logging = MagicMock() + # Skip team-membership cache invalidation — out of scope for this test. + mock_proxy_logging.call_details.get = MagicMock(return_value=None) + + team_id = "team-abc" + user_id = "user-xyz" + response_cost = 0.75 + entity_id = f"team_id::{team_id}::user_id::{user_id}" + db_spend_update_transactions = { + "user_list_transactions": {}, + "end_user_list_transactions": {}, + "key_list_transactions": {}, + "team_list_transactions": {}, + "team_member_list_transactions": {entity_id: response_cost}, + "org_list_transactions": {}, + "tag_list_transactions": {}, + "agent_list_transactions": {}, + } + + with patch("litellm.proxy.utils._raise_failed_update_spend_exception"): + await db_writer._commit_spend_updates_to_db( + prisma_client=mock_prisma_client, + n_retry_times=0, + proxy_logging_obj=mock_proxy_logging, + db_spend_update_transactions=db_spend_update_transactions, + ) + + mock_batcher.litellm_teammembership.update_many.assert_called_once() + call_kwargs = mock_batcher.litellm_teammembership.update_many.call_args[1] + assert call_kwargs["where"] == {"team_id": team_id, "user_id": user_id} + assert call_kwargs["data"] == { + "spend": {"increment": response_cost}, + "total_spend": {"increment": response_cost}, + } + + @pytest.mark.asyncio async def test_add_spend_log_transaction_to_daily_tag_transaction_with_request_id(): """ From 1a0ac9634cd4bcef0044fc0c6c8aefc870679f7d Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 21 Apr 2026 15:38:58 -0700 Subject: [PATCH 05/11] Keep budget_reset_at off the user-settable budget allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiteLLM_BudgetTable is documented as "user-controllable params" and its model_fields.keys() is used as the allowlist for extracting budget fields from incoming API request bodies (management_helpers/utils.py:88, organization_endpoints.py:112/255/537/549, project_endpoints.py:197/245/632, customer_endpoints.py:598). Request models like NewOrganizationRequest inherit from LiteLLM_BudgetTable, so anything on the base class becomes user-settable — a caller could set budget_reset_at far in the future and evade budget cycling. Move budget_reset_at from the base class to LiteLLM_BudgetTableFull so it appears on API responses without becoming writable, and type LiteLLM_TeamMembership.litellm_budget_table as Union[Full, Base] so Pydantic picks Full when the data has server-managed fields (/team/info reads Prisma rows that include budget_reset_at and created_at) and Base when callers construct with only user-settable fields (existing auth tests and caches). --- litellm/proxy/_types.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 819a38eec1..9e3cd18ff5 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1997,7 +1997,12 @@ class TeamRequest(LiteLLMPydanticObjectBase): class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase): - """Represents user-controllable params for a LiteLLM_BudgetTable record""" + """Represents user-controllable params for a LiteLLM_BudgetTable record. + + Budget-write paths use `model_fields.keys()` on this class as an allowlist + for user input. Keep server-managed fields (e.g. `budget_reset_at`) on + `LiteLLM_BudgetTableFull` so they aren't user-settable. + """ budget_id: Optional[str] = None soft_budget: Optional[float] = None @@ -2007,7 +2012,6 @@ class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase): rpm_limit: Optional[int] = None model_max_budget: Optional[dict] = None budget_duration: Optional[str] = None - budget_reset_at: Optional[datetime] = None allowed_models: Optional[List[str]] = ( None # per-member model scope; empty = inherit team models ) @@ -2016,8 +2020,9 @@ class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase): class LiteLLM_BudgetTableFull(LiteLLM_BudgetTable): - """Represents all params for a LiteLLM_BudgetTable record""" + """LiteLLM_BudgetTable + server-managed fields returned on API responses.""" + budget_reset_at: Optional[datetime] = None created_at: datetime @@ -3696,7 +3701,12 @@ class LiteLLM_TeamMembership(LiteLLMPydanticObjectBase): budget_id: Optional[str] = None spend: Optional[float] = 0.0 total_spend: Optional[float] = 0.0 - litellm_budget_table: Optional[LiteLLM_BudgetTable] + # Union so Pydantic picks Full when data has server-managed fields + # (/team/info) and Base when callers/tests construct with only + # user-settable fields. + litellm_budget_table: Optional[ + Union[LiteLLM_BudgetTableFull, LiteLLM_BudgetTable] + ] def safe_get_team_member_rpm_limit(self) -> Optional[int]: if self.litellm_budget_table is not None: From 9a6ddef09fd17659f75cabbba993d7123d6c4a0b Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 21 Apr 2026 15:46:51 -0700 Subject: [PATCH 06/11] fmt: apply black to _types.py --- litellm/proxy/_types.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 9e3cd18ff5..84a9c4b793 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3704,9 +3704,7 @@ class LiteLLM_TeamMembership(LiteLLMPydanticObjectBase): # Union so Pydantic picks Full when data has server-managed fields # (/team/info) and Base when callers/tests construct with only # user-settable fields. - litellm_budget_table: Optional[ - Union[LiteLLM_BudgetTableFull, LiteLLM_BudgetTable] - ] + litellm_budget_table: Optional[Union[LiteLLM_BudgetTableFull, LiteLLM_BudgetTable]] def safe_get_team_member_rpm_limit(self) -> Optional[int]: if self.litellm_budget_table is not None: From a292845dcf7d4929b5b842171b659ed31a86b4c8 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 17:40:42 -0700 Subject: [PATCH 07/11] [Fix] Harden spend accuracy test against transient aiohttp connection errors Two changes, both test-only: - Configure the aiohttp session with TCPConnector(force_close=True) and an explicit ClientTimeout(total=30, connect=10). Prevents reuse of idle TCP connections that the proxy/kernel may have closed during the long window between setup POSTs and the later poll loop, and surfaces a blocked proxy event loop quickly instead of hanging on aiohttp's 5-minute default. - In poll_key_spend_until, catch aiohttp.ClientError and asyncio.TimeoutError around the single /key/info call. A transient transport hiccup now logs and retries on the next tick instead of failing the entire polling loop. Addresses the ConnectionTimeoutError observed on the first /key/info call after the 20 chat completions. --- .../test_spend_accuracy_tests.py | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/spend_tracking_tests/test_spend_accuracy_tests.py b/tests/spend_tracking_tests/test_spend_accuracy_tests.py index 8c523e9191..15e00d9335 100644 --- a/tests/spend_tracking_tests/test_spend_accuracy_tests.py +++ b/tests/spend_tracking_tests/test_spend_accuracy_tests.py @@ -48,6 +48,22 @@ POLL_TIMEOUT_SECONDS = 60 TOLERANCE = 1e-10 +def _make_test_session() -> aiohttp.ClientSession: + """ + Session tuned for CI reliability: + - force_close: avoid aiohttp reusing a TCP connection that the proxy/kernel + silently closed during the long idle window between setup POSTs and the + later poll loop (observed failure mode: ConnectionTimeoutError on the + first /key/info call after 20 chat completions). + - explicit connect timeout: surface a blocked proxy event loop quickly + instead of hanging on aiohttp's 5-minute default total timeout. + """ + return aiohttp.ClientSession( + connector=aiohttp.TCPConnector(force_close=True), + timeout=aiohttp.ClientTimeout(total=30, connect=10), + ) + + async def create_organization(session, organization_alias: str): """Helper function to create a new organization""" url = "http://0.0.0.0:4000/organization/new" @@ -156,7 +172,16 @@ async def poll_key_spend_until(session, key: str, expected: float) -> float: start = time.time() last_spend = 0.0 while time.time() - start < POLL_TIMEOUT_SECONDS: - key_info = await get_spend_info(session, "key", key) + try: + key_info = await get_spend_info(session, "key", key) + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: + print( + f"Transient transport error during spend poll: " + f"{type(exc).__name__}: {exc}. Retrying... " + f"({time.time() - start:.1f}s elapsed)" + ) + await asyncio.sleep(POLL_INTERVAL_SECONDS) + continue last_spend = key_info["info"]["spend"] if abs(last_spend - expected) < TOLERANCE: print( @@ -193,7 +218,7 @@ async def test_basic_spend_accuracy(): """ NUM_LLM_REQUESTS = 20 - async with aiohttp.ClientSession() as session: + async with _make_test_session() as session: await assert_proxy_healthy(session) org_response = await create_organization( @@ -278,7 +303,7 @@ async def test_long_term_spend_accuracy_with_bursts(): BURST_1_REQUESTS = 22 BURST_2_REQUESTS = 12 - async with aiohttp.ClientSession() as session: + async with _make_test_session() as session: await assert_proxy_healthy(session) org_response = await create_organization( From c67d193400eb05779384196fd170079372ad0e56 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Thu, 23 Apr 2026 03:00:04 +0200 Subject: [PATCH 08/11] fix(docker.non_root): use numeric UID 65534 for K8s runAsNonRoot (#26268) --- docker/Dockerfile.non_root | 2 +- docker/tests/nonroot.yaml | 2 +- .../test_litellm/test_dockerfile_non_root.py | 54 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/test_litellm/test_dockerfile_non_root.py diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index e916167609..3666a850d9 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -138,7 +138,7 @@ RUN mkdir -p /nonexistent /var/lib/litellm/assets /var/lib/litellm/ui && \ [ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g+w "$LITELLM_PROXY_EXTRAS_PATH" || true && \ chmod -R g+rX "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets /app/.cache -USER nobody +USER 65534 RUN prisma generate --schema=./schema.prisma diff --git a/docker/tests/nonroot.yaml b/docker/tests/nonroot.yaml index 821b1a105a..36118ca8c5 100644 --- a/docker/tests/nonroot.yaml +++ b/docker/tests/nonroot.yaml @@ -2,7 +2,7 @@ schemaVersion: 2.0.0 metadataTest: entrypoint: ["docker/prod_entrypoint.sh"] - user: "nobody" + user: "65534" workdir: "/app" fileExistenceTests: diff --git a/tests/test_litellm/test_dockerfile_non_root.py b/tests/test_litellm/test_dockerfile_non_root.py new file mode 100644 index 0000000000..694da6368e --- /dev/null +++ b/tests/test_litellm/test_dockerfile_non_root.py @@ -0,0 +1,54 @@ +""" +Static checks on docker/Dockerfile.non_root. + +The non_root image is intended for deployment into hardened Kubernetes +clusters where `securityContext.runAsNonRoot: true` is enforced. The +kubelet validates non-root status by parsing the image's USER field as +an integer — a string name like "nobody" is rejected with +CreateContainerConfigError because the kubelet cannot resolve +/etc/passwd inside the image at admission time. +""" + +import os +import re + +import pytest + +DOCKERFILE_PATH = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "docker", + "Dockerfile.non_root", +) + + +def _final_user_directive(dockerfile_text: str) -> str: + """Return the value of the last `USER` directive in the file.""" + matches = re.findall(r"^USER\s+(\S+)\s*$", dockerfile_text, re.MULTILINE) + assert matches, "Dockerfile.non_root has no USER directive" + return matches[-1] + + +@pytest.mark.skipif( + not os.path.exists(DOCKERFILE_PATH), + reason="Dockerfile.non_root not present in this checkout", +) +def test_final_user_directive_is_numeric(): + """The runtime USER must be a numeric UID so kubelet's runAsNonRoot + admission check (strconv.Atoi) succeeds.""" + with open(DOCKERFILE_PATH, "r", encoding="utf-8") as f: + contents = f.read() + + final_user = _final_user_directive(contents) + + assert final_user.isdigit(), ( + f"Dockerfile.non_root final USER is {final_user!r}; must be a numeric UID " + "so Kubernetes' runAsNonRoot admission check can verify non-root status. " + "See https://kubernetes.io/docs/tasks/configure-pod-container/security-context/" + ) + + assert int(final_user) != 0, ( + f"Dockerfile.non_root final USER is {final_user} (root); the non_root image " + "must run as a non-zero UID." + ) From 3ddb3cbdf61071506b2289e1604ace38816a632e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 18:20:21 -0700 Subject: [PATCH 09/11] =?UTF-8?q?bump:=20version=200.4.67=20=E2=86=92=200.?= =?UTF-8?q?4.68?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- litellm-proxy-extras/pyproject.toml | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 959f9519a7..65f95dbde7 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-proxy-extras" -version = "0.4.67" +version = "0.4.68" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." readme = "README.md" requires-python = ">=3.9" @@ -25,7 +25,7 @@ required-version = "==0.10.9" module-root = "" [tool.commitizen] -version = "0.4.67" +version = "0.4.68" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-proxy-extras==", diff --git a/pyproject.toml b/pyproject.toml index 75aec08c99..be0fe36335 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ proxy = [ "azure-identity==1.25.2", "azure-storage-blob==12.28.0", "mcp==1.26.0", - "litellm-proxy-extras==0.4.67", + "litellm-proxy-extras==0.4.68", "litellm-enterprise==0.1.38", "RestrictedPython==8.1", "rich==13.9.4", From 9f46d838fd348146add15ba12dd3d2a68bbb0c13 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 18:21:47 -0700 Subject: [PATCH 10/11] =?UTF-8?q?bump:=20version=201.83.11=20=E2=86=92=201?= =?UTF-8?q?.83.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index be0fe36335..41334f830f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.83.11" +version = "1.83.12" description = "Library to easily interface with LLM API providers" readme = "README.md" requires-python = ">=3.10, <3.14" @@ -236,7 +236,7 @@ source-exclude = [ profile = "black" [tool.commitizen] -version = "1.83.11" +version = "1.83.12" version_files = [ "pyproject.toml:^version", ] From 95fa7678afb9d960d4b134fdf0d655491734f67b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 18:25:37 -0700 Subject: [PATCH 11/11] uv lock --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 1d449012d9..20f519ca70 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-19T01:10:36.69677Z" +exclude-newer = "2026-04-20T01:21:50.985363Z" exclude-newer-span = "P3D" [manifest] @@ -3085,7 +3085,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.83.11" +version = "1.83.12" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -3418,7 +3418,7 @@ source = { editable = "enterprise" } [[package]] name = "litellm-proxy-extras" -version = "0.4.67" +version = "0.4.68" source = { editable = "litellm-proxy-extras" } [[package]]