Merge pull request #18263 from BerriAI/litellm_ui_cloudzero_improvements

[Feature] UI - Improve Create and Delete Path for CloudZero
This commit is contained in:
yuneng-jiang 2025-12-22 10:43:17 -08:00 committed by GitHub
commit 6dc11deeac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 208 additions and 39 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)

View file

@ -17,19 +17,35 @@ const getCloudZeroSettings = async (accessToken: string): Promise<CloudZeroSetti
},
});
if (response.status === 404) {
// 404 means no settings are configured - this is expected and not an error
return null;
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
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<DeleteRespo
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
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);
}

View file

@ -36,7 +36,9 @@ export default function CloudZeroCostTracking() {
if (error) {
return (
<Card>
<Typography.Text className="text-red-600">Error loading CloudZero settings: {error.message}</Typography.Text>
<Typography.Text className="text-red-600">
Error loading CloudZero settings: {error instanceof Error ? error.message : String(error)}
</Typography.Text>
</Card>
);
}

View file

@ -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();
});
});

View file

@ -21,7 +21,7 @@ export default function CloudZeroEmptyPlaceholder({ startCreation }: CloudZeroEm
}
>
<Button type="primary" size="large" onClick={startCreation} className="flex items-center gap-2 mx-auto mt-4">
Create Integration
Add CloudZero Integration
</Button>
</Empty>
</div>

View file

@ -134,10 +134,14 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
}}
>
<Descriptions.Item label="API Key (Redacted)">
<span className="font-mono text-gray-600">{settings.api_key_masked}</span>
<span className="font-mono text-gray-600">
{settings.api_key_masked || <span className="text-gray-400 italic">Not configured</span>}
</span>
</Descriptions.Item>
<Descriptions.Item label="Connection ID">
<span className="font-mono text-gray-600">{settings.connection_id}</span>
<span className="font-mono text-gray-600">
{settings.connection_id || <span className="text-gray-400 italic">Not configured</span>}
</span>
</Descriptions.Item>
<Descriptions.Item label="Timezone">
{settings.timezone || <span className="text-gray-400 italic">Default (UTC)</span>}

View file

@ -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;
}