diff --git a/plans/渠道资源分配接口整合方案.md b/plans/渠道资源分配接口整合方案.md new file mode 100644 index 0000000..e279ac4 --- /dev/null +++ b/plans/渠道资源分配接口整合方案.md @@ -0,0 +1,567 @@ +# 渠道资源分配接口整合方案 + +> **版本**: v1.0.0 +> **创建时间**: 2026-01-07 +> **状态**: 待实施 + +--- + +## 1. 背景分析 + +### 1.1 现有接口 + +**`PUT /api/channel/tenants/{tenantId}/resources`** ([`channel.py:244`](services/mcp-server/app/routes/channel.py:244)) + +现有请求参数 ([`schemas.py:303`](services/mcp-server/app/schemas.py:303)): +```python +class AllocateResourcesRequest(BaseModel): + agents: List[ResourceAgentAllocation] # 平台端 Agent 分配 + models: List[ResourceModelAllocation] # 模型资源分配 + customAgentResources: Optional[CustomAgentResources] # 已废弃 + customAgentQuota: Optional[CustomAgentQuotaConfig] # 自定义 Agent 配额 +``` + +现有模型分配参数 ([`schemas.py:269`](services/mcp-server/app/schemas.py:269)): +```python +class ResourceModelAllocation(BaseModel): + modelName: str + rpm: int + tpm: int +``` + +### 1.2 LiteLLM 新增接口 + +**`PUT /api/channel/tenants/{id}/models`** ([`channel.py:1126`](services/mcp-server/app/routes/channel.py:1126)) + +新增参数: +- `max_budget`: 最大预算 +- `budget_duration`: 预算周期(monthly/total) + +### 1.3 问题 + +1. **功能重叠**:两个接口都可以分配模型给租户 +2. **参数不一致**:新接口有 `max_budget`、`budget_duration`,旧接口没有 +3. **计费逻辑不清晰**:配额(quota)与余额(balance)的关系需要明确 + +--- + +## 2. 整合方案 + +### 2.1 方案概述 + +**统一使用现有接口** `PUT /api/channel/tenants/{tenantId}/resources`,扩展其参数以支持 LiteLLM 的预算控制功能。 + +### 2.2 Schema 修改 + +#### 修改 `ResourceModelAllocation` ([`schemas.py:269`](services/mcp-server/app/schemas.py:269)) + +```python +class ResourceModelAllocation(BaseModel): + """模型资源分配""" + modelName: str = Field(..., description="模型名称,如 azure/gpt-4") + rpm: int = Field(60, ge=0, description="每分钟请求数限制") + tpm: int = Field(10000, ge=0, description="每分钟 Token 数限制") + + # LiteLLM 预算控制(新增) + maxBudget: Optional[float] = Field(None, ge=0, description="最大预算金额") + budgetDuration: Optional[str] = Field( + None, + pattern="^(monthly|total)$", + description="预算周期:monthly(月度)或 total(总计)" + ) +``` + +### 2.3 接口行为修改 + +#### 修改 `allocate_tenant_resources` ([`channel.py:244`](services/mcp-server/app/routes/channel.py:244)) + +**当前行为**(第 334-350 行): +```python +for model_alloc in req.models: + # 查找模型供应商 + result = await db.execute( + select(ModelProvider).where(ModelProvider.name == model_alloc.modelName) + ) + model_provider = result.scalar_one_or_none() + + if model_provider: + allocation = ResourceAllocation( + target_id=tenant_id, + target_type="tenant", + resource_type="model", + resource_id=str(model_provider.id), + rpm=model_alloc.rpm, + tpm=model_alloc.tpm, + ) + db.add(allocation) +``` + +**修改后行为**: +```python +for model_alloc in req.models: + # 1. 验证渠道是否有该模型的权限 + 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_alloc.modelName + ) + ) + ) + if not model_allocation_result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"渠道没有模型 '{model_alloc.modelName}' 的权限" + ) + + # 2. 检查租户是否已有该模型的 Key + existing_key_result = await db.execute( + select(TenantModelKey).where( + and_( + TenantModelKey.tenant_id == tenant_id, + TenantModelKey.model_name == model_alloc.modelName + ) + ) + ) + existing_key = existing_key_result.scalar_one_or_none() + + if existing_key: + # 3a. 更新现有 Key 的配额 + await _update_tenant_model_quota( + db=db, + tenant_key=existing_key, + rpm_limit=model_alloc.rpm, + tpm_limit=model_alloc.tpm, + max_budget=model_alloc.maxBudget, + budget_duration=model_alloc.budgetDuration, + ) + else: + # 3b. 创建新的 LiteLLM Key + await _create_tenant_model_key( + db=db, + tenant=tenant, + channel=channel, + model_name=model_alloc.modelName, + rpm_limit=model_alloc.rpm, + tpm_limit=model_alloc.tpm, + max_budget=model_alloc.maxBudget, + budget_duration=model_alloc.budgetDuration, + ) +``` + +--- + +## 3. 计费逻辑说明 + +### 3.1 配额(Quota)vs 余额(Balance) + +| 概念 | 说明 | 控制方式 | 用途 | +|------|------|---------|------| +| **余额 (Balance)** | 租户账户中的实际金额 | `POST /api/channel/tenants/{id}/recharge` | 实际扣费来源 | +| **授信额度 (Credit Limit)** | 允许透支的金额 | `PUT /api/channel/tenants/{id}/credit` | 余额不足时的缓冲 | +| **模型配额 (Model Quota)** | 速率限制和预算上限 | `PUT /api/channel/tenants/{id}/resources` | 防止单个模型过度消费 | + +### 3.2 计费流程 + +```mermaid +flowchart TD + A[租户调用模型] --> B{检查模型配额} + B -->|超过 RPM/TPM| C[拒绝请求 - 429] + B -->|超过 maxBudget| D[拒绝请求 - 预算超限] + B -->|配额内| E{检查余额} + E -->|余额 + 授信 >= 费用| F[扣费并执行] + E -->|余额 + 授信 < 费用| G[拒绝请求 - 余额不足] + F --> H[更新余额] + H --> I[记录计费] +``` + +### 3.3 配额与余额的关系 + +1. **配额是上限控制**:即使租户有 $10000 余额,如果模型配额设置为 `maxBudget=$500/月`,该模型每月最多消费 $500 +2. **余额是实际扣费来源**:所有模型消费都从租户余额中扣除 +3. **配额不影响余额**:配额只是限制,不会预扣余额 + +### 3.4 示例场景 + +**场景**:租户 A 的配置 +- 余额:$1000 +- 授信额度:$500 +- 模型配额: + - gpt-4: maxBudget=$300/月, rpm=100 + - gpt-3.5: maxBudget=$200/月, rpm=200 + +**结果**: +- 租户可用总额:$1000 + $500 = $1500 +- gpt-4 每月最多消费 $300(即使余额充足) +- gpt-3.5 每月最多消费 $200 +- 两个模型合计每月最多消费 $500 + +--- + +## 4. 实施步骤 + +### 4.1 Schema 修改 + +**文件**: [`services/mcp-server/app/schemas.py`](services/mcp-server/app/schemas.py) + +```python +# 修改 ResourceModelAllocation 类(第 269-273 行) +class ResourceModelAllocation(BaseModel): + """模型资源分配""" + modelName: str = Field(..., description="模型名称,如 azure/gpt-4") + rpm: int = Field(60, ge=0, description="每分钟请求数限制") + tpm: int = Field(10000, ge=0, description="每分钟 Token 数限制") + + # LiteLLM 预算控制(新增) + maxBudget: Optional[float] = Field(None, ge=0, description="最大预算金额") + budgetDuration: Optional[str] = Field( + None, + pattern="^(monthly|total)$", + description="预算周期:monthly(月度)或 total(总计)" + ) +``` + +### 4.2 接口逻辑修改 + +**文件**: [`services/mcp-server/app/routes/channel.py`](services/mcp-server/app/routes/channel.py) + +1. **添加辅助函数**(在文件顶部导入区域后): + +```python +async def _create_tenant_model_key( + db: AsyncSession, + tenant: User, + channel: Channel, + model_name: str, + rpm_limit: int, + tpm_limit: int, + max_budget: Optional[float], + budget_duration: Optional[str], +) -> TenantModelKey: + """创建租户的 LiteLLM Key""" + from app.litellm_client import get_litellm_client, LiteLLMClientError + + if not channel.litellm_team_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="渠道尚未关联 LiteLLM team,请联系管理员" + ) + + 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, + } + ) + + encrypted_key = litellm_client.encrypt_key(key.key) + + tenant_key = TenantModelKey( + tenant_id=str(tenant.id), + channel_id=str(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 or "monthly", + status="active", + ) + db.add(tenant_key) + + return tenant_key + + +async def _update_tenant_model_quota( + db: AsyncSession, + tenant_key: TenantModelKey, + rpm_limit: Optional[int], + tpm_limit: Optional[int], + max_budget: Optional[float], + budget_duration: Optional[str], +) -> None: + """更新租户的 LiteLLM Key 配额""" + 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 +``` + +2. **修改 `allocate_tenant_resources` 函数**(第 334-350 行): + +```python +# 分配模型资源(集成 LiteLLM) +for model_alloc in req.models: + # 验证渠道是否有该模型的权限 + model_allocation_result = await db.execute( + select(ResourceAllocation).where( + and_( + ResourceAllocation.target_id == str(channel_id), + ResourceAllocation.target_type == "channel", + ResourceAllocation.resource_type == "model", + ResourceAllocation.resource_id == model_alloc.modelName + ) + ) + ) + if not model_allocation_result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"渠道没有模型 '{model_alloc.modelName}' 的权限" + ) + + # 检查租户是否已有该模型的 Key + existing_key_result = await db.execute( + select(TenantModelKey).where( + and_( + TenantModelKey.tenant_id == tenant_id, + TenantModelKey.model_name == model_alloc.modelName + ) + ) + ) + existing_key = existing_key_result.scalar_one_or_none() + + try: + if existing_key: + # 更新现有 Key 的配额 + await _update_tenant_model_quota( + db=db, + tenant_key=existing_key, + rpm_limit=model_alloc.rpm, + tpm_limit=model_alloc.tpm, + max_budget=model_alloc.maxBudget, + budget_duration=model_alloc.budgetDuration, + ) + logger.info(f"更新租户 {tenant.name} 的模型 {model_alloc.modelName} 配额") + else: + # 获取渠道信息 + channel_result = await db.execute( + select(Channel).where(Channel.id == channel_id) + ) + channel = channel_result.scalar_one_or_none() + + if channel and channel.litellm_team_id: + # 创建新的 LiteLLM Key + await _create_tenant_model_key( + db=db, + tenant=tenant, + channel=channel, + model_name=model_alloc.modelName, + rpm_limit=model_alloc.rpm, + tpm_limit=model_alloc.tpm, + max_budget=model_alloc.maxBudget, + budget_duration=model_alloc.budgetDuration, + ) + logger.info(f"为租户 {tenant.name} 创建模型 {model_alloc.modelName} 的 LiteLLM Key") + else: + # 渠道未配置 LiteLLM,仅记录 ResourceAllocation + logger.warning(f"渠道 {channel_id} 未配置 LiteLLM team,仅记录资源分配") + + # 同时记录到 ResourceAllocation(兼容旧逻辑) + allocation = ResourceAllocation( + target_id=tenant_id, + target_type="tenant", + resource_type="model", + resource_id=model_alloc.modelName, + rpm=model_alloc.rpm, + tpm=model_alloc.tpm, + ) + db.add(allocation) + + except Exception as e: + logger.error(f"模型分配失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"模型 '{model_alloc.modelName}' 分配失败: {str(e)}" + ) +``` + +### 4.3 保留独立接口 + +保留以下独立接口用于精细化管理: + +| 接口 | 用途 | 说明 | +|------|------|------| +| `GET /api/channel/tenants/{id}/models` | 获取租户模型列表 | 查看已分配的模型及配额 | +| `DELETE /api/channel/tenants/{id}/models/{model}` | 取消单个模型分配 | 精细化管理 | +| `PUT /api/channel/tenants/{id}/models/{model}/quota` | 更新单个模型配额 | 精细化管理 | + +--- + +## 5. 前端对接说明 + +### 5.1 现有接口扩展 + +前端调用 `PUT /api/channel/tenants/{tenantId}/resources` 时,`models` 数组中的每个元素可以包含新增字段: + +**请求示例**: +```json +{ + "agents": [ + {"agentId": "echo_agent", "quantity": 3} + ], + "models": [ + { + "modelName": "azure/gpt-4", + "rpm": 100, + "tpm": 50000, + "maxBudget": 500.0, + "budgetDuration": "monthly" + }, + { + "modelName": "azure/gpt-3.5-turbo", + "rpm": 200, + "tpm": 100000, + "maxBudget": 200.0, + "budgetDuration": "monthly" + } + ], + "customAgentQuota": { + "cpuQuota": 2.0, + "memoryQuota": 4.0 + } +} +``` + +### 5.2 新增字段说明 + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `maxBudget` | float | 否 | null | 最大预算金额(美元),null 表示不限制 | +| `budgetDuration` | string | 否 | "monthly" | 预算周期:`monthly`(月度重置)或 `total`(总计不重置) | + +### 5.3 向后兼容 + +- 如果前端不传 `maxBudget` 和 `budgetDuration`,行为与之前一致 +- 新字段为可选,不影响现有前端代码 + +--- + +## 6. 测试用例 + +### 6.1 基本功能测试 + +```bash +# 1. 分配模型(不带预算控制) +curl -X PUT "http://localhost:8002/api/channel/tenants/{tenant_id}/resources" \ + -H "Authorization: Bearer {token}" \ + -H "Content-Type: application/json" \ + -d '{ + "models": [ + {"modelName": "azure/gpt-4", "rpm": 100, "tpm": 50000} + ] + }' + +# 2. 分配模型(带预算控制) +curl -X PUT "http://localhost:8002/api/channel/tenants/{tenant_id}/resources" \ + -H "Authorization: Bearer {token}" \ + -H "Content-Type: application/json" \ + -d '{ + "models": [ + { + "modelName": "azure/gpt-4", + "rpm": 100, + "tpm": 50000, + "maxBudget": 500.0, + "budgetDuration": "monthly" + } + ] + }' + +# 3. 查看租户模型列表 +curl -X GET "http://localhost:8002/api/channel/tenants/{tenant_id}/models" \ + -H "Authorization: Bearer {token}" + +# 4. 更新单个模型配额 +curl -X PUT "http://localhost:8002/api/channel/tenants/{tenant_id}/models/azure%2Fgpt-4/quota?rpm_limit=200&max_budget=1000" \ + -H "Authorization: Bearer {token}" + +# 5. 取消模型分配 +curl -X DELETE "http://localhost:8002/api/channel/tenants/{tenant_id}/models/azure%2Fgpt-4" \ + -H "Authorization: Bearer {token}" +``` + +### 6.2 边界条件测试 + +1. **渠道无模型权限**:应返回 403 错误 +2. **重复分配同一模型**:应更新配额而非创建新记录 +3. **渠道未配置 LiteLLM**:应记录 ResourceAllocation 但跳过 LiteLLM Key 创建 +4. **预算超限**:LiteLLM 应拒绝请求 + +--- + +## 7. 文档更新 + +### 7.1 需要更新的文档 + +1. [`Docs/渠道合作伙伴平台-接口对接文档.md`](Docs/渠道合作伙伴平台-接口对接文档.md) - 更新分配资源接口说明 +2. [`plans/LiteLLM集成接口变动清单.md`](plans/LiteLLM集成接口变动清单.md) - 标注接口整合情况 + +### 7.2 接口文档更新 + +**`PUT /api/channel/tenants/{tenantId}/resources`** + +新增请求参数: + +| 参数路径 | 类型 | 必填 | 说明 | +|----------|------|------|------| +| `models[].maxBudget` | float | 否 | 最大预算金额(美元) | +| `models[].budgetDuration` | string | 否 | 预算周期:monthly/total | + +--- + +## 8. 总结 + +### 8.1 修改范围 + +| 文件 | 修改内容 | +|------|----------| +| `schemas.py` | 扩展 `ResourceModelAllocation` 类 | +| `channel.py` | 修改 `allocate_tenant_resources` 函数,添加辅助函数 | + +### 8.2 影响评估 + +- **前端影响**:无(新字段可选,向后兼容) +- **数据库影响**:无(使用现有 `TenantModelKey` 表) +- **LiteLLM 影响**:无(使用现有 LiteLLM 客户端) + +### 8.3 待确认事项 + +1. **预算超限时的行为**:是否需要通知渠道管理员? +2. **预算重置时间**:月度预算是每月 1 日重置还是从分配日期开始计算? +3. **多模型预算汇总**:是否需要显示租户所有模型的预算使用汇总? + +--- + +*文档创建时间: 2026-01-07*