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:
2026-05-12 15:43:10 +08:00
co-authored by Claude Opus 4.7
parent eb17ed84f8
commit 610fde5d03
20 changed files with 7218 additions and 46 deletions
+6
View File
@@ -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 同步更新。
+832
View File
@@ -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
+333
View File
@@ -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 | 初版:基于代码核实结果绘制 |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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 路径
+71
View File
@@ -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
+263
View File
@@ -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),
})
+199 -14
View File
@@ -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)
+17
View File
@@ -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);
+71 -2
View File
@@ -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),
)