forked from xiaohei/taiji-AI-PAD
更新litemll
This commit is contained in:
+33
@@ -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
@@ -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!"}]
|
||||
)
|
||||
```
|
||||
@@ -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 调用模型。
|
||||
@@ -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
@@ -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