Files
sub2api/openspec/changes/codex-manifest-pinned-accounts/design.md
T

99 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
动机见 proposal.md。与方案相关的现状:
- Codex Model Manifest 入口是 `OpenAIGatewayHandler.CodexModels`:先尝试用账号模型映射本地生成;否则循环 `SelectAccountForModelWithExclusions` 选账号、`FetchCodexModelsManifest` 拉取、`CompleteAPIKeyCodexModelsManifestForClient` 补全、`MergeGroupConfiguredCodexModels` 做分组过滤与 ETag。
- `FetchCodexModelsManifest` 对 API Key 账号走 `fetchCachedAPIKeyCodexModelsManifest`(30 秒新鲜、5 分钟乐观、单飞后台刷新),对 OAuth 账号直接请求上游且带 agent identity 任务恢复逻辑,不缓存。
- 分组配置落地链路:ent schema → SQL 迁移 → `service.Group` → `group_repo` 创建与更新 setter → `api_key_repo` 分组字段投影与 `groupEntityToService` → 认证快照 `api_key_auth_cache.go` 结构体及 impl 两处映射 → 管理端 DTO / mapper → `admin_group.go` 创建与更新 → 分组复制。`models_list_config` 是完整样板。
- 前端 `GroupsView.vue` 已有 7000 行;模型路由的账号选择用「标签 + 搜索输入 + 下拉」内联实现,`Select.vue` 仅支持单选。`components/admin/group/` 下已有抽出的表单片段组件(如 `ReasoningEffortPolicyFields.vue`)。
- 后端管理端账号列表接口支持 `platform`、`group`、`search` 过滤。
## Goals / Non-Goals
**Goals:**
- 固定账号模式的运行时逻辑放在 service 层,handler 只做分支与错误映射。
- 合并逻辑是纯函数,可独立单测。
- 缓存策略只有一套实现,OAuth 与 API Key 账号共用;固定账号模式不引入第二层分组级缓存。
- 新字段在所有分组加载路径上都可见,特别是认证快照与 API Key 投影。
**Non-Goals:**
- 不改动调度器逻辑与普通请求的账号选择。
- 不为账号解绑或删除增加对该配置的级联清理,运行时容忍失效 ID。
- 固定账号关闭时保留本地生成 manifest 的路径;开启时以指定账号上游发现为先,映射在发现后应用。
- 创建分组对话框不提供该配置。
## Decisions
### D1:配置以单个 JSONB 列存储
`groups.codex_models_manifest_config`,领域类型 `domain.GroupCodexModelsManifestConfig{Enabled bool; AccountIDs []int64; FallbackToScheduler bool}`,JSON 键为 `enabled`、`account_ids`、`fallback_to_scheduler`。
- 备选:三个独立列。否决:三个字段语义耦合,JSON 保证原子写入,且与 `models_list_config` 一致。
- 迁移文件 `234_group_codex_models_manifest_config.sql`:`ADD COLUMN IF NOT EXISTS ... JSONB NOT NULL DEFAULT '{}'::jsonb`。ent schema 使用 `field.JSON(...).Default(domain.GroupCodexModelsManifestConfig{})`。
### D2:校验放在 admin service,创建路径拒绝开启
- 新文件 `group_codex_models_manifest.go` 提供 `normalizeCodexModelsManifestConfig`(平台非 openai 归零;去重保序;`enabled=false` 时保留列表便于再次启用)与 `validateCodexModelsManifestConfig(ctx, groupID, cfg)`(`enabled=true` 时:非空、≤10、全部属于 `accountRepo.ListByGroup(groupID)` 且平台 openai)。
- 创建路径:`enabled=true` 直接 400。原因:账号绑定发生在分组创建之后,创建时无法校验成员关系;前端也不展示。
- 备选:校验放 handler。否决:需要访问账号仓储,且创建与更新两条路径共用,service 是唯一收口。
### D3:运行时入口与回退策略
- handler 在本地生成分支之前判断 `group.Platform == openai && cfg.Enabled`,调用 `gatewayService.FetchPinnedCodexModelsManifest(ctx, group, clientVersion)`。
- 返回值:`(*OpenAIModelsResponse, *Account, error)`,`*Account` 为配置顺序中第一个成功账号,用于 `setOpsSelectedAccount`。
- 「无可用账号」以哨兵错误 `ErrNoPinnedCodexModelsAccounts` 表示;「全部失败」返回最后一个上游错误。handler 根据 `FallbackToScheduler` 决定:true 时跌入现有调度器循环;false 时无可用账号返回 503、全部失败按 `infraerrors.Code(err)` 返回。
- 可用性判定:`IsActive() && Schedulable && !(AutoPauseOnExpired && 已过期)`。刻意不使用 `IsSchedulable()`,因为它包含限流、过载与临时不可调度窗口。
- 账号来源:`accountRepo.ListByGroup(group.ID)`(active 成员),按配置顺序筛选并跳过缺失 ID。
- 备选:复用 `BuildGroupConfiguredCodexModelsManifest` 已加载的账号列表以省一次查询。否决:那两个列表是可调度集合,会漏掉限流中的账号;一次按分组的查询成本可接受。
### D4:并发拉取与纯函数合并
- 用 `sync.WaitGroup` 对每个可用账号并发执行:`FetchCodexModelsManifest(ctx, acc, clientVersion, "")` → API Key 账号再 `CompleteAPIKeyCodexModelsManifestForClient`。结果写入按配置顺序索引的切片,失败记录到同下标的错误切片;各账号独立完成,不因单账号失败取消其他请求。
- Codex 合并函数 `mergeCodexModelsManifestBodies(bodies [][]byte) ([]byte, error)`:以第一个 body 的顶层信封为基底,`models` 按 slug 并集,先出现者优先;slug 为空或解析失败的条目按出现顺序保留一次。输出后设置 `ETag = codexModelsManifestBodyETag(body)`,再交给 `MergeGroupConfiguredCodexModels` 做分组过滤与 304 判断。
- 部分失败:`slog.Warn` 带 group_id 与失败账号 ID 列表。
- 实现放在新文件 `openai_codex_models_pinned.go`,避免继续膨胀 2300 行的主文件。
### D5:缓存统一并调整时效
- 将 `fetchCachedAPIKeyCodexModelsManifest` 泛化为 `fetchCachedOpenAIModels(ctx, request, fetch, ifNoneMatch)`,`fetch func(ctx, ifNoneMatch) (*CodexModelsManifest, error)` 由调用方提供:API Key 路径传 `fetchCodexModelsManifestUpstream`;OAuth 路径传一个包含 agent identity 任务恢复逻辑的闭包。`handleCodexModelsManifestAccountAuthError` 在 OAuth 闭包返回错误时照旧调用。
- 常量:`openAIModelsCacheTTL` 30s → 60s;`openAIModelsCacheStaleTTL` 保持 5 分钟;超过 5 分钟 `get` 删除条目返回 miss,调用方同步等待单飞结果,这与「超期强制等待上游刷新」一致,无需新状态。
- 缓存键已包含 Authorization 与 Version 头,令牌刷新自然失效旧条目;`client_version` 不同的客户端各占一个条目。
- 缓存键不含分组信息,多个分组共用同一账号时共享同一条目,新鲜期内零上游请求,乐观期与超期刷新均按键单飞,同一时刻一个账号只发一次上游请求;分组级处理(并集合并、自定义列表过滤、别名合并)都在缓存体的克隆上进行,不写回缓存。
- `openAIModelsCacheMaxEntries` 64 → 512。OAuth 路径接入后条目数为「账号数 × 客户端版本数」,64 条在大规模部署下会被淘汰导致额外上游请求;单条清单通常几十 KB,最坏情况内存占用为几十 MB 量级。
- 后台刷新使用 `context.Background()` 加上游 15 秒超时,与现状一致。
- 备选:为固定账号模式增加分组级合并结果缓存。否决:账号级缓存命中后合并只是内存操作,再加一层会引入两套时效与失效问题。
### D6:前端拆出独立组件
- 新组件 `components/admin/group/CodexManifestAccountsField.vue`:props 为 `groupId`、`modelValue`(`{enabled, account_ids, fallback_to_scheduler}`)与已选账号名称映射;内部实现开关、标签列表、带防抖的搜索输入与下拉、回退子开关。搜索调用 `adminAPI.accounts.list(1, 20, {search, platform: 'openai', group: String(groupId)})`。
- `GroupsView.vue` 只在编辑对话框 OpenAI 区块挂载组件,打开编辑时用 `adminAPI.accounts.getById` 解析已存 ID 的名称,失败则显示 `#<id>`。提交时开关打开且列表为空则 toast 报错并阻止。
- 备选:在 GroupsView 内联复制模型路由的搜索状态。否决:会再引入一套按 key 索引的搜索状态,文件已过大。
## Risks / Trade-offs
- [固定账号模式下每次缓存超期会向 N 个账号并发请求] → 账号数上限 10;账号级缓存 1 分钟新鲜期把稳态请求量压到低于现状。
- [限流中的账号被强制用于拉取 manifest 可能加重其 429] → 这是需求明确要求的行为;manifest 请求不占并发槽,且被缓存吸收;失败时按部分合并处理。
- [部分账号失败导致模型列表短暂缺项] → 记录警告日志;下一次超期刷新恢复。用户已确认接受。
- [OAuth manifest 首次接入缓存,乐观期内令牌已被撤销仍会返回旧内容最多 4 分钟] → manifest 非敏感数据,且撤销后的下一次刷新会失败并按现有 401 处理流程处理账号。
- [配置引用的账号被解绑或删除后成为脏 ID] → 运行时跳过;编辑对话框显示 `#<id>` 提示管理员清理;全部失效时按回退配置处理。
- [认证快照漏投影新字段会让开关静默失效] → 仓库已有投影对账集成测试,任务中包含更新该测试。
## Migration Plan
1. 部署包含迁移 234 的后端版本;迁移幂等,默认值 `{}` 反序列化为关闭状态,存量分组行为不变。
2. 缓存时效变化随部署即时生效,无需数据迁移。
3. 回滚:回退代码即可,列保留无副作用;如需彻底清理可手工 `DROP COLUMN`。
### D7:普通模型列表共用固定账号来源
- `GatewayHandler.Models` 在 OpenAI 分组开启固定账号时调用 `FetchPinnedOpenAIModelsList`;普通 `/models` 别名共用该入口。
- 共享原有配置、固定账号成员筛选、并发收集与部分失败规则;标准列表失败且允许回退时用现有 OpenAI 调度器选账,再调用普通目录获取。无账号为 503,全部上游失败保留上游错误语义,不以静态默认列表掩盖失败。
- API Key 使用标准模型 URL,不添加 Codex `client_version` 或身份请求头;从管理端抽出 API Key 请求构造。OAuth 复用 `FetchCodexModelsManifest` 和规范客户端版本,保留凭据引用、Agent Identity 恢复、401 状态处理。
- 响应/缓存元数据类型泛化为 `OpenAIModelsResponse`;抽出原始 HTTP 响应获取,Codex 专用转换/元数据补全仅在 manifest 路径执行。两种请求使用同一缓存实现,以响应格式、完整 URL、账号、凭据、代理和请求头区分条目;相同 OAuth 请求共享条目。
- 标准列表保留上游 `id`、`created`、`owned_by` 等字段。OAuth slug 转成标准条目;无创建时间采用 0。有效空数组是成功结果,缺失或错误的数组结构是上游错误。
### D8:固定账号目录的映射与过滤
- 每个成功账号先基于它的原始目录做公开名称投影,随后按配置顺序取并集。透传或无映射账号保留上游具体 ID;有映射账号仅暴露映射目标存在于该账号目录的具体公开名称。
- 具体映射键以及分组选择的具体名称可作为别名候选;通配映射可匹配上游/分组提供的具体候选,但通配规则本身不作为模型 ID 返回。复用 `ResolveMappedModel` 的匹配优先级。
- Codex 别名继承对应上游条目的能力元数据;固定账号开启时不再注入其他分组账号的本地别名。固定账号回退选出的账号也应用相同投影。
- 分组列表过滤最后执行,遵守配置顺序;过滤后为空返回 200 空数组,不使用静态默认模型。最终响应体计算 ETag 并处理 304。
- 前端文案说明该配置覆盖普通模型列表和 Codex Model Manifest。持久化配置名称和结构不变。
### D9:扩展验证
新增路由/handler 与 service 测试覆盖普通两条路由、固定账号优先、原始媒体和未知模型、映射与通配别名、部分/全部失败及调度器回退、凭据/请求协议隔离、OAuth 缓存复用、三段缓存时效、分组过滤隔离与空目录、最终 ETag。运行受影响 Go 测试(含 race)、完整 unit 检查及前端类型/组件检查。