From f2ebb09e34dd88c8c234dd253e631d5c13c30286 Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 13 Aug 2026 16:57:40 +0800 Subject: [PATCH] docs: describe group routing and account scheduling Co-authored-by: Codex --- docs/api.md | 61 ++++++++++++++++++++++++++++++++------------ docs/architecture.md | 38 +++++++++++++++++++++------ 2 files changed, 75 insertions(+), 24 deletions(-) diff --git a/docs/api.md b/docs/api.md index cc5eac0b2..7a9545d89 100644 --- a/docs/api.md +++ b/docs/api.md @@ -13,7 +13,9 @@ Authorization: Bearer sk_... ``` -Client Key 固定绑定一个 Provider:`openai` 或 `xai`。同一次请求不会跨 Provider fallback。 +Client Key 通过账号分组限定路由范围:未绑定分组时可使用全部账号,绑定一个或多个分组时只能使用 +已启用分组成员的并集。分组可以混合 `openai` 与 `xai` 账号;同一请求只会在模型能力明确匹配且满足 +重放安全边界时跨 Provider fallback。 ### 管理接口 @@ -62,7 +64,7 @@ Responses HTTP body、WebSocket message 和 frame 不设置网关私有长度上 | `POST` | `/v1/responses` | OpenAI Responses JSON;`stream=true` 返回 SSE,否则返回完整 JSON | | `GET` | `/v1/responses` | 通过 HTTP Upgrade 建立 Responses WebSocket | | `POST` | `/v1/responses/review` | 使用同一 Responses 合同发起 review 子代理请求 | -| `GET` | `/v1/models` | 返回当前 Client Key 所属 Provider 的可用公开模型;有两种响应形态,见下 | +| `GET` | `/v1/models` | 返回当前 Client Key 账号范围内各 Provider 的可用公开模型并集;有两种响应形态,见下 | | `GET` | `/v1/models/catalog` | 返回 Codex 客户端使用的模型目录 | | `GET` | `/v1/models/{model_id}/info` | 返回 Codex 客户端使用的单模型信息 | | `GET` | `/v1/models/{model_id}` | 返回 OpenAI 兼容的单模型详情 | @@ -75,7 +77,7 @@ OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段 假设 UUID 或固定长度;OpenAI 上游错误 envelope 和允许下发的 opaque header 值也不由 canonical 观测结果重写。xAI 是 Grok wire 与 Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。 上游结构化错误的 message/code/type 会透传给客户端,其中内嵌的账号指纹 UUID 已脱敏。模型映射是 -全局精确映射,未命中时模型名原样交给所属 Provider。 +全局精确映射,未命中时模型名原样交给候选 Provider;分组只限定账号集合,不参与模型改名。 ## 4. 管理员认证 @@ -92,14 +94,14 @@ OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段 | 方法 | 路由 | 主要 query/body | 说明 | | --- | --- | --- | --- | -| `GET` | `/api/admin/accounts` | `page`、`pageSize`、`provider`、`search`、`status`、排序字段 | 分页查询账号与汇总 | +| `GET` | `/api/admin/accounts` | `page`、`pageSize`、`provider`、`groupId`、`search`、`status`、排序字段 | 分页查询账号与汇总 | | `GET` | `/api/admin/accounts/detail` | `accountId` | 查询账号详情、额度和本地用量 | | `GET` | `/api/admin/accounts/export` | `accountIds`、`confirm=export_sensitive_accounts` | 显式导出最多 200 个账号的敏感 Provider 文档 | -| `POST` | `/api/admin/accounts/import` | `{ provider, data }` | 导入或按上游身份更新账号 | +| `POST` | `/api/admin/accounts/import` | `{ provider, data }` | 导入或按上游身份更新账号;新账号保持未分组,已有账号保留所属分组 | | `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 | | `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token | -| `POST` | `/api/admin/accounts/enable` | `{ provider, accountId }` | 启用账号 | -| `POST` | `/api/admin/accounts/disable` | `{ provider, accountId }` | 禁用账号 | +| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds }` | 一次更新账号调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)与所属分组 | +| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled, concurrencyLimit, weight, groupIds }` | 一次事务统一更新所选账号的全部调度字段与完整分组集合 | | `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 | | `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 | | `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 访问 Provider 并刷新额度,同时同步额度所属状态 | @@ -107,11 +109,12 @@ OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段 | `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖 cache | | `GET` | `/api/admin/accounts/connection-test` | `accountId`、`modelId` | 通过 SSE 返回实时连接测试事件,不作为业务 Responses 用量记录 | | `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, accountId? }` | 创建 OpenAI 或 xAI OAuth flow;`accountId` 表示重新授权 | -| `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl }` | 消费 OAuth callback 并写入账号 | +| `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl }` | 消费 OAuth callback;新账号保持未分组,重新授权保留所属分组 | 账号列表支持以下稳定值: - `provider`: `all`、`openai`、`xai`; +- `groupId`: 分组 ID、`ungrouped`,或省略以不过滤; - `status`: `normal`、`quota_exhausted`、`rate_limited`、`disabled`、`error`; - `sortBy`: `email`、`status`、`planType`、`usage`、`lastUsedAt`、`expiresAt`; - `sortDirection`: `asc`、`desc`。 @@ -124,6 +127,10 @@ OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段 - xAI API Key 不是受支持的账号 credential; - 导入不会只凭文件外形写入账号;目标 Provider 使用认证材料完成必要的 token exchange 或已认证账号资料补全。 +账号导入与 OAuth complete 不接收 `groupIds`。首次创建的账号保持未分组;按既有上游身份重新导入、 +重新授权以及普通 credential refresh/rotation 均保留已有分组。分组关系只通过账号编辑维护。 +账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。 + OpenAI 的 CPR 导出保持 OAuth 账号的既有字段;Agent Identity 账号则输出 `authMode: "agentIdentity"`、`agentRuntimeId`、`agentPrivateKey` 和可选的 `taskId`,不伪造 OAuth token 或过期时间。一个 `sourceFormat: "cpr"` 文档可以包含两种 @@ -203,21 +210,43 @@ OAuth start 使用: 新状态不符合当前筛选,该行从当前页移除。请求驱动或后台任务产生的状态变化,需要下一次显式查询账号 列表后才会显示。 -## 6. Client Key +## 6. 账号分组 + +分组是 Provider-neutral 的账号集合;一个组可包含任意 Provider 账号,一个账号也可属于多个组。 + +| 方法 | 路由 | 主要 query/body | 说明 | +| --- | --- | --- | --- | +| `GET` | `/api/admin/account-groups` | `page`、`pageSize`、`search`、`enabled` | 分页查询分组;返回账号可用性、并发槽位(Redis 不可用时 `usedSlots=null`)及成功请求 USD 用量 | +| `POST` | `/api/admin/account-groups/create` | `{ name, description, color }` | 创建空分组;`color` 严格为 `#RRGGBBAA`,返回时统一大写 | +| `POST` | `/api/admin/account-groups/update` | `{ id, name, description, color }` | 更新名称、描述和颜色 | +| `POST` | `/api/admin/account-groups/enable` | `{ id }` | 启用 | +| `POST` | `/api/admin/account-groups/disable` | `{ id }` | 禁用;已绑定 Key 保持受限,不回退到全部账号 | +| `POST` | `/api/admin/account-groups/delete` | `{ id }` | 删除未被 Client Key 引用的组 | +| `GET` | `/api/admin/account-groups/members` | `id` | 查询跨 Provider 成员 | + +列表数据为 `{ items, page, configRevision }`,其中 item 返回 `memberCount`、按 Provider 聚合的 +`providerCounts` 和 `clientKeyCount`。成员响应为 `{ id, items, total, configRevision }`;成员的 +`providerKind` 描述账号自身 Provider,并不是分组属性。 + +## 7. Client Key | 方法 | 路由 | 主要 query/body | 说明 | | --- | --- | --- | --- | | `GET` | `/api/admin/client-keys` | `cursor`、`limit`、`search`、`sortBy`、`sortDirection` | 游标分页查询 | -| `POST` | `/api/admin/client-keys/create` | 创建字段 | 创建绑定 Provider 的 Client Key | +| `POST` | `/api/admin/client-keys/create` | 创建字段 | 创建带账号范围的 Client Key | | `GET` | `/api/admin/client-keys/reveal` | `id` | 显式读取完整明文 Key | -| `POST` | `/api/admin/client-keys/update` | 更新字段 | 原子更新名称、Provider 和限额 | +| `POST` | `/api/admin/client-keys/update` | 更新字段 | 原子更新名称、分组范围和限额 | | `POST` | `/api/admin/client-keys/enable` | `{ id }` | 启用 | | `POST` | `/api/admin/client-keys/disable` | `{ id }` | 禁用 | | `POST` | `/api/admin/client-keys/delete` | `{ id }` | 删除 | -创建字段为 `name`、可选 `label`、`providerKind`、`maxConcurrency`、`requestsPerMinute`。更新请求再增加 `id`。创建和 reveal 响应会返回完整明文 Key,调用方必须立即安全保存。 +创建字段为 `name`、可选 `label`、`groupIds`、`maxConcurrency`、`requestsPerMinute`,更新请求再增加 +`id`。`groupIds` 必须显式提交:空数组派生 `routingScope: "all"`,非空数组派生 +`routingScope: "groups"`。响应同时返回分组引用 `groups`,以及从当前有效账号池派生、仅供展示的 +`providerKinds`;Client Key 不再保存 `providerKind`。创建和 reveal 响应会返回完整明文 Key,调用方 +必须立即安全保存。 -## 7. 运行设置 +## 8. 运行设置 | 方法 | 路由 | 说明 | | --- | --- | --- | @@ -243,7 +272,7 @@ auditRetentionDays `rotationStrategy` 可取 `smart`、`quota_reset_priority`、`round_robin`、`sticky`。 -## 8. 备份 +## 9. 备份 全部备份端点位于 `/api/admin/settings/backups/*`,内部由独立 BackupService 承担,不并入设置用例。响应继续使用 `AdminEnvelope`,wire 字段 camelCase,`Cache-Control: no-store`。 @@ -317,7 +346,7 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt 审计动作:`backup.s3_config_updated`、`backup.s3_connection_tested`、`backup.schedule_updated`、`backup.created`、`backup.download_url_created`、`backup.delete_requested`。审计详情与记录表均不保存 Secret、数据库连接串或预签名 URL query。 -## 9. Dashboard、用量与错误 +## 10. Dashboard、用量与错误 | 方法 | 路由 | 说明 | | --- | --- | --- | @@ -342,7 +371,7 @@ OpenAI 的 `serviceTier` 只接受上游响应生命周期事件确认的实际 `flex` 映射为 `Flex`,缺失或 `default` 映射为 `Default`;未知非空值原样展示。Fast 优先使用模型的 priority 价格,缺少专用价格时回退到标准价格的 `2.00x`;Flex 为 `0.50x`,Default 为 `1.00x`。 -## 10. 版本、更新与重启 +## 11. 版本、更新与重启 | 方法 | 路由 | 主要 query/body | 说明 | | --- | --- | --- | --- | diff --git a/docs/architecture.md b/docs/architecture.md index 79f4d2595..593acd839 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,16 +71,26 @@ API decode/auth 它在实际首字节写出前越过并按序入队。`downstream_committed_at` 是该事实的 PostgreSQL 观测投影, 不宣称字节已经到达客户端,也不反向决定协议交付。 5. Provider 每次 `execute` 只能选择一个 credential 并准备一个 cold stream,不得隐藏换号或业务 retry。 -6. 下游 commit 前,Core 可按冻结策略处理同一 Provider 内的账号 fallback;禁止跨 Provider fallback。 +6. 下游 commit 前,Core 先在当前 Provider 内按冻结策略换账号;只有调用仍处于 `not_sent`、错误属于 + 可安全推进的资源/基础设施类别且 operation 允许重放时,才推进到路由计划中的下一个 Provider。 7. 本架构不通过隐式连接复用承载业务身份。HTTP client 可安全复用 transport 连接,但账号、credential revision、cookie/session binding 必须显式绑定到本次调用。 管理端 Usage 详情中的 attempt 列表是 best-effort 观测(见 adr-002):中间失败来自 `ops_events`,最终尝试由 `model_requests` 合成,二者都不承诺完整(队列丢弃、写入失败、非失败观测不落库会造成缺口)。API 通过 `attemptsComplete: false` 显式标注这一语义,不再声称「全部尝试」。 ## 4. 路由、fallback 与错误处理 -RuntimeSnapshot 冻结 Provider 集合、模型能力、运行时策略和全局 `config_revision`。一次请求始终使用同一快照,不在执行中拼接新旧配置。 +RuntimeSnapshot 冻结 Provider 集合、模型能力、全部账号及 membership 索引、每个 Client Key 的账号 +范围、运行时策略和全局 `config_revision`。Key 零分组关联表示 `AllAccounts`;存在关联时表示 +`Restricted`,只允许已启用绑定组成员的并集。已绑定组全部禁用或为空会得到空池,绝不回退到全部账号。 +一次请求始终使用认证时取得的同一账号范围和快照,不在执行中拼接新旧配置,热路径也不查询 membership。 +账号调度事实包含可空的并发上限覆盖与 1–100 的权重;并发上限为空时继承运行参数,权重默认 1。 +管理员单账号或批量编辑在同一事务中替换启停、并发上限、权重和完整分组集合;credential 导入、 +重新授权与后台刷新不覆盖已有调度事实。 -Fallback 只允许在同一 Provider 内更换可用账号,不跨 Provider。 +模型先经过全局精确映射,再按账号范围内实际存在的 Provider 及其明确模型/operation 能力编译有序候选。 +普通请求和 `ReplayAny` 只有在 Provider 尚未发出请求,且返回无可用账号、账号容量不足或 Provider +基础设施不可用时,才可推进到下一 Provider;已建立流后的重试先在当前 Provider 内换账号。任何发送结果 +不明确或 downstream 已 commit 的请求都不跨目标重放。 明确的上游认证、封禁、额度或 cooldown 错误会更新对应账号状态,使下一次选择排除该账号;满足重放安全条件且尚未 downstream commit 时,可以换号。传输结果不明确时不得假定请求未到达上游。 @@ -96,6 +106,9 @@ continuation 或 claim 表。 - `store=false`:opaque replay state 仅存在于活连接内,不落 PostgreSQL。 - OpenAI continuation 顺序为 native、replay owner、replay any。 - xAI 使用客户端提交的完整历史作为已验证 continuation 路径。 +- native continuation pin 同时绑定创建它的 Client Key、冻结账号范围、Provider 和账号;跨 Key 复用、 + scope 外账号或计划中不存在该 Provider 都 fail closed。`ReplayOwner` 同样固定原 Provider 和账号, + 不参与跨 Provider fallback。 - continuation 失败仍受 send barrier、downstream commit 和 Provider kind 边界约束。 ## 6. Provider 与 credential owner @@ -157,22 +170,29 @@ Admin API 不要求客户端提交配置 revision,也不向客户端暴露配 - 推进 `runtime_settings.config_revision`; - 写入脱敏 `admin_audit_events`。 -推进全局 revision 的 mutation 包括 runtime settings、客户端 Key、账号导入/创建/删除、管理员显式启停和管理员 credential rotation。 +推进全局 revision 的 mutation 包括 runtime settings、账号分组 CRUD、客户端 Key 及其分组 +替换、账号编辑、账号导入/创建/删除、管理员显式启停和管理员 credential rotation。上述分组关系 +写入与业务 mutation、单次 revision 推进、脱敏审计处于同一 PostgreSQL 事务;审计会明确记录 +`routing_scope: groups -> all` 等权限范围变化。 不推进全局 revision 的运行时观测包括 quota、cooldown、catalog generation、请求统计以及自动 credential refresh;自动 refresh 只推进 `credential_revision`。提交后 Redis 通知只负责缩短其他副本的收敛延迟,周期性 PostgreSQL 对账才是正确性基础。 ## 8. PostgreSQL 终态 -`backend/migrations/0001_initial.sql` 直接定义当前大版本完整的新库基线,共创建九张业务表: +当前 schema 由冻结的编号迁移演进:`0001_initial.sql` 创建九张基线业务表, +`0005_account_groups.sql` 再增加三张 Provider-neutral 分组关系表,合计十二张业务表: | 表 | 权威事实 | | --- | --- | | `admin_users` | 管理员身份与密码摘要 | | `admin_audit_events` | 管理 mutation 审计 | | `client_api_keys` | 客户端鉴权、限额与授权范围 | +| `account_groups` | Provider-neutral 分组资料与启停事实 | +| `account_group_accounts` | 分组到账号的多对多 membership | +| `client_api_key_groups` | Client Key 到分组的多对多授权;零行表示全部账号 | | `runtime_settings` | 全局配置与 `config_revision` | | `provider_accounts` | 账号资料、Provider-owned 明文 credential JSON、revision、凭据事实与规范化 quota 事实 | -| `model_requests` | 请求、attempt、计费、交付与恢复事实 | +| `model_requests` | 请求、attempt、计费、交付、恢复事实及请求开始时冻结的路由范围快照 | | `ops_events` | 脱敏运行事件 | | `backup_settings` | S3/R2 存储、Cron 计划与保留策略单例配置 | | `backup_records` | 备份任务状态与归档事实(`queued/dumping/uploading/completed/failed/deleting`) | @@ -197,8 +217,10 @@ heartbeat;计划时间点冲突只记录日志/指标并推进游标,不产 客户端可见协议结果。队列不是第二份业务权威,也不做无幂等键的盲目重试。 - retention 只删除已满足保留规则的历史事实,不改变运行中请求。 -业务 schema 由 `backend/migrations/0001_initial.sql` 直接定义当前大版本的完整新库基线,其中 +业务 schema 由编号迁移定义;`0001_initial.sql` 是当前大版本基线,后续冻结迁移只向前演进。其中 response ID 使用无格式假设的 UTF-8 bytes,`model_requests.service_tier` 保存响应观测到的服务档位, +`routing_scope` 与 group ID/name 快照保存请求开始时的 `all`/`groups` 授权事实,迁移前历史明确标为 +`legacy_provider`, 账号对外五态由 PostgreSQL 凭据/额度事实与 Redis 429 冷却通过 Core 唯一解析器动态派生, 不保存重复的 `status` 列。同一大版本内已合入的迁移永久冻结—— `.frozen-sha256` 是冻结清单,CI 校验清单相对 PR base 只增不改,启动时 sqlx 重校验已应用 @@ -206,7 +228,7 @@ response ID 使用无格式假设的 UTF-8 bytes,`model_requests.service_tier` ## 9. Redis 终态 -Redis 保存的协调状态恰好是:客户端 admission 热状态、credential lease/fencing 与调度信号、账号 429 cooldown 与 credential state 可重建缓存、catalog 缓存、Provider circuit、会话亲和与会话级账号排除、continuation pin、OAuth pending flow、worker leader lease以及 runtime change 通知。Redis 丢失后必须从 PostgreSQL 或 Provider 事实恢复;恢复完成前需要保护的 acquire 路径 fail closed。 +Redis 保存的协调状态恰好是:客户端 admission 热状态、credential lease/fencing 与调度信号、账号 429 cooldown 与 credential state 可重建缓存、catalog 缓存、Provider circuit、会话亲和与会话级账号排除、continuation pin、OAuth pending flow、worker leader lease以及 runtime change 通知。会话亲和和 round-robin 游标按 Client Key + Provider 隔离;continuation payload 带 Client Key 所有权并在读取时校验。账号 lease、quota、cooldown 和请求间隔仍按真实账号全局共享。Redis 丢失后必须从 PostgreSQL 或 Provider 事实恢复;恢复完成前需要保护的 acquire 路径 fail closed。 多副本部署时,跨副本协调面只有上述 Redis 状态与 PostgreSQL 事实;WebSocket 连接池、请求画像与 RuntimeSnapshot 副本都是进程内状态,其中 RuntimeSnapshot 依靠周期对账收敛,请求画像依靠各自的官方版本检查收敛。