forked from xiaohei/taiji-AI-PAD
更新litemll
This commit is contained in:
@@ -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
|
||||
@@ -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="渠道已删除"
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 # 秒
|
||||
|
||||
@@ -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())
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user