mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-18 06:26:16 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/unify_uv_cache
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ schemaVersion: 2.0.0
|
||||
|
||||
metadataTest:
|
||||
entrypoint: ["docker/prod_entrypoint.sh"]
|
||||
user: "nobody"
|
||||
user: "65534"
|
||||
workdir: "/app"
|
||||
|
||||
fileExistenceTests:
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_TeamMembership" ADD COLUMN "total_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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==",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5515,6 +5515,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:
|
||||
@@ -5611,6 +5613,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
@@ -123,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(
|
||||
|
||||
+12
-3
@@ -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
|
||||
@@ -2015,7 +2020,7 @@ 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
|
||||
@@ -3695,7 +3700,11 @@ class LiteLLM_TeamMembership(LiteLLMPydanticObjectBase):
|
||||
team_id: str
|
||||
budget_id: Optional[str] = None
|
||||
spend: Optional[float] = 0.0
|
||||
litellm_budget_table: Optional[LiteLLM_BudgetTable]
|
||||
total_spend: Optional[float] = 0.0
|
||||
# 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
+3
-3
@@ -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"
|
||||
@@ -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",
|
||||
@@ -236,7 +236,7 @@ source-exclude = [
|
||||
profile = "black"
|
||||
|
||||
[tool.commitizen]
|
||||
version = "1.83.11"
|
||||
version = "1.83.12"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
]
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: Optional[str] = None,
|
||||
litellm_params: Optional[dict] = None,
|
||||
api_base: Optional[str] = None,
|
||||
) -> dict:
|
||||
return headers
|
||||
|
||||
@@ -262,3 +267,141 @@ 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_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
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user