更新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
+33
View File
@@ -0,0 +1,33 @@
# Taiji AI-PAD 环境变量配置
# 数据库配置
ASYNC_DATABASE_URL=postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres
DATABASE_URL=postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres?sslmode=require
# Redis配置 (Azure Cache for Redis - 端口10000)
# REDIS_URL已配置为Azure Redis (端口10000)
REDIS_URL=rediss://:nkJgt1ERFpdeYrEFNyFtsc5K4ycvx2jIeAzCaGGf1OQ%3D@taiji.southeastasia.redis.azure.net:10000/0?ssl_cert_reqs=none
# NATS消息队列配置
NATS_URL=nats://nats:4222
# LiteLLM网关配置
LITELLM_MASTER_KEY=sk-1234567890abcdef
LITELLM_URL=http://litellm-gateway:4000
# OpenRouter配置
OPENROUTER_API_KEY=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
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# RapidAPI配置
RAPIDAPI_KEY=33902cc39dmsha572ec6ae920fb5p13c196jsn8a11209a7e67
RAPIDAPI_HOST=rapidapi.com
# JWT配置
JWT_SECRET_KEY=your-super-secret-jwt-key-change-this-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=1440
# 应用配置
APP_ENV=development
LOG_LEVEL=INFO
+2 -1
View File
@@ -83,7 +83,8 @@ services:
- ASYNC_DATABASE_URL=${ASYNC_DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- NATS_URL=nats://nats:4222
- LITELLM_URL=http://litellm-gateway:4000
- LITELLM_URL=${LITELLM_URL:-http://4.144.175.186}
- LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:-sk-1f06b8f0d2e34c9b8a9f3d75a1c4e9b7-7e3a2c6bd9f441d8}
- AGENT_MANAGER_URL=${AGENT_MANAGER_URL:-http://host.docker.internal:8000}
extra_hosts:
- "host.docker.internal:host-gateway"
@@ -0,0 +1,263 @@
# Agent-Manager 接口变动需求文档
> **版本**: v1.0.0
> **创建时间**: 2026-01-07
> **关联设计**: 模型供应商与租户模型使用设计方案 v5.0
---
## 1. 背景
根据「模型供应商与租户模型使用设计方案」,mcp-server 需要在创建 Agent 时注入 LiteLLM 相关的环境变量,使 Agent 能够通过 LiteLLM 网关访问模型。
**核心变更**:
- Agent 创建时需要支持注入额外的环境变量
- 环境变量包含敏感信息(API Key),需要安全处理
---
## 2. 接口变动清单
### 2.1 创建 Agent 接口
**接口路径**: `POST /agents`
**变更内容**: 请求体中的 `env_vars` 字段需要支持以下新的环境变量
| 环境变量 | 类型 | 必填 | 说明 |
|----------|------|------|------|
| `OPENAI_API_BASE` | string | 否 | LiteLLM 网关地址,如 `http://4.144.175.186` |
| `OPENAI_API_KEY` | string | 否 | 租户的 LiteLLM API Key(敏感信息) |
| `MODEL_NAME` | string | 否 | 模型名称,如 `azure/gpt-4` |
| `LITELLM_MODEL` | string | 否 | 同 MODEL_NAME,兼容不同框架 |
**请求示例**:
```json
{
"name": "my-custom-agent",
"template": "langchain-agent",
"config": {
"user_id": "user-uuid",
"cpu_request": "100m",
"cpu_limit": "500m",
"memory_request": "128Mi",
"memory_limit": "512Mi"
},
"env_vars": {
"OPENAI_API_BASE": "http://4.144.175.186",
"OPENAI_API_KEY": "sk-xxxxxxxxxxxxxxxx",
"MODEL_NAME": "azure/gpt-4",
"LITELLM_MODEL": "azure/gpt-4",
"CUSTOM_VAR": "custom_value"
}
}
```
**安全要求**:
1. `OPENAI_API_KEY` 等敏感环境变量应作为 Kubernetes Secret 存储,而非明文写入 Pod spec
2. 建议使用 `secretKeyRef` 引用 Secret 中的值
3. 日志中不应打印敏感环境变量的值
---
### 2.2 创建平台 Agent 接口
**接口路径**: `POST /platform-agents`
**变更内容**: 同上,支持注入 LiteLLM 环境变量
---
### 2.3 创建自定义 Agent 接口
**接口路径**: `POST /custom-agents`
**变更内容**: 同上,支持注入 LiteLLM 环境变量
---
## 3. 实现建议
### 3.1 环境变量注入方式
**方式一:直接注入(简单但不够安全)**
```yaml
spec:
containers:
- name: agent
env:
- name: OPENAI_API_BASE
value: "http://4.144.175.186"
- name: OPENAI_API_KEY
value: "sk-xxxxxxxx" # 不推荐
```
**方式二:使用 Secret(推荐)**
```yaml
spec:
containers:
- name: agent
env:
- name: OPENAI_API_BASE
value: "http://4.144.175.186"
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: agent-{agent-name}-secrets
key: openai-api-key
```
### 3.2 Secret 管理
Agent-Manager 需要:
1. 在创建 Agent 前,先创建对应的 Secret
2. Secret 名称建议使用 `agent-{agent-name}-secrets`
3. 删除 Agent 时同时删除对应的 Secret
**创建 Secret 示例**:
```python
from kubernetes import client
def create_agent_secret(name: str, namespace: str, env_vars: dict):
"""创建 Agent 的 Secret"""
# 筛选敏感环境变量
sensitive_keys = ["OPENAI_API_KEY", "API_KEY", "SECRET_KEY"]
secret_data = {}
for key, value in env_vars.items():
if any(sk in key.upper() for sk in sensitive_keys):
secret_data[key.lower().replace("_", "-")] = base64.b64encode(value.encode()).decode()
if not secret_data:
return None
secret = client.V1Secret(
metadata=client.V1ObjectMeta(
name=f"agent-{name}-secrets",
namespace=namespace,
labels={"app": "agent", "agent-name": name}
),
type="Opaque",
data=secret_data
)
core_v1 = client.CoreV1Api()
return core_v1.create_namespaced_secret(namespace, secret)
```
---
## 4. mcp-server 调用示例
```python
# mcp-server 中创建 Agent 的代码
async def create_custom_agent(
name: str,
template: str,
user_id: str,
model_name: str,
db: AsyncSession
):
# 1. 查询租户的模型 Key
tenant_key = await get_tenant_model_key(user_id, model_name, db)
if not tenant_key:
raise HTTPException(403, f"您没有使用模型 {model_name} 的权限")
# 2. 解密 Key
decrypted_key = litellm_client.decrypt_key(tenant_key.litellm_key_hash)
# 3. 构建环境变量
env_vars = {
"OPENAI_API_BASE": settings.litellm_url,
"OPENAI_API_KEY": decrypted_key,
"MODEL_NAME": model_name,
"LITELLM_MODEL": model_name,
}
# 4. 调用 Agent Manager
result = await agent_manager_client.create_custom_agent(
name=name,
template=template,
user_id=user_id,
env_vars=env_vars,
config=agent_config
)
return result
```
---
## 5. 测试要点
### 5.1 功能测试
| 测试项 | 预期结果 |
|--------|----------|
| 创建 Agent 时传入 LiteLLM 环境变量 | Agent Pod 中能读取到这些环境变量 |
| Agent 使用注入的 Key 调用 LiteLLM | 请求成功,返回模型响应 |
| 不传入 LiteLLM 环境变量 | Agent 正常创建,但无法调用模型 |
### 5.2 安全测试
| 测试项 | 预期结果 |
|--------|----------|
| 查看 Pod spec | 敏感环境变量通过 secretKeyRef 引用 |
| 查看 Agent Manager 日志 | 不包含 API Key 明文 |
| 删除 Agent | 对应的 Secret 也被删除 |
---
## 6. 时间线
| 阶段 | 时间 | 内容 |
|------|------|------|
| 需求确认 | 2026-01-07 | 确认接口变动范围 |
| 开发实现 | 2026-01-08 ~ 2026-01-10 | Agent Manager 支持 Secret 管理 |
| 联调测试 | 2026-01-11 ~ 2026-01-12 | mcp-server 与 Agent Manager 联调 |
| 上线部署 | 2026-01-13 | 生产环境部署 |
---
## 7. 联系人
- **mcp-server 负责人**: [待填写]
- **Agent Manager 负责人**: [待填写]
---
## 附录 A: 现有 Agent Manager 接口参考
根据 `plans/agent-manager接口文档.md`,现有接口已支持 `env_vars` 参数,本次变更主要是:
1. **明确 LiteLLM 相关环境变量的命名规范**
2. **增加敏感环境变量的安全处理要求**
3. **确保 mcp-server 和 Agent Manager 的对接一致性**
---
## 附录 B: LiteLLM 环境变量说明
| 环境变量 | 用途 | 示例值 |
|----------|------|--------|
| `OPENAI_API_BASE` | LiteLLM 网关地址 | `http://4.144.175.186` |
| `OPENAI_API_KEY` | 租户的 LiteLLM API Key | `sk-xxxxxxxx` |
| `MODEL_NAME` | 要使用的模型名称 | `azure/gpt-4` |
| `LITELLM_MODEL` | 同 MODEL_NAME | `azure/gpt-4` |
**Agent 代码中使用示例**:
```python
import os
from openai import OpenAI
client = OpenAI(
base_url=os.getenv("OPENAI_API_BASE"),
api_key=os.getenv("OPENAI_API_KEY"),
)
response = client.chat.completions.create(
model=os.getenv("MODEL_NAME", "gpt-4"),
messages=[{"role": "user", "content": "Hello!"}]
)
```
+261
View File
@@ -0,0 +1,261 @@
# LiteLLM 集成实现总结
> **版本**: v1.0.0
> **完成时间**: 2026-01-07
> **状态**: ✅ 已完成
---
## 1. 实现概述
根据「模型供应商与租户模型使用设计方案 v5.0」,已完成 mcp-server 与 LiteLLM 的集成。
### 1.1 核心功能
| 功能 | 状态 | 说明 |
|------|------|------|
| 创建渠道时同步创建 LiteLLM team | ✅ 已完成 | 渠道 = LiteLLM team |
| 删除渠道时同步删除 LiteLLM team | ✅ 已完成 | 级联删除 |
| 分配模型给租户时创建 LiteLLM key | ✅ 已完成 | 包含 RPM/TPM/Budget 配额 |
| 取消模型分配时删除 LiteLLM key | ✅ 已完成 | 清理资源 |
| 更新租户配额时更新 LiteLLM key | ✅ 已完成 | 实时生效 |
| 租户查看可用模型 | ✅ 已完成 | 从 tenant_model_keys 表查询 |
| Agent 创建时注入 LiteLLM key | ✅ 已完成 | 环境变量注入 |
---
## 2. 新增/修改的文件
### 2.1 新增文件
| 文件路径 | 说明 |
|----------|------|
| `services/mcp-server/app/litellm_client.py` | LiteLLM Admin API 客户端(525行) |
| `services/mcp-server/migrations/011_add_litellm_integration.sql` | 数据库迁移脚本 |
| `services/mcp-server/migrations/run_011_migration.py` | 迁移执行脚本 |
| `plans/Agent-Manager接口变动需求文档.md` | Agent Manager 对接需求 |
### 2.2 修改文件
| 文件路径 | 修改内容 |
|----------|----------|
| `services/mcp-server/models.py` | 新增 `TenantModelKey` 模型,`Channel` 增加 `litellm_team_id` 字段 |
| `services/mcp-server/config.py` | 更新 LiteLLM master key 配置 |
| `services/mcp-server/app/routes/admin.py` | 渠道创建/删除时同步 LiteLLM team |
| `services/mcp-server/app/routes/channel.py` | 新增模型分配接口(4个端点) |
| `services/mcp-server/app/routes/user.py` | 新增租户模型查询接口,Agent 创建时注入 key |
| `docker-compose.yml` | 添加 LITELLM_URL 和 LITELLM_MASTER_KEY 环境变量 |
| `.env` | 更新 LiteLLM 配置 |
---
## 3. 数据库变更
### 3.1 新增表:tenant_model_keys
```sql
CREATE TABLE 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_id VARCHAR(255) NOT NULL,
litellm_key_hash TEXT NOT NULL, -- 加密存储
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(),
CONSTRAINT uq_tenant_model UNIQUE(tenant_id, model_name)
);
```
### 3.2 修改表:channels
```sql
ALTER TABLE channels ADD COLUMN litellm_team_id VARCHAR(100);
```
---
## 4. API 接口清单
### 4.1 渠道管理(admin.py)
| 接口 | 方法 | 功能 | LiteLLM 操作 |
|------|------|------|-------------|
| `/api/admin/channels/create` | POST | 创建渠道 | 创建 team |
| `/api/admin/channels/{id}` | DELETE | 删除渠道 | 删除 team |
### 4.2 模型分配(channel.py)
| 接口 | 方法 | 功能 | LiteLLM 操作 |
|------|------|------|-------------|
| `/api/channel/tenants/{id}/models` | PUT | 分配模型给租户 | 创建 key |
| `/api/channel/tenants/{id}/models` | GET | 获取租户模型列表 | - |
| `/api/channel/tenants/{id}/models/{model}` | DELETE | 取消模型分配 | 删除 key |
| `/api/channel/tenants/{id}/models/{model}/quota` | PUT | 更新配额 | 更新 key |
### 4.3 租户接口(user.py)
| 接口 | 方法 | 功能 |
|------|------|------|
| `/api/user/models/available` | GET | 获取可用模型列表 |
| `/api/user/models/usage/stats` | GET | 获取用量统计 |
| `/api/user/custom-agents` | POST | 创建 Agent(注入 key) |
---
## 5. 配置说明
### 5.1 环境变量
| 变量名 | 说明 | 示例值 |
|--------|------|--------|
| `LITELLM_URL` | LiteLLM 网关地址 | `http://4.144.175.186` |
| `LITELLM_MASTER_KEY` | LiteLLM 管理密钥 | `sk-1f06b8f0d2e34c9b8a9f3d75a1c4e9b7-7e3a2c6bd9f441d8` |
| `LITELLM_KEY_ENCRYPTION_KEY` | Key 加密密钥 | Base64 编码的 32 字节密钥 |
### 5.2 .env 配置
```bash
# LiteLLM网关配置
LITELLM_MASTER_KEY=sk-1f06b8f0d2e34c9b8a9f3d75a1c4e9b7-7e3a2c6bd9f441d8
LITELLM_URL=http://4.144.175.186
```
---
## 6. 测试验证
### 6.1 测试流程
```
1. 超级管理员登录 ✅
2. 创建渠道 → LiteLLM team 创建成功 ✅
3. 分配模型给渠道 ✅
4. 渠道管理员登录 ✅
5. 创建租户 ✅
6. 分配模型给租户 → LiteLLM key 创建成功 ✅
7. 租户登录 ✅
8. 租户查看可用模型 ✅
```
### 6.2 测试结果
**创建渠道响应**:
```json
{
"success": true,
"data": {
"id": "6e147f61-e2a8-422a-ab86-17ebbf3eff7b",
"name": "LiteLLM集成测试渠道",
"litellmTeamId": "4fe022d7-f484-4cf7-ac91-de1ed06aa99c"
}
}
```
**分配模型响应**:
```json
{
"success": true,
"data": {
"tenantId": "7363295d-2a22-4f60-9298-ef65d2715731",
"modelName": "gpt-3.5-turbo",
"rpmLimit": 60,
"tpmLimit": 10000,
"maxBudget": 100.0,
"status": "active"
}
}
```
**LiteLLM 中的 Key 信息**:
```json
{
"models": ["gpt-3.5-turbo"],
"rpm_limit": 60,
"tpm_limit": 10000,
"max_budget": 100.0,
"budget_duration": "monthly",
"metadata": {
"tenant_id": "7363295d-2a22-4f60-9298-ef65d2715731",
"channel_id": "6e147f61-e2a8-422a-ab86-17ebbf3eff7b",
"tenant_name": "测试租户",
"channel_name": "LiteLLM集成测试渠道"
}
}
```
---
## 7. 待完成事项
### 7.1 Agent Manager 对接
需要 Agent Manager 支持:
1. 接收 `env_vars` 参数中的 LiteLLM 环境变量
2. 将敏感环境变量(如 `OPENAI_API_KEY`)存储为 Kubernetes Secret
3. 在 Pod spec 中通过 `secretKeyRef` 引用
详见:`plans/Agent-Manager接口变动需求文档.md`
### 7.2 充值功能
充值接口 `/api/user/billing/recharge` 需要:
1. 更新 `tenant_model_keys.max_budget`
2. 调用 LiteLLM `/key/update` 更新 budget
### 7.3 用量统计
用量统计接口需要:
1. 调用 LiteLLM `/spend/logs` 获取实际用量
2. 与本地记录对比
---
## 8. 架构图
```
┌─────────────────────────────────────────────────────────────────┐
│ mcp-server │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ admin.py │ │ channel.py │ │ user.py │ │
│ │ 创建/删除渠道│ │ 分配模型给租户│ │ 查询可用模型/创建Agent │ │
│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │
│ │ │ │ │
│ └────────────────┼──────────────────────┘ │
│ │ │
│ ┌───────▼───────┐ │
│ │ litellm_client│ │
│ │ (Admin API) │ │
│ └───────┬───────┘ │
└──────────────────────────┼───────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ LiteLLM Gateway │
│ http://4.144.175.186 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ /team/new │ │/key/generate│ │ /chat/completions │ │
│ │ /team/delete│ │ /key/update │ │ (Agent 调用) │ │
│ └─────────────┘ │ /key/delete │ └─────────────────────────┘ │
│ └─────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
---
## 9. 总结
LiteLLM 集成已完成核心功能:
1. ✅ **渠道 = LiteLLM team**:创建渠道时自动创建 team
2. ✅ **租户模型 = LiteLLM key**:分配模型时创建带配额的 key
3. ✅ **配额管理**:RPM/TPM/Budget 由 LiteLLM 执行
4. ✅ **Key 加密存储**:使用 Fernet 加密存储 API Key
5. ✅ **实时生效**:配额更新后立即生效,无需重启
下一步:与 Agent Manager 联调,确保 Agent 能正确使用注入的 LiteLLM key 调用模型。
+406
View File
@@ -0,0 +1,406 @@
# LiteLLM 集成接口变动清单
> **版本**: v1.0.0
> **创建时间**: 2026-01-07
> **状态**: 已实现
---
## 1. 超级管理员端接口变动 (admin.py)
### 1.1 已修改接口
#### POST /api/admin/channels/create - 创建渠道
**变动说明**: 创建渠道时同步在 LiteLLM 中创建对应的 team
**请求参数**: 无变化
**响应变动**:
```json
{
"success": true,
"data": {
"id": "渠道ID",
"name": "渠道名称",
"email": "渠道邮箱",
"litellmTeamId": "LiteLLM team ID (新增)",
"litellmWarning": "LiteLLM 创建失败时的警告信息 (可选)"
},
"message": "渠道创建成功"
}
```
**新增响应字段**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `litellmTeamId` | string | LiteLLM 中创建的 team ID |
| `litellmWarning` | string | 可选,LiteLLM 操作失败时的警告信息 |
---
#### DELETE /api/admin/channels/{channel_id} - 删除渠道
**变动说明**: 删除渠道时同步删除 LiteLLM 中对应的 team
**请求参数**: 无变化
**响应变动**:
```json
{
"success": true,
"data": {
"id": "渠道ID",
"litellmWarning": "LiteLLM 删除失败时的警告信息 (可选)"
},
"message": "渠道已删除"
}
```
**新增响应字段**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `litellmWarning` | string | 可选,LiteLLM 操作失败时的警告信息 |
---
### 1.2 数据库变动
#### Channel 表新增字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `litellm_team_id` | VARCHAR(100) | LiteLLM 中对应的 team ID |
---
## 2. 渠道端接口变动 (channel.py)
### 2.1 新增接口
#### PUT /api/channel/tenants/{tenant_id}/models - 分配模型给租户
**功能**: 为租户创建 LiteLLM API Key,绑定指定的模型和配额
**请求参数** (Query):
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `model_name` | string | 是 | 模型名称,如 `azure/gpt-4` |
| `rpm_limit` | int | 否 | 每分钟请求数限制,默认 60 |
| `tpm_limit` | int | 否 | 每分钟 Token 数限制,默认 10000 |
| `max_budget` | float | 否 | 最大预算,默认 100.0 |
| `budget_duration` | string | 否 | 预算周期,`monthly` 或 `total`,默认 `monthly` |
| `channel_id` | string | 否 | 渠道ID(超级管理员必填) |
**响应**:
```json
{
"success": true,
"data": {
"tenantId": "租户ID",
"tenantName": "租户名称",
"modelName": "模型名称",
"rpmLimit": 60,
"tpmLimit": 10000,
"maxBudget": 100.0,
"budgetDuration": "monthly",
"status": "active"
},
"message": "模型 'xxx' 分配成功"
}
```
**权限**: `manage:resources` (channel_admin, billing_admin, super_admin)
---
#### GET /api/channel/tenants/{tenant_id}/models - 获取租户的模型分配列表
**功能**: 返回租户已分配的所有模型及其配额信息
**请求参数** (Query):
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `channel_id` | string | 否 | 渠道ID(超级管理员必填) |
**响应**:
```json
{
"success": true,
"data": {
"tenantId": "租户ID",
"tenantName": "租户名称",
"models": [
{
"modelName": "azure/gpt-4",
"rpmLimit": 60,
"tpmLimit": 10000,
"maxBudget": 100.0,
"budgetDuration": "monthly",
"status": "active",
"createdAt": "2026-01-07T12:00:00",
"updatedAt": "2026-01-07T12:00:00"
}
]
}
}
```
**权限**: `view:resources` (channel_admin, billing_admin, operations_admin, super_admin)
---
#### DELETE /api/channel/tenants/{tenant_id}/models/{model_name} - 取消租户的模型分配
**功能**: 删除租户在 LiteLLM 中的 API Key,租户将无法再使用该模型
**请求参数** (Query):
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `channel_id` | string | 否 | 渠道ID(超级管理员必填) |
**响应**:
```json
{
"success": true,
"data": {
"tenantId": "租户ID",
"tenantName": "租户名称",
"modelName": "模型名称",
"litellmWarning": "LiteLLM Key 删除失败时的警告信息 (可选)"
},
"message": "模型 'xxx' 分配已取消"
}
```
**权限**: `manage:resources` (channel_admin, billing_admin, super_admin)
---
#### PUT /api/channel/tenants/{tenant_id}/models/{model_name}/quota - 更新租户的模型配额
**功能**: 更新租户在 LiteLLM 中的 API Key 配额,更新后立即生效
**请求参数** (Query):
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `rpm_limit` | int | 否 | 每分钟请求数限制 |
| `tpm_limit` | int | 否 | 每分钟 Token 数限制 |
| `max_budget` | float | 否 | 最大预算 |
| `budget_duration` | string | 否 | 预算周期,`monthly` 或 `total` |
| `channel_id` | string | 否 | 渠道ID(超级管理员必填) |
**响应**:
```json
{
"success": true,
"data": {
"tenantId": "租户ID",
"tenantName": "租户名称",
"modelName": "模型名称",
"rpmLimit": 120,
"tpmLimit": 20000,
"maxBudget": 200.0,
"budgetDuration": "monthly",
"status": "active"
},
"message": "模型配额更新成功,立即生效"
}
```
**权限**: `manage:resources` (channel_admin, billing_admin, super_admin)
---
### 2.2 数据库变动
#### 新增表: tenant_model_keys
```sql
CREATE TABLE 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(),
UNIQUE(tenant_id, model_name)
);
```
---
## 3. 用户端接口变动 (user.py)
### 3.1 新增接口
#### GET /api/user/models/available - 获取可用模型列表
**功能**: 返回当前租户已分配的所有模型
**响应**:
```json
{
"success": true,
"data": {
"models": [
{
"modelName": "azure/gpt-4",
"rpmLimit": 60,
"tpmLimit": 10000,
"maxBudget": 100.0,
"budgetDuration": "monthly",
"status": "active"
}
]
}
}
```
**权限**: 已认证用户
---
#### GET /api/user/models/usage/stats - 获取模型用量统计
**功能**: 从 LiteLLM 获取当前租户的模型用量统计
**响应**:
```json
{
"success": true,
"data": {
"models": [
{
"modelName": "azure/gpt-4",
"rpmLimit": 60,
"tpmLimit": 10000,
"maxBudget": 100.0,
"budgetDuration": "monthly",
"spend": 25.50,
"budgetRemaining": 74.50,
"status": "active"
}
],
"totalSpend": 25.50,
"totalBudget": 100.0
}
}
```
**权限**: 已认证用户
---
### 3.2 已修改接口
#### POST /api/user/custom-agents - 创建自定义 Agent
**变动说明**: 创建 Agent 时自动注入 LiteLLM Key 环境变量
**新增逻辑**:
1. 查询租户的模型 Key
2. 如果租户有可用的模型 Key,自动注入以下环境变量:
- `OPENAI_API_BASE`: LiteLLM 网关地址
- `OPENAI_API_KEY`: 租户的 LiteLLM Key
- `MODEL_NAME`: 模型名称
---
## 4. 接口变动汇总
### 4.1 超级管理员端 (admin.py)
| 接口 | 方法 | 变动类型 | 说明 |
|------|------|----------|------|
| `/api/admin/channels/create` | POST | 修改 | 响应新增 `litellmTeamId` 字段 |
| `/api/admin/channels/{id}` | DELETE | 修改 | 同步删除 LiteLLM team |
### 4.2 渠道端 (channel.py)
| 接口 | 方法 | 变动类型 | 说明 |
|------|------|----------|------|
| `/api/channel/tenants/{id}/models` | PUT | **新增** | 分配模型给租户 |
| `/api/channel/tenants/{id}/models` | GET | **新增** | 获取租户的模型列表 |
| `/api/channel/tenants/{id}/models/{model}` | DELETE | **新增** | 取消模型分配 |
| `/api/channel/tenants/{id}/models/{model}/quota` | PUT | **新增** | 更新模型配额 |
### 4.3 用户端 (user.py)
| 接口 | 方法 | 变动类型 | 说明 |
|------|------|----------|------|
| `/api/user/models/available` | GET | **新增** | 获取可用模型列表 |
| `/api/user/models/usage/stats` | GET | **新增** | 获取模型用量统计 |
| `/api/user/custom-agents` | POST | 修改 | 自动注入 LiteLLM Key |
---
## 5. 前端对接注意事项
### 5.1 渠道管理员界面
1. **租户详情页** 需要新增"模型管理"标签页,包含:
- 模型列表展示
- 分配模型按钮
- 配额编辑功能
- 取消分配功能
2. **分配模型表单** 需要包含:
- 模型选择(下拉框,从渠道已有模型中选择)
- RPM 限制输入
- TPM 限制输入
- 预算限制输入
- 预算周期选择(月度/总计)
### 5.2 租户用户界面
1. **仪表板** 可以展示:
- 可用模型列表
- 各模型的配额使用情况
- 预算剩余
2. **创建 Agent** 时:
- 如果租户有可用模型,Agent 会自动获得访问权限
- 无需用户手动配置 API Key
### 5.3 超级管理员界面
1. **渠道列表** 可以展示 `litellmTeamId`(可选)
2. **创建渠道** 后检查响应中的 `litellmWarning` 字段
---
## 6. 错误处理
### 6.1 常见错误码
| 错误码 | 说明 |
|--------|------|
| 400 | 参数错误(如无效的渠道ID格式) |
| 403 | 权限不足(如渠道没有该模型的权限) |
| 404 | 资源不存在(如租户不存在、模型分配记录不存在) |
| 500 | LiteLLM 操作失败 |
### 6.2 LiteLLM 操作失败处理
- 创建渠道时 LiteLLM team 创建失败:渠道仍会创建成功,响应中包含 `litellmWarning`
- 删除渠道时 LiteLLM team 删除失败:渠道仍会删除成功,响应中包含 `litellmWarning`
- 分配模型时 LiteLLM Key 创建失败:返回 500 错误,分配失败
- 更新配额时 LiteLLM Key 更新失败:返回 500 错误,更新失败
File diff suppressed because it is too large Load Diff
+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):