更新litemll

This commit is contained in:
zhanggangyong
2026-01-07 14:46:15 +00:00
parent bb719db8ed
commit 9f2ed20fa9
16 changed files with 2601 additions and 1523 deletions
+525
View File
@@ -0,0 +1,525 @@
"""
LiteLLM Admin API 客户端
用于与 LiteLLM Gateway 进行交互,管理 team 和 key。
职责划分:
- mcp-server: 业务规则制定者(决定谁能用什么模型、配额多少)
- litellm-gateway: 规则执行者(真正拦截超额请求)
概念映射:
- 渠道 (Channel) = LiteLLM team
- 租户 (Tenant) = LiteLLM key(归属于 team)
"""
import logging
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
import httpx
from cryptography.fernet import Fernet
import base64
import hashlib
from config import settings
logger = logging.getLogger(__name__)
@dataclass
class LiteLLMTeam:
"""LiteLLM Team 信息"""
team_id: str
team_alias: str
metadata: Dict[str, Any]
@dataclass
class LiteLLMKey:
"""LiteLLM Key 信息"""
key: str # 完整的 API key
key_name: Optional[str] = None
team_id: Optional[str] = None
models: Optional[List[str]] = None
rpm_limit: Optional[int] = None
tpm_limit: Optional[int] = None
max_budget: Optional[float] = None
budget_duration: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
class LiteLLMClientError(Exception):
"""LiteLLM 客户端错误"""
def __init__(self, message: str, status_code: Optional[int] = None, response: Optional[Dict] = None):
super().__init__(message)
self.status_code = status_code
self.response = response
class LiteLLMClient:
"""LiteLLM Admin API 客户端
用于管理 LiteLLM 的 team 和 key。
使用示例:
```python
client = LiteLLMClient()
# 创建渠道时,同步创建 LiteLLM team
team = await client.create_team(
team_alias=f"channel-{channel_id}",
metadata={"channel_id": str(channel_id), "channel_name": channel_name}
)
# 分配模型给租户时,创建 LiteLLM key
key = await client.generate_key(
team_id=team.team_id,
models=["azure/gpt-4"],
rpm_limit=60,
tpm_limit=10000,
max_budget=100.0,
metadata={"tenant_id": str(tenant_id)}
)
# 充值时,更新 key 的 budget
await client.update_key(
key=key.key,
max_budget=200.0
)
```
"""
def __init__(
self,
base_url: Optional[str] = None,
master_key: Optional[str] = None,
timeout: float = 30.0
):
"""初始化 LiteLLM 客户端
Args:
base_url: LiteLLM Gateway URL,默认从配置读取
master_key: LiteLLM Master Key,默认从配置读取
timeout: 请求超时时间(秒)
"""
self.base_url = (base_url or settings.litellm_url).rstrip("/")
self.master_key = master_key or settings.litellm_api_key
self.timeout = timeout
# 初始化加密器(用于加密存储 key)
self._init_encryption()
def _init_encryption(self):
"""初始化加密器"""
# 使用配置的加密密钥生成 Fernet key
key_bytes = settings.encryption_key.encode()
# 使用 SHA256 生成 32 字节的 key,然后 base64 编码
key_hash = hashlib.sha256(key_bytes).digest()
fernet_key = base64.urlsafe_b64encode(key_hash)
self._fernet = Fernet(fernet_key)
def encrypt_key(self, key: str) -> str:
"""加密 API Key
Args:
key: 原始 API key
Returns:
加密后的 key(base64 编码)
"""
return self._fernet.encrypt(key.encode()).decode()
def decrypt_key(self, encrypted_key: str) -> str:
"""解密 API Key
Args:
encrypted_key: 加密的 key
Returns:
原始 API key
"""
return self._fernet.decrypt(encrypted_key.encode()).decode()
async def _request(
self,
method: str,
endpoint: str,
json: Optional[Dict] = None,
params: Optional[Dict] = None
) -> Dict[str, Any]:
"""发送 HTTP 请求到 LiteLLM
Args:
method: HTTP 方法
endpoint: API 端点(不含 base_url)
json: 请求体
params: 查询参数
Returns:
响应 JSON
Raises:
LiteLLMClientError: 请求失败时抛出
"""
url = f"{self.base_url}{endpoint}"
headers = {
"Authorization": f"Bearer {self.master_key}",
"Content-Type": "application/json"
}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.request(
method=method,
url=url,
json=json,
params=params,
headers=headers
)
if response.status_code >= 400:
error_data = None
try:
error_data = response.json()
except Exception:
pass
logger.error(
f"LiteLLM API 错误: {method} {endpoint} -> {response.status_code}",
extra={"response": error_data}
)
raise LiteLLMClientError(
message=f"LiteLLM API 错误: {response.status_code}",
status_code=response.status_code,
response=error_data
)
return response.json()
except httpx.TimeoutException as e:
logger.error(f"LiteLLM 请求超时: {method} {endpoint}")
raise LiteLLMClientError(f"请求超时: {str(e)}")
except httpx.RequestError as e:
logger.error(f"LiteLLM 请求错误: {method} {endpoint} -> {str(e)}")
raise LiteLLMClientError(f"请求错误: {str(e)}")
# ==================== Team 管理 ====================
async def create_team(
self,
team_alias: str,
metadata: Optional[Dict[str, Any]] = None,
models: Optional[List[str]] = None,
max_budget: Optional[float] = None
) -> LiteLLMTeam:
"""创建 LiteLLM Team
创建渠道时调用此方法,同步创建 LiteLLM team。
Args:
team_alias: Team 别名(建议使用 channel-{channel_id})
metadata: 元数据(如 channel_id, channel_name)
models: 允许的模型列表(可选,通常在 key 级别限制)
max_budget: 最大预算(可选)
Returns:
LiteLLMTeam 对象
"""
payload = {
"team_alias": team_alias,
}
if metadata:
payload["metadata"] = metadata
if models:
payload["models"] = models
if max_budget is not None:
payload["max_budget"] = max_budget
logger.info(f"创建 LiteLLM Team: {team_alias}")
data = await self._request("POST", "/team/new", json=payload)
return LiteLLMTeam(
team_id=data.get("team_id"),
team_alias=team_alias,
metadata=metadata or {}
)
async def get_team(self, team_id: str) -> Dict[str, Any]:
"""获取 Team 信息
Args:
team_id: Team ID
Returns:
Team 信息
"""
return await self._request("GET", f"/team/info", params={"team_id": team_id})
async def update_team(
self,
team_id: str,
team_alias: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
models: Optional[List[str]] = None,
max_budget: Optional[float] = None
) -> Dict[str, Any]:
"""更新 Team 信息
Args:
team_id: Team ID
team_alias: 新的别名
metadata: 新的元数据
models: 新的模型列表
max_budget: 新的最大预算
Returns:
更新后的 Team 信息
"""
payload = {"team_id": team_id}
if team_alias:
payload["team_alias"] = team_alias
if metadata:
payload["metadata"] = metadata
if models:
payload["models"] = models
if max_budget is not None:
payload["max_budget"] = max_budget
logger.info(f"更新 LiteLLM Team: {team_id}")
return await self._request("POST", "/team/update", json=payload)
async def delete_team(self, team_id: str) -> Dict[str, Any]:
"""删除 Team
删除渠道时调用此方法,同步删除 LiteLLM team。
Args:
team_id: Team ID
Returns:
删除结果
"""
logger.info(f"删除 LiteLLM Team: {team_id}")
return await self._request("POST", "/team/delete", json={"team_ids": [team_id]})
# ==================== Key 管理 ====================
async def generate_key(
self,
team_id: str,
models: List[str],
rpm_limit: Optional[int] = None,
tpm_limit: Optional[int] = None,
max_budget: Optional[float] = None,
budget_duration: Optional[str] = "monthly",
key_name: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> LiteLLMKey:
"""生成 LiteLLM API Key
分配模型给租户时调用此方法,创建绑定 team + model + 配额的 key。
Args:
team_id: Team ID(渠道的 litellm_team_id)
models: 允许的模型列表(如 ["azure/gpt-4"])
rpm_limit: 每分钟请求数限制
tpm_limit: 每分钟 Token 数限制
max_budget: 最大预算
budget_duration: 预算周期(monthly, total)
key_name: Key 名称
metadata: 元数据(如 tenant_id, channel_id, model)
Returns:
LiteLLMKey 对象
"""
payload = {
"team_id": team_id,
"models": models,
}
if rpm_limit is not None:
payload["rpm_limit"] = rpm_limit
if tpm_limit is not None:
payload["tpm_limit"] = tpm_limit
if max_budget is not None:
payload["max_budget"] = max_budget
if budget_duration:
payload["budget_duration"] = budget_duration
if key_name:
payload["key_name"] = key_name
if metadata:
payload["metadata"] = metadata
logger.info(f"生成 LiteLLM Key: team={team_id}, models={models}")
data = await self._request("POST", "/key/generate", json=payload)
return LiteLLMKey(
key=data.get("key"),
key_name=key_name,
team_id=team_id,
models=models,
rpm_limit=rpm_limit,
tpm_limit=tpm_limit,
max_budget=max_budget,
budget_duration=budget_duration,
metadata=metadata
)
async def get_key_info(self, key: str) -> Dict[str, Any]:
"""获取 Key 信息
Args:
key: API Key
Returns:
Key 信息
"""
return await self._request("GET", "/key/info", params={"key": key})
async def update_key(
self,
key: str,
models: Optional[List[str]] = None,
rpm_limit: Optional[int] = None,
tpm_limit: Optional[int] = None,
max_budget: Optional[float] = None,
budget_duration: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""更新 Key 配置
修改配额或充值时调用此方法。
Args:
key: API Key
models: 新的模型列表
rpm_limit: 新的 RPM 限制
tpm_limit: 新的 TPM 限制
max_budget: 新的最大预算
budget_duration: 新的预算周期
metadata: 新的元数据
Returns:
更新结果
"""
payload = {"key": key}
if models is not None:
payload["models"] = models
if rpm_limit is not None:
payload["rpm_limit"] = rpm_limit
if tpm_limit is not None:
payload["tpm_limit"] = tpm_limit
if max_budget is not None:
payload["max_budget"] = max_budget
if budget_duration is not None:
payload["budget_duration"] = budget_duration
if metadata is not None:
payload["metadata"] = metadata
logger.info(f"更新 LiteLLM Key: {key[:20]}...")
return await self._request("POST", "/key/update", json=payload)
async def delete_key(self, key: str) -> Dict[str, Any]:
"""删除 Key
取消模型分配时调用此方法。
Args:
key: API Key
Returns:
删除结果
"""
logger.info(f"删除 LiteLLM Key: {key[:20]}...")
return await self._request("POST", "/key/delete", json={"keys": [key]})
# ==================== 用量查询 ====================
async def get_spend_logs(
self,
api_key: Optional[str] = None,
team_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None
) -> Dict[str, Any]:
"""查询用量日志
Args:
api_key: 按 API Key 过滤
team_id: 按 Team ID 过滤
start_date: 开始日期(YYYY-MM-DD)
end_date: 结束日期(YYYY-MM-DD)
Returns:
用量日志
"""
params = {}
if api_key:
params["api_key"] = api_key
if team_id:
params["team_id"] = team_id
if start_date:
params["start_date"] = start_date
if end_date:
params["end_date"] = end_date
return await self._request("GET", "/spend/logs", params=params)
async def get_key_spend(self, key: str) -> Dict[str, Any]:
"""获取 Key 的消费统计
Args:
key: API Key
Returns:
消费统计
"""
return await self._request("GET", "/key/info", params={"key": key})
# ==================== 健康检查 ====================
async def health_check(self) -> bool:
"""检查 LiteLLM Gateway 健康状态
Returns:
是否健康
"""
try:
await self._request("GET", "/health")
return True
except LiteLLMClientError:
return False
async def get_models(self) -> List[Dict[str, Any]]:
"""获取可用模型列表
Returns:
模型列表
"""
data = await self._request("GET", "/model/info")
return data.get("data", [])
# 全局客户端实例
_litellm_client: Optional[LiteLLMClient] = None
def get_litellm_client() -> LiteLLMClient:
"""获取 LiteLLM 客户端单例
Returns:
LiteLLMClient 实例
"""
global _litellm_client
if _litellm_client is None:
_litellm_client = LiteLLMClient()
return _litellm_client
+68 -6
View File
@@ -792,6 +792,8 @@ async def create_channel(
):
"""
创建渠道(super_admin 和 billing_admin 可用)
同时在 LiteLLM 中创建对应的 team,用于管理该渠道下租户的模型访问权限。
"""
_verify_write_permission(principal)
@@ -822,12 +824,49 @@ async def create_channel(
await db.commit()
await db.refresh(channel)
# 在 LiteLLM 中创建对应的 team
litellm_team_id = None
litellm_error = None
try:
from app.litellm_client import get_litellm_client, LiteLLMClientError
litellm_client = get_litellm_client()
team = await litellm_client.create_team(
team_alias=f"channel-{channel.id}",
metadata={
"channel_id": str(channel.id),
"channel_name": channel.name,
"channel_email": channel.email,
}
)
# 保存 LiteLLM team_id 到渠道记录
channel.litellm_team_id = team.team_id
await db.commit()
litellm_team_id = team.team_id
logger.info(f"渠道 {channel.name} 创建成功,LiteLLM team_id: {team.team_id}")
except LiteLLMClientError as e:
# LiteLLM 创建失败,记录错误但不影响渠道创建
litellm_error = str(e)
logger.warning(f"创建渠道 {channel.name} 时 LiteLLM team 创建失败: {e}")
except Exception as e:
litellm_error = str(e)
logger.warning(f"创建渠道 {channel.name} 时 LiteLLM 连接失败: {e}")
response_data = {
"id": str(channel.id),
"name": channel.name,
"email": channel.email,
"litellmTeamId": litellm_team_id,
}
if litellm_error:
response_data["litellmWarning"] = f"LiteLLM team 创建失败: {litellm_error}"
return SuccessResponse(
data={
"id": str(channel.id),
"name": channel.name,
"email": channel.email,
},
data=response_data,
message="渠道创建成功"
)
@@ -904,6 +943,8 @@ async def delete_channel(
):
"""
删除渠道(软删除,super_admin 和 billing_admin 可用)
同时删除 LiteLLM 中对应的 team。
"""
_verify_write_permission(principal)
@@ -937,12 +978,33 @@ async def delete_channel(
detail=f"渠道下有 {tenant_count} 个活跃租户,无法删除。请先移除或停用所有租户。"
)
# 删除 LiteLLM 中对应的 team
litellm_error = None
if channel.litellm_team_id:
try:
from app.litellm_client import get_litellm_client, LiteLLMClientError
litellm_client = get_litellm_client()
await litellm_client.delete_team(channel.litellm_team_id)
logger.info(f"渠道 {channel.name} 的 LiteLLM team {channel.litellm_team_id} 已删除")
except LiteLLMClientError as e:
litellm_error = str(e)
logger.warning(f"删除渠道 {channel.name} 时 LiteLLM team 删除失败: {e}")
except Exception as e:
litellm_error = str(e)
logger.warning(f"删除渠道 {channel.name} 时 LiteLLM 连接失败: {e}")
# 软删除:标记为不活跃
channel.status = "inactive"
await db.commit()
response_data = {"id": str(channel.id)}
if litellm_error:
response_data["litellmWarning"] = f"LiteLLM team 删除失败: {litellm_error}"
return SuccessResponse(
data={"id": str(channel.id)},
data=response_data,
message="渠道已删除"
)
+589 -1
View File
@@ -16,7 +16,7 @@ from models import (
BillingRecord, RechargeRecord, Application, ModelProvider,
ChannelProviderAccess, ProviderApplication, TenantCustomAgentQuota,
ChannelCustomAgentQuota, ResourceApplication, PlatformAgentQuota,
AgentBillingRecord, PlatformAgentTemplateConfig
AgentBillingRecord, PlatformAgentTemplateConfig, TenantModelKey
)
from app.auth import require_auth, get_password_hash
from app.permissions import has_permission
@@ -1121,6 +1121,594 @@ async def get_tenant_custom_agent_quota(
)
# ============= 租户模型分配(LiteLLM 集成)=============
@router.put("/tenants/{tenant_id}/models", response_model=SuccessResponse)
async def allocate_model_to_tenant(
tenant_id: str,
model_name: str = Query(..., description="模型名称,如 azure/gpt-4"),
rpm_limit: int = Query(60, ge=0, description="每分钟请求数限制"),
tpm_limit: int = Query(10000, ge=0, description="每分钟 Token 数限制"),
max_budget: float = Query(100.0, ge=0, description="最大预算"),
budget_duration: str = Query("monthly", pattern="^(monthly|total)$", description="预算周期"),
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
分配模型给租户(创建 LiteLLM Key)
在 LiteLLM 中为租户创建 API Key,绑定指定的模型和配额。
租户的 Agent 启动时会使用此 Key 访问模型。
权限:manage:resources (channel_admin, billing_admin, super_admin)
注意:
- 渠道必须先拥有该模型的权限(通过 ResourceAllocation 分配)
- 每个租户每个模型只能有一个 Key
- 超级管理员必须提供 channel_id 参数
"""
_verify_permission(principal, "manage:resources")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
tenant_result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = tenant_result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 获取渠道信息
channel_result = await db.execute(
select(Channel).where(Channel.id == channel_id)
)
channel = channel_result.scalar_one_or_none()
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="渠道不存在"
)
# 验证渠道是否有该模型的权限
model_allocation_result = await db.execute(
select(ResourceAllocation).where(
and_(
ResourceAllocation.target_id == channel_id,
ResourceAllocation.target_type == "channel",
ResourceAllocation.resource_type == "model",
ResourceAllocation.resource_id == model_name
)
)
)
model_allocation = model_allocation_result.scalar_one_or_none()
if not model_allocation:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"渠道没有模型 '{model_name}' 的权限"
)
# 检查租户是否已有该模型的 Key
existing_key_result = await db.execute(
select(TenantModelKey).where(
and_(
TenantModelKey.tenant_id == tenant_id,
TenantModelKey.model_name == model_name
)
)
)
existing_key = existing_key_result.scalar_one_or_none()
if existing_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"租户已有模型 '{model_name}' 的 Key,请使用更新配额接口"
)
# 检查渠道是否有 LiteLLM team_id
if not channel.litellm_team_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="渠道尚未关联 LiteLLM team,请联系管理员"
)
# 在 LiteLLM 中创建 Key
try:
from app.litellm_client import get_litellm_client, LiteLLMClientError
litellm_client = get_litellm_client()
key = await litellm_client.generate_key(
team_id=channel.litellm_team_id,
models=[model_name],
rpm_limit=rpm_limit,
tpm_limit=tpm_limit,
max_budget=max_budget,
budget_duration=budget_duration,
key_name=f"tenant-{tenant_id}-{model_name}",
metadata={
"tenant_id": str(tenant_id),
"tenant_name": tenant.name,
"channel_id": str(channel_id),
"channel_name": channel.name,
"model": model_name,
}
)
# 加密存储 Key
encrypted_key = litellm_client.encrypt_key(key.key)
# 保存到数据库
tenant_key = TenantModelKey(
tenant_id=tenant_id,
channel_id=channel_id,
model_name=model_name,
litellm_key_id=key.key,
litellm_key_hash=encrypted_key,
rpm_limit=rpm_limit,
tpm_limit=tpm_limit,
max_budget=max_budget,
budget_duration=budget_duration,
status="active",
)
db.add(tenant_key)
# 同时记录到 ResourceAllocation
tenant_allocation = ResourceAllocation(
target_id=tenant_id,
target_type="tenant",
resource_type="model",
resource_id=model_name,
rpm=rpm_limit,
tpm=tpm_limit,
)
db.add(tenant_allocation)
await db.commit()
logger.info(
f"为租户 {tenant.name} 分配模型 {model_name} 成功",
extra={"tenant_id": tenant_id, "model": model_name}
)
return SuccessResponse(
data={
"tenantId": str(tenant_id),
"tenantName": tenant.name,
"modelName": model_name,
"rpmLimit": rpm_limit,
"tpmLimit": tpm_limit,
"maxBudget": max_budget,
"budgetDuration": budget_duration,
"status": "active",
},
message=f"模型 '{model_name}' 分配成功"
)
except LiteLLMClientError as e:
logger.error(f"LiteLLM Key 创建失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"LiteLLM Key 创建失败: {str(e)}"
)
except Exception as e:
logger.error(f"模型分配失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"模型分配失败: {str(e)}"
)
@router.delete("/tenants/{tenant_id}/models/{model_name}", response_model=SuccessResponse)
async def revoke_model_from_tenant(
tenant_id: str,
model_name: str,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
取消租户的模型分配(删除 LiteLLM Key)
删除租户在 LiteLLM 中的 API Key,租户将无法再使用该模型。
权限:manage:resources (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数
- 删除后租户正在运行的 Agent 将无法继续使用该模型
"""
_verify_permission(principal, "manage:resources")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
tenant_result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = tenant_result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 查找租户的模型 Key
key_result = await db.execute(
select(TenantModelKey).where(
and_(
TenantModelKey.tenant_id == tenant_id,
TenantModelKey.model_name == model_name
)
)
)
tenant_key = key_result.scalar_one_or_none()
if not tenant_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"租户没有模型 '{model_name}' 的分配记录"
)
# 在 LiteLLM 中删除 Key
litellm_error = None
try:
from app.litellm_client import get_litellm_client, LiteLLMClientError
litellm_client = get_litellm_client()
await litellm_client.delete_key(tenant_key.litellm_key_id)
logger.info(f"LiteLLM Key 删除成功: {tenant_key.litellm_key_id[:20]}...")
except LiteLLMClientError as e:
litellm_error = str(e)
logger.warning(f"LiteLLM Key 删除失败: {e}")
except Exception as e:
litellm_error = str(e)
logger.warning(f"LiteLLM 连接失败: {e}")
# 删除数据库记录
await db.delete(tenant_key)
# 删除 ResourceAllocation 记录
allocation_result = await db.execute(
select(ResourceAllocation).where(
and_(
ResourceAllocation.target_id == tenant_id,
ResourceAllocation.target_type == "tenant",
ResourceAllocation.resource_type == "model",
ResourceAllocation.resource_id == model_name
)
)
)
allocation = allocation_result.scalar_one_or_none()
if allocation:
await db.delete(allocation)
await db.commit()
response_data = {
"tenantId": str(tenant_id),
"tenantName": tenant.name,
"modelName": model_name,
}
if litellm_error:
response_data["litellmWarning"] = f"LiteLLM Key 删除失败: {litellm_error}"
return SuccessResponse(
data=response_data,
message=f"模型 '{model_name}' 分配已取消"
)
@router.put("/tenants/{tenant_id}/models/{model_name}/quota", response_model=SuccessResponse)
async def update_tenant_model_quota(
tenant_id: str,
model_name: str,
rpm_limit: Optional[int] = Query(None, ge=0, description="每分钟请求数限制"),
tpm_limit: Optional[int] = Query(None, ge=0, description="每分钟 Token 数限制"),
max_budget: Optional[float] = Query(None, ge=0, description="最大预算"),
budget_duration: Optional[str] = Query(None, pattern="^(monthly|total)$", description="预算周期"),
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
更新租户的模型配额(更新 LiteLLM Key)
更新租户在 LiteLLM 中的 API Key 配额,包括 RPM、TPM 和预算限制。
更新后立即生效,无需重启任何服务。
权限:manage:resources (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数
- 只更新提供的参数,未提供的参数保持不变
"""
_verify_permission(principal, "manage:resources")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
tenant_result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = tenant_result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 查找租户的模型 Key
key_result = await db.execute(
select(TenantModelKey).where(
and_(
TenantModelKey.tenant_id == tenant_id,
TenantModelKey.model_name == model_name
)
)
)
tenant_key = key_result.scalar_one_or_none()
if not tenant_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"租户没有模型 '{model_name}' 的分配记录"
)
# 在 LiteLLM 中更新 Key
try:
from app.litellm_client import get_litellm_client, LiteLLMClientError
litellm_client = get_litellm_client()
await litellm_client.update_key(
key=tenant_key.litellm_key_id,
rpm_limit=rpm_limit,
tpm_limit=tpm_limit,
max_budget=max_budget,
budget_duration=budget_duration,
)
# 更新数据库记录
if rpm_limit is not None:
tenant_key.rpm_limit = rpm_limit
if tpm_limit is not None:
tenant_key.tpm_limit = tpm_limit
if max_budget is not None:
tenant_key.max_budget = max_budget
if budget_duration is not None:
tenant_key.budget_duration = budget_duration
# 同时更新 ResourceAllocation
allocation_result = await db.execute(
select(ResourceAllocation).where(
and_(
ResourceAllocation.target_id == tenant_id,
ResourceAllocation.target_type == "tenant",
ResourceAllocation.resource_type == "model",
ResourceAllocation.resource_id == model_name
)
)
)
allocation = allocation_result.scalar_one_or_none()
if allocation:
if rpm_limit is not None:
allocation.rpm = rpm_limit
if tpm_limit is not None:
allocation.tpm = tpm_limit
await db.commit()
logger.info(
f"租户 {tenant.name} 的模型 {model_name} 配额更新成功",
extra={"tenant_id": tenant_id, "model": model_name}
)
return SuccessResponse(
data={
"tenantId": str(tenant_id),
"tenantName": tenant.name,
"modelName": model_name,
"rpmLimit": tenant_key.rpm_limit,
"tpmLimit": tenant_key.tpm_limit,
"maxBudget": float(tenant_key.max_budget) if tenant_key.max_budget else None,
"budgetDuration": tenant_key.budget_duration,
"status": tenant_key.status,
},
message="模型配额更新成功,立即生效"
)
except LiteLLMClientError as e:
logger.error(f"LiteLLM Key 更新失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"LiteLLM Key 更新失败: {str(e)}"
)
except Exception as e:
logger.error(f"配额更新失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"配额更新失败: {str(e)}"
)
@router.get("/tenants/{tenant_id}/models", response_model=SuccessResponse)
async def get_tenant_models(
tenant_id: str,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取租户的模型分配列表
返回租户已分配的所有模型及其配额信息。
权限:view:resources (channel_admin, billing_admin, operations_admin, super_admin)
"""
_verify_permission(principal, "view:resources")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
tenant_result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = tenant_result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 获取租户的所有模型 Key
keys_result = await db.execute(
select(TenantModelKey).where(
TenantModelKey.tenant_id == tenant_id
)
)
keys = keys_result.scalars().all()
data = []
for key in keys:
data.append({
"modelName": key.model_name,
"rpmLimit": key.rpm_limit,
"tpmLimit": key.tpm_limit,
"maxBudget": float(key.max_budget) if key.max_budget else None,
"budgetDuration": key.budget_duration,
"status": key.status,
"createdAt": key.created_at.isoformat() if key.created_at else None,
"updatedAt": key.updated_at.isoformat() if key.updated_at else None,
})
return SuccessResponse(
data={
"tenantId": str(tenant_id),
"tenantName": tenant.name,
"models": data,
}
)
# ============= 管理员管理 =============
@router.post("/admins/create", response_model=SuccessResponse)
+202 -8
View File
@@ -13,7 +13,7 @@ from database import get_db
from models import (
User, Agent, Tool, GatewayAPI, DataTemplate,
Workflow, BillingRecord, RechargeRecord, TenantCustomAgentQuota,
PlatformAgentQuota, AgentBillingRecord
PlatformAgentQuota, AgentBillingRecord, TenantModelKey
)
from app.billing import get_agent_billing_stats
from app.auth import require_auth
@@ -866,6 +866,155 @@ async def delete_user_workflow(
)
# ============= 模型使用(LiteLLM 集成)=============
@router.get("/models/available", response_model=SuccessResponse)
async def get_available_models(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取当前用户可用的模型列表
返回渠道分配给该租户的模型及其配额信息。
这些模型可以在创建 Agent 时使用。
"""
user_id = principal.get("user_id")
# 查询分配给该租户的模型 Key
result = await db.execute(
select(TenantModelKey).where(
and_(
TenantModelKey.tenant_id == user_id,
TenantModelKey.status == "active"
)
)
)
keys = result.scalars().all()
models = []
for key in keys:
models.append({
"modelName": key.model_name,
"rpmLimit": key.rpm_limit,
"tpmLimit": key.tpm_limit,
"maxBudget": float(key.max_budget) if key.max_budget else None,
"budgetDuration": key.budget_duration,
"status": key.status,
"allocatedAt": key.created_at.isoformat() if key.created_at else None,
})
return SuccessResponse(
data={
"models": models,
"count": len(models),
}
)
@router.get("/models/usage/stats", response_model=SuccessResponse)
async def get_model_usage_stats(
model_name: Optional[str] = Query(None, description="模型名称,不传则返回所有模型"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取模型使用统计
从 LiteLLM 获取用量数据,包括:
- 请求数
- Token 使用量
- 费用
"""
user_id = principal.get("user_id")
# 查询租户的模型 Key
query = select(TenantModelKey).where(
and_(
TenantModelKey.tenant_id == user_id,
TenantModelKey.status == "active"
)
)
if model_name:
query = query.where(TenantModelKey.model_name == model_name)
result = await db.execute(query)
keys = result.scalars().all()
if not keys:
return SuccessResponse(
data={
"models": [],
"totalSpend": 0,
}
)
# 尝试从 LiteLLM 获取用量数据
usage_data = []
total_spend = 0
try:
from app.litellm_client import get_litellm_client, LiteLLMClientError
litellm_client = get_litellm_client()
for key in keys:
try:
# 获取该 Key 的用量
spend_logs = await litellm_client.get_spend_logs(
api_key=key.litellm_key_id
)
# 汇总数据
model_spend = sum(log.get("spend", 0) for log in spend_logs)
model_tokens = sum(log.get("total_tokens", 0) for log in spend_logs)
model_requests = len(spend_logs)
total_spend += model_spend
usage_data.append({
"modelName": key.model_name,
"requests": model_requests,
"totalTokens": model_tokens,
"spend": model_spend,
"rpmLimit": key.rpm_limit,
"tpmLimit": key.tpm_limit,
"maxBudget": float(key.max_budget) if key.max_budget else None,
"budgetRemaining": float(key.max_budget) - model_spend if key.max_budget else None,
})
except LiteLLMClientError as e:
# 单个模型查询失败,记录但继续
usage_data.append({
"modelName": key.model_name,
"requests": 0,
"totalTokens": 0,
"spend": 0,
"error": str(e),
})
except Exception as e:
# LiteLLM 不可用,返回基本信息
for key in keys:
usage_data.append({
"modelName": key.model_name,
"requests": 0,
"totalTokens": 0,
"spend": 0,
"rpmLimit": key.rpm_limit,
"tpmLimit": key.tpm_limit,
"maxBudget": float(key.max_budget) if key.max_budget else None,
"note": "LiteLLM 服务暂不可用,无法获取用量数据",
})
return SuccessResponse(
data={
"models": usage_data,
"totalSpend": total_spend,
}
)
# ============= 计费与资源 =============
@router.get("/billing/balance", response_model=SuccessResponse)
@@ -1335,8 +1484,14 @@ async def create_custom_agent(
用户需要提供自己的终结点、密钥等配置。
会检查用户的 CPU/内存配额。
如果用户指定了模型(通过 req.model),会自动注入 LiteLLM 相关环境变量:
- OPENAI_API_BASE: LiteLLM 网关地址
- OPENAI_API_KEY: 租户的 LiteLLM API Key
- MODEL_NAME: 模型名称
"""
from app.agent_manager_client import get_agent_manager_client, AgentConfig, AgentManagerError
from config import settings
user_id = principal.get("user_id")
channel_id = principal.get("channel_id")
@@ -1381,6 +1536,51 @@ async def create_custom_agent(
detail=f"内存配额不足。剩余: {remaining_memory:.2f} GB,请求: {memory_request:.2f} GB"
)
# 构建环境变量
env_vars = req.envConfig or {}
if req.endpoint:
env_vars["ENDPOINT"] = req.endpoint
if req.apiKey:
env_vars["API_KEY"] = req.apiKey
# 如果指定了模型,查询租户的 LiteLLM Key 并注入环境变量
model_name = getattr(req, 'model', None)
if model_name:
tenant_key_result = await db.execute(
select(TenantModelKey).where(
and_(
TenantModelKey.tenant_id == user_id,
TenantModelKey.model_name == model_name,
TenantModelKey.status == "active"
)
)
)
tenant_key = tenant_key_result.scalar_one_or_none()
if not tenant_key:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"您没有使用模型 '{model_name}' 的权限,请联系渠道管理员分配"
)
# 解密 LiteLLM Key 并注入环境变量
try:
from app.litellm_client import get_litellm_client
litellm_client = get_litellm_client()
decrypted_key = litellm_client.decrypt_key(tenant_key.litellm_key_hash)
# 注入 LiteLLM 相关环境变量
env_vars["OPENAI_API_BASE"] = settings.litellm_url
env_vars["OPENAI_API_KEY"] = decrypted_key
env_vars["MODEL_NAME"] = model_name
env_vars["LITELLM_MODEL"] = model_name
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取模型密钥失败: {str(e)}"
)
try:
client = get_agent_manager_client()
@@ -1393,13 +1593,6 @@ async def create_custom_agent(
memory_limit=req.memoryLimit or req.memoryRequest,
)
# 构建环境变量
env_vars = req.envConfig or {}
if req.endpoint:
env_vars["ENDPOINT"] = req.endpoint
if req.apiKey:
env_vars["API_KEY"] = req.apiKey
# 创建自定义 Agent
result = await client.create_custom_agent(
name=req.name,
@@ -1436,6 +1629,7 @@ async def create_custom_agent(
"status": result.status,
"servicePort": result.service_port,
"accessInfo": result.access_info,
"modelInjected": model_name is not None,
"quotaRemaining": {
"cpu": remaining_cpu - cpu_request,
"memory": remaining_memory - memory_request,
+10 -2
View File
@@ -43,8 +43,16 @@ class Settings(BaseSettings):
nats_max_reconnect_attempts: int = 10
# LiteLLM网关设置
litellm_url: str = os.getenv("LITELLM_URL", "http://litellm-gateway:4000")
litellm_api_key: str = os.getenv("LITELLM_API_KEY", "sk-taiji-master-key")
litellm_url: str = os.getenv("LITELLM_URL", "http://4.144.175.186")
litellm_api_key: str = os.getenv("LITELLM_API_KEY", "sk-1f06b8f0d2e34c9b8a9f3d75a1c4e9b7-7e3a2c6bd9f441d8")
litellm_master_key: str = os.getenv("LITELLM_MASTER_KEY", "sk-1f06b8f0d2e34c9b8a9f3d75a1c4e9b7-7e3a2c6bd9f441d8")
# LiteLLM Key 加密密钥(用于加密存储租户的 API Key)
# 必须是 32 字节的 base64 编码字符串,用于 Fernet 加密
litellm_key_encryption_key: str = os.getenv(
"LITELLM_KEY_ENCRYPTION_KEY",
"dGFpamktYWktcGFkLWxpdGVsbG0ta2V5LWVuY3J5cHQ=" # 默认密钥,生产环境必须更换
)
# MCP协议设置
mcp_timeout: int = 30 # 秒
+5 -1
View File
@@ -136,12 +136,16 @@ async def create_initial_data():
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
admin_user = User(
name="系统管理员", # 必填字段
username="admin",
email="admin@taiji-ai.com",
password_hash=pwd_context.hash("admin123"), # 必填字段
hashed_password=pwd_context.hash("admin123"),
full_name="系统管理员",
role="super_admin", # 设置为超级管理员
is_active=True,
is_admin=True
is_admin=True,
status="active",
)
session.add(admin_user)
@@ -0,0 +1,73 @@
-- 迁移脚本:添加 LiteLLM 集成相关表和字段
-- 版本:011
-- 日期:2026-01-07
-- 描述:实现模型供应商与租户模型使用设计方案
-- 1. 给 channels 表添加 litellm_team_id 字段
ALTER TABLE channels ADD COLUMN IF NOT EXISTS litellm_team_id VARCHAR(100);
-- 添加索引
CREATE INDEX IF NOT EXISTS idx_channel_litellm_team ON channels(litellm_team_id);
-- 2. 创建 tenant_model_keys 表
CREATE TABLE IF NOT EXISTS tenant_model_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES users(id),
channel_id UUID REFERENCES channels(id),
-- 模型信息
model_name VARCHAR(100) NOT NULL,
-- LiteLLM Key 信息
litellm_key_id VARCHAR(255) NOT NULL, -- LiteLLM 返回的完整 key
litellm_key_hash TEXT NOT NULL, -- 加密存储
-- 配额配置(与 LiteLLM 同步)
rpm_limit INTEGER DEFAULT 0,
tpm_limit INTEGER DEFAULT 0,
max_budget NUMERIC(12, 2),
budget_duration VARCHAR(20) DEFAULT 'monthly',
-- 状态
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
-- 唯一约束:每个租户每个模型只能有一个 Key
CONSTRAINT uq_tenant_model UNIQUE(tenant_id, model_name)
);
-- 添加索引
CREATE INDEX IF NOT EXISTS idx_tenant_model_key_tenant ON tenant_model_keys(tenant_id);
CREATE INDEX IF NOT EXISTS idx_tenant_model_key_model ON tenant_model_keys(model_name);
CREATE INDEX IF NOT EXISTS idx_tenant_model_key_status ON tenant_model_keys(status);
-- 3. 添加注释
COMMENT ON TABLE tenant_model_keys IS '租户模型 Key 表,存储租户在 LiteLLM 中的 API Key 信息';
COMMENT ON COLUMN tenant_model_keys.model_name IS '模型名称,如 azure/gpt-4, gemini/gemini-pro';
COMMENT ON COLUMN tenant_model_keys.litellm_key_id IS 'LiteLLM 返回的完整 API Key';
COMMENT ON COLUMN tenant_model_keys.litellm_key_hash IS '加密存储的 Key(用于解密后注入到 Agent)';
COMMENT ON COLUMN tenant_model_keys.rpm_limit IS '每分钟请求数限制';
COMMENT ON COLUMN tenant_model_keys.tpm_limit IS '每分钟 Token 数限制';
COMMENT ON COLUMN tenant_model_keys.max_budget IS '最大预算';
COMMENT ON COLUMN tenant_model_keys.budget_duration IS '预算周期:monthly(每月)或 total(总计)';
COMMENT ON COLUMN tenant_model_keys.status IS '状态:active(活跃)、suspended(暂停)、expired(过期)';
COMMENT ON COLUMN channels.litellm_team_id IS 'LiteLLM team ID,创建渠道时同步创建';
-- 4. 创建更新时间触发器
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- 为 tenant_model_keys 表添加更新时间触发器
DROP TRIGGER IF EXISTS update_tenant_model_keys_updated_at ON tenant_model_keys;
CREATE TRIGGER update_tenant_model_keys_updated_at
BEFORE UPDATE ON tenant_model_keys
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
迁移脚本:添加 LiteLLM 集成相关表和字段
运行方式:
cd services/mcp-server
python migrations/run_011_migration.py
"""
import asyncio
import os
import sys
# 添加父目录到路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text
from database import engine
async def run_migration():
"""执行迁移"""
# 读取 SQL 文件
sql_file = os.path.join(os.path.dirname(__file__), "011_add_litellm_integration.sql")
with open(sql_file, "r", encoding="utf-8") as f:
sql_content = f.read()
# 分割 SQL 语句(按分号分割,但忽略函数体内的分号)
statements = []
current_statement = []
in_function = False
for line in sql_content.split("\n"):
stripped = line.strip()
# 跳过注释
if stripped.startswith("--"):
continue
# 检测函数开始
if "AS $$" in line or "AS $" in line:
in_function = True
# 检测函数结束
if in_function and ("$$ language" in line.lower() or "$$ LANGUAGE" in line):
in_function = False
current_statement.append(line)
# 如果不在函数内且行以分号结尾,则完成一条语句
if not in_function and stripped.endswith(";"):
statement = "\n".join(current_statement).strip()
if statement and not statement.startswith("--"):
statements.append(statement)
current_statement = []
# 处理最后一条语句
if current_statement:
statement = "\n".join(current_statement).strip()
if statement and not statement.startswith("--"):
statements.append(statement)
print("=" * 60)
print("LiteLLM 集成迁移脚本")
print("=" * 60)
print(f"共 {len(statements)} 条 SQL 语句待执行")
print()
async with engine.begin() as conn:
for i, statement in enumerate(statements, 1):
# 显示语句摘要
first_line = statement.split("\n")[0][:60]
print(f"[{i}/{len(statements)}] 执行: {first_line}...")
try:
await conn.execute(text(statement))
print(f" ✓ 成功")
except Exception as e:
error_msg = str(e)
# 忽略 "already exists" 类型的错误
if "already exists" in error_msg.lower():
print(f" ⚠ 已存在,跳过")
else:
print(f" ✗ 失败: {error_msg}")
raise
print()
print("=" * 60)
print("迁移完成!")
print("=" * 60)
print()
print("新增内容:")
print(" - channels 表添加 litellm_team_id 字段")
print(" - 创建 tenant_model_keys 表")
print(" - 添加相关索引和触发器")
if __name__ == "__main__":
asyncio.run(run_migration())
+52
View File
@@ -315,6 +315,11 @@ class Channel(BaseModel, Base):
渠道可以分配两种类型的 Agent 资源给租户:
1. 平台端 Agent:由管理员分配给渠道,渠道再分配给租户
2. 自定义 Agent 配额:渠道分配 CPU/内存配额上限给租户,租户在配额内创建多个自定义 Agent
LiteLLM 集成:
- 每个渠道对应 LiteLLM 中的一个 team
- 创建渠道时同步创建 LiteLLM team
- litellm_team_id 存储 LiteLLM 返回的 team_id
"""
__tablename__ = "channels"
@@ -325,6 +330,9 @@ class Channel(BaseModel, Base):
commission_rate = Column(sa.Numeric(5, 2), default=0)
channel_credit = Column(sa.Numeric(12, 2), default=0) # 渠道授信额度
# LiteLLM 集成
litellm_team_id = Column(String(100)) # LiteLLM team ID,创建渠道时同步创建
# 自定义 Agent 资源配额上限(渠道可分配给租户的总配额)
# 注意:这是配额上限,不是默认值。租户可以在配额内创建多个自定义 Agent
custom_agent_cpu_quota = Column(sa.Numeric(12, 2), default=0) # 自定义 Agent CPU 配额上限(核心数)
@@ -1225,3 +1233,47 @@ class PlatformAgentTemplateConfig(BaseModel, Base):
Index("idx_template_config_name", template_name),
Index("idx_template_config_enabled", is_enabled),
)
class TenantModelKey(BaseModel, Base):
"""租户模型 Key 表
存储租户在 LiteLLM 中的 API Key 信息。
每个租户可以有多个模型的 Key,每个 Key 对应一个模型。
设计说明:
- 渠道分配模型给租户时,在 LiteLLM 创建 key 并保存到此表
- Agent 启动时,从此表获取 key 注入到环境变量
- 充值时,更新 LiteLLM key 的 max_budget
"""
__tablename__ = "tenant_model_keys"
tenant_id = Column(GUID(), ForeignKey("users.id"), nullable=False)
channel_id = Column(GUID(), ForeignKey("channels.id"))
# 模型信息
model_name = Column(String(100), nullable=False) # 如 "azure/gpt-4", "gemini/gemini-pro"
# LiteLLM Key 信息
litellm_key_id = Column(String(255), nullable=False) # LiteLLM 返回的完整 key
litellm_key_hash = Column(Text, nullable=False) # 加密存储的 key
# 配额配置(与 LiteLLM 同步)
rpm_limit = Column(Integer, default=0) # 每分钟请求数限制
tpm_limit = Column(Integer, default=0) # 每分钟 Token 数限制
max_budget = Column(sa.Numeric(12, 2)) # 最大预算
budget_duration = Column(String(20), default="monthly") # 预算周期: monthly, total
# 状态
status = Column(String(20), default="active") # active, suspended, expired
# 关联关系
tenant = relationship("User")
channel = relationship("Channel")
__table_args__ = (
Index("idx_tenant_model_key_tenant", tenant_id),
Index("idx_tenant_model_key_model", model_name),
Index("idx_tenant_model_key_status", status),
UniqueConstraint("tenant_id", "model_name", name="uq_tenant_model"),
)
+11 -1
View File
@@ -515,12 +515,22 @@ class PaginatedResponse(BaseModel, Generic[T]):
# ========== 系统状态 ==========
class ServiceStatus(BaseModel):
"""服务状态"""
status: str
latency: int = 0
error: Optional[str] = None
code: Optional[int] = None
class HealthCheck(BaseModel):
"""健康检查响应"""
status: str
timestamp: datetime
services: Dict[str, str]
services: Dict[str, ServiceStatus]
version: str = "1.0.0"
score: Optional[int] = None
uptime_seconds: Optional[float] = None
class SystemMetrics(BaseModel):