Files
lijiaoqiao/llm-gateway-competitors/litellm-wheel-src/litellm/proxy/spend_tracking/cloudzero_endpoints.py
2026-03-26 16:04:46 +08:00

580 lines
19 KiB
Python

import json
from fastapi import APIRouter, Depends, HTTPException
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
from litellm.types.proxy.cloudzero_endpoints import (
CloudZeroExportRequest,
CloudZeroExportResponse,
CloudZeroInitRequest,
CloudZeroInitResponse,
CloudZeroSettingsUpdate,
CloudZeroSettingsView,
)
router = APIRouter()
# Initialize the sensitive data masker for API key masking
_sensitive_masker = SensitiveDataMasker()
async def _set_cloudzero_settings(api_key: str, connection_id: str, timezone: str):
"""
Store CloudZero settings in the database with encrypted API key.
Args:
api_key: CloudZero API key to encrypt and store
connection_id: CloudZero connection ID
timezone: Timezone for date handling
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
# Encrypt the API key before storing
encrypted_api_key = encrypt_value_helper(api_key)
cloudzero_settings = {
"api_key": encrypted_api_key,
"connection_id": connection_id,
"timezone": timezone,
}
await prisma_client.db.litellm_config.upsert(
where={"param_name": "cloudzero_settings"},
data={
"create": {
"param_name": "cloudzero_settings",
"param_value": json.dumps(cloudzero_settings),
},
"update": {"param_value": json.dumps(cloudzero_settings)},
},
)
async def _get_cloudzero_settings():
"""
Retrieve CloudZero settings from the database with decrypted API key.
Returns:
dict: CloudZero settings with decrypted API key, or empty dict if not configured
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
cloudzero_config = await prisma_client.db.litellm_config.find_first(
where={"param_name": "cloudzero_settings"}
)
if cloudzero_config is None or cloudzero_config.param_value is None:
return {}
# 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")
if encrypted_api_key:
decrypted_api_key = decrypt_value_helper(
encrypted_api_key, key="cloudzero_api_key", exception_type="error"
)
if decrypted_api_key is None:
raise HTTPException(
status_code=500,
detail={
"error": "Failed to decrypt CloudZero API key. Check your salt key configuration."
},
)
settings["api_key"] = decrypted_api_key
return settings
@router.get(
"/cloudzero/settings",
tags=["CloudZero"],
dependencies=[Depends(user_api_key_auth)],
response_model=CloudZeroSettingsView,
)
async def get_cloudzero_settings(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
View current 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.
"""
# Validation
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={"error": CommonProxyErrors.not_allowed_access.value},
)
try:
# 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.get("api_key"),
connection_id=settings.get("connection_id"),
timezone=settings.get("timezone"),
status="configured",
)
except HTTPException as e:
# Re-raise HTTPExceptions as-is
raise e
except Exception as e:
verbose_proxy_logger.error(f"Error retrieving CloudZero settings: {str(e)}")
raise HTTPException(
status_code=500,
detail={"error": f"Failed to retrieve CloudZero settings: {str(e)}"},
)
@router.put(
"/cloudzero/settings",
tags=["CloudZero"],
dependencies=[Depends(user_api_key_auth)],
response_model=CloudZeroInitResponse,
)
async def update_cloudzero_settings(
request: CloudZeroSettingsUpdate,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Update existing CloudZero settings.
Allows updating individual CloudZero configuration fields without requiring all fields.
Only provided fields will be updated; others will remain unchanged.
Parameters:
- api_key: (Optional) New CloudZero API key for authentication
- connection_id: (Optional) New CloudZero connection ID for data submission
- timezone: (Optional) New timezone for date handling
Only admin users can update CloudZero settings.
"""
# Validation
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={"error": CommonProxyErrors.not_allowed_access.value},
)
# Check if at least one field is provided
if not any([request.api_key, request.connection_id, request.timezone]):
raise HTTPException(
status_code=400,
detail={"error": "At least one field must be provided for update"},
)
try:
# Get current settings
current_settings = await _get_cloudzero_settings()
# Update only provided fields
updated_api_key = (
request.api_key
if request.api_key is not None
else current_settings["api_key"]
)
updated_connection_id = (
request.connection_id
if request.connection_id is not None
else current_settings["connection_id"]
)
updated_timezone = (
request.timezone
if request.timezone is not None
else current_settings["timezone"]
)
# Store updated settings using the setter method with encryption
await _set_cloudzero_settings(
api_key=updated_api_key,
connection_id=updated_connection_id,
timezone=updated_timezone,
)
verbose_proxy_logger.info("CloudZero settings updated successfully")
return CloudZeroInitResponse(
message="CloudZero settings updated successfully", status="success"
)
except HTTPException as e:
if e.status_code == 400:
# Settings not configured yet
raise HTTPException(
status_code=404,
detail={
"error": "CloudZero settings not found. Please initialize settings first using /cloudzero/init"
},
)
raise e
except Exception as e:
verbose_proxy_logger.error(f"Error updating CloudZero settings: {str(e)}")
raise HTTPException(
status_code=500,
detail={"error": f"Failed to update CloudZero settings: {str(e)}"},
)
# Global variable to track if CloudZero background job has been initialized
_cloudzero_background_job_initialized = False
async def is_cloudzero_setup_in_db() -> bool:
"""
Check if CloudZero is setup in the database.
CloudZero is considered setup in the database if:
- CloudZero settings exist in the database
- The settings have a non-None value
Returns:
bool: True if CloudZero is active, False otherwise
"""
try:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
return False
# Check for CloudZero settings in database
cloudzero_config = await prisma_client.db.litellm_config.find_first(
where={"param_name": "cloudzero_settings"}
)
# CloudZero is setup in the database if config exists and has non-None value
return cloudzero_config is not None and cloudzero_config.param_value is not None
except Exception as e:
verbose_proxy_logger.error(f"Error checking CloudZero status: {str(e)}")
return False
def is_cloudzero_setup_in_config() -> bool:
"""
Check if CloudZero is setup in config.yaml or environment variables.
CloudZero is considered setup in config if:
- "cloudzero" is in the callbacks list in config.yaml, OR
Returns:
bool: True if CloudZero is configured, False otherwise
"""
import litellm
return "cloudzero" in litellm.callbacks
async def is_cloudzero_setup() -> bool:
"""
Check if CloudZero is setup in either config.yaml/env vars OR database.
CloudZero is considered setup if:
- CloudZero is configured in config.yaml callbacks, OR
- CloudZero environment variables are set, OR
- CloudZero settings exist in the database
Returns:
bool: True if CloudZero is configured anywhere, False otherwise
"""
try:
# Check config.yaml/environment variables first
if is_cloudzero_setup_in_config():
return True
# Check database as fallback
if await is_cloudzero_setup_in_db():
return True
return False
except Exception as e:
verbose_proxy_logger.error(f"Error checking CloudZero setup: {str(e)}")
return False
@router.post(
"/cloudzero/init",
tags=["CloudZero"],
dependencies=[Depends(user_api_key_auth)],
response_model=CloudZeroInitResponse,
)
async def init_cloudzero_settings(
request: CloudZeroInitRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Initialize CloudZero settings and store in the database.
This endpoint stores the CloudZero API key, connection ID, and timezone configuration
in the proxy database for use by the CloudZero logger.
Parameters:
- api_key: CloudZero API key for authentication
- connection_id: CloudZero connection ID for data submission
- timezone: Timezone for date handling (default: UTC)
Only admin users can configure CloudZero settings.
"""
# Validation
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={"error": CommonProxyErrors.not_allowed_access.value},
)
try:
# Store settings using the setter method with encryption
await _set_cloudzero_settings(
api_key=request.api_key,
connection_id=request.connection_id,
timezone=request.timezone,
)
verbose_proxy_logger.info("CloudZero settings initialized successfully")
return CloudZeroInitResponse(
message="CloudZero settings initialized successfully", status="success"
)
except Exception as e:
verbose_proxy_logger.error(f"Error initializing CloudZero settings: {str(e)}")
raise HTTPException(
status_code=500,
detail={"error": f"Failed to initialize CloudZero settings: {str(e)}"},
)
@router.post(
"/cloudzero/dry-run",
tags=["CloudZero"],
dependencies=[Depends(user_api_key_auth)],
response_model=CloudZeroExportResponse,
)
async def cloudzero_dry_run_export(
request: CloudZeroExportRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Perform a dry run export using the CloudZero logger.
This endpoint uses the CloudZero logger to perform a dry run export,
which returns the data that would be exported without actually sending it to CloudZero.
Parameters:
- limit: Optional limit on number of records to process (default: 10000)
Returns:
- usage_data: Sample of the raw usage data (first 50 records)
- cbf_data: CloudZero CBF formatted data ready for export
- summary: Statistics including total cost, tokens, and record counts
Only admin users can perform CloudZero exports.
"""
# Validation
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={"error": CommonProxyErrors.not_allowed_access.value},
)
try:
# Import and initialize CloudZero logger with credentials
from litellm.integrations.cloudzero.cloudzero import CloudZeroLogger
# Initialize logger with credentials directly
logger = CloudZeroLogger()
dry_run_result = await logger.dry_run_export_usage_data(limit=request.limit)
verbose_proxy_logger.info("CloudZero dry run export completed successfully")
return CloudZeroExportResponse(
message="CloudZero dry run export completed successfully.",
status="success",
dry_run_data=dry_run_result,
summary=dry_run_result.get("summary") if dry_run_result else None,
)
except Exception as e:
verbose_proxy_logger.error(
f"Error performing CloudZero dry run export: {str(e)}"
)
raise HTTPException(
status_code=500,
detail={"error": f"Failed to perform CloudZero dry run export: {str(e)}"},
)
@router.post(
"/cloudzero/export",
tags=["CloudZero"],
dependencies=[Depends(user_api_key_auth)],
response_model=CloudZeroExportResponse,
)
async def cloudzero_export(
request: CloudZeroExportRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Perform an actual export using the CloudZero logger.
This endpoint uses the CloudZero logger to export usage data to CloudZero AnyCost API.
Parameters:
- limit: Optional limit on number of records to export
- operation: CloudZero operation type ("replace_hourly" or "sum", default: "replace_hourly")
Only admin users can perform CloudZero exports.
"""
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={"error": CommonProxyErrors.not_allowed_access.value},
)
try:
# Get CloudZero settings using the accessor method with decryption
settings = await _get_cloudzero_settings()
# Import and initialize CloudZero logger with credentials
from litellm.integrations.cloudzero.cloudzero import CloudZeroLogger
# Initialize logger with credentials directly
logger = CloudZeroLogger(
api_key=settings.get("api_key"),
connection_id=settings.get("connection_id"),
timezone=settings.get("timezone"),
)
await logger.export_usage_data(
limit=request.limit,
operation=request.operation,
start_time_utc=request.start_time_utc,
end_time_utc=request.end_time_utc,
)
verbose_proxy_logger.info("CloudZero export completed successfully")
return CloudZeroExportResponse(
message="CloudZero export completed successfully",
status="success",
dry_run_data=None,
summary=None,
)
except Exception as e:
verbose_proxy_logger.error(f"Error performing CloudZero export: {str(e)}")
raise HTTPException(
status_code=500,
detail={"error": f"Failed to perform CloudZero export: {str(e)}"},
)
@router.delete(
"/cloudzero/delete",
tags=["CloudZero"],
dependencies=[Depends(user_api_key_auth)],
response_model=CloudZeroInitResponse,
)
async def delete_cloudzero_settings(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Delete CloudZero settings from the database.
This endpoint removes the CloudZero configuration (API key, connection ID, timezone)
from the proxy database. Only the CloudZero settings entry will be deleted;
other configuration values in the database will remain unchanged.
Only admin users can delete CloudZero settings.
"""
# Validation
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={"error": CommonProxyErrors.not_allowed_access.value},
)
try:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
# Check if CloudZero settings exist
cloudzero_config = await prisma_client.db.litellm_config.find_first(
where={"param_name": "cloudzero_settings"}
)
if cloudzero_config is None:
raise HTTPException(
status_code=404,
detail={"error": "CloudZero settings not found"},
)
# Delete only the CloudZero settings entry
# This uses a specific where clause to target only the cloudzero_settings row
await prisma_client.db.litellm_config.delete(
where={"param_name": "cloudzero_settings"}
)
verbose_proxy_logger.info("CloudZero settings deleted successfully")
return CloudZeroInitResponse(
message="CloudZero settings deleted successfully", status="success"
)
except HTTPException as e:
raise e
except Exception as e:
verbose_proxy_logger.error(f"Error deleting CloudZero settings: {str(e)}")
raise HTTPException(
status_code=500,
detail={"error": f"Failed to delete CloudZero settings: {str(e)}"},
)