mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
Merge pull request #18263 from BerriAI/litellm_ui_cloudzero_improvements
[Feature] UI - Improve Create and Delete Path for CloudZero
This commit is contained in:
commit
6dc11deeac
9 changed files with 208 additions and 39 deletions
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue