From e92db2d718a78dc0443c801cb8467e6a5160c725 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 19 Dec 2025 13:04:42 -0800 Subject: [PATCH 1/2] Improve e2e flow for cloudzero --- .../hooks/cloudzero/useCloudZeroSettings.ts | 70 +++++++++++++++---- .../CloudZeroCostTracking.tsx | 4 +- .../CloudZeroEmptyPlaceholder.test.tsx | 2 +- .../CloudZeroEmptyPlaceholder.tsx | 2 +- .../CloudZeroIntegrationSettings.tsx | 8 ++- .../components/CloudZeroCostTracking/types.ts | 8 +-- 6 files changed, 71 insertions(+), 23 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts index 5ccbe244e60..96f5ab2f944 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts @@ -17,19 +17,35 @@ const getCloudZeroSettings = async (accessToken: string): Promise ({})); - const errorMessage = - errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to fetch CloudZero settings"; + let errorMessage = "Failed to fetch CloudZero settings"; + try { + const errorData = await response.json(); + // Handle different error response formats + if (typeof errorData === "object" && errorData !== null) { + errorMessage = + errorData?.error?.message || + errorData?.error || + errorData?.message || + errorData?.detail || + (typeof errorData?.error === "string" ? errorData.error : errorMessage); + } else if (typeof errorData === "string") { + errorMessage = errorData; + } + } catch { + // If JSON parsing fails, use the status text + errorMessage = response.statusText || errorMessage; + } throw new Error(errorMessage); } const data = await response.json(); + + // Check if settings are actually configured (all required fields are present) + if (!data || (!data.api_key_masked && !data.connection_id)) { + return null; + } + return data; }; @@ -77,9 +93,22 @@ const updateCloudZeroSettings = async (accessToken: string, params: UpdateParams }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - const errorMessage = - errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to update CloudZero settings"; + let errorMessage = "Failed to update CloudZero settings"; + try { + const errorData = await response.json(); + if (typeof errorData === "object" && errorData !== null) { + errorMessage = + errorData?.error?.message || + errorData?.error || + errorData?.message || + errorData?.detail || + (typeof errorData?.error === "string" ? errorData.error : errorMessage); + } else if (typeof errorData === "string") { + errorMessage = errorData; + } + } catch { + errorMessage = response.statusText || errorMessage; + } throw new Error(errorMessage); } @@ -117,9 +146,22 @@ const deleteCloudZeroSettings = async (accessToken: string): Promise ({})); - const errorMessage = - errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to delete CloudZero settings"; + let errorMessage = "Failed to delete CloudZero settings"; + try { + const errorData = await response.json(); + if (typeof errorData === "object" && errorData !== null) { + errorMessage = + errorData?.error?.message || + errorData?.error || + errorData?.message || + errorData?.detail || + (typeof errorData?.error === "string" ? errorData.error : errorMessage); + } else if (typeof errorData === "string") { + errorMessage = errorData; + } + } catch { + errorMessage = response.statusText || errorMessage; + } throw new Error(errorMessage); } diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx index fbb892cb1d8..db3ea94bbf9 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx @@ -36,7 +36,9 @@ export default function CloudZeroCostTracking() { if (error) { return ( - Error loading CloudZero settings: {error.message} + + Error loading CloudZero settings: {error instanceof Error ? error.message : String(error)} + ); } diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.test.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.test.tsx index 04e0a67dea6..f7b90884006 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.test.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.test.tsx @@ -9,6 +9,6 @@ describe("CloudZeroEmptyPlaceholder", () => { expect(screen.getByText("No CloudZero Integration Found")).toBeInTheDocument(); expect(screen.getByText(/Connect your CloudZero account/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Create Integration" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add CloudZero Integration" })).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx index 1719a949b86..aca074dc290 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx @@ -21,7 +21,7 @@ export default function CloudZeroEmptyPlaceholder({ startCreation }: CloudZeroEm } > diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx index 780fa83652a..c161d241f7d 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx @@ -134,10 +134,14 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl }} > - {settings.api_key_masked} + + {settings.api_key_masked || Not configured} + - {settings.connection_id} + + {settings.connection_id || Not configured} + {settings.timezone || Default (UTC)} diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/types.ts b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/types.ts index a41afee4f72..ed3c76cc3b1 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/types.ts +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/types.ts @@ -1,6 +1,6 @@ export interface CloudZeroSettings { - api_key_masked: string; - connection_id: string; - timezone?: string; - status?: string; + api_key_masked: string | null; + connection_id: string | null; + timezone?: string | null; + status?: string | null; } From 74842de78eddfdea22448b85aec9a0974b28a36f Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 19 Dec 2025 13:04:59 -0800 Subject: [PATCH 2/2] Adding backend --- .../spend_tracking/cloudzero_endpoints.py | 34 ++++-- litellm/types/proxy/cloudzero_endpoints.py | 8 +- .../test_cloudzero_endpoints.py | 111 +++++++++++++++++- 3 files changed, 137 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/spend_tracking/cloudzero_endpoints.py b/litellm/proxy/spend_tracking/cloudzero_endpoints.py index 2cf4ce8f16a..172169f2c7a 100644 --- a/litellm/proxy/spend_tracking/cloudzero_endpoints.py +++ b/litellm/proxy/spend_tracking/cloudzero_endpoints.py @@ -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)}") diff --git a/litellm/types/proxy/cloudzero_endpoints.py b/litellm/types/proxy/cloudzero_endpoints.py index 1d909bf7f8c..fc48717e80a 100644 --- a/litellm/types/proxy/cloudzero_endpoints.py +++ b/litellm/types/proxy/cloudzero_endpoints.py @@ -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): diff --git a/tests/test_litellm/proxy/spend_tracking/test_cloudzero_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_cloudzero_endpoints.py index 8ff5774bf50..6d460f63332 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_cloudzero_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_cloudzero_endpoints.py @@ -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) +