Adding backend

This commit is contained in:
yuneng-jiang 2025-12-19 13:04:59 -08:00
parent e92db2d718
commit 74842de78e
3 changed files with 137 additions and 16 deletions

View file

@ -69,7 +69,7 @@ async def _get_cloudzero_settings():
Retrieve CloudZero settings from the database with decrypted API key.
Returns:
dict: CloudZero settings with decrypted API key
dict: CloudZero settings with decrypted API key, or empty dict if not configured
"""
from litellm.proxy.proxy_server import prisma_client
@ -82,10 +82,16 @@ async def _get_cloudzero_settings():
cloudzero_config = await prisma_client.db.litellm_config.find_first(
where={"param_name": "cloudzero_settings"}
)
if cloudzero_config is None:
if cloudzero_config is None or cloudzero_config.param_value is None:
return {}
settings = dict(cloudzero_config.param_value)
# Handle both dict and JSON string cases
if isinstance(cloudzero_config.param_value, dict):
settings = cloudzero_config.param_value
elif isinstance(cloudzero_config.param_value, str):
settings = json.loads(cloudzero_config.param_value)
else:
settings = dict(cloudzero_config.param_value)
# Decrypt the API key
encrypted_api_key = settings.get("api_key")
@ -119,6 +125,7 @@ async def get_cloudzero_settings(
Returns the current CloudZero configuration with the API key masked for security.
Only the first 4 and last 4 characters of the API key are shown.
Returns null/empty values when settings are not configured (consistent with other settings endpoints).
Only admin users can view CloudZero settings.
"""
@ -133,22 +140,27 @@ async def get_cloudzero_settings(
# Get CloudZero settings using the accessor method
settings = await _get_cloudzero_settings()
# If settings are empty, return null/empty values (consistent with other endpoints)
if not settings:
return CloudZeroSettingsView(
api_key_masked=None,
connection_id=None,
timezone=None,
status=None,
)
# Use SensitiveDataMasker to mask the API key
masked_settings = _sensitive_masker.mask_dict(settings)
return CloudZeroSettingsView(
api_key_masked=masked_settings["api_key"],
connection_id=settings["connection_id"],
timezone=settings["timezone"],
api_key_masked=masked_settings.get("api_key"),
connection_id=settings.get("connection_id"),
timezone=settings.get("timezone"),
status="configured",
)
except HTTPException as e:
if e.status_code == 400:
# Settings not configured
raise HTTPException(
status_code=404, detail={"error": "CloudZero settings not configured"}
)
# Re-raise HTTPExceptions as-is
raise e
except Exception as e:
verbose_proxy_logger.error(f"Error retrieving CloudZero settings: {str(e)}")

View file

@ -45,10 +45,10 @@ class CloudZeroExportResponse(BaseModel):
class CloudZeroSettingsView(BaseModel):
"""Response model for viewing CloudZero settings with masked API key"""
api_key_masked: str = Field(..., description="Masked API key showing only first 4 and last 4 characters")
connection_id: str = Field(..., description="CloudZero connection ID for data submission")
timezone: str = Field(..., description="Timezone for date handling")
status: str = Field(..., description="Configuration status")
api_key_masked: Optional[str] = Field(None, description="Masked API key showing only first 4 and last 4 characters")
connection_id: Optional[str] = Field(None, description="CloudZero connection ID for data submission")
timezone: Optional[str] = Field(None, description="Timezone for date handling")
status: Optional[str] = Field(None, description="Configuration status")
class CloudZeroSettingsUpdate(BaseModel):

View file

@ -1,6 +1,6 @@
import os
import sys
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
@ -77,3 +77,112 @@ async def test_delete_cloudzero_settings_not_found(client, monkeypatch):
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_get_cloudzero_settings_success(client, monkeypatch):
"""Test GET /cloudzero/settings returns settings when configured"""
mock_config = MagicMock()
mock_config.param_name = "cloudzero_settings"
mock_config.param_value = {
"api_key": "encrypted_key",
"connection_id": "conn_123",
"timezone": "UTC"
}
mock_litellm_config = MagicMock()
mock_litellm_config.find_first = AsyncMock(return_value=mock_config)
mock_prisma = MagicMock()
mock_prisma.db = MagicMock()
mock_prisma.db.litellm_config = mock_litellm_config
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
# Mock the decrypt function to return a decrypted key
with patch("litellm.proxy.spend_tracking.cloudzero_endpoints.decrypt_value_helper") as mock_decrypt:
mock_decrypt.return_value = "decrypted_api_key"
# Mock the masker
with patch("litellm.proxy.spend_tracking.cloudzero_endpoints._sensitive_masker") as mock_masker:
mock_masker.mask_dict.return_value = {"api_key": "test****key"}
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
response = client.get("/cloudzero/settings")
assert response.status_code == 200
data = response.json()
assert data["connection_id"] == "conn_123"
assert data["timezone"] == "UTC"
assert data["status"] == "configured"
assert data["api_key_masked"] == "test****key"
mock_litellm_config.find_first.assert_awaited_once()
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_get_cloudzero_settings_not_configured(client, monkeypatch):
"""Test GET /cloudzero/settings returns 200 with null values when not configured (consistent with other endpoints)"""
mock_litellm_config = MagicMock()
mock_litellm_config.find_first = AsyncMock(return_value=None)
mock_prisma = MagicMock()
mock_prisma.db = MagicMock()
mock_prisma.db.litellm_config = mock_litellm_config
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
response = client.get("/cloudzero/settings")
# Should return 200 with null values (not 404) - consistent with other settings endpoints
assert response.status_code == 200
data = response.json()
assert data["api_key_masked"] is None
assert data["connection_id"] is None
assert data["timezone"] is None
assert data["status"] is None
mock_litellm_config.find_first.assert_awaited_once()
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_get_cloudzero_settings_empty_param_value(client, monkeypatch):
"""Test GET /cloudzero/settings returns 200 with null values when param_value is None"""
mock_config = MagicMock()
mock_config.param_name = "cloudzero_settings"
mock_config.param_value = None
mock_litellm_config = MagicMock()
mock_litellm_config.find_first = AsyncMock(return_value=mock_config)
mock_prisma = MagicMock()
mock_prisma.db = MagicMock()
mock_prisma.db.litellm_config = mock_litellm_config
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
response = client.get("/cloudzero/settings")
# Should return 200 with null values (not 404) - consistent with other settings endpoints
assert response.status_code == 200
data = response.json()
assert data["api_key_masked"] is None
assert data["connection_id"] is None
assert data["timezone"] is None
assert data["status"] is None
mock_litellm_config.find_first.assert_awaited_once()
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)