forked from xiaohei/taiji-AI-PAD
feat(mcp-server): Heicode integration + register transaction hardening
== Heicode integration (~41 endpoints across 5 modules) ==
- §2 ResourceBinding (5 endpoints) — resources.py / resource_grants.py
- §4 NewAPI metadata proxy (4 endpoints) — heicode_proxy.py + heicode_client.py
- §5 Agnet platform stub (12 endpoints, in-memory mock) — agnet_stub.py
- §6 Task orchestration (5 endpoints + 3 extension endpoints) — heicode_tasks.py
6.1-6.5: intent / list / get / answer / messages
6.6-6.8: execution / delivery / audit?tab=... (Slice 8/9/10)
- §7 SSE single channel + approvals (4 endpoints + 5 event types) —
heicode_events.py + event_bus.py
- §7.8.1 internal billing-provider PUT endpoint — auth.py (routes)
== Schema changes ==
- migrations/026 heicode_tasks (orchestration state)
- migrations/027 users.billing_provider (litellm | newapi switch)
- migrations/028 heicode_approvals (high-risk approval queue)
== Register transaction hardening (P0 + P1 + P2) ==
routes/auth.py register():
- Pre-existing P0: failed register returned IntegrityError str verbatim
(leaking SQL params + ~50 plaintext LiteLLM keys per attempt).
Now logs exc_info, returns {code: REGISTER_FAILED, message: ...}.
- Pre-existing P0: model dedupe — two ModelProvider rows with overlapping
supported_models (e.g. taiji/gpt-4o-mini in both taiji and azure providers)
collide on uq_tenant_model. seen_models set deduplicates within the loop.
- New P1: track created_litellm_keys; on any failure call delete_key() for
each — prevents remote orphan keys when DB rollback fires.
- New P1: replace verify_code with peek_verification_code at the start;
only call verify_code (which consumes) after commit succeeds. Failed
registrations no longer burn the user's one-shot code.
- New P2: narrow inner `except (LiteLLMClientError, Exception)` to just
LiteLLMClientError so SQLAlchemy errors bubble to the outer rollback
instead of being silently swallowed into a half-allocated 200 response.
- New P2: same narrowing on outer `except (AgentManagerError, Exception)`.
== Auth middleware ==
- app/auth.py: allow /api/auth/internal/billing-provider and
/api/auth/internal/approvals to bypass user JWT (service-token auth
via HEICODE_INTERNAL_SERVICE_TOKEN, validated in-route).
== Docs ==
- Heicode-接口契约文档.md v2.2 (41 endpoints + SSE schema + 6.6-6.8)
- Heicode-对接进度与待办.md (through §7.14 SSE + 7.8.2 delivery回执)
- Heicode-完整调用流程图.md (sequence + routing diagrams)
- Agent-Manager-Heicode对接需求文档.md
- HEICODE_API_INTEGRATION.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -162,3 +162,9 @@ logs/
|
||||
# Cache directories
|
||||
cache/
|
||||
models/
|
||||
|
||||
# Heicode service-account credentials — never commit
|
||||
Docs/heicode-svc-token.txt
|
||||
Docs/heicode-internal-token.txt
|
||||
Docs/*.token
|
||||
Docs/*-secret.*
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
# Agent-Manager (= Heicode Agnet 平台) 对接需求文档
|
||||
|
||||
**版本**: v1.1
|
||||
**生效日期**: 2026-05-07
|
||||
**目标读者**: agent-manager 服务的开发团队
|
||||
**对接方**: mcp-server(Heicode Manager)
|
||||
**依据**:
|
||||
- Heicode 主线:`heicode.md` / `plan.md`
|
||||
- 接口契约:`integration/agnet-platform-request-contract.md`
|
||||
- 运行时设计:`heicode-runtime-auth-newapi-secret-design.md`
|
||||
|
||||
**v1.1 修订**(2026-05-07,按 Heicode 团队 4 路径架构修订):
|
||||
- §1.1 架构图:反映双模型网关(NewAPI + LiteLLM)并存
|
||||
- §3.1 payload 校验:新增 `billing_context.provider` enum 约束(`newapi` | `litellm`)
|
||||
- §4.1 Pod 启动:按 provider 注入不同 token(`HEICODE_NEWAPI_USER_TOKEN` 或 `LITELLM_USER_KEY`)
|
||||
- §3a(**新增**):子 Agent 模型网关路由说明
|
||||
|
||||
**配套文档**:
|
||||
- 调用关系全景:[`Heicode-完整调用流程图.md`](./Heicode-完整调用流程图.md)
|
||||
- mcp-server 已上线接口:[`Heicode-接口契约文档.md`](./Heicode-接口契约文档.md)
|
||||
- 整体进度与待办:[`Heicode-对接进度与待办.md`](./Heicode-对接进度与待办.md)
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
agent-manager 在 Heicode 架构里担任 **Agnet 平台**角色——**执行层**,运行子 Agent、回传日志/事件/审计。
|
||||
|
||||
需要做三件事:
|
||||
|
||||
1. **新增 12 个 HTTP 接口**(`/api/agnet/*`),接收 mcp-server 的部署请求并回传状态
|
||||
2. **改 Pod 启动方式**:子 Agent Pod 启动时只接收 `AGENT.md` + `resource_context` + `permission_manifest`,**不再接收长期密钥**
|
||||
3. **接入 AKS Workload Identity**:子 Agent Pod 通过 ServiceAccount 拿身份,按需从 Vault 拉短期凭据
|
||||
|
||||
⚠️ **现有 agent-manager 接口不动**(taiji 业务还在用),**全部增量**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与边界
|
||||
|
||||
### 1.1 Heicode 全栈架构(v1.1 修订:4 路径模型调用)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 入口层 │
|
||||
│ cc-haha 桌面客户端 heicode web 前端 │
|
||||
│ (Tauri + Bun) (React + Rsbuild) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
│ 登录 4 接口 │
|
||||
└───────────┬───────────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ Heicode Manager (mcp-server) │
|
||||
│ ✅ 登录 IdP │
|
||||
│ ✅ ResourceBinding/Grant │
|
||||
│ ❌ /api/agnet/* (12 接口) │
|
||||
│ ❌ /api/user/heicode/* (4 透传) │
|
||||
└──────┬───────────┬───────────┬───┘
|
||||
│ │ │
|
||||
部署请求 │ │ NewAPI 元数据查询
|
||||
│ │ (service token)
|
||||
▼ ▼
|
||||
┌────────────────────────────────────────┐
|
||||
│ ★ 你要做的:agent-manager (Agnet 平台)│
|
||||
│ - 12 个新接口 │
|
||||
│ - 创建 K8s Deployment │
|
||||
│ - 按 billing_context.provider 路由 │
|
||||
└──────────────┬───────────────────────┬──┘
|
||||
│ │
|
||||
provider=newapi│ provider=litellm │
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ 子 Agent Pod │ │ 子 Agent Pod │
|
||||
│ (Heicode 用户的) │ │ (taijiagent 用户的) │
|
||||
│ ENV: │ │ ENV: │
|
||||
│ HEICODE_NEWAPI_ │ │ LITELLM_USER_KEY │
|
||||
│ USER_TOKEN │ │ LITELLM_BASE_URL │
|
||||
└──────────┬───────────┘ └──────────┬───────────┘
|
||||
│ /v1/chat/completions │ /v1/chat/completions
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ Heicode NewAPI │ │ taijiagent LiteLLM │
|
||||
│ code.xinghanlab.com │ │ (mcp-server 内置) │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
│ │
|
||||
└────────────┬────────────┘
|
||||
▼
|
||||
40+ AI 提供商(OpenAI、Claude、Gemini...)
|
||||
```
|
||||
|
||||
**boundary**:
|
||||
- Manager (mcp-server) = **用户控制台 + 编排中枢**,不直接动 K8s
|
||||
- Agnet 平台 (agent-manager) = **执行层**,唯一接触 K8s deployment 的服务
|
||||
- Manager 通过 HTTP 调 Agnet 平台,**不**直接调 K8s API
|
||||
- **★ 重要**:模型调用是 4 路径(cc-haha + heicode 前端 → NewAPI;子 Agent → NewAPI 或 LiteLLM 看 provider;mcp-server 内部 → LiteLLM),详见 [`Heicode-完整调用流程图.md §2.5`](./Heicode-完整调用流程图.md)
|
||||
|
||||
### 1.2 不要做什么
|
||||
|
||||
| 不要做 | 为什么 |
|
||||
|---|---|
|
||||
| ❌ 在 agent-manager 里再发起高危操作审批 | 审批只在客户端做,agent-manager 只**校验** approval_id 是否有效 |
|
||||
| ❌ 直接信任 mcp-server 传来的 role 提升 | 高权限角色由 Vault policy / K8s RBAC 强制,不靠应用层声明 |
|
||||
| ❌ 把长期密钥(Git PAT、云 access key)注入 Pod env | Pod 只能拿短期、最小权限凭证;长期密钥放 Vault |
|
||||
| ❌ 让 Pod 直连 mcp-server 拿用户上下文 | 上下文应在创建 Deployment 时一次性写入 K8s Secret/ConfigMap |
|
||||
| ❌ 替换或破坏现有 agent-manager 老接口 | taiji 业务(channel admin → 创建 Agent → 部署)正在用,必须向后兼容 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 12 个新接口(必须实现)
|
||||
|
||||
完整字段定义见 [`integration/agnet-platform-request-contract.md`](http://gitee.ath.cx:3000/xiaohei/heicode/src/branch/main/docs/integration/agnet-platform-request-contract.md)。
|
||||
|
||||
下表是必须实现的 11 个接口 + 1 个可选 SSE:
|
||||
|
||||
| 序号 | 接口 | 用途 | mcp-server 何时调 |
|
||||
|---|---|---|---|
|
||||
| 1 | `POST /api/agnet/deployments` | 创建子 Agent 部署 | 用户在 Manager 点"部署" |
|
||||
| 2 | `GET /api/agnet/deployments` | 列表 | Manager 显示"我的部署"页 |
|
||||
| 3 | `GET /api/agnet/deployments/{id}` | 详情 | Manager 显示部署详情页 |
|
||||
| 4 | `POST /api/agnet/deployments/{id}/stop` | 停止 | 用户点"停止"或预算超 |
|
||||
| 5 | `GET /api/agnet/deployments/{id}/logs` | 日志(脱敏) | 用户看子 Agent 输出 |
|
||||
| 6 | `GET /api/agnet/deployments/{id}/logs/stream` | SSE 实时日志 | (可选)实时控制台 |
|
||||
| 7 | `GET /api/agnet/projects/{binding_scope}/dashboard-snapshot` | 资源作用域监控快照 | Manager 总览页 |
|
||||
| 8 | `GET /api/agnet/deployments/{id}/metrics` | 单部署指标序列 | Manager 详情页"性能"tab |
|
||||
| 9 | `GET /api/agnet/deployments/{id}/events` | 事件流 | Manager 详情页"事件"tab |
|
||||
| 10 | `GET /api/agnet/audit-logs` | 审计日志 | Manager 审计页 |
|
||||
| 11 | `POST /api/agnet/sk-snapshots/resolve` | 触发 SK 快照解析 | Manager 拉取/刷新 SK |
|
||||
| 12 | `GET /api/agnet/deployments/{id}/sk-snapshots` | 查询 SK 快照 | Manager 部署详情 |
|
||||
|
||||
### 2.1 mcp-server 调用 agent-manager 的认证模型
|
||||
|
||||
mcp-server 用**服务身份令牌**(service token)调 agent-manager,**不传**用户凭据:
|
||||
|
||||
```http
|
||||
POST /api/agnet/deployments
|
||||
Authorization: Bearer <manager-service-token>
|
||||
Content-Type: application/json
|
||||
X-Correlation-Id: <uuid>
|
||||
X-User-Id: <end-user-id>
|
||||
X-Binding-Scope: <binding_scope>
|
||||
Idempotency-Key: <uuid> # 创建类接口建议
|
||||
```
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `Authorization: Bearer <token>` | 是 | manager 的服务令牌;agent-manager 校验签名/有效期 |
|
||||
| `X-Correlation-Id` | 是 | mcp-server 生成;全链路追踪 ID |
|
||||
| `X-User-Id` | 建议 | 实际终端用户 ID;冗余于 body `user_context.user_id` |
|
||||
| `X-Binding-Scope` | 建议 | 当前操作的资源作用域;冗余于 body `resource_grants[].binding_scope` |
|
||||
| `Idempotency-Key` | 创建类建议 | mcp-server 生成;agent-manager 缓存幂等结果 |
|
||||
|
||||
**待决策**:服务令牌怎么发?三种方案:
|
||||
|
||||
| 方案 | 说明 |
|
||||
|---|---|
|
||||
| (A) Pre-shared bearer | mcp-server 配 env `AGENT_MANAGER_SERVICE_TOKEN`;agent-manager 配等值校验。最简单 |
|
||||
| (B) JWT 签发 | 共享 secret 签发短期 JWT;agent-manager 校验签名 |
|
||||
| (C) AKS Workload Identity | mcp-server pod 用 SA 拿 token;agent-manager 校验 OIDC issuer。最规范 |
|
||||
|
||||
mcp-server 团队建议: **(A) 先做,后期升 (C)**。请告知你们偏好。
|
||||
|
||||
### 2.2 通用响应包裹
|
||||
|
||||
成功:
|
||||
```json
|
||||
{ "success": true, "data": { ... } }
|
||||
```
|
||||
|
||||
失败(**结构化必填**):
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "POLICY_REJECTED",
|
||||
"message": "human readable",
|
||||
"request_id": "req_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
mcp-server 会按 business `code` 路由处理。建议 code 集合:
|
||||
|
||||
| code | 场景 | mcp-server 行为 |
|
||||
|---|---|---|
|
||||
| `POLICY_REJECTED` | 缺必填、风险等级非法 | 显示校验错误,不重试 |
|
||||
| `BUDGET_EXCEEDED` | 超 token/金额/时长预算 | 显示预算告警 |
|
||||
| `MODEL_NOT_ALLOWED` | 模型不在 allowed_model_ids 内 | 显示模型未授权 |
|
||||
| `FORBIDDEN_SCOPE` | header 与 body 用户/资源作用域不一致 | 阻断 + 写审计 |
|
||||
| `RESOURCE_GRANT_INVALID` | resource_grants 字段缺失 / 跨用户 / 角色不匹配 | 拒绝部署 |
|
||||
| `RESOURCE_GRANT_SECRET_REJECTED` | 请求中出现明文密钥字段 | 让 mcp-server 重新生成 payload |
|
||||
| `SK_SOURCE_UNRESOLVABLE` | SK 来源不可解析 | 重试或提示 |
|
||||
| `DEPLOYMENT_CONFLICT` | 部署不存在 / 状态冲突 / 重复提交 | 用 Idempotency-Key 查既有结果 |
|
||||
| `NOT_FOUND` | 资源不存在 | 返回空态 |
|
||||
| `CURSOR_EXPIRED` | 分页游标过期 | 弃 cursor,重新拉 |
|
||||
| `RATE_LIMITED` | 限流 | 按 `Retry-After` 退避 |
|
||||
| `INTERNAL_ERROR` | 内部错误 | 退避重试 + 人工排查 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口详情速览(agent-manager 视角)
|
||||
|
||||
> 完整 payload 字段见 heicode 仓库的 `agnet-platform-request-contract.md`,本节只给你们 server 端实现要点。
|
||||
|
||||
### 3.1 POST /api/agnet/deployments — 创建部署
|
||||
|
||||
**收到 payload 后必须做的事**:
|
||||
|
||||
1. **服务令牌校验** —— 401 否则
|
||||
2. **Idempotency-Key 查重** —— 若同 key 已处理,返回原结果(不重复创建 K8s deployment)
|
||||
3. **payload 字段校验**:
|
||||
- `orchestration_plan.intent_id` / `template_hint` / `objective` / `risk_level` / `budget` / `metadata.correlation_id` / `agents[]` 必填
|
||||
- `risk_level=high` 时 `agents[].resource_grants[].constraints.approval_id` 必须存在
|
||||
- `agents[].default_model_id` 若设置,必须 ∈ `constraints.allowed_model_ids`
|
||||
- `resource_grants[]`:`grant_id` / `resource_id` / `resource_type` / `user_id` / `binding_scope` / `target_role` / `target_agent_ref` / `permission_scope` / `status` 必填
|
||||
- 凭据型资源(git/sk/cloud_account/cloud_resource):`secret_ref` 必填;`project_doc` 可空
|
||||
- **`billing_context.provider`**(Heicode 2026-05-07 修订):枚举 = `newapi` | `litellm`
|
||||
- `newapi` → 子 Agent Pod 调模型走 Heicode NewAPI(`code.xinghanlab.com`)
|
||||
- `litellm` → 子 Agent Pod 调模型走 taijiagent LiteLLM
|
||||
- agent-manager 据此决定 Pod env 注入哪个 token:`HEICODE_NEWAPI_USER_TOKEN` 或 `LITELLM_USER_KEY`
|
||||
- **agent-manager 不需要做产品决策,仅按 mcp-server 传来的值路由**
|
||||
4. **敏感字段拒绝**:递归扫 `metadata` / `constraints` / `audit`,key 含 `password|token|secret|private_key|access_key|credential` → `RESOURCE_GRANT_SECRET_REJECTED`
|
||||
5. **审批校验**(仅 risk_level=high):
|
||||
- `approval_id` 在 `constraints` 或 `audit` 中
|
||||
- 审批主体 ∈ `user_context.user_id` / `resource_grants[].user_id`
|
||||
- 审批未过期(含 TTL / window)
|
||||
- 范围覆盖 `binding_scope` + `permission_scope` + 目标环境 + 资源 ID
|
||||
6. **创建 K8s Deployment**:
|
||||
- 命名空间:建议 `agnet-{user_id 短哈希}` 或现有规则
|
||||
- ServiceAccount:按 `agents[].role_template` + user_id 派生(见 §4)
|
||||
- Pod 启动配置:把 AGENT.md + resource_context + permission_manifest 写入 ConfigMap,挂到 Pod
|
||||
- **不写明文密钥**到 env / configmap
|
||||
7. **返回**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deployment_id": "dep_xxx",
|
||||
"status": "accepted",
|
||||
"agent_instances": [
|
||||
{ "instance_id": "agi_xxx", "role": "builder", "phase": "pending" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 POST /api/agnet/deployments/{id}/stop — 停止
|
||||
|
||||
- 已停止 → 200 + `status=stopped`(幂等)
|
||||
- 进入终态(如 `completed`)且无运行实例 → 409 `DEPLOYMENT_CONFLICT`
|
||||
- 高风险停止缺审批 → 422 `POLICY_REJECTED`
|
||||
|
||||
### 3.3 GET /api/agnet/deployments/{id}/logs — 日志(**强制脱敏**)
|
||||
|
||||
**返回前必须做**:扫描 `message` 字段,删/掩盖任何疑似密码、token、私钥、连接串、access key 的字符串。
|
||||
|
||||
字段:
|
||||
```json
|
||||
{
|
||||
"log_id": "log_xxx",
|
||||
"deployment_id": "dep_xxx",
|
||||
"agent_instance_id": "agi_xxx",
|
||||
"stream": "stdout|stderr|system|audit",
|
||||
"level": "info|warn|error",
|
||||
"message": "task started",
|
||||
"redacted": true,
|
||||
"occurred_at": "ISO 8601"
|
||||
}
|
||||
```
|
||||
|
||||
支持 query:`agent_instance_id`、`stream`、`since`、`limit`(默认 200,建议 max 1000)、`cursor`。
|
||||
|
||||
### 3.4 GET /api/agnet/deployments/{id}/events — 事件
|
||||
|
||||
至少实现这些事件名:
|
||||
- `deployment.accepted` — 平台接受请求
|
||||
- `instance.phase_changed` — 子 Agent phase 变化
|
||||
- `sk_snapshot_refreshed` — SK 快照刷新
|
||||
- `resource_grant.attached` / `resource_grant.revoked` — 授权绑定/撤销
|
||||
- `budget.threshold_reached` — 预算触发
|
||||
- `deployment.failed` — 部署失败
|
||||
|
||||
字段:`event_id` / `event` / `schema_version` / `user_id` / `channel_id` / `binding_scope` / `deployment_id` / `correlation_id` / `occurred_at`。
|
||||
|
||||
### 3.5 GET /api/agnet/projects/{binding_scope}/dashboard-snapshot — 监控快照
|
||||
|
||||
> 注意路径里写 `projects/{binding_scope}` 是契约保留旧名;参数值是 `binding_scope` 不是 project_id。
|
||||
|
||||
返回:active_instances、phase_distribution、failure_rate_1h、avg_task_duration、budget(tokens/cost/duration)、resource_usage(cpu/mem/network)、updated_at。
|
||||
|
||||
### 3.6 GET /api/agnet/deployments/{id}/metrics — 单部署指标(建议)
|
||||
|
||||
返回时间序列:
|
||||
- `tokens_used` (count)
|
||||
- `cost_usd` (number)
|
||||
- `duration_sec` (count)
|
||||
- `cpu_millicores` (millicore)
|
||||
- `memory_mb` (mb)
|
||||
- `restart_count` / `tool_call_count` / `error_count` / `queue_latency_ms`
|
||||
|
||||
支持 `window=15m&step=60s` 等参数。
|
||||
|
||||
### 3.7 GET /api/agnet/audit-logs — 审计日志
|
||||
|
||||
字段:`audit_id` / `actor` / `action` / `resource` / `user_id` / `channel_id` / `binding_scope` / `request_id` / `correlation_id` / `result` / `occurred_at`。
|
||||
|
||||
支持 query:`user_id`、`binding_scope`、`actor`、`action`、`since`、`limit`、`cursor`。
|
||||
|
||||
### 3.8 POST /api/agnet/sk-snapshots/resolve — SK 快照解析
|
||||
|
||||
请求:`{"deployment_id": "dep_xxx"}`
|
||||
|
||||
服务端动作:把 deployment 的 `agents[].sk_sources[]` 里的 git/upload 资源拉取下来,生成只读快照(**不带凭据**),生成 `snapshot_id` + `artifact_ref` + `checksum`。
|
||||
|
||||
### 3.9 GET /api/agnet/deployments/{id}/sk-snapshots — SK 快照查询
|
||||
|
||||
返回 snapshots 列表,含 `source_ref`(如 `main:skills/heicode/**@sha_xxx`)、`resolved_at`、`status: ready/resolving/failed`。
|
||||
|
||||
---
|
||||
|
||||
## 3a. 子 Agent 模型网关路由(v1.1 新增 — 必须实现)
|
||||
|
||||
### 3a.1 背景:为什么有这个章节
|
||||
|
||||
按 Heicode 团队 2026-05-07 的修订(详见 [`Heicode-完整调用流程图.md §2.5`](./Heicode-完整调用流程图.md)),整个生态有**两套并存的产品级模型网关**:
|
||||
|
||||
| 网关 | 服务对象 | provider 字段值 |
|
||||
|---|---|---|
|
||||
| **Heicode NewAPI** (`code.xinghanlab.com`) | cc-haha 桌面端用户、Heicode 用户部署的子 Agent | `"newapi"` |
|
||||
| **taijiagent LiteLLM** | 原生 taijiagent 用户、taijiagent 用户部署的子 Agent | `"litellm"` |
|
||||
|
||||
mcp-server 创建 deployment 时会在 `billing_context.provider` 字段告诉 agent-manager:"这个子 Agent 调模型走哪条网关"。
|
||||
|
||||
**agent-manager 不需要做产品决策**,只按 provider 字段路由。
|
||||
|
||||
### 3a.2 校验规则(agent-manager 在 §3.1 step 3 校验)
|
||||
|
||||
| 字段 | 取值 | 行为 |
|
||||
|---|---|---|
|
||||
| `billing_context.provider` | `"newapi"` | 走 Heicode NewAPI |
|
||||
| `billing_context.provider` | `"litellm"` | 走 taijiagent LiteLLM |
|
||||
| 缺失 / 其他值 | — | 返回 422 `POLICY_REJECTED`,message 提示有效取值 |
|
||||
|
||||
### 3a.3 token 来源约定
|
||||
|
||||
mcp-server 在 `resource_grants[]` 里会传 `secret_ref` 指向用户的模型调用 token:
|
||||
|
||||
```json
|
||||
{
|
||||
"billing_context": {
|
||||
"provider": "newapi",
|
||||
"newapi_user_ref": "newapi_user_123",
|
||||
"newapi_group": "development",
|
||||
"quota_ref": "newapi_token_or_group_quota_ref"
|
||||
},
|
||||
"agents": [{
|
||||
"resource_grants": [
|
||||
{
|
||||
"resource_type": "model_gateway_token",
|
||||
"secret_ref": "vault://secret/users/{user_id}/heicode/newapi_user_token",
|
||||
...
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
agent-manager 实现时:
|
||||
- 拿到 deployment 后,按 provider 找出对应的 `secret_ref`
|
||||
- 通过 Vault Kubernetes Auth 拿真实 token
|
||||
- 注入 Pod env(详见 §4.1 步骤 4)
|
||||
|
||||
### 3a.4 联调阶段简化(Phase 2-3 可接受)
|
||||
|
||||
Phase 2-3 联调时如果 Vault 还没就位,**允许临时用预共享 token**(agent-manager pod env 配一个测试用 token)作为 fallback,但必须:
|
||||
- 标注 `Deployment.metadata.annotations["heicode.io/token-source"] = "fallback-shared"`
|
||||
- Phase 5 (Vault 接入) 完成后立即删除 fallback 路径
|
||||
- 测试用 token 限额低(例如 $1/day)
|
||||
|
||||
### 3a.5 模型调用路径汇总
|
||||
|
||||
```
|
||||
子 Agent Pod (provider=newapi):
|
||||
POST /v1/chat/completions
|
||||
Authorization: Bearer ${HEICODE_NEWAPI_USER_TOKEN}
|
||||
↓
|
||||
https://code.xinghanlab.com (Heicode NewAPI)
|
||||
↓
|
||||
转发到 OpenAI / Claude / Gemini / ...
|
||||
|
||||
子 Agent Pod (provider=litellm):
|
||||
POST /v1/chat/completions
|
||||
Authorization: Bearer ${LITELLM_USER_KEY}
|
||||
↓
|
||||
${LITELLM_BASE_URL} (taijiagent LiteLLM)
|
||||
↓
|
||||
转发到 OpenAI / Claude / Gemini / ...
|
||||
```
|
||||
|
||||
两条路径**互不替代**,由 provider 字段一次性决定。
|
||||
|
||||
---
|
||||
|
||||
## 4. Pod 启动行为改造(必须)
|
||||
|
||||
依据 `heicode.md §七 AKS 上的 Agnet 凭证访问`。
|
||||
|
||||
### 4.1 推荐流程
|
||||
|
||||
```
|
||||
mcp-server POST /api/agnet/deployments (含 user_id, role, resource_grants, secret_refs,
|
||||
billing_context.provider)
|
||||
↓
|
||||
agent-manager:
|
||||
1. 在 AKS 创建 ServiceAccount(命名规则:sa-{role}-{user_id 短哈希})
|
||||
2. 给 SA 绑定 Vault Kubernetes Auth role(pol 路径包含 user_id + binding_scope)
|
||||
3. 创建 ConfigMap:AGENT.md + resource_context.json + permission_manifest.json
|
||||
4. ★ 按 billing_context.provider 路由模型网关 token:
|
||||
- provider=newapi → 从 secret_ref 拿 Heicode NewAPI user token
|
||||
注入 Pod env:
|
||||
HEICODE_NEWAPI_BASE_URL=https://code.xinghanlab.com
|
||||
HEICODE_NEWAPI_USER_TOKEN=<从 Vault/secret_ref 取>
|
||||
- provider=litellm → 从 secret_ref 拿 LiteLLM user key
|
||||
注入 Pod env:
|
||||
LITELLM_BASE_URL=<内网 LiteLLM 地址>
|
||||
LITELLM_USER_KEY=<从 Vault/secret_ref 取>
|
||||
5. 创建 Deployment,spec:
|
||||
- serviceAccountName: <上面那个 SA>
|
||||
- volumeMounts: ConfigMap 挂到 /etc/agent/
|
||||
- env (Vault 部分):
|
||||
VAULT_ADDR: 内网 Vault 地址
|
||||
VAULT_AUTH_PATH: /auth/kubernetes/login
|
||||
VAULT_ROLE: <上面 SA 绑定的 role>
|
||||
- env (模型网关部分): 见步骤 4 按 provider 决定
|
||||
- **不**写任何 GIT_TOKEN、AZURE_KEY 等业务凭据明文 env
|
||||
↓
|
||||
Pod 启动:
|
||||
- 读 ConfigMap 里的 AGENT.md / resource_context / permission_manifest
|
||||
- 调模型时用 HEICODE_NEWAPI_USER_TOKEN 或 LITELLM_USER_KEY
|
||||
- 调外部业务凭据(如 git clone)时,用 SA token 调 Vault 拿短期凭证,用完即弃
|
||||
```
|
||||
|
||||
> **关于模型 token 注入的安全权衡**(v1.1 补充):
|
||||
> NewAPI/LiteLLM user token 是"模型调用费用归属凭据",不是"业务最高权限凭据"。把它作为 env 一次性注入是 Heicode 团队认可的妥协方案(避免每次调模型都过 Vault)。Token 必须满足:
|
||||
> - 由 Heicode/taijiagent 平台**按 user 分发**(不是 admin token)
|
||||
> - **TTL 短**(建议 24h)或可被快速撤销
|
||||
> - **额度受限**(不超过用户 budget)
|
||||
> - agent-manager 在 Deployment annotation 里记 `secret_ref` 引用,便于审计/吊销追溯
|
||||
> - Pod 销毁时 token 也跟 Pod env 一起消失
|
||||
|
||||
### 4.2 ConfigMap 三个文件的格式建议
|
||||
|
||||
**AGENT.md**(自然语言上下文):
|
||||
```markdown
|
||||
# Role: backend builder
|
||||
# Goal: 在 services/api/** 路径下完成实现并提交代码
|
||||
# Resources you can use:
|
||||
- Git: <repo_url> (ref: main, paths: services/api/**, actions: read/write)
|
||||
- Models: gpt-5.4-mini (max_tokens: 100000)
|
||||
# Forbidden:
|
||||
- 修改 services/api/** 之外的文件
|
||||
- 创建新分支
|
||||
```
|
||||
|
||||
**resource_context.json**(结构化资源元数据,**无密钥**):
|
||||
```json
|
||||
{
|
||||
"agent_role": "backend",
|
||||
"deployment_id": "dep_xxx",
|
||||
"resources": [
|
||||
{
|
||||
"resource_id": "res_git_001",
|
||||
"type": "git",
|
||||
"external_ref": "https://example.com/org/repo.git",
|
||||
"constraints": { "ref": "main", "allowed_paths": "services/api/**" },
|
||||
"secret_ref": "vault://secret/users/{user_id}/bindings/repo_default/resources/res_git_001"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**permission_manifest.json**(结构化权限清单,**给系统强制执行用**):
|
||||
```json
|
||||
{
|
||||
"user_id": "user_123",
|
||||
"binding_scope": "repo_default",
|
||||
"agent_role": "backend",
|
||||
"resource_grants": [
|
||||
{
|
||||
"grant_id": "grant_xxx",
|
||||
"resource_type": "git",
|
||||
"allowed_actions": ["repo:read"],
|
||||
"constraints": { "ref": "main", "allowed_paths": "services/api/**" },
|
||||
"secret_ref": "vault://..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 强制规定
|
||||
|
||||
| 项 | 必须 | 不得 |
|
||||
|---|---|---|
|
||||
| Pod env | 仅 VAULT_ADDR / VAULT_ROLE / 公开配置 | 任何长期凭据、连接串、token、密码 |
|
||||
| ConfigMap 内容 | 元数据 + secret_ref 引用 | 凭据原文 |
|
||||
| Pod 日志 | 脱敏后输出 | 凭据片段、env dump |
|
||||
| Pod 镜像 | 公共 base + 启动 script | 凭据嵌入到镜像 |
|
||||
| Vault 访问 | 通过 SA + Kubernetes Auth | Pod 直接拿 root token |
|
||||
|
||||
---
|
||||
|
||||
## 5. AKS 基础设施对齐(与基础设施团队协作)
|
||||
|
||||
### 5.1 Workload Identity 启用
|
||||
|
||||
- AKS 集群启用 OIDC issuer + Workload Identity addon
|
||||
- 命名空间级 ServiceAccount 标注:
|
||||
```yaml
|
||||
metadata:
|
||||
annotations:
|
||||
azure.workload.identity/client-id: <managed-identity-client-id>
|
||||
```
|
||||
- 给 SA 配 Federated Identity Credential 关联到 Azure AD
|
||||
|
||||
### 5.2 Vault Kubernetes Auth 配置
|
||||
|
||||
```hcl
|
||||
# Vault policy: per (user_id, binding_scope) 派生
|
||||
path "secret/users/${user_id}/bindings/${binding_scope}/resources/*" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
# Kubernetes Auth role: 绑定 SA → policy
|
||||
{
|
||||
"bound_service_account_names": ["sa-backend-${user_id_hash}"],
|
||||
"bound_service_account_namespaces": ["agnet-${user_id_hash}"],
|
||||
"policies": ["heicode-${user_id}-${binding_scope}"],
|
||||
"ttl": "1h"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 网络策略
|
||||
|
||||
- agent-manager → Vault:内网;Vault 不暴露公网
|
||||
- Pod → Vault:通过 K8s service 或 private endpoint
|
||||
- Pod → Git/Cloud:按 `network_policy_ref` 限制出站
|
||||
|
||||
---
|
||||
|
||||
## 6. 当前业务影响(保证现有 taiji 业务不挂)
|
||||
|
||||
agent-manager 当前接口(核实自 mcp-server 老代码 `app/agent_manager_client.py`,2026-05-05):
|
||||
|
||||
| 方法 | 路径 | mcp-server 调用方 |
|
||||
|---|---|---|
|
||||
| GET | `/templates` | 列模板 |
|
||||
| GET | `/templates/platform` | 平台模板 |
|
||||
| GET | `/templates/custom` | 自定义模板 |
|
||||
| GET | `/templates/{template_name}` | 单模板详情 |
|
||||
| POST | `/agents` | 创建 Agent(payload: AgentConfig) |
|
||||
| GET | `/agents` | 列 Agent |
|
||||
| GET | `/agents/{name}/status` | Agent 状态 |
|
||||
| GET | `/agents/{name}/metrics` | Agent 指标 |
|
||||
| GET | `/agents/{name}/logs` | Agent 日志 |
|
||||
| DELETE | `/agents/{name}` | 删除 Agent |
|
||||
| POST | `/agents/{name}/restart` | 重启 |
|
||||
| PATCH | `/agents/{name}` (scale) | 扩缩容 |
|
||||
| POST | `/external-tools/{tool_id}` 等 | 外部工具生成/更新/删除 |
|
||||
| POST | `/external-tools/agents/create-with-tools` | 用工具集创建 Agent |
|
||||
| GET | `/resources/stats` | 资源统计 |
|
||||
| GET | `/resources/user/{id}` | 用户资源 |
|
||||
| GET | `/resources/channel/{id}` | 渠道资源 |
|
||||
| GET | `/health` | 健康检查 |
|
||||
|
||||
**前缀对比**:
|
||||
- 老 API:根路径下 `/templates/*`、`/agents/*`、`/external-tools/*`、`/resources/*`、`/health`
|
||||
- 新 Heicode 契约:`/api/agnet/*`
|
||||
|
||||
**纪律(已经核实无冲突)**:
|
||||
- ✅ 前缀完全不重叠 → 老接口和新接口可以**并存**
|
||||
- ✅ 路径冲突 = 0
|
||||
- ❌ 不改老接口路径、字段、响应形态
|
||||
- ❌ 不改老的 K8s namespace 命名规则(taiji 老 Agent 还在跑)
|
||||
|
||||
**纪律**:
|
||||
- ✅ 全部新增 12 个接口在 `/api/agnet/*` 前缀下
|
||||
- ❌ 不改老接口路径、字段、响应形态
|
||||
- ❌ 不改老的 K8s namespace 命名规则(taiji 老 Agent 还在跑)
|
||||
- ✅ 新建用 `agnet-*` namespace,与老 namespace 隔离
|
||||
|
||||
---
|
||||
|
||||
## 7. 联调计划
|
||||
|
||||
### Phase 1: 服务令牌打通(半天)
|
||||
1. mcp-server 配置环境变量 `AGENT_MANAGER_SERVICE_TOKEN`
|
||||
2. agent-manager 实现 token 校验中间件
|
||||
3. mcp-server 写一个 dummy 调用,确认 401/200 通畅
|
||||
|
||||
### Phase 2: POST /api/agnet/deployments 通跑(2-3 天)
|
||||
1. agent-manager 实现接口(不要求真起 Pod,先打日志返回 mock deployment_id)
|
||||
2. mcp-server 写出站客户端
|
||||
3. 联调 payload 校验、错误码、Idempotency-Key
|
||||
|
||||
### Phase 3: 状态/日志/事件/审计(3-5 天)
|
||||
- agent-manager 实现 GET 类接口
|
||||
- 至少能返回 mock 数据或真实 K8s 数据
|
||||
|
||||
### Phase 4: 真实 Pod 部署(5-7 天)
|
||||
- 接入 K8s API 真起 Deployment
|
||||
- ConfigMap 写 AGENT.md / resource_context / permission_manifest
|
||||
- Pod 启动后能读到这些文件
|
||||
|
||||
### Phase 5: AKS Workload Identity + Vault(1-2 周)
|
||||
- 基础设施部署 Vault
|
||||
- ServiceAccount + Workload Identity 联通
|
||||
- Pod 通过 SA 调 Vault 拿短期凭据
|
||||
|
||||
---
|
||||
|
||||
## 8. mcp-server 这边能给的支持
|
||||
|
||||
mcp-server(Heicode Manager)已经准备好的:
|
||||
|
||||
| 项 | 状态 |
|
||||
|---|---|
|
||||
| ResourceBinding/Grant 数据模型 + 9 个 CRUD 接口 | ✅ 已上线 |
|
||||
| 登录联邦(heicode 调 mcp-server `/me` `/refresh`)| ✅ 已上线 |
|
||||
| 从 mcp-server 出站调 agent-manager 的客户端代码 | ⏳ 等 agent-manager 接口 ready 后做(~3-5 天) |
|
||||
| 本地 stub `/api/agnet/*` 给前端联调用 | ⏳ 1-2 天可交付 |
|
||||
|
||||
**请 agent-manager 团队尽快确认**:
|
||||
- ❓ 你们偏好哪种服务令牌方案(A pre-shared / B JWT / C Workload Identity)?
|
||||
- ❓ 你们的开发节奏?(按 §7 phase 排,预计 3-4 周完整闭环)
|
||||
- ❓ 联调环境地址:staging 用什么 base URL?mcp-server 这边怎么配?
|
||||
- ❓ 现有 agent-manager 老接口的契约文档在哪?mcp-server 老代码还在调,避免迁移时踩坑
|
||||
|
||||
---
|
||||
|
||||
## 9. 快速导航
|
||||
|
||||
| 我想了解… | 看哪 |
|
||||
|---|---|
|
||||
| Heicode 整体边界 | `heicode.md`(heicode 仓库 docs/) |
|
||||
| 12 接口完整 payload | `integration/agnet-platform-request-contract.md` |
|
||||
| Pod 启动安全约束 | `heicode.md §七` + `heicode-runtime-auth-newapi-secret-design.md §三` |
|
||||
| mcp-server 已上线接口 | `Docs/Heicode-接口契约文档.md`(mcp-server 仓库) |
|
||||
| 整体进度与待办 | `Docs/Heicode-对接进度与待办.md`(mcp-server 仓库) |
|
||||
| 部署安全清单 | `deployment/azure-production-deploy-guardrails.md`(heicode 仓库) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 联系
|
||||
|
||||
mcp-server 这边联系点:
|
||||
- 出站客户端代码改动:mcp-server 后端
|
||||
- 接口契约对齐:见 §2.2 错误码表与 §3 各接口
|
||||
- 测试账号、APIM 路由、CORS 等:mcp-server 后端
|
||||
|
||||
如发现本文档与 heicode 主线文档冲突,**以 heicode 主线为准**,并请回函通知 mcp-server 同步更新。
|
||||
@@ -0,0 +1,832 @@
|
||||
# Heicode Agent Manager API 对接文档
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [1. 概述](#1-概述)
|
||||
- [2. 认证方式](#2-认证方式)
|
||||
- [3. API 端点](#3-api-端点)
|
||||
- [4. 数据模型](#4-数据模型)
|
||||
- [5. 使用示例](#5-使用示例)
|
||||
- [6. 错误处理](#6-错误处理)
|
||||
- [7. 最佳实践](#7-最佳实践)
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 服务信息
|
||||
|
||||
- **服务名称**: Agent Manager - Heicode Integration API
|
||||
- **版本**: v2.0.0 (heicode-v2)
|
||||
- **Base URL**: `http://agent-manager.taijiagnet.com`
|
||||
- **API 前缀**: `/api/agnet`
|
||||
|
||||
### 1.2 核心功能
|
||||
|
||||
- ✅ 多 Agent 编排部署
|
||||
- ✅ 预算控制和计费管理
|
||||
- ✅ 风险等级评估(low/medium/high)
|
||||
- ✅ Vault 密钥集成
|
||||
- ✅ 实时日志和事件追踪
|
||||
- ✅ 资源监控和指标统计
|
||||
- ✅ 幂等性保证
|
||||
|
||||
### 1.3 架构说明
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Heicode │
|
||||
│ Platform │
|
||||
└──────┬──────┘
|
||||
│ HTTPS + Token Auth
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ Agent Manager API │
|
||||
│ /api/agnet/* │
|
||||
└──────┬──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ Kubernetes Cluster (AKS) │
|
||||
│ - Namespace 隔离 │
|
||||
│ - Pod 管理 │
|
||||
│ - ConfigMap/Secret │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 认证方式
|
||||
|
||||
### 2.1 Service Token 认证
|
||||
|
||||
所有 API 请求必须在 HTTP Header 中携带服务令牌:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <HEICODE_SERVICE_TOKEN>
|
||||
```
|
||||
|
||||
### 2.2 必需的 HTTP Headers
|
||||
|
||||
| Header | 必需 | 说明 | 示例 |
|
||||
|--------|------|------|------|
|
||||
| `Authorization` | ✅ | 服务令牌 | `Bearer sk_xxx` |
|
||||
| `X-User-ID` | ✅ | 用户标识 | `user_12345` |
|
||||
| `X-Binding-Scope` | ✅ | 绑定范围 | `workspace_abc` |
|
||||
| `X-Correlation-ID` | ✅ | 请求追踪 ID | `req_xyz789` |
|
||||
| `X-Idempotency-Key` | ⚪ | 幂等性键(推荐) | `idem_abc123` |
|
||||
| `Content-Type` | ✅ | 内容类型 | `application/json` |
|
||||
|
||||
### 2.3 获取 Service Token
|
||||
|
||||
请联系系统管理员获取 `HEICODE_SERVICE_TOKEN`。
|
||||
|
||||
---
|
||||
|
||||
## 3. API 端点
|
||||
|
||||
### 3.1 健康检查
|
||||
|
||||
#### `GET /api/agnet/health`
|
||||
|
||||
检查服务状态。
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
curl -X GET "http://agent-manager.taijiagnet.com/api/agnet/health" \
|
||||
-H "Authorization: Bearer sk_xxx"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "healthy",
|
||||
"service": "agent-manager-agnet",
|
||||
"version": "1.0.0",
|
||||
"phase": "2-deployments"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 创建部署
|
||||
|
||||
#### `POST /api/agnet/deployments`
|
||||
|
||||
创建一个新的 Agent 部署。
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"orchestration_plan": "multi-agent-workflow",
|
||||
"risk_level": "medium",
|
||||
"approval_token": "optional_for_high_risk",
|
||||
"budget": {
|
||||
"max_usd": 100.0,
|
||||
"alert_threshold_pct": 80
|
||||
},
|
||||
"billing_context": {
|
||||
"provider": "newapi",
|
||||
"default_model_id": "gpt-4",
|
||||
"allowed_model_ids": ["gpt-4", "gpt-3.5-turbo"],
|
||||
"secret_ref": "vault:heicode/model-gateway-key"
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"role": "researcher",
|
||||
"image": "agnettaiji.azurecr.io/ai-agents/search-agent:latest"
|
||||
},
|
||||
{
|
||||
"role": "writer",
|
||||
"image": "agnettaiji.azurecr.io/ai-agents/doc-creator:latest"
|
||||
}
|
||||
],
|
||||
"resource_grants": [
|
||||
{
|
||||
"type": "database",
|
||||
"ref": "vault:heicode/db-credentials"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"deployment_id": "dep_a1b2c3d4e5f6",
|
||||
"status": "pending",
|
||||
"agent_instances": [
|
||||
{
|
||||
"agent_instance_id": "agi_123abc",
|
||||
"role": "researcher",
|
||||
"status": "pending",
|
||||
"phase": null
|
||||
},
|
||||
{
|
||||
"agent_instance_id": "agi_456def",
|
||||
"role": "writer",
|
||||
"status": "pending",
|
||||
"phase": null
|
||||
}
|
||||
],
|
||||
"created_at": "2026-05-12T10:30:00Z",
|
||||
"estimated_ready_at": "2026-05-12T10:32:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 列出部署
|
||||
|
||||
#### `GET /api/agnet/deployments`
|
||||
|
||||
获取部署列表,支持过滤和分页。
|
||||
|
||||
**查询参数**:
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `user_id` | string | ⚪ | 按用户过滤 |
|
||||
| `binding_scope` | string | ⚪ | 按绑定范围过滤 |
|
||||
| `status` | string | ⚪ | 按状态过滤 (pending/running/stopped/failed) |
|
||||
| `limit` | integer | ⚪ | 每页数量 (默认 50, 最大 200) |
|
||||
| `cursor` | string | ⚪ | 分页游标 |
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
curl -X GET "http://agent-manager.taijiagnet.com/api/agnet/deployments?user_id=user_123&status=running&limit=10" \
|
||||
-H "Authorization: Bearer sk_xxx" \
|
||||
-H "X-User-ID: user_123" \
|
||||
-H "X-Binding-Scope: workspace_abc" \
|
||||
-H "X-Correlation-ID: req_list_001"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"deployments": [
|
||||
{
|
||||
"deployment_id": "dep_a1b2c3d4e5f6",
|
||||
"status": "running",
|
||||
"risk_level": "medium",
|
||||
"budget": {
|
||||
"max_usd": 100.0,
|
||||
"consumed_usd": 23.5,
|
||||
"remaining_usd": 76.5
|
||||
},
|
||||
"created_at": "2026-05-12T10:30:00Z",
|
||||
"agent_instances_count": 2
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"next_cursor": null,
|
||||
"has_more": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 获取部署详情
|
||||
|
||||
#### `GET /api/agnet/deployments/{deployment_id}`
|
||||
|
||||
获取指定部署的详细信息。
|
||||
|
||||
**路径参数**:
|
||||
- `deployment_id`: 部署 ID
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
curl -X GET "http://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6" \
|
||||
-H "Authorization: Bearer sk_xxx" \
|
||||
-H "X-User-ID: user_123" \
|
||||
-H "X-Binding-Scope: workspace_abc" \
|
||||
-H "X-Correlation-ID: req_get_001"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"deployment_id": "dep_a1b2c3d4e5f6",
|
||||
"user_id": "user_123",
|
||||
"binding_scope": "workspace_abc",
|
||||
"status": "running",
|
||||
"phase": "executing",
|
||||
"orchestration_plan": "multi-agent-workflow",
|
||||
"risk_level": "medium",
|
||||
"budget": {
|
||||
"max_usd": 100.0,
|
||||
"consumed_usd": 23.5,
|
||||
"remaining_usd": 76.5
|
||||
},
|
||||
"billing_context": {
|
||||
"provider": "newapi",
|
||||
"default_model_id": "gpt-4",
|
||||
"allowed_model_ids": ["gpt-4", "gpt-3.5-turbo"]
|
||||
},
|
||||
"agent_instances": [
|
||||
{
|
||||
"agent_instance_id": "agi_123abc",
|
||||
"role": "researcher",
|
||||
"status": "running",
|
||||
"phase": "searching"
|
||||
},
|
||||
{
|
||||
"agent_instance_id": "agi_456def",
|
||||
"role": "writer",
|
||||
"status": "running",
|
||||
"phase": "writing"
|
||||
}
|
||||
],
|
||||
"resource_grants": [
|
||||
{
|
||||
"type": "database",
|
||||
"ref": "vault:heicode/db-credentials"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-05-12T10:30:00Z",
|
||||
"updated_at": "2026-05-12T10:35:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.5 停止部署
|
||||
|
||||
#### `POST /api/agnet/deployments/{deployment_id}/stop`
|
||||
|
||||
停止一个正在运行的部署。
|
||||
|
||||
**路径参数**:
|
||||
- `deployment_id`: 部署 ID
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"reason": "User requested stop",
|
||||
"approval_token": "optional_for_high_risk"
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
curl -X POST "http://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6/stop" \
|
||||
-H "Authorization: Bearer sk_xxx" \
|
||||
-H "X-User-ID: user_123" \
|
||||
-H "X-Binding-Scope: workspace_abc" \
|
||||
-H "X-Correlation-ID: req_stop_001" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"reason": "Task completed"
|
||||
}'
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"deployment_id": "dep_a1b2c3d4e5f6",
|
||||
"status": "stopped",
|
||||
"stopped_at": "2026-05-12T11:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.6 获取部署日志
|
||||
|
||||
#### `GET /api/agnet/deployments/{deployment_id}/logs`
|
||||
|
||||
获取部署的实时日志。
|
||||
|
||||
**路径参数**:
|
||||
- `deployment_id`: 部署 ID
|
||||
|
||||
**查询参数**:
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `agent_instance_id` | string | ⚪ | 按 Agent 实例过滤 |
|
||||
| `since` | datetime | ⚪ | 起始时间 (ISO 8601) |
|
||||
| `limit` | integer | ⚪ | 日志条数 (默认 100) |
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
curl -X GET "http://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6/logs?limit=50" \
|
||||
-H "Authorization: Bearer sk_xxx" \
|
||||
-H "X-User-ID: user_123" \
|
||||
-H "X-Binding-Scope: workspace_abc" \
|
||||
-H "X-Correlation-ID: req_logs_001"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"deployment_id": "dep_a1b2c3d4e5f6",
|
||||
"logs": [
|
||||
{
|
||||
"timestamp": "2026-05-12T10:31:00Z",
|
||||
"agent_instance_id": "agi_123abc",
|
||||
"level": "info",
|
||||
"message": "Starting search task...",
|
||||
"source": "stdout"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-12T10:31:05Z",
|
||||
"agent_instance_id": "agi_123abc",
|
||||
"level": "info",
|
||||
"message": "Found 10 relevant documents",
|
||||
"source": "stdout"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"has_more": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 获取部署事件
|
||||
|
||||
#### `GET /api/agnet/deployments/{deployment_id}/events`
|
||||
|
||||
获取部署的事件历史。
|
||||
|
||||
**路径参数**:
|
||||
- `deployment_id`: 部署 ID
|
||||
|
||||
**查询参数**:
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `event_type` | string | ⚪ | 事件类型过滤 |
|
||||
| `since` | datetime | ⚪ | 起始时间 (ISO 8601) |
|
||||
| `limit` | integer | ⚪ | 事件条数 (默认 100) |
|
||||
|
||||
**事件类型**:
|
||||
- `deployment.accepted` - 部署已接受
|
||||
- `deployment.started` - 部署已启动
|
||||
- `deployment.stopped` - 部署已停止
|
||||
- `deployment.failed` - 部署失败
|
||||
- `agent.started` - Agent 启动
|
||||
- `agent.completed` - Agent 完成
|
||||
- `budget.alert` - 预算告警
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
curl -X GET "http://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6/events" \
|
||||
-H "Authorization: Bearer sk_xxx" \
|
||||
-H "X-User-ID: user_123" \
|
||||
-H "X-Binding-Scope: workspace_abc" \
|
||||
-H "X-Correlation-ID: req_events_001"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"deployment_id": "dep_a1b2c3d4e5f6",
|
||||
"events": [
|
||||
{
|
||||
"event_id": "evt_abc123",
|
||||
"event_type": "deployment.accepted",
|
||||
"agent_instance_id": null,
|
||||
"occurred_at": "2026-05-12T10:30:00Z",
|
||||
"payload": {
|
||||
"risk_level": "medium"
|
||||
}
|
||||
},
|
||||
{
|
||||
"event_id": "evt_def456",
|
||||
"event_type": "agent.started",
|
||||
"agent_instance_id": "agi_123abc",
|
||||
"occurred_at": "2026-05-12T10:31:00Z",
|
||||
"payload": {
|
||||
"role": "researcher"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"has_more": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.8 获取资源指标
|
||||
|
||||
#### `GET /api/agnet/deployments/{deployment_id}/metrics`
|
||||
|
||||
获取部署的资源使用指标。
|
||||
|
||||
**路径参数**:
|
||||
- `deployment_id`: 部署 ID
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
curl -X GET "http://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6/metrics" \
|
||||
-H "Authorization: Bearer sk_xxx" \
|
||||
-H "X-User-ID: user_123" \
|
||||
-H "X-Binding-Scope: workspace_abc" \
|
||||
-H "X-Correlation-ID: req_metrics_001"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"deployment_id": "dep_a1b2c3d4e5f6",
|
||||
"timestamp": "2026-05-12T10:35:00Z",
|
||||
"agent_metrics": [
|
||||
{
|
||||
"agent_instance_id": "agi_123abc",
|
||||
"role": "researcher",
|
||||
"status": "running",
|
||||
"resources": {
|
||||
"cpu_usage_cores": 0.25,
|
||||
"memory_usage_mb": 256.0,
|
||||
"network_rx_bytes": 1048576,
|
||||
"network_tx_bytes": 524288
|
||||
},
|
||||
"uptime_seconds": 300
|
||||
},
|
||||
{
|
||||
"agent_instance_id": "agi_456def",
|
||||
"role": "writer",
|
||||
"status": "running",
|
||||
"resources": {
|
||||
"cpu_usage_cores": 0.15,
|
||||
"memory_usage_mb": 128.0,
|
||||
"network_rx_bytes": 524288,
|
||||
"network_tx_bytes": 262144
|
||||
},
|
||||
"uptime_seconds": 300
|
||||
}
|
||||
],
|
||||
"total_resources": {
|
||||
"cpu_usage_cores": 0.40,
|
||||
"memory_usage_mb": 384.0,
|
||||
"network_rx_bytes": 1572864,
|
||||
"network_tx_bytes": 786432
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 部署状态 (DeploymentStatus)
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| `pending` | 等待启动 |
|
||||
| `running` | 运行中 |
|
||||
| `stopped` | 已停止 |
|
||||
| `failed` | 失败 |
|
||||
|
||||
### 4.2 风险等级 (RiskLevel)
|
||||
|
||||
| 等级 | 说明 | 审批要求 |
|
||||
|------|------|----------|
|
||||
| `low` | 低风险 | 无需审批 |
|
||||
| `medium` | 中风险 | 无需审批 |
|
||||
| `high` | 高风险 | 需要 approval_token |
|
||||
|
||||
### 4.3 计费提供商 (BillingProvider)
|
||||
|
||||
| 提供商 | 说明 |
|
||||
|--------|------|
|
||||
| `newapi` | Heicode NewAPI Gateway |
|
||||
| `litellm` | LiteLLM Proxy |
|
||||
|
||||
### 4.4 资源授权类型 (ResourceGrantType)
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| `database` | 数据库访问 |
|
||||
| `storage` | 存储访问 |
|
||||
| `api` | API 访问 |
|
||||
| `custom` | 自定义资源 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 使用示例
|
||||
|
||||
### 5.1 完整工作流示例
|
||||
|
||||
```python
|
||||
import requests
|
||||
import time
|
||||
|
||||
# 配置
|
||||
BASE_URL = "http://agent-manager.taijiagnet.com"
|
||||
TOKEN = "sk_your_service_token"
|
||||
USER_ID = "user_123"
|
||||
BINDING_SCOPE = "workspace_abc"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {TOKEN}",
|
||||
"X-User-ID": USER_ID,
|
||||
"X-Binding-Scope": BINDING_SCOPE,
|
||||
"X-Correlation-ID": f"req_{int(time.time())}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# 1. 创建部署
|
||||
create_payload = {
|
||||
"orchestration_plan": "research-and-write",
|
||||
"risk_level": "medium",
|
||||
"budget": {
|
||||
"max_usd": 50.0,
|
||||
"alert_threshold_pct": 80
|
||||
},
|
||||
"billing_context": {
|
||||
"provider": "newapi",
|
||||
"default_model_id": "gpt-4",
|
||||
"allowed_model_ids": ["gpt-4", "gpt-3.5-turbo"],
|
||||
"secret_ref": "vault:heicode/model-gateway-key"
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"role": "researcher",
|
||||
"image": "agnettaiji.azurecr.io/ai-agents/search-agent:latest"
|
||||
},
|
||||
{
|
||||
"role": "writer",
|
||||
"image": "agnettaiji.azurecr.io/ai-agents/doc-creator:latest"
|
||||
}
|
||||
],
|
||||
"resource_grants": []
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/agnet/deployments",
|
||||
headers=headers,
|
||||
json=create_payload
|
||||
)
|
||||
deployment = response.json()
|
||||
deployment_id = deployment["deployment_id"]
|
||||
print(f"✅ 部署创建成功: {deployment_id}")
|
||||
|
||||
# 2. 等待部署就绪
|
||||
time.sleep(120) # 等待 2 分钟
|
||||
|
||||
# 3. 获取部署详情
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/agnet/deployments/{deployment_id}",
|
||||
headers=headers
|
||||
)
|
||||
details = response.json()
|
||||
print(f"📊 部署状态: {details['status']}")
|
||||
|
||||
# 4. 获取实时日志
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/agnet/deployments/{deployment_id}/logs?limit=20",
|
||||
headers=headers
|
||||
)
|
||||
logs = response.json()
|
||||
print(f"📝 最新日志: {len(logs['logs'])} 条")
|
||||
|
||||
# 5. 获取资源指标
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/agnet/deployments/{deployment_id}/metrics",
|
||||
headers=headers
|
||||
)
|
||||
metrics = response.json()
|
||||
print(f"💻 CPU 使用: {metrics['total_resources']['cpu_usage_cores']} cores")
|
||||
print(f"💾 内存使用: {metrics['total_resources']['memory_usage_mb']} MB")
|
||||
|
||||
# 6. 停止部署
|
||||
stop_payload = {
|
||||
"reason": "Task completed successfully"
|
||||
}
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/agnet/deployments/{deployment_id}/stop",
|
||||
headers=headers,
|
||||
json=stop_payload
|
||||
)
|
||||
result = response.json()
|
||||
print(f"🛑 部署已停止: {result['stopped_at']}")
|
||||
```
|
||||
|
||||
### 5.2 幂等性示例
|
||||
|
||||
使用 `X-Idempotency-Key` 确保请求幂等性:
|
||||
|
||||
```python
|
||||
import uuid
|
||||
|
||||
idempotency_key = f"idem_{uuid.uuid4().hex}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {TOKEN}",
|
||||
"X-User-ID": USER_ID,
|
||||
"X-Binding-Scope": BINDING_SCOPE,
|
||||
"X-Correlation-ID": f"req_{int(time.time())}",
|
||||
"X-Idempotency-Key": idempotency_key, # 幂等性键
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# 第一次请求
|
||||
response1 = requests.post(
|
||||
f"{BASE_URL}/api/agnet/deployments",
|
||||
headers=headers,
|
||||
json=create_payload
|
||||
)
|
||||
|
||||
# 重复请求(使用相同的 idempotency_key)
|
||||
response2 = requests.post(
|
||||
f"{BASE_URL}/api/agnet/deployments",
|
||||
headers=headers,
|
||||
json=create_payload
|
||||
)
|
||||
|
||||
# response1 和 response2 返回相同的结果
|
||||
assert response1.json()["deployment_id"] == response2.json()["deployment_id"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 错误处理
|
||||
|
||||
### 6.1 错误响应格式
|
||||
|
||||
所有错误响应遵循统一格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Human-readable error message",
|
||||
"request_id": "req_xyz789"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 错误码列表
|
||||
|
||||
| HTTP 状态码 | 错误码 | 说明 |
|
||||
|------------|--------|------|
|
||||
| 401 | `UNAUTHORIZED` | 认证失败,Token 无效 |
|
||||
| 403 | `FORBIDDEN` | 权限不足 |
|
||||
| 404 | `DEPLOYMENT_NOT_FOUND` | 部署不存在 |
|
||||
| 409 | `DEPLOYMENT_CONFLICT` | 部署状态冲突 |
|
||||
| 422 | `MODEL_NOT_ALLOWED` | 模型不在允许列表中 |
|
||||
| 422 | `POLICY_REJECTED` | 策略拒绝(如高风险需审批) |
|
||||
| 422 | `VALIDATION_ERROR` | 请求参数验证失败 |
|
||||
| 500 | `INTERNAL_ERROR` | 服务器内部错误 |
|
||||
|
||||
### 6.3 错误处理示例
|
||||
|
||||
```python
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/agnet/deployments",
|
||||
headers=headers,
|
||||
json=create_payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
deployment = response.json()
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
error_data = e.response.json()
|
||||
error_code = error_data["error"]["code"]
|
||||
error_message = error_data["error"]["message"]
|
||||
|
||||
if error_code == "MODEL_NOT_ALLOWED":
|
||||
print(f"❌ 模型配置错误: {error_message}")
|
||||
elif error_code == "POLICY_REJECTED":
|
||||
print(f"❌ 需要审批: {error_message}")
|
||||
else:
|
||||
print(f"❌ 请求失败: {error_message}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 最佳实践
|
||||
|
||||
### 7.1 认证和安全
|
||||
|
||||
✅ **推荐做法**:
|
||||
- 将 Service Token 存储在环境变量或密钥管理系统中
|
||||
- 使用 HTTPS 进行所有 API 调用
|
||||
- 定期轮换 Service Token
|
||||
- 使用 Vault 存储敏感配置(如 API Key)
|
||||
|
||||
❌ **避免**:
|
||||
- 在代码中硬编码 Token
|
||||
- 在日志中打印 Token
|
||||
- 在 URL 参数中传递敏感信息
|
||||
|
||||
### 7.2 幂等性
|
||||
|
||||
✅ **推荐做法**:
|
||||
- 对所有创建操作使用 `X-Idempotency-Key`
|
||||
- 使用 UUID 或时间戳生成唯一的幂等性键
|
||||
- 在网络不稳定时重试请求
|
||||
|
||||
### 7.3 预算控制
|
||||
|
||||
✅ **推荐做法**:
|
||||
- 设置合理的 `max_usd` 预算上限
|
||||
- 设置 `alert_threshold_pct` 为 80-90%
|
||||
- 定期检查 `consumed_usd` 和 `remaining_usd`
|
||||
- 在预算告警时及时停止部署
|
||||
|
||||
### 7.4 日志和监控
|
||||
|
||||
✅ **推荐做法**:
|
||||
- 使用 `X-Correlation-ID` 追踪请求链路
|
||||
- 定期轮询 `/logs` 和 `/events` 端点
|
||||
- 监控 `/metrics` 端点的资源使用情况
|
||||
- 保存审计日志用于问题排查
|
||||
|
||||
### 7.5 错误处理
|
||||
|
||||
✅ **推荐做法**:
|
||||
- 实现指数退避重试机制
|
||||
- 区分可重试错误(5xx)和不可重试错误(4xx)
|
||||
- 记录完整的错误上下文(request_id, correlation_id)
|
||||
- 为高风险操作准备回滚方案
|
||||
|
||||
### 7.6 性能优化
|
||||
|
||||
✅ **推荐做法**:
|
||||
- 使用分页参数避免一次性获取大量数据
|
||||
- 缓存不常变化的数据(如模板列表)
|
||||
- 使用 `since` 参数增量获取日志和事件
|
||||
- 并发调用独立的 API 端点
|
||||
|
||||
---
|
||||
|
||||
## 8. 附录
|
||||
|
||||
### 8.1 支持的 Agent 镜像
|
||||
|
||||
| Agent 类型 | 镜像地址 | 说明 |
|
||||
|-----------|---------|------|
|
||||
| Search Agent | `agnettaiji.azurecr.io/ai-agents/search-agent:latest` | 搜索和信息检索 |
|
||||
| Doc Creator | `agnettaiji.azurecr.io/ai-agents/doc-creator:latest` | 文档生成 |
|
||||
| Code AI Agent | `agnettaiji.azurecr.io/ai-agents/code-ai-agent:latest` | 代码生成和 CI/CD |
|
||||
| Ad Creator | `agnettaiji.azurecr.io/ai-agents/ad-creator:latest` | 广告创意生成 |
|
||||
| Video Generator | `agnettaiji.azurecr.io/ai-agents/video-generator:latest` | 视频生成 |
|
||||
|
||||
### 8.2 联系方式
|
||||
|
||||
- **技术支持**: support@taijiagnet.com
|
||||
- **API 文档**: http://agent-manager.taijiagnet.com/docs
|
||||
- **问题反馈**: https://github.com/your-org/agent-manager/issues
|
||||
|
||||
### 8.3 更新日志
|
||||
|
||||
| 版本 | 日期 | 更新内容 |
|
||||
|------|------|----------|
|
||||
| v2.0.0 | 2026-05-12 | 初始版本,支持 Heicode 集成 |
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v2.0.0
|
||||
**最后更新**: 2026-05-12
|
||||
**维护者**: Agent Manager Team
|
||||
@@ -0,0 +1,333 @@
|
||||
# Heicode 全栈完整调用流程图
|
||||
|
||||
**版本**: v1.0
|
||||
**生效日期**: 2026-05-05
|
||||
**目标**: 把 Heicode 整体架构里 5 个组件之间的真实调用关系画清楚,避免每次新功能上线时大家对边界理解不一致。
|
||||
|
||||
> 本文档是基于实际代码(heicode 仓库 + mcp-server 仓库)的核实结果,**不是设计文档**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 5 个组件 + 各自定位
|
||||
|
||||
| 组件 | 物理形态 | 角色(按 Heicode 主线文档) | 对应代码 |
|
||||
|---|---|---|---|
|
||||
| **cc-haha 桌面客户端** | Tauri 桌面应用 + Bun CLI | "Heicode 客户端"(用户编程入口) | `heicode/cc-haha/` |
|
||||
| **heicode web/default** | React + Rsbuild SPA | NewAPI 自带的管理 UI(普通用户进的那个) | `heicode/heicode/web/default/` |
|
||||
| **heicode 后端 (Go)** | Gin + GORM | **NewAPI** —— 模型网关 + 计费 + 用户/Token/Group | `heicode/heicode/` |
|
||||
| **mcp-server** | FastAPI (Python) | **Manager** —— 用户控制台 + 编排中枢 + 资源绑定 | `services/mcp-server/` |
|
||||
| **agent-manager** | (待实现 12 接口) | **Agnet 平台** —— K8s 上跑子 Agent | 独立服务 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 已上线的调用关系(已核实)
|
||||
|
||||
### 2.1 登录流程(已上线 + 实测通过)
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ heicode web/default │ ← 用户在浏览器打开 https://heicode.../
|
||||
│ (React SPA) │
|
||||
└──────────┬──────────────┘
|
||||
│
|
||||
│ 1. POST /api/auth/login {email, password, role:"user"}
|
||||
│ 跨域调用,VITE_HEICODE_AUTH_BASE_URL=https://apimtaiji.azure-api.net/api/mcp
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ mcp-server (Manager) │
|
||||
│ https://apimtaiji.azure-api.net │
|
||||
│ /api/mcp │
|
||||
└──────────┬───────────────────────┘
|
||||
│
|
||||
│ 2. 200 → {token, refreshToken, user{id, email, name, role, channelId}}
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ heicode web/default │
|
||||
│ 存 access/refresh 到 │
|
||||
│ localStorage │
|
||||
└──────────┬──────────────┘
|
||||
│
|
||||
│ 3. POST /api/user/session/from-agnet {access_token, refresh_token}
|
||||
│ 同源调用 heicode 后端
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ heicode 后端 (Go / NewAPI) │
|
||||
│ controller.HeicodeAgnetSession │
|
||||
│ Login │
|
||||
└──────────┬───────────────────────┘
|
||||
│
|
||||
│ 4. GET /api/auth/me (Bearer access_token)
|
||||
│ 跨服务调用 mcp-server
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ mcp-server │
|
||||
│ (401 时 heicode 后端会 fallback │
|
||||
│ 先调 /api/auth/refresh) │
|
||||
└──────────┬───────────────────────┘
|
||||
│
|
||||
│ 5. 200 → {id, email, name, role, channelId, status}
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ heicode 后端 │
|
||||
│ - 按 email JIT 创建本地 user │
|
||||
│ - channelId → User.Group │
|
||||
│ - role 用本地白名单决定(不信任 │
|
||||
│ mcp-server 的 role 字段) │
|
||||
│ - 颁发 heicode session cookie │
|
||||
└──────────┬───────────────────────┘
|
||||
│
|
||||
│ 6. 200 → {success, data{id, role, group, ...}}
|
||||
│ + Set-Cookie: heicode_session=...
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ heicode web/default │
|
||||
│ 显示已登录 │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
**关键事实**:
|
||||
- heicode 前端**直接跨域**调 mcp-server 的登录接口(4 个:login/me/refresh/logout)
|
||||
- heicode 后端**也调** mcp-server 的 `/me` 和 `/refresh`(用前端给的 token 验证)
|
||||
- mcp-server 是**事实上的主认证源**,heicode 不维护自己独立的密码体系
|
||||
- heicode 后端**不信任** mcp-server 的 role 字段(防止外部身份提升),高权限角色由本地 `HEICODE_ROOT_EMAILS`/`HEICODE_ADMIN_EMAILS` 环境变量决定
|
||||
|
||||
### 2.2 cc-haha 桌面客户端是否调 mcp-server?
|
||||
|
||||
**结论:当前不直接调**(已通过 grep 核实)。
|
||||
|
||||
cc-haha (`./bin/claude-haha`) 是独立的 CLI + Tauri 桌面工具,主要功能是 AI 编程辅助。它通过本地 server (`SERVER_PORT=3456 bun run src/server/index.ts`) 工作,**不直接对接 mcp-server**。
|
||||
|
||||
后续如果 cc-haha 要接入 Heicode 主流程(用户绑定资源 → 部署子 Agent),它会通过 heicode 后端中转,与 web/default 前端走同样的登录路径。
|
||||
|
||||
### 2.3 heicode 后端 → mcp-server 的依赖
|
||||
|
||||
代码位置:`controller/heicode_agnet_session.go`
|
||||
|
||||
| heicode 调用 | mcp-server 端点 | 用途 | mcp-server 端能改吗? |
|
||||
|---|---|---|---|
|
||||
| `GET /api/auth/me` | ✅ 已上线 | JIT 同步用户身份 | **字段形态不能改**:id/email/name/role/channelId/status 都被 hardcode 解析 |
|
||||
| `POST /api/auth/refresh` | ✅ 已上线 | access token 401 时 fallback | 字段形态不能改:data.token/refreshToken |
|
||||
|
||||
### 2.4 heicode 前端 → mcp-server 的依赖
|
||||
|
||||
代码位置:`web/default/src/features/auth/api.ts`
|
||||
|
||||
| heicode 前端调用 | mcp-server 端点 | 用途 |
|
||||
|---|---|---|
|
||||
| `POST /api/auth/login` | ✅ 已上线 | 用户登录 |
|
||||
| `GET /api/auth/me` | ✅ 已上线 | 启动时校验 + 周期刷新 |
|
||||
| `POST /api/auth/refresh` | ✅ 已上线 | access token 失效续期 |
|
||||
| `POST /api/auth/logout` | ✅ 已上线 | 登出(与 heicode 后端 logout 双调用) |
|
||||
|
||||
### 2.5 mcp-server → heicode 后端的依赖(**当前 0 调用**)
|
||||
|
||||
mcp-server 现在**不调 heicode 后端**。
|
||||
|
||||
> **Heicode 团队 2026-05-07 修订(C 方案——两平台并存且各自有 product-level 模型网关)**:
|
||||
>
|
||||
> | 平台 | 组成 | 自家模型网关 | 服务对象 |
|
||||
> |---|---|---|---|
|
||||
> | **taijiagent** | mcp-server + agent-manager + LiteLLM | **LiteLLM**(产品级,不是 mcp-server 进程内细节)| 通过 mcp-server 直接部署 agent 的"原生 taijiagent 用户" |
|
||||
> | **Heicode** | Heicode 客户端(桌面)+ heicode web(浏览器)+ heicode 后端(NewAPI)| **NewAPI**(产品级)| Heicode 客户端实时交互式 chat 用户 |
|
||||
>
|
||||
> 两个平台**共用 mcp-server 的账号体系**,**模型网关各自独立**。
|
||||
>
|
||||
> **四条独立的模型调用路径**(完整真实场景):
|
||||
>
|
||||
> | # | 调用方 | 触发场景 | 走哪 |
|
||||
> |---|---|---|---|
|
||||
> | 1 | Heicode 客户端(桌面)+ heicode web 前端 | 用户实时交互式 chat / 写代码 | **Heicode NewAPI** |
|
||||
> | 2 | 子 Agent Pod(**Heicode 用户部署的**) | 在 AKS 跑任务时调模型,`billing_context.provider="newapi"` | **Heicode NewAPI**(带 newapi_user_ref / newapi_group) |
|
||||
> | 3 | 子 Agent Pod(**原生 taijiagent 用户部署的**) | 在 AKS 跑任务时调模型,`billing_context.provider` 默认或 = `"litellm"` | **taijiagent LiteLLM** |
|
||||
> | 4 | mcp-server 进程内(embedding / 内部分类 / 系统功能) | mcp-server 自己内部使用 | **LiteLLM** |
|
||||
>
|
||||
> **关键事实**:
|
||||
> - LiteLLM 同时承担两种角色 —— ① taijiagent 用户的 agent 模型网关(产品级)+ ② mcp-server 自己的内部工具
|
||||
> - NewAPI 同时承担两种角色 —— ① Heicode 客户端用户的实时交互网关 + ② Heicode 部署的 agent 计费网关
|
||||
> - 子 Agent 走哪条网关由 `billing_context.provider` 字段决定(mcp-server 在 `POST /api/agnet/deployments` payload 里设置)
|
||||
> - 两个网关**互不替代**,**P4 不需要做迁移**
|
||||
>
|
||||
> **P4 NewAPI 解耦的真实任务**:mcp-server 在 Manager 控制台聚合费用展示时,对**Heicode 用户那部分**调用(路径 1 + 路径 2)从 NewAPI 拉元数据;对**纯 taijiagent 用户**那部分(路径 3)继续用自己的 LiteLLM 数据。聚合后展示给用户。属于**只读元数据查询 + 前端聚合**,不涉及网关迁移。
|
||||
|
||||
如果实施 P4 元数据查询,需要:
|
||||
- mcp-server 新增 `app/heicode_client.py` 出站客户端
|
||||
- 调 heicode 后端的用户视角 API(`/api/user/self`、`/api/user/self/models`、`/api/log/self/stat` 等)
|
||||
- LiteLLM **保留**,不替换
|
||||
|
||||
---
|
||||
|
||||
## 3. 待实现的调用关系(按 Heicode 主线 P5)
|
||||
|
||||
### 3.1 部署子 Agent 流程(设计中,agent-manager 待实现 12 接口)
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ heicode web/default 或 │
|
||||
│ cc-haha 客户端 │ ← 用户点"部署"
|
||||
└──────────┬──────────────┘
|
||||
│
|
||||
│ 1. (经 heicode 后端中转 或 直接) POST /api/resources / /api/resource-grants
|
||||
│ 定义资源绑定与授权
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ mcp-server (Manager) │
|
||||
│ ✅ ResourceBinding/Grant 已上线 │
|
||||
└──────────┬───────────────────────┘
|
||||
│
|
||||
│ 2. POST /api/agnet/deployments
|
||||
│ {orchestration_plan, user_context, billing_context, agent_runtime, resource_grants}
|
||||
│ ❌ 出站客户端待实现
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ agent-manager (Agnet 平台) │
|
||||
│ ❌ 12 个新接口全部待实现 │
|
||||
└──────────┬───────────────────────┘
|
||||
│
|
||||
│ 3. 创建 K8s Deployment + ServiceAccount + ConfigMap
|
||||
│ ConfigMap 含 AGENT.md / resource_context / permission_manifest
|
||||
│ ❌ Pod 启动行为待改造
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ 子 Agent Pod (在 AKS) │
|
||||
│ ❌ 启动后通过 Workload Identity │
|
||||
│ 向 Vault 拉短期凭据 │
|
||||
└──────────┬───────────────────────┘
|
||||
│
|
||||
│ 4. (运行时) 调 Vault 拿 git token / cloud key
|
||||
│ ❌ Vault 部署 + Workload Identity 配置待做
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ Vault / OpenBao │
|
||||
│ ❌ 基础设施待部署 │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 子 Agent 模型调用(已工作 + 待对齐)
|
||||
|
||||
```
|
||||
子 Agent 在 Pod 里跑
|
||||
│
|
||||
│ POST /v1/chat/completions
|
||||
│ Authorization: Bearer <heicode-token>
|
||||
▼
|
||||
heicode 后端 (NewAPI)
|
||||
│
|
||||
│ 路由到具体 provider
|
||||
▼
|
||||
OpenAI / Claude / Gemini / Azure / Bedrock / ...
|
||||
```
|
||||
|
||||
注:子 Agent 拿到的 heicode token 由 mcp-server 在创建 Deployment 时通过 `billing_context` 传给 agent-manager,agent-manager 注入 Pod env。**现在还没这个链路**。
|
||||
|
||||
---
|
||||
|
||||
## 4. mcp-server 角色总结
|
||||
|
||||
mcp-server 在 Heicode 全栈里**目前**承担 3 件事:
|
||||
|
||||
| 角色 | 状态 | 接口 |
|
||||
|---|---|---|
|
||||
| **认证 IdP**(heicode 前端 + 后端的统一身份源) | ✅ 已上线 | `/api/auth/login` `/me` `/refresh` `/logout` |
|
||||
| **资源绑定与授权**(用户绑定 Git/SK/云资源/项目文档;分配给子 Agent 角色)| ✅ 已上线 | `/api/resources/*` `/api/resource-grants/*` |
|
||||
| **Agnet 平台编排器**(创建/查询/停止子 Agent 部署,转发给 agent-manager)| ❌ 待实现 | `/api/agnet/*`(12 个) |
|
||||
|
||||
mcp-server **不**承担:
|
||||
- ❌ 模型调用网关(heicode 后端 = NewAPI 干这事)
|
||||
- ❌ AI 提供商接入(heicode 后端的 relay/channel 干这事)
|
||||
- ❌ K8s 部署执行(agent-manager 干这事)
|
||||
- ❌ 凭据托管(Vault 干这事,待部署)
|
||||
|
||||
---
|
||||
|
||||
## 5. 现有 mcp-server 业务(不归 Heicode,但要知道避坑)
|
||||
|
||||
mcp-server 还服务 **taiji 业务**:渠道后台、超管、用户中心、Agent 管理、PayPal 充值。这些不在 Heicode 范围,但代码共用。
|
||||
|
||||
| taiji 业务路由 | 状态 | 与 Heicode 的关系 |
|
||||
|---|---|---|
|
||||
| `/api/channel/*` 渠道后台 | 在用 | 不归 Heicode,**保持不动** |
|
||||
| `/api/admin/*` 超管 | 在用 | 同上 |
|
||||
| `/api/user/*` 用户中心 | 在用 | 同上 |
|
||||
| `/api/agents/*` Agent 管理 | 在用 | 同上 |
|
||||
| `/api/auth/*` 登录 | 在用 + Heicode 复用 | **被 Heicode 共用**,字段形态绝不能改 |
|
||||
| `/api/billing/*` `/api/paypal/*` 计费 | 在用 | 不归 Heicode |
|
||||
|
||||
---
|
||||
|
||||
## 6. CORS 现状与改进
|
||||
|
||||
**生产配置**:mcp-server 的 `cors_origins` 在 ConfigMap `taiji-config` 里**未设置**,fallback 到 `["*"]`。
|
||||
|
||||
**潜在问题**:
|
||||
- mcp-server 设了 `allow_credentials=True` + `allow_origins=["*"]`,浏览器规范上**会拒绝**带 cookie 的跨域请求
|
||||
- 但 heicode 前端用 `Authorization: Bearer` 传 token,**不依赖 cookie**,实际可用
|
||||
|
||||
**改进建议**(不阻塞当前对接):
|
||||
- 把 heicode 前端的真实部署域名加到 `CORS_ORIGINS`,去掉 `*`
|
||||
- 例如:`CORS_ORIGINS=["https://heicode.xinghanlab.com","https://heicode-staging.xinghanlab.com"]`
|
||||
- 与 heicode 团队确认其前端实际部署域名后配置
|
||||
|
||||
---
|
||||
|
||||
## 7. 已知技术债(**不阻塞**当前对接)
|
||||
|
||||
| 项 | 说明 | 影响 |
|
||||
|---|---|---|
|
||||
| Channel 登录写审计日志 FK 错 | `audit_logs.user_id` FK 与 channel.id 不匹配 | 仅 channel 角色登录有 warning,user 角色无影响 |
|
||||
| `cors_origins=["*"]` + `allow_credentials=True` | 与浏览器规范冲突 | 当前无影响(heicode 用 Bearer),未来要硬化 |
|
||||
| LiteLLM vs NewAPI(heicode 后端)双轨 | 模型调用走 LiteLLM,未对接 heicode | 取决于产品决策,可能要迁移 |
|
||||
| 死路由清理(之前已删 11 个)| `frontend_integration.py` 仍有部分历史代码 | 不影响功能 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 联调测试可执行步骤
|
||||
|
||||
### 步骤 1: 确认 mcp-server 4 个登录接口(已上线,无需操作)
|
||||
|
||||
```bash
|
||||
# 用真实账号登录
|
||||
curl -X POST https://apimtaiji.azure-api.net/api/mcp/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"55@55.com","password":"By@123456.","role":"user"}'
|
||||
|
||||
# 拿 token 调 /me
|
||||
curl https://apimtaiji.azure-api.net/api/mcp/api/auth/me \
|
||||
-H "Authorization: Bearer <access_token>"
|
||||
```
|
||||
|
||||
### 步骤 2: heicode 前端联调(heicode 团队执行)
|
||||
|
||||
1. 部署 heicode web/default,配置 `VITE_HEICODE_AUTH_BASE_URL=https://apimtaiji.azure-api.net/api/mcp`
|
||||
2. 在登录页输入 `55@55.com` / `By@123456.`
|
||||
3. 预期:成功登录,浏览器 localStorage 有 `heicode_access_token` + `heicode_refresh_token`
|
||||
4. 预期:heicode 后端 session cookie 也已颁发(通过 `from-agnet` 流程)
|
||||
|
||||
### 步骤 3: 资源绑定联调(heicode 团队执行)
|
||||
|
||||
1. 用步骤 2 的 access token 调 `POST /api/resources` 创建 git 绑定
|
||||
2. 调 `POST /api/resource-grants` 把 binding 授给 backend role
|
||||
3. 预期:所有调用 200,DB 里有对应记录
|
||||
|
||||
### 步骤 4: 子 Agent 部署联调(**等 agent-manager 实现 12 接口后**)
|
||||
|
||||
待 agent-manager 团队实现接口后再做。
|
||||
|
||||
---
|
||||
|
||||
## 9. 文档导航
|
||||
|
||||
| 文档 | 受众 | 内容 |
|
||||
|---|---|---|
|
||||
| **本文档** | 全员 | 整体调用关系、组件定位 |
|
||||
| `Docs/Heicode-接口契约文档.md` | heicode 前后端开发 | 13 个 mcp-server 已上线接口的详细契约 |
|
||||
| `Docs/Heicode-对接进度与待办.md` | PM / Lead | 进度盘点 + 待决策 |
|
||||
| `Docs/Agent-Manager-Heicode对接需求文档.md` | agent-manager 团队 | 12 个新接口要求 + Pod 改造 + AKS 基础设施 |
|
||||
| `Docs/Heicode-登录接口对接文档.md` | (旧版,已被超集化)| 登录单接口;建议转看接口契约文档 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 修订记录
|
||||
|
||||
| 版本 | 日期 | 变更 |
|
||||
|---|---|---|
|
||||
| v1.0 | 2026-05-05 | 初版:基于代码核实结果绘制 |
|
||||
+1066
-30
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -205,6 +205,8 @@ async def require_auth(
|
||||
"/api/admin/auth/login",
|
||||
"/api/providers/auth/login",
|
||||
"/api/auth/login", # 添加统一登录接口
|
||||
"/api/auth/internal/billing-provider", # Heicode §7.8.1 内部端点(用 HEICODE_INTERNAL_SERVICE_TOKEN 鉴权,不走 user JWT)
|
||||
"/api/auth/internal/approvals", # Heicode §7.8.3 内部端点(同上)
|
||||
"/agents/templates", # 模板列表公开访问
|
||||
}
|
||||
# 允许公开路径和非 API/agents 路径
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Heicode SSE 事件总线(§7.8.3 配套)
|
||||
|
||||
进程内 pub/sub:每个用户的活跃 SSE 订阅者用一个 asyncio.Queue 排队接收事件;
|
||||
其他业务路径(approvals/tasks/...)通过 emit() 发布事件。
|
||||
|
||||
⚠️ 单进程范围:mcp-server 多副本部署下,副本 A 上的订阅者**收不到**副本 B 上
|
||||
emit 的事件。MVP 阶段可接受(cc-haha 单连接);生产化时换成 Redis pub/sub
|
||||
或 NATS(mcp-server 已部署 NATS)即可,emit/subscribe 接口不变。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
|
||||
class _EventBus:
|
||||
"""每用户独立队列的内存 pub/sub。"""
|
||||
|
||||
def __init__(self):
|
||||
# user_id (str) → set of asyncio.Queue
|
||||
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def subscribe(self, user_id: str) -> asyncio.Queue:
|
||||
"""订阅指定用户的事件流。返回一个新的 Queue。"""
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=256)
|
||||
async with self._lock:
|
||||
self._subscribers.setdefault(user_id, set()).add(queue)
|
||||
return queue
|
||||
|
||||
async def unsubscribe(self, user_id: str, queue: asyncio.Queue) -> None:
|
||||
async with self._lock:
|
||||
subs = self._subscribers.get(user_id)
|
||||
if subs:
|
||||
subs.discard(queue)
|
||||
if not subs:
|
||||
self._subscribers.pop(user_id, None)
|
||||
|
||||
async def emit(self, user_id: str, event_type: str, data: Dict[str, Any]) -> int:
|
||||
"""向指定用户的所有订阅者推送事件。返回送达的订阅者数。
|
||||
|
||||
队列已满时 drop(不阻塞 emit;客户端断重连后会重拉一次状态)。
|
||||
"""
|
||||
payload = {"event": event_type, "data": data}
|
||||
delivered = 0
|
||||
async with self._lock:
|
||||
subs = list(self._subscribers.get(user_id, []))
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
delivered += 1
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
return delivered
|
||||
|
||||
def subscriber_count(self, user_id: Optional[str] = None) -> int:
|
||||
if user_id is not None:
|
||||
return len(self._subscribers.get(user_id, set()))
|
||||
return sum(len(s) for s in self._subscribers.values())
|
||||
|
||||
|
||||
# 单例
|
||||
_default_bus: Optional[_EventBus] = None
|
||||
|
||||
|
||||
def get_event_bus() -> _EventBus:
|
||||
global _default_bus
|
||||
if _default_bus is None:
|
||||
_default_bus = _EventBus()
|
||||
return _default_bus
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Heicode NewAPI 出站客户端(P4)
|
||||
|
||||
mcp-server 通过 admin service token 调用 Heicode NewAPI(`code.xinghanlab.com`),
|
||||
拉取用户视角的元数据(余额 / 模型 / 用量 / 调用日志),用于 Manager 控制台展示。
|
||||
|
||||
关键约束(NewAPI middleware/auth.go 强制):
|
||||
- `Authorization: Bearer <admin_access_token>` — admin 身份
|
||||
- `New-Api-User: <admin_user_id>` — **必须等于** access token 对应的用户 id(CSRF 检查)
|
||||
|
||||
→ 这意味着 admin token **不能"切身份"调 user-self 路由**(`/api/user/self*`),
|
||||
必须用 admin 路由 + `?user_id=X` query 参数定位目标用户。
|
||||
|
||||
→ "把当前 mcp-server 用户的 email 解析成 heicode 本地 user_id" 通过 admin search 实现:
|
||||
`GET /api/user/search?keyword=<email>` → cache 结果。
|
||||
|
||||
依据:
|
||||
- Docs/Heicode-对接进度与待办.md §2.3 + §2.3.1(Heicode 团队 2026-05-07 决策 B 方案)
|
||||
- Docs/Heicode-完整调用流程图.md §2.5 4 路径架构
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class HeicodeNewAPIError(Exception):
|
||||
"""Heicode NewAPI 调用失败"""
|
||||
def __init__(self, message: str, status_code: int = 0, body: Any = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.body = body
|
||||
|
||||
|
||||
class HeicodeNewAPIClient:
|
||||
"""Heicode NewAPI HTTP 客户端(admin token-based)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
admin_token: Optional[str] = None,
|
||||
admin_user_id: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
):
|
||||
self.base_url = (base_url or settings.heicode_newapi_base_url).rstrip("/")
|
||||
self.admin_token = admin_token or settings.heicode_newapi_admin_token
|
||||
self.admin_user_id = admin_user_id or settings.heicode_newapi_admin_user_id
|
||||
self.timeout = timeout or settings.heicode_newapi_timeout
|
||||
|
||||
# 简单内存缓存:email → heicode local user_id(int)
|
||||
# TTL 30 分钟,命中即用
|
||||
self._user_id_cache: Dict[str, Tuple[int, float]] = {}
|
||||
self._cache_ttl_sec = 1800
|
||||
self._cache_lock = asyncio.Lock()
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""判断是否已配好可用的 admin token + user_id"""
|
||||
return bool(self.admin_token and self.admin_user_id)
|
||||
|
||||
def _admin_headers(self, request_id: Optional[str] = None) -> Dict[str, str]:
|
||||
"""构造 admin 调用的 headers(含 Authorization + New-Api-User)"""
|
||||
if not self.is_configured():
|
||||
raise HeicodeNewAPIError(
|
||||
"Heicode NewAPI admin token / admin_user_id 未配置 "
|
||||
"(设置 HEICODE_NEWAPI_SERVICE_TOKEN + HEICODE_NEWAPI_ADMIN_USER_ID)",
|
||||
status_code=503,
|
||||
)
|
||||
h = {
|
||||
"Authorization": f"Bearer {self.admin_token}",
|
||||
"New-Api-User": str(self.admin_user_id),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if request_id:
|
||||
h["X-Request-Id"] = request_id
|
||||
return h
|
||||
|
||||
async def _get(
|
||||
self,
|
||||
path: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""统一 GET 调用"""
|
||||
url = f"{self.base_url}{path}"
|
||||
headers = self._admin_headers(request_id)
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, params=params)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(
|
||||
"heicode_newapi_request_failed",
|
||||
method="GET", url=url, error=str(e),
|
||||
)
|
||||
raise HeicodeNewAPIError(f"请求 Heicode NewAPI 失败: {e}", status_code=502)
|
||||
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
body = {"raw": resp.text[:500]}
|
||||
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
"heicode_newapi_non_2xx",
|
||||
method="GET", url=url, status=resp.status_code,
|
||||
body_preview=str(body)[:200],
|
||||
)
|
||||
raise HeicodeNewAPIError(
|
||||
f"Heicode NewAPI HTTP {resp.status_code}: {body}",
|
||||
status_code=resp.status_code,
|
||||
body=body,
|
||||
)
|
||||
|
||||
# NewAPI 响应惯例:{"success": bool, "message": str, "data": ...}
|
||||
if isinstance(body, dict) and body.get("success") is False:
|
||||
raise HeicodeNewAPIError(
|
||||
f"Heicode NewAPI business error: {body.get('message', '')}",
|
||||
status_code=resp.status_code,
|
||||
body=body,
|
||||
)
|
||||
|
||||
return body if isinstance(body, dict) else {"raw": body}
|
||||
|
||||
# ==================== email → heicode user_id 解析 ====================
|
||||
|
||||
async def resolve_user_id_by_email(self, email: str) -> Optional[int]:
|
||||
"""通过 admin search 把 email 解析成 heicode 本地 user_id(int)
|
||||
|
||||
命中缓存 → 直接返回;否则调 admin /api/user/search?keyword=<email> 查询。
|
||||
"""
|
||||
import time
|
||||
if not email:
|
||||
return None
|
||||
email_key = email.strip().lower()
|
||||
now = time.time()
|
||||
|
||||
async with self._cache_lock:
|
||||
cached = self._user_id_cache.get(email_key)
|
||||
if cached and (now - cached[1]) < self._cache_ttl_sec:
|
||||
return cached[0]
|
||||
|
||||
try:
|
||||
body = await self._get(
|
||||
"/api/user/search",
|
||||
params={"keyword": email_key, "group": ""},
|
||||
)
|
||||
except HeicodeNewAPIError as e:
|
||||
logger.warning(
|
||||
"heicode_user_resolve_failed",
|
||||
email=email_key, error=str(e),
|
||||
)
|
||||
return None
|
||||
|
||||
# NewAPI 返回结构通常为 {"data": {"items": [...]}} 或 {"data": [...]}
|
||||
data = body.get("data") if isinstance(body, dict) else None
|
||||
items: List[Dict[str, Any]] = []
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif isinstance(data, dict):
|
||||
items = data.get("items") or data.get("users") or []
|
||||
|
||||
# 精确匹配 email
|
||||
for u in items:
|
||||
if not isinstance(u, dict):
|
||||
continue
|
||||
if (u.get("email") or "").strip().lower() == email_key:
|
||||
uid = u.get("id")
|
||||
if isinstance(uid, int) and uid > 0:
|
||||
async with self._cache_lock:
|
||||
self._user_id_cache[email_key] = (uid, now)
|
||||
return uid
|
||||
|
||||
# 找不到精确匹配
|
||||
return None
|
||||
|
||||
# ==================== 用户视角元数据查询(admin 路由 + user_id 参数) ====================
|
||||
|
||||
async def get_user_info(self, heicode_user_id: int, request_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""GET /api/user/{id} — 用户详情(含 quota/group/status/...)"""
|
||||
body = await self._get(f"/api/user/{heicode_user_id}", request_id=request_id)
|
||||
return body.get("data") if isinstance(body, dict) else body
|
||||
|
||||
async def list_user_models(
|
||||
self, heicode_user_id: int,
|
||||
request_id: Optional[str] = None,
|
||||
) -> List[Any]:
|
||||
"""GET /api/user/{id}/models — 按用户 id 列出该用户可用模型清单。
|
||||
|
||||
修订(2026-05-08,按 Heicode §7.11.2):原 `/api/models` 是渠道仪表盘
|
||||
视角(key 是 channelId),mcp-server-service 没渠道,自然空。改用
|
||||
`/api/user/{id}/models`(admin 替指定用户查),返回 string[]。
|
||||
"""
|
||||
body = await self._get(
|
||||
f"/api/user/{heicode_user_id}/models",
|
||||
request_id=request_id,
|
||||
)
|
||||
data = body.get("data") if isinstance(body, dict) else body
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return data.get("items") or data.get("models") or []
|
||||
return []
|
||||
|
||||
async def get_user_quota_dates(
|
||||
self, heicode_user_id: int,
|
||||
days: int = 30,
|
||||
request_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""GET /api/data/users — 按用户 id 拉取最近 N 天的用量"""
|
||||
body = await self._get(
|
||||
"/api/data/users",
|
||||
params={"user_id": heicode_user_id, "default_time": str(days)},
|
||||
request_id=request_id,
|
||||
)
|
||||
data = body.get("data") if isinstance(body, dict) else None
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return data.get("items") or []
|
||||
return []
|
||||
|
||||
async def get_user_logs(
|
||||
self, heicode_user_id: int,
|
||||
page: int = 1, page_size: int = 50,
|
||||
request_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""GET /api/log/?user_id=X — 用户调用日志(admin 路由)"""
|
||||
body = await self._get(
|
||||
"/api/log/",
|
||||
params={
|
||||
"p": page,
|
||||
"page_size": page_size,
|
||||
"user_id": heicode_user_id,
|
||||
"type": 0, # 0=全部
|
||||
},
|
||||
request_id=request_id,
|
||||
)
|
||||
data = body.get("data") if isinstance(body, dict) else None
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return data.get("items") or []
|
||||
return []
|
||||
|
||||
|
||||
# ==================== 单例 ====================
|
||||
|
||||
_default_client: Optional[HeicodeNewAPIClient] = None
|
||||
|
||||
|
||||
def get_heicode_client() -> HeicodeNewAPIClient:
|
||||
global _default_client
|
||||
if _default_client is None:
|
||||
_default_client = HeicodeNewAPIClient()
|
||||
return _default_client
|
||||
@@ -14,6 +14,10 @@ from . import (
|
||||
external_tools, # 外部数据工具管理
|
||||
paypal, # PayPal 支付集成
|
||||
resources, resource_grants, # Heicode P1:资源绑定与授权
|
||||
heicode_proxy, # Heicode P4:NewAPI 用户视角元数据透传
|
||||
agnet_stub, # Heicode P5:Agnet 平台本地 stub(12 个 /api/agnet/* 端点)
|
||||
heicode_tasks, # Heicode 任务编排(5 个 /api/user/tasks/* 端点)
|
||||
heicode_events, # Heicode §7.8.3:SSE 单通道 + Approval REST
|
||||
)
|
||||
|
||||
|
||||
@@ -53,5 +57,13 @@ def register_routes(app: FastAPI) -> None:
|
||||
# Heicode P1: 资源绑定与授权
|
||||
resources.router,
|
||||
resource_grants.router,
|
||||
# Heicode P4: NewAPI 元数据透传
|
||||
heicode_proxy.router,
|
||||
# Heicode P5: Agnet 平台本地 stub
|
||||
agnet_stub.router,
|
||||
# Heicode 任务编排
|
||||
heicode_tasks.router,
|
||||
# Heicode §7.8.3 SSE + Approval
|
||||
heicode_events.router,
|
||||
):
|
||||
app.include_router(router)
|
||||
|
||||
@@ -0,0 +1,722 @@
|
||||
"""
|
||||
Heicode P5 — Manager 侧 /api/agnet/* 本地 stub
|
||||
|
||||
完全按 heicode 仓库 docs/integration/agnet-platform-request-contract.md 的字段形态返回 mock 数据,
|
||||
便于 cc-haha 客户端 / heicode web 前端在 agent-manager 真正落地前先联调主流程。
|
||||
|
||||
存储:内存 dict,重启清空。不调真实 K8s。
|
||||
鉴权:复用 require_auth(与已上线 4 个登录接口同一机制)。
|
||||
真实 Agnet 平台落地后,把内部 _store + 假数据生成切换成 outbound HTTP client 即可,
|
||||
对前端 0 改动(路径 / 字段 / 错误码完全相同)。
|
||||
|
||||
12 个端点:
|
||||
1. POST /api/agnet/deployments
|
||||
2. GET /api/agnet/deployments
|
||||
3. GET /api/agnet/deployments/{id}
|
||||
4. POST /api/agnet/deployments/{id}/stop
|
||||
5. GET /api/agnet/deployments/{id}/logs
|
||||
6. GET /api/agnet/deployments/{id}/logs/stream (SSE)
|
||||
7. GET /api/agnet/projects/{binding_scope}/dashboard-snapshot
|
||||
8. GET /api/agnet/deployments/{id}/metrics
|
||||
9. GET /api/agnet/deployments/{id}/events
|
||||
10. GET /api/agnet/audit-logs
|
||||
11. POST /api/agnet/sk-snapshots/resolve
|
||||
12. GET /api/agnet/deployments/{id}/sk-snapshots
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.routes.resources import reject_sensitive_keys
|
||||
from app.schemas import SuccessResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/agnet", tags=["Heicode P5 Agnet Stub"])
|
||||
|
||||
|
||||
# ==================== 内存 store ====================
|
||||
|
||||
class _AgnetStubStore:
|
||||
"""内存存储所有 stub 数据。多副本部署下每副本独立,仅供联调。"""
|
||||
def __init__(self):
|
||||
self.deployments: Dict[str, Dict[str, Any]] = {}
|
||||
self.events: Dict[str, List[Dict[str, Any]]] = {} # deployment_id → events
|
||||
self.logs: Dict[str, List[Dict[str, Any]]] = {} # deployment_id → logs
|
||||
self.sk_snapshots: Dict[str, List[Dict[str, Any]]] = {} # deployment_id → snapshots
|
||||
self.audit_logs: List[Dict[str, Any]] = []
|
||||
self.idempotency_cache: Dict[str, Dict[str, Any]] = {} # idempotency_key → response
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
|
||||
_store = _AgnetStubStore()
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.utcnow().isoformat(timespec="microseconds") + "Z"
|
||||
|
||||
|
||||
def _new_id(prefix: str) -> str:
|
||||
return f"{prefix}_{secrets.token_hex(6)}"
|
||||
|
||||
|
||||
def _correlation_id(request: Request, fallback_field: Optional[str] = None) -> str:
|
||||
return (
|
||||
request.headers.get("X-Correlation-Id")
|
||||
or request.headers.get("X-Request-Id")
|
||||
or fallback_field
|
||||
or str(uuid.uuid4())
|
||||
)
|
||||
|
||||
|
||||
def _err(http_status: int, code: str, message: str, request_id: Optional[str] = None):
|
||||
detail = {"code": code, "message": message}
|
||||
if request_id:
|
||||
detail["request_id"] = request_id
|
||||
raise HTTPException(status_code=http_status, detail=detail)
|
||||
|
||||
|
||||
# ==================== 共享:payload 校验 ====================
|
||||
|
||||
ALLOWED_PROVIDERS = {"newapi", "litellm"}
|
||||
ALLOWED_RISK_LEVELS = {"low", "medium", "high"}
|
||||
ALLOWED_RESOURCE_TYPES_DEPLOYMENT = {
|
||||
"git", "sk", "project_doc", "cloud_account", "cloud_resource",
|
||||
"model_gateway_token", # P5 新增(按 §3a)
|
||||
}
|
||||
DEPLOYMENT_NON_TERMINAL = {"accepted", "pending", "running", "stopping", "processing"}
|
||||
DEPLOYMENT_TERMINAL = {"stopped", "completed", "failed", "cancelled"}
|
||||
|
||||
|
||||
def _validate_orchestration_plan(op: Dict[str, Any], request_id: str) -> None:
|
||||
"""完整校验 orchestration_plan 结构,按契约 §2.3 + §8。"""
|
||||
if not isinstance(op, dict):
|
||||
_err(400, "POLICY_REJECTED", "orchestration_plan 必须是 object", request_id)
|
||||
|
||||
required_fields = [
|
||||
"intent_id", "template_hint", "objective", "risk_level",
|
||||
"budget", "metadata", "agents",
|
||||
]
|
||||
for f in required_fields:
|
||||
if f not in op or op[f] in (None, "", [], {}):
|
||||
_err(400, "POLICY_REJECTED",
|
||||
f"orchestration_plan.{f} 必填", request_id)
|
||||
|
||||
if op["risk_level"] not in ALLOWED_RISK_LEVELS:
|
||||
_err(400, "POLICY_REJECTED",
|
||||
f"risk_level 必须是 {sorted(ALLOWED_RISK_LEVELS)} 之一", request_id)
|
||||
|
||||
bud = op.get("budget") or {}
|
||||
for f in ("max_tokens", "max_cost_usd", "max_duration_sec"):
|
||||
if f not in bud:
|
||||
_err(400, "POLICY_REJECTED", f"budget.{f} 必填", request_id)
|
||||
|
||||
metadata = op.get("metadata") or {}
|
||||
if not metadata.get("correlation_id"):
|
||||
_err(400, "POLICY_REJECTED",
|
||||
"metadata.correlation_id 必填", request_id)
|
||||
|
||||
# billing_context.provider 必须是 newapi 或 litellm
|
||||
bc = op.get("billing_context") or {}
|
||||
provider = bc.get("provider")
|
||||
if provider is not None and provider not in ALLOWED_PROVIDERS:
|
||||
_err(400, "POLICY_REJECTED",
|
||||
f"billing_context.provider 必须是 {sorted(ALLOWED_PROVIDERS)} 之一,收到 '{provider}'",
|
||||
request_id)
|
||||
|
||||
# agents[] 至少 1 个
|
||||
agents = op.get("agents") or []
|
||||
if not isinstance(agents, list) or len(agents) == 0:
|
||||
_err(400, "POLICY_REJECTED",
|
||||
"orchestration_plan.agents 至少 1 项", request_id)
|
||||
|
||||
# 校验每个 agent
|
||||
for i, agent in enumerate(agents):
|
||||
if not isinstance(agent, dict):
|
||||
_err(400, "POLICY_REJECTED",
|
||||
f"agents[{i}] 必须是 object", request_id)
|
||||
if not agent.get("role_template"):
|
||||
_err(400, "POLICY_REJECTED",
|
||||
f"agents[{i}].role_template 必填", request_id)
|
||||
if not agent.get("goal"):
|
||||
_err(400, "POLICY_REJECTED",
|
||||
f"agents[{i}].goal 必填", request_id)
|
||||
|
||||
# default_model_id 若设置必须 ∈ allowed_model_ids
|
||||
constraints = op.get("constraints") or {}
|
||||
allowed = constraints.get("allowed_model_ids") or []
|
||||
dmi = agent.get("default_model_id")
|
||||
if dmi and allowed and dmi not in allowed:
|
||||
_err(403, "MODEL_NOT_ALLOWED",
|
||||
f"agents[{i}].default_model_id='{dmi}' 不在 allowed_model_ids 内",
|
||||
request_id)
|
||||
|
||||
# resource_grants[] 校验
|
||||
grants = agent.get("resource_grants") or []
|
||||
for j, g in enumerate(grants):
|
||||
if not isinstance(g, dict):
|
||||
_err(422, "RESOURCE_GRANT_INVALID",
|
||||
f"agents[{i}].resource_grants[{j}] 必须是 object", request_id)
|
||||
for f in ("grant_id", "resource_id", "resource_type", "user_id",
|
||||
"binding_scope", "target_role", "target_agent_ref",
|
||||
"permission_scope", "status"):
|
||||
if f not in g or g[f] in (None, ""):
|
||||
_err(422, "RESOURCE_GRANT_INVALID",
|
||||
f"agents[{i}].resource_grants[{j}].{f} 必填", request_id)
|
||||
if g["resource_type"] not in ALLOWED_RESOURCE_TYPES_DEPLOYMENT:
|
||||
_err(422, "RESOURCE_GRANT_INVALID",
|
||||
f"resource_grants[{j}].resource_type 不合法: {g['resource_type']}",
|
||||
request_id)
|
||||
# 凭据型必填 secret_ref
|
||||
if g["resource_type"] != "project_doc":
|
||||
if not g.get("secret_ref"):
|
||||
_err(422, "RESOURCE_GRANT_SECRET_REF_REQUIRED",
|
||||
f"agents[{i}].resource_grants[{j}].secret_ref 必填({g['resource_type']})",
|
||||
request_id)
|
||||
# role 一致
|
||||
if g.get("target_role") and g["target_role"] != agent["role_template"]:
|
||||
_err(422, "RESOURCE_GRANT_INVALID",
|
||||
f"resource_grants[{j}].target_role 必须等于 agents[{i}].role_template",
|
||||
request_id)
|
||||
|
||||
# high risk 必须有 approval_id
|
||||
if op["risk_level"] == "high":
|
||||
has_approval = False
|
||||
for g in grants:
|
||||
if (g.get("constraints") or {}).get("approval_id"):
|
||||
has_approval = True
|
||||
break
|
||||
if (g.get("audit") or {}).get("approval_id"):
|
||||
has_approval = True
|
||||
break
|
||||
if not has_approval:
|
||||
_err(403, "POLICY_REJECTED",
|
||||
"risk_level=high 必须在 resource_grants 的 constraints 或 audit 中携带 approval_id",
|
||||
request_id)
|
||||
|
||||
# 敏感字段递归扫描 — **仅** scan 契约 §8.3 列出的字段:metadata / constraints / audit
|
||||
# 不能扫 budget(含 max_tokens)/ billing_context(含 newapi_token_or_group_quota_ref)/
|
||||
# agent_runtime / orchestration_plan 顶层(避免误杀合法字段名)
|
||||
if isinstance(op.get("metadata"), dict):
|
||||
reject_sensitive_keys(op["metadata"], "orchestration_plan.metadata.")
|
||||
for i, agent in enumerate(op.get("agents", [])):
|
||||
for j, g in enumerate(agent.get("resource_grants") or []):
|
||||
base = f"orchestration_plan.agents[{i}].resource_grants[{j}]"
|
||||
for sub in ("metadata", "constraints", "audit"):
|
||||
v = g.get(sub)
|
||||
if isinstance(v, dict):
|
||||
reject_sensitive_keys(v, f"{base}.{sub}.")
|
||||
|
||||
|
||||
# ==================== 1. POST /deployments ====================
|
||||
|
||||
@router.post("/deployments", response_model=SuccessResponse)
|
||||
async def create_deployment(
|
||||
payload: Dict[str, Any],
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
):
|
||||
request_id = _correlation_id(request)
|
||||
idempotency_key = request.headers.get("Idempotency-Key")
|
||||
|
||||
# 幂等:同 key 命中直接返回缓存结果
|
||||
if idempotency_key:
|
||||
async with _store.lock:
|
||||
cached = _store.idempotency_cache.get(idempotency_key)
|
||||
if cached:
|
||||
return SuccessResponse(data=cached)
|
||||
|
||||
op = payload.get("orchestration_plan")
|
||||
if not op:
|
||||
_err(400, "POLICY_REJECTED", "请求体缺少 orchestration_plan", request_id)
|
||||
|
||||
_validate_orchestration_plan(op, request_id)
|
||||
|
||||
# 创建 deployment
|
||||
deployment_id = _new_id("dep")
|
||||
now = _now_iso()
|
||||
user_id = (op.get("user_context") or {}).get("user_id") or \
|
||||
request.headers.get("X-User-Id")
|
||||
binding_scope = (op.get("user_context") or {}).get("channel_id") or \
|
||||
request.headers.get("X-Binding-Scope") or "default"
|
||||
correlation_id_field = (op.get("metadata") or {}).get("correlation_id", request_id)
|
||||
|
||||
instances = []
|
||||
for agent in op.get("agents", []):
|
||||
instances.append({
|
||||
"instance_id": _new_id("agi"),
|
||||
"role": agent["role_template"],
|
||||
"phase": "pending",
|
||||
})
|
||||
|
||||
deployment = {
|
||||
"deployment_id": deployment_id,
|
||||
"status": "accepted",
|
||||
"phase": "pending",
|
||||
"agent_instances": instances,
|
||||
"user_id": user_id,
|
||||
"binding_scope": binding_scope,
|
||||
"correlation_id": correlation_id_field,
|
||||
"intent_id": op.get("intent_id"),
|
||||
"risk_level": op.get("risk_level"),
|
||||
"budget": op.get("budget"),
|
||||
"billing_context": op.get("billing_context"),
|
||||
"agent_runtime": op.get("agent_runtime"),
|
||||
"resource_grants_summary": [
|
||||
{
|
||||
"grant_id": g.get("grant_id"),
|
||||
"resource_type": g.get("resource_type"),
|
||||
"binding_scope": g.get("binding_scope"),
|
||||
"target_role": g.get("target_role"),
|
||||
"permission_scope": g.get("permission_scope"),
|
||||
"status": g.get("status"),
|
||||
}
|
||||
for agent in op.get("agents", [])
|
||||
for g in (agent.get("resource_grants") or [])
|
||||
],
|
||||
"budget_consumed": {"tokens_used": 0, "cost_usd": 0.0, "duration_sec": 0},
|
||||
"last_error": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
# seed events
|
||||
events = [{
|
||||
"event_id": _new_id("evt"),
|
||||
"event": "deployment.accepted",
|
||||
"schema_version": 1,
|
||||
"user_id": user_id,
|
||||
"channel_id": binding_scope,
|
||||
"binding_scope": binding_scope,
|
||||
"deployment_id": deployment_id,
|
||||
"correlation_id": correlation_id_field,
|
||||
"occurred_at": now,
|
||||
}]
|
||||
for ins in instances:
|
||||
events.append({
|
||||
"event_id": _new_id("evt"),
|
||||
"event": "instance.phase_changed",
|
||||
"schema_version": 1,
|
||||
"user_id": user_id,
|
||||
"channel_id": binding_scope,
|
||||
"binding_scope": binding_scope,
|
||||
"deployment_id": deployment_id,
|
||||
"instance_id": ins["instance_id"],
|
||||
"phase": "pending",
|
||||
"correlation_id": correlation_id_field,
|
||||
"occurred_at": now,
|
||||
})
|
||||
|
||||
# seed logs(脱敏占位)
|
||||
logs = [{
|
||||
"log_id": _new_id("log"),
|
||||
"deployment_id": deployment_id,
|
||||
"agent_instance_id": instances[0]["instance_id"] if instances else None,
|
||||
"stream": "system",
|
||||
"level": "info",
|
||||
"message": "[stub] deployment accepted, awaiting K8s bring-up",
|
||||
"redacted": True,
|
||||
"occurred_at": now,
|
||||
}]
|
||||
|
||||
# audit
|
||||
audit_entry = {
|
||||
"audit_id": _new_id("aud"),
|
||||
"actor": "manager",
|
||||
"action": "deployment.accepted",
|
||||
"resource": deployment_id,
|
||||
"user_id": user_id,
|
||||
"channel_id": binding_scope,
|
||||
"binding_scope": binding_scope,
|
||||
"request_id": request_id,
|
||||
"correlation_id": correlation_id_field,
|
||||
"result": "ok",
|
||||
"occurred_at": now,
|
||||
}
|
||||
|
||||
async with _store.lock:
|
||||
_store.deployments[deployment_id] = deployment
|
||||
_store.events[deployment_id] = events
|
||||
_store.logs[deployment_id] = logs
|
||||
_store.sk_snapshots[deployment_id] = []
|
||||
_store.audit_logs.append(audit_entry)
|
||||
|
||||
response_data = {
|
||||
"deployment_id": deployment_id,
|
||||
"status": "accepted",
|
||||
"agent_instances": instances,
|
||||
}
|
||||
if idempotency_key:
|
||||
_store.idempotency_cache[idempotency_key] = response_data
|
||||
|
||||
return SuccessResponse(data=response_data)
|
||||
|
||||
|
||||
# ==================== 2-4. 部署列表 / 详情 / 停止 ====================
|
||||
|
||||
@router.get("/deployments", response_model=SuccessResponse)
|
||||
async def list_deployments(
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
user_id: Optional[str] = Query(None),
|
||||
binding_scope: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
cursor: Optional[str] = Query(None),
|
||||
):
|
||||
async with _store.lock:
|
||||
items = []
|
||||
for d in _store.deployments.values():
|
||||
if user_id and d.get("user_id") != user_id:
|
||||
continue
|
||||
if binding_scope and d.get("binding_scope") != binding_scope:
|
||||
continue
|
||||
if status_filter and d.get("status") != status_filter:
|
||||
continue
|
||||
items.append({
|
||||
"deployment_id": d["deployment_id"],
|
||||
"status": d["status"],
|
||||
"phase": d["phase"],
|
||||
"user_id": d.get("user_id"),
|
||||
"binding_scope": d.get("binding_scope"),
|
||||
"created_at": d["created_at"],
|
||||
"updated_at": d["updated_at"],
|
||||
})
|
||||
items.sort(key=lambda x: x["created_at"], reverse=True)
|
||||
items = items[:limit]
|
||||
return SuccessResponse(data={"items": items, "total": len(items)})
|
||||
|
||||
|
||||
@router.get("/deployments/{deployment_id}", response_model=SuccessResponse)
|
||||
async def get_deployment(
|
||||
deployment_id: str,
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
):
|
||||
async with _store.lock:
|
||||
d = _store.deployments.get(deployment_id)
|
||||
if d is None:
|
||||
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request))
|
||||
return SuccessResponse(data=d)
|
||||
|
||||
|
||||
@router.post("/deployments/{deployment_id}/stop", response_model=SuccessResponse)
|
||||
async def stop_deployment(
|
||||
deployment_id: str,
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
request_id = _correlation_id(request)
|
||||
async with _store.lock:
|
||||
d = _store.deployments.get(deployment_id)
|
||||
if d is None:
|
||||
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", request_id)
|
||||
# 幂等:已 stopped 重复 stop 仍返 200
|
||||
if d["status"] == "stopped":
|
||||
return SuccessResponse(data={
|
||||
"deployment_id": deployment_id,
|
||||
"status": "stopped",
|
||||
})
|
||||
# 终态(completed 等)拒绝
|
||||
if d["status"] in DEPLOYMENT_TERMINAL and d["status"] != "stopped":
|
||||
_err(409, "DEPLOYMENT_CONFLICT",
|
||||
f"部署已进入终态 {d['status']},不能停止", request_id)
|
||||
d["status"] = "stopped"
|
||||
d["phase"] = "stopped"
|
||||
d["updated_at"] = _now_iso()
|
||||
for ins in d.get("agent_instances", []):
|
||||
ins["phase"] = "stopped"
|
||||
# event
|
||||
_store.events[deployment_id].append({
|
||||
"event_id": _new_id("evt"),
|
||||
"event": "deployment.stopped",
|
||||
"schema_version": 1,
|
||||
"user_id": d.get("user_id"),
|
||||
"channel_id": d.get("binding_scope"),
|
||||
"binding_scope": d.get("binding_scope"),
|
||||
"deployment_id": deployment_id,
|
||||
"correlation_id": d.get("correlation_id"),
|
||||
"occurred_at": d["updated_at"],
|
||||
})
|
||||
# audit
|
||||
_store.audit_logs.append({
|
||||
"audit_id": _new_id("aud"),
|
||||
"actor": "manager",
|
||||
"action": "deployment.stop",
|
||||
"resource": deployment_id,
|
||||
"user_id": d.get("user_id"),
|
||||
"binding_scope": d.get("binding_scope"),
|
||||
"request_id": request_id,
|
||||
"correlation_id": d.get("correlation_id"),
|
||||
"result": "ok",
|
||||
"occurred_at": d["updated_at"],
|
||||
})
|
||||
|
||||
return SuccessResponse(data={
|
||||
"deployment_id": deployment_id,
|
||||
"status": "stopped",
|
||||
})
|
||||
|
||||
|
||||
# ==================== 5-6. 日志 + SSE ====================
|
||||
|
||||
@router.get("/deployments/{deployment_id}/logs", response_model=SuccessResponse)
|
||||
async def list_logs(
|
||||
deployment_id: str,
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
agent_instance_id: Optional[str] = Query(None),
|
||||
stream: Optional[str] = Query(None),
|
||||
since: Optional[str] = Query(None),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
cursor: Optional[str] = Query(None),
|
||||
):
|
||||
async with _store.lock:
|
||||
if deployment_id not in _store.deployments:
|
||||
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request))
|
||||
logs = list(_store.logs.get(deployment_id, []))
|
||||
# 过滤
|
||||
if agent_instance_id:
|
||||
logs = [l for l in logs if l.get("agent_instance_id") == agent_instance_id]
|
||||
if stream:
|
||||
logs = [l for l in logs if l.get("stream") == stream]
|
||||
if since:
|
||||
logs = [l for l in logs if (l.get("occurred_at") or "") >= since]
|
||||
return SuccessResponse(data={
|
||||
"items": logs[:limit],
|
||||
"next_cursor": None,
|
||||
"total": len(logs),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/deployments/{deployment_id}/logs/stream")
|
||||
async def stream_logs(
|
||||
deployment_id: str,
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
agent_instance_id: Optional[str] = Query(None),
|
||||
):
|
||||
async with _store.lock:
|
||||
if deployment_id not in _store.deployments:
|
||||
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request))
|
||||
|
||||
async def gen():
|
||||
# 发送 2 条 mock log + 1 个 heartbeat + done
|
||||
for i in range(2):
|
||||
line = {
|
||||
"log_id": _new_id("log"),
|
||||
"deployment_id": deployment_id,
|
||||
"level": "info",
|
||||
"message": f"[stub-stream] mock log line {i+1}",
|
||||
"occurred_at": _now_iso(),
|
||||
}
|
||||
yield f"event: log\ndata: {json.dumps(line)}\n\n"
|
||||
await asyncio.sleep(0.2)
|
||||
yield f"event: heartbeat\ndata: {{}}\n\n"
|
||||
await asyncio.sleep(0.2)
|
||||
yield f"event: done\ndata: {{}}\n\n"
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ==================== 7-8. 监控快照 + metrics ====================
|
||||
|
||||
@router.get("/projects/{binding_scope}/dashboard-snapshot", response_model=SuccessResponse)
|
||||
async def dashboard_snapshot(
|
||||
binding_scope: str,
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
window: str = Query("1h"),
|
||||
):
|
||||
async with _store.lock:
|
||||
deps = [d for d in _store.deployments.values() if d.get("binding_scope") == binding_scope]
|
||||
phase_dist = {"pending": 0, "running": 0, "stopped": 0, "failed": 0}
|
||||
for d in deps:
|
||||
ph = d.get("phase", "pending")
|
||||
if ph not in phase_dist:
|
||||
phase_dist[ph] = 0
|
||||
phase_dist[ph] += 1
|
||||
|
||||
return SuccessResponse(data={
|
||||
"project_id": binding_scope,
|
||||
"binding_scope": binding_scope,
|
||||
"active_instances": sum(1 for d in deps if d.get("status") in DEPLOYMENT_NON_TERMINAL),
|
||||
"phase_distribution": phase_dist,
|
||||
"failure_rate_1h": 0.0,
|
||||
"avg_task_duration": 0.0,
|
||||
"budget": {"tokens_used": 0, "cost_usd": 0.0, "duration_sec": 0},
|
||||
"resource_usage": {
|
||||
"cpu_millicores": 0, "memory_mb": 0,
|
||||
"network_rx_bytes": 0, "network_tx_bytes": 0,
|
||||
},
|
||||
"updated_at": _now_iso(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/deployments/{deployment_id}/metrics", response_model=SuccessResponse)
|
||||
async def deployment_metrics(
|
||||
deployment_id: str,
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
window: str = Query("15m"),
|
||||
step: str = Query("60s"),
|
||||
):
|
||||
async with _store.lock:
|
||||
if deployment_id not in _store.deployments:
|
||||
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request))
|
||||
|
||||
now = _now_iso()
|
||||
series = [
|
||||
{"metric": "tokens_used", "unit": "count", "points": [[now, 0]]},
|
||||
{"metric": "cost_usd", "unit": "usd", "points": [[now, 0.0]]},
|
||||
{"metric": "duration_sec", "unit": "count", "points": [[now, 0]]},
|
||||
{"metric": "cpu_millicores", "unit": "millicore", "points": [[now, 0]]},
|
||||
{"metric": "memory_mb", "unit": "mb", "points": [[now, 0]]},
|
||||
{"metric": "restart_count", "unit": "count", "points": [[now, 0]]},
|
||||
{"metric": "tool_call_count", "unit": "count", "points": [[now, 0]]},
|
||||
{"metric": "error_count", "unit": "count", "points": [[now, 0]]},
|
||||
{"metric": "queue_latency_ms", "unit": "ms", "points": [[now, 0]]},
|
||||
]
|
||||
return SuccessResponse(data={
|
||||
"deployment_id": deployment_id,
|
||||
"window": window,
|
||||
"step": step,
|
||||
"series": series,
|
||||
})
|
||||
|
||||
|
||||
# ==================== 9-10. 事件 + 审计 ====================
|
||||
|
||||
@router.get("/deployments/{deployment_id}/events", response_model=SuccessResponse)
|
||||
async def list_events(
|
||||
deployment_id: str,
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
since: Optional[str] = Query(None),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
cursor: Optional[str] = Query(None),
|
||||
):
|
||||
async with _store.lock:
|
||||
if deployment_id not in _store.deployments:
|
||||
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request))
|
||||
events = list(_store.events.get(deployment_id, []))
|
||||
if since:
|
||||
events = [e for e in events if (e.get("occurred_at") or "") >= since]
|
||||
return SuccessResponse(data={
|
||||
"items": events[:limit],
|
||||
"next_cursor": None,
|
||||
"total": len(events),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/audit-logs", response_model=SuccessResponse)
|
||||
async def list_audit_logs(
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
user_id: Optional[str] = Query(None),
|
||||
binding_scope: Optional[str] = Query(None),
|
||||
actor: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
since: Optional[str] = Query(None),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
cursor: Optional[str] = Query(None),
|
||||
):
|
||||
async with _store.lock:
|
||||
items = list(_store.audit_logs)
|
||||
if user_id:
|
||||
items = [a for a in items if a.get("user_id") == user_id]
|
||||
if binding_scope:
|
||||
items = [a for a in items if a.get("binding_scope") == binding_scope]
|
||||
if actor:
|
||||
items = [a for a in items if a.get("actor") == actor]
|
||||
if action:
|
||||
items = [a for a in items if a.get("action") == action]
|
||||
if since:
|
||||
items = [a for a in items if (a.get("occurred_at") or "") >= since]
|
||||
items.sort(key=lambda a: a.get("occurred_at", ""), reverse=True)
|
||||
return SuccessResponse(data={
|
||||
"items": items[:limit],
|
||||
"next_cursor": None,
|
||||
"total": len(items),
|
||||
})
|
||||
|
||||
|
||||
# ==================== 11-12. SK 快照 ====================
|
||||
|
||||
@router.post("/sk-snapshots/resolve", response_model=SuccessResponse)
|
||||
async def resolve_sk_snapshot(
|
||||
payload: Dict[str, Any],
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
):
|
||||
request_id = _correlation_id(request)
|
||||
deployment_id = payload.get("deployment_id")
|
||||
if not deployment_id:
|
||||
_err(400, "POLICY_REJECTED", "deployment_id 必填", request_id)
|
||||
async with _store.lock:
|
||||
d = _store.deployments.get(deployment_id)
|
||||
if d is None:
|
||||
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", request_id)
|
||||
snap = {
|
||||
"snapshot_id": _new_id("sks"),
|
||||
"deployment_id": deployment_id,
|
||||
"user_id": d.get("user_id"),
|
||||
"binding_scope": d.get("binding_scope"),
|
||||
"source_type": "git",
|
||||
"source_ref": "main:skills/heicode/**@stub-checksum",
|
||||
"artifact_ref": f"artifact://stub/{deployment_id}/sks",
|
||||
"checksum": "sha256:stub-redacted",
|
||||
"status": "ready",
|
||||
"resolved_at": _now_iso(),
|
||||
}
|
||||
_store.sk_snapshots[deployment_id].append(snap)
|
||||
# event
|
||||
_store.events[deployment_id].append({
|
||||
"event_id": _new_id("evt"),
|
||||
"event": "sk_snapshot_refreshed",
|
||||
"schema_version": 1,
|
||||
"user_id": d.get("user_id"),
|
||||
"binding_scope": d.get("binding_scope"),
|
||||
"deployment_id": deployment_id,
|
||||
"snapshot_id": snap["snapshot_id"],
|
||||
"occurred_at": snap["resolved_at"],
|
||||
})
|
||||
return SuccessResponse(data={
|
||||
"deployment_id": deployment_id,
|
||||
"items": [snap],
|
||||
"total": 1,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/deployments/{deployment_id}/sk-snapshots", response_model=SuccessResponse)
|
||||
async def list_sk_snapshots(
|
||||
deployment_id: str,
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
user_id: Optional[str] = Query(None),
|
||||
binding_scope: Optional[str] = Query(None),
|
||||
source_type: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
cursor: Optional[str] = Query(None),
|
||||
):
|
||||
async with _store.lock:
|
||||
if deployment_id not in _store.deployments:
|
||||
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request))
|
||||
items = list(_store.sk_snapshots.get(deployment_id, []))
|
||||
if source_type:
|
||||
items = [s for s in items if s.get("source_type") == source_type]
|
||||
return SuccessResponse(data={
|
||||
"items": items[:limit],
|
||||
"next_cursor": None,
|
||||
"total": len(items),
|
||||
})
|
||||
@@ -35,8 +35,9 @@ from app.schemas import (
|
||||
RegenerateAPIKeyResponse,
|
||||
UserCreate,
|
||||
)
|
||||
from app.email_verification import verify_code, send_and_store_verification_code, check_rate_limit, send_password_reset_code
|
||||
from app.email_verification import verify_code, peek_verification_code, send_and_store_verification_code, check_rate_limit, send_password_reset_code
|
||||
from app.audit import log_audit_event
|
||||
from pydantic import BaseModel
|
||||
from app.agent_manager_client import get_agent_manager_client, AgentManagerError
|
||||
from app.litellm_client import get_litellm_client, LiteLLMClientError
|
||||
from config import settings
|
||||
@@ -748,8 +749,10 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
detail="该用户名已被使用"
|
||||
)
|
||||
|
||||
# 3. 最后验证邮箱验证码(验证成功后会消耗验证码)
|
||||
is_valid = await verify_code(req.email, req.verification_code)
|
||||
# 3. 验证邮箱验证码(先 peek 不消费 —— 等 DB commit + 外部副作用全部成功
|
||||
# 后再调 verify_code 真消费。否则后续任何步骤失败,用户的验证码就被
|
||||
# 白白烧掉了,必须重新发码。
|
||||
is_valid = await peek_verification_code(req.email, req.verification_code)
|
||||
if not is_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -815,7 +818,11 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
|
||||
user_id = new_user.id
|
||||
|
||||
|
||||
# 跟踪本次注册在 LiteLLM 远端已经创建的 key —— commit 失败时回扫 delete,
|
||||
# 避免远端泄漏 orphan key。注意:generate_key 是 fire-and-forget 网络副作
|
||||
# 用,DB rollback 不能撤销它,必须显式 delete。
|
||||
created_litellm_keys: list[str] = []
|
||||
try:
|
||||
# 1. 创建余额记录,初始余额 20 元
|
||||
balance = Balance(
|
||||
@@ -892,7 +899,9 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
quantity=1
|
||||
)
|
||||
db.add(allocation)
|
||||
except (AgentManagerError, Exception) as e:
|
||||
except AgentManagerError as e:
|
||||
# AgentManager 不可达是已知运维态,平台 Agent 分配跳过,注册主流程
|
||||
# 继续;其他异常(SQLAlchemy / 编程错误等)让外层 try 兜底回滚。
|
||||
logger.warning(f"获取平台 Agent 模板失败,跳过平台 Agent 分配: {e}")
|
||||
|
||||
# 4. 分配所有供应商模型
|
||||
@@ -903,10 +912,21 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
if channel.litellm_team_id:
|
||||
litellm_client = get_litellm_client()
|
||||
|
||||
|
||||
# 跨 provider 去重:DB 唯一约束是 (tenant_id, model_name),
|
||||
# 如果两个 ModelProvider 行的 supported_models 有重叠(例如都包含
|
||||
# taiji/gpt-4o-mini),不去重会触发 uq_tenant_model 导致整个事务
|
||||
# 失败 → 注册 500。先以 model_name 为键去重,第一个看到的 provider 胜出。
|
||||
seen_models: set[str] = set()
|
||||
for provider in providers:
|
||||
# 为每个供应商的每个模型创建 TenantModelKey
|
||||
for model_name in provider.supported_models:
|
||||
if model_name in seen_models:
|
||||
logger.debug(
|
||||
f"模型 {model_name} 已在其他 provider 处理过,跳过"
|
||||
)
|
||||
continue
|
||||
seen_models.add(model_name)
|
||||
try:
|
||||
# 在 LiteLLM 中创建 Key
|
||||
key = await litellm_client.generate_key(
|
||||
@@ -926,9 +946,14 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
}
|
||||
)
|
||||
|
||||
# 远端 key 创建成功 —— 立刻登记到 created_litellm_keys,
|
||||
# 任何后续失败(DB add/flush/commit)都能 best-effort
|
||||
# 删除掉远端 orphan。
|
||||
created_litellm_keys.append(key.key)
|
||||
|
||||
# 加密存储 Key
|
||||
encrypted_key = litellm_client.encrypt_key(key.key)
|
||||
|
||||
|
||||
# 保存到数据库
|
||||
tenant_key = TenantModelKey(
|
||||
tenant_id=user_id,
|
||||
@@ -954,30 +979,81 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
tpm=provider.tpm or 10000,
|
||||
)
|
||||
db.add(model_allocation)
|
||||
except (LiteLLMClientError, Exception) as e:
|
||||
except LiteLLMClientError as e:
|
||||
# LiteLLM 远端 API 错误:单模型分配失败可接受,跳过该
|
||||
# 模型继续。注意 generate_key 已在 created_litellm_keys
|
||||
# 追加之前抛出,所以这条路径不会有 orphan。
|
||||
logger.warning(f"为模型 {model_name} 创建 LiteLLM Key 失败: {e}")
|
||||
# 继续处理其他模型
|
||||
# SQLAlchemy / 编程错误等其他异常:故意不 catch —— 让外层 try
|
||||
# 接住执行 rollback + LiteLLM orphan 清理,避免静默返回 200
|
||||
# 但只分到一半 key 的「假成功」。
|
||||
else:
|
||||
logger.warning(f"渠道 {TAIJI_CHANNEL_ID} 未配置 LiteLLM team,跳过模型分配")
|
||||
|
||||
# 提交所有更改
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"用户注册成功并分配默认资源",
|
||||
user_id=str(user_id),
|
||||
email=req.email,
|
||||
channel_id=str(TAIJI_CHANNEL_ID)
|
||||
channel_id=str(TAIJI_CHANNEL_ID),
|
||||
litellm_key_count=len(created_litellm_keys),
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
# 1. DB 回滚(new_user / balance / quota / TenantModelKey 全部撤销)
|
||||
await db.rollback()
|
||||
logger.error(f"用户注册失败: {e}")
|
||||
|
||||
# 2. 关键:DB 回滚撤不掉 LiteLLM 远端已创建的 key —— 必须显式逐个 delete。
|
||||
# best-effort:每个 delete 都 try/except,避免一个失败阻塞剩下的清理。
|
||||
if created_litellm_keys:
|
||||
logger.warning(
|
||||
"注册失败 —— 开始清理 LiteLLM 远端 orphan key",
|
||||
email=req.email,
|
||||
orphan_count=len(created_litellm_keys),
|
||||
)
|
||||
try:
|
||||
client = get_litellm_client()
|
||||
for k in created_litellm_keys:
|
||||
try:
|
||||
await client.delete_key(k)
|
||||
except Exception as del_err:
|
||||
logger.error(
|
||||
"清理 orphan LiteLLM key 失败(需人工跟进)",
|
||||
key_prefix=k[:12] if k else None,
|
||||
email=req.email,
|
||||
error=str(del_err),
|
||||
)
|
||||
except Exception as cleanup_err:
|
||||
# 连 LiteLLM client 都拿不到:把 orphan key 写到日志里,运维兜底
|
||||
logger.error(
|
||||
"无法获取 LiteLLM client 清理 orphan key",
|
||||
email=req.email,
|
||||
orphan_keys_first_chars=[k[:12] for k in created_litellm_keys],
|
||||
error=str(cleanup_err),
|
||||
)
|
||||
|
||||
# 3. 注意:绝不能把 str(e) 回给客户端 —— SQLAlchemy IntegrityError 的 str
|
||||
# 会包含完整 SQL + 全部 parameters,参数里有 LiteLLM 明文 key
|
||||
# (litellm_key_id 列)。日志里完整记录便于排障,响应只给通用错误码。
|
||||
logger.error("用户注册失败", email=req.email, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"注册失败: {str(e)}"
|
||||
detail={"code": "REGISTER_FAILED",
|
||||
"message": "注册失败,请稍后重试或联系管理员"},
|
||||
)
|
||||
|
||||
# DB commit 已成功 —— 最后才真正消费验证码。这一步即使失败也不再回滚
|
||||
# (用户已经注册成功,重复消费没意义;Redis 里的过期码不会被复用,因为
|
||||
# 同 email 第二次 register 会在 line 737 的 existing_user 检查处 400)。
|
||||
try:
|
||||
await verify_code(req.email, req.verification_code)
|
||||
except Exception as e:
|
||||
logger.warning("注册成功后消费验证码失败(不影响注册结果)",
|
||||
email=req.email, error=str(e))
|
||||
|
||||
# 创建JWT token,自动登录
|
||||
token_data = {
|
||||
@@ -1112,8 +1188,117 @@ async def forgot_password_reset(
|
||||
await db.commit()
|
||||
|
||||
logger.info("密码重置成功", email=req.email, user_id=str(user.id))
|
||||
|
||||
|
||||
return SuccessResponse(
|
||||
message="密码重置成功,请使用新密码登录"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Heicode §7.8.1 — 内部端点:Heicode 后端 syncLocalUserFromAgnet 调用
|
||||
# 标记用户的 billing_provider('newapi' / 'litellm')
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _verify_internal_service_token(request: Request) -> None:
|
||||
"""校验内部服务密钥(不是用户 JWT)。失败 401/403/503。"""
|
||||
expected = (settings.heicode_internal_service_token or "").strip()
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"code": "INTERNAL_SERVICE_AUTH_NOT_CONFIGURED",
|
||||
"message": "HEICODE_INTERNAL_SERVICE_TOKEN 未配置",
|
||||
},
|
||||
)
|
||||
auth = (request.headers.get("Authorization") or "").strip()
|
||||
if not auth.lower().startswith("bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authorization: Bearer <internal service token> required",
|
||||
)
|
||||
presented = auth[7:].strip()
|
||||
# 常量时间比较防 timing attack
|
||||
if not secrets.compare_digest(presented, expected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "INVALID_INTERNAL_SERVICE_TOKEN",
|
||||
"message": "internal service token mismatch"},
|
||||
)
|
||||
|
||||
|
||||
class _BillingProviderUpdate(BaseModel):
|
||||
"""内部端点请求体。email 或 user_id 二选一。"""
|
||||
email: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
billing_provider: str # 'newapi' | 'litellm'
|
||||
|
||||
|
||||
@router.put("/internal/billing-provider", response_model=SuccessResponse)
|
||||
async def set_billing_provider(
|
||||
payload: _BillingProviderUpdate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Heicode §7.8.1 内部端点:标记用户的 billing_provider。
|
||||
|
||||
**不**用 user JWT 鉴权,用 HEICODE_INTERNAL_SERVICE_TOKEN 共享密钥(避免
|
||||
给 Heicode 后端发用户 JWT)。
|
||||
|
||||
用途:Heicode 后端 syncLocalUserFromAgnet 同步出新用户时,调本接口把
|
||||
User.billing_provider 设为 'newapi'。
|
||||
"""
|
||||
_verify_internal_service_token(request)
|
||||
|
||||
if payload.billing_provider not in ("newapi", "litellm"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": "INVALID_BILLING_PROVIDER",
|
||||
"message": "billing_provider 必须是 'newapi' 或 'litellm'"},
|
||||
)
|
||||
if not payload.email and not payload.user_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": "MISSING_USER_REF",
|
||||
"message": "email 或 user_id 二选一必填"},
|
||||
)
|
||||
|
||||
user = None
|
||||
if payload.user_id:
|
||||
try:
|
||||
uid = uuid.UUID(payload.user_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="user_id 不是有效 UUID")
|
||||
user = await db.get(User, uid)
|
||||
else:
|
||||
result = await db.execute(select(User).where(User.email == payload.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"code": "USER_NOT_FOUND",
|
||||
"message": f"找不到用户 (email={payload.email}, user_id={payload.user_id})"},
|
||||
)
|
||||
|
||||
old = user.billing_provider
|
||||
user.billing_provider = payload.billing_provider
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
"billing_provider_updated",
|
||||
user_id=str(user.id),
|
||||
email=user.email,
|
||||
old_provider=old,
|
||||
new_provider=user.billing_provider,
|
||||
actor="heicode_backend_internal",
|
||||
)
|
||||
|
||||
return SuccessResponse(data={
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"billing_provider": user.billing_provider,
|
||||
"old_billing_provider": old,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
Heicode 事件 + 高危审批(§7.8.3)
|
||||
|
||||
3 个端点:
|
||||
GET /api/user/events/stream SSE 单通道
|
||||
GET /api/user/approvals 待处理审批列表
|
||||
POST /api/user/approvals/{id}/decision 用户响应审批
|
||||
|
||||
+ 1 个内部端点(mcp-server 内部 / 测试用):
|
||||
POST /api/auth/internal/approvals 创建审批 + 广播 approval.requested
|
||||
|
||||
事件类型见 [Heicode-接口契约文档.md §7]:
|
||||
approval.requested / approval.resolved / task.status_changed /
|
||||
task.execution_progress / heartbeat
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets as _secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from database import get_db
|
||||
from models import HeicodeApproval, User
|
||||
from app.auth import require_auth
|
||||
from app.event_bus import get_event_bus
|
||||
from app.schemas import SuccessResponse
|
||||
|
||||
|
||||
router = APIRouter(tags=["Heicode 事件 / 审批"])
|
||||
|
||||
HEARTBEAT_INTERVAL_SEC = 25
|
||||
|
||||
|
||||
# ==================== 共享工具 ====================
|
||||
|
||||
def _current_user_id(principal: dict) -> uuid.UUID:
|
||||
uid = principal.get("user_id") or (principal.get("claims") or {}).get("sub")
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
try:
|
||||
return uuid.UUID(str(uid))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="user_id 不是有效 UUID")
|
||||
|
||||
|
||||
def _approval_to_dict(a: HeicodeApproval) -> Dict[str, Any]:
|
||||
return {
|
||||
"approval_id": str(a.id),
|
||||
"user_id": str(a.user_id),
|
||||
"task_id": str(a.task_id) if a.task_id else None,
|
||||
"task_name": a.task_name,
|
||||
"operation": a.operation,
|
||||
"target_resource": a.target_resource,
|
||||
"requesting_role": a.requesting_role,
|
||||
"risk_level": a.risk_level,
|
||||
"impact_summary": a.impact_summary or [],
|
||||
"heicode_suggestion": a.heicode_suggestion,
|
||||
"derives_short_lived_credential": a.derives_short_lived_credential,
|
||||
"ttl_minutes": a.ttl_minutes,
|
||||
"enqueued_at": a.enqueued_at.isoformat() + "Z" if a.enqueued_at else None,
|
||||
"decision": a.decision,
|
||||
"resolved_by": str(a.resolved_by) if a.resolved_by else None,
|
||||
"resolved_at": a.resolved_at.isoformat() + "Z" if a.resolved_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _is_expired(a: HeicodeApproval) -> bool:
|
||||
if a.decision is not None:
|
||||
return False
|
||||
if not a.enqueued_at or not a.ttl_minutes:
|
||||
return False
|
||||
# DB 字段是 timezone-aware(TIMESTAMP WITH TIME ZONE),需用 aware now
|
||||
enq = a.enqueued_at
|
||||
if enq.tzinfo is None:
|
||||
enq = enq.replace(tzinfo=timezone.utc)
|
||||
return datetime.now(timezone.utc) > enq + timedelta(minutes=a.ttl_minutes)
|
||||
|
||||
|
||||
def _verify_internal_service_token(request: Request) -> None:
|
||||
"""与 auth.py 同款 — 用 HEICODE_INTERNAL_SERVICE_TOKEN 共享密钥校验。"""
|
||||
expected = (settings.heicode_internal_service_token or "").strip()
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"code": "INTERNAL_SERVICE_AUTH_NOT_CONFIGURED",
|
||||
"message": "HEICODE_INTERNAL_SERVICE_TOKEN 未配置"},
|
||||
)
|
||||
auth = (request.headers.get("Authorization") or "").strip()
|
||||
if not auth.lower().startswith("bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authorization: Bearer <internal service token> required",
|
||||
)
|
||||
presented = auth[7:].strip()
|
||||
if not _secrets.compare_digest(presented, expected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "INVALID_INTERNAL_SERVICE_TOKEN",
|
||||
"message": "internal service token mismatch"},
|
||||
)
|
||||
|
||||
|
||||
# ==================== Pydantic 请求体 ====================
|
||||
|
||||
class ApprovalDecision(BaseModel):
|
||||
decision: str # 'approve' | 'reject'
|
||||
|
||||
|
||||
class ApprovalCreate(BaseModel):
|
||||
"""内部端点(service-token 鉴权)创建 approval 用"""
|
||||
user_id: Optional[str] = None
|
||||
user_email: Optional[str] = None
|
||||
task_id: Optional[str] = None
|
||||
task_name: str = Field(..., max_length=255)
|
||||
operation: str
|
||||
target_resource: str = Field(..., max_length=500)
|
||||
requesting_role: str = Field(..., max_length=100)
|
||||
risk_level: str = "high"
|
||||
impact_summary: List[str] = Field(default_factory=list)
|
||||
heicode_suggestion: Optional[str] = None
|
||||
derives_short_lived_credential: bool = False
|
||||
ttl_minutes: int = 60
|
||||
|
||||
|
||||
# ==================== SSE 端点 ====================
|
||||
|
||||
@router.get("/api/user/events/stream")
|
||||
async def stream_events(
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
):
|
||||
"""SSE 单通道 — 推送当前用户的 approval / task 事件 + 25s heartbeat。
|
||||
|
||||
客户端建议用 fetch + ReadableStream 实现(EventSource 不支持 Authorization 头)。
|
||||
"""
|
||||
user_id = str(_current_user_id(principal))
|
||||
bus = get_event_bus()
|
||||
queue = await bus.subscribe(user_id)
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
# 立刻发一个 hello heartbeat 让客户端知道连接成功
|
||||
yield (
|
||||
"event: heartbeat\n"
|
||||
f"data: {json.dumps({'server_time': datetime.utcnow().isoformat() + 'Z', 'connected': True})}\n\n"
|
||||
)
|
||||
|
||||
while True:
|
||||
# 客户端断连退出
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
# 等下一个事件 / heartbeat 周期
|
||||
try:
|
||||
payload = await asyncio.wait_for(
|
||||
queue.get(), timeout=HEARTBEAT_INTERVAL_SEC
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
yield (
|
||||
"event: heartbeat\n"
|
||||
f"data: {json.dumps({'server_time': datetime.utcnow().isoformat() + 'Z'})}\n\n"
|
||||
)
|
||||
continue
|
||||
|
||||
event_type = payload.get("event") or "message"
|
||||
data = payload.get("data") or {}
|
||||
yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
finally:
|
||||
await bus.unsubscribe(user_id, queue)
|
||||
|
||||
return StreamingResponse(
|
||||
gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no", # 反向代理关闭 buffering(如 Nginx)
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ==================== 公共 REST:列待处理 + 决定 ====================
|
||||
|
||||
@router.get("/api/user/approvals", response_model=SuccessResponse)
|
||||
async def list_my_approvals(
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""当前用户**待处理**审批列表(启动时拉一次,之后靠 SSE 增量)。"""
|
||||
user_id = _current_user_id(principal)
|
||||
result = await db.execute(
|
||||
select(HeicodeApproval)
|
||||
.where(HeicodeApproval.user_id == user_id)
|
||||
.where(HeicodeApproval.decision.is_(None))
|
||||
.order_by(HeicodeApproval.enqueued_at.desc())
|
||||
.limit(200)
|
||||
)
|
||||
items = []
|
||||
expired_ids = []
|
||||
for a in result.scalars().all():
|
||||
if _is_expired(a):
|
||||
expired_ids.append(a.id)
|
||||
continue
|
||||
items.append(_approval_to_dict(a))
|
||||
|
||||
# lazy expire:访问列表时把已过期的标记为 expired(不广播 resolved,避免噪声)
|
||||
if expired_ids:
|
||||
for aid in expired_ids:
|
||||
a = await db.get(HeicodeApproval, aid)
|
||||
if a and a.decision is None:
|
||||
a.decision = "expired"
|
||||
a.resolved_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
return SuccessResponse(data={"items": items, "total": len(items)})
|
||||
|
||||
|
||||
@router.post("/api/user/approvals/{approval_id}/decision", response_model=SuccessResponse)
|
||||
async def decide_approval(
|
||||
approval_id: str,
|
||||
payload: ApprovalDecision,
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""用户响应审批(approve / reject)。"""
|
||||
user_id = _current_user_id(principal)
|
||||
if payload.decision not in ("approve", "reject"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": "INVALID_DECISION",
|
||||
"message": "decision 必须是 'approve' 或 'reject'"},
|
||||
)
|
||||
try:
|
||||
aid = uuid.UUID(approval_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="approval_id 不是有效 UUID")
|
||||
|
||||
a = await db.get(HeicodeApproval, aid)
|
||||
if a is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"code": "NOT_FOUND", "message": "审批不存在"},
|
||||
)
|
||||
if a.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={"code": "FORBIDDEN_SCOPE",
|
||||
"message": "该审批不属于当前用户"},
|
||||
)
|
||||
if _is_expired(a):
|
||||
# lazy 标记 expired
|
||||
a.decision = "expired"
|
||||
a.resolved_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail={"code": "APPROVAL_EXPIRED",
|
||||
"message": "审批已超时(已自动标记为 expired)"},
|
||||
)
|
||||
if a.decision is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "ALREADY_RESOLVED",
|
||||
"message": f"审批已被响应(decision={a.decision})"},
|
||||
)
|
||||
|
||||
a.decision = payload.decision
|
||||
a.resolved_by = user_id
|
||||
a.resolved_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(a)
|
||||
|
||||
# SSE 广播 approval.resolved(让该用户其他设备的弹窗自动关闭)
|
||||
await get_event_bus().emit(
|
||||
str(user_id),
|
||||
"approval.resolved",
|
||||
{
|
||||
"approval_id": str(a.id),
|
||||
"decision": a.decision,
|
||||
"resolved_by": str(a.resolved_by),
|
||||
"resolved_at": a.resolved_at.isoformat() + "Z",
|
||||
},
|
||||
)
|
||||
return SuccessResponse(data=_approval_to_dict(a))
|
||||
|
||||
|
||||
# ==================== 内部端点:创建 approval(service-token 鉴权)====================
|
||||
|
||||
@router.post("/api/auth/internal/approvals", response_model=SuccessResponse)
|
||||
async def create_approval_internal(
|
||||
payload: ApprovalCreate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""内部端点(service-token 鉴权):创建一个待处理审批 + 广播 approval.requested。
|
||||
|
||||
用途:
|
||||
- 单元测试 / 联调 mock
|
||||
- 未来 Agnet 真实链路中"高危操作触发审批"的入口
|
||||
"""
|
||||
_verify_internal_service_token(request)
|
||||
|
||||
if payload.risk_level not in ("low", "medium", "high"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": "INVALID_RISK_LEVEL",
|
||||
"message": "risk_level 必须是 low/medium/high"},
|
||||
)
|
||||
if payload.heicode_suggestion not in (None, "approve", "reject", "delegate"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": "INVALID_SUGGESTION",
|
||||
"message": "heicode_suggestion 必须是 approve/reject/delegate"},
|
||||
)
|
||||
if not (payload.user_id or payload.user_email):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": "MISSING_USER_REF",
|
||||
"message": "user_id 或 user_email 二选一必填"},
|
||||
)
|
||||
|
||||
# 解析 target user
|
||||
user = None
|
||||
if payload.user_id:
|
||||
try:
|
||||
uid = uuid.UUID(payload.user_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="user_id 不是有效 UUID")
|
||||
user = await db.get(User, uid)
|
||||
else:
|
||||
result = await db.execute(select(User).where(User.email == payload.user_email))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"code": "USER_NOT_FOUND", "message": "目标用户不存在"},
|
||||
)
|
||||
|
||||
task_uuid: Optional[uuid.UUID] = None
|
||||
if payload.task_id:
|
||||
try:
|
||||
task_uuid = uuid.UUID(payload.task_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="task_id 不是有效 UUID")
|
||||
|
||||
a = HeicodeApproval(
|
||||
user_id=user.id,
|
||||
task_id=task_uuid,
|
||||
task_name=payload.task_name,
|
||||
operation=payload.operation,
|
||||
target_resource=payload.target_resource,
|
||||
requesting_role=payload.requesting_role,
|
||||
risk_level=payload.risk_level,
|
||||
impact_summary=payload.impact_summary,
|
||||
heicode_suggestion=payload.heicode_suggestion,
|
||||
derives_short_lived_credential=payload.derives_short_lived_credential,
|
||||
ttl_minutes=payload.ttl_minutes,
|
||||
)
|
||||
db.add(a)
|
||||
await db.commit()
|
||||
await db.refresh(a)
|
||||
|
||||
# 广播 approval.requested
|
||||
await get_event_bus().emit(
|
||||
str(user.id),
|
||||
"approval.requested",
|
||||
_approval_to_dict(a),
|
||||
)
|
||||
return SuccessResponse(data=_approval_to_dict(a))
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Heicode NewAPI 用户视角元数据透传层(P4)
|
||||
|
||||
mcp-server 包装 4 个端点暴露给前端,背后调 Heicode NewAPI(admin token)。
|
||||
前端只对接 mcp-server 一个域,避免直连 NewAPI 引入的 CORS/审计/限流问题。
|
||||
|
||||
依据:
|
||||
- Docs/Heicode-对接进度与待办.md §2.3.1(Heicode 团队 2026-05-07 决策 ④:透传方案)
|
||||
- Docs/Heicode-完整调用流程图.md §2.5
|
||||
|
||||
端点:
|
||||
- GET /api/user/heicode/balance → 当前用户余额 + group + status
|
||||
- GET /api/user/heicode/models → 当前用户可用模型清单(按 group 过滤的视图)
|
||||
- GET /api/user/heicode/usage → 30 天用量数据
|
||||
- GET /api/user/heicode/logs → 最近 50 条调用日志
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from models import User
|
||||
from app.auth import require_auth
|
||||
from app.heicode_client import (
|
||||
HeicodeNewAPIClient, HeicodeNewAPIError, get_heicode_client,
|
||||
)
|
||||
from app.schemas import SuccessResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/user/heicode", tags=["Heicode NewAPI 透传"])
|
||||
|
||||
|
||||
# ==================== 共享:取当前 mcp-server 用户的 email ====================
|
||||
|
||||
async def _current_user_email(principal: dict, db: AsyncSession) -> str:
|
||||
"""取当前登录用户的 email(用于解析 heicode 本地 user_id)"""
|
||||
user_id = principal.get("user_id") or (principal.get("claims") or {}).get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
# 优先 claims.email
|
||||
email = (principal.get("claims") or {}).get("email") or principal.get("email")
|
||||
if email:
|
||||
return str(email).strip().lower()
|
||||
|
||||
# fallback 查 DB
|
||||
try:
|
||||
uid = uuid.UUID(str(user_id))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="user_id 不是有效 UUID")
|
||||
|
||||
user = await db.get(User, uid)
|
||||
if user is None or not user.email:
|
||||
raise HTTPException(status_code=404, detail="当前用户无 email,无法关联到 Heicode 账号")
|
||||
return user.email.strip().lower()
|
||||
|
||||
|
||||
async def _resolve_heicode_uid(
|
||||
client: HeicodeNewAPIClient, email: str,
|
||||
) -> int:
|
||||
"""email → heicode user_id(int);找不到 502"""
|
||||
if not client.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"code": "HEICODE_NEWAPI_NOT_CONFIGURED",
|
||||
"message": "Heicode NewAPI 未配置 admin token,请联系运维",
|
||||
},
|
||||
)
|
||||
try:
|
||||
uid = await client.resolve_user_id_by_email(email)
|
||||
except HeicodeNewAPIError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={
|
||||
"code": "HEICODE_NEWAPI_UPSTREAM_ERROR",
|
||||
"message": str(e),
|
||||
},
|
||||
)
|
||||
if uid is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={
|
||||
"code": "HEICODE_USER_NOT_FOUND",
|
||||
"message": f"在 Heicode NewAPI 找不到 email='{email}' 的用户。"
|
||||
f"用户需先在 Heicode 完成首次登录建账(from-agnet 流程)。",
|
||||
},
|
||||
)
|
||||
return uid
|
||||
|
||||
|
||||
def _request_id(request: Request) -> str:
|
||||
return request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
||||
|
||||
|
||||
# ==================== 4 个透传端点 ====================
|
||||
|
||||
@router.get("/balance", response_model=SuccessResponse)
|
||||
async def get_user_balance(
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
返回当前用户在 Heicode NewAPI 上的:余额、group、status、剩余 quota 等关键字段。
|
||||
(NewAPI 的 quota 单位见其文档,通常是积分)
|
||||
"""
|
||||
email = await _current_user_email(principal, db)
|
||||
client = get_heicode_client()
|
||||
uid = await _resolve_heicode_uid(client, email)
|
||||
try:
|
||||
info = await client.get_user_info(uid, request_id=_request_id(request))
|
||||
except HeicodeNewAPIError as e:
|
||||
raise HTTPException(status_code=502, detail={"code": "HEICODE_NEWAPI_UPSTREAM_ERROR", "message": str(e)})
|
||||
|
||||
# 提炼前端常用字段(不要直接 dump 上游全部字段,避免泄露多余信息)
|
||||
info = info or {}
|
||||
return SuccessResponse(data={
|
||||
"heicodeUserId": uid,
|
||||
"email": email,
|
||||
"username": info.get("username"),
|
||||
"displayName": info.get("display_name"),
|
||||
"group": info.get("group"),
|
||||
"status": info.get("status"),
|
||||
"quota": info.get("quota"),
|
||||
"usedQuota": info.get("used_quota"),
|
||||
"requestCount": info.get("request_count"),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/models", response_model=SuccessResponse)
|
||||
async def get_user_models(
|
||||
request: Request,
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
返回当前 Heicode NewAPI admin 视野内的全部模型清单。
|
||||
前端结合 /balance 的 `group` 字段做用户可见性过滤。
|
||||
"""
|
||||
email = await _current_user_email(principal, db)
|
||||
client = get_heicode_client()
|
||||
uid = await _resolve_heicode_uid(client, email)
|
||||
try:
|
||||
# 修订 2026-05-08(§7.11.2):改调 admin /api/user/{id}/models 而非 /api/models
|
||||
models = await client.list_user_models(uid, request_id=_request_id(request))
|
||||
except HeicodeNewAPIError as e:
|
||||
raise HTTPException(status_code=502, detail={"code": "HEICODE_NEWAPI_UPSTREAM_ERROR", "message": str(e)})
|
||||
|
||||
return SuccessResponse(data={
|
||||
"heicodeUserId": uid,
|
||||
"email": email,
|
||||
"items": models,
|
||||
"count": len(models) if isinstance(models, list) else 0,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/usage", response_model=SuccessResponse)
|
||||
async def get_user_usage(
|
||||
request: Request,
|
||||
days: int = Query(30, ge=1, le=90, description="拉取最近多少天的用量数据"),
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
返回当前用户在 Heicode NewAPI 上最近 N 天的用量数据(按日聚合)。
|
||||
默认 30 天。
|
||||
"""
|
||||
email = await _current_user_email(principal, db)
|
||||
client = get_heicode_client()
|
||||
uid = await _resolve_heicode_uid(client, email)
|
||||
try:
|
||||
items = await client.get_user_quota_dates(
|
||||
uid, days=days, request_id=_request_id(request),
|
||||
)
|
||||
except HeicodeNewAPIError as e:
|
||||
raise HTTPException(status_code=502, detail={"code": "HEICODE_NEWAPI_UPSTREAM_ERROR", "message": str(e)})
|
||||
|
||||
return SuccessResponse(data={
|
||||
"heicodeUserId": uid,
|
||||
"email": email,
|
||||
"days": days,
|
||||
"items": items,
|
||||
"count": len(items) if isinstance(items, list) else 0,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/logs", response_model=SuccessResponse)
|
||||
async def get_user_logs(
|
||||
request: Request,
|
||||
limit: int = Query(50, ge=1, le=200, description="返回的日志条数"),
|
||||
page: int = Query(1, ge=1),
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
返回当前用户在 Heicode NewAPI 上最近 N 条调用日志。
|
||||
默认最近 50 条。
|
||||
"""
|
||||
email = await _current_user_email(principal, db)
|
||||
client = get_heicode_client()
|
||||
uid = await _resolve_heicode_uid(client, email)
|
||||
try:
|
||||
items = await client.get_user_logs(
|
||||
uid, page=page, page_size=limit, request_id=_request_id(request),
|
||||
)
|
||||
except HeicodeNewAPIError as e:
|
||||
raise HTTPException(status_code=502, detail={"code": "HEICODE_NEWAPI_UPSTREAM_ERROR", "message": str(e)})
|
||||
|
||||
return SuccessResponse(data={
|
||||
"heicodeUserId": uid,
|
||||
"email": email,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"items": items,
|
||||
"count": len(items) if isinstance(items, list) else 0,
|
||||
})
|
||||
@@ -0,0 +1,705 @@
|
||||
"""
|
||||
Heicode 任务编排 API(cc-haha 切片 7/8/9/10 任务驾驶舱配套)
|
||||
|
||||
8 个端点:
|
||||
POST /api/user/tasks/intent 创建任务(用户输入想法 → 第一轮 followups)
|
||||
GET /api/user/tasks 用户任务列表
|
||||
GET /api/user/tasks/{id} 任务详情
|
||||
POST /api/user/tasks/{id}/answer 提交一个 followup 选项 → 触发下一轮 followups 或 TaskCard
|
||||
POST /api/user/tasks/{id}/messages 用户继续追加要求
|
||||
GET /api/user/tasks/{id}/execution §7.8.2 执行反馈(Slice 8)
|
||||
GET /api/user/tasks/{id}/delivery §7.8.2 交付结果(Slice 9)
|
||||
GET /api/user/tasks/{id}/audit?tab=... §7.8.2 任务详情审计(Slice 10)
|
||||
|
||||
字段形态严格对齐 cc-haha/desktop/src/stores/heicodeTaskStore.ts。
|
||||
|
||||
MVP 编排器(无 LLM 调用,确定性):
|
||||
- intent → 2 个 followups(scope + tech)
|
||||
- 全部 followups 答完 → 生成 TaskCard
|
||||
- 后续 messages → 追加到 thread 不重新追问
|
||||
|
||||
后续可在 _orchestrator 模块加 LLM 增强(用 LiteLLM 生成更智能的 followups),
|
||||
对前端 0 影响(字段形态完全一致)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from models import HeicodeApproval, HeicodeTask, User
|
||||
from app.auth import require_auth
|
||||
from app.event_bus import get_event_bus
|
||||
from app.schemas import SuccessResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/user/tasks", tags=["Heicode 任务编排"])
|
||||
|
||||
|
||||
# ==================== 数据模型(Pydantic) ====================
|
||||
|
||||
class IntentRequest(BaseModel):
|
||||
intent: str = Field(..., min_length=2, max_length=4000)
|
||||
name: Optional[str] = None # 可选,若不传从 intent 抽取
|
||||
|
||||
|
||||
class AnswerRequest(BaseModel):
|
||||
question_id: str
|
||||
option_id: str
|
||||
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=4000)
|
||||
|
||||
|
||||
# ==================== 编排器(确定性 MVP) ====================
|
||||
|
||||
# 初始 followups 模板 — cc-haha wireframe §3 风格
|
||||
INITIAL_FOLLOWUPS: List[Dict[str, Any]] = [
|
||||
{
|
||||
"id": "scope",
|
||||
"question": "你希望第一阶段交付到什么程度?",
|
||||
"options": [
|
||||
{"id": "mvp", "label": "MVP:能跑通主流程"},
|
||||
{"id": "polish", "label": "完整功能 + UI 细节打磨"},
|
||||
{"id": "prod", "label": "直接上生产", "risk": "high-risk"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tech",
|
||||
"question": "技术栈倾向?",
|
||||
"options": [
|
||||
{"id": "modern_web", "label": "现代 Web(React + Node/Python)"},
|
||||
{"id": "py_backend", "label": "Python 后端为主"},
|
||||
{"id": "let_heicode", "label": "让 Heicode 决定"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _new_question_id() -> str:
|
||||
return f"q_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _intent_to_name(intent: str) -> str:
|
||||
"""从 intent 抽取一个简短任务名(首句或前 30 字)"""
|
||||
s = intent.strip().split("\n")[0].strip()
|
||||
# 取到第一个标点符号
|
||||
for sep in ["。", "!", "?", ".", "!", "?", ",", ","]:
|
||||
idx = s.find(sep)
|
||||
if idx != -1 and idx > 5:
|
||||
s = s[:idx]
|
||||
break
|
||||
if len(s) > 30:
|
||||
s = s[:28] + "…"
|
||||
return s or "新任务"
|
||||
|
||||
|
||||
def _build_initial_thread(intent: str) -> List[Dict[str, Any]]:
|
||||
"""根据用户的 intent 构造初始 thread:
|
||||
[user 消息, heicode 第一轮追问]"""
|
||||
now = _now_ms()
|
||||
return [
|
||||
{"kind": "user", "text": intent, "at": now},
|
||||
{
|
||||
"kind": "heicode",
|
||||
"text": "好的。我先问你两个问题,方便我组织接下来的工作。",
|
||||
"at": now + 1,
|
||||
"followups": INITIAL_FOLLOWUPS,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _all_followups_answered(thread: List[Dict[str, Any]]) -> bool:
|
||||
"""检查 thread 中是否所有 followup 都已答完"""
|
||||
for turn in thread:
|
||||
if turn.get("kind") == "heicode":
|
||||
for f in (turn.get("followups") or []):
|
||||
if not f.get("answer"):
|
||||
return False
|
||||
# 至少有一个 heicode turn 才算
|
||||
return any(t.get("kind") == "heicode" and t.get("followups") for t in thread)
|
||||
|
||||
|
||||
def _collect_answers(thread: List[Dict[str, Any]]) -> Dict[str, str]:
|
||||
"""从 thread 抽出 {question_id: option_id} 字典"""
|
||||
out: Dict[str, str] = {}
|
||||
for turn in thread:
|
||||
for f in (turn.get("followups") or []):
|
||||
if f.get("answer"):
|
||||
out[f["id"]] = f["answer"]
|
||||
return out
|
||||
|
||||
|
||||
def _generate_task_card(intent: str, answers: Dict[str, str]) -> Dict[str, Any]:
|
||||
"""根据已收集的 answers + intent 生成 TaskCard。
|
||||
MVP:基于模板 + answers 简单填空。"""
|
||||
scope_answer = answers.get("scope", "mvp")
|
||||
tech_answer = answers.get("tech", "let_heicode")
|
||||
|
||||
scope_map = {
|
||||
"mvp": ["MVP 范围:核心功能跑通", "技术债务可后续清理", "暂不做高级 UI/性能优化"],
|
||||
"polish": ["完整功能交付", "UI 细节打磨", "覆盖核心异常场景"],
|
||||
"prod": ["直接生产部署(高风险)", "完整测试覆盖", "灰度 / 回滚预案", "生产监控接入"],
|
||||
}
|
||||
tech_map = {
|
||||
"modern_web": "React + Node/Python 后端",
|
||||
"py_backend": "Python 后端为主",
|
||||
"let_heicode": "由 Heicode 团队按场景决定",
|
||||
}
|
||||
|
||||
return {
|
||||
"goal": _intent_to_name(intent),
|
||||
"scope": scope_map.get(scope_answer, scope_map["mvp"]),
|
||||
"generated_artifacts": [
|
||||
"产品说明",
|
||||
"原型描述",
|
||||
"技术方案",
|
||||
f"代码骨架({tech_map.get(tech_answer, '待定')})",
|
||||
],
|
||||
"manager_actions": [
|
||||
{
|
||||
"label": "去 Manager 准备资源",
|
||||
"deeplink": "/manager/resources?from=task",
|
||||
},
|
||||
{
|
||||
"label": "查看团队建议",
|
||||
"deeplink": "/manager/team?from=task",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ==================== 共享工具 ====================
|
||||
|
||||
def _current_user_id(principal: dict) -> uuid.UUID:
|
||||
uid = principal.get("user_id") or (principal.get("claims") or {}).get("sub")
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
try:
|
||||
return uuid.UUID(str(uid))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="user_id 格式无效")
|
||||
|
||||
|
||||
def _to_dict(t: HeicodeTask) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": str(t.id),
|
||||
"user_id": str(t.user_id),
|
||||
"name": t.name,
|
||||
"status": t.status,
|
||||
"status_caption": t.status_caption,
|
||||
"intent": t.intent,
|
||||
"thread": t.thread or [],
|
||||
"card": t.card,
|
||||
"created_at": int(t.created_at.timestamp() * 1000) if t.created_at else None,
|
||||
"updated_at": int(t.updated_at.timestamp() * 1000) if t.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def _load_owned(db: AsyncSession, task_id: str, user_id: uuid.UUID) -> HeicodeTask:
|
||||
try:
|
||||
tid = uuid.UUID(task_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="task_id 格式无效")
|
||||
task = await db.get(HeicodeTask, tid)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail={"code": "NOT_FOUND", "message": "任务不存在"})
|
||||
if task.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail={"code": "FORBIDDEN_SCOPE", "message": "无权访问该任务"}
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
def _mark_dirty(task: HeicodeTask) -> None:
|
||||
"""SQLAlchemy 对 JSON 字段的修改不会自动检测;强制重新赋值确保持久化"""
|
||||
task.thread = list(task.thread or [])
|
||||
if task.card is not None:
|
||||
task.card = dict(task.card)
|
||||
|
||||
|
||||
# ==================== 1. POST /tasks/intent ====================
|
||||
|
||||
@router.post("/intent", response_model=SuccessResponse)
|
||||
async def create_task_from_intent(
|
||||
payload: IntentRequest,
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""用户输入想法,创建新任务并返回第一轮 followups"""
|
||||
user_id = _current_user_id(principal)
|
||||
name = (payload.name or _intent_to_name(payload.intent))[:255]
|
||||
|
||||
task = HeicodeTask(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
status="configuring",
|
||||
status_caption="等待你回答几个问题",
|
||||
intent=payload.intent,
|
||||
thread=_build_initial_thread(payload.intent),
|
||||
card=None,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return SuccessResponse(data=_to_dict(task))
|
||||
|
||||
|
||||
# ==================== 2. GET /tasks ====================
|
||||
|
||||
@router.get("", response_model=SuccessResponse)
|
||||
async def list_tasks(
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
user_id = _current_user_id(principal)
|
||||
stmt = select(HeicodeTask).where(HeicodeTask.user_id == user_id)
|
||||
if status_filter:
|
||||
stmt = stmt.where(HeicodeTask.status == status_filter)
|
||||
stmt = stmt.order_by(HeicodeTask.updated_at.desc()).offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
items = [_to_dict(t) for t in result.scalars().all()]
|
||||
return SuccessResponse(data={
|
||||
"items": items, "total": len(items),
|
||||
"offset": offset, "limit": limit,
|
||||
})
|
||||
|
||||
|
||||
# ==================== 3. GET /tasks/{id} ====================
|
||||
|
||||
@router.get("/{task_id}", response_model=SuccessResponse)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user_id = _current_user_id(principal)
|
||||
task = await _load_owned(db, task_id, user_id)
|
||||
return SuccessResponse(data=_to_dict(task))
|
||||
|
||||
|
||||
# ==================== 4. POST /tasks/{id}/answer ====================
|
||||
|
||||
@router.post("/{task_id}/answer", response_model=SuccessResponse)
|
||||
async def answer_followup(
|
||||
task_id: str,
|
||||
payload: AnswerRequest,
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""用户选了某个 followup 选项;如全部答完,生成 TaskCard 并把 status 推到 running"""
|
||||
user_id = _current_user_id(principal)
|
||||
task = await _load_owned(db, task_id, user_id)
|
||||
if task.status not in ("draft", "configuring"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": "TASK_STATE_INVALID",
|
||||
"message": f"任务当前状态 {task.status},不能再答 followup"},
|
||||
)
|
||||
|
||||
thread = list(task.thread or [])
|
||||
matched = False
|
||||
valid_option_ids: List[str] = []
|
||||
for turn in thread:
|
||||
if turn.get("kind") != "heicode":
|
||||
continue
|
||||
for f in (turn.get("followups") or []):
|
||||
if f.get("id") == payload.question_id:
|
||||
valid_option_ids = [o["id"] for o in (f.get("options") or [])]
|
||||
if payload.option_id not in valid_option_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "INVALID_OPTION",
|
||||
"message": f"option_id '{payload.option_id}' 不在该 followup 的允许选项内 {valid_option_ids}",
|
||||
},
|
||||
)
|
||||
f["answer"] = payload.option_id
|
||||
matched = True
|
||||
break
|
||||
if matched:
|
||||
break
|
||||
|
||||
if not matched:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"code": "QUESTION_NOT_FOUND",
|
||||
"message": f"找不到 question_id '{payload.question_id}'"},
|
||||
)
|
||||
|
||||
# 追加用户消息(让 thread 反映用户做了选择)
|
||||
answered_label = next(
|
||||
(o["label"] for f in (turn.get("followups") or [])
|
||||
for o in (f.get("options") or [])
|
||||
if f["id"] == payload.question_id and o["id"] == payload.option_id),
|
||||
payload.option_id,
|
||||
)
|
||||
thread.append({
|
||||
"kind": "user",
|
||||
"text": f"我选了:{answered_label}",
|
||||
"at": _now_ms(),
|
||||
})
|
||||
|
||||
# 全部答完?生成 TaskCard
|
||||
old_status = task.status
|
||||
status_changed_to: Optional[str] = None
|
||||
if _all_followups_answered(thread):
|
||||
answers = _collect_answers(thread)
|
||||
card = _generate_task_card(task.intent, answers)
|
||||
thread.append({
|
||||
"kind": "heicode",
|
||||
"text": "好,按你的选择,我整理出这张任务卡。你可以去 Manager 准备资源 / 查看团队建议。",
|
||||
"at": _now_ms(),
|
||||
})
|
||||
task.card = card
|
||||
task.status = "running"
|
||||
task.status_caption = "任务卡已生成,等待 Manager 准备资源"
|
||||
status_changed_to = "running"
|
||||
|
||||
task.thread = thread
|
||||
task.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
# SSE 广播 task.status_changed(§7.8.3)
|
||||
if status_changed_to and status_changed_to != old_status:
|
||||
await get_event_bus().emit(
|
||||
str(user_id),
|
||||
"task.status_changed",
|
||||
{
|
||||
"task_id": str(task.id),
|
||||
"old_status": old_status,
|
||||
"new_status": status_changed_to,
|
||||
"status_caption": task.status_caption,
|
||||
"at": datetime.utcnow().isoformat() + "Z",
|
||||
},
|
||||
)
|
||||
|
||||
return SuccessResponse(data=_to_dict(task))
|
||||
|
||||
|
||||
# ==================== 5. POST /tasks/{id}/messages ====================
|
||||
|
||||
@router.post("/{task_id}/messages", response_model=SuccessResponse)
|
||||
async def post_message(
|
||||
task_id: str,
|
||||
payload: MessageRequest,
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""用户继续追加要求;MVP 仅追加到 thread,不再生成新 followups"""
|
||||
user_id = _current_user_id(principal)
|
||||
task = await _load_owned(db, task_id, user_id)
|
||||
thread = list(task.thread or [])
|
||||
thread.append({
|
||||
"kind": "user",
|
||||
"text": payload.text,
|
||||
"at": _now_ms(),
|
||||
})
|
||||
# 简单 ack
|
||||
thread.append({
|
||||
"kind": "heicode",
|
||||
"text": "收到。我会把这条要求纳入任务上下文。",
|
||||
"at": _now_ms() + 1,
|
||||
})
|
||||
task.thread = thread
|
||||
task.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return SuccessResponse(data=_to_dict(task))
|
||||
|
||||
|
||||
# ==================== 6. GET /tasks/{id}/execution (§7.8.2 Slice 8) ====================
|
||||
|
||||
def _iso_z(dt: Optional[datetime]) -> Optional[str]:
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + ("Z" if dt.tzinfo is None else "")
|
||||
|
||||
|
||||
def _seed_execution(task: HeicodeTask) -> Dict[str, Any]:
|
||||
"""根据 task 状态 + intent,确定性生成 ExecutionState(mock 数据,字段对齐 cc-haha
|
||||
heicodeTaskStore.ts: ExecutionState)。
|
||||
|
||||
生命周期:task.status ∈ {running, awaiting_approval, completed} 才有意义;
|
||||
其他状态返回空 sub_steps。
|
||||
"""
|
||||
base_at = int((task.created_at.timestamp() if task.created_at else time.time()) * 1000)
|
||||
finished = task.status == "completed"
|
||||
|
||||
# 5 个 sub_step(与 cc-haha seed 对齐)
|
||||
steps = [
|
||||
("step_1", "需求分解", "done"),
|
||||
("step_2", "技术方案设计", "done"),
|
||||
("step_3", "代码骨架生成", "done" if finished else "running"),
|
||||
("step_4", "测试 & 联调", "done" if finished else "waiting"),
|
||||
("step_5", "部署 & 验证", "done" if finished else "waiting"),
|
||||
]
|
||||
sub_steps = [
|
||||
{
|
||||
"id": sid,
|
||||
"title": title,
|
||||
"status": status_,
|
||||
"caption": "已完成" if status_ == "done" else (
|
||||
"进行中" if status_ == "running" else "排队中"
|
||||
),
|
||||
"at": base_at + i * 1000,
|
||||
}
|
||||
for i, (sid, title, status_) in enumerate(steps)
|
||||
]
|
||||
|
||||
sk_tool_calls = [
|
||||
{
|
||||
"id": "tc_1",
|
||||
"name": "read_codebase",
|
||||
"status": "done",
|
||||
"summary": "扫描了 23 个文件,识别出主入口与核心依赖",
|
||||
"at": base_at + 200,
|
||||
},
|
||||
{
|
||||
"id": "tc_2",
|
||||
"name": "draft_spec",
|
||||
"status": "done",
|
||||
"summary": "草拟了 PRD(含 4 个 user story)",
|
||||
"at": base_at + 1200,
|
||||
},
|
||||
{
|
||||
"id": "tc_3",
|
||||
"name": "scaffold_repo",
|
||||
"status": "done" if finished else "running",
|
||||
"summary": "生成 React + FastAPI 骨架",
|
||||
"at": base_at + 2200,
|
||||
},
|
||||
]
|
||||
|
||||
events = [
|
||||
{"id": "ev_1", "level": "info", "message": "任务启动,分配资源池 default-pool",
|
||||
"at": base_at + 100},
|
||||
{"id": "ev_2", "level": "info", "message": "需求分解完成,生成 5 个 sub_step",
|
||||
"at": base_at + 1100},
|
||||
{"id": "ev_3",
|
||||
"level": "warn" if not finished else "info",
|
||||
"message": ("当前在 step_3:代码骨架生成中" if not finished else "已完成全部 sub_step"),
|
||||
"at": base_at + 2100},
|
||||
]
|
||||
|
||||
artifacts = [
|
||||
{"id": "art_1", "kind": "doc", "label": "产品说明(PRD)",
|
||||
"url": f"/manager/artifacts/{task.id}/prd.md"},
|
||||
{"id": "art_2", "kind": "doc", "label": "原型描述",
|
||||
"url": f"/manager/artifacts/{task.id}/proto.md"},
|
||||
{"id": "art_3", "kind": "api", "label": "API 设计稿",
|
||||
"url": f"/manager/artifacts/{task.id}/api.yaml"},
|
||||
{"id": "art_4", "kind": "diff", "label": "代码骨架 diff",
|
||||
"url": f"/manager/artifacts/{task.id}/scaffold.diff"},
|
||||
]
|
||||
|
||||
return {
|
||||
"sub_steps": sub_steps,
|
||||
"sk_tool_calls": sk_tool_calls,
|
||||
"events": events,
|
||||
"artifacts": artifacts,
|
||||
"spend_today": "¥12.30",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{task_id}/execution", response_model=SuccessResponse)
|
||||
async def get_task_execution(
|
||||
task_id: str,
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""§7.8.2 Slice 8:执行反馈面板。任务在 running/awaiting_approval/completed
|
||||
时填充 sub_steps / sk_tool_calls / events / artifacts / spend_today。"""
|
||||
user_id = _current_user_id(principal)
|
||||
task = await _load_owned(db, task_id, user_id)
|
||||
if task.status not in ("running", "awaiting_approval", "completed"):
|
||||
# 未启动的任务返回空 ExecutionState(前端会显示「等待启动」占位)
|
||||
return SuccessResponse(data={
|
||||
"sub_steps": [], "sk_tool_calls": [], "events": [],
|
||||
"artifacts": [], "spend_today": None,
|
||||
})
|
||||
return SuccessResponse(data=_seed_execution(task))
|
||||
|
||||
|
||||
# ==================== 7. GET /tasks/{id}/delivery (§7.8.2 Slice 9) ====================
|
||||
|
||||
def _seed_delivery(task: HeicodeTask) -> Dict[str, Any]:
|
||||
"""task.status == completed 时生成 DeliveryResult。"""
|
||||
return {
|
||||
"summary": f"任务「{task.name}」已交付。包含产品说明、代码骨架、测试报告 3 大类产物,可直接推进到部署阶段。",
|
||||
"deliverables": [
|
||||
{
|
||||
"id": "deliv_1",
|
||||
"kind": "product-spec",
|
||||
"title": "产品说明 PRD v1",
|
||||
"primary_action": {"label": "查看文档",
|
||||
"deeplink": f"/manager/artifacts/{task.id}/prd.md"},
|
||||
},
|
||||
{
|
||||
"id": "deliv_2",
|
||||
"kind": "code-diff",
|
||||
"title": "代码骨架(React + FastAPI)",
|
||||
"primary_action": {"label": "查看 diff",
|
||||
"deeplink": f"/manager/artifacts/{task.id}/scaffold.diff"},
|
||||
"secondary_action": {"label": "去 Manager 拉分支",
|
||||
"deeplink": f"/manager/git?task={task.id}"},
|
||||
},
|
||||
{
|
||||
"id": "deliv_3",
|
||||
"kind": "test-env",
|
||||
"title": "测试环境 staging-001",
|
||||
"primary_action": {"label": "打开测试环境",
|
||||
"deeplink": f"/manager/envs/staging-001?from=task"},
|
||||
},
|
||||
{
|
||||
"id": "deliv_4",
|
||||
"kind": "prod-env",
|
||||
"title": "生产部署预案",
|
||||
"primary_action": {"label": "查看预案",
|
||||
"deeplink": f"/manager/deploy/plan?task={task.id}"},
|
||||
},
|
||||
],
|
||||
"quality": [
|
||||
{"id": "q_1", "label": "单元测试覆盖率", "status": "pass", "detail": "82%"},
|
||||
{"id": "q_2", "label": "Lint / 静态扫描", "status": "pass", "detail": "0 个高危"},
|
||||
{"id": "q_3", "label": "安全扫描", "status": "pass", "detail": "通过"},
|
||||
{"id": "q_4", "label": "性能基线", "status": "warn", "detail": "p95 略高于阈值,建议复测"},
|
||||
],
|
||||
"next_actions": [
|
||||
{"label": "去 Manager 准备资源", "intent": "manager.resources"},
|
||||
{"label": "进入测试联调", "intent": "manager.test"},
|
||||
{"label": "提交灰度发布申请", "intent": "manager.deploy"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{task_id}/delivery", response_model=SuccessResponse)
|
||||
async def get_task_delivery(
|
||||
task_id: str,
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""§7.8.2 Slice 9:交付结果面板。仅 task.status == completed 时返回完整数据;
|
||||
其他状态返回空 DeliveryResult(前端显示「任务尚未完成」占位)。"""
|
||||
user_id = _current_user_id(principal)
|
||||
task = await _load_owned(db, task_id, user_id)
|
||||
if task.status != "completed":
|
||||
return SuccessResponse(data={
|
||||
"summary": "", "deliverables": [], "quality": [], "next_actions": [],
|
||||
})
|
||||
return SuccessResponse(data=_seed_delivery(task))
|
||||
|
||||
|
||||
# ==================== 8. GET /tasks/{id}/audit?tab=... (§7.8.2 Slice 10) ====================
|
||||
|
||||
_AUDIT_TABS = {"usage", "resources", "approvals", "security"}
|
||||
|
||||
|
||||
def _audit_seed_usage(task: HeicodeTask) -> List[Dict[str, Any]]:
|
||||
"""模型用量(mock)"""
|
||||
return [
|
||||
{"id": "u_1", "model": "gpt-4o-mini", "input_tokens": 12_400,
|
||||
"output_tokens": 3_200, "cost": "¥4.20", "at_iso": _iso_z(task.created_at)},
|
||||
{"id": "u_2", "model": "claude-sonnet-4", "input_tokens": 8_100,
|
||||
"output_tokens": 2_600, "cost": "¥6.80", "at_iso": _iso_z(task.created_at)},
|
||||
{"id": "u_3", "model": "gpt-4o", "input_tokens": 2_300,
|
||||
"output_tokens": 1_100, "cost": "¥1.30", "at_iso": _iso_z(task.updated_at)},
|
||||
]
|
||||
|
||||
|
||||
def _audit_seed_resources(task: HeicodeTask) -> List[Dict[str, Any]]:
|
||||
"""资源访问(mock)"""
|
||||
return [
|
||||
{"id": "r_1", "resource": "git/heicode-frontend", "scope": "read",
|
||||
"last_used_iso": _iso_z(task.updated_at)},
|
||||
{"id": "r_2", "resource": "k8s/staging-001", "scope": "deploy",
|
||||
"last_used_iso": _iso_z(task.updated_at)},
|
||||
{"id": "r_3", "resource": "secret/db-readonly", "scope": "read",
|
||||
"last_used_iso": _iso_z(task.updated_at)},
|
||||
]
|
||||
|
||||
|
||||
async def _audit_real_approvals(
|
||||
db: AsyncSession, task: HeicodeTask
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""approvals tab:从 heicode_approvals 真表拉对应 task 的审批历史"""
|
||||
result = await db.execute(
|
||||
select(HeicodeApproval)
|
||||
.where(HeicodeApproval.task_id == task.id)
|
||||
.order_by(HeicodeApproval.enqueued_at.desc())
|
||||
.limit(50)
|
||||
)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for a in result.scalars().all():
|
||||
out.append({
|
||||
"id": str(a.id),
|
||||
"operation": a.operation,
|
||||
"target_resource": a.target_resource,
|
||||
"risk_level": a.risk_level,
|
||||
"decision": a.decision,
|
||||
"enqueued_iso": _iso_z(a.enqueued_at),
|
||||
"resolved_iso": _iso_z(a.resolved_at),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _audit_seed_security(task: HeicodeTask) -> List[Dict[str, Any]]:
|
||||
"""安全事件(mock)"""
|
||||
return [
|
||||
{"id": "s_1", "event": "task_created", "level": "info",
|
||||
"at_iso": _iso_z(task.created_at)},
|
||||
{"id": "s_2", "event": "resource_grant_used", "level": "info",
|
||||
"at_iso": _iso_z(task.updated_at)},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{task_id}/audit", response_model=SuccessResponse)
|
||||
async def get_task_audit(
|
||||
task_id: str,
|
||||
principal: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tab: Optional[str] = Query(None, description="usage|resources|approvals|security;不传则返回 4 tab 全量"),
|
||||
):
|
||||
"""§7.8.2 Slice 10:任务详情抽屉。
|
||||
- 默认(不传 tab)返回 `{usage, resources, approvals, security}` 4 tab 全量;
|
||||
- 传 tab 则只返回该 tab 字段;其他 tab 字段返回空数组(保持 schema 稳定)。
|
||||
|
||||
其中 `approvals` 是真实查 heicode_approvals 表,其他 3 tab 是 deterministic mock。
|
||||
"""
|
||||
user_id = _current_user_id(principal)
|
||||
task = await _load_owned(db, task_id, user_id)
|
||||
if tab is not None and tab not in _AUDIT_TABS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": "INVALID_TAB",
|
||||
"message": f"tab 必须是 {sorted(_AUDIT_TABS)} 之一"},
|
||||
)
|
||||
|
||||
out: Dict[str, Any] = {
|
||||
"usage": [], "resources": [], "approvals": [], "security": [],
|
||||
}
|
||||
if tab is None or tab == "usage":
|
||||
out["usage"] = _audit_seed_usage(task)
|
||||
if tab is None or tab == "resources":
|
||||
out["resources"] = _audit_seed_resources(task)
|
||||
if tab is None or tab == "approvals":
|
||||
out["approvals"] = await _audit_real_approvals(db, task)
|
||||
if tab is None or tab == "security":
|
||||
out["security"] = _audit_seed_security(task)
|
||||
return SuccessResponse(data=out)
|
||||
@@ -119,6 +119,23 @@ class Settings(BaseSettings):
|
||||
return "https://api-m.paypal.com"
|
||||
return "https://api-m.sandbox.paypal.com"
|
||||
|
||||
# Heicode NewAPI 集成(P4 — Manager 控制台展示模型/余额/用量元数据)
|
||||
# 详见 Docs/Heicode-对接进度与待办.md §2.3
|
||||
heicode_newapi_base_url: str = os.getenv(
|
||||
"HEICODE_NEWAPI_BASE_URL", "https://code.xinghanlab.com"
|
||||
)
|
||||
# 由 Heicode 团队提供:mcp-server-service 用户的 admin access token
|
||||
heicode_newapi_admin_token: str = os.getenv("HEICODE_NEWAPI_SERVICE_TOKEN", "")
|
||||
# admin token 对应用户的 user_id(NewAPI UserAuth 强制 New-Api-User 头与 token 用户匹配)
|
||||
heicode_newapi_admin_user_id: str = os.getenv("HEICODE_NEWAPI_ADMIN_USER_ID", "")
|
||||
# 调用超时(秒)
|
||||
heicode_newapi_timeout: int = int(os.getenv("HEICODE_NEWAPI_TIMEOUT", "10"))
|
||||
|
||||
# Heicode 后端→mcp-server 的反向调用用的共享密钥(§7.8.1 内部 set-provider 端点)
|
||||
# Heicode 后端在 syncLocalUserFromAgnet 时调 PUT /api/auth/internal/billing-provider
|
||||
# 用 Authorization: Bearer <这个值> 鉴权
|
||||
heicode_internal_service_token: str = os.getenv("HEICODE_INTERNAL_SERVICE_TOKEN", "")
|
||||
|
||||
# 云存储设置(Azure Blob Storage)
|
||||
azure_storage_connection_string: str = os.getenv("AZURE_STORAGE_CONNECTION_STRING", "")
|
||||
s3_bucket: str = os.getenv("S3_BUCKET", "taiji-ai-exports")
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Migration 026: Heicode 任务编排表
|
||||
-- 配套 cc-haha 切片 7 任务驾驶舱 + 工作台 UI(heicodeTaskStore 字段对齐)
|
||||
-- 字段定义来自 cc-haha/desktop/src/stores/heicodeTaskStore.ts
|
||||
|
||||
CREATE TABLE IF NOT EXISTS heicode_tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
-- draft | configuring | running | awaiting_approval | completed | failed | paused
|
||||
status_caption VARCHAR(255),
|
||||
intent TEXT NOT NULL,
|
||||
thread JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
-- ChatTurn[]:用户消息 + Heicode 追问 + 用户答案
|
||||
card JSONB,
|
||||
-- TaskCard:goal/scope[]/generated_artifacts[]/manager_actions[]
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_heicode_tasks_user ON heicode_tasks(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_heicode_tasks_status ON heicode_tasks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_heicode_tasks_updated ON heicode_tasks(updated_at DESC);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Migration 027: Heicode §7.7.1 细节② — User.billing_provider 字段
|
||||
--
|
||||
-- 用途:mcp-server 创建子 Agent 部署时,根据用户的 billing_provider 决定
|
||||
-- POST /api/agnet/deployments 的 billing_context.provider 取值。
|
||||
--
|
||||
-- 默认 'litellm'(taijiagent 用户);Heicode 后端 syncLocalUserFromAgnet
|
||||
-- 时通过 PUT /api/auth/internal/billing-provider 设为 'newapi'。
|
||||
--
|
||||
-- 完全增量:仅新增列,对现有业务零影响。
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS billing_provider VARCHAR(20) NOT NULL DEFAULT 'litellm';
|
||||
|
||||
-- 允许值:'litellm' | 'newapi'
|
||||
-- 不加 CHECK 约束(与现有 status / role 等字段保持风格一致,应用层校验)
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_billing_provider ON users(billing_provider);
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Migration 028: Heicode 高危审批表(§7.8.3 配套)
|
||||
--
|
||||
-- 高危操作(risk_level=high)部署 / 资源访问产生 approval 记录;用户在
|
||||
-- cc-haha ApprovalDialog 选择 approve/reject 后落库;同时通过 SSE 单通道
|
||||
-- 把 approval.requested / approval.resolved 推给所有当前用户在线的客户端。
|
||||
--
|
||||
-- 字段对齐 cc-haha/desktop/src/stores/approvalStore.ts:ApprovalRequest
|
||||
|
||||
CREATE TABLE IF NOT EXISTS heicode_approvals (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
-- 谁需要决策这个审批
|
||||
task_id UUID,
|
||||
-- 关联任务(可空,如系统级审批);不加 FK 因为 task 可能由其他平台创建
|
||||
task_name VARCHAR(255) NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
target_resource VARCHAR(500) NOT NULL,
|
||||
requesting_role VARCHAR(100) NOT NULL,
|
||||
risk_level VARCHAR(16) NOT NULL DEFAULT 'high',
|
||||
-- low | medium | high
|
||||
impact_summary JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
-- string[]:弹窗显示的 bullet 列表
|
||||
heicode_suggestion VARCHAR(16),
|
||||
-- approve | reject | delegate
|
||||
derives_short_lived_credential BOOLEAN NOT NULL DEFAULT false,
|
||||
ttl_minutes INTEGER NOT NULL DEFAULT 60,
|
||||
enqueued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
decision VARCHAR(16),
|
||||
-- approve | reject | expired(NULL = 待响应)
|
||||
resolved_by UUID,
|
||||
-- 谁做的决定(user_id);expired 时为 NULL
|
||||
resolved_at TIMESTAMP WITH TIME ZONE,
|
||||
-- BaseModel 框架字段(每张 ORM 表都需要)
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_heicode_approvals_user ON heicode_approvals(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_heicode_approvals_pending ON heicode_approvals(user_id, decision)
|
||||
WHERE decision IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_heicode_approvals_task ON heicode_approvals(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_heicode_approvals_enqueued ON heicode_approvals(enqueued_at);
|
||||
@@ -91,9 +91,14 @@ class User(BaseModel, Base):
|
||||
rpm_limit = Column(Integer, default=60) # 每分钟请求数限制
|
||||
tpm_limit = Column(Integer, default=10000) # 每分钟Token数限制
|
||||
daily_cost_limit = Column(sa.Numeric(12, 2), default=100) # 每日成本限制
|
||||
|
||||
|
||||
status = Column(String(20), default="active")
|
||||
|
||||
|
||||
# Heicode §7.7.1 细节② — billing_provider
|
||||
# 'litellm'(默认,taijiagent 用户)/ 'newapi'(Heicode 用户,由 from-agnet 同步设置)
|
||||
# 决定 mcp-server 创建子 Agent 部署时 billing_context.provider 取值
|
||||
billing_provider = Column(String(20), nullable=False, default="litellm")
|
||||
|
||||
# 登录追踪
|
||||
last_login_at = Column(DateTime, nullable=True) # 最后登录时间
|
||||
|
||||
@@ -1559,3 +1564,67 @@ class ResourceGrant(BaseModel, Base):
|
||||
Index("idx_resource_grants_role", role),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Heicode 任务编排(cc-haha 切片 7 UI 配套)
|
||||
# ------------------------------------------------------------
|
||||
# 字段对齐 cc-haha/desktop/src/stores/heicodeTaskStore.ts
|
||||
# ============================================================
|
||||
|
||||
|
||||
class HeicodeTask(BaseModel, Base):
|
||||
"""Heicode 任务(用户输入想法 → 追问 → 任务卡 → 部署)"""
|
||||
__tablename__ = "heicode_tasks"
|
||||
|
||||
user_id = Column(GUID(), ForeignKey("users.id"), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
status = Column(String(32), nullable=False, default="draft")
|
||||
# 允许值:draft | configuring | running | awaiting_approval | completed | failed | paused
|
||||
status_caption = Column(String(255), nullable=True)
|
||||
intent = Column(Text, nullable=False)
|
||||
thread = Column(JSON, nullable=False, default=list)
|
||||
# ChatTurn[]:用户消息 + Heicode 追问 + 用户答案
|
||||
card = Column(JSON, nullable=True)
|
||||
# TaskCard:{ goal, scope[], generated_artifacts[], manager_actions[] }
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_heicode_tasks_user", user_id),
|
||||
Index("idx_heicode_tasks_status", status),
|
||||
)
|
||||
|
||||
|
||||
class HeicodeApproval(BaseModel, Base):
|
||||
"""Heicode 高危审批(§7.8.3 配套)
|
||||
|
||||
字段对齐 cc-haha/desktop/src/stores/approvalStore.ts:ApprovalRequest
|
||||
"""
|
||||
__tablename__ = "heicode_approvals"
|
||||
|
||||
user_id = Column(GUID(), ForeignKey("users.id"), nullable=False)
|
||||
task_id = Column(GUID(), nullable=True) # 不加 FK,task 可能由其他平台创建
|
||||
task_name = Column(String(255), nullable=False)
|
||||
operation = Column(Text, nullable=False)
|
||||
target_resource = Column(String(500), nullable=False)
|
||||
requesting_role = Column(String(100), nullable=False)
|
||||
risk_level = Column(String(16), nullable=False, default="high")
|
||||
# 允许值:low | medium | high
|
||||
impact_summary = Column(JSON, nullable=False, default=list)
|
||||
heicode_suggestion = Column(String(16), nullable=True)
|
||||
# 允许值:approve | reject | delegate
|
||||
derives_short_lived_credential = Column(Boolean, nullable=False, default=False)
|
||||
ttl_minutes = Column(Integer, nullable=False, default=60)
|
||||
enqueued_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
decision = Column(String(16), nullable=True)
|
||||
# 允许值:approve | reject | expired(NULL = 待响应)
|
||||
resolved_by = Column(GUID(), nullable=True)
|
||||
resolved_at = Column(DateTime, nullable=True)
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_heicode_approvals_user", user_id),
|
||||
Index("idx_heicode_approvals_task", task_id),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user