From 42be14c8d0582e88544abd39f7027c1e7ff91103 Mon Sep 17 00:00:00 2001 From: chuan Date: Fri, 14 Aug 2026 14:47:33 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E7=AC=AC=E4=B8=80=E4=B8=AA=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E7=9A=84=E8=AE=A8=E8=AE=BA=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/README.md | 75 ++++ docs/modules/access-routing.md | 199 +++++++++ docs/modules/api.md | 437 ++++++++++++++++++++ docs/modules/billing.md | 618 ++++++++++++++++++++++++++++ docs/modules/collection.md | 189 +++++++++ docs/modules/core.md | 430 +++++++++++++++++++ docs/modules/data.md | 429 +++++++++++++++++++ docs/modules/dev.md | 610 +++++++++++++++++++++++++++ docs/modules/operations.md | 454 ++++++++++++++++++++ docs/modules/persistence.md | 191 +++++++++ docs/modules/pricing.md | 378 +++++++++++++++++ docs/modules/security.md | 364 ++++++++++++++++ docs/modules/statistics.md | 342 +++++++++++++++ docs/modules/test-plan.md | 662 ++++++++++++++++++++++++++++++ docs/modules/ui.md | 261 ++++++++++++ docs/modules/upstream-accounts.md | 395 ++++++++++++++++++ 16 files changed, 6034 insertions(+) create mode 100644 docs/modules/README.md create mode 100644 docs/modules/access-routing.md create mode 100644 docs/modules/api.md create mode 100644 docs/modules/billing.md create mode 100644 docs/modules/collection.md create mode 100644 docs/modules/core.md create mode 100644 docs/modules/data.md create mode 100644 docs/modules/dev.md create mode 100644 docs/modules/operations.md create mode 100644 docs/modules/persistence.md create mode 100644 docs/modules/pricing.md create mode 100644 docs/modules/security.md create mode 100644 docs/modules/statistics.md create mode 100644 docs/modules/test-plan.md create mode 100644 docs/modules/ui.md create mode 100644 docs/modules/upstream-accounts.md diff --git a/docs/modules/README.md b/docs/modules/README.md new file mode 100644 index 0000000..5ea80bd --- /dev/null +++ b/docs/modules/README.md @@ -0,0 +1,75 @@ +# 功能模块 + +`cpa-ext` 采用组合式模块架构。每个模块拥有清晰的职责、数据和接口,可以独立测试与演进;Core 负责组合业务,插件运行时只负责把 CLIProxyAPI 的能力调用适配到 Core 和相应模块。 + +## 模块列表 + +| 模块 | 状态 | 职责 | +| --- | --- | --- | +| [数据模块](data.md) | 设计中 | 定义整个插件流转时共同关注的数据、来源、语义、质量和输出契约 | +| [Core 核心业务模块](core.md) | 设计中 | 组合认证、准入、路由、采集、计费、持久化、统计通知和管理用例,形成完整业务闭环 | +| [插件运行时与 CPA 集成](dev.md) | 设计中 | 负责 ABI/RPC、能力注册、CPA 适配、配置、生命周期、构建和宿主验证 | +| [CPA 数据采集模块](collection.md) | 设计中 | 从 CPA 请求生命周期、用量、管理接口和上游响应取得数据并生成标准事实 | +| [上游账户与配额模块](upstream-accounts.md) | 设计中 | 将 CPA Auth 抽象为稳定上游账户,维护 priority、可绑定性、配额、订阅和账户池 | +| [本地持久化模块](persistence.md) | 设计中 | 使用 SQLite 安全保存事实、账本、目录、快照和可重建聚合 | +| [价格目录与计价政策模块](pricing.md) | 设计中 | 管理价格来源、不可变版本、模型别名、长上下文和 Fast/priority 政策 | +| [计费模块](billing.md) | 设计中 | API Key 套餐、金额额度、请求准入、费用结算和账本 | +| [Key 准入与账户路由模块](access-routing.md) | 设计中 | 决定 Key 是否可用,并可将指定 Key 路由到指定上游账户 | +| [统计分析模块](statistics.md) | 设计中 | 把不可变事实与账本增量处理为可重建聚合、实时查询和权限化展示投影 | +| [HTTP API 模块](api.md) | 设计中 | 冻结管理员与用户接口、精确路径、DTO、金额、错误、权限、分页和幂等契约 | +| [管理与展示 UI 模块](ui.md) | 设计中 | 提供用户金额视图和管理员统计、账户、配额、价格与诊断界面 | +| [安全模块](security.md) | 设计中 | 定义认证旁路防护、secret、租户、账本、网络、native 和供应链安全边界 | +| [部署与运维模块](operations.md) | 设计中 | 构建安装、配置、readiness、监控、备份恢复、升级回滚和故障 Runbook | +| [测试与发布门禁](test-plan.md) | 设计中 | 将业务闭环、故障、安全、性能、容量、soak 和平台兼容转成发布证据 | + +跨模块只能通过数据模块定义的明确契约协作。数据模块定义“系统关注什么”,不负责计费、聚合或展示;计费模块不能依赖统计查询才能决定是否放行请求;统计模块不能重新决定一笔请求应该扣多少钱。 + +## 结构关系 + +```text +CLIProxyAPI + │ ABI / JSON RPC + ▼ +Dev runtime & CPA adapters + │ + ▼ +Core application use cases + ├─ Collection + ├─ Upstream accounts & quota + ├─ Access & Routing + ├─ Pricing policy + ├─ Billing + ├─ Statistics query facade + └─ Management use cases + │ + ▼ + Persistence + +Data contracts:贯穿所有模块 +HTTP API:把 Core/Query 用例暴露给管理员和用户 +UI:只通过 HTTP API 访问业务 +Security / Operations / Test Plan:贯穿设计、实现与发布 +``` + +## 实现顺序 + +Core 会从第一条纵向链路开始逐步长成,不应等所有领域模块完成后一次编写。推荐顺序: + +1. P0 runtime/performance spike:验证双 Go runtime、零常驻 goroutine、SQLite driver、callback-driven pump、schema 3 协商、stream hook RPC 字节量、disable/reconfigure/restart 和 24h soak;失败则确定 sidecar 或 CPA host-contract 改造; +2. 冻结数据契约 v1; +3. 建立 Dev runtime 最小可加载骨架,并完成 required-plugin readiness + sentinel/gateway 门禁; +4. 建立 SQLite、migration、Repository、ProjectionEvent 和 Unit of Work; +5. 打通 Collection 的 durable Request/Execution/Usage 事实链,并建立 UpstreamAccount/Auth 引用目录; +6. 实现不可变 Pricing Snapshot、GPT-5.6/Fast golden cases 和发布流程; +7. 实现 Billing 纯领域计算和不可变账本; +8. 实现 Access/Route,并由 Core 组合出真实请求闭环; +9. 完成插件自管 Key、管理用例和 v1 Management/User API; +10. 实现 Statistics MVP 增量聚合和查询; +11. 实现用户金额页与管理员 UI; +12. 按 Operations 完成打包、备份、监控和升级流程,并通过 Test Plan 全部门禁。 + +第八步结束时必须先形成最小价值闭环:签发 Key → 金额准入 → 指定 Codex 账户 → 调用 → 按实际 Usage 扣费 → 额度耗尽拒绝。取消、失败和迟到 Usage 也必须在这个闭环中验收,不能留到 UI 阶段。 + +第一版 c-shared 动态库默认采用 callback-driven maintenance;复杂常驻聚合、归档、备份和 provider refresh 只有 soak gate 通过后才进入动态库,否则由 sidecar/运维命令承担。CPA `home.enabled` 整体不支持,二进制升级要求排空并重启 CPA。 + +Security 不是最后补做的模块:exclusive/sentinel/gateway、secret 处理、租户 scope 和 fail-closed 从第一条纵向链路就必须实现。每个阶段都执行 Test Plan 对应 gate,不能等发布前一次性补测试。 diff --git a/docs/modules/access-routing.md b/docs/modules/access-routing.md new file mode 100644 index 0000000..ced18d9 --- /dev/null +++ b/docs/modules/access-routing.md @@ -0,0 +1,199 @@ +# Key 准入与账户路由模块 + +## 1. 定位 + +该模块回答两个实时问题: + +1. 当前下游 Key 是否允许发起请求; +2. 如果允许,这个 Key 应该使用哪个上游 Codex 账户。 + +它与计费模块及 [上游账户模块](upstream-accounts.md) 协作,但职责不同:计费模块维护金额额度和账本;上游账户模块维护 CPA Auth、priority、配额和 bindability 快照;本模块读取这些状态和路由策略,产出允许、拒绝或选中上游账户的决定。 + +## 2. 能否指定 Key 使用某个账户 + +可以,当前 CPA 已提供原生 `scheduler` capability,不需要修改 CPA。 + +`scheduler.pick` 能取得: + +- request metadata 中的 `caller_scope`; +- provider、model、stream; +- CPA 已过滤出的当前可用 auth candidates; +- candidate 的稳定 `AuthID`、provider、priority、status 和安全 attributes。 + +插件通过 [upstream-accounts.md](upstream-accounts.md) 维护内部账户到当前 CPA AuthID 的显式映射,并建立: + +```text +caller_scope → DownstreamCredential → RoutePolicy → UpstreamIdentity.AuthID +``` + +当目标 `AuthID` 出现在 candidates 中,scheduler 返回该 ID,CPA 使用指定账户。候选已经经过 disabled、provider、model compatibility、cooldown、tried 等过滤,因此插件不能也不需要强行选择不可用账户。 + +重要限制: + +- CPA 当前只启用最高优先级的一个 scheduler plugin;本插件会占用该能力,必须在内部组合所有调度规则; +- CPA `home.enabled` 会跳过 plugin scheduler,并让 plugin Management/resource routes 返回 404;cpa-ext 第一版整体拒绝 Home 模式,不只是禁用账户绑定; +- scheduler 接收的是 `AuthID`,管理 UI 可展示稳定 `AuthIndex`/别名,但落库必须能解析到当前 `AuthID`; +- `request_interceptor` 不能修改 metadata,因此不能依靠它注入 `pinned_auth_id`;正确入口是 scheduler; +- scheduler candidate metadata 在当前构造中基本为空,路由应依赖 ID、provider、priority、status、attributes 和本地身份目录。 +- plugin scheduler 当前只看到 CPA 已选出的最高可用 priority tier,而不是所有健康 Auth;绑定到较低 priority 的账号即使健康也不会出现在 candidates。MVP 要求所有可绑定 Codex Auth 使用相同 CPA priority,管理 UI 明确显示 `scheduler_visible/bindable`;需要跨 priority 绑定时必须先扩展宿主候选契约。 + +## 3. 准入决策顺序 + +插件自管 Key 主路径先由 exclusive `frontend_auth_provider` 校验 method/path allowlist、Key 的密码学有效性及 active 状态。未知入口或未知、畸形、pending、disabled、expired、revoked Key 对外统一认证失败,不向未认证调用方枚举具体状态。 + +当前 CPA revision 的 frontend-auth wire response 只能表达 `Authenticated`、`Principal` 和 metadata。宿主适配器会把插件 RPC 错误或 `Authenticated=false` 都转换为 `NotHandled`;在本插件是唯一生效的 exclusive provider 时,最终响应是宿主统一的 `401 no_credentials`。因此第一版不能承诺 `invalid_credential`、`credential_disabled` 或认证依赖故障的自定义 HTTP 状态;如需区分,必须先扩展 CPA 的 frontend-auth 错误契约与适配器。 + +路径 allowlist 必须在 frontend auth 完成,而不能只依赖 request interceptor:CPA 有些经过 frontend authentication 的 Alpha Search、Live、Realtime/client-secret 路由并不进入普通 interceptor/scheduler/usage/lifecycle 链。MVP 支持矩阵如下,默认拒绝未列入口: + +| 入口 | MVP | 准入/路由/结算要求 | +| --- | --- | --- | +| `POST /v1/responses` | 允许 | 必须通过完整宿主集成测试 | +| `POST /v1/responses/compact` | 允许 | 必须验证 Usage 与 completion | +| `POST /backend-api/codex/responses` | 允许 | 同 Responses 链 | +| `POST /backend-api/codex/responses/compact` | 允许 | 同 Compact 链 | +| `GET /v1/models` | 条件允许、零金额 | 仅验证无可计费执行后开启 | +| Responses WebSocket、Alpha Search、Live、Realtime/client secret | 拒绝 | 当前链路覆盖不足,存在二级凭证/绕过风险 | +| chat/completions、messages、图片、视频、Gemini/其他未知入口 | 拒绝 | 非初期 Codex 范围或尚未验证 | + +认证成功后,`request.intercept_before` 按固定顺序执行: + +1. 验证 `caller_scope` 能映射到已认证 credential,并重新检查状态以防认证后的并发变更; +2. 检查账户是否启用、Credential 是否仍 active; +3. 检查允许的 provider/model/endpoint/service tier; +4. 查询计费周期、额度和余额; +5. 检查并发限制或进行额度预留(若启用); +6. 固化 plan/cycle/account 路由快照; +7. 允许请求进入 CPA auth selection。 + +拒绝应返回稳定错误码: + +| 场景 | HTTP | 错误码 | +| --- | --- | --- | +| 缺少/未知/无效/非 active Key(当前 CPA 认证阶段) | 401 | `no_credentials`(宿主统一返回) | +| 已认证但未绑定本地账户/策略 | 403 | `access_key_unbound` | +| 认证后状态被并发改为不可用 | 403 | `access_credential_inactive` | +| 模型/endpoint 不允许 | 403 | `access_scope_denied` | +| 余额不足 | 429 | `billing_quota_exhausted` | +| 并发已满 | 429 | `access_concurrency_exceeded` | +| 严格绑定账户不可用 | 503 | `upstream_binding_unavailable` | +| 未找到可信价格 | 503 | `billing_price_unavailable` | + +只有身份验证失败使用不泄露细节的统一 401;身份已经确认后的策略拒绝、额度耗尽和上游不可用必须使用各自稳定错误码。认证数据库故障同样会被当前适配器折叠为 `401 no_credentials`:系统仍然 fail closed,但必须写内部诊断和告警,不能把该响应误判成真实坏 Key。认证后的 Core/账本依赖故障仍可由 interceptor 返回明确的 `503`。 + +interceptor 的业务拒绝必须返回正常 RPC success envelope,并设置 `Terminate=true`、合法 `StatusCode` 和安全响应体;返回 RPC error 或让 panic 越过插件边界会被当前 CPA 忽略并继续请求。scheduler 的 strict 拒绝必须返回 RPC error;`Handled=false`、空值、未知 AuthID 和宿主边界 panic都会回退内置 scheduler,不能用来表达 fail closed。 + +## 4. Key 状态 + +Credential 状态固定为: + +- `active`:正常使用; +- `disabled`:管理员禁止; +- `expired`:超过有效期; +- `revoked`:永久撤销,仅保留历史; +- `pending`:已创建但尚未启用。 + +`unbound` 是账户/套餐/路由绑定状态,不是 Credential 认证状态。active Credential 可以先通过身份认证,再由 interceptor 返回 `403 access_key_unbound`;不能把它和未知 Key 一样藏进凭证状态后丢失可管理性。 + +人工禁用优先于所有自动策略。删除/轮换 Key 不删除账户余额和历史账本;一个账户可以拥有多个 Key。 + +## 5. 上游路由策略 + +每个 credential/account 可配置以下策略: + +### 5.1 `default` + +不指定账户,scheduler 返回 unhandled 或委托 CPA `round-robin` / `fill-first`。适合普通共享池。 + +### 5.2 `strict` + +只允许指定上游账户。目标不在 candidates 时返回 scheduler 错误,不允许 CPA 自动切换其他账号。 + +dispatcher 必须在插件内部 recover scheduler panic 并转为明确 RPC error。after-auth interceptor 还要在执行前复核实际 `selected_auth_id`;不属于允许集合时 `Terminate=true`。不过如果插件已在宿主边界被 fuse,当前 CPA 仍可能跳过这两层并回退,生产部署必须依赖 dev 模块定义的 required-plugin/startup 门禁,而不能声称纯插件具备绝对隔离。 + +用途:成本归属、专属账户、隐私隔离、测试账号。 + +### 5.3 `preferred` + +优先指定账户;不可用时按明确 fallback pool 或 CPA 内置策略选择。 + +必须记录:目标账户、实际账户、是否 fallback、fallback 原因。 + +### 5.4 `pool` + +绑定到一组上游账户,在组内 round-robin、fill-first、最低使用率或健康优先。第一版建议只实现有确定语义的 round-robin/fill-first,配额感知调度以后再做。 + +数据结构示意: + +```go +type RoutePolicy struct { + CredentialID string + Mode string // default, strict, preferred, pool + PrimaryUpstreamID string + FallbackPoolID string + BuiltinDelegate string // round-robin, fill-first + AllowedModels []string + AllowedTiers []string +} +``` + +## 6. 重试语义 + +CPA 每次重试都会重新构造候选,并排除已经 tried 的账户: + +- strict:第一次指定账户失败后,下一次 pick 中它通常不再是 candidate;插件应拒绝,保证绝不串到别的账户; +- preferred:指定账户不在 candidate 时选择 fallback,并创建新的 Execution; +- default/pool:按剩余 candidates 继续选择。 + +用户视角仍是一条 Request,底层每次账户选择是一条 Execution。每个能够可靠观察和关联的 Execution Token 都必须保留,不能主动用最后一次尝试覆盖前面的事实。当前 CPA `usage.handle` 缺少 RequestID/AttemptID,无法安全关联的失败尝试必须标为 `unmeasured`,不能按时间或 Auth 猜配收费。 + +## 7. 与计费模块协作 + +- 计费额度属于 BillingAccount,Key 是 Credential;每个 Credential 必须绑定一个 BillingAccount,多个 Key 是否共享额度由绑定关系决定; +- 准入时读取余额,但结算使用实际完成后的金额; +- 准入快照锁定 account、plan、cycle,Key 在执行中被重新绑定不改变本次费用归属; +- 并发请求可能产生有限负余额,后续请求立即拒绝; +- 若未来使用预授权,预留与最终金额差额通过账本释放/补扣; +- 失败/取消只要上游报告用量仍可结算;本地直接拒绝且无上游执行不收费。 + +这是请求结束后结算的**软金额额度**,不能对用户承诺绝不超额。MVP 每个 BillingAccount 默认并发上限为 1,把最坏超额限制在单个在途请求;管理员可以明确调高。只有加入输入估算、最大输出预占并确认上游硬输出限制后,才可以提供更接近硬上限的模式。 + +## 8. 管理能力 + +管理员需要: + +- 创建、启用、禁用、撤销和轮换 Key; +- 设置 Key 标签、有效期、模型/tier 范围; +- 绑定账户和金额套餐; +- 选择 default/strict/preferred/pool; +- 选择上游账户/池并看到其状态、订阅和配额; +- 看到当前最高 priority tier、`scheduler_visible/bindable`,并阻止绑定宿主永远不会暴露给 scheduler 的 Auth; +- 查看“期望账户 vs 实际账户”和 fallback 记录; +- 批量禁用、批量绑定和同步 CPA Key; +- 在修改前预览会影响哪些 Key。 + +## 9. 安全与故障策略 + +- Key 明文只在创建时显示一次;事件和日志仅存 credential/public ID 与 preview,不记录明文或 HMAC digest;兼容 CPA Key 只允许记录不可逆 scope; +- scheduler 超时、panic 或数据存储不可用时,已受控 Key 应 fail closed,不能绕过额度或账户隔离; +- 配置热更新使用不可变快照原子替换;已注册插件的失败按 dev 文档返回 LKG success registration 并保留上一个有效配置,不能让宿主撤下 exclusive capability; +- 准入与 completion 清理必须幂等; +- scheduler 不进行网络请求或慢查询,路由目录保存在内存快照并由数据库更新驱动刷新; +- 管理员紧急禁用应快速进入内存快照,新请求立即生效,已执行请求仍完成账务归属。 + +## 10. 参考路径与验收 + +CPA: + +- `sdk/pluginapi/types.go` 的 Scheduler 类型; +- `internal/pluginhost/scheduler.go`; +- `sdk/cliproxy/auth/conductor_selection.go`; +- `examples/plugin/scheduler/`; +- `examples/plugin/request-lifecycle/`。 + +下游参考: + +- `cpa-plugin-key-billing/internal/billing/enforce.go`; +- `pending.go`、`keys.go`、`plan.go`; +- `internal/plugin/intercept.go`。 + +验收场景包括:枚举全部 CPA 已认证路由并验证 allowlist、未知 Key、禁用、过期、余额不足、严格绑定可用/不可用、preferred fallback、重试、模型限制、并发、LKG reconfigure、重复 completion、interceptor/scheduler error 与 panic、插件 fuse/缺失、Home 模式启动拒绝以及 CPA 内置 scheduler 委托。 diff --git a/docs/modules/api.md b/docs/modules/api.md new file mode 100644 index 0000000..8de46c7 --- /dev/null +++ b/docs/modules/api.md @@ -0,0 +1,437 @@ +# HTTP API 模块 + +## 1. 定位 + +本模块冻结 cpa-ext 的 HTTP 边界:谁可以调用、使用哪些固定路径、请求/响应如何表达、错误是否稳定,以及 UI 如何只通过公开用例访问 Core。 + +API 不直接暴露 Repository 表,也不允许 UI 自行拼 SQL 语义。每个写接口都对应 [core.md](core.md) 中的应用用例,每个查询都经过权限化 facade。 + +当前宿主基线为 CLIProxyAPI `v7.2.130`: + +- 插件 Management routes 位于 `/v0/management/...`,由 CPA Management Key 保护; +- plugin resource 位于 `/v0/resource/plugins//...`,宿主只支持未鉴权的精确 GET; +- 插件声明的路径不能包含 `:`, `*` 或 `..`; +- Management 和 resource handler 都会在进入插件前被宿主完整读入内存; +- `home.enabled` 时两类插件路由均不可用; +- Management JSON 的所有字符串目前会被宿主 HTML entity 转义,resource JSON 不会。 + +这些是设计约束,不是实现细节。 + +## 2. API 表面 + +### 2.1 管理 API + +固定基路径: + +```text +/v0/management/plugins/cpa-ext/v1 +``` + +CPA 先执行 Management 鉴权,再把请求交给插件。cpa-ext 不创建第二套管理员密码或 session。第一版 CPA 只有一把 Management Key,因此所有 Management 调用都是全管理员权限,不能在文档中虚构细粒度 RBAC。 + +### 2.2 Browser resources + +固定基路径: + +```text +/v0/resource/plugins/cpa-ext +``` + +用途分为: + +- 静态 UI shell 和构建产物; +- 最小公开 readiness; +- 由插件自行校验 downstream Key 的用户只读 JSON。 + +resource route 在宿主层**没有鉴权**。任何包含管理员数据、写操作、OAuth 原文或数据库备份的 handler 都不得注册为 ResourceRoute。 + +### 2.3 模型调用 API + +模型调用仍使用 CPA 原生 Codex/OpenAI Responses 路径。cpa-ext 通过 frontend auth、interceptor、scheduler 和 response/usage callback 参与,不另建代理入口。允许路径见 [access-routing.md](access-routing.md)。 + +## 3. 通用 wire 约定 + +### 3.1 JSON + +- Content-Type:`application/json; charset=utf-8`; +- 字段名:`snake_case`; +- 未知写入字段默认拒绝; +- 空集合返回 `[]`/`{}`,不使用 `null`; +- ID 是 opaque string,调用方不能解析前缀获得权限; +- 时间是 UTC RFC3339Nano; +- 时长使用整数毫秒或明确命名的秒数; +- 百分比使用 `0..100` 并明确字段名,fraction 使用 `0..1`; +- Token 只出现在管理员审计 DTO; +- secrets、Authorization、Prompt、Response 和 raw Auth JSON 永不进入普通 DTO。 + +### 3.2 金额 + +```json +{ + "currency": "USD", + "micros": 1234567, + "display": "$1.234567" +} +``` + +`currency + micros` 是机器真值,`display` 由服务端统一 formatter 生成。用户看到的余额、额度、消费和账单只有金额,不返回 credits/token 余额。管理员可以在账单明细中额外看到 Token、费率和倍率。 + +### 3.3 成功响应 + +单对象: + +```json +{ + "data": {}, + "meta": { + "request_id": "req_opaque", + "revision": 12 + } +} +``` + +列表: + +```json +{ + "data": [], + "meta": { + "request_id": "req_opaque", + "next_cursor": "opaque_cursor", + "has_more": false + } +} +``` + +创建 Credential/轮换 secret 的第一次成功响应额外包含 `secret`,并强制 `Cache-Control: no-store`。明文只展示一次。 + +### 3.4 错误响应 + +```json +{ + "error": { + "code": "billing_quota_exhausted", + "message": "Amount balance is exhausted.", + "request_id": "req_opaque", + "retryable": false, + "details": { + "field": "billing_account_id" + } + } +} +``` + +`message` 可本地化且不得作为程序判断依据;`code` 稳定。`details` 只包含安全字段、约束和资源 ID,不回显 secret 或原始数据库错误。 + +状态映射: + +| HTTP | 语义 | +| ---: | --- | +| 400 | DTO/字段/游标无效 | +| 401 | 用户 Key 无效;当前 CPA frontend auth 也会把认证依赖故障折叠为 401 | +| 403 | 已认证但无权限/入口不允许 | +| 404 | 资源不存在或为防枚举而隐藏 | +| 409 | revision、状态或幂等冲突 | +| 412 | `expected_revision` 不匹配 | +| 413 | 请求体过大;真正上限必须先由反向代理/CPA HTTP 层执行 | +| 422 | 语法正确但领域规则不可满足 | +| 429 | 金额额度、并发或速率限制 | +| 500 | 未分类内部缺陷,响应脱敏 | +| 503 | 数据库、账本、价格或必需 runtime 不健康 | + +### 3.5 版本与并发控制 + +- 路径版本为 `v1`; +- 响应头返回 `X-CPA-Ext-API-Version: 1`; +- 每个可写对象返回 `revision`; +- PATCH/PUT/状态动作必须携带 `expected_revision`; +- 版本不匹配返回 `412 revision_mismatch` 和当前 revision; +- 不允许 last-write-wins 覆盖套餐、价格、路由和账户状态。 + +### 3.6 幂等 + +以下写操作必须携带 `Idempotency-Key`: + +- provision/签发/轮换 Credential; +- 金额 adjustment; +- 价格发布/回滚; +- quota 批量刷新; +- backup/rebuild 等 maintenance command。 + +相同 key + 相同 payload 返回同一业务结果;相同 key + 不同 payload 返回 `409 idempotency_conflict`。 + +Credential 明文不持久化,因此签发/轮换响应重放只保证不会再创建第二个 secret:首次响应有 `secret`,后续幂等重放返回同一 Credential 但 `secret=null`、`secret_available=false`。如果调用方丢失首次响应,只能执行轮换。 + +## 4. 管理 API 路由 + +CPA 只允许精确路径,所以记录 ID 放 query/body,不使用 `/credentials/:id`。 + +### 4.1 系统与诊断 + +| Method | Path suffix | 用途 | +| --- | --- | --- | +| GET | `/status` | 版本、runtime topology、schema/capabilities、数据库与统计状态 | +| GET | `/readiness` | 计费依赖、HMAC keyring、active price、gateway 前置条件 | +| GET | `/diagnostics` | 脱敏故障、lag、unpriced/unmeasured/persistence gap | + +`readiness` 只能证明插件自身依赖。部署网关还必须验证 CPA 中插件 active、Home 关闭、exclusive/sentinel 流量探针成立,不能只信一个自报 JSON。 + +### 4.2 下游账户、Credential 与套餐 + +| Method | Path suffix | 用途 | +| --- | --- | --- | +| POST | `/provision` | 原子创建 BillingAccount、绑定 Plan/Route 并签发第一把 Key | +| GET | `/billing-accounts` | 列表或 `?id=` 详情 | +| POST | `/billing-accounts` | 创建空账户 | +| PATCH | `/billing-accounts` | 修改 label/status/并发等安全字段 | +| GET | `/credentials` | 列表或 `?id=` 详情,不返回明文/digest | +| POST | `/credentials` | 为现有账户签发 Key | +| PATCH | `/credentials` | label、有效期、模型/tier 范围 | +| POST | `/credentials/rotate` | 创建新 SecretVersion 并按策略失效旧版本 | +| POST | `/credentials/revoke` | 撤销 Credential/SecretVersion | +| GET | `/plans` | 计划列表或详情 | +| POST | `/plans` | 创建计划 | +| PATCH | `/plans` | 修改计划,必须预览影响 | +| DELETE | `/plans` | retire 未使用计划;历史引用不删除 | +| GET | `/route-policies` | 查询账户/credential 路由策略 | +| PUT | `/route-policies` | 原子替换一条策略及绑定 | + +`POST /provision` 是 UI 的主路径,避免业务层出现“账户创建成功但 Key/Plan/Route 只完成一半”。其事务边界以 [core.md](core.md) 为准。 + +### 4.3 金额账本 + +| Method | Path suffix | 用途 | +| --- | --- | --- | +| GET | `/ledger` | 按 account、时间、kind 稳定分页 | +| POST | `/ledger/adjustments` | 管理员充值、扣减、refund/纠错 | +| GET | `/billing-cycles` | 周期、额度、消费、余额投影 | + +Adjustment 必须包含 `billing_account_id`、Money、kind、reason、`expected_revision` 和 `Idempotency-Key`。API 不提供“直接改 balance”端点。 + +### 4.4 价格 + +| Method | Path suffix | 用途 | +| --- | --- | --- | +| GET | `/pricing/active` | active version 与模型政策 | +| GET | `/pricing/versions` | 版本历史/详情 | +| GET | `/pricing/candidates` | 候选、来源和冲突 | +| POST | `/pricing/candidates/refresh` | 拉取外部 candidate,不发布 | +| POST | `/pricing/preview` | 校验 draft、diff、golden cases | +| POST | `/pricing/publish` | 显式发布不可变版本 | +| POST | `/pricing/rollback` | 以历史内容创建并发布新版本 | + +没有“直接 PUT 当前价格”的捷径。所有变更走 draft/preview/publish,详见 [pricing.md](pricing.md)。 + +### 4.5 上游账户和池 + +| Method | Path suffix | 用途 | +| --- | --- | --- | +| GET | `/upstream-accounts` | 目录、状态、priority、bindability | +| POST | `/upstream-accounts/sync` | 触发 host.auth.list reconciliation | +| POST | `/upstream-accounts/reconcile` | 人工确认 Auth replacement/merge 拒绝 | +| GET | `/upstream-quotas` | 最新 quota snapshot | +| POST | `/upstream-quotas/refresh` | 单个/有界批量按需刷新 | +| GET | `/upstream-pools` | 池列表/详情 | +| POST | `/upstream-pools` | 创建池 | +| PATCH | `/upstream-pools` | 修改名称/成员/revision | +| DELETE | `/upstream-pools` | retire 无引用池 | + +Quota refresh 请求只接受内部 `upstream_account_id[]`,不接受 URL、Header、token 或任意 provider payload。 + +### 4.6 统计、请求与审计 + +| Method | Path suffix | 用途 | +| --- | --- | --- | +| GET | `/statistics/overview` | 金额/请求/健康总览 | +| GET | `/statistics/activity` | 时间序列 | +| GET | `/statistics/analysis` | 维度分析 | +| GET | `/requests` | Request 列表或 `?request_id=` 详情 | +| GET | `/executions` | Execution/重试明细 | +| GET | `/audit-events` | 管理动作与安全事件 | + +图表接口返回服务端聚合。请求详情默认不含 Prompt/Response;CPA request log 访问走单独显式授权、短期 token、审计和脱敏流程。 + +### 4.7 Maintenance + +| Method | Path suffix | 用途 | +| --- | --- | --- | +| GET | `/maintenance/status` | checkpoint、备份、完整性和 runner 状态 | +| POST | `/maintenance/backup` | 创建 SQLite online backup | +| GET | `/maintenance/backups` | 脱敏备份清单,不直接返回任意文件路径 | +| POST | `/maintenance/integrity-check` | 启动有界检查 | +| POST | `/maintenance/rebuild` | 从指定 event_seq 重建投影 | + +在线 API 不提供 restore、删除数据库或任意路径下载。恢复属于 [operations.md](operations.md) 的离线、排空流程。 + +## 5. 用户只读 API + +固定 ResourceRoute: + +| Method | Path | 用途 | +| --- | --- | --- | +| GET | `/v0/resource/plugins/cpa-ext/v1/me` | 当前 Credential、共享金额账户和周期摘要 | +| GET | `/v0/resource/plugins/cpa-ext/v1/me/requests` | 当前 Credential 的逐请求金额记录 | +| GET | `/v0/resource/plugins/cpa-ext/v1/me/activity` | 当前 Credential/账户金额趋势 | + +鉴权: + +```http +Authorization: Bearer +``` + +规则: + +- Key 不得放 query、fragment、cookie、URL 或 localStorage; +- 插件自行 HMAC lookup,并按 Credential scope 构造查询; +- 共享 BillingAccount 的余额可以显示,但默认请求列表只显示当前 Credential 产生的记录; +- 请求参数中的 account/credential ID 不能扩大 scope; +- 响应 `Cache-Control: no-store`、`Pragma: no-cache`、`Vary: Authorization`; +- 错误不区分不存在、撤销、过期等可枚举细节; +- 用户接口只返回 Money、模型展示名、时间和安全状态,不返回 Token、上游账户、价格规则或管理员诊断。 + +当前 resource 只有 GET,且没有 HttpOnly session/CSRF 模型。需要用户写操作、稳定登录或团队账号时必须增加 sidecar/public API 或扩展 CPA authenticated user plugin routes。 + +## 6. UI resources + +至少注册: + +```text +GET /v0/resource/plugins/cpa-ext/ui +GET /v0/resource/plugins/cpa-ext/assets/<每个确定文件名> +GET /v0/resource/plugins/cpa-ext/ready +``` + +宿主不支持 wildcard,因此每个构建产物在 `management.register` 中逐个声明。前端使用相对 base、HashRouter、不注册 service worker。 + +`/ready` 只返回: + +```json +{"ready":true,"version":"0.1.0"} +``` + +它不包含数据库路径、price 内容、账户数、错误详情或 secret。外部 gateway 可以把 404/非 200 视为插件缺失,但仍需执行部署级旁路探针。 + +## 7. 查询、分页和导出 + +- 默认 `limit=50`,最大 `200`; +- 使用 `(occurred_at, stable_id)` 编码的 opaque cursor,不使用大 offset; +- cursor 绑定 endpoint、scope、排序和过滤条件,修改条件后 cursor 无效; +- 默认时间范围 24 小时,普通交互最大 90 天; +- 过滤字段使用 allowlist,未知字段返回 400; +- 排序字段固定,禁止任意 SQL column; +- 大导出使用受控异步任务/sidecar,不能在动态库 handler 中一次加载全表; +- CSV 需要防 `=`, `+`, `-`, `@` 公式注入,并记录导出审计。 + +## 8. 输入与资源限制 + +插件 handler 内执行: + +- 方法和精确路径二次校验; +- Content-Type 校验; +- strict JSON + 单文档 EOF; +- 字段长度、集合数量、时间范围和分页上限; +- 响应行数/字节预算; +- context cancellation; +- 不持锁执行 SQL、host callback 或网络。 + +但 CPA 当前在调用 handler 前执行 `io.ReadAll`,所以这些校验无法保护宿主免受超大 body。生产反向代理/CPA HTTP 层必须先限制: + +- Management body 建议不超过 1 MiB; +- Resource GET 不接收 body; +- Header/query 总大小由入口代理限制; +- 请求读取超时和并发连接数在入口层限制。 + +## 9. 当前 CPA Management JSON 转义限制 + +`internal/pluginhost/management.go` 会对看起来像 JSON 的响应递归执行 `html.EscapeString`。因此 `A & B ` 在 wire 上会变成 HTML entity;这不是标准 JSON API 应有的语义。 + +过渡规则: + +- cpa-ext Management JSON 响应头标记 `X-CPA-Ext-String-Encoding: html-entity-v1`; +- 内置 UI 对**响应字符串**最多兼容 decode 一次,再通过 React text node 渲染; +- 禁止 `dangerouslySetInnerHTML`; +- IDs、error codes、cursor 只使用不受影响的安全字符; +- 写 DTO 不能盲目回传未 decode 的读 DTO; +- 测试覆盖 `A & B ` 往返和已含 entity 文本; +- 在 CPA 停止改写 JSON 或 API 移到 sidecar 前,不将 Management API 宣称为无损的通用第三方 API。 + +Resource JSON 不经过该 escape 路径,仍然必须使用普通 JSON encoder 和文本渲染防 XSS。 + +## 10. 安全头与浏览器策略 + +HTML: + +- `Content-Security-Policy`,默认 `default-src 'self'`,按实际构建最小放开; +- `X-Content-Type-Options: nosniff`; +- `Referrer-Policy: no-referrer`; +- `Cache-Control: no-store`(HTML shell); +- 不使用 inline script,或固定构建 hash; +- 不信任 iframe parent 消息,除非校验明确 origin。 + +敏感 JSON: + +- `Cache-Control: no-store`; +- 默认不启用 CORS; +- 不在错误响应反射 Origin/Header/Body; +- 不把 Management Key 或 downstream Key 写入前端持久存储。 + +完整要求见 [security.md](security.md)。 + +## 11. 审计 + +以下写操作必须形成审计事件: + +- provision、签发、轮换、撤销; +- 账户/套餐/路由/池修改; +- 金额 adjustment/refund; +- 价格发布/回滚; +- 上游 Auth reconciliation; +- quota reset credits 等有副作用 provider 操作; +- backup、rebuild、导出; +- 安全设置和热配置拒绝。 + +CPA 当前只有共享 Management Key,Actor 至少记录 management principal fingerprint、来源 IP 的受信代理结果、request ID、reason 和变更前后 revision。调用方自报 `actor` 只能作为 label,除非受信 gateway 已验证并覆盖该 header。 + +## 12. 如何参考现有项目 + +### 12.1 CLIProxyAPI + +- `sdk/pluginapi/types.go`:ManagementRequest/Response、Route/Resource; +- `internal/pluginhost/management.go`:精确路径、未鉴权 resource、body ReadAll、JSON escape; +- `internal/api/server_management.go`:Management middleware、Home 限制和 route mounting; +- `examples/plugin/management-api/`:ABI/RPC 形状。 + +### 12.2 `cpa-plugin-key-billing` + +- `internal/plugin/management.go`:精确 route 表、query/body 传 ID、严格 DTO; +- `internal/plugin/admin.go`:价格、计划、Key 操作用例; +- `internal/plugin/ui.html`:Management 页面调用方式。 + +吸收其固定路径和 handler 分派;不要继承错误响应泄露内部文本、浏览器持久化 Management Key 或 UI 直接承担业务原子性的做法。 + +### 12.3 `cpa-usage-keeper` + +- `internal/api/router.go`:管理员/Key viewer 分离、no-store 与静态资源; +- `internal/api/usage_*`:分页、范围和统计投影; +- `internal/api/pricing*.go`、`quota.go`:领域 API; +- `internal/api/request_limits.go`、`errors.go`:入口限制和脱敏错误; +- API security tests:session、rate limit、secret redaction、CSP、request log token。 + +Keeper 是独立 HTTP 服务,Gin wildcard、middleware session 和后台任务接口不能假设在 CPA plugin route 中同样可用。 + +## 13. 验收标准 + +- 所有注册 route 都是宿主允许的精确路径且无冲突; +- Management 数据必须通过 CPA Management 鉴权; +- resource 静态页不含敏感数据,用户 JSON 独立校验 downstream Key; +- 用户不能通过 ID/query 越权读取其他 Credential/账户; +- 所有金额 DTO 使用 currency + micros + display; +- 用户 DTO 不出现 Token、价格规则和上游账户; +- 写接口 strict decode、revision check、审计和必要幂等全部生效; +- secret 只在首次签发/轮换响应显示,重放不创建第二把 Key; +- 超大 body 在反向代理层被拒绝,不能依赖插件收到后再拒绝; +- Management JSON entity 转义兼容测试通过,且限制被明确标记; +- 列表 cursor 在并发插入下无重复/漏页; +- panic/DB 失败返回脱敏 5xx,不泄露 SQL、路径或 secret; +- Home 模式返回明确不支持,UI 不显示空数据假象; +- API、Core、UI 的字段和错误码保持同一份实现定义。 diff --git a/docs/modules/billing.md b/docs/modules/billing.md new file mode 100644 index 0000000..a67bebc --- /dev/null +++ b/docs/modules/billing.md @@ -0,0 +1,618 @@ +# 计费模块 + +## 1. 模块目标 + +计费模块服务于一个核心场景:管理员把 CPA 的模型能力分享给其他人,为每位用户分发独立 API Key,并以**金额额度**控制其使用。 + +完整闭环: + +```text +管理员创建或同步 API Key + → 设置金额套餐与周期 + → 请求进入时检查剩余金额 + → CPA 执行模型请求 + → 按真实 Usage、价格和倍率计算金额 + → 原子记账并更新余额 + → 额度耗尽后拒绝新请求 +``` + +本模块包含: + +- API Key 身份识别和脱敏标识; +- 金额套餐、周期、额度和余额; +- 请求准入、并发状态和终态清理; +- Usage 归一化、价格解析和金额计算; +- Fast/priority、长上下文等计价规则; +- 不可重复结算的金额账本; +- 管理端的套餐、绑定、充值、重置、停用和账单查询; +- 向统计模块输出标准计费事件。 + +本模块不负责趋势图、排行榜、热力图、RPM/TPM、延迟分析等统计产品能力。 + +## 2. 不可违背的金额原则 + +### 2.1 唯一计费单位 + +系统对共享用户提供的计费单位有且只有**金额**。 + +用户获得、看到和理解的内容只能是: + +- 套餐金额; +- 已消费金额; +- 剩余金额; +- 本次请求金额; +- 周期内金额明细。 + +用户侧不得出现以下额度概念: + +- Credits、点数或积分; +- Token 配额或 Token 余额; +- 按请求次数折算的额度; +- 隐藏的第二套扣费单位。 + +Token、缓存、模型单价、倍率和价格版本是后台计算与审计信息。管理员可以查看,统计模块也可以分析,但它们不能成为用户的余额单位。 + +### 2.2 结算币种 + +一个部署实例只使用一种结算币种。第一版固定使用 USD;未来如支持其他币种,必须通过明确的汇率版本换算为该实例的唯一结算币种,不能让同一账户同时持有多种余额。 + +所有金额必须使用定点表示: + +```go +type Money struct { + Currency string // v1: USD + Micros int64 // 1 USD = 1,000,000 micros +} +``` + +持久化、比较、扣减和额度判断全部使用整数 `Micros`。`float64` 只允许出现在外部价格解析边界,进入领域层前必须按统一规则转换并检查溢出,不能直接用于余额和账本。 + +### 2.3 用户视图与管理员视图 + +用户视图只返回金额和必要状态: + +```json +{ + "limit": "10.00", + "spent": "3.27", + "remaining": "6.73", + "currency": "USD", + "cycle_ends_at": "2026-09-01T00:00:00Z", + "enabled": true +} +``` + +管理员视图可以额外包含计价依据:模型、输入/缓存/输出 Token、基础价格、Fast 倍率、长上下文价格、最终金额和价格版本。 + +## 3. 模块边界 + +### 3.1 计费模块拥有 + +- 套餐和周期定义; +- API Key 与套餐绑定; +- 周期金额额度、已用金额和余额; +- 请求准入结果; +- 请求与最终 Usage 的关联; +- 每笔金额账本; +- 消费 [pricing.md](pricing.md) 返回的不可变 `ResolvedPricePolicy`,并把实际使用的价格政策快照固化到每笔账单; +- 充值、退款、人工调整和重置记录; +- 幂等结算键。 + +价格来源、候选、模型 alias、长上下文/tier 政策、版本发布和回滚属于 [价格目录与计价政策模块](pricing.md)。Billing 不能在结算时自行拉取价格或修改 active version。 + +### 3.2 统计模块拥有 + +- 原始事件的长期保存策略; +- 小时、天和实时聚合; +- 趋势、构成、排行、热力图; +- RPM、TPM、成功率、TTFT、延迟; +- 按 API Key、模型、Provider、Auth 等维度查询; +- 导出和只读分析页面。 + +统计模块只能消费计费模块已经确定的 `charged_amount`,不能在查询时重新计算历史账单。价格修改不得悄悄改变已经结算的金额。 + +### 3.3 CLIProxyAPI 能力 + +预期使用以下最小能力组合,最终以目标 CLIProxyAPI 源码为准: + +| 能力 | 用途 | +| ----------------------------------------------------- | ------------------------------------------------------------------------- | +| `frontend_auth_provider` | 校验插件签发的下游 Key,并向 CPA 返回不含明文 Key 的稳定身份 | +| `request_interceptor` | 识别稳定下游身份、检查金额额度并拒绝请求 | +| `request_lifecycle_plugin` | 接收成功、失败、拒绝、取消等终态,释放并发并触发幂等结算 | +| `response_before_translator`、response interceptors | 取得带 RequestID 的上游 Usage,并关联非流式或流式响应 | +| `usage_plugin` | 补充 CPA 标准 Usage、上游身份、性能和失败信息;当前不能单独承担按请求结算 | +| `scheduler` | 按 Key 的账户绑定选择上游 AuthID | +| `management_api` | 暴露计费管理 API 和管理页面 | + +主路径使用 CPA 的 `frontend_auth_provider`,由插件自己签发、校验、禁用和轮换下游 Key。插件只保存高熵 Key 的安全校验值,认证成功后把稳定 `credential_id` 作为 Principal 返回给 CPA;CPA 再基于该 Principal 产生不可逆 `caller_scope`。这样 Key 生命周期、金额账户和路由绑定可以在同一业务事务内维护,不依赖修改 CPA 配置文件。 + +生产模式必须声明 `frontend_auth_provider_exclusive: true`,并在真实 CPA 中验证本插件是唯一生效的认证路径,避免 CPA 原生 Key 绕过插件准入。CPA 原生 `api-keys` 只保留为显式兼容/迁移模式;该模式下任何不能映射到本地 Credential 的调用都必须按配置拒绝或标为受控例外,不能默认无限使用。 + +## 4. 核心领域模型 + +### 4.1 API Key 标识 + +插件不持久化明文 Key。签发时从明文生成或提取: + +- public key ID 与 HMAC 校验摘要:用于认证 lookup; +- 脱敏 `key_preview`:用于管理员辨认; +- 可选用户标签。 + +一个逻辑 Credential 可以包含多个 `CredentialSecretVersion`。secret 轮换新增 version、保留同一 `credential_id` 和 BillingAccount,overlap 后撤销旧 version;只有独立新 Key 才创建新 Credential。认证成功后向 CPA 返回稳定 `credential_id` Principal;CPA 的 `caller_scope` 是该 Principal 的不可逆请求期标识。业务表使用 `credential_id/billing_account_id` 外键,不把 caller scope 当作唯一长期主键。 + +额度实际挂在 `BillingAccount`,每个 Credential 必须绑定一个 BillingAccount。第一版创建用户 Key 时默认一并创建独立 BillingAccount,因此产品表现仍是“每个 Key 可分配金额额度”;轮换 Key 时绑定同一 BillingAccount 即可保留余额和历史。未来多个 Key 需要共享额度时只调整绑定,不改变账本模型。 + +### 4.2 套餐 + +```text +BillingPlan +├─ id +├─ name +├─ amount_micros +├─ currency +├─ period_kind fixed / never +├─ period_seconds daily/weekly 等 UI 预设都换算为固定秒数 +├─ enabled +└─ timestamps +``` + +周期在该 BillingAccount 第一次成功准入时开始,沿用 `cpa-plugin-key-billing` 的简单行为。日历月、统一账单日和时区对齐留给后续明确需求,第一版不暗中引入复杂规则。 + +### 4.3 周期账户 + +```text +BillingAccountCycle +├─ billing_account_id +├─ plan_id +├─ cycle_id +├─ starts_at +├─ ends_at +├─ limit_micros +├─ spent_micros # 可重建消费投影 +├─ balance_micros # 可重建余额投影 +└─ status +``` + +请求准入时必须记录其 `plan_id` 和 `cycle_id`。延迟结束的请求只能计入准入时所属周期,不能误扣到新周期或新套餐。 + +### 4.4 账本 + +账本是金额事实来源,不以可变聚合字段作为唯一依据: + +```text +BillingLedgerEntry +├─ id +├─ idempotency_key +├─ billing_account_id +├─ credential_id # 请求归因;充值可为空 +├─ request_id +├─ execution_id +├─ billing_record_id +├─ cycle_id +├─ kind charge / late_settlement / credit / refund / adjustment +├─ spend_delta_micros +├─ balance_delta_micros +├─ currency +├─ occurred_at +├─ booked_at +├─ pricing_version +├─ pricing_snapshot +└─ usage_snapshot +``` + +每次结算必须按 `(request_id, execution_id, settlement_kind, usage_revision)` 或等价稳定规则生成 `idempotency_key`。同一 Usage revision 的重复、迟到或重放不能产生第二次扣费;新的可靠 Usage revision 只能追加差额账本。 + +账本用两个带符号字段避免把“用户消费”和“账户余额变化”混成一个数字: + +| kind | `spend_delta_micros` | `balance_delta_micros` | +| -------------------------------- | ---------------------: | -----------------------: | +| `charge` / `late_settlement` | 正数 | 负数 | +| 与 Usage 关联的`refund` | 负数 | 正数 | +| 充值`credit` | 0 | 正数 | +| 非 Usage 人工增减 | 0 | 按方向正/负 | +| 纠正历史费用的 adjustment | 按消费方向正/负 | 与其相反 | + +用户消费趋势汇总 `spend_delta_micros`;余额从 `balance_delta_micros` 推导。充值和普通账户调整没有 model/Auth/endpoint 维度,不能塞进请求消费趋势。 + +## 5. 计价管线 + +计价管线必须是单向、可审计的: + +```text +原始 Usage + → Token 语义归一化 + → 确定计费模型 + → 解析版本化基础价格 + → 应用长上下文价格 + → 确定 effective service tier + → 应用 Fast/priority 倍率 + → 转换为整数金额 + → 写入不可变账本 + → 发布 BillingEvent +``` + +### 5.1 Token 归一化 + +计价使用四个互不重叠的区段: + +```text +普通输入 + 缓存读取 + 缓存写入 + 输出 +``` + +必须识别供应商字段语义: + +- 缓存 Token 是否已包含在 `input_tokens` 中; +- Reasoning Token 是否已包含在 `output_tokens` 中; +- `total_tokens` 与各区段是否一致; +- 重试和流式响应是否上报了重复 Usage。 + +信息不完整或矛盾时必须标记 `accounting_quality`,并采用以下固定策略: + +- `complete` / `normalized`:正常结算; +- `partial`:只结算语义明确且未与其他字段重叠的已确认部分,状态保持 `partial`,后续 revision 只追加差额; +- `missing` / `unclassified` / `inconsistent`:不猜测金额,进入 `awaiting_usage` 或 `review_required`,当前展示金额为 0 但不标记为免费; +- 准入时找不到可信价格:在触达上游前返回 `503 billing_price_unavailable`; +- 实际模型/tier 与准入快照不同而在执行后才发现无价格:保存 `unpriced` 事实、告警并阻止该 credential/model 的后续新请求,管理员发布可追溯价格版本后通过追加结算处理。 + +不得用“按最高价保守扣费”或“把未知都当零”代替真实事实。 + +### 5.2 基础金额公式 + +```text +基础金额 = + 普通输入 Token × 输入单价 + + 缓存读取 Token × 缓存读取单价 + + 缓存写入 Token × 缓存写入单价 + + 输出 Token × 输出单价 +``` + +Reasoning Token 用于后台明细,但若上游语义表明其已经包含在输出中,不得再次相加收费。 + +### 5.3 Fast / priority 2.5× + +Fast/priority 是金额倍率,不是另一种余额单位: + +```text +Fast 最终金额 = 基础金额 × 2.5 +``` + +不能同时对请求 `service_tier=priority` 和响应 `response_service_tier=priority` 各乘一次。应先得到唯一的有效速度层级: + +```text +effective_service_tier = + 响应明确确认的层级 + 否则请求指定的层级 + 否则 standard +``` + +然后只应用一次倍率。倍率必须保存在账本价格快照中。 + +Fast 规则必须是版本化价格政策的一部分,不能依赖管理员每次手工补规则。实现时需要用当前 OpenAI 官方资料再次核对支持模型和倍率。 + +### 5.4 长上下文 + +价格目录包含长上下文阈值时,使用归一化后的总输入 Token 判断是否进入阶梯价;阈值、命中的价格和判断输入必须写入价格快照。 + +Fast 倍率作用于长上下文基础金额之后: + +```text +最终金额 = 长上下文规则计算出的基础金额 × Fast 倍率 +``` + +### 5.5 价格来源与优先级 + +价格不能无条件信任第三方目录。当前已观察到 models.dev 的部分 GPT-5.6 Terra/Luna `openai` 条目与 OpenAI 官方价格不一致。 + +推荐优先级: + +1. 管理员明确覆盖并确认的价格; +2. 项目维护的、带生效日期和来源链接的官方价格表; +3. models.dev 自动同步的候选价格; +4. 无法定价,进入显式待处理状态。 + +自动同步必须是“预览 → 确认 → 发布新价格版本”,不能在后台静默修改生产计费。 + +每笔账单至少保存: + +- 实际计费模型和匹配方式; +- 四段 Token 数; +- 四段实际单价; +- 长上下文状态; +- effective service tier; +- 最终倍率; +- 价格来源和版本; +- 舍入前结果与最终 `amount_micros`。 + +### 5.6 Usage revision 与差额算法 + +`usage_revision` 由插件生成,不是 CPA/provider 传入: + +1. 每个 Execution 保存 canonical cumulative vector(四段 Token)、source rank、response ID 与 canonical hash; +2. 同一 vector/hash 的重复 callback 不创建 revision; +3. 只有 SQLite 事务内 CAS 确认 canonical vector 改变后才递增 revision; +4. CPA usage、翻译前响应和翻译后响应是择优/交叉校验来源,绝不能三份相加; +5. 新应收额使用该 Execution 的原始 admission/price policy 重算,差额为“新 canonical total 应收 - 该 Execution 已入账 usage spend”; +6. 更小或矛盾 vector 标为 `inconsistent`,自动差额为 0;退款只能由显式 refund/adjustment 账本产生。 + +Request 级汇总不能参与单个 Execution 的差额,避免重试费用互相抵消。 + +## 6. 请求准入与结算 + +### 6.1 准入 + +请求进入时: + +1. 使用 frontend auth 已确认的 `credential_id`,并校验 CPA `caller_scope` 映射; +2. 查询有效套餐和周期; +3. 结算已过期周期; +4. 检查账户启用状态和剩余金额; +5. 保存 pending request、周期和价格政策版本; +6. 放行或返回结构化错误。 + +未绑定套餐的策略必须可配置。开发/迁移时可以显式使用“记录金额但不限制”的 observe-only 模式;生产默认必须是“未绑定即拒绝”,避免新建或漏同步的 Key 意外获得无限额度。 + +额度耗尽时返回 HTTP `429` 和稳定错误码 `billing_quota_exhausted`。响应只描述金额余额,不泄露内部 Token、价格表或凭证信息。 + +### 6.2 并发透支 + +请求开始时无法知道最终金额。第一版沿用 `cpa-plugin-key-billing` 的务实策略: + +- 余额大于零即可准入; +- 请求结束后按真实金额结算; +- 单次或并发请求可以造成有限负余额; +- 余额不再为正时拒绝新的请求。 + +这项行为必须在管理界面明确说明为“软金额额度/请求后结算”,不能承诺绝不超额。MVP 为每个 BillingAccount 设置默认并发上限 1,管理员可显式调高;这只能把最坏超额限制在少量在途请求,仍不是硬额度。严格预授权、输入成本估算和按允许最大输出预占属于后续增强,不能伪装成已经做到严格不透支。 + +### 6.3 终态 + +成功、失败、取消、客户端断开、流式中止和宿主关闭都必须结束运行中的并发占用。`request.complete` 是请求终态信号,但**终态类型本身不决定是否收费**;是否扣费只由上游实际产生且能够可靠确认的 Usage 决定。 + +统一规则: + +| 场景 | 金额处理 | 记录处理 | +| ----------------------------------------- | ------------------------------- | ------------------------------------------ | +| 本地准入拒绝,未到达上游 | 不扣费 | `outcome=rejected`,无 Usage、无消费账本 | +| 请求在上游执行前取消 | 不扣费 | `outcome=canceled`,金额为 0 | +| 成功完成且有可靠 Usage | 按实际 Usage 扣费 | 正常结算 | +| 执行失败但上游报告了 Usage | 按实际 Usage 扣费 | `outcome=failed` 与金额同时保留 | +| 用户取消/断开,但取消前已经产生可靠 Usage | 按实际 Usage 扣费 | `outcome=canceled` 与金额同时保留 | +| 流式输出一部分后取消 | 按上游累计报告的实际 Usage 扣费 | 不能只按已发送到下游的 chunk 猜测 | +| 已触达上游但没有取得可靠 Usage | 不猜测、不虚构金额,暂记 0 | 标记`unmeasured` 并进入迟到 Usage 观察 | + +取消自动免单会形成明显漏洞:调用方可以在最后一个流式事件前主动断开,从而反复使用已经由上游执行的计算。因此用户侧可以看到“已取消”和本次实际金额,但不能把取消理解为退款。 + +#### 6.3.1 取消时的处理顺序 + +1. 幂等接收 `request.complete`,保存 `outcome=canceled`; +2. 立即释放该请求的并发占用或预授权资源; +3. 封存此时已经取得的 Execution 与 Usage 快照; +4. 有可靠 Usage 时按同一价格快照正常结算; +5. 没有 Usage 时写入 `settlement_status=awaiting_usage`、`accounting_quality=unmeasured`,用户当前金额显示为 `$0`; +6. 保留有界的持久化关联窗口,等待可能迟到的 response/usage 事实; +7. 到期仍无 Usage 时转为 `unmeasured_final`,保留诊断和风险计数,绝不能伪造 Token。 + +第一版 `late_usage_ttl` 默认 24 小时,与持久化 pending/recovery 机制配合;到期只清理内存关联并把状态转为 `unmeasured_final`,不是“24 小时后永远免费”的边界。只要以后到达的 Usage 仍能用 RequestID/ExecutionID 可靠关联,仍可通过追加账本进行补记。 + +#### 6.3.2 迟到 Usage 与补记 + +回调不能假定同步、严格有序或只调用一次。终态后到达的新 Usage 不得直接修改已经提交的 BillingRecord 或 LedgerEntry,而应: + +1. 以 `request_id + execution_id + usage_revision` 去重; +2. 对新的累计 Usage 生成新的计费版本; +3. 计算“新确认应收金额 - 已入账金额”的差额; +4. 差额为正时追加 `late_settlement` 账本; +5. 出现更小或矛盾的 Usage 时不自动退款,标记 `inconsistent` 等待管理员复核; +6. 原请求的展示投影汇总原账与补记账,账本历史保持不可变。 + +部分可确认 Usage 可以先按确认部分结算并标记 `partial`,迟到的新增部分仍走差额补记。重复回调、相同 revision 或相同幂等键必须产生零次新扣费。 + +#### 6.3.3 防止取消逃费 + +系统按 Key/账户统计以下诊断: + +- canceled 请求数量与占比; +- `canceled + unmeasured` 数量与连续次数; +- 迟到补记金额; +- 取消发生时是否已经选择上游账户、收到首字或输出 chunk。 + +偶发无 Usage 不惩罚用户;高频、持续的 `canceled + unmeasured` 可以触发并发收紧、暂时限流或管理员告警。风控动作属于 Core 的准入策略,计费模块只提供事实和计数,不能凭猜测生成金额。 + +## 7. 提供给统计模块的数据 + +计费模块不承担详细分析,只发布足以支撑未来统计的标准事件。建议字段: + +```go +type BillingEvent struct { + EventID string + IdempotencyKey string + RequestID string + ExecutionID string + CredentialID string + KeyPreview string + BillingAccountID string + PlanID string + CycleID string + Kind string // charge/late_settlement/refund/credit/adjustment + + Provider string + ExecutorType string + Model string + ModelAlias string + AuthID string + AuthIndex string + Source string + Endpoint string + ReasoningEffort string + RequestedServiceTier string + ResponseServiceTier string + EffectiveServiceTier string + + RequestedAt time.Time + CompletedAt time.Time + OccurredAt time.Time + BookedAt time.Time + Latency time.Duration + TTFT time.Duration + Failed bool + FailureStatusCode int + Outcome string // succeeded/failed/rejected/canceled + + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CacheReadTokens int64 + CacheCreationTokens int64 + TotalTokens int64 + AccountingQuality string + + Currency string + BaseAmountMicros int64 + AppliedMultiplier string // 十进制定点文本,例如 "2.5" + BillingAmountMicros int64 // 当前 BillingRecord 的完整应收快照 + SpendDeltaMicros int64 // 本事件对消费趋势的带符号变化 + BalanceDeltaMicros int64 // 本事件对余额的带符号变化 + PricingVersion string + PricingSource string + LongContext bool + SettlementStatus string // settled/awaiting_usage/unmeasured_final/corrected + UsageRevision int64 + CorrectionOfEventID string +} +``` + +安全要求:统计事件不得包含明文 API Key、Bearer Token、上游凭证、原始请求体或失败响应中的敏感内容。 + +持久化层以 `EventID/IdempotencyKey` 去重,并在同一领域事务中把事件转换为带单调 `event_seq` 的 `ProjectionEvent`。统计模块聚合 `SpendDeltaMicros`;余额投影聚合 `BalanceDeltaMicros`。它可以展示 Token 和倍率,但不能根据 Token 重新计算或覆盖已经入账的金额。 + +## 8. 如何参考现有项目 + +### 8.1 `cpa-plugin-key-billing` + +定位:实时金额额度控制和插件生命周期的首要参考。 + +优先阅读: + +| 主题 | 源码位置 | +| ------------------- | ----------------------------------------------------------------- | +| 插件装配与 RPC 分发 | `cpa-plugin-key-billing/internal/plugin/app.go` | +| 请求准入与 Key 识别 | `internal/plugin/intercept.go`、`internal/billing/enforce.go` | +| Request/Usage 关联 | `internal/plugin/usage_tracker.go` | +| 上游 Usage 归一化 | `internal/plugin/upstream_usage.go` | +| 四段计价与长上下文 | `internal/billing/pricing.go` | +| 套餐、周期与绑定 | `internal/billing/plan.go`、`keys.go`、`account.go` | +| 并发安全与持久化 | `internal/billing/store.go`、`pending.go` | +| 管理 API 与 UI | `internal/plugin/management.go`、`admin.go`、`ui.html` | +| C ABI 入口 | `cmd/cpa-key-billing/main.go` | + +适合直接吸收的设计: + +- Key 摘要和脱敏,不保存明文; +- 准入周期快照,避免跨周期误扣; +- 请求、Usage 和终态关联; +- 先可靠结算能用 RequestID/response ID 关联的最终 Usage;其覆盖失败尝试的限制不能作为长期完整计费模型; +- 429 拒绝流程; +- 原子热重配、幂等关闭和无后台 goroutine 的插件约束; +- 长上下文阶梯价与不确定 Usage 的保守处理。 + +不能直接照搬: + +- `float64` 作为余额和累计金额,必须改为整数定点金额; +- JSON 状态文件作为长期账本,目标实现应使用 SQLite 和不可变账本; +- 缺少 Fast/priority 计价; +- models.dev 自动价格不能直接视为权威; +- 当前简单累计统计不能成为统计模块基础; +- 固定 `SchemaVersion=2` 且忽略宿主 lifecycle `schema_version` 的实现; +- 无条件同时声明 response-before 与 stream chunk hook:这是为弥补 Usage 缺少 RequestID/WebSocket 同协议透传的过渡关联方案,会把 JSON/base64/C ABI 成本放到每个流式帧; +- 仅把 schema 改成 3 就宣称性能问题解决:schema 3 不会消除 response-before 请求体和 `HistoryChunks` 的重复传输。 + +### 8.2 `cpa-usage-keeper` + +定位:持久化、Token 语义修正、价格快照、条件倍率以及未来统计模块的首要参考。 + +计费模块优先阅读: + +| 主题 | 源码位置 | +| ---------------- | -------------------------------------------------------- | +| 四段费用计算 | `cpa-usage-keeper/internal/helper/usage_cost.go` | +| Token 语义修正 | `internal/service/tokenprocessor/` | +| 价格快照与校验 | `internal/pricing/snapshot.go`、`catalog.go` | +| 条件倍率 | `internal/pricing/fields.go`、`resolver.go` | +| 价格持久化与规则 | `internal/repository/pricing.go`、`pricing_rules.go` | +| models.dev 同步 | `internal/service/pricing_metadata_sync.go` | +| Usage 事件实体 | `internal/entities/usage_event.go` | + +未来统计模块重点参考: + +- `internal/repository/usage_*`:SQLite 事件、实时/小时/天聚合; +- `internal/service/usage.go`:Overview、Activity 和 Analysis 服务; +- `internal/api/usage_*`:查询接口; +- `web/src/components/usage/`:请求、Token、费用、健康和延迟展示; +- `web/src/features/ranking/`:排名能力,是否纳入产品需另行决定。 + +适合吸收的设计: + +- SQLite 和迁移体系; +- Provider 感知的 Token 归一化; +- 原子发布的只读价格快照; +- `service_tier` 等条件倍率; +- 价格修改后各查询路径使用一致快照; +- 完整的统计维度和前端组件体系。 + +不能直接照搬: + +- 价格规则同时命中多个字段时全部相乘,Fast 请求/响应 tier 必须先合并成唯一有效 tier; +- 当前自动价格同步忽略长上下文 tiers; +- models.dev 候选价格必须经过官方表或管理员确认; +- Keeper 是独立服务,后台 worker、定时器和运行模型不能原样搬入 c-shared 插件; +- 统计查询时动态重算费用的行为不能改变已经落账的历史金额。 + +### 8.3 复用规则 + +两个参考项目均使用 MIT License。复制或修改核心代码时必须: + +- 保留相应版权与许可证声明; +- 在仓库 NOTICE/THIRD_PARTY 文档中记录来源文件和上游提交; +- 优先按领域模块移植并补测试,不做无法追踪来源的大段拼贴; +- CLIProxyAPI ABI、RPC DTO 和能力名称始终以目标 `CLIProxyAPI` 源码为准,两个下游项目不能覆盖宿主契约。 + +当前检查基线: + +- `cpa-plugin-key-billing`: `25b534ae386f830f537cca9215cff5586e630b3a` +- `cpa-usage-keeper`: `d62cad3f345ae574089a14a4ac75cca023c7ead6` + +## 9. 验收标准 + +计费模块完成必须至少证明: + +- 用户所有额度、余额、扣费和账单均以金额显示; +- 余额与账本使用整数定点金额,无浮点累计误差; +- 普通输入、缓存读、缓存写和输出不会重复计价; +- Reasoning 不会因字段语义误判而重复计价; +- GPT-5.6 Sol/Terra/Luna 使用经过确认、带版本的价格; +- Fast/priority 只应用一次 2.5×; +- 长上下文与 Fast 可以正确组合; +- 本地拒绝不收费,失败或取消只按可确认的实际上游 Usage 收费; +- 取消但没有可靠 Usage 时不猜费,并能记录 `unmeasured`; +- 迟到 Usage 通过幂等追加账本补记,重复 Usage 不会重复扣费; +- 重试产生的每个**可可靠关联**上游 Execution 用量都能独立去重和结算;当前 CPA 无 RequestID 的 attempt usage 必须标为限制/`unmeasured`,不能猜配; +- 延迟完成不会扣到错误周期; +- 额度耗尽时稳定拒绝新请求; +- 明确为软额度,默认 BillingAccount 并发 1,最后一个在途请求可能形成有限负余额; +- Credential secret 轮换不创建新 BillingAccount/cycle,也不刷新额度; +- 相同 canonical Usage 重放不增加 revision,多来源不会重复相加; +- 重启后账本、周期和余额一致; +- 统计事件足以支持后续统计模块,且不含敏感信息; +- 实际动态库通过单元测试、竞态测试和 CLIProxyAPI 端到端加载测试。 + +## 10. MVP 产品默认值 + +为避免实现阶段再次产生不同口径,第一版固定: + +1. 生产未绑定账户/套餐/价格一律拒绝;observe-only 只允许显式开发/迁移模式; +2. 周期从第一次成功准入开始,按 plan 的固定时长推进;不实现自然月/统一账单日; +3. 允许管理员充值或扣减,但必须追加 credit/adjustment 账本和审计原因; +4. 用户可以查看自己的逐请求时间、模型、状态和最终金额,也可以看周期汇总; +5. 负余额按真实负金额展示,并说明软额度语义,不伪装成 `0.00`; +6. 结算币种只支持 USD。 diff --git a/docs/modules/collection.md b/docs/modules/collection.md new file mode 100644 index 0000000..1672629 --- /dev/null +++ b/docs/modules/collection.md @@ -0,0 +1,189 @@ +# CPA 数据采集模块 + +## 1. 定位 + +CPA 数据采集模块是 CLIProxyAPI 与 [数据模块](data.md) 之间的适配层。它负责从 CPA 的多个入口取得原始碎片,按请求关联、规范化并产出标准事实;它不定义价格、不扣款、不聚合图表,也不决定数据保存多久。 + +```text +CPA callbacks / response hooks / Management API / provider API + ↓ + CPA 数据采集模块 + ↓ + RequestRecord / ExecutionRecord / UsageRecord / IdentityRecord / QuotaSnapshot +``` + +目标不是把每个 CPA payload 原样抄进数据库,而是确保两个参考项目实际使用过的数据都存在可靠的取得路径。 + +## 2. CPA 中需要声明的插件能力 + +初期需要以下能力: + +| CPA capability | 方法 | 用途 | +| --- | --- | --- | +| `request_interceptor` | `request.intercept_before`、`request.intercept_after` | 建立请求、识别下游 Key、记录路由与最终上游格式/模型、执行准入 | +| `request_lifecycle_plugin` | `request.complete` | 接收 succeeded/failed/rejected/canceled 终态并完成或清理请求 | +| `response_before_translator` | 对应翻译前响应方法 | 读取最接近 provider 原始语义的非流式/流式用量 | +| `response_interceptor` | `response.intercept_after` | 用 RequestID 将非流式响应关联回请求 | +| `response_stream_interceptor` | `response.intercept_stream_chunk` | 用 RequestID 关联流式响应及最终 usage chunk | +| `usage_plugin` | `usage.handle` | 获取 CPA 标准用量、上游 credential 身份、时延、TTFT、失败和响应 tier 信息 | +| `management_api` | `management.register`、`management.handle` | 提供同步入口、查询 API 和内嵌 UI | +| `scheduler` | `scheduler.pick` | 供 Key→上游账户路由模块选择 auth;不是用量采集本身 | + +ABI 和 RPC schema 必须在注册时协商。当前参考 CPA 修订的 native ABI 为 1、RPC schema 为 3;实现只能声明实际支持的能力,并为 `plugin.register` 与 `plugin.reconfigure` 返回相同能力形状。 + +## 3. 一次请求的完整采集过程 + +### 3.1 请求进入:建立 Request + +`request.intercept_before` 是请求事实的起点: + +1. 用 `RequestID` 创建临时请求状态; +2. 读取 `TraceID`、`caller_scope`、`request_path`、source、source format; +3. 记录 requested/routed model、stream、generate、reasoning effort、requested service tier; +4. 用 `caller_scope` 查询本地下游 credential/account; +5. 调用 Key 准入与计费余额判断; +6. 被拒绝时返回明确的 401/403/429 响应,同时等待终态做幂等清理。 + +完整 Header 和 Body 只允许在回调期间解析,默认不写入通用存储。`caller_scope` 是 CPA 根据认证结果 Principal 计算的不可逆稳定标识:插件自管 Key 主路径中 Principal 是稳定 `credential_id`,兼容 CPA 原生 Key 时才是原生 Key 对应身份。它可以作为请求期关联键,但数据库外键仍应使用本地 Credential ID。 + +### 3.2 选择上游账户 + +CPA 进入 `scheduler.pick` 时会提供: + +- request-scoped headers 与 metadata,其中包含 `caller_scope`; +- provider、model、stream; +- 当前真正可用的 auth candidates; +- 每个 candidate 的 ID、provider、priority、status 和经过安全过滤的 attributes。 + +路由模块可以按 `caller_scope` 返回指定 `AuthID`。选择完成后,`request.intercept_after` 的 metadata 会包含 `selected_auth_id` / `selected_auth_index`,采集模块据此建立 Execution 与上游身份关联。 + +当前 CPA 在一次 handler lifecycle 内生成一个 RequestID;认证失败重试和模型池尝试会用同一个 RequestID 多次调用 after-auth interceptor。因此每次 after-auth 都应追加带 `attempt_no` 的 Execution,不能用 RequestID 覆盖上一尝试。TraceID 是父级入口请求/日志关联,不代替 ExecutionID。 + +### 3.3 观察上游响应和 Token + +必须同时使用两类来源: + +1. `usage_plugin`:CPA 已经标准化的用量、身份、性能和失败信息; +2. response hooks:包含 RequestID 的上游响应,用来恢复精确请求关联和 CPA 暂未完整传出的字段。 + +当前 CPA 的插件 `UsageRecord` 没有 `RequestID`,并且插件适配层未完整暴露内部的 request/response service tier 双字段,因此仅靠 `usage_plugin` 不能构造完全可靠的逐请求账单。 + +初期采用 `cpa-plugin-key-billing` 已验证的方式: + +- 在翻译前解析 provider 原始 usage; +- 支持 Codex/OpenAI Responses 的 SSE 和非流式响应; +- 用 response ID 关联翻译前无 RequestID 的观察与翻译后带 RequestID 的响应; +- 处理 Claude cache 独立计数、OpenAI cache/output 子集、Gemini reasoning 独立计数等不同语义; +- 对多 chunk 使用覆盖/合并规则,不能把累计值重复相加; +- 未知格式只记录 unclassified,不猜测可计费分项。 + +同时保留 CPA `usage_plugin` 数据,用于交叉校验、Credential 身份、TTFT、Latency、failure、executor、provider 和未来 CPA 契约补齐后的主来源切换。 + +每个 Execution 的多来源观察先规范化为 canonical cumulative usage vector,并保存 source rank、response ID、hash 和本地 revision。相同 vector/hash 的重复 callback 不新增 revision,多来源只择优/校验,不能相加。response hook 一旦得到新的可靠 canonical Usage,必须在返回 CPA 前同步写入 `usage_observations`/durable inbox;不能只放内存等待异步 `request.complete`。 + +当前 ABI 的重要限制:`usage.handle` 没有 RequestID/AttemptID,而带 RequestID 的 response hooks 通常只能观察最终返回响应。插件可以保存每次 after-auth 的 Execution 事实,却不能把并发环境中的无 RequestID usage 按时间或 Auth 猜配给某次失败重试。第一版只结算能够可靠关联的 Execution Usage;疑似产生消耗但无法关联的失败尝试标为 `unmeasured`。要完整结算所有重试成本,需要 CPA 后续在 UsageRecord 中加入 RequestID/AttemptID 或提供等价的 attempt lifecycle。 + +#### 3.3.1 为什么 key-billing 使用 schema 2 和双 hook + +该实现是理解“为什么能计费”的参考,也同时是性能反例: + +1. 它用 `response.normalize_before` 取得 provider 权威 Usage; +2. 该回调没有 RequestID,于是再用 `response.intercept_after` / `response.intercept_stream_chunk` 中的 RequestID + response ID 绑定归属; +3. Codex WebSocket 同协议透传可能没有翻译前回调,因此 v0.3.1 还会在下游 chunk 中读取 response ID/Usage; +4. 它固定声明 schema 2,生命周期 DTO 没有读取 host schema,说明实现没有做 `min(host, plugin_max)` 协商; +5. 当前源码只能证明它在旧契约上实现并保留兼容,不能把作者主观动机写成事实。 + +schema 2 使当前 CPA 为每个流式 payload chunk 重复附带完整原始/翻译后请求;双 hook 又使一个上游帧可能经过翻译前和翻译后两次同步 DLL RPC。schema 3 可以消除 stream interceptor 的逐 chunk 请求体重传,但 response-before 的请求体和 stream `HistoryChunks` 仍然存在。因此本项目只复用 response ID 关联与 Token 归一化算法,不直接复用“固定 schema 2 + 无条件双逐 chunk hook”的能力拓扑。 + +目标契约是让 CPA 在 Usage/final-frame 事件中直接提供 RequestID、Execution/AttemptID 和 ResponseID。宿主契约补齐前,任何过渡采集方案都必须通过 [dev.md](dev.md) 13.4 的性能/正确性联合门禁。 + +### 3.4 请求终态:提交事实 + +`request.complete` 是 Request 的终态信号: + +- outcome:succeeded、failed、rejected、canceled; +- status code、error; +- started/completed time; +- RequestID、TraceID、model 和 metadata。 + +终态处理: + +1. 封存 Request 与所有 Execution; +2. 生成零个或多个 UsageRecord; +3. 标记 missing/partial/inconsistent 等质量; +4. 交给持久化模块在事务中写入事实; +5. 触发计费模块处理可结算用量; +6. 立即释放并发槽和大对象;若结算仍为 awaiting/partial,则保留最小持久化关联和有界 response-ID tombstone,供迟到 Usage 补记。 + +终态回调是异步的,不能假定与 usage/response 回调严格有序或只调用一次。所有提交都必须使用 EventID/IdempotencyKey 幂等。终态等于“请求不再执行”,不等于“所有 Usage 已经到齐”;迟到窗口和最终 `unmeasured` 规则以计费模块为准。 + +## 4. 非请求数据的采集 + +参考 `cpa-usage-keeper`,除逐请求回调外还需要同步: + +| 数据 | CPA 来源 | 建议触发方式 | +| --- | --- | --- | +| CPA 原生 API Key 目录 | `/v0/management/api-keys` | 只用于显式兼容/迁移模式;插件自管 Key 主路径读取本地目录 | +| 上游 auth files/runtime | CPA `host.auth.list/get_runtime` | 交给 [upstream-accounts.md](upstream-accounts.md) 在启动、配置变化和手动命令中同步 | +| Provider API Key 配置 | CPA 各 provider management endpoint | 管理员凭证页面刷新 | +| 模型目录 | `/v1/models` | 启动/配置变化/手动同步 | +| 上游配额与订阅 | 受限 provider adapter;必要时 `host.auth.get` + `host.http.do` | 由上游账户模块手动/有界刷新;不转发 Management Key,不接受任意 URL | +| 请求日志 | `/v0/management/request-log-by-id` | 管理员按需查看,不批量复制 | + +原生插件没有必要复刻 keeper 的 Redis 拉取作为主入口,因为插件已经处于 CPA 进程内并能直接收到回调。Redis/HTTP usage queue 只作为未来的兼容导入或灾难恢复入口,不应与原生回调同时无条件入库造成重复。 + +## 5. 临时关联状态 + +内存中至少维护: + +- `pendingRequests[RequestID]`; +- 未绑定 RequestID 的 response observation,按 response ID 短期保存; +- 已完成 EventID 的有界去重缓存; +- 请求准入时的 account/credential/plan/cycle 快照; +- selected auth 和 execution attempt; +- 每个 attempt 的 Token observation 与质量。 + +所有临时项必须有 TTL 和定期清理;shutdown 必须幂等释放。不能持锁执行数据库、网络、host callback 或复杂 JSON 解析。 + +内存 response-ID cache 的默认 TTL 为 24 小时,但 SQLite `usage_correlations` 的最小 response ID→Request/Execution 映射至少保留 90 天;终态只删除大对象和释放并发。超过持久化关联期后,无 RequestID/ExecutionID 的迟到观察无法可靠归属,必须保持 unmeasured,不能按时间/Auth 猜配。 + +## 6. 失败与数据质量策略 + +- 没有 Token 不等于零 Token,记为 `missing`; +- 不同来源相等则提升可信度,不一致则保留两者来源并标记 `inconsistent`; +- 已产生 Token 的失败/取消执行仍输出 Usage,由计费模块决定收费; +- 被本地准入直接拒绝且未触达上游的请求输出 Request,不伪造 Usage; +- 晚到回调可以补充未封存数据;已记账事实不能被无版本覆盖; +- nested plugin host model callback 必须识别,避免外层请求重复采集与重复计费; +- 原始错误 Body、Prompt、Response、Authorization 和秘密凭证不得写入通用日志。 + +## 7. 与参考项目的关系 + +从 `cpa-plugin-key-billing` 直接参考: + +- `internal/plugin/intercept.go`; +- `internal/plugin/usage_tracker.go`; +- `internal/plugin/upstream_usage.go`; +- `internal/billing/pending.go`。 + +只参考上述文件的 Usage 语义、response ID 关联和幂等思路;schema 常量、生命周期 DTO、双逐 chunk capability 声明和流式 payload 形状必须按当前 CPA 重新设计。 + +从 `cpa-usage-keeper` 参考: + +- `internal/cpa/endpoints.go` 与 `internal/cpa/client.go`; +- `internal/service/sync.go`; +- `internal/service/tokenprocessor/`; +- `internal/quota/`; +- `internal/cpa/dto/`。 + +CPA 协议事实以当前 checkout 的 `sdk/pluginapi/types.go`、`sdk/pluginabi/types.go`、`internal/pluginhost/` 和官方 examples 为最高优先级。 + +## 8. 验收标准 + +- 成功、失败、拒绝、取消、流式、非流式和重试都能形成正确 Request 终态; +- 每个可计费用量能关联到下游 Key、上游账户和实际模型; +- OpenAI/Codex cache、reasoning、Fast tier 能被表达且不会重复计数; +- 回调乱序、重复、晚到和插件重启不会造成重复账单; +- 身份、模型、配额和订阅可从 CPA 同步; +- 不保存 Prompt、完整响应或秘密; +- 采集模块只输出数据模块定义的事实,不直接生成页面聚合。 diff --git a/docs/modules/core.md b/docs/modules/core.md new file mode 100644 index 0000000..94bad07 --- /dev/null +++ b/docs/modules/core.md @@ -0,0 +1,430 @@ +# Core 核心业务模块 + +## 1. 定位 + +Core 是 `cpa-ext` 的应用层和核心功能入口。它不代表某一种数据,也不等于某一个 CPA callback;它把多个领域模块组合成一个完整、可执行、可恢复的业务流程。 + +Core 负责回答: + +- 管理员创建一个用户和 Key 时,哪些模块按什么顺序执行; +- 一个下游请求进入后,如何完成认证、金额准入、账户路由、采集和结算; +- 失败、取消、重试、迟到 Usage 和插件重启时,状态如何收敛; +- 管理 API 或 CPA 回调触发的动作应调用哪些领域能力; +- 哪些步骤必须处于同一事务,失败时系统选择放行还是拒绝。 + +Core 不拥有计价公式、SQL、CPA ABI、图表算法或 UI。它通过清晰接口编排这些能力。 + +```text +CPA callbacks / Management handlers / maintenance triggers + │ + ▼ + Core use cases + ┌───────────────┼────────────────┐ + ▼ ▼ ▼ + Auth & Access Billing/Route Query/Statistics + └───────────────┼────────────────┘ + ▼ + Repository / Outbox +``` + +## 2. Core 拥有什么 + +Core 拥有: + +- 应用用例和调用顺序; +- 请求业务状态机; +- 跨模块事务边界; +- 失败策略和稳定业务错误码; +- 已认证主体到权限 scope 的转换; +- 管理操作的审计编排; +- 维护任务的触发依赖和恢复顺序; +- 模块组合后的只读 facade。 + +Core 不拥有: + +| 内容 | 所属模块 | +| --- | --- | +| RPC JSON/C ABI、能力方法分发 | `dev.md` 运行时适配层 | +| Request/Usage 字段定义 | `data.md` | +| CPA payload 解析和关联 | `collection.md` | +| SQL、migration、WAL、备份 | `persistence.md` | +| 价格来源、版本、模型匹配和 tier 政策 | `pricing.md` | +| Usage 金额计算、套餐、余额和账本 | `billing.md` | +| CPA Auth、priority、配额和账户池目录 | `upstream-accounts.md` | +| Key 状态规则和 Auth 候选选择 | `access-routing.md` | +| 小时/日聚合和查询分析 | `statistics.md` | +| HTTP route/DTO/权限/错误 | `api.md` | +| HTML、React 页面 | `ui.md` | + +Core 可以依赖这些模块暴露的接口,但领域模块不能反向依赖 Core 的具体实现。 + +## 3. 下游身份与 Key 主路径 + +### 3.1 架构决断 + +第一版主路径由 `cpa-ext` 自己管理下游 Key,并通过 CPA `frontend_auth_provider` 完成认证。生产配置使用 `frontend_auth_provider_exclusive: true`,在插件**已经成功加载且未被 fuse**时阻止 CPA 原生 Key 或其他认证 provider 与它并行生效。exclusive 不是“插件必需加载”的启动保证;插件缺失/失败时的部署门禁见 [dev.md](dev.md)。 + +原因: + +- Key 创建、禁用、过期、轮换可以立即生效; +- Key、用户金额账户和上游路由绑定可以处于同一事务; +- 不需要让插件反向修改 CPA `config.yaml`; +- 不需要在事件和管理 API 中保存或传播明文 Key; +- 可以把稳定 `credential_id` 交给 CPA;同一 Credential 的 secret version 轮换仍返回相同 Principal,因此 CPA 生成的 `caller_scope` 不变。 + +CPA 原生 `api-keys` 只作为显式兼容/迁移模式。兼容模式必须有清晰策略:映射成功才进入本地账户;未映射 Key 默认拒绝,不能自动成为无限额度 Key。 + +### 3.2 Key 格式与保存 + +推荐格式: + +```text +cpa__ +``` + +- `random_secret` 至少来自 256 bit CSPRNG; +- 创建成功时只展示一次完整 Key; +- 一个稳定 Credential 可以拥有多个 `CredentialSecretVersion`;每个 version 保存 public ID、校验摘要、`hmac_key_id`、脱敏预览和状态,不保存可恢复明文; +- 使用实例级 secret 对 Key 做 HMAC-SHA-256 lookup/verification,并用常量时间比较; +- 实例级 secret 不得写入 SQLite、日志或 UI; +- secret 轮换为同一 Credential 新增 version,overlap 后只撤销旧 version;只有管理员明确签发独立 Key 才创建新 Credential; +- 认证成功返回稳定 `credential_id` 作为 CPA Principal,而不是返回明文 Key。 + +下游 Key 是高熵机器凭证,不需要为每次请求使用昂贵的密码哈希;若未来支持人类口令登录,口令必须使用独立的 Argon2id/bcrypt 体系,不能复用 API Key 校验逻辑。 + +实例级 HMAC secret 是凭证数据库的必要恢复材料:启动时缺失未知 `hmac_key_id` 必须停止认证并告警,不能临时生成新 secret 后让所有旧 Key 悄然失效。轮换使用带 ID 的 keyring;新 Credential 用 active secret,旧摘要继续用其原 key ID 校验,直到对应 Credential 全部轮换/撤销。备份与灾难恢复文档必须把 secret keyring 和 SQLite 分开保护、成对验证。 + +## 4. 核心接口 + +Core 依赖面向行为的接口,避免一个巨型 `Store` 暴露全部内部状态: + +```go +type CredentialService interface { + Authenticate(ctx context.Context, presentedKey string) (Principal, error) + Create(ctx context.Context, command CreateCredential) (CreatedCredential, error) + Revoke(ctx context.Context, command RevokeCredential) error +} + +type AccessService interface { + Admit(ctx context.Context, command AdmitRequest) (Admission, error) + SelectAuth(ctx context.Context, command SelectAuth) (RouteDecision, error) + Release(ctx context.Context, requestID string) error +} + +type CollectionService interface { + Begin(ctx context.Context, observation RequestObservation) error + ObserveExecution(ctx context.Context, observation ExecutionObservation) error + ObserveUsage(ctx context.Context, observation UsageObservation) error + Complete(ctx context.Context, completion RequestCompletion) (CollectedRequest, error) +} + +type BillingService interface { + Price(ctx context.Context, input PriceInput) (BillingRecord, error) + Settle(ctx context.Context, command SettlementCommand) (SettlementResult, error) + ApplyLateUsage(ctx context.Context, command LateUsageCommand) (SettlementResult, error) +} + +type UnitOfWork interface { + WithinTransaction(ctx context.Context, fn func(Tx) error) error +} +``` + +具体接口可以随实现收敛,但必须保持:Core 负责用例,模块负责自己的确定性规则,Repository 负责持久化。 + +## 5. 管理业务用例 + +### 5.1 创建账户并签发 Key + +```text +管理员鉴权 +→ 校验账户/套餐/额度/路由命令 +→ 事务内创建 DownstreamAccount +→ 创建 BillingAccount / Cycle policy +→ 创建 RouteBinding +→ 创建逻辑 Credential 与首个 CredentialSecretVersion 摘要 +→ 写 AuditEvent +→ 提交 +→ 只在响应中返回一次完整 Key +``` + +随机 Key 可以在事务前生成,但只有事务成功后才能返回。事务失败时不得返回一个数据库中不存在的凭证。 + +### 5.2 禁用、撤销和删除 + +- `disable`:暂时禁止认证,历史和账本保留; +- `revoke`:Credential 永久不可再次启用; +- `delete account`:默认只做软删除,先撤销所有 Key; +- 任何操作都写审计事件; +- 已在执行的请求按准入快照完成结算,禁用只阻止新请求; +- 账本、BillingRecord 和历史 Request 不随 Key 删除。 + +### 5.3 套餐、余额和人工调整 + +- 套餐修改只影响指定生效时间后的准入; +- 当前执行请求继续使用 admission snapshot; +- 充值、扣减、退款、重置必须追加 LedgerEntry; +- Core 调用 Billing 校验金额和周期,再由持久化事务同时写审计; +- 禁止管理 handler 直接更新 `spent`、`remaining` 等投影字段。 + +### 5.4 账户绑定 + +绑定命令同时校验: + +- downstream account/credential 是否有效; +- upstream identity 是否存在、provider 是否为 Codex、是否已删除; +- route mode 是否为 default/strict/preferred/pool; +- strict 绑定是否至少存在一个目标; +- 修改是否影响已有执行; +- 操作者权限和审计原因。 + +## 6. 请求业务状态机 + +### 6.1 状态 + +```text +received + ├─ rejected + └─ admitted + ├─ routing + ├─ executing (1..N attempts) + └─ terminal: succeeded / failed / canceled + │ + ├─ awaiting_usage + ├─ settled + └─ unmeasured_final +``` + +请求终态与结算状态分开保存。`canceled` 可以是 `settled`,`succeeded` 也可能暂时是 `awaiting_usage`。 + +### 6.2 认证 + +CPA 调用 `frontend_auth.authenticate` 时: + +1. 先检查 method + path allowlist;只有经过完整 interceptor/scheduler/usage/lifecycle 验证的入口才继续认证; +2. 从支持的 Header 提取 Key,禁止从日志或错误回显; +3. 解析 public key ID,执行有界数据库 lookup; +4. 常量时间校验对应 secret version 的摘要; +5. 检查 Credential 与 secret version 的 active/disabled/revoked/expired;非 active 状态与未知 Key 对外统一失败; +6. 返回 `Authenticated=true`、`Principal=credential_id` 和最少安全 metadata; +7. 不在 metadata 中放账户余额、上游 AuthID 或秘密。 + +MVP allowlist 固定为 `POST /v1/responses`、`POST /v1/responses/compact`、`POST /backend-api/codex/responses`、`POST /backend-api/codex/responses/compact`;`GET /v1/models` 只有在宿主集成测试确认不发生可计费执行后才作为零金额只读入口开启。Alpha Search、Live、Realtime/client secret、WebSocket Responses、图片、视频、其他 provider 路由和未知入口默认认证失败。每增加一个入口,必须先证明它完整进入准入、路由、Usage 和终态链路。 + +认证只证明“是谁”,金额准入仍在 request interceptor/Core 中执行。这样错误码、周期结转和并发限制可以统一处理。 + +### 6.3 准入与开始 + +`request.intercept_before` 调用 Core: + +1. 使用预存/按目标 CPA 算法确定的 `credential_id → caller_scope` 映射查找本地 Credential;request interceptor 本身看不到原始 Principal; +2. 拒绝 nested plugin callback 的重复计费路径; +3. 校验 Key、账户、模型范围、周期金额、并发和价格可用性; +4. 在短事务中保存 Request pending、admission snapshot,并占用并发槽; +5. 返回放行或稳定错误。业务拒绝、数据库/账本/价格故障都必须作为成功 RPC envelope 内的 `Terminate=true + StatusCode + safe ResponseBody` 返回,不能返回 RPC error,因为当前宿主会忽略 interceptor error 并继续请求。 + +推荐生产失败策略: + +- 已完成认证后,本地数据库/账本不可用:`503`,fail closed; +- 金额耗尽:`429 billing_quota_exhausted`; +- 缺少、未知、畸形、禁用、撤销或过期 Key:当前 CPA 认证阶段由宿主统一返回 `401 no_credentials`; +- 认证后状态并发变化:interceptor 二次检查并返回 `403 access_credential_inactive`; +- 无价格且策略要求严格计费:`503 pricing_unavailable`。 + +这是当前宿主契约的限制,而不是 Core 自定义的业务错误:frontend-auth adapter 会把插件错误和 `Authenticated=false` 都映射为 `NotHandled`。所以认证 lookup 自身遇到数据库故障时也会 fail closed 为 `401 no_credentials`;插件必须另记内部诊断/告警。若产品必须向调用方返回认证依赖故障的 `503`,需要先增强 CPA frontend-auth response 和 adapter,不能只改 Core。 + +插件 dispatcher 必须在每个 RPC 方法边界 recover。interceptor 内部 panic 转为正常 RPC success + `Terminate=true, 503`;不能让 panic 越过 native 边界触发 CPA fuse,因为当前宿主在 interceptor error/panic 时会 fail open。 + +### 6.4 路由与执行尝试 + +CPA `scheduler.pick` 调用 Core 的 Route use case: + +1. 读取 request metadata 中的 caller scope; +2. 解析 Credential/Account 的 RouteBinding; +3. 只在 CPA 已过滤后的 candidates 中选择; +4. strict 目标不在候选集时返回 scheduler RPC error,阻止选择;绝不能用空/unknown AuthID 或 `Handled=false` 表达拒绝,因为宿主会回退内置调度; +5. preferred/pool 按文档规则回退; +6. 保存 `route_decision`,但不把数据库锁带入 CPA callback。 + +`request.intercept_after` 每次可观察的上游选择后建立一个 Execution attempt,并在实际执行前再次验证 `selected_auth_id` 属于该 Credential 允许集合;不匹配时用 `Terminate=true` 拒绝。重试不能覆盖上一尝试;最后结果与每次实际 Usage 分开保存。 + +MVP 的 Execution 精确定义为“一次 `request.intercept_after` 可观察到的 auth/model 执行段”,不保证等于一个物理 HTTP dispatch;例如 auth refresh 后的内部重发不会再次触发 after-auth,只能记录 `subattempt_count/observability=partial`。当前 CPA 会在同一个 RequestID 下多次调用 after-auth,但 `usage.handle` 没有 RequestID/AttemptID。Core 不得把无关联 Usage 猜配给某个并发请求:带 response/RequestID 的 Usage可以结算,无法关联的失败 attempt 只记录 `unmeasured`。完整的 dispatch/attempt 成本需要宿主契约增强。 + +### 6.5 Usage 观察与终态结算 + +Response hooks 和 `usage.handle` 只提交观察事实。Core 的关联服务将它们合并到 Request/Execution,但不能在每个 chunk 上直接扣款。response hook 一旦取得新的可靠 canonical Usage,必须在该 hook 返回 CPA 前把 observation/inbox 与 revision 原子持久化;至少最终 Usage 不能只留在内存等待 completion。 + +终态用例: + +1. 幂等接收 `request.complete`; +2. 立即释放并发占用; +3. 封存 Request/Execution 当前事实;已经耐久化的 Usage 不依赖 completion 才存在; +4. rejected 且没有上游执行:不生成消费账本; +5. 有可靠 Usage:Billing 计算金额,事务内写 BillingRecord、LedgerEntry、余额投影和 projection event; +6. 无 Usage 但可能已触达上游:进入 `awaiting_usage`; +7. 提交后用 outbox/非阻塞通知唤醒统计; +8. 重复终态得到相同结果,不产生第二笔账。 + +### 6.6 取消、失败和迟到 Usage + +统一决断:**按可靠的实际上游 Usage 收费,不按 outcome 收费。** + +- 取消前已经产生 Usage:正常扣费并显示 `outcome=canceled`; +- 失败前已经产生 Usage:正常扣费并显示 `outcome=failed`; +- 无 Usage:不猜费,暂记 0 并标记 `unmeasured`; +- 迟到 Usage:按新累计应收与已收金额的差额追加 `late_settlement`; +- 重复/旧 revision:不扣费; +- 矛盾或减少的累计值:不自动退款,进入人工复核。 + +Core 必须把“释放并发”和“完成金额核算”拆开,否则为了等待迟到 Usage 会长期占用 Key 并发槽。 + +## 7. 事务与一致性 + +必须处于同一 SQLite 事务的典型操作: + +- 创建账户、Key 摘要、套餐绑定、路由绑定和审计; +- admission snapshot 与并发占用; +- BillingRecord、LedgerEntry、余额投影和结算幂等键; +- 迟到补记账本与原请求结算投影; +- 管理员余额调整与审计。 + +不能放进事务: + +- CPA host callback; +- provider HTTP 请求; +- 长时间统计查询; +- 静态资源处理; +- 等待维护 runner/sidecar。 + +跨事务通知使用本地 outbox 或由已提交事实的 checkpoint 追赶。不能先发内存事件再提交数据库。 + +## 8. 并发和幂等 + +Core 的每个命令携带: + +- command/event ID; +- actor; +- request/trace/execution ID(适用时); +- expected version(管理更新适用时)。 + +关键约束: + +- RequestID 唯一; +- ExecutionID 或 `(request_id, attempt_no)` 唯一; +- Usage revision 幂等; +- 每种结算类型拥有唯一 idempotency key; +- Credential public ID 唯一; +- 更新绑定和套餐使用乐观版本或事务锁定,防止管理页面覆盖并发变更。 + +进程内 map 只能做短期关联和缓存,数据库唯一约束才是最终防线。 + +Usage revision 由插件生成而非来自 CPA:每个 Execution 保存 canonical cumulative usage vector、source rank、response ID 和 canonical hash。相同 hash/vector 的重复回调不提升 revision;只有事务内 CAS 确认 canonical vector 实际变化时才创建下一 revision。多来源用于择优与交叉校验,绝不能相加;结算差额只在单个 Execution 内计算。 + +## 9. 管理与查询 facade + +管理 API handler 不直接调用 Repository,而调用 Core facade: + +```go +type AdminFacade interface { + CreateAccount(context.Context, Actor, CreateAccountCommand) (AccountView, error) + IssueCredential(context.Context, Actor, IssueCredentialCommand) (IssuedCredential, error) + ChangePlan(context.Context, Actor, ChangePlanCommand) error + AdjustBalance(context.Context, Actor, AdjustBalanceCommand) error + ChangeRoute(context.Context, Actor, ChangeRouteCommand) error + RevokeCredential(context.Context, Actor, RevokeCredentialCommand) error +} + +type QueryFacade interface { + UserDashboard(context.Context, Principal, DashboardFilter) (UserMoneyView, error) + AdminOverview(context.Context, Actor, OverviewFilter) (AdminOverview, error) +} +``` + +Core 构造权限 scope,Statistics/Repository 执行查询。用户请求不能通过传 account ID 越权;管理员操作必须包含 Actor 和 Audit reason。 + +## 10. 启动、恢复与降级 + +Core 初始化顺序: + +1. 解析并验证配置; +2. 打开 SQLite,执行 migration 和 integrity 基础检查; +3. 加载实例 secret、价格版本和必要目录; +4. 构造 Repository 与领域服务; +5. 恢复 pending/abandoned Request、已保存 Usage 但未结算的 Execution、awaiting usage、未发布 projection/outbox; +6. 装配 callback-driven maintenance;只有 dev runtime 双 Go runtime soak gate 通过后才启动可选 worker,否则长任务交给 sidecar; +7. 形成 immutable Runtime,开始接受 CPA 方法。 + +核心计费依赖失败时必须 fail closed。可选能力可以降级: + +- 统计聚合失败:请求和计费继续,UI 显示 lag; +- 上游配额刷新失败:保留旧快照并标过期; +- 最近事件缓存失败:回退 SQLite; +- UI 静态资源失败:不影响请求计费; +- migration、账本写入、Key 校验库失败:停止新请求准入。 + +当前 CPA 对插件缺失、host 级 fuse、interceptor host-boundary panic 没有纯插件可实现的绝对 fail-closed 保证。生产发布必须把 dev 文档中的 required-plugin/startup gate 和 sentinel provider 作为外部安全前提;缺一项时该部署只能标为开发模式。 + +## 11. 建议代码结构 + +```text +internal/ + core/ + app.go # 依赖装配后的应用 facade + request_flow.go # 请求状态机与编排 + credential_usecases.go + billing_usecases.go + routing_usecases.go + query_facade.go + errors.go + domain/ + identity/ + access/ + billing/ + statistics/ + repository/ + cpaadapter/ + management/ +cmd/cpa-ext/ # 薄 C ABI 入口 +``` + +Core 包不能 import `C`、不能解析 CPA wire JSON,也不能渲染 HTTP。 + +## 12. 如何参考现有项目 + +`cpa-plugin-key-billing` 参考其纵向闭环: + +- `internal/plugin/app.go`:RPC 到应用入口的分发; +- `internal/plugin/intercept.go`:准入、终态和取消; +- `internal/plugin/usage_tracker.go`:Request/response 关联; +- `internal/billing/pending.go`:准入快照和单一提交点; +- `internal/billing/account.go`:Usage 到账户结算; +- `internal/billing/admin.go`、`keys.go`:管理用例。 + +吸收业务顺序和边界,不复制巨型状态 Store、浮点账本或把管理 handler 直接当领域服务的结构。 + +`cpa-usage-keeper` 参考其应用装配、算法和维护任务边界: + +- `internal/app/app.go`:Repository、服务、runner 和 router 的装配/关闭; +- `internal/service/`:API 与 Repository 之间的服务层; +- `internal/poller/usage_aggregation_runner.go`:已提交事实驱动聚合;只能借鉴 batch/checkpoint 算法,不能默认复制 goroutine; +- `internal/app/maintenance.go`、`backup_runner.go`:维护任务生命周期。 + +它是独立服务,不能直接复制其 HTTP server、goroutine、timer 或 backup runner 到 CPA 进程内 c-shared 插件。 + +CPA 契约依据: + +- `CLIProxyAPI/sdk/pluginapi/types.go` 的 frontend auth、interceptor、lifecycle、scheduler、management 类型; +- `internal/pluginhost/adapters_auth.go` 的 exclusive provider 选择; +- `sdk/api/handlers/handlers.go` 的 Principal → caller scope; +- `examples/plugin/frontend-auth-exclusive/`、`request-lifecycle/`、`scheduler/`。 + +## 13. 验收标准 + +- 能完成“创建用户和金额账户 → 签发 Key → 绑定 Codex 账户 → 请求 → 扣费 → 耗尽拒绝”的真实闭环; +- 在受支持部署(Home 关闭、sentinel + readiness gateway 生效)中,插件 active/异常/缺失状态都不存在用户可用的 CPA 原生 Key 绕过; +- 完整 Key 只在签发时显示一次,数据库和日志不保存明文; +- 请求重试、重复回调、取消、失败和迟到 Usage 不会重复扣费; +- 禁用 Key 立即阻止新请求,不影响已发生费用的结算; +- 每个跨模块事务都有清晰原子边界和崩溃恢复结果; +- 管理 handler 与 UI 不能直接修改存储或余额投影; +- 统计故障不影响计费,计费/数据库故障时新请求安全拒绝; +- Core 可用普通 Go 单元测试覆盖,不依赖 CGO 或真实 CPA 动态库。 diff --git a/docs/modules/data.md b/docs/modules/data.md new file mode 100644 index 0000000..85c0959 --- /dev/null +++ b/docs/modules/data.md @@ -0,0 +1,429 @@ +# 数据模块 + +## 1. 定位 + +数据模块是 `cpa-ext` 的统一数据语言。它只回答三件事: + +1. 系统需要从 CLIProxyAPI(CPA)及相关来源关注什么数据; +2. 这些数据在插件内部以什么稳定语义流转; +3. 计费、统计、Key 管理、配额、审计和展示模块可以得到什么输出。 + +数据模块不负责采集回调、计算费用、执行扣款、生成统计图或决定界面权限。具体模块负责产生和消费数据,数据模块只定义契约。 + +本模块以两个已经实际工作的项目为需求来源: + +- `cpa-plugin-key-billing` 已经拿到并使用的数据,全部视为我们需要关注的数据; +- `cpa-usage-keeper` 已经拿到并使用的数据,全部视为我们需要关注的数据; +- 两者已经对外提供的数据产品,全部纳入我们的输出能力全集; +- 第一版可以只实现其中一部分,但数据抽象不能阻止以后补齐其余能力。 + +## 2. 两个参考项目的数据总结 + +### 2.1 `cpa-plugin-key-billing` + +#### 它拿到的数据 + +| 数据域 | 实际取得的内容 | 主要来源 | +| --- | --- | --- | +| 下游调用身份 | `caller_scope`、Key 脱敏预览、标签、Key 是否仍在 CPA 配置中 | 请求 metadata、管理端同步的 CPA Key 列表 | +| 请求关联 | `request_id`、入口 endpoint、请求格式、上游格式、流式标记、是否真实生成 | request interceptor、request lifecycle | +| 模型 | 用户/路由模型、实际上游模型、稳定 billing model | 请求阶段、响应阶段 | +| 上游凭证 | `auth_index`、provider、auth type、OAuth 账户或脱敏 provider API Key | CPA usage callback | +| 请求终态 | 正常、失败、取消、拒绝 | request lifecycle | +| 原始用量 | OpenAI/Codex、Claude、Gemini、Interactions 等响应中的 usage 和 response ID | 翻译前响应、非流式响应、流式 chunk | +| 标准 Token | 非缓存输入、缓存读取、缓存写入、普通输出、推理输出、总量、无法分类数量 | 自身规范化逻辑 | +| 数据质量 | complete、inconsistent、unclassified、未观察到用量 | 自身规范化逻辑 | +| 价格 | 模型匹配规则、每百万 Token 各分项价格、长上下文门槛与价格、价格来源 | 内置 models.dev 目录、管理员覆盖 | +| 套餐与周期 | 套餐 ID/名称、美元额度、日/周/月/自定义周期、周期开始与结束 | 插件配置与持久化状态 | +| 请求准入快照 | 请求进入时绑定的 Key、套餐和周期 | 请求 interceptor | + +它没有把 CPA 的 `usage_plugin` 当作完整用量事实:该回调缺少 `RequestID`,所以项目主要从带有请求关联信息的响应 hook 中恢复权威 Token,再在终态回调结算;`usage_plugin` 主要用来补充上游凭证身份。 + +#### 它存储的数据 + +- 价格规则、套餐、Key 状态、上游凭证目录; +- Key 的当前周期和生命周期累计; +- 按模型累计; +- 最近 30 天请求日志; +- 每次请求的标准 Token、金额分项、价格来源、长上下文状态和数据质量; +- 运行时未定价、无 Token、无法分类等诊断计数。 + +#### 它输出的数据 + +| 输出域 | 已提供的数据 | +| --- | --- | +| 请求控制 | 放行或拒绝,额度耗尽时返回明确的 HTTP 错误 | +| Key 目录 | Key 标识/预览/标签、套餐、是否无限、是否阻断、周期额度、已用、使用率、周期结束时间 | +| 套餐管理 | 套餐及金额、周期,绑定、解绑和重置 | +| 请求账目 | 时间、Key、请求 ID、endpoint、上游凭证、模型、结果、Token、金额、价格来源和核算质量 | +| 汇总 | Key 数量、阻断数量、生命周期请求/Token/金额、按模型汇总 | +| 价格管理 | 当前模型价格、自定义覆盖、价格刷新结果 | +| 运维诊断 | 持久化状态、配置状态、未定价/缺失/无法分类计数 | + +### 2.2 `cpa-usage-keeper` + +#### 它拿到的数据 + +| 数据域 | 实际取得的内容 | 主要来源 | +| --- | --- | --- | +| 用量事件 | provider、endpoint、auth type/index、request ID、API Key、模型/别名、请求与响应 service tier、reasoning effort、executor、时间、失败、延迟、TTFT、完整 Token 分项 | CPA Redis usage queue / HTTP usage queue | +| 请求环境 | client IP、X-Forwarded-For、User-Agent、source、API group key | CPA usage payload | +| 下游 Key | 完整 CPA API Key、展示值、别名、删除/同步状态 | CPA Management API | +| 上游 Auth File | auth index、名称、文件、email、provider、label、状态、priority、disabled、note、account/project、订阅起止与 plan type | CPA Management API | +| Provider API Key 配置 | provider 类型、名称、prefix、base URL、lookup key 等归一化元数据 | CPA 各 provider management API | +| 模型目录 | 模型 ID、owner、创建时间 | CPA `/v1/models` | +| 上游配额 | Codex 主/次窗口、允许状态、使用率、重置时间、额外限制、reset credits;以及其他 provider 的 quota | CPA 代调用各 provider API | +| 上游订阅 | Codex plan、tier、有效期;其他 provider 的订阅/账户资料 | auth metadata、provider API | +| 请求日志 | 按 request ID 获取的请求日志、预览与下载数据 | CPA request-log management API | +| 价格 | 模型价格快照、条件规则、service tier、reasoning、模型、Key、auth、endpoint、executor 等价格维度 | models.dev、管理员配置 | + +#### 它存储的数据 + +- 原始 Redis inbox 及处理/重试状态; +- 标准化的逐次用量事件和归档; +- CPA API Key 与上游身份目录; +- 模型价格设置、同步快照和条件价格规则; +- 小时/日 overview、activity、latency、identity 等预聚合及各自 checkpoint; +- 配额快照和应用设置; +- 排名等派生结果。 + +#### 它输出的数据 + +| 输出域 | 已提供的数据 | +| --- | --- | +| Overview | 请求数、Token、金额、RPM、TPM、日均值、缓存命中率和时间序列 | +| 实时状态 | Token 速度、请求速度、TTFT/Latency P50/P95、响应分布、当前模型/Key/Auth Top、缓存水平 | +| 事件明细 | 可分页筛选的逐请求记录,包含模型、身份、结果、Token、金额、延迟和请求日志入口 | +| Analysis | Token 时序、模型使用、Key/模型/Auth 构成、Key×模型热力图、金额分项、模型效率 | +| Activity | 日/周/月/年活动格、成功失败、成功率和各 Token 分项 | +| 身份目录 | 下游 Key 与上游账号/provider 的元数据、别名、启停状态、订阅和累计使用情况 | +| 配额 | 各上游账号的窗口、使用率、剩余、重置时间、订阅层级和 reset credits | +| 价格 | 当前价格、价格来源、同步状态、条件规则及预览 | +| 排名与导出 | 本地排名、事件导出、请求日志查看/下载 | + +## 3. 我们的数据关注全集 + +两个项目的数据取并集后,数据模块定义以下九个数据域。这里的“需要”表示契约需要容纳,不表示所有字段都必须在 MVP 首日采集完成。 + +### 3.1 请求与执行 + +必须同时表达两个层级: + +- `Request`:一次下游用户请求; +- `Execution`:一次 CPA after-auth 可观察到的上游逻辑执行段。 + +一个 Request 可以因为重试、故障切换或路由产生多个 Execution。Execution 不保证等于一个物理 HTTP dispatch:当前 CPA 在 auth refresh 后的内部重发不会再次触发 after-auth,应以 `subattempt_count/observability=partial` 表达,而不是伪造额外 Execution。未来宿主提供 dispatch lifecycle 时再增加 `DispatchAttempt`。 + +关注字段: + +- request/event/trace/execution ID; +- endpoint、source、source format、upstream format; +- requested model、routed model、upstream model、model alias; +- stream、generate、reasoning effort; +- requested、reported、effective service tier; +- requested、started、first-token、completed 时间; +- outcome、HTTP status、标准错误类别; +- settlement status(与请求 outcome 分开,允许 canceled/failed 请求在迟到 Usage 后结算); +- latency、TTFT; +- 重试序号及最终尝试标记。 + +### 3.2 下游身份 + +关注字段: + +- account/member ID; +- credential ID; +- credential secret version/public ID/HMAC key ID/status; +- 自管 Key 的 public key ID、HMAC digest、digest secret version; +- CPA 根据 Principal 派生的稳定 caller scope(仅用于请求期关联,不作为业务主键); +- 兼容模式下 CPA 原生 API Key 的稳定 scope/hash; +- 脱敏预览、别名、标签; +- 启用、删除、同步状态; +- 套餐/计费账户绑定。 + +自管 Key 的完整值只在创建/轮换成功时展示一次,持久层只保存摘要;CPA 原生 Key 即使在兼容迁移模式由 CPA 配置持有,也不得复制进插件事件、日志或跨模块消息。 + +### 3.3 上游身份 + +关注字段: + +- auth ID/index/type; +- provider、executor type; +- 名称、别名、email/account、文件标识; +- provider prefix、base URL、lookup key; +- priority、disabled、status、note; +- account/project ID; +- plan/tier 和订阅有效期。 + +秘密凭证不属于数据流转契约。 + +### 3.4 用量 + +统一为互不重叠、跨 provider 可比较的字段: + +- uncached input; +- cache read; +- cache creation/write; +- non-reasoning output; +- reasoning output; +- total; +- unclassified; +- generate 标记。 + +同时保留规范化质量:`complete`、`normalized`、`partial`、`inconsistent`、`unclassified`、`missing`,以及修正动作/异常代码。原始 provider 计数可以作为受限审计快照保存,但不能直接成为跨模块语义。 + +### 3.5 金额与价格 + +关注字段: + +- settlement currency; +- 金额的定点整数值; +- 未缓存输入、缓存读取、缓存写入、输出的金额分项; +- base amount、final charged amount; +- 每百万 Token 的实际应用价格; +- service tier / Fast multiplier; +- 长上下文门槛及是否命中; +- price version、source、effective time; +- 价格是否可用及不可用原因; +- billing record ID、request/execution ID、usage revision; +- charge、late settlement、credit、refund、adjustment 类型及被修正记录; +- pending/awaiting usage/settled/unmeasured/inconsistent 状态; +- occurred at 与 booked at。 + +用户侧核心金额只使用最终结算金额;Token、价格与倍率是后台审计数据。 + +### 3.6 下游套餐、周期与余额 + +关注字段: + +- plan、额度金额和周期规则; +- BillingAccount 与 plan 的绑定、Credential → BillingAccount 绑定; +- cycle start/end; +- limit、spent、remaining; +- blocked/allowed; +- 管理员调整及其原因; +- 请求准入时使用的周期快照。 + +### 3.7 上游配额与订阅 + +它和我们分配给用户的金额额度是不同概念,必须分开表达。 + +关注字段: + +- provider/account; +- plan/tier、订阅起止; +- primary/secondary/additional windows; +- used、limit、remaining、percentage; +- allowed、limit reached; +- window duration、reset at/after; +- window usage token/cost; +- reset credit 数量、状态和过期时间; +- snapshot time 和数据新鲜度。 + +### 3.8 模型、价格与配置目录 + +关注字段: + +- CPA 可用模型及 owner; +- 模型别名和路由映射; +- provider/model 对应关系; +- 价格目录、管理员覆盖和条件规则; +- Key、auth、provider 的当前配置事实; +- 同步时间、来源、版本和删除状态。 + +### 3.9 诊断、日志与数据质量 + +关注字段: + +- schema version、producer version; +- source/provenance、observed at、persisted at; +- idempotency key; +- 缺失、修正、冲突、未定价状态; +- inbox/checkpoint/retry/archive 状态; +- 请求日志引用和受控下载信息; +- 原始数据哈希或受限审计引用。 + +Prompt、模型完整响应、Authorization、Cookie、OAuth token 和 provider API Key 默认不进入通用数据契约。 + +## 4. 核心抽象 + +数据模块不要求所有内容塞进一张表。它定义以下稳定对象,存储模块可以分别落库。 + +```text +DownstreamAccount + └─ BillingAccount + └─ DownstreamCredential (1..N) + ├─ CredentialSecretVersion (1..N) + └─ Request + └─ Execution (1..N) + ├─ Usage + ├─ Outcome & Performance + └─ UpstreamIdentity + +Usage + PricingSnapshot + └─ BillingRecord + └─ LedgerEntry + +Usage / BillingRecord / Identity / QuotaSnapshot + └─ Aggregate & View +``` + +### 4.1 `RequestRecord` + +用户视角的一次请求。保存下游身份、入口、请求模型、最终结果、时间以及所有 Execution 的关联,不直接猜测每次上游尝试的细节。 + +### 4.2 `ExecutionRecord` + +一次 CPA after-auth 可观察到的逻辑执行段。保存上游凭证、provider、实际模型、tier、格式、终态、性能、重试关系和 observability;不能宣称等于每个物理 HTTP dispatch。 + +### 4.3 `UsageRecord` + +一次 Execution 对应的标准 Token 事实,包含质量和来源。Request 层用量由明确规则合并,不覆盖底层执行事实。当前 CPA 对部分 Usage 缺少 RequestID/AttemptID,无法可靠归属时必须保持 `unmeasured`/未关联事实,不能按时间或上游账户猜配。 + +### 4.4 `BillingRecord` + +计费模块对 Usage 应用价格后产生的不可变金额事实。它引用 Usage revision 和价格快照,迟到结算、退款或人工修正通过新增记录表达;金额一旦入账,统计模块不得重新计算并改写历史。 + +### 4.5 `IdentityRecord` + +统一承载下游 Key/账户和上游 auth/provider 的非秘密身份信息,但通过明确的 identity kind 区分,不能只靠一个字符串猜类型。 + +### 4.6 `QuotaSnapshot` + +某个上游身份在某一时刻的 provider 配额和订阅快照。它是时间点事实,不覆盖历史;也不与下游金额余额混用。 + +### 4.7 `CatalogSnapshot` + +模型、价格、路由和配置目录的带版本快照,使历史请求可以解释当时使用的模型和价格。 + +### 4.8 `AggregateRecord` + +从不可变事实派生的小时、日、活动、延迟、身份和排名数据。它可以删除并重建,不是账本真相。 + +### 4.9 `ProjectionEvent` + +跨 Request、Usage、Billing、Ledger 多表的统一追加变化流。它携带单调 `event_seq`、唯一 `event_id`、kind、fact ID/revision、occurred/booked time、可选 supersedes ID 和确定性统计差量。领域事实与对应 ProjectionEvent 必须在同一事务提交;统计 projector 只按这条流推进,不能比较各表互不相关的自增 ID。 + +## 5. 统一数据元信息 + +所有可持久化的事实对象都应携带或可追溯到: + +| 字段 | 含义 | +| --- | --- | +| `schema_version` | 数据契约版本 | +| `event_id` | 全局稳定事件 ID | +| `idempotency_key` | 重复回调或重放时去重 | +| `source` | CPA callback、response hook、Redis、Management API、provider API 等 | +| `observed_at` | 来源数据被观察到的时间 | +| `occurred_at` | 业务事实真实发生时间 | +| `persisted_at` | 成功落库时间 | +| `quality` | 完整、修正、部分、矛盾、缺失等 | +| `producer_version` | 产生该规范化数据的插件版本 | + +字段缺失必须保持“未知”,不能自动等价为零、空字符串或成功。 + +## 6. 数据模块输出契约 + +数据模块定义四类输出,具体模块按需实现和消费。 + +### 6.1 事实输出 + +- Request、Execution、Usage、Billing、Ledger、ProjectionEvent; +- Downstream/Upstream Identity; +- Quota、Subscription、Catalog 快照; +- 数据质量和诊断事件。 + +### 6.2 查询输出 + +- 逐请求和逐执行明细; +- Key、账户、上游身份、模型和价格目录; +- 套餐、周期、余额和账本; +- 配额、订阅和重置时间; +- 请求日志的受控引用。 + +### 6.3 聚合输出 + +完整能力集合至少容纳: + +- 请求、Token、金额、RPM、TPM; +- 时间序列和日均; +- 成功率、错误分布; +- TTFT/Latency 分位数和分布; +- 缓存读取率; +- 模型、Key、账户、Auth、Provider 构成及 Top; +- Key×模型热力图; +- 金额分项和模型效率; +- 活动格、排名和历史对比。 + +### 6.4 控制输出 + +- 请求允许/拒绝及稳定错误码; +- Key、套餐、价格和绑定的变更结果; +- 配额刷新/重置结果; +- 同步、归档、重建和诊断结果。 + +控制输出由业务模块决定,数据模块只定义其可交换的数据形状。 + +## 7. 模块边界 + +| 模块 | 对数据模块的关系 | +| --- | --- | +| CPA 适配层 | 把 CPA 回调、响应和 Management API 数据翻译成标准事实 | +| 存储模块 | 按契约持久化、查询、去重、归档和迁移 | +| Core/Key 模块 | 维护下游身份、金额账户和绑定关系 | +| 上游账户模块 | 维护 CPA Auth 引用、priority、bindability 和 Quota/Subscription 快照 | +| 价格模块 | 维护不可变 PriceVersion、模型 alias 和计价政策 | +| 计费模块 | 消费 Usage、Pricing、Plan,输出 Billing 和 Ledger | +| 统计模块 | 消费不可变事实,输出可重建的 Aggregate | +| API/UI 模块 | 将同一数据按用户或管理员权限投影,不发明新的业务事实 | + +数据模块自身: + +- 不依赖 UI; +- 不依赖某一种数据库; +- 不调用 CPA; +- 不计算价格; +- 不聚合统计; +- 不保存或传播秘密; +- 不把参考项目现有字段名直接当成永久领域语义。 + +## 8. 如何参考两个项目 + +### 8.1 从 `cpa-plugin-key-billing` 参考 + +重点查看: + +- `internal/plugin/usage_tracker.go`:请求与响应用量关联; +- `internal/plugin/upstream_usage.go`:跨 provider Token 语义; +- `internal/billing/pricing.go`:标准 Token、数据质量、价格和金额分项; +- `internal/billing/account.go`:终态用量到费用记录; +- `internal/billing/state.go`:价格、套餐、Key、周期、累计和日志; +- `internal/billing/log.go`、`keys.go`:请求账目和管理输出; +- `internal/billing/credentials.go`:上游凭证的安全展示身份。 + +吸收它对请求关联、Token 不重叠、质量显式化、请求准入周期快照和上游身份脱敏的定义;不要继承其 JSON 大状态、浮点余额、30 天日志限制和把重试只保留为最终 provider usage 的数据损失。 + +### 8.2 从 `cpa-usage-keeper` 参考 + +重点查看: + +- `internal/entities/usage_event.go`:逐请求事件字段; +- `internal/entities/usage_identity.go`、`cpa_api_key.go`:身份目录; +- `internal/service/sync.go`:用量规范化、来源和 inbox 处理; +- `internal/service/tokenprocessor/`:Token 修正与质量判断; +- `internal/pricing/`、`internal/entities/model_price_*`:价格快照与条件; +- `internal/quota/`:上游配额、订阅和 provider 抽象; +- `internal/service/dto/usage.go`、`analysis.go`、`usage_activity.go`:查询和分析输出; +- `internal/repository/usage*.go`:明细、聚合、checkpoint 和归档。 + +吸收它完整的事件、身份、配额、价格、实时和分析数据面;不要把其 Redis/HTTP 拉取方式、明文 CPA Key 存储方式、GORM 实体或动态历史费用重算直接变成我们的领域契约。 + +## 9. 本模块的完成标准 + +- 两个参考项目实际使用的输入数据均能映射到本模块的数据域; +- 两个参考项目实际提供的输出均能由本模块的事实或派生数据表达; +- Request 与 Execution、下游额度与上游配额、Usage 与 Billing、事实与聚合均明确分离; +- 未知、缺失、零和失败具有不同语义; +- 所有金额可使用定点整数,所有历史收费可追溯到价格快照; +- 所有秘密字段均被排除或明确限定在受保护配置边界; +- 后续模块只能扩展契约版本,不能用自己的私有字段重新定义同一个业务事实。 diff --git a/docs/modules/dev.md b/docs/modules/dev.md new file mode 100644 index 0000000..c5da29c --- /dev/null +++ b/docs/modules/dev.md @@ -0,0 +1,610 @@ +# 插件运行时与 CPA 集成开发文档 + +## 1. 定位 + +本模块是 `cpa-ext` 与 CLIProxyAPI(CPA)之间的工程适配层。它负责: + +- native dynamic library ABI; +- JSON RPC envelope 和方法分发; +- capability 注册与 schema 协商; +- CPA callback 到 Core/采集模块的转换; +- 配置注册、热重配、运行时切换和 shutdown; +- Management API 与静态资源接入; +- 构建、动态库检查、安装和宿主集成测试。 + +它必须保持很薄。任何计价公式、余额规则、SQL、聚合算法或路由策略都不应写进 C ABI 入口或 RPC dispatcher。 + +生产安全前提见 [security.md](security.md),安装/升级/恢复见 [operations.md](operations.md),所有 ABI、故障、性能和 soak 证据见 [test-plan.md](test-plan.md)。 + +## 2. 当前兼容基线 + +设计时核对的源码版本: + +| 项目 | revision | +| --- | --- | +| `CLIProxyAPI` | `f43aad7637ad813745bf7d341acb5663617570c5` | +| `cpa-plugin-key-billing` | `25b534ae386f830f537cca9215cff5586e630b3a` | +| `cpa-usage-keeper` | `d62cad3f345ae574089a14a4ac75cca023c7ead6` | + +该 CPA revision 的契约: + +- minimum host baseline:CLIProxyAPI `v7.2.130`(上述 exact tag/revision); +- Native ABI:`1`; +- RPC schema:最高 `3`; +- 宿主实际调用的 RPC lifecycle:`plugin.register`、`plugin.reconfigure`;`plugin.shutdown` 目前只存在方法常量,没有宿主调用点; +- CPA module 声明 Go `1.26.0`,当前 cpa-ext module 声明 Go `1.24`;构建矩阵必须固定实际 Go/toolchain 版本并纳入双 runtime soak,不能只比较 `go.mod` 最低版本; +- stream schema 3 的 payload chunk 不再重复携带 request body,必须在 header-init 或请求 hook 缓存关联所需的最小信息。 + +源码 checkout 才是最终规范。每次升级 CPA 都必须重新核对: + +- `CLIProxyAPI/sdk/pluginabi/types.go`; +- `CLIProxyAPI/sdk/pluginapi/types.go`; +- `CLIProxyAPI/internal/pluginhost/rpc_schema.go`; +- `CLIProxyAPI/internal/pluginhost/rpc_client.go`; +- 相同 capability 的官方 example 和测试。 + +ABI 版本与 RPC schema 版本相互独立,不能因为两者当前分别是 1 和 3 就混成一个“插件版本”。 + +## 3. 目标 capability + +完整产品需要以下 capability,但开发时只声明已经实现并测试的方法: + +| Capability | RPC 方法 | 适配到内部能力 | +| --- | --- | --- | +| `frontend_auth_provider` | `frontend_auth.identifier`、`frontend_auth.authenticate` | 插件自管下游 Key 认证 | +| `frontend_auth_provider_exclusive` | 注册字段,无独立方法 | 生产模式阻止其他认证 provider 绕过 Core | +| `request_interceptor` | `request.intercept_before`、`request.intercept_after` | 准入、Request 建立、Execution attempt | +| `request_lifecycle_plugin` | `request.complete` | 成功/失败/拒绝/取消终态与清理 | +| `response_before_translator` | `response.normalize_before` | 读取接近 provider 原始语义的 Usage | +| `response_interceptor` | `response.intercept_after` | 非流式响应与 RequestID 关联 | +| `response_stream_interceptor` | `response.intercept_stream_chunk` | 流式 response ID/Usage 与 RequestID 关联 | +| `usage_plugin` | `usage.handle` | CPA 标准 Usage、身份、TTFT、Latency、失败交叉校验 | +| `scheduler` | `scheduler.pick` | Key/账户绑定到候选 AuthID | +| `management_api` | `management.register`、`management.handle` | 管理 JSON API、静态 shell、用户只读资源 | + +不需要声明:model registrar/provider、auth provider、executor、translator、model router 等。`cpa-ext` 初期不替代 CPA 的 Codex 执行器或协议翻译器。 + +### 3.1 分阶段声明 + +建议 capability 开通顺序: + +1. 当前骨架:`usage_plugin`; +2. 采集闭环:request interceptor + lifecycle + response hooks; +3. 自管 Key:frontend auth,并在生产测试完成后打开 exclusive; +4. 账户绑定:scheduler; +5. 管理与 UI:management API。 + +未实现的方法不能返回空成功来假装支持。注册字段为 true 后,该 capability 隐含的所有方法都必须有结构化响应和测试。 + +## 4. Native ABI 入口 + +Go c-shared 入口放在 `cmd/cpa-ext/main.go`,使用 `//go:build cshared`。普通测试使用 `main_stub.go` 的 `//go:build !cshared`,确保 `go test ./...` 不要求 CGO。 + +`cliproxy_plugin_init` 必须严格按当前 CPA C 声明填充: + +```text +abi_version +call +free_buffer +shutdown (optional but cpa-ext 必须实现) +``` + +硬规则: + +- `plugin == nil` 时返回非零; +- 每次 call 开始先把 response ptr/len 初始化为零; +- 请求字节若要离开当前函数必须先复制到 Go-owned memory; +- 返回值使用 C allocator 分配,且只由插件自己的 `free_buffer` 释放; +- host callback 返回的 buffer 必须由 host 的 free function 释放; +- 不向 C 返回 Go heap pointer; +- 不在 ABI 层使用 `os.Exit`、`log.Fatal` 或故意 panic; +- recovered panic 转换成脱敏 error envelope;宿主自身也会 fuse panic 插件,但插件仍应保护 Core 边界。 + +入口只做:byte copy、envelope marshalling、App 指针读取、C buffer ownership。业务逻辑进入普通 Go dispatcher/Core。 + +## 5. RPC envelope 与本地 DTO + +所有 RPC 使用: + +```json +{ + "ok": true, + "result": {} +} +``` + +或: + +```json +{ + "ok": false, + "error": { + "code": "stable_code", + "message": "safe message", + "http_status": 400, + "retryable": false + } +} +``` + +未知方法返回 `unknown_method`,不能 panic 或返回裸字符串。 + +支持多个 CPA release 时可以维护插件自己的 wire DTO,避免编译期强耦合宿主 module;但每个字段名、JSON tag、零值和新增字段都必须逐项对照当前 `pluginapi/types.go` 和 `rpc_schema.go`。CPA capability payload 多数嵌入无 JSON tag 的 exported struct,wire 字段通常是 PascalCase;生命周期与 registration wrapper 使用明确 snake_case,不能统一猜测命名规则。 + +## 6. 注册与热重配 + +### 6.1 `plugin.register` + +宿主传入: + +- `config_yaml`; +- host 支持的 `schema_version`。 + +插件执行: + +1. 解析 lifecycle request; +2. 协商 `min(host_schema, plugin_max_schema)`; +3. 严格解析和校验配置; +4. 初始化或取得 Core Runtime; +5. 返回 metadata、协商 schema 和已实现 capability; +6. metadata 的 Name、Version、Author、GitHubRepository 必须非空且稳定。 + +### 6.2 `plugin.reconfigure` + +`plugin.reconfigure` 返回的 metadata/capability shape 必须与 register 一致。配置更新流程: + +```text +parse → validate → build immutable candidate → open/check dependencies + → atomic swap Runtime pointer → drain old Runtime +``` + +首次 `plugin.register` 配置无效时返回错误,不进入 active capabilities。**已经注册后的 `plugin.reconfigure` 不能直接返回错误**:当前 CPA 会因此把该插件从本轮 active records 移除、清除 exclusive provider,可能恢复原生认证。安全做法是保留最后一个有效 Runtime,记录 `reconfigure_rejected` 诊断,并仍以 success envelope 返回上一次有效 metadata/schema/capability shape。只有先修改 CPA 使 reconfigure 失败保留旧 active record 后,插件才可以对无效热配置返回 error。 + +配置分两类: + +- 热配置:UI 选项、限流阈值、未绑定策略、统计刷新参数; +- 冷配置:database path、Key HMAC secret 来源、结算币种、插件 identity。 + +冷配置变化默认以上述 LKG 方式拒绝热切换并在诊断/API 提示重启;除非实现了完整的双 Runtime 打开、迁移和原子切换。当前 CPA 替换二进制时会把旧 native 实例放入 retired 集合直到宿主整体 shutdown,不能假定热替换时旧实例已排空。 + +### 6.3 建议配置 + +```yaml +plugins: + enabled: true + dir: plugins + configs: + cpa-ext: + enabled: true + priority: 100 + codex_only: true + data_dir: data/cpa-ext + database_file: cpa-ext.db + settlement_currency: USD + frontend_auth_exclusive: true + unbound_key_policy: deny + unpriced_usage_policy: deny_new_requests +``` + +实例 Key 校验 secret 使用环境变量、secret file 或操作系统 secret store,不直接写进该 YAML,也不放进管理 metadata。 + +### 6.4 Schema 协商也是性能契约 + +生命周期 DTO 必须同时读取 `config_yaml` 和宿主传入的 `schema_version`,注册结果使用: + +```text +negotiated_schema = min(host_schema, plugin_max_schema) +``` + +不能把 schema 固定成编译期常量后忽略宿主版本。当前 `cpa-plugin-key-billing v0.3.1` 正是一个反例: + +- `internal/plugin/types.go` 固定 `SchemaVersion = 2`; +- 它自己的 `LifecycleRequest` 只定义 `config_yaml`,没有读取 host schema; +- `registration()` 每次都返回固定 schema 2; +- README 同时需要兼容最低 CPA `7.2.103`,而其计费闭环使用 schema 2 已具备的 request lifecycle/terminate 能力。 + +因此可以确认“固定 2 是旧契约/兼容实现且尚未适配新性能契约”;无法仅凭源码断言作者的主观动机。当前 CPA `v7.2.130` 的 schema 3 专门解决一项流式放大:payload chunk 不再重复携带 `OriginalRequest` 和 `RequestBody`,只在 `ChunkIndex=-1` 的 header-init 发送一次。由于 Go JSON 会把 `[]byte` 编成 base64,schema 2 在长 Prompt 下会把复制量和编码量放大到“请求体大小 × chunk 数”。 + +本项目规则: + +- cpa-ext 的生产流式计费最低要求 RPC schema 3;宿主低于 3 时拒绝启用流式计费,不静默回退到高开销 schema 2; +- register 与 reconfigure 都返回实际协商版本,并用真机测试确认宿主记录的 schema; +- schema 3 只解决 `response.intercept_stream_chunk` 的重复请求体,**不解决全部流式开销**; +- 当前 `response.normalize_before` 在每个上游流式帧仍携带 `OriginalRequest`、`TranslatedRequest` 和 `Body`;stream interceptor 还会携带最多 64 个、合计 1 MiB 的 `HistoryChunks`; +- 插件本地 DTO 即使忽略这些字段,宿主侧 JSON/base64 编码、内存复制和 C ABI 调用已经发生,不能把“没有读取字段”当成零成本。 + +所以 schema 3 是必须的立即缓解,不是完整解决。完整解决需要缩小宿主 callback DTO 或消除逐 chunk callback,见 8.4 和 13.4。 + +## 7. Runtime 与模块装配 + +只保留一个有明确所有权的全局 App 入口: + +```text +atomic App/Runtime pointer + ├─ Core facade + ├─ CPA adapters + ├─ repositories + ├─ callback-driven maintenance + ├─ optional sidecar client + └─ immutable config/catalog snapshots +``` + +建议目录: + +```text +cmd/cpa-ext/ + main.go # cshared ABI + main_stub.go +internal/plugin/ + dispatcher.go + registration.go + envelope.go + wire_*.go +internal/cpaadapter/ + frontend_auth.go + interceptor.go + lifecycle.go + responses.go + usage.go + scheduler.go + management.go +internal/core/ +internal/domain/ +internal/repository/ +internal/statistics/ +web/ # React/Vite source +internal/webui/ # go:embed built assets +``` + +`internal/plugin` 只理解 RPC,`internal/cpaadapter` 只把 CPA DTO 转成内部 command/observation,`internal/core` 不 import `C` 或 CPA wire DTO。 + +### 7.1 P0:双 Go runtime 可行性门禁 + +`cpa-plugin-key-billing/internal/billing/store.go` 记录过实际事故:Go c-shared 插件在 Go 宿主进程中运行自己的 timer/GC/preemption 后,整个 CPA 因 `fatal error: bad flushGen` 崩溃,因此该插件刻意不保留 goroutine。当前 CPA 在 config disable 或二进制热替换时也不会立刻 shutdown 旧 native 实例,旧 goroutine 可能长期存活。 + +所以在实现业务前必须完成 runtime spike/soak,不能先把“动态库内 SQLite + 多个长期 worker”当成已成立: + +1. 最小 c-shared 插件零长期 goroutine,验证 ABI、并发 callback、GC 压力和反复 register/reconfigure; +2. 加入选定 SQLite driver 的同步短事务,确认 driver/`database/sql` 是否暗启长期 goroutine/timer; +3. 分别测试 callback-driven 维护、一个受控 worker、完整 worker 集合; +4. 覆盖 config disable、无效 reconfigure、binary replacement、plugin fuse、CPA shutdown; +5. 在目标 Windows DLL 与 Linux/WSL `.so` 上执行并发压力和至少 24 小时 soak,任何宿主 crash、旧实例活动或不可解释 goroutine 增长都判失败。 + +门禁通过前的 MVP 采用 callback/management-request 驱动:事实同步短事务落库,聚合小批追赶、清理和 checkpoint 只在安全宿主调用中有预算地执行;备份和长任务交给外部 sidecar/运维命令。门禁失败时长期聚合、归档、备份、provider refresh 必须永久移到 sidecar,动态库只保留认证/准入/采集/结算的薄同步路径。 + +## 8. CPA 生命周期映射 + +### 8.1 Frontend auth + +`frontend_auth.authenticate` 收到 method/path/headers/query/body。适配器应: + +- 在读取/验证 Key 前应用 [access-routing.md](access-routing.md) 的 method + path allowlist,未知入口默认 `Authenticated=false`; +- 只读取允许的认证 Header; +- 限制 body/headers 的处理量; +- 不记录完整请求或 Key; +- 调用 Core credential authentication; +- 返回稳定 Credential ID 作为 Principal; +- 失败返回 `Authenticated=false`,不泄露“Key 是否存在”等枚举信息。 + +生产打开 exclusive 时,当前 CPA 会在多个 exclusive provider 中选择最高 priority,priority 相同按 plugin ID 决定。必须在真实宿主中确认 `cpa-ext` 是唯一生效的认证路径。 + +当前 CPA frontend-auth contract 不能携带插件自定义认证错误。`internal/pluginhost/adapters_auth.go` 会将插件 RPC 错误或 `Authenticated=false` 都转换为 access manager 的 `NotHandled`;如果所有生效 provider 都是 `NotHandled`,manager 返回宿主 `401 no_credentials`。由此得到三条实现约束: + +- 第一版不要在 API 文档中承诺 `invalid_credential`、disabled/expired 的独立错误码; +- credential lookup 数据库故障仍然 fail closed,但外部也会看到 `401 no_credentials`,内部必须保留可告警的诊断分类; +- 如需向客户端准确返回认证服务 `503`,必须升级 CPA frontend-auth wire response/adapter,并更新兼容性矩阵。 + +Core readiness(数据库、HMAC keyring、账本、当前价格政策)不健康时,frontend auth 也返回 `Authenticated=false`,作为 interceptor 之前的第二道 fail-closed;外部仍只看到 401,真实原因进入诊断。 + +当前宿主在调用 frontend auth 前会 `ReadAll` 完整请求 Body 并将 headers/query/body 复制进 RPC,即使插件只检查 Header 也无法在内存分配前拒绝;Management handler 同样先被宿主完整读取。反向代理/CPA HTTP 层必须配置全局请求体上限,frontend-auth 对 Codex 大 Prompt 还要做容量基准。长期应推动 CPA 提供 header-only auth DTO 和宿主级 `MaxBytesReader`;插件自己的长度校验只减少后续解析,不能宣称保护了宿主内存。 + +更关键的是:exclusive 只在插件 active 时存在。当前 CPA 没有 `required plugin` 或“零认证 provider 默认拒绝”的配置;插件缺失/加载失败/被 fuse 时会清除 exclusive,恢复其他 provider,甚至在零 provider 时走 legacy 放行。生产部署必须同时满足: + +1. CPA 配置一个不分发给用户的 256-bit 以上随机 native sentinel key,确保插件缺失时不是零 provider; +2. `cpa-ext` 注册 management readiness 路径,报告数据库、HMAC keyring、价格、exclusive/capability 自检结果; +3. 外部启动器/反向代理在 readiness 成功前不开放用户端口,并在插件丢失/fuse 后撤流量; +4. 最终推动 CPA 增加 required-plugin + authentication-default-deny;在此之前发布说明必须标注这是部署安全前提,而非动态库自身能力。 + +sentinel 只由部署运维保管,不能交给用户、写进前端或作为日常调用 Key;否则它本身就是计费旁路。 + +### 8.2 Request interceptors + +- before-upstream-auth(CPA 方法名 before-auth):同步耐久化 Request pending、金额准入、占用并发; +- after-auth:每次上游尝试记录 selected auth/model/format; +- metadata 视为只读 JSON-like snapshot; +- interceptor response 不能任意给后续阶段增加 metadata; +- nested `plugin_host_model_callback` 必须识别,避免重复计费。 + +当前 CPA 对 interceptor RPC error 或 host-boundary panic 的处理是记录后继续请求。所有预期拒绝以及 DB、账本、价格故障必须返回正常 RPC success envelope,结果为 `Terminate=true`、合法状态码和脱敏错误体;不得 `return error` 表示拒绝。dispatcher 在方法边界 recover,并把 interceptor panic 转为上述 `503` termination。若 panic 已越过插件边界并导致宿主 fuse,当前请求无法由纯插件保证 fail closed,因此必须依赖上一节的部署门禁。 + +### 8.3 Scheduler + +`scheduler.pick` 只能从宿主提供的 candidate 列表选择 AuthID,或明确 delegate 内置 scheduler。候选已经经过 provider/model/disabled/cooldown/tried 等过滤,插件不能选列表之外的 Auth。 + +宿主把 `Handled=false`、空响应、unknown AuthID 和 scheduler host-boundary panic 当作 unhandled,然后回退内置选择。strict 绑定的拒绝必须由插件内部 recover 后返回 scheduler RPC error;after-auth interceptor 在真正执行前还要复核 selected AuthID。不应承诺 scheduler 自定义 HTTP error body:当前契约通常只能向调用方形成 generic 5xx。 + +当前宿主只会让最高优先级的有效 scheduler 策略实际主导选择,因此所有 Key binding、strict/preferred/pool 和 fallback 规则应在 `cpa-ext` 一个 scheduler 内组合。CPA `home.enabled` 会跳过 plugin scheduler,并使 plugin Management/resource routes 返回 404;cpa-ext 第一版整体不支持 Home,生产启动检查必须拒绝该模式。 + +### 8.4 Response 与 Usage + +- 翻译前 response 用来解析 provider 权威 Usage; +- 非流式/流式 response interceptor 用 RequestID 绑定 response ID; +- schema 3 stream payload chunk 不含重复 request body,关联数据在 header-init/请求阶段缓存; +- `usage.handle` 当前没有 RequestID,不能单独作为逐请求扣费提交点; +- 多来源观察保留 provenance 和 quality,不一致时不静默覆盖; +- callback 可能并发、重复、乱序或迟到。 + +为什么参考插件会同时使用两个同步 response hook: + +- 翻译前 hook 能看到 provider 权威 Usage,但当前没有 RequestID; +- 翻译后的 response/stream hook 有 RequestID,可以用 response ID 完成归属; +- Codex WebSocket 同协议透传时可能不经过翻译前 hook,`cpa-plugin-key-billing v0.3.1` 因而又从下游 chunk 读取 response ID/Usage。 + +这个关联思路可以借鉴,但 capability 组合不能原样复制。当前两个 hook 都在下游交付关键路径同步执行:每个 frame/chunk 要经过宿主结构复制、JSON/base64、C ABI、插件 JSON 解码和返回 envelope。快速判断“这个事件没 Usage”只能节省插件内部解析,无法消除前面的传输成本。 + +采集方案按以下优先级选择: + +1. 最优:扩展 CPA `UsageRecord`,直接提供 RequestID、Execution/AttemptID、ResponseID、Usage revision/source;插件不注册逐 chunk hook; +2. 次优:新增只读 usage observer/final-frame callback,只发送 response ID、RequestID 和 Usage,不携带 Prompt、HistoryChunks 或可修改响应的能力; +3. 过渡:必须使用双 hook 时协商 schema 3,在 header-init 缓存最小关联,并推动 response-before 增加同类“payload frame 省略 request bodies”契约以及 stream capability 的 `needs_history=false`; +4. 禁止:schema 2 + 双 hook 作为生产默认,或通过时间/AuthID 猜配 Usage 来换取速度。 + +在宿主契约尚未补齐时,是否保留 stream hook 由 13.4 的性能/正确性联合门禁决定,不能只因功能测试能扣到钱就发布。 + +每个 Execution 维护 canonical cumulative usage vector、source rank、response ID 和 hash。只有事务内 CAS 确认 vector 改变时才增加本地 `usage_revision`;相同 callback 重放不产生新 revision,多来源不能相加。response hook 取得新的可靠 canonical Usage 后,必须在返回 CPA 前同步写 SQLite observation/inbox;至少最终 Usage 不能只进入内存 channel。 + +若上游已经产生 Usage 后 SQLite 持久化失败,纯插件无法撤销该上游成本。adapter 记录高严重度诊断、把 Core readiness 置为 unhealthy,并让后续 frontend auth/interceptor 拒绝新请求;不得返回一个会被宿主忽略的 response-hook RPC error并假装已经可靠落库。该故障是管理员对账中的 `persistence_gap`,恢复后需要显式核对,不能猜费。 + +### 8.5 Request completion + +当前 CPA 定义:succeeded、failed、rejected、canceled。completion 包含 RequestID、TraceID、模型、时间、状态和 metadata,但不包含 Token。 + +宿主以异步、单次且无 durable retry 的方式通知 lifecycle plugin,业务不能依赖调用方 context 仍然存活。适配器应快速完成持久化命令或投递到**已经持久化的 inbox**;不能只放进可能丢失的内存 channel。completion 只负责终态、释放并发和触发结算,不是 Request/Usage 的唯一落库点。启动恢复必须扫描 pending、已保存 Usage 但未结算的 Execution 和未发布 projection/outbox。 + +取消处理按 `billing.md`/`core.md`:释放并发与完成金额核算分离;有 Usage 结算,无 Usage 进入 awaiting,迟到 Usage 追加补记。 + +## 9. Management API 与用户页面限制 + +CPA 当前提供两类 plugin route: + +### 9.1 Management routes + +- 挂载于 `/v0/management/...`; +- 由 CPA Management Key 认证; +- 支持声明的精确 HTTP method/path; +- 用于管理员 CRUD、账本、价格、路由、统计和诊断; +- handler 必须在 DTO 层拒绝过大 body、错误 content type,并限制分页和响应大小;但宿主已先完整读取 body,真正的内存上限必须设置在反向代理/CPA HTTP 层。 + +### 9.2 Resource routes + +- 挂载于 `/v0/resource/plugins//...`; +- 当前只允许 browser GET resource; +- 不经过 CPA Management Key; +- 只能声明精确路径,不支持 `:param`、`*`、`..`; +- 适合静态 HTML/JS/CSS shell,但天然不是安全的管理员 API。 + +因此: + +- 静态 shell 可以公开; +- 管理员数据只从 Management routes 取得; +- 不能把 CPA Management Key 写入 HTML、JS、URL、localStorage 或插件 session; +- 用户自助页不能直接调用管理员接口。 + +MVP 用户只读数据可以注册少量精确 resource GET JSON 路径,并由插件 handler 自行校验 `Authorization: Bearer `,只返回该 Credential 的金额投影。Key 只保存在页面内存,不放 URL、cookie 日志或 localStorage,刷新后重新输入。若需要 HttpOnly session、POST 操作、稳定登录和完整 CSRF 模型,应增加经过明确设计的外部 sidecar/public API,或推动 CPA 增加 authenticated user plugin routes;不能假装当前 Management capability 已经提供这类接口。 + +Home 模式下 Management 与 resource route 都不可用;不是只有 scheduler 失效。第一版 readiness 遇到 `home.enabled` 必须失败。 + +### 9.3 Adapter 数据最小化与脱敏 + +CPA wire payload 的秘密面比业务契约大。每个 adapter 在进入 Core 前执行 allowlist/drop: + +| 入口 | 可能含秘密 | adapter 规则 | +| --- | --- | --- | +| frontend auth | Authorization、query、完整 Prompt Body | 只提取允许 Header 和 method/path;验证后丢弃原值,禁止日志/持久化 | +| scheduler | `Options.Headers` 当前未保证已脱敏 | 路由只读取 host metadata 中 caller_scope;headers 默认全部丢弃 | +| Management | CPA Management Key、Cookie、完整 body | 进入 Core 前删除 Authorization/Cookie;body 严格 DTO 解码、字段/大小校验 | +| compatibility usage | `UsageRecord.APIKey` 可能是原始 CPA Key | 在 adapter 内立即映射/HMAC,之后只传 credential/scope;原值不得落库 | +| failure/response hooks | 错误 body、request/response 正文 | 只提取标准错误类别、response ID、Usage;原始正文默认丢弃 | + +日志 API 默认只接收 stable IDs、preview、长度和分类,不接受通用 wire DTO。任何调试开关也不得输出 Authorization、Management Key、OAuth/provider token、Prompt 或完整响应。 + +## 10. 后台任务 + +只有通过 7.1 的目标平台 soak gate 后,插件才允许启用有界后台 worker;否则以下任务由 callback-driven runner 或 sidecar 承担: + +- statistics aggregation; +- awaiting usage reconciliation; +- archive/cleanup; +- backup; +- catalog/quota refresh。 + +规则: + +- 每个 worker 接收 Runtime context; +- queue 有界且可观测,关键事实先落 SQLite; +- 不持锁执行 host callback、网络或慢 SQL; +- 单个可选 worker 失败不杀死 CPA; +- migration/账本等核心失败进入 fail-closed health; +- reconfigure 不得重复启动同一 worker; +- shutdown cancel 后有界等待,超时记录错误并继续释放资源。 + +CPA config disable/热替换不会立即调用旧 native shutdown,因此“worker 属于 Runtime”仍不足以保证停止;启用 worker 的版本还必须有 host generation/lease,使失去 active 身份的旧 Runtime 在下一次 lease 检查时自行停机。纯插件拿不到可靠 active lease 时,不得启用长期 worker。 + +## 11. Shutdown + +当前 CPA revision 只会通过 native function table 的 `shutdown` 真正通知关闭;虽然 ABI 常量中定义了 `plugin.shutdown`,宿主没有调用点,不能依赖它。native shutdown 必须进入一个幂等 `sync.Once`/状态机: + +1. 拒绝新管理写入与新准入; +2. cancel worker context; +3. 有界等待 worker; +4. 提交已在事务中的必要账本写入; +5. checkpoint/关闭 SQLite reader 和 writer; +6. 清除 host callback context 引用; +7. 重复 shutdown 返回成功。 + +不能无限等待外部网络、不能调用 `os.Exit`、不能在动态库卸载后留下仍访问插件状态的 goroutine。 + +不能依赖 shutdown 处理 config disable 或 binary hot replacement:当前 CPA 可能只摘除 capability/retire 旧库,直至整个 plugin host 关闭才调用旧实例 shutdown。生产更新动态库采用排空并重启 CPA,不支持带长期资源的原地热替换。 + +## 12. 构建、发现与安装 + +Go c-shared 需要目标平台 C toolchain。仅设置 `GOOS/GOARCH` 通常不足以交叉编译 CGO,应在目标系统或可靠对应 toolchain 中分别构建。 + +Linux/WSL: + +```bash +gofmt -w cmd internal +go test ./... +go test -race ./... +CGO_ENABLED=1 go build -tags cshared -buildmode=c-shared \ + -o bin/cpa-ext.so ./cmd/cpa-ext +file bin/cpa-ext.so +nm -D bin/cpa-ext.so | grep cliproxy_plugin_init +``` + +WSL 产出的 `.so` 只能给 Linux/WSL CPA 使用,不能放进 Windows CPA。 + +Windows: + +```powershell +$env:CGO_ENABLED='1' +go build -tags cshared -buildmode=c-shared -o bin/cpa-ext.dll ./cmd/cpa-ext +# 使用 Visual Studio dumpbin /exports 或 llvm-nm 验证 cliproxy_plugin_init +``` + +CPA discovery 支持: + +```text +plugins///cpa-ext.so +plugins/cpa-ext.so +plugins/cpa-ext-v.so +``` + +Windows 使用 `.dll`,macOS 使用 `.dylib`。插件 ID 必须与配置 key、文件名 ID 和 management/resource path 一致。版本化文件名不带前导 `v` 的版本字段部分,例如 `cpa-ext-v0.1.0.so`。 + +发布按 OS/architecture 分包,包含 checksum、插件版本、目标 CPA revision/minimum version、ABI/RPC schema、固定构建工具链和 migration 说明;不包含数据库、secret、auth 文件或生成的 `.h`(除非确有消费者)。版本化文件名、Metadata.Version 和 release metadata 必须一致。第一版升级要求排空并重启 CPA,不宣传 live binary hot reload。 + +## 13. 测试顺序 + +### 13.1 普通 Go 测试 + +- RPC envelope、未知方法、畸形 JSON; +- register/reconfigure 一致性;已注册后的无效 reconfigure 返回 LKG registration、保留旧 Runtime并记录诊断; +- Core 领域/用例; +- SQLite transaction、migration、幂等、恢复; +- 并发认证、准入、终态、迟到 Usage; +- 各 capability dispatcher; +- shutdown/reconfigure race。 +- adapter redaction:Authorization/Management Key/Prompt/UsageRecord.APIKey 不进入 Core、日志或数据库。 + +### 13.2 动态库测试 + +- 实际 c-shared build; +- `cliproxy_plugin_init` export; +- 文件名/目录 discovery; +- ABI 不匹配拒绝; +- response buffer 能被正确释放; +- shutdown 可重复。 +- 零常驻 goroutine基线、SQLite driver goroutine/timer 清单和双 runtime 压力/soak。 + +### 13.3 CPA 真机端到端 + +按 capability 逐项验证: + +1. 宿主日志显示 plugin registered、schema 和 capability 正确; +2. reconfigure 保留数据库且不重复 worker; +3. 自管 Key 成功,未知/禁用 Key 失败,原生 Key 无法绕过 exclusive; +4. 非流式与流式 Codex 请求; +5. strict/preferred/pool Auth 路由; +6. 成功、上游失败、本地拒绝、客户端取消; +7. 取消前有 Usage 扣费、无 Usage 标 unmeasured、迟到 Usage 补记; +8. 重试和重复 callback 不重复扣; +9. 额度耗尽返回 429; +10. 管理路由受 Management Key 保护,resource 不泄密; +11. 重启后余额、账本、pending recovery 和统计 checkpoint 一致。 +12. 写入畸形热配置后 exclusive/LKG 仍 active;插件缺失、fuse 和 Home 模式被 gateway/readiness 阻断; +13. 枚举全部 authenticated endpoint,allowlist 外入口拒绝; +14. DB error、interceptor/scheduler panic/invalid response 故障注入不形成可控边界内的免费请求或串号路由; +15. 大 Prompt/Management body 的代理上限和 frontend-auth 复制成本达到容量目标; +16. `A & B ` Management JSON 往返和 CSV 公式注入防护正确。 + +### 13.4 流式性能回归门禁 + +基准必须在**同一个 CPA、同一个 OAuth 账户、同一模型、同一 effective service tier、同一 transport**下做 A/B,避免把账号、Fast 或网络差异误判成插件开销。固定四组: + +| 组别 | 目的 | +| --- | --- | +| 不加载任何目标插件 | CPA/OAuth 基线 | +| schema 3 空 hook 插件 | 测量宿主 callback/ABI 固有成本 | +| 仅采集与关联 | 测量 response parsing、锁和持久化成本 | +| 完整 cpa-ext | 最终性能与计费正确性 | + +覆盖: + +- SSE 与 Codex WebSocket; +- 1 KiB、128 KiB、1 MiB Prompt; +- 短输出以及 32、256、1024 chunks; +- 并发 1、8、32; +- standard 与 priority/Fast; +- 成功、上游失败和客户端取消。 + +采集指标:TTFT P50/P95、总时长、tokens/s 或 chunks/s、chunk 间隔、CPU、alloc/GC、插件 RPC 次数/总字节/最大 payload、锁等待,以及最终 canonical Usage/金额。 + +结构性断言: + +- schema 3 payload chunk 的 `OriginalRequest`/`RequestBody` 为空,完整请求只允许出现在 header-init; +- 若仍启用 response-before,必须单独统计它重复携带请求体的字节数,不能混入“schema 3 已优化”的结论; +- `HistoryChunks` 未被业务使用时不得长期作为生产 payload;当前宿主无法关闭时,必须以基准证明成本可接受或先修改宿主; +- 单个普通 chunk 的目标大小为 `O(chunk)`,不得随 Prompt 或累计历史线性增长; +- 优化前后响应字节、Usage、金额和取消结算结果一致。 + +默认发布阈值:常规负载吞吐下降不超过 5%,压力负载不超过 10%,P95 TTFT 增量不超过 `max(20ms, 5%)`。任一结构性断言失败,或正确性依赖猜配,均直接判定不通过,百分比阈值不能豁免。 + +## 14. 源码导航 + +CPA 权威源码: + +| 问题 | 路径 | +| --- | --- | +| ABI/schema/method 常量 | `CLIProxyAPI/sdk/pluginabi/types.go` | +| capability DTO | `CLIProxyAPI/sdk/pluginapi/types.go` | +| registration wire schema | `CLIProxyAPI/internal/pluginhost/rpc_schema.go` | +| schema negotiation/RPC adapter | `internal/pluginhost/rpc_client.go` | +| 加载、配置和 lifecycle | `internal/pluginhost/host.go`、`config.go` | +| 动态库命名/发现 | `internal/pluginhost/platform.go` | +| C loading/ownership | `internal/pluginhost/loader_windows.go`、`loader_unix.go` | +| frontend auth/exclusive | `internal/pluginhost/adapters_auth.go` | +| scheduler | `internal/pluginhost/scheduler.go` | +| intercept/lifecycle adapters | `internal/pluginhost/adapters_interceptors.go` | +| usage/response adapters | `internal/pluginhost/adapters_usage_translation.go` | +| management/resource routes | `internal/pluginhost/management.go` | +| Principal → caller_scope | `CLIProxyAPI/sdk/api/handlers/handlers.go`、`sdk/cliproxy/session/identity.go` | + +优先官方 examples: + +- `examples/plugin/frontend-auth-exclusive/`; +- `examples/plugin/request-lifecycle/`; +- `examples/plugin/scheduler/`; +- `examples/plugin/usage/`; +- `examples/plugin/management-api/`; +- `examples/plugin/codex-service-tier/`。 + +生产结构参考 `cpa-plugin-key-billing/cmd/cpa-key-billing/main.go` 和 `internal/plugin/`,但协议冲突时永远以目标 CPA 源码和同 revision 官方测试为准。 + +## 15. 验收标准 + +- C ABI 入口保持薄且内存所有权正确; +- register/reconfigure 协商不高于宿主 schema,并返回相同 capability shape; +- 只声明已实现能力,所有隐含方法均有测试; +- Core、领域和 Repository 可以在无 CGO 情况下测试; +- callback 并发、重复、乱序、迟到不会造成状态泄漏或重复账单; +- production exclusive auth 已通过真实 CPA 验证,不存在认证旁路; +- 全部 CPA 已认证 endpoint 已枚举,MVP allowlist 外入口用用户 Key 一律失败; +- 插件缺失、加载失败、host fuse 时 sentinel + readiness gate 能阻止用户流量; +- interceptor/scheduler 的 error、invalid response 和 panic 已验证不会在插件可控边界内放行; +- 部署检查拒绝 `home.enabled`,因为 scheduler 与全部插件 UI/API 均不可用; +- Management API、resource 和用户只读路径权限分离; +- build 产物导出正确 init symbol,并能被目标 CPA revision 加载; +- shutdown/reconfigure 不泄漏 worker、数据库连接或 host callback context; +- Windows/Linux 目标工具链下双 Go runtime/SQLite spike 与 24h soak 通过,或所有长期任务已移入 sidecar; +- 发布说明准确报告 CPA revision、ABI、schema、capability、构建命令、产物和未执行检查。 diff --git a/docs/modules/operations.md b/docs/modules/operations.md new file mode 100644 index 0000000..56d7693 --- /dev/null +++ b/docs/modules/operations.md @@ -0,0 +1,454 @@ +# 部署与运维模块 + +## 1. 定位 + +本模块规定 cpa-ext 如何构建、安装、启动、观察、备份、升级和恢复。它把 [dev.md](dev.md) 的 native runtime 约束、[security.md](security.md) 的 fail-closed 前提和 [persistence.md](persistence.md) 的数据库规则转成可执行运维流程。 + +当前仓库仍是只声明 `usage_plugin` 的基础骨架,不具备本文所述完整生产能力。只有 [test-plan.md](test-plan.md) 的相应发布门禁通过后,才能把某个版本标记为 production-ready。 + +## 2. 支持基线 + +设计验证基线: + +| 项目 | 目标 | +| --- | --- | +| CLIProxyAPI | `v7.2.130` / `f43aad7637ad813745bf7d341acb5663617570c5` | +| Native ABI | `1` | +| RPC schema | `3` | +| cpa-ext plugin ID | `cpa-ext` | +| 当前 module Go | `1.24` | +| CPA module Go | `1.26.0` | +| Home mode | 不支持,必须关闭 | +| CPA build | 必须是 CGO/plugin-capable,不能使用 `no-plugin` 产物 | + +生产兼容性按“精确 CPA tag + OS + arch + cpa-ext artifact”发布,不只写“ABI 1”。每次升级 CPA 都要重新跑 ABI、E2E、安全、性能和 soak 门禁。 + +目标发布矩阵: + +- Linux amd64/arm64 `.so`; +- Windows amd64 `.dll`; +- macOS amd64/arm64 `.dylib`(若实际维护); +- WSL 构建的 `.so` 只供运行在 WSL/Linux 的 CPA,不能装入 Windows CPA。 + +Go c-shared 需要目标平台 C toolchain,不能只设置 `GOOS/GOARCH` 假装完成 CGO 交叉编译。 + +## 3. 运行拓扑 + +### 3.1 默认 MVP + +```text +reverse proxy / readiness gate + │ + ▼ +CLIProxyAPI + cpa-ext.so + │ + ├─ synchronous short SQLite transactions + ├─ callback-driven bounded maintenance + └─ no long-lived plugin worker +``` + +第一版默认 callback-driven:认证、准入、事实和账本在回调内完成必要短事务;聚合追赶、清理和 checkpoint 只在安全宿主调用中按时间/页数预算推进。 + +### 3.2 Sidecar 拓扑 + +P0 runtime/24h soak 失败,或需要长期 quota refresh、备份、归档、大导出时: + +```text +CLIProxyAPI + thin cpa-ext plugin ── SQLite/IPC ── cpa-ext sidecar +``` + +sidecar 只承担长期任务/公共 API,不得形成第二套余额或价格权威。SQLite 单 writer、IPC 认证、文件锁和 crash ownership 必须另行验证。 + +### 3.3 Worker 拓扑 + +只有目标 CPA/Go/toolchain/SQLite driver 的双 runtime 24h soak 通过,才允许动态库启用有界 worker。发布元数据必须标出 topology;不能在补丁版本中静默从 callback-driven 改为 worker。 + +## 4. 文件与权限布局 + +建议 Linux 布局: + +```text +/ +├─ cliproxy +├─ config.yaml +├─ plugins/ +│ └─ linux/amd64/cpa-ext-v0.1.0.so +└─ data/cpa-ext/ + ├─ cpa-ext.db + ├─ cpa-ext.db-wal + ├─ cpa-ext.db-shm + ├─ backups/ + └─ diagnostics/ + +/etc/cpa-ext/ +└─ hmac-keyring.json # 或 OS secret store,绝不放进 data/backups +``` + +要求: + +- CPA 运行用户拥有 plugin/data,其他用户默认不可读; +- keyring `0600`、目录 `0700`; +- DB/backup `0600`、目录 `0700`; +- plugin binary 只由发布/运维用户写,运行用户只读; +- 数据目录不能位于临时目录、网络共享或会被自动清理的位置; +- 不把 SQLite、WAL、keyring、Auth、日志放进发布归档; +- Windows 使用等价 ACL,不能只依赖扩展名。 + +## 5. 目标配置 + +```yaml +remote-management: + allow-remote: false + secret-key: "" + +# 仅保留一个运维离线 sentinel;绝不分发给用户。 +api-keys: + - "" + +plugins: + enabled: true + dir: plugins + configs: + cpa-ext: + enabled: true + priority: 100 + store: + version: "0.1.0" + codex_only: true + data_dir: data/cpa-ext + database_file: cpa-ext.db + settlement_currency: USD + frontend_auth_exclusive: true + unbound_key_policy: deny + unpriced_usage_policy: deny_new_requests + maintenance_mode: callback +``` + +说明: + +- 上述是完整目标配置,当前基础插件只解析 `codex_only`; +- CPA `Home` 是由 `-home-jwt` 注入的 runtime-only 状态,YAML 中的 `home:` 会被当前 parser 忽略;生产启动命令不得使用 `-home-jwt`,并由 readiness 检查实际 runtime 状态; +- `enabled`、`priority` 是 CPA host-owned 字段,也会出现在 `config_yaml`; +- `store.version` 用于选择版本化动态库; +- `database_file` 相对 `data_dir` 解析,不能允许 `..` 逃逸; +- `settlement_currency` 第一版只能是 USD; +- HMAC keyring 通过受限 secret-file/OS store locator 注入,不把 key 值放进 YAML; +- 生产不允许 observe-only、unbound allow 或 unknown price=0; +- 所有可互相绑定的 Codex Auth 应使用相同 CPA priority。 + +热配置:UI 展示、查询限制、非安全统计刷新参数、明确允许的业务阈值。冷配置:data/database path、keyring source、currency、plugin identity、runtime topology。冷配置变化由 LKG reconfigure 拒绝并提示排空重启。 + +## 6. 本地开发构建 + +### 6.1 WSL/Linux + +```bash +cd /mnt/d/agent/cpa-plugin +./scripts/check-env.sh +go test ./... +go test -race ./... +./scripts/build.sh +file bin/cpa-ext.so +nm -D bin/cpa-ext.so | grep cliproxy_plugin_init +``` + +`build.sh` 会执行 gofmt、`go mod tidy`、单元测试和 c-shared 构建。正式 CI 应把“依赖文件是否被意外修改”作为检查,不能让 tidy 的变化无人审阅。 + +### 6.2 PowerShell 调用 WSL + +```powershell +./scripts/build.ps1 +wsl file /mnt/d/agent/cpa-plugin/bin/cpa-ext.so +wsl nm -D /mnt/d/agent/cpa-plugin/bin/cpa-ext.so +``` + +当前产物是 Linux `.so`。Windows `.dll` 必须在 Windows CGO toolchain 下单独构建和验证 export。 + +### 6.3 当前测试宿主 + +仓库已有: + +```bash +./scripts/run-test-host.sh +tail -f .runtime/cliproxy.log +./scripts/stop-test-host.sh +``` + +`.runtime/` 只用于本地测试,不是生产目录。`/healthz` 只证明 CPA 进程存活,不证明 cpa-ext active、价格可用或不存在认证旁路。 + +## 7. 发布物 + +每个 artifact 必须包含/伴随: + +- `cpa-ext` 动态库; +- version、git commit、build timestamp; +- target OS/arch、Go version、C toolchain; +- minimum/exact tested CPA version; +- ABI=1、max RPC schema=3、capability 清单; +- runtime topology; +- LICENSE、NOTICE/THIRD_PARTY; +- SHA-256 checksum,推荐额外签名/provenance/SBOM。 + +CPA plugin store zip 根目录中的动态库文件名使用: + +```text +cpa-ext.so +# 或 +cpa-ext-v0.1.0.so +``` + +安装目标可位于 `plugins///`。发布包不能包含生成 `.h`,除非消费者明确需要。 + +## 8. 安装前检查 + +1. 确认 CPA exact version、plugin-capable build 和平台架构; +2. 确认 CPA 未使用 `-home-jwt` 启动,实际 runtime `Home.Enabled=false`; +3. 备份 CPA config、SQLite 和匹配 keyring; +4. 校验 artifact checksum/signature; +5. 校验动态库 export `cliproxy_plugin_init`; +6. 检查 data/keyring 目录权限和磁盘余量; +7. 检查只有 sentinel 留在 CPA native `api-keys`,没有用户 Key; +8. 检查 management 仅 loopback/受信网关可达; +9. 检查所有可绑定 Codex Auth priority; +10. 准备 gateway drain 和 rollback artifact; +11. 首次生产部署必须先有已审阅 published price version; +12. 确认 P0 runtime/performance/soak gate 结论与所选 topology 一致。 + +## 9. 首次安装 + +1. 在排空环境停止 CPA; +2. 原子复制版本化 dynamic library 到目标目录; +3. 创建 data/keyring 目录和权限; +4. 写入/审阅配置,先不开放用户入口; +5. 启动 CPA,观察 plugin load/register/schema/capabilities; +6. 等待 migration、integrity 基础检查、keyring、price 和 recovery 完成; +7. 用 Management `/plugins` 确认 `cpa-ext` active 和版本; +8. 调用 cpa-ext `/readiness` 与最小 `/ready`; +9. 执行无效用户 Key 负向探针,确认不能访问上游; +10. 在隔离测试账户完成“签发 → 准入 → strict route → Usage → 金额账本”; +11. 对比无插件 OAuth 基线的 TTFT/吞吐; +12. gateway 才开始逐步放量。 + +任何一步失败都保持用户入口关闭。不能因为 CPA `/healthz` 为 200 就继续。 + +## 10. 启动顺序与 readiness + +插件内部: + +1. 解析 lifecycle config/schema; +2. 验证冷配置和路径; +3. 打开 SQLite、migration、quick integrity; +4. 加载 HMAC keyring; +5. 加载 active PriceVersion; +6. 恢复 pending/Usage/settlement/outbox; +7. 加载账户、Credential、route 和统计 Snapshot; +8. 装配 callback runner/sidecar; +9. 原子发布 Runtime; +10. 返回 registration。 + +Readiness component: + +| Component | 失败影响 | +| --- | --- | +| runtime/schema/capabilities | 不注册/不开放流量 | +| SQLite/migration/ledger | fail closed | +| HMAC keyring | fail closed | +| active pricing | fail closed | +| Credential/account directory | fail closed | +| statistics projection | 可降级,显示 lag | +| quota refresh | 可降级,显示 stale | +| UI resources | 不影响计费,但管理页不可用 | +| backup/archive runner | 告警,不允许掩盖核心 readiness | + +外部 gate 组合: + +- CPA process health; +- Management plugin active/version/capabilities; +- cpa-ext self readiness; +- resource `/ready` 存在; +- Home 关闭; +- 负向认证探针; +- gateway 自己确认 sentinel 未被分发。 + +## 11. 监控与告警 + +最低指标: + +| 类别 | 指标 | +| --- | --- | +| Runtime | active/fused/reconfigure rejected/schema/topology | +| RPC | 每 method 调用数、错误、panic、耗时、payload bytes | +| Auth | success/reject/unknown/dependency failure、rate limit | +| Access | terminate code、strict mismatch、scheduler fallback | +| Billing | settlement、amount、late revision、unpriced、unmeasured、negative balance | +| Persistence | transaction latency/error/busy、WAL/DB size、integrity、backup age | +| Projection | event_seq、checkpoint、lag、rebuild status | +| Upstream | account active/missing/priority shadow、quota age/error | +| Performance | TTFT、total latency、chunk gap、alloc/GC、stream RPC bytes | +| Gateway | open/drained、last readiness/negative probe | + +必须告警: + +- plugin route/active 状态消失或 fuse; +- readiness 核心 component 失败; +- persistence gap、账本不一致; +- active price 缺失/未知 GPT-5.6; +- unpriced/unmeasured 超阈值; +- keyring mismatch; +- DB/WAL/磁盘逼近上限; +- backup 超过目标 RPO; +- projection lag; +- priority 变化导致 binding 不可用; +- stream 性能相对基线退化。 + +指标端点默认只在 Management/受信 sidecar 暴露,不把账户数、金额或错误详情放进公开 `/ready`。 + +## 12. 日常维护 + +### 12.1 每日 + +- 检查 readiness、gateway 和关键告警; +- 检查 unpriced/unmeasured/persistence gap; +- 检查 backup 成功和磁盘; +- 检查 quota stale、missing Auth 和 strict mismatch; +- 检查异常 Credential 消费和负余额。 + +### 12.2 每周 + +- 运行账本余额 reconciliation; +- 检查 projection checkpoint 与 archive eligibility; +- 审阅价格候选,但不自动发布; +- 审阅 audit、Key rotation/expiry 和管理员操作; +- 抽样恢复 backup 到隔离目录。 + +### 12.3 每月/发布前 + +- 完整离线 restore drill; +- 容量/查询计划检查; +- 依赖、CPA 和价格来源复审; +- sentinel/Management/HMAC/key rotation 演练; +- 安全故障注入和 24h soak(涉及 runtime/toolchain/SQLite 变化时)。 + +## 13. 备份与恢复 + +### 13.1 备份 + +- 使用 SQLite online backup API,不直接复制 WAL 模式 `.db`; +- 备份包含 schema/version/content hash/created_at; +- keyring 单独加密备份,记录非秘密 fingerprint/key IDs; +- 保留 7~30 天并至少一份异地副本; +- 备份目录不由 public API 任意下载; +- 每次破坏性 migration 和版本升级前强制备份; +- backup 成功必须通过打开、integrity 和基本账本检查。 + +### 13.2 离线恢复 + +1. gateway 排空并停止 CPA/sidecar; +2. 保存损坏现场副本; +3. 选择匹配时间点的 SQLite + keyring; +4. 在隔离目录验证 checksum、schema、integrity、key fingerprint; +5. 替换明确目标文件,不操作宽泛目录; +6. 启动时运行 migration/recovery; +7. 从权威事实重建 projection; +8. 运行账本、Credential 和 PriceVersion reconciliation; +9. 完成全套 readiness/负向/计费探针; +10. 逐步恢复流量并记录事件审计。 + +缺失/不匹配 keyring 时不得自动生成新 key 后继续;保持 fail closed。 + +## 14. 升级与回滚 + +### 14.1 禁止热替换生产动态库 + +当前 CPA binary hot reload 会把旧 native 实例放入 retired 集合,直到宿主整体 shutdown 才真正 shutdown。旧 runtime/worker/SQLite handle 可能继续存在,因此生产升级固定为:排空 → 停 CPA → 替换/切换 version pin → 启动。 + +### 14.2 升级流程 + +1. 阅读 release notes、CPA compatibility、migration 和 topology 变化; +2. staging 完成 test-plan 全门禁; +3. 备份 SQLite + keyring + config; +4. gateway drain; +5. 停止 CPA 并确认进程退出; +6. 安装新版本化 artifact、更新 version pin; +7. 启动、migration/recovery/readiness; +8. 执行负向认证、金额 golden、route、cancel/stream 和性能 smoke; +9. 小流量观察; +10. 标记升级完成并保留旧 artifact/backup。 + +### 14.3 回滚 + +如果 migration 保持旧版兼容,可以停机切回旧 artifact。若 schema 已不兼容: + +- 停机; +- 恢复升级前 SQLite + 对应 keyring; +- 恢复旧 config/artifact; +- 重新完成 readiness/探针; +- 对升级窗口产生的真实上游消费人工 reconciliation,不能静默丢账。 + +价格回滚不需要二进制回滚,走 [pricing.md](pricing.md) 的“创建新发布版本”。 + +## 15. 配置变更 + +- 使用 CPA Management API/YAML 变更前保存当前 revision; +- 安全敏感变更必须双人复核和 reason; +- 写入后检查 LKG/`reconfigure_rejected`; +- capability shape 不因热配置错误消失; +- 冷配置只在排空重启中修改; +- 不用手改 SQLite 代替管理 API; +- 不在 config 中放 Management/downstream/HMAC/OAuth secret 的副本; +- 变更 Codex Auth priority 前预览受影响 binding。 + +## 16. 常见故障 Runbook + +### 16.1 CPA 正常但 `/ready` 404 + +立即撤流量 → 检查 Home、plugin global/instance enabled、文件名/版本 pin、架构、export、load/register 日志 → 不要临时删除 sentinel 或放开 native Key。 + +### 16.2 Readiness 显示 DB/keyring/price unhealthy + +撤流量 → 保留现场 → 检查权限、磁盘、key ID、migration、active price → 修复后运行 integrity/reconciliation → 不切 observe-only。 + +### 16.3 用户请求突然变慢 + +先 A/B 比较同 CPA/OAuth/model/tier/transport:无插件、空 hook、collection-only、完整插件 → 检查 schema=3、每 chunk RPC 字节、HistoryChunks、response-before 重复 body、DB/锁和 GC → 超过 [dev.md](dev.md) 阈值即回滚/撤下相关 hook。 + +### 16.4 strict binding 不可用 + +检查 CPA Auth active/disabled/unavailable、模型和 priority → 查看 scheduler candidates/bindable reason → `priority_shadowed` 时统一 priority 或调整产品绑定,不强行返回隐藏 Auth ID。 + +### 16.5 quota stale + +不影响已由 CPA candidate 证明可用的基础 strict 请求,但 UI 标 stale → 手动小批 refresh → 检查 provider allowlist、Auth ref、限流和 sidecar → 不把 stale 写成 0/无限。 + +### 16.6 persistence gap/unmeasured 激增 + +撤流量或收紧并发 → 检查 callback、DB、response correlation 和 CPA 版本 → 保存上游证据 → 修复后追加 settlement/adjustment;禁止猜 Token 或覆盖旧账本。 + +## 17. 容量与保留 + +发布前用真实形状数据验证: + +- 50/500/更多 Credential 与上游账户; +- 百万级 Request/Execution/Usage/Ledger; +- 30/90 天查询; +- streaming 长 Prompt/多 chunk; +- 并发 1/8/32 及计划上限; +- WAL、backup、rebuild 和 archive; +- 磁盘增长、峰值内存、CPU、p95/p99。 + +容量结论记录最后成功点和最小失败点,生产持续负载取安全折扣。可参考 usage-keeper `internal/benchmark/capacity-v1` 的 canonical dataset、clone、cgroup memory peak 和 p99 方法,但需要增加 native plugin/CPA/stream 维度。 + +## 18. 验收标准 + +- 文档明确当前骨架与 production-ready 版本的差别; +- artifact、CPA、ABI/schema、OS/arch/toolchain 可追溯; +- 安装前 checksum/export/权限/version pin 均验证; +- 生产 gateway、sentinel、exclusive 和负向探针缺一不可; +- `/healthz` 不被误用为插件 readiness; +- SQLite/keyring 成对备份和隔离恢复演练通过; +- 升级一定排空并重启 CPA,不热替换; +- migration 失败、keyring mismatch、price 缺失时保持 fail closed; +- 关键指标/告警/日常检查和故障 runbook 可执行; +- WSL/Linux 与 Windows artifact 不混用; +- 生产配置不使用 `000`、observe-only、用户 native CPA Key 或未确认价格; +- 发布流程引用并满足 [test-plan.md](test-plan.md) 全部门禁。 diff --git a/docs/modules/persistence.md b/docs/modules/persistence.md new file mode 100644 index 0000000..86dc77f --- /dev/null +++ b/docs/modules/persistence.md @@ -0,0 +1,191 @@ +# 本地持久化模块 + +## 1. 定位 + +持久化模块负责把 [数据模块](data.md) 的事实安全地保存到本地,并提供事务、查询、迁移、备份、归档和重建能力。它不解释 CPA payload、不计算价格、不决定 Key 是否放行,也不包含 UI 逻辑。 + +第一版采用单文件 SQLite。它符合插件单机、低运维、随 CPA 一起分发的目标,又能提供计费账本需要的事务与唯一约束。不能沿用 `cpa-plugin-key-billing` 把整个状态写成一个 JSON 文档的方式。 + +## 2. 存储分层 + +### 2.1 权威事实:不可随意修改 + +- Request 与 Execution; +- 标准 Usage; +- BillingRecord 与 LedgerEntry; +- 管理员余额调整; +- 请求准入使用的 plan/cycle/price 快照。 + +事实只允许追加、幂等补全或显式冲正。历史价格变化不能重算并覆盖已经入账金额。 + +### 2.2 当前目录与配置事实 + +- 下游 account、credential、自管 Key public ID/HMAC digest/preview/alias,以及兼容模式的 CPA scope; +- 上游 auth/provider identity; +- Key→plan 与 Key→upstream binding; +- plan、route policy、price catalog/rule; +- plugin settings 和 schema migrations。 + +同步删除采用 soft delete,历史请求不能因当前 Key 或上游账号被删除而失去解释能力。 + +### 2.3 时间点快照 + +- 上游 quota/subscription snapshot; +- 模型与价格目录版本; +- credential health; +- 同步运行结果。 + +快照按时间追加,当前值由最新有效快照投影,不覆盖历史。 + +### 2.4 可重建派生数据 + +- 小时/日 overview; +- activity; +- latency 分布; +- identity totals; +- ranking、Top 和 heatmap; +- realtime cache。 + +这些数据可以删除后从事实表重建,不能作为余额或账本真相。 + +## 3. 建议表组 + +字段细节由数据模块契约决定,持久化初版至少需要以下表组: + +| 表组 | 建议表 | 说明 | +| --- | --- | --- | +| 下游身份 | `accounts`、`billing_accounts`、`credentials`、`credential_secret_versions` | 用户、金额账户、逻辑 Credential 和可轮换 secret version 分开;只存 public ID、HMAC digest/key ID、preview,不存可恢复明文 | +| 上游身份 | `upstream_identities` | auth ID/index、provider、账户展示、状态、订阅元数据 | +| 绑定与策略 | `billing_account_plans`、`credential_routes` | 金额套餐属于 BillingAccount,上游账户路由属于 Credential/Account | +| 请求事实 | `requests`、`executions`、`usage_records` | 一次 Request 可有多个 Execution/Usage | +| Usage 关联 | `usage_correlations`、`usage_observations` | response ID→Request/Execution 最小映射、canonical vector/hash/revision 与迟到观察 | +| 价格 | `price_catalogs`、`price_rules`、`pricing_snapshots` | 带版本、来源、有效期和审批状态 | +| 计费 | `billing_records`、`ledger_entries`、`billing_cycles` | 定点整数金额,事务提交 | +| 上游配额 | `quota_snapshots`、`subscription_snapshots` | 与下游余额严格分离 | +| 同步诊断 | `ingest_inbox`、`sync_runs`、`diagnostic_events` | 失败重试、来源与处理状态 | +| 统一投影流 | `projection_events` | 全库单调 event_seq,领域事实/修订对应的确定性统计变化 | +| 聚合 | `overview_hourly`、`overview_daily`、`activity_stats`、`latency_stats`、`aggregation_checkpoints` | 可以重建 | +| 系统 | `schema_migrations`、`app_settings` | 数据库版本和插件设置;只有引入 sidecar/已认证用户路由后才增加 `auth_sessions` | + +`billing_records` 对 Usage 使用稳定逻辑 ID/revision,`ledger_entries.billing_record_id`、各 EventID/IdempotencyKey 必须有唯一约束。`usage_observations` 至少唯一约束 `(execution_id, canonical_hash)`;相同累计 vector 重放不能产生新 revision。 + +## 4. SQLite 运行策略 + +参考 `cpa-usage-keeper/internal/repository/db.go`: + +- 文件库启用 WAL; +- `busy_timeout=5000`; +- `foreign_keys=ON`; +- 单 writer connection,所有写事务串行; +- 独立只读 pool,可允许少量并发查询; +- 内存数据库测试时复用单连接; +- 数据库路径使用插件专属 data directory,不放入动态库目录或当前工作目录猜测位置。 + +插件在 CPA 进程内,任何数据库操作都不能长期阻塞请求线程。准入 pending 和 response hook 取得的可靠 canonical Usage 必须在回调返回前耐久化;completion 不是 Usage 唯一落库点。使用有界队列时,队列满不能静默丢弃,应同步落库、进入本地 inbox,或对新请求 fail closed。 + +Go c-shared 内长期 goroutine/timer 的安全性尚未通过验证,MVP 持久化先采用 host callback/管理请求驱动的同步短事务,不自行启动后台 flusher。连接池、WAL checkpoint、聚合和 backup worker 只有通过 [dev.md](dev.md) 的双 Go runtime soak gate 后才能开启;否则长期任务移到 sidecar。 + +## 5. 原子事务 + +一次正常结算至少在一个事务内完成: + +1. 幂等插入/确认 Request、Execution、Usage; +2. 读取准入时锁定的 cycle 与 pricing snapshot; +3. 插入 BillingRecord; +4. 插入不可变 LedgerEntry; +5. 更新 cycle/account 的缓存余额投影; +6. 在 `projection_events` 插入可供聚合追赶的单调 `event_seq`,事务后再发送轻量通知;结算事务不提前推进任何聚合 checkpoint。 + +唯一约束是最终防线。进程内去重缓存只能提高性能,不能替代数据库幂等。 + +管理员充值、扣减、重置和冲正同样必须写 LedgerEntry,禁止直接修改 `spent` 或 `balance` 而不留原因。 + +## 6. 金额、时间和未知值 + +- 金额统一保存为 `int64 micros` 和 currency,不使用 float 作为余额或账本字段; +- Token 使用非负 `int64`; +- 时间保存 UTC 或一个固定的规范化格式,展示时再转时区; +- `NULL` 表示未知,数值 0 表示确认观察到零; +- bool 若来源可能缺失则使用 nullable; +- 枚举按稳定字符串保存,并由 schema version 管理扩展。 + +## 7. Hot、Archive 与保留策略 + +参考 usage-keeper 的 `usage_events` / `usage_events_archive`,但不能直接照搬其单表 `INSERT SELECT + DELETE`:本项目存在 Request→Execution→Usage→Billing→Ledger 永久审计关系。 + +MVP 决断:不物理移动或删除 Request/Execution/Usage 最小事实行;只清理原始大响应引用、临时诊断和已过期内存缓存。BillingRecord、LedgerEntry 和管理员调整永久保留。待真实容量达到阈值后再设计统一逻辑 ID + hot/cold view,或只归档大字段/诊断明细;任何方案都不能打断账本引用。 + +- 最小 `usage_correlations` 默认随 hot Request 保留至少 90 天;24 小时只表示内存 cache TTL/awaiting 快速扫描窗口,不立即删除 response-ID 映射; +- 超过关联保留期后,只有自带 RequestID/ExecutionID 的迟到事实仍可可靠补记,无 ID 观察不得猜配; +- 小时级高分辨率聚合可以短期保留,日级金额/请求聚合长期保留; +- quota snapshot 可以按策略降采样,保留重置边界和异常快照。 + +归档不是删除历史。用户要求清除日志时,必须区分“隐藏/清理诊断日志”“删除请求内容引用”和“不可删除的金额账本”。 + +## 8. 聚合与 checkpoint + +`projection_events.event_seq INTEGER PRIMARY KEY AUTOINCREMENT` 是全库唯一聚合游标;同一事务还写 `event_id UNIQUE`、`event_kind`、`fact_id`、`fact_revision`、`occurred_at`、`booked_at`、`supersedes_event_id` 和确定性 delta payload。状态从 unmeasured 变为 measured 时,事件明确携带 `unmeasured_count=-1`、`measured_count=+1`、`spend_delta=...`,不能只追加一个“已结算”状态造成双计数。 + +每类聚合使用独立 checkpoint:overview、activity、latency、identity、ranking 互不阻塞。checkpoint 保存已处理的最大单调 `event_seq`,而不是 UUID `event_id` 或只保存时间;`event_id` 负责全局身份/幂等,不能拿来比较处理先后。 + +- 新事件提交后发送轻量通知; +- callback-driven runner、sidecar 或 soak 通过后的后台 runner 批量追赶; +- 启动时检查 lag 并恢复; +- 单个聚合失败只保留自己的旧 checkpoint; +- 聚合 upsert 与 checkpoint 推进处于同一事务; +- 支持从 0 或指定 `event_seq` 重建; +- UI 能展示各 checkpoint lag 和最后错误。 + +## 9. Inbox、崩溃恢复与备份 + +如果存在 Redis/HTTP 导入或异步采集,应先把原始消息写入 `ingest_inbox`,再解码为事实,状态至少包含 pending、processed、process_failed、discarded。失败重试必须有次数和最后错误。 + +原生回调的 Request 临时状态不能完整依赖内存。至少在准入/执行开始处留下轻量 pending 事实,使进程崩溃后可以将未完成请求标记为 abandoned/unknown,而不是永久占用并发或额度预留。 + +恢复扫描还必须包括:已保存 canonical Usage 但尚未结算的 Execution、awaiting usage、未发布 projection/outbox。即使 completion 永久丢失,Request 可以转为 abandoned/unknown,可靠 Usage 仍必须结算。 + +备份使用 SQLite online backup API,不能只复制正在 WAL 模式运行的 `.db` 文件。建议: + +- 每日自动备份; +- 默认保留 7~30 天; +- 管理员可立即创建和下载备份; +- 恢复必须校验 schema version、integrity check 和账本一致性; +- SQLite 备份不包含外部 HMAC secret keyring;运维必须分开加密备份、成对恢复,并用非秘密 fingerprint/key ID 验证匹配;缺失或不匹配时认证 fail closed,禁止自动生成替代 secret; +- shutdown 时停止 runner、checkpoint WAL、关闭 reader/writer,且整个过程幂等有超时。 + +## 10. 索引原则 + +索引围绕真实查询建立: + +- request/event ID 唯一索引; +- timestamp + ID 游标; +- downstream credential/account + timestamp; +- upstream identity + timestamp; +- model + timestamp; +- outcome/status + timestamp; +- cycle、ledger account + occurred_at; +- archive 只保留主键和必要的少数索引。 + +不要为 UI 的每个筛选组合创建索引。使用真实数据容量测试查询计划,再调整复合索引。 + +## 11. 迁移策略 + +- 新库可创建当前完整 schema; +- 已存在数据库必须执行有序、带版本的显式 migration; +- migration 在插件开始接收业务请求前完成; +- 破坏性迁移先备份; +- migration 失败时插件进入不可计费/不放行的安全状态,不能打开空数据库继续免费运行; +- 数据契约 schema version 与 SQLite migration version 分开管理。 + +## 12. 参考路径与验收 + +主要参考: + +- `cpa-usage-keeper/internal/repository/db.go`; +- `internal/entities/`; +- `internal/repository/migration/`; +- `internal/repository/usage_event_archive.go`; +- `internal/backup/`; +- `internal/repository/usage*.go`。 + +验收必须覆盖事务回滚、重复事件、并发读写、WAL、数据库锁、崩溃恢复、migration、备份恢复、archive 前 checkpoint 检查、账本金额一致性和 `go test -race`。 diff --git a/docs/modules/pricing.md b/docs/modules/pricing.md new file mode 100644 index 0000000..11eee2a --- /dev/null +++ b/docs/modules/pricing.md @@ -0,0 +1,378 @@ +# 价格目录与计价政策模块 + +## 1. 定位 + +价格模块负责回答一个问题:**在某个不可变价格版本下,这次实际模型与速度层级应该使用什么计价政策**。 + +它拥有: + +- 模型价格来源、候选、草稿和已发布版本; +- 模型别名到规范计费模型的显式映射; +- 普通输入、缓存读取、缓存写入和输出四段单价; +- 长上下文阈值与阶梯价格; +- Fast/priority 等速度层级政策; +- 价格预览、发布、回滚和只读运行时快照; +- 无法定价、来源过期和规则冲突诊断。 + +它不拥有: + +- Provider Usage 的 Token 语义归一化,该职责属于 [collection.md](collection.md); +- 最终金额结算、余额、套餐和账本,该职责属于 [billing.md](billing.md); +- 模型调用、OAuth 账户健康和路由选择; +- 按历史 Token 动态改写已经入账的金额; +- 用户侧 Credits 或 Token 额度。用户核心单位始终是金额。 + +Billing 向本模块提供价格查询主题,本模块返回不可变 `ResolvedPricePolicy`。Billing 再使用实际规范化 Usage 计算金额并将所用政策完整快照写入账单。 + +## 2. 不可违背的原则 + +### 2.1 候选价格绝不是生产价格 + +外部目录、官方网页抓取结果和管理员输入先进入 candidate/draft,只有经过验证和显式发布后才成为生产价格。 + +生产请求只能读取 `published` 版本。以下行为禁止: + +- models.dev 或其他第三方目录刷新后自动改变生产计费; +- 请求执行过程中读取网络价格; +- 直接修改已发布版本; +- 因价格源暂时不可用而把未知价格当成 0; +- 用“当前最新价格”重算并覆盖历史账单。 + +### 2.2 价格和倍率使用确定性表示 + +结算币种第一版固定为 USD。持久化和领域计算不使用 `float64` 表示最终费率或金额: + +```go +type Rate struct { + MicrosPer1M int64 // 每 1,000,000 Token 的微美元数 +} + +type Ratio struct { + Numerator int64 + Denominator int64 +} +``` + +例如 Fast 2.5× 保存为 `5/2`,不是二进制浮点数。计算使用溢出安全的整数/大整数中间值,每个 Execution 的完整 canonical Usage 只在最终总额处执行一次 `round-half-up` 到 micro USD。Usage revision 通过“新累计应收总额减已入账总额”生成差额,不能对每次增量分别舍入后累加。 + +### 2.3 发布后不可变 + +回滚不是重新编辑旧版本,而是以旧版本内容创建一个新的发布版本。这样每笔账单都能永久引用原始 `price_version_id`,同时保留谁在何时恢复了哪一版。 + +### 2.4 无价格必须显式失败 + +- 准入时无法解析允许模型的可信价格:`503 billing_price_unavailable`,不触达上游; +- 执行后才发现实际模型/tier 与准入假设不同且无法定价:保存 `unpriced` 事实、告警并阻止该 Credential/模型的后续请求; +- 明确发布的零价格是合法价格,必须通过 `is_explicit_free=true` 与“未解析”区分; +- 管理员补发价格后只能追加 settlement/adjustment,不修改原始 Usage 或旧账本。 + +## 3. 核心领域对象 + +### 3.1 `PriceSource` + +记录价格信息从哪里来: + +```text +PriceSource +├─ source_id +├─ kind # official/manual/external +├─ name +├─ source_uri +├─ retrieved_at +├─ effective_hint +├─ content_hash +├─ raw_reference # 受限引用或原始快照位置 +└─ verification_status +``` + +`official` 只表示来源类型,不代表内容已自动获准发布。外部内容要保存抓取时间和哈希;来源正文不进入请求关键路径。 + +### 3.2 `PriceCandidate` + +候选是某个来源解析出的模型价格建议: + +```text +PriceCandidate +├─ candidate_id +├─ source_id +├─ source_model +├─ canonical_model_hint +├─ four_segment_rates +├─ long_context_policy +├─ tier_policy +├─ source_effective_at +├─ parse_warnings[] +└─ status # new/reviewed/rejected/imported +``` + +同一模型可以同时存在多个来源候选。冲突必须显示差异,不能用抓取先后顺序静默覆盖。 + +### 3.3 `PriceVersion` + +```text +PriceVersion +├─ price_version_id +├─ sequence +├─ currency # USD +├─ state # draft/published/superseded/rejected +├─ parent_version_id +├─ content_hash +├─ change_summary +├─ created_at / created_by +├─ published_at / published_by +└─ source_snapshot[] +``` + +任一时刻只有一个运行时 active published version。数据库可以保存未来生效版本,但 MVP 不做定时自动切换;管理员在生效点明确发布,避免 c-shared 内依赖常驻 timer。 + +### 3.4 `ModelPricePolicy` + +```text +ModelPricePolicy +├─ price_version_id +├─ canonical_model +├─ provider # MVP 为 openai/codex +├─ input_rate +├─ cache_read_rate +├─ cache_write_rate +├─ output_rate +├─ explicit_free +├─ long_context_policy? +├─ service_tier_policy +├─ source_refs[] +└─ notes +``` + +四段费率始终完整保存。来源没有缓存写价格时不能擅自沿用普通输入价格;只有管理员明确确认的 fallback 规则才能编译进版本。 + +### 3.5 `ModelAlias` + +```text +ModelAlias +├─ alias +├─ canonical_model +├─ alias_kind # request/route/upstream +├─ provider +├─ valid_from_version +└─ status +``` + +MVP 生产匹配只允许精确模型名和显式 alias,不支持任意 glob。key-billing 的 glob 实现可以参考算法,但模糊规则在新增模型后可能意外继承价格,不适合作为金额账本默认策略。 + +### 3.6 `ResolvedPricePolicy` + +这是 Pricing 给 Billing 的唯一运行时输出: + +```text +ResolvedPricePolicy +├─ price_version_id +├─ canonical_model +├─ matched_value +├─ matched_by # upstream_model/model_alias/route_alias +├─ four_segment_rates +├─ long_context_policy +├─ effective_service_tier +├─ tier_multiplier # Ratio +├─ source_refs +└─ policy_hash +``` + +Billing 必须把这些字段复制为账单价格快照,不能只保存一个指向可变当前目录的模型名。 + +## 4. 模型解析 + +一次请求至少可能出现: + +- `requested_model`:用户请求的名字; +- `route_model` / `model_alias`:CPA 路由和别名; +- `selected_model`:选择账户后准备执行的模型; +- `upstream_model`:上游响应或 Usage 确认的实际模型。 + +MVP 解析顺序固定为: + +1. 去除已被 CPA 证明只是选项的 thinking suffix,不改变真正模型 ID; +2. 若实际 `upstream_model` 精确命中已发布模型,使用它; +3. 否则按类型和 provider 查找显式 `ModelAlias`; +4. 若管理员明确把 route model 发布为独立计费模型,允许精确命中; +5. 多个候选同优先级冲突时返回 `ambiguous_price_match`; +6. 没有可信匹配时返回 unavailable,绝不按字符串相似度猜测。 + +准入时还没有最终 upstream model。此时必须锁定本次允许使用的 `price_version_id` 和候选政策。执行后若模型发生变化,在**同一个价格版本**内重新解析并结算;不能因调用期间发布了新版本而混用新价格。 + +GPT-5.6 Sol/Terra/Luna 必须作为不同 canonical model 分别发布。名字相近不代表价格相同,禁止通过 `gpt-5.6-*` 默认继承同一价格。 + +## 5. 四段 Token 价格 + +价格政策只定义单价;Token 是否重叠由采集/归一化模块决定。Billing 输入必须已经形成四个互斥区段: + +```text +普通输入 + 缓存读取 + 缓存写入 + 输出 +``` + +Reasoning Token 若已经包含在输出中,不增加第五段价格。后台可以展示 reasoning 明细,但不能重复收费。 + +费率验证至少包括: + +- 所有整数非负且不溢出 `int64` 支持范围; +- 四段字段不得缺失; +- 显式免费必须带确认标记和审计原因; +- 最坏 Token × 费率 × 倍率的中间结果可安全计算; +- 相同 canonical model 在同一版本中只能有一条 base policy。 + +## 6. Fast / priority 政策 + +Fast 是金额倍率,不是独立余额单位。GPT-5.6 Fast/priority 的当前产品政策保存为版本化 `5/2`,只应用一次。 + +首先合并请求和响应事实: + +```text +effective_service_tier = + 响应明确确认的 tier + 否则请求明确指定的 tier + 否则 standard +``` + +然后只对 `effective_service_tier=priority` 应用一次倍率。禁止同时配置: + +- `service_tier=priority × 2.5`;以及 +- `response_service_tier=priority × 2.5` + +并让二者命中时变成 6.25×。 + +`cpa-usage-keeper` 的固定字段规则和不可变 Snapshot 值得复用,但其 resolver 会把每个命中的字段规则连续相乘;这对一般分析倍率合理,对 Fast 请求/响应两个同义观察不合理。本项目必须先归一化为唯一 `effective_service_tier`。 + +发布 GPT-5.6 价格版本时必须用当时的 OpenAI 官方资料重新确认:支持的具体模型、倍率、生效时间以及是否有额外限制。文档不硬编码可能过期的具体基础单价。 + +## 7. 长上下文政策 + +```text +LongContextPolicy +├─ threshold_input_tokens +├─ comparison # gt/gte,必须显式 +├─ input_rate +├─ cache_read_rate +├─ cache_write_rate +└─ output_rate +``` + +判断输入是归一化后的总输入 Token,包含普通输入、缓存读取和缓存写入;阈值边界由 `comparison` 明确,不能隐藏在代码的 `>` 或 `>=` 中。 + +组合顺序固定为: + +1. 根据总输入判断是否命中长上下文; +2. 选择完整四段 base rates; +3. 计算四段基础金额; +4. 对总基础金额应用一次 Fast/tier 倍率; +5. 最终舍入为 `amount_micros`。 + +导入 models.dev 时必须保留并核对 tiers。当前 usage-keeper 的自动价格同步忽略长上下文 tiers,不能原样复制。 + +## 8. 来源优先级与发布流程 + +候选展示优先级: + +1. 管理员手工输入并说明来源; +2. 项目维护的官方来源快照; +3. models.dev 等外部目录; +4. 无来源。 + +优先级只影响 review UI,不自动决定发布。标准发布流程: + +```text +fetch/import candidate + → parse + hash + → match canonical models + → validate numeric/token/tier semantics + → diff against active version + → run golden price cases + → administrator approve with reason + → persist immutable version + → atomically publish compiled Snapshot + → emit audit + projection event +``` + +发布事务必须先完整写入版本、明细、来源引用和审计,再更新 active pointer。运行时 `Catalog` 使用原子指针替换完整只读 Snapshot;请求不得看见半个新版本。 + +回滚流程创建新版本并重复相同验证。发布失败时继续使用最后有效版本,readiness 显示 `pricing_publish_failed`;不得清空当前价格。 + +## 9. 持久化建议 + +| 表 | 用途 | +| --- | --- | +| `price_sources` | 来源与抓取元信息 | +| `price_candidates` | 未发布候选和解析诊断 | +| `price_versions` | 不可变版本头 | +| `model_price_policies` | 每版模型四段价格 | +| `model_aliases` | 版本化精确别名 | +| `long_context_policies` | 阈值与阶梯价格 | +| `service_tier_policies` | tier 与有理数倍率 | +| `price_publications` | 发布、回滚和审批审计 | + +active version 可以存于 `app_settings`,但更新必须与版本发布事务一致。旧版本不能随普通归档删除;只要账本仍引用就永久保留。 + +## 10. API 与 UI 输出 + +管理员需要看到: + +- active version、发布时间、操作者和来源; +- 当前模型价格、alias、长上下文和 tier 政策; +- candidate 与 active 的逐字段差异; +- 使用中的未定价模型和受影响请求; +- 发布前 golden case 结果; +- 历史版本和回滚入口。 + +用户 API 只返回已结算金额、币种和必要说明,不返回 Token 单价、来源、规则或倍率。管理员接口路径和 DTO 见 [api.md](api.md)。 + +## 11. 运行时与故障策略 + +- Resolver 只读取内存 Snapshot,不做 SQL、网络或 host callback; +- Snapshot 编译在管理命令/安全 maintenance budget 中完成; +- 外部目录失败只影响 candidate refresh,不影响 active version; +- active version 缺失、损坏或 currency 不匹配时计费 readiness 失败,新请求 fail closed; +- 实际 Usage 无法匹配价格时保存事实并阻断后续同范围请求,不能 panic 或返回会被 CPA 忽略的 interceptor RPC error; +- 热配置不能直接改变历史或 active price content;价格发布走业务 API 和审计事务。 + +## 12. 如何参考现有项目 + +### 12.1 `cpa-plugin-key-billing` + +优先参考: + +- `internal/billing/pricing.go`:四段 Token 价格、模型匹配和长上下文计算; +- `internal/billing/catalog.go`:models.dev 下载、大小限制、缓存和候选解析; +- `internal/billing/admin.go`:目录搜索、覆盖和价格校验; +- `internal/billing/pricing_test.go`:四段不重复和阈值测试。 + +可以复用算法思路,不能复制其 `float64` 账务表示、自动把目录当 builtin production price、模糊 glob 默认匹配或缺少 Fast 政策的行为。 + +### 12.2 `cpa-usage-keeper` + +优先参考: + +- `internal/pricing/catalog.go`、`snapshot.go`:完整编译后原子发布只读快照; +- `internal/pricing/fields.go`、`resolver.go`:固定枚举维度和条件倍率; +- `internal/repository/pricing*.go`:价格与规则持久化; +- `internal/service/pricing_metadata_sync.go`:同步预览; +- `internal/helper/usage_cost.go`:四段费用拆分。 + +应吸收 Snapshot、固定字段和完整校验;不能继承动态查询重算历史费用、多条同义 tier 规则相乘、浮点账本和忽略长上下文 tiers。 + +参考版本: + +- `cpa-plugin-key-billing` `25b534ae386f830f537cca9215cff5586e630b3a`; +- `cpa-usage-keeper` `d62cad3f345ae574089a14a4ac75cca023c7ead6`。 + +## 13. 验收标准 + +- 没有已发布可信价格时请求不会免费穿透; +- GPT-5.6 Sol/Terra/Luna 独立解析且来源可追溯; +- Fast/priority 在请求、响应或两者同时报告时都只应用一次 2.5×; +- 长上下文阈值前、边界和边界后结果符合显式 comparison; +- 四段 Token 价格不重叠,Reasoning 不重复收费; +- 所有费率、倍率和最终金额确定性计算,无浮点累计漂移; +- 外部目录刷新不自动改变生产价格; +- 发布原子可见,失败保持 LKG; +- 回滚生成新版本,旧账单仍引用原版本; +- alias 冲突和未知模型明确失败,不做模糊猜测; +- 价格 Golden Tests、版本重放和溢出边界全部通过; +- 用户侧只看到金额,管理员才能查看价格计算细节。 diff --git a/docs/modules/security.md b/docs/modules/security.md new file mode 100644 index 0000000..cc0e47a --- /dev/null +++ b/docs/modules/security.md @@ -0,0 +1,364 @@ +# 安全模块 + +## 1. 安全目标 + +cpa-ext 处理三类高价值资产:下游用户 Key、金额账本、上游 OAuth/Auth。它又以 native dynamic library 运行在 CPA 进程内,因此安全目标不只是“接口有密码”,而是: + +1. 未通过 cpa-ext 认证和金额准入的用户不能调用上游; +2. 插件缺失、panic、fuse、配置失败或数据库故障时不能退化成免费放行; +3. 一个下游 Credential 只能读取和消费其授权的 BillingAccount; +4. 金额、价格版本和账本不能被静默覆盖或重复结算; +5. downstream Key、Management Key、HMAC keyring、OAuth Token 和请求内容不泄露; +6. 管理写操作可追责、可回滚且不能通过 CSRF/SSRF/路径注入扩大权限; +7. native 插件故障不应无限阻塞、退出或破坏 CPA 进程。 + +## 2. 信任边界 + +```text +Untrusted user/client + │ downstream key + model request + ▼ +Reverse proxy / readiness gate + │ + ▼ +CLIProxyAPI HTTP + auth pipeline + │ in-process C ABI / JSON RPC + ▼ +cpa-ext native plugin ───── SQLite + HMAC keyring + │ host callbacks + ▼ +CPA Auth manager / provider network + +Administrator ── CPA Management auth ── cpa-ext Management API +Browser resource ── unauthenticated static GET / user-key-protected GET +``` + +信任结论: + +- plugin binary 是与 CPA 同权限的受信代码,不是隔离沙箱; +- Management Key 信任的是 CPA 管理员,不自动代表具体个人; +- ResourceRoute 默认完全不可信; +- scheduler candidate 是宿主过滤后的瞬时候选,不是完整账户清单; +- provider response、models.dev、Auth 文件 display metadata 和所有 HTTP 输入都必须验证。 + +## 3. 必须始终成立的安全不变量 + +- 生产 `frontend_auth_provider_exclusive=true`; +- CPA 原生 `api-keys` 中不保存任何分发给用户的 Key; +- 未绑定账户/套餐/价格默认拒绝; +- 核心依赖不健康时新请求 fail closed; +- 用户核心额度单位只有 Money; +- 账本 append-only,余额只是可重建投影; +- secret 明文只在签发/轮换首次响应存在; +- 下游 Key 不出现在 URL、日志、事件、数据库明文字段或前端持久存储; +- 上游 Auth 原文只允许存在于最小 provider adapter 的短生命周期缓冲; +- 所有业务拒绝都走 CPA 实际会执行的终止协议; +- 权限由服务端 scope 构造,不能信任调用方传入 account/credential ID; +- 插件无法独自证明的宿主状态由部署网关强制检查。 + +## 4. 当前 CPA 的 Critical 限制 + +### 4.1 Exclusive 只在插件 active 时存在 + +当前 CPA 在插件未加载、被 disable、register/reconfigure 失败或 fuse 后会重建 frontend providers。exclusive 可能被清除,CPA 原生认证重新生效;零 provider 还可能走 legacy 放行路径。 + +因此“插件声明 exclusive”不是完整安全边界。生产必须同时配置: + +1. 一个高熵、随机、只由运维离线保管的 CPA native sentinel key; +2. 不把 sentinel 发给用户、前端、自动化调用方或日常网关; +3. 所有用户只获得 cpa-ext 自管 Key; +4. 外部 gateway 在 cpa-ext readiness 和负向认证探针通过前不开放用户端口; +5. 插件 route 消失、状态不健康或负向探针异常时立即撤流量。 + +sentinel 的作用是防止“零 native provider”变成隐式放行,并确保插件失效时普通用户 Key 仍不被 CPA 原生认证接受。它不是备用共享 Key。 + +### 4.2 Reconfigure 失败会撤掉 active record + +当前 `Host.ApplyConfig` 中,已经加载的插件如果 `plugin.reconfigure` 返回错误,会被排除在新 active records 外,随后可能清除 exclusive。 + +所以已注册后的无效热配置必须: + +- 插件内部保留 last-known-good Runtime; +- 记录 `reconfigure_rejected`; +- 仍返回 success registration 和上一次相同 capability shape; +- 由 API/诊断提示配置未生效。 + +只有 CPA 宿主先实现“失败保留旧 active record”后,插件才可以安全返回 reconfigure error。 + +### 4.3 Interceptor error/panic 是 fail-open + +CPA 对 request interceptor 的 Go error 或越过边界的 panic 会记录并继续原请求。所有预期拒绝、DB/账本/价格依赖故障必须返回: + +```text +RPC envelope: ok=true +RequestInterceptResponse: + Terminate=true + StatusCode=合适的 4xx/503 + ResponseBody=脱敏稳定错误 +``` + +dispatcher 在插件内部 recover,并把 panic 转为上述 503 termination。若 panic 已越过 native 边界导致宿主 fuse,纯插件无法保证当前请求,必须由 gateway/sentinel 防线兜底。 + +### 4.4 Scheduler error/panic/无效响应会 fallback + +strict binding 不能只依赖 `scheduler.pick`。每个 Execution 的 after-auth interceptor 必须复核实际 `selected_auth_id`,不属于允许集合就正常返回 `Terminate=true`。宿主 fuse 场景仍依赖外部门禁。 + +### 4.5 Handler 前宿主会 ReadAll + +frontend auth 和 Management handler 在插件校验前已经收到完整 Body copy。插件内部 413 不能保护 CPA 内存,反向代理/CPA HTTP 层必须限制 body、header、连接数和读取时间。 + +### 4.6 Resource routes 未鉴权 + +ResourceRoute 只能放静态资源、最小 readiness 和自行校验 downstream Key 的用户只读 JSON。绝不允许 `host.auth.get/save`、管理员数据、备份、价格发布或任意写操作。CPA 官方 auth-files callback example 是能力演示,不是生产安全模板。 + +## 5. 下游 Credential 安全 + +### 5.1 Key 格式与生成 + +建议格式: + +```text +cpae__ +``` + +- `public_id` 只用于快速定位 Credential,可公开但不可授权; +- `random_secret` 至少 256 bit CSPRNG; +- 完整 Key 只在首次签发/轮换响应显示一次; +- 前缀和 preview 用于人工识别,不足以认证; +- Key 必须可按 SecretVersion 独立撤销、过期和轮换。 + +### 5.2 存储与校验 + +数据库保存: + +- public ID; +- `HMAC-SHA-256(keyring_secret, full_key)` digest; +- `hmac_key_id`; +- preview、状态、创建/过期时间。 + +不保存可恢复明文。HMAC secret 来自环境指向的受限 secret file/OS secret store,不能写入 CPA YAML、SQLite、日志、metadata 或备份包。比较使用 constant-time compare。 + +高熵随机 Key 使用 keyed digest 足够;不能改为普通无密钥 SHA-256。若未来允许用户自选短密码,必须改用专门 password KDF,不能沿用此模型。 + +### 5.3 Keyring 轮换 + +- keyring 每个 secret 有稳定 key ID; +- 新 Credential 使用 active key; +- 旧 digest 继续按原 key ID 校验; +- 缺失未知 key ID 时认证 fail closed 并使 readiness 失败; +- 不能启动时自动生成替代 secret; +- SQLite 与 keyring 分开加密备份、成对恢复并校验 fingerprint。 + +## 6. 身份认证与授权 + +### 6.1 模型请求 + +frontend auth 只完成 Credential lookup 和 principal 建立。金额、状态、模型、tier、endpoint、并发和 route policy 在 before-auth/Core 准入再次检查。 + +principal metadata 只携带 opaque Credential/BillingAccount IDs 和最小 scope,不携带 raw Key、digest、余额或可篡改 JSON。 + +### 6.2 用户查询 + +用户 Resource API 每次自行校验 Bearer downstream Key,并由 Credential 反查服务端 scope: + +- 共享 BillingAccount 可以看共享金额余额; +- 默认逐请求只看当前 Credential; +- 调用方传入 ID 只能缩小,不能扩大 scope; +- unknown/revoked/expired 使用统一 401,防止枚举; +- 登录尝试按 IP + public ID preview + 全局维度限流; +- Key 不进入 URL/cookie/localStorage,请求和响应 no-store。 + +### 6.3 管理员 + +Management API 继承 CPA Management middleware: + +- 生产 `remote-management.allow-remote=false`,或只经受信 TLS/mTLS 管理网关暴露; +- 使用随机高熵 Management Key,不复用 sentinel/downstream/HMAC secret; +- 浏览器只在当前页面内存保存 Key,刷新即失效; +- cpa-ext 不把入站 Authorization 传给 Core、日志、host HTTP 或 provider; +- 每个写操作需要 reason、revision 和审计; +- CPA 共享 Key 不能提供真实个人 RBAC。需要多管理员角色时增加可信 identity gateway/sidecar。 + +测试环境可以按用户约定使用 `000` 便于联调,但仅限 loopback、无真实 OAuth/生产数据、端口不对外开放的临时环境。任何可被其他机器访问或接入真实账户的部署都禁止使用 `000`。 + +## 7. 租户与数据隔离 + +- Repository 查询必须接收已验证 `ViewerScope`,不接受裸 account ID 作为授权; +- 管理 DTO 与用户 DTO 分开定义,不能返回管理员对象后由前端隐藏; +- 缓存键包含 viewer scope,不能跨 Credential 复用敏感响应; +- cursor 绑定 scope 和过滤条件,不能拿另一账户 cursor 继续翻页; +- 导出任务固定 scope、时间范围和创建者; +- 账本 adjustment、route binding、价格发布均检查对象 revision; +- 统计聚合中低基数结果也不能绕过用户 scope。 + +## 8. 上游 Auth 与网络安全 + +### 8.1 最小读取 + +账户目录只调用 `host.auth.list/get_runtime`。`host.auth.get` 返回原始 Auth JSON,只能由 provider-specific quota adapter 在管理员触发的有界命令中读取。 + +raw Auth 数据: + +- 不进入 domain DTO、generic map、error、trace 或数据库; +- 不跨 goroutine/channel; +- 从最小缓冲提取必要字段后尽快覆盖并释放; +- 任何失败只输出分类,不包含 body/header/token。 + +Go/OS 无法保证内存立即物理清零,因此更安全的长期方案是 CPA 提供 provider-scoped quota callback,不把 secret 交给插件。 + +### 8.2 SSRF 防护 + +Quota/provider adapter 的 URL、method、headers 和 body 均由代码定义: + +- 只允许 HTTPS; +- 精确 allowlist host + path,不允许重定向到新 host; +- 禁止 localhost、私网、link-local、file/unix scheme; +- 调用方只能传内部 UpstreamAccountID; +- 限制响应 body 和 header 大小; +- 独立超时、并发和速率限制; +- 不把 CPA Management Authorization 转发给 `/api-call` 或第三方; +- 代理配置由宿主/运维控制,不允许用户覆盖。 + +## 9. 金额和数据完整性 + +- 金额/费率使用整数定点和有理数倍率; +- PriceVersion 发布后不可变; +- canonical Usage revision 使用 CAS/idempotency; +- 账本 append-only,删除 API 不存在; +- adjustment/refund 是新账本项,必须含 reason/actor; +- DB 事务同时写事实、账本、projection event/outbox; +- checkpoint 可从事实重建并做总额 reconciliation; +- 时间来自服务端 UTC,调用方时间只作为未经信任 metadata; +- 重复、乱序、迟到 callback 不能重复扣费; +- SQLite 失败后不得改用内存余额继续免费运行。 + +数据库文件、WAL、备份目录只允许 CPA 运行用户访问。备份使用 SQLite online backup API;恢复前校验 schema、integrity、账本和 keyring fingerprint。 + +## 10. 管理 API 与浏览器安全 + +- exact route + method 二次校验; +- strict JSON、字段/集合/响应大小限制; +- 所有动态 HTML/文本安全转义; +- React 只用 text node,不使用 `dangerouslySetInnerHTML`; +- CSP、nosniff、no-referrer、no-store; +- 默认无 CORS; +- 不注册 service worker; +- CSV 防公式注入; +- 用户和管理员 secret 不进入 query/fragment; +- Management JSON 的宿主 entity 转义按 [api.md](api.md) 兼容一次,不能把 entity decode 后内容当 HTML。 + +如果未来使用 cookie session:必须在独立 authenticated route/sidecar 设计 HttpOnly、Secure、SameSite、CSRF token、Origin 校验、登录限流和 session revoke。当前插件 resource GET 不具备这些条件。 + +## 11. Native 插件与供应链 + +### 11.1 运行时 + +- ABI 入口只做 byte copy、JSON envelope、C buffer ownership; +- C 返回内存由匹配的 plugin free 释放,不返回 Go heap pointer; +- 不使用 `os.Exit`、`log.Fatal` 或故意 panic; +- dispatcher/Core 边界 recover,错误脱敏; +- shutdown 幂等且有界; +- 不持锁执行 callback、网络或慢 SQL; +- 双 Go runtime/SQLite/worker 必须通过 24h soak; +- gate 未通过前动态库零长期 worker,复杂任务移到 sidecar/运维命令; +- 二进制升级排空并重启 CPA,不依赖 hot reload 回收旧 runtime。 + +### 11.2 发布物 + +- 每 OS/arch 独立构建; +- 记录 Go/C toolchain、CPA commit、ABI/schema 和源码 commit; +- 归档只含动态库、LICENSE/NOTICE、版本元数据; +- 发布 SHA-256 checksums,最好增加签名/透明 provenance; +- 安装前验证 checksum、文件名和目标目录; +- 不从未知 plugin store/source 安装; +- 发布包不含 config、SQLite、keyring、日志、Auth 或测试 secret; +- 复制两个 MIT 项目代码时维护 THIRD_PARTY/NOTICE 和来源 revision。 + +## 12. 日志、诊断与审计 + +禁止记录: + +- Authorization、X-Management-Key、Cookie; +- downstream full key/HMAC digest; +- OAuth/API token、Auth JSON、代理凭证; +- Prompt、Response、原始失败 body; +- database absolute path(普通用户可见日志); +- raw config YAML。 + +允许记录: + +- stable opaque IDs; +- Key preview; +- provider/model 分类; +- Money、状态、事件 ID; +- 脱敏 error code; +- callback/RPC 大小和耗时; +- readiness component 状态。 + +日志参数采用结构化白名单 DTO,不接受任意 wire object。安全审计和调试日志分开保留,审计不可由普通“清空日志”操作删除。 + +## 13. 可用性与资源滥用 + +- downstream/API Key 级并发和速率限制; +- BillingAccount 默认并发 1; +- Management 写操作串行化到明确对象/revision,不用全局长锁; +- query 时间范围、分页、维度和导出上限; +- SQLite busy timeout 有界,锁等待产生 metric; +- quota refresh 去重、批量/并发/冷却; +- stream callback 性能门禁,正常 chunk 成本不能随 prompt/history 线性放大; +- 请求体上限在反向代理先执行; +- readiness unhealthy 时撤流量,不能让重试风暴打满 DB/provider。 + +## 14. 威胁与控制矩阵 + +| 威胁 | 主要控制 | +| --- | --- | +| 插件缺失/fuse 后免费调用 | exclusive + sentinel + required readiness gateway + 负向探针 | +| DB 故障被 interceptor error 忽略 | success envelope + Terminate 503,frontend auth 第二道 fail closed | +| strict binding 回退其他账户 | scheduler 决策 + after-auth 实际 Auth 验证 | +| 用户 Key 枚举/泄露 | 高熵 Key、HMAC、统一 401、限流、no-store、日志白名单 | +| 跨租户查询 | 服务端 ViewerScope、scope-bound cursor/cache、独立 DTO | +| 余额/价格篡改 | revision、不可变 PriceVersion、append-only ledger、审计 | +| quota SSRF/凭证代理 | 固定 provider adapter、HTTPS host/path allowlist、无任意 URL/header | +| Management Key 泄露 | loopback/管理网、TLS、高熵、仅内存、响应/日志删除 | +| 大 body/慢请求 DoS | 入口代理 limit/read timeout、分页/响应预算 | +| 恶意动态库/供应链 | checksums/signature/provenance、受信 registry、最小发布包 | +| SQLite/backup 被复制 | OS ACL、加密备份、keyring 分离、secret 不落库 | +| 重复/迟到 Usage 重复扣费 | event/idempotency key、canonical revision、CAS、差额账本 | + +## 15. 事件响应 + +### 15.1 downstream Key 泄露 + +撤销对应 SecretVersion → 刷新内存 snapshot → 确认新请求 401 → 签发新版本 → 审计受影响窗口 → 检查异常金额/路由。 + +### 15.2 Management Key 泄露 + +立即从 gateway 撤下管理入口 → 轮换 CPA Management Key → 审查价格、adjustment、Key、route、backup/export 审计 → 必要时恢复已验证 DB backup。 + +### 15.3 HMAC keyring 泄露 + +视为所有 downstream Key 校验材料泄露。先撤流量,再增加新 active HMAC key、轮换所有 Credential SecretVersion、保留旧 key 仅完成迁移,最后撤销旧 key。仅轮换 keyring 而不轮换用户 Key 不足以消除风险。 + +### 15.4 OAuth/Auth 泄露 + +在 CPA/provider 侧撤销 OAuth → 将 UpstreamAccount 标不可用 → 检查 quota adapter、日志、dump 和 backup → 重新登录并人工 reconciliation,不自动继承旧绑定。 + +### 15.5 账本/数据库疑似损坏 + +gateway fail closed → 保存现场副本 → integrity/reconciliation → 用匹配 keyring 的已验证备份离线恢复 → 从权威事实重建投影 → 对差异使用显式 adjustment,不手改账本行。 + +## 16. 验收标准 + +- 插件缺失、disable、register/reconfigure 失败、panic、fuse 和 Home 模式均无法让普通用户 Key 访问上游; +- interceptor/scheduler 的 error、panic、timeout 和无效响应完成故障注入; +- DB、keyring、price、ledger 不健康时新请求 fail closed; +- downstream/Management/HMAC/OAuth secrets 不出现在数据库、日志、API、core dump 测试样本或发布包; +- 用户跨账户 ID/cursor/cache 尝试全部失败; +- resource route 枚举证明没有未鉴权写操作或管理员数据; +- SSRF 测试覆盖重定向、私网、DNS 变化、超大响应和恶意 URL; +- 金额/价格/账本篡改和重复事件被 revision/hash/idempotency 检测; +- 入口大小限制在 CPA ReadAll 前生效; +- 动态库检查、checksum、NOTICE 和构建 provenance 完整; +- 安全事件均有可执行 runbook 和审计证据; +- 所有门禁纳入 [test-plan.md](test-plan.md),而不是只写在文档里。 diff --git a/docs/modules/statistics.md b/docs/modules/statistics.md new file mode 100644 index 0000000..77201ee --- /dev/null +++ b/docs/modules/statistics.md @@ -0,0 +1,342 @@ +# 统计分析模块 + +## 1. 定位 + +统计分析模块是 `cpa-ext` 的数据处理层。它把已经持久化的请求、执行、用量、账单和账本事实,转换为可查询、可重建、适合 UI 展示的统计结果。 + +它回答以下问题: + +- 一段时间内消费了多少钱、发生了多少请求; +- 金额、请求、Token 和性能随时间如何变化; +- 哪些 Key、用户、模型和上游账户产生了消费; +- 成功率、错误、TTFT、Latency 和缓存情况如何; +- 原始事实是否已被聚合、统计是否滞后、是否存在无法核算的数据。 + +它不负责: + +- 从 CPA 回调取得事实; +- 决定请求是否放行; +- 选择上游账户; +- 计算一笔请求应该扣多少钱; +- 修改账单、余额或账本。 + +最重要的边界是:**统计模块只能汇总计费模块已经确定的金额,不能根据 Token 和当前价格重新计算历史费用。** + +## 2. 输入、处理与输出 + +```text +Request / Execution / Usage / Billing / Ledger / Identity facts + │ + ▼ + 增量读取 → 维度规范化 → 时间分桶 → 聚合计算 + │ + ▼ + Rollup rows + independent checkpoints + │ + ▼ + Query services → permission projection → UI/API DTO +``` + +### 2.1 输入事实 + +| 输入 | 用途 | 权威来源 | +| --- | --- | --- | +| `RequestRecord` | 请求数量、终态、入口、下游身份、请求时间 | 采集与 Core | +| `ExecutionRecord` | 重试、上游账户、实际模型、性能、失败 | 采集与 Core | +| `UsageRecord` | Token 分项、数据质量、generate 状态 | 采集模块 | +| `BillingRecord` | 请求对应的已确认金额、价格快照引用、核算状态 | 计费模块 | +| `LedgerEntry` | 实际扣款、补记、退款、充值和管理员调整 | 计费模块 | +| `IdentityRecord` | Key、账户、Auth、Provider 的稳定展示维度 | 数据/目录模块 | +| `QuotaSnapshot` | 上游配额与订阅的时间点分析 | 采集模块 | + +所有输入必须已经落入持久化层。进程内通知只负责唤醒处理器,不能作为唯一事实来源。 + +### 2.2 处理结果 + +统计处理产生两类数据: + +- `AggregateRecord`:小时、日、活动度、延迟等可重建聚合; +- 查询投影:把事实、聚合、目录和权限组合成 API/UI 需要的只读结果。 + +聚合数据不是账本真相。它可以删除、重建和升级;任何重建结果都必须与原始事实和账本一致。 + +## 3. 统一统计口径 + +### 3.1 请求、执行和账单不能混为一层 + +- 用户请求数按 `RequestRecord` 计数; +- 上游尝试数按 `ExecutionRecord` 计数; +- 上游失败率可以按 Execution 计算; +- 用户看到的请求结果按 Request 最终终态计算; +- 金额按属于该 Request 的有效 LedgerEntry 净额汇总。 + +一次请求发生三次重试时,用户请求数是 1,上游执行数是 3;如果三个执行都产生了**可可靠关联**的上游消费,管理员审计必须能看到三次成本事实。当前 CPA 无 RequestID 的 attempt usage 无法安全归属时,执行仍计数,但金额覆盖率通过 `unmeasured` 单独表达。 + +### 3.2 金额口径 + +金额统计统一使用 `int64 amount_micros`: + +```text +请求最终消费 = Σ usage-linked `spend_delta_micros` +``` + +其中 charge/late settlement 为正、Usage refund 为负;账户充值和普通余额 adjustment 的 spend delta 为 0。余额则单独由 `balance_delta_micros` 推导:扣费为负、充值/退款为正。二者都以金额表示,但不能混为同一个统计口径。 + +禁止: + +- 使用 `float64` 累计余额; +- 查询时按当前价格重算历史 Usage; +- 因价格目录更新而改变已经结算的趋势; +- 把未定价或未取得 Usage 自动视为确认的免费请求。 + +用户视图只返回金额;管理员视图可以同时返回 Token、单价、倍率、核算质量和价格版本。 + +### 3.3 取消、失败和迟到结算 + +请求终态与金额是两个正交维度: + +- `outcome=canceled/failed` 但存在可靠 Usage:金额进入正常统计; +- `outcome=canceled/failed` 且无可靠 Usage:当前金额为 0,同时计入 `unmeasured_count`; +- 迟到 Usage 产生 `late_settlement`:用差额账本更新原请求所属的业务时间桶; +- 账本的 `booked_at` 同时保留,用于管理员审计“何时发现并补扣”。 + +因此查询可以同时提供: + +- `occurred_amount`:按原请求时间归属的消费趋势; +- `booked_amount`:按实际入账时间归属的账本变化。 + +用户消费趋势默认使用 `occurred_amount`;账本审计默认按 `booked_at` 排序。 + +### 3.4 时间和区间 + +- 事实时间保存 UTC; +- 所有查询使用半开区间 `[start, end)`; +- 小时桶以配置时区的钟面整点为边界; +- 日桶以配置时区的本地自然日为边界; +- DST 日期允许出现 23 或 25 小时,不能固定假设一天等于 24 小时; +- 修改统计时区需要显式重建依赖本地边界的聚合。 + +## 4. 目标聚合集合 + +### 4.1 `money_overview_hourly` / `money_overview_daily` + +这是用户和管理员金额页面的核心聚合。 + +维度按实际查询需求控制: + +- downstream account / credential; +- model / model alias; +- provider / upstream auth; +- effective service tier; +- outcome; +- endpoint。 + +指标: + +- request count、success/failure/canceled/rejected count; +- charge、late-settled、Usage refund/correction 的 spend delta micros; +- final charged amount micros; +- measured/partial/unmeasured count; +- input/output/cache/reasoning Token(仅管理员)。 + +不要把所有维度机械组合到一张极宽聚合表。MVP 先覆盖 UI 已经确定的筛选;低频组合可以查日级聚合或受限事实明细。 + +账户充值和非 Usage 人工余额调整只进入 balance/ledger 投影,不具有 model/Auth/endpoint,不能强行归属到本表这些请求维度。 + +### 4.2 `request_health` + +按时间桶和必要维度保存: + +- 用户请求数与上游执行数; +- 成功、失败、取消、拒绝; +- HTTP/标准错误类别; +- RPM; +- 取消率、无核算率、价格不可用率。 + +RPM 是所选窗口请求数除以窗口分钟数。空窗口返回 0;部分窗口必须使用真实覆盖分钟数,不能按完整日除。 + +### 4.3 `usage_activity` + +参考 `cpa-usage-keeper` 的 Activity 设计,提供: + +- 日内细粒度活动; +- 7 天和 30 天活动; +- 长期自然日活动; +- 成功/失败以及 Token 分项。 + +第一版可以简化为小时和日两级;但边界函数必须只有一套实现,并同时被聚合、查询和测试使用。 + +### 4.4 `latency_stats` + +只把满足条件的真实生成请求作为性能样本: + +- 成功执行; +- `generate=true`; +- TTFT、总延迟为有效正值。 + +保存: + +- sample count; +- TTFT/Latency 可合并分位 sketch; +- P50/P95/P99; +- 精确最大值; +- 有界、稳定、按 EventID 去重的真实散点样本。 + +不要只保存平均值,也不要为图表永久保存无限散点。 + +### 4.5 `identity_stats` + +按下游账户、Key、模型、上游 Auth 和 Provider 提供: + +- 请求与执行数量; +- 金额; +- 最近使用时间; +- 成功率和取消率; +- 管理员 Token 与性能摘要。 + +目录名称变化只改变展示解析,不应重写历史事实中的稳定 ID。 + +## 5. 增量处理与 checkpoint + +所有领域事务向统一 `projection_events` 追加变化。该表包含 `event_seq INTEGER PRIMARY KEY AUTOINCREMENT`、`event_id UNIQUE`、kind、fact ID/revision、occurred/booked time、可选 supersedes ID 和确定性 delta payload。外部 UUID/EventID 用于业务幂等,`event_seq` 用于稳定分页和 checkpoint;不能拿时间戳、随机 UUID 或各事实表互不相关的自增 ID 充当全局聚合游标。 + +状态修订必须发布逆向/正向差量。例如 unmeasured 在迟到 Usage 后变为 measured 时,同一 projection event 表达 `unmeasured_count=-1`、`measured_count=+1` 和新增 `spend_delta_micros`,不能只新增 measured 行让请求被计两次。领域事实与 projection event 必须在同一 SQLite 事务提交。 + +每种聚合拥有独立 checkpoint,例如: + +```text +money_overview +request_health +activity +latency +identity +``` + +处理规则: + +1. 新事实事务提交后,只发送非阻塞唤醒和最大 `event_seq`; +2. runner 冻结本轮目标上界,按固定页大小读取事实; +3. 纯函数在内存中生成确定性的增量行; +4. 聚合 upsert 与 checkpoint 推进处于同一短事务; +5. 单类失败只阻止自己的 checkpoint,不冻结其他聚合; +6. 启动时从数据库最大事实序号和各 checkpoint 自动追平; +7. SQLite 有前台事实等待写入时,聚合主动让出唯一 writer。 + +同一输入页重复执行必须得到相同结果。推荐使用 `(aggregate_kind, source_event_seq)` inbox/应用记录或等价事务约束,确保崩溃发生在 upsert 与推进之间时仍可安全重试。 + +动态库内后台 goroutine 在目标 CPA 的双 Go runtime 下尚未证明安全。runtime soak gate 通过前,runner 由安全的 host callback/管理查询按时间和页数预算增量驱动,或运行在 sidecar;统计算法与 checkpoint 不依赖“常驻 goroutine 一定存在”。 + +## 6. 实时查询 + +实时视图由两部分组成: + +```text +已聚合到 checkpoint 的稳定数据 + + checkpoint 之后的近期事实右边界补偿 +``` + +近期缓存只是性能优化: + +- 缓存丢失时回退到 SQLite; +- 缓存项必须来自已提交事务; +- 查询以 checkpoint/event_seq 切开两段,不能重复累计; +- 页面不可见时停止轮询; +- 实时窗口默认 5~15 分钟,刷新间隔由 UI 控制。 + +每个 widget 返回自身 projector 的 `as_of_seq` 和 lag。一个组合响应若要求强一致,使用所有相关 checkpoint 的最小值作为共同 `as_of_seq`,并分别从该位置做右边界补偿;不能拿 money checkpoint 切 latency/identity 数据。 + +## 7. 查询服务 + +统计模块向 Core/管理 API 提供只读服务,不直接处理 HTTP: + +```go +type StatisticsService interface { + Overview(ctx context.Context, scope QueryScope, filter OverviewFilter) (Overview, error) + Realtime(ctx context.Context, scope QueryScope, filter RealtimeFilter) (Realtime, error) + Analysis(ctx context.Context, scope QueryScope, filter AnalysisFilter) (Analysis, error) + Activity(ctx context.Context, scope QueryScope, filter ActivityFilter) (Activity, error) + Events(ctx context.Context, scope QueryScope, filter EventFilter) (EventPage, error) + Diagnostics(ctx context.Context) (AggregationDiagnostics, error) +} +``` + +`QueryScope` 必须由 Core 根据已认证主体构造: + +- 用户 scope 只能查询自己的 account/credential,且 DTO 只含金额; +- 管理员 scope 才能指定任意 Key/Auth/model 并查看 Token、价格与核算质量; +- Repository 不能信任浏览器传来的 account ID; +- 分页、时间上限、导出数量和查询复杂度必须由服务端限制。 + +交付阶段固定为: + +- MVP:money hourly/daily(合并基本 request outcome/quality count)、用户金额摘要/趋势/最近请求、管理员 Overview、请求事件分页和一个统一 money checkpoint; +- 第二阶段:独立 request health、Activity、latency sketch/散点、identity rollup、Key/模型/Auth 构成、realtime cache; +- 数据量达到实测阈值后:冷归档、影子表重建、排名和高维分析。 + +所有阶段都保留完整底层事实;延后页面能力不等于丢弃数据。 + +## 8. 重建、修正和归档 + +- 所有聚合必须支持从 0 全量重建; +- 也可按聚合类型或时间范围重建; +- 重建写入影子表/新版本后原子切换,避免页面看到半成品; +- 聚合 schema/算法版本必须记录; +- 事实修正只能追加 revision/correction,不能原地篡改已入账历史; +- MVP 不物理归档 Request/Execution/Usage 最小事实;账本引用关系先保持完整; +- 后续若引入冷归档,所有依赖 checkpoint 必须追过归档上界,统一 view 仍能读取重建来源,不能只保留聚合后删除账本依据。 + +价格变化不触发历史金额重建。只有数据修复或追加账本会改变金额聚合。 + +## 9. 数据质量与诊断 + +统计服务必须公开: + +- 各 checkpoint 当前值、目标值和 lag; +- 最后成功/错误时间与脱敏错误; +- measured、partial、unmeasured、inconsistent 数量; +- 未定价、迟到结算和冲正数量/金额; +- 近期缓存是否降级; +- 聚合版本与是否正在重建。 + +用户视图不展示内部 Token 或价格错误细节;用户只看到本次金额是否“待核算/已补记”。管理员可以查看完整质量原因。 + +## 10. 如何参考现有项目 + +`cpa-usage-keeper` 是本模块的主要实现参考: + +| 主题 | 源码位置 | +| --- | --- | +| Overview 小时/日聚合纯函数 | `cpa-usage-keeper/internal/overview/aggregate.go` | +| Activity 多粒度边界与聚合 | `internal/activity/grain.go`、`aggregate.go` | +| 延迟 sketch 与稳定抽样 | `internal/latency/aggregate.go`、`sketch.go`、`sample.go` | +| 独立 checkpoint 和公平 runner | `internal/poller/usage_aggregation_runner.go` | +| 聚合实体 | `internal/entities/usage_*_stat.go`、`usage_aggregation_checkpoint.go` | +| 查询与右边界补偿 | `internal/repository/usage_overview_stats.go`、`usage_recent_event_cache.go` | +| Analysis 与事件查询 | `internal/repository/usage_analysis_projection.go`、`usage.go` | +| 服务层投影 | `internal/service/usage.go` | +| API 输出 | `internal/api/usage_overview.go`、`usage_analysis.go`、`usage_events.go` | + +应该吸收:确定性聚合、独立 checkpoint、批量追赶、实时补偿、分位 sketch、有界样本、查询服务分层和丰富的测试。 + +不能直接照搬: + +- 查询时依据当前价格重算历史费用; +- 把 Token 作为普通用户的核心展示单位; +- 所有分析功能一次性进入 MVP; +- 为每个 UI 组合增加写放大很高的索引或聚合维度。 + +`cpa-plugin-key-billing` 只用来核对账单输出字段和简单累计口径,不作为统计架构参考。其状态内累计和 30 天日志不能替代事实表、聚合表和 checkpoint。 + +## 11. 验收标准 + +- 同一事实集全量构建与任意批次增量构建结果一致; +- 重复通知、重复执行、崩溃恢复不会重复累计; +- MVP 统一 money checkpoint、后续各聚合 checkpoint 可以按阶段独立失败和恢复; +- 请求数、执行数和账单数不会混淆; +- 金额直接来自不可变账本,价格更新不会改变历史; +- 取消/失败的可靠 Usage 被计入金额,无 Usage 被计入 `unmeasured`; +- 迟到结算按原请求时间修正消费趋势,同时保留真实入账时间; +- 用户查询只能看到自己的金额投影; +- 管理员 Overview、Events、账本和余额在相同过滤范围内可核对; +- DST、空窗口、部分窗口、大范围查询和归档边界有测试; +- 百万级事实容量下,前台写入不会被聚合长事务持续阻塞。 diff --git a/docs/modules/test-plan.md b/docs/modules/test-plan.md new file mode 100644 index 0000000..788be34 --- /dev/null +++ b/docs/modules/test-plan.md @@ -0,0 +1,662 @@ +# 测试与发布门禁 + +## 1. 目标 + +本测试计划证明 cpa-ext 不仅“能加载”,还满足三项核心结果: + +1. 用户 Key 的认证、金额额度、上游绑定和实际 Usage 结算形成完整闭环; +2. 取消、重试、乱序、崩溃和宿主 fail-open 限制不会造成重复扣费或免费旁路; +3. native 插件不会把 CPA 的稳定性和流式性能降到不可接受水平。 + +测试以可观察结果为准,不以代码覆盖率或“接口返回 200”替代业务证明。 + +## 2. 固定基线 + +当前设计基线: + +| 项目 | 版本 | +| --- | --- | +| CLIProxyAPI | `v7.2.130` / `f43aad7637ad813745bf7d341acb5663617570c5` | +| ABI | `1` | +| RPC schema | `3` | +| key-billing 参考 | `v0.3.1` / `25b534ae386f830f537cca9215cff5586e630b3a` | +| usage-keeper 参考 | `v1.14.4` / `d62cad3f345ae574089a14a4ac75cca023c7ead6` | + +测试报告必须记录: + +- cpa-ext commit/version; +- CPA commit/tag、artifact checksum; +- OS/arch、Go/C compiler、SQLite driver; +- artifact checksum/export; +- topology(callback/sidecar/worker); +- plugin negotiated schema/capabilities; +- 测试配置 hash; +- 未执行项及原因。 + +“使用最新 CPA”不是可复现的测试标识。 + +## 3. 门禁分层 + +| Gate | 内容 | PR | Release | +| --- | --- | :---: | :---: | +| G0 | 格式、静态检查、依赖/文档一致性 | 必须 | 必须 | +| G1 | 纯领域/dispatcher 单元测试 | 必须 | 必须 | +| G2 | SQLite、并发、恢复和 race 集成测试 | 必须 | 必须 | +| G3 | 真正 c-shared ABI/export/load 测试 | 必须 | 必须 | +| G4 | 目标 CPA 端到端业务闭环 | 关键变更 | 必须 | +| G5 | 安全与故障注入 | 关键变更 | 必须 | +| G6 | 流式性能、容量和查询基准 | 性能相关 | 必须 | +| G7 | 24h soak、备份恢复、升级回滚和平台矩阵 | 可选 nightly | 必须 | + +任一 Required gate 失败都不能发布。不得以“已知问题”豁免认证旁路、账本不一致、秘密泄露、数据库损坏或结构性流式放大。 + +## 4. 测试环境 + +### 4.1 纯 Go + +- `cmd/cpa-ext/main_stub.go` 让普通 `go test ./...` 不依赖 C ABI; +- 每个测试使用独立 temp SQLite; +- 注入 fixed clock、ID generator、CSPRNG facade、price snapshot 和 fake host adapter; +- 禁止依赖测试执行顺序、真实当前时间或共享全局 DB; +- fixture 中金额使用 micros、倍率使用 Ratio; +- 所有 callback 默认视为并发、乱序、重复和可能缺失。 + +### 4.2 ABI 宿主 + +使用真实构建产物,不用纯 Go dispatcher 代替: + +- WSL/Linux `.so`; +- Windows `.dll`; +- 对应平台 CPA plugin-capable binary; +- 独立 config/auth/data/plugins 目录; +- 随机端口和临时 sentinel/Management/downstream Key; +- 测试完成明确停止进程并保存日志/结果。 + +### 4.3 Provider + +测试分三层: + +1. deterministic fake provider:覆盖错误、stream、Usage、迟到和断连; +2. CPA 内部兼容 provider/test server:验证完整翻译/重试; +3. 受控真实 Codex OAuth 测试账户:只在 release/nightly 验证真实协议和费用,使用最小请求预算。 + +真实 secret 只由 CI secret store 注入,不出现在命令行回显、fixture、artifact 或日志。普通 PR 不依赖真实 OAuth。 + +## 5. G0:静态与契约检查 + +最低命令: + +```bash +test -z "$(gofmt -l cmd internal)" +go vet ./... +go test ./... +git diff --check +``` + +检查: + +- ABI version 和 RPC schema 未混用; +- register/reconfigure capability shape 相同; +- 只声明已实现 method; +- wire JSON tag/casing 对照目标 CPA; +- API exact route 无 `:`, `*`, `..` 和宿主保留冲突; +- config/API/error enums 有唯一实现来源; +- migration 连续且不可重复编号; +- SQL 查询使用固定列/allowlist; +- 日志调用不接收 raw request/config/auth DTO; +- `os.Exit`、`log.Fatal`、未保护 panic 不存在; +- 文档中的 CPA revision、ABI/schema、capability 与代码一致; +- NOTICE/THIRD_PARTY 记录移植代码来源和许可证。 + +建议增加 secret scanner、dependency vulnerability scan、SBOM 和 artifact provenance。 + +## 6. G1:领域与 dispatcher 单元测试 + +### 6.1 数据和 Token 归一化 + +- OpenAI/Codex 普通输入、cache read、cache write、output 四段互斥; +- Reasoning 已包含 output 时不重复; +- total 与分项一致/不一致; +- missing、partial、unclassified、inconsistent; +- 相同 Usage 多来源择优,不相加; +- response ID / RequestID / ExecutionID 关联; +- nested host model callback 不重复采集; +- raw provider 值脱敏和质量字段保留。 + +### 6.2 Pricing Golden Tests + +每个 PriceVersion fixture 至少覆盖: + +| 场景 | 期望 | +| --- | --- | +| 四段各 1M Token | 每段精确使用自己的 rate | +| cache 已包含在 input | 普通输入扣除 cache,不重复 | +| request priority | 总基础金额 × 5/2 一次 | +| response priority | 总基础金额 × 5/2 一次 | +| request + response 都 priority | 仍然 × 5/2,不是 × 25/4 | +| response 明确 standard | 按有效 tier 政策处理并记录来源 | +| 长上下文 threshold-1/equal/+1 | 符合显式 gt/gte | +| 长上下文 + priority | 先阶梯价,再 × 5/2 | +| GPT-5.6 Sol/Terra/Luna | 分别 exact match,不互相继承 | +| alias | 只按显式 alias,冲突失败 | +| unknown model | unavailable,不是 0 | +| explicit free | available + 0,带确认标记 | +| 最大 Token/rate/multiplier | 不溢出、不出现 NaN/Inf | +| Usage revision | 新总额减已入账总额,舍入确定 | + +发布价格版本时在事务提交前运行同一组 compiled golden cases;API preview 和 runtime resolver 必须共享算法。 + +### 6.3 Billing + +- plan 周期首次准入激活、到期、never/custom; +- BillingAccount 多 Credential 共享金额; +- secret rotate 不新建余额/周期; +- 本地拒绝且无 Execution 金额 0; +- 成功/失败/取消有可靠 Usage 时按事实收费; +- 取消无 Usage 为 unmeasured,不猜费; +- 迟到 Usage 创建差额账本; +- 重复 callback/EventID/response ID 幂等; +- 更小/矛盾 cumulative vector 不自动退款; +- last request 可形成有限负余额,之后拒绝; +- 默认 account concurrency=1; +- adjustment/refund 使用新账本项; +- 余额从 ledger 重建与投影一致。 + +### 6.4 Access/Route + +- unknown/disabled/expired/revoked Key; +- unbound account/plan/price 默认拒绝; +- endpoint/model/tier allowlist; +- strict target 在 candidates 中/不在 candidates; +- preferred fallback 与原因; +- pool round-robin/fill-first; +- CPA delegate response; +- retry 排除已 tried Auth; +- lower priority account `priority_shadowed`; +- scheduler error/panic/invalid/unhandled; +- after-auth actual Auth mismatch 必须 Terminate; +- config snapshot 原子替换。 + +### 6.5 Upstream accounts + +- AuthID/AuthIndex/internal ID 映射; +- 新增、rename、missing、return、replacement; +- 同 email/label 不自动 merge; +- host status/disabled/unavailable/next retry; +- candidate visibility 按 provider/model/priority; +- quota fresh/stale/partial/error/unknown; +- Codex 主/次窗口、additional limits、subscription/reset credits; +- refresh 去重、批量、冷却和超时; +- raw Auth JSON 不越过 adapter。 + +### 6.6 RPC dispatcher + +- malformed JSON、nil/empty bytes、unknown method; +- register/reconfigure schema negotiation; +- future host/低 schema; +- invalid initial config 返回错误; +- invalid reconfigure 返回 LKG success registration; +- 每个 declared method 可分派; +- method boundary panic recover; +- error envelope 脱敏; +- concurrent register/reconfigure/call/shutdown; +- shutdown 多次调用、调用后请求; +- response buffer ownership 的普通 helper 测试。 + +## 7. G2:SQLite、并发与恢复 + +### 7.1 Migration + +- 空库创建当前 schema; +- 每个历史 schema 逐级迁移; +- 重复启动不重复 migration; +- migration 中途失败不开放 readiness; +- 破坏性 migration 前 backup gate; +- 新版本 DB 回滚兼容声明有自动测试; +- schema/data contract version 分离。 + +### 7.2 事务和幂等 + +- provision 原子写账户、Credential、plan/route、audit; +- Request→Execution→Usage→Billing→Ledger→ProjectionEvent 原子边界; +- DB error 在每个 statement/commit 点注入; +- 同一 EventID 并发 2/10/100 次只结算一次; +- revision CAS 冲突重试/返回冲突; +- outbox/checkpoint 与事实一致; +- 账本总和、余额、统计 spend reconciliation。 + +### 7.3 Crash recovery + +在以下时点强制终止进程并重启: + +- Request pending 已写、尚未选 Auth; +- Execution 已写、Usage 未到; +- Usage 已 durable、账本未写; +- 账本已写、projection/outbox 未发布; +- completion 丢失; +- backup/checkpoint/rebuild 中间; +- WAL 有未 checkpoint 内容。 + +恢复后不能永久占用并发、重复扣费或把可靠 Usage 丢成 0。 + +### 7.4 并发/race + +```bash +go test -race ./... +``` + +覆盖: + +- 同 BillingAccount 并发准入/结算; +- Key rotate/revoke 与在途请求; +- price publish 与在途 Usage; +- route/account snapshot replace 与 scheduler pick; +- quota refresh dedupe; +- shutdown/reconfigure 与 callback; +- SQLite busy/locked、reader/writer pool; +- callback-driven maintenance budget。 + +## 8. G3:C ABI 与动态库 + +WSL/Linux 示例: + +```bash +CGO_ENABLED=1 go build -tags cshared -buildmode=c-shared -o bin/cpa-ext.so ./cmd/cpa-ext +file bin/cpa-ext.so +nm -D bin/cpa-ext.so | grep cliproxy_plugin_init +``` + +验证: + +- 目标架构/动态库格式; +- export `cliproxy_plugin_init`; +- ABI version=1; +- `call/free_buffer/shutdown` 均非空且签名正确; +- request bytes 在保留前复制; +- empty/error response ptr/len 初始化; +- plugin 分配的内存只由 plugin free; +- host callback buffer 只由 host free; +- 大小 0、畸形长度、并发调用; +- reentrant host callback; +- shutdown 等待/超时和 active call; +- Windows shadow copy/changed content path; +- Linux/macOS loader 错误和缺失 symbol。 + +普通 Go 测试通过不能替代此 Gate。 + +## 9. G4:真实 CPA 端到端 + +### 9.1 启动验证 + +1. 目标 CPA 启动; +2. plugin global/instance enabled; +3. 日志显示正确 ID/version/path; +4. negotiated schema=3; +5. capability shape 与阶段一致; +6. Management exact routes 注册且无冲突; +7. frontend exclusive active; +8. Home 关闭; +9. self readiness + gateway gate 通过。 + +### 9.2 MVP 价值闭环 + +```text +管理员发布价格 +→ 同步 Codex Auth / 确认 bindable +→ provision BillingAccount + Key + strict route +→ 用户调用 /v1/responses +→ CPA 选择指定 OAuth Auth +→ provider 返回 Usage +→ cpa-ext durable Usage + 金额账本 +→ 用户 API 显示金额余额/请求 +→ 额度耗尽后新请求 429 +``` + +验证数据库事实、管理 API、用户 API、CPA log 和 provider fake recorder 五方一致。 + +### 9.3 请求矩阵 + +| 维度 | 值 | +| --- | --- | +| transport | HTTP SSE;Codex WebSocket 若发布支持 | +| response | streaming / non-streaming | +| outcome | success / upstream 4xx / upstream 5xx / timeout / canceled / local reject | +| Usage | complete / partial / missing / duplicate / late / inconsistent | +| retry | 0 / 1 / 多 Auth attempts | +| tier | standard / priority | +| context | normal / long threshold 边界 | +| route | strict / preferred / pool / delegate | +| state | active / quota exhausted / key revoked / price unavailable | + +当前 MVP allowlist 外的 WebSocket Alpha Search、Live、Realtime/client secret、chat/messages/image/video 等路径必须证明用户 Key 请求失败,不能只测试支持路径。 + +### 9.4 取消测试 + +- client 在连接上游前取消:不收费; +- first byte 前取消但 provider 报 Usage:收费; +- stream 中途断开,provider 有最终/迟到 Usage:按实际差额收费; +- stream 中途断开永远无 Usage:unmeasured,不猜费; +- completion 在 Usage 前/后/重复; +- 取消后并发槽及时释放; +- 高频 canceled+unmeasured 触发风险计数,不直接伪造金额。 + +可参考 key-billing `scripts/e2e_cpa_billing.sh` 的多协议、stream/non-stream、账单比对和客户端断开测试;需要扩展金额定点、Fast、strict route、数据库恢复和旁路门禁。 + +## 10. G5:安全与故障注入 + +### 10.1 认证旁路矩阵 + +逐项验证普通用户 Key 无法访问上游: + +- plugin binary 缺失; +- global plugins disabled; +- cpa-ext instance disabled; +- register invalid; +- reconfigure invalid; +- frontend auth panic/error; +- request interceptor panic/error; +- scheduler panic/error/invalid response; +- plugin fused; +- Home enabled; +- database/keyring/price unavailable; +- native CPA 只剩 sentinel; +- gateway readiness 失联。 + +同时验证 sentinel 未出现在 UI/config response/log/test report,且不被日常应用持有。 + +### 10.2 依赖故障期望 + +| 故障 | 当前请求 | 后续请求 | 内部状态 | +| --- | --- | --- | --- | +| credential DB lookup 失败 | 401 no_credentials(宿主限制) | fail closed | high severity auth dependency | +| admission DB/ledger/price 失败 | success RPC + Terminate 503 | fail closed | readiness unhealthy | +| scheduler error/panic | 宿主可能 fallback | after-auth mismatch Terminate | fuse/diagnostic | +| Usage durable write 失败 | 无法撤销已产生上游成本 | 后续 fail closed | persistence_gap + reconciliation | +| completion 丢失 | 响应可能已完成 | recovery 扫描 | abandoned/settle pending | +| price candidate refresh 失败 | 不影响 active price | 继续 LKG | candidate stale | +| statistics projection 失败 | 计费继续 | UI 显示 lag | checkpoint error | +| quota refresh 失败 | 基础路由按 host candidate | snapshot stale | quota error | + +### 10.3 Web/API 安全 + +- Management route 无 key/错误 key/remote deny; +- resource 枚举、非 GET、敏感 route 404; +- 用户 cross-account ID/cursor/cache; +- brute force/rate limit; +- SQL/filter/order injection; +- JSON duplicate/unknown/oversize/trailing document; +- XSS labels、Management entity encoding、CSP/nosniff; +- CSV formula injection; +- CORS/Origin/iframe/message; +- SSRF:localhost、私网、redirect、DNS rebinding 模拟、非 HTTPS、超大 body; +- request log/download token 过期和审计; +- backup/path traversal/symlink; +- error/log secret redaction。 + +### 10.4 Secret canary + +为 downstream Key、Management Key、HMAC key、OAuth token、cookie、Prompt 各生成唯一 canary,测试结束扫描: + +- SQLite/WAL/backup(按预期字段例外审查); +- stdout/file logs; +- API/HTML/CSV; +- panic/error strings; +- test artifacts; +- release archives。 + +任何未批准位置命中即失败。 + +## 11. G6:性能与容量 + +### 11.1 流式 A/B + +保持完全相同的 CPA binary、OAuth/Auth、provider、model、service tier、transport、请求体和网络路径,只改变插件阶段: + +1. 无 cpa-ext; +2. schema 3 + 空 capability/hook; +3. collection-only; +4. 完整 cpa-ext。 + +矩阵: + +- SSE / WebSocket; +- prompt 1 KiB / 128 KiB / 1 MiB; +- 32 / 256 / 1024 chunks; +- concurrency 1 / 8 / 32; +- standard / Fast; +- success / failure / cancel。 + +采集: + +- TTFT P50/P95/P99; +- 总时长、tokens/s、chunks/s、chunk gap; +- CPU、RSS、alloc、GC; +- 每 method RPC count/bytes/max payload; +- SQLite/lock latency; +- canonical Usage/amount 一致性。 + +门槛: + +- 常见场景完整插件相对无插件中位退化不超过 5%; +- 压力场景不超过 10%; +- P95 TTFT 增量不超过 `max(20ms, 5%)`; +- 超门槛需要明确审批和产品理由,不能隐藏; +- 结构性断言失败无条件失败。 + +结构性断言: + +- schema 3 payload chunk 的 OriginalRequest/RequestBody 为空,header-init 除外; +- 单独统计 response-before 仍重复携带的 request bodies; +- 未使用 HistoryChunks 时不能默认传 64 chunks/1 MiB;若宿主暂不能关闭,必须量化并推动契约修改; +- 正常 chunk 的插件成本近似 O(chunk),不能是 O(prompt + history); +- 计费正确性与无插件 provider Usage 一致。 + +### 11.2 SQLite/查询容量 + +参考 usage-keeper capacity-v1,构造 canonical 数据集: + +- 3M+ events; +- 50 Key、500 upstream identities、50 models 的基础档,并增加更大档; +- 90 天 hot data; +- 1% failure、取消、late usage、retries; +- 完整 ledger/projection/checkpoint。 + +验证 ingestion 最大稳定点、Dashboard 核心 API p95/p99、RSS/cgroup peak、DB/WAL size、backup/rebuild 时间和查询计划。每个 probe 使用独立 clone,避免缓存/WAL 污染。 + +## 12. G7:24h Soak 与生命周期 + +P0 runtime spike 至少比较: + +- 零后台 goroutine + 内存状态; +- callback-driven + 目标 SQLite driver; +- sidecar; +- 仅在候选时验证的 bounded worker。 + +24h workload: + +- 持续 stream/non-stream 请求; +- concurrency 波动; +- 周期性管理查询/price/account snapshot; +- callback-driven maintenance; +- DB WAL/checkpoint/backup; +- config valid/invalid reconfigure; +- cancel/retry/provider failures; +- quota refresh(适用拓扑)。 + +每轮生命周期注入: + +- plugin config disable/enable; +- binary version change; +- CPA graceful shutdown/restart; +- blocked plugin call 时 shutdown timeout; +- Windows shadow copy/retired library; +- Linux loader/unload 行为。 + +通过条件: + +- 无 `bad flushGen`、runtime fatal、panic、deadlock、use-after-free; +- RSS/handle/goroutine/DB connection 无持续无界增长; +- retired runtime 不继续写同一 DB; +- shutdown 有界且事实/账本一致; +- 无重复 settlement/checkpoint; +- TTFT/吞吐无随时间恶化; +- backup/restore 和 integrity 通过。 + +若 DLL worker 方案失败,结论不是“再调参数”,而是把长期任务永久移到 sidecar/callback 拓扑并重跑门禁。 + +## 13. API 与 UI 测试 + +### 13.1 API contract + +- 每个 [api.md](api.md) route 的 method/path/auth/status/envelope; +- Money JSON、UTC time、empty collection、opaque ID; +- revision/If stale、Idempotency-Key; +- Credential secret first response/replay; +- cursor 并发插入、scope binding、过期/篡改; +- pagination/time/filter max; +- stable error codes 与脱敏 500; +- resource JSON plain string 与 Management entity-v1 差异。 + +### 13.2 UI + +- desktop 与窄屏; +- admin/user view 数据隔离; +- Key/Management secret 仅内存; +- negative/large/zero amount formatting; +- quota unknown/stale/partial; +- unpriced/unmeasured/persistence gap; +- loading/error/empty states; +- `A & B ` entity decode 后仍以 text node 渲染; +- CSP 下无 inline/eval 违规; +- no service worker/cache sensitive data; +- Home/ready failure 显示阻断,不伪装空数据; +- destructive actions 有确认、reason、revision conflict 和恢复提示。 + +若修改 UI,按 key-billing AGENTS 的实践使用真实浏览器检查桌面/窄屏;静态快照不能替代交互验证。 + +## 14. 备份、升级和灾难恢复测试 + +- 在线 backup 与并发写; +- 打开 backup、integrity、schema/ledger check; +- SQLite + matching keyring 成对恢复; +- 缺失/错误 keyring fail closed; +- 从事实重建全部 projections; +- 升级前 backup → migration → 新版流量; +- backward-compatible binary rollback; +- incompatible migration 使用 backup rollback; +- 升级窗口 Usage reconciliation; +- price rollback 创建新版本; +- 操作均有审计和 runbook 时间记录。 + +RPO/RTO 必须用演练结果制定,不能只写目标数字。 + +## 15. 平台与兼容矩阵 + +每个 release: + +| CPA | Linux amd64 | Linux arm64 | Windows amd64 | macOS(若发布) | +| --- | :---: | :---: | :---: | :---: | +| exact minimum/tested baseline | 全 Gate | G0-G5 + smoke/soak | G0-G5 + Windows lifecycle | 对应 artifact gates | +| 拟升级 CPA | 全 Gate | smoke + ABI | smoke + ABI | smoke + ABI | + +当前正式 baseline 固定 `v7.2.130`。当支持范围扩展时,最低版本和最高验证版本都必须有 E2E;中间版本不能仅凭 semver 假设兼容。`no-plugin` artifact 必须启动拒绝/部署前置失败,不能被误判为兼容。 + +## 16. CI/Release 命令清单 + +WSL/Linux 基础: + +```bash +./scripts/check-env.sh +test -z "$(gofmt -l cmd internal)" +go vet ./... +go test ./... +go test -race ./... +CGO_ENABLED=1 go build -tags cshared -buildmode=c-shared -o bin/cpa-ext.so ./cmd/cpa-ext +file bin/cpa-ext.so +nm -D bin/cpa-ext.so | grep cliproxy_plugin_init +``` + +随后执行: + +- CPA test-host load/register/capability tests; +- deterministic provider E2E; +- security/fault suite; +- stream A/B; +- migration/backup/restore; +- release OS/arch builds + checksums; +- nightly/release soak。 + +脚本必须使用临时明确目录并在退出时停止子进程;不得递归删除未验证路径。测试日志先脱敏再上传。 + +## 17. 参考现有项目 + +### 17.1 CLIProxyAPI + +重点复用 `internal/pluginhost/*_test.go` 对以下行为的断言: + +- schema negotiation、future schema; +- reconfigure、fuse、blocked load/shutdown; +- frontend exclusive priority; +- scheduler error/panic/invalid fallback; +- request termination 和 async completion; +- Management exact/resource route; +- host callback buffer/context; +- Windows shadow copy; +- stream bridge cancel/full buffer。 + +### 17.2 key-billing + +- `scripts/e2e_cpa_billing.sh`:真实 CPA release、协议/stream、账单和 cancel; +- `internal/billing/*_test.go`:Key、plan、四段价格、长上下文、store; +- `internal/plugin/*_test.go`:dispatcher、usage correlation、Management UI。 + +不能继承其固定 schema 2 性能形状、JSON store、缺少 Fast 和只验证最终 Usage 的覆盖边界。 + +### 17.3 usage-keeper + +- `internal/pricing/test/`:Snapshot/rule/resolver; +- `internal/quota/test/`:Codex quota、refresh、subscription; +- `internal/repository/*_test.go`:SQLite、migration、aggregation; +- `internal/api/test/`:auth、scope、CSP、no-store、redaction; +- `internal/benchmark/capacity-v1`:真实容量方法。 + +Keeper 是 sidecar 服务,其 worker/session/Gin 路由测试不能直接证明 c-shared 安全,必须在真实 CPA ABI 中重测。 + +## 18. 发布报告模板 + +```text +cpa-ext version/commit: +CPA version/commit/checksum: +ABI / negotiated schema: +Capabilities: +OS/arch/toolchains: +Runtime topology: +Artifact path/checksum/export: +G0: +G1: +G2: +G3: +G4: +G5: +G6: +G7: +Performance delta: +Soak duration/result: +Backup/restore result: +Known limitations: +Checks not run: +Approver/date: +``` + +## 19. 最终完成标准 + +- 所有模块验收项映射到自动测试或明确人工证据; +- MVP 纵向闭环、取消、失败、迟到 Usage 和重试全部通过; +- Fast/priority 2.5× golden cases 无双乘; +- plugin 缺失/fuse/reconfigure/Home 等旁路测试全部 fail closed; +- secret canary 扫描无非预期命中; +- SQLite crash/recovery、backup/restore、ledger reconciliation 通过; +- stream 结构断言和性能阈值通过; +- 24h soak 无 runtime crash/leak/deadlock; +- 每个发布 artifact 在目标 CPA/OS/arch 真机加载; +- 发布报告完整记录未执行项,不以口头确认替代证据。 diff --git a/docs/modules/ui.md b/docs/modules/ui.md new file mode 100644 index 0000000..cb1f86a --- /dev/null +++ b/docs/modules/ui.md @@ -0,0 +1,261 @@ +# 管理与展示 UI 模块 + +## 1. 定位 + +UI 模块负责把数据、计费、Key 路由、配额和诊断能力投影成可使用的页面。信息架构和统计展示主要参考 `cpa-usage-keeper`,Key 套餐与金额操作参考 `cpa-plugin-key-billing`。 + +UI 不计算价格、不重新聚合账本、不保存业务真相。页面中的所有数字都来自 [api.md](api.md) 定义的权限化查询 API;UI 不直接读取 Repository 或 CPA Auth 原文。 + +## 2. 两种视图必须分离 + +### 2.1 用户视图 + +用户通过自己的 API Key 登录,只能看到与该 Key 所属账户有关的数据。根据已经确定的产品原则,用户可见的计费核心单位只有金额: + +- 总额度; +- 已消费金额; +- 剩余金额; +- 本周期起止; +- 今日/本月消费; +- 按日金额趋势; +- 最近请求的模型、时间、状态和最终金额; +- Key 状态和到期时间。 + +用户界面不显示 Token 数、credits、倍率、每百万 Token 价格、上游账户配额或内部核算字段。金额统一显示 settlement currency,并使用服务器返回的格式化字符串/定点值,不能由浏览器 float 重算。 + +### 2.2 管理员视图 + +管理员拥有完整运维与审计能力,可以查看: + +- 金额及 Token 明细; +- 价格和 Fast/long-context 规则; +- 下游账户、Key、套餐和余额; +- 上游账号、订阅、配额和健康状态; +- 请求/执行、重试、实际路由与失败; +- 统计分析、同步状态、数据库和插件诊断。 + +管理员页面中的“用户花费”和“上游配额”必须视觉与命名分离,避免把两种额度混为一谈。 + +本项目采用请求结束后结算的软金额额度。用户页必须写“软额度/请求后结算”,不得宣传“绝不超额”;余额很低时仍可能由最后一个在途请求产生有限负余额。 + +## 3. 管理员信息架构 + +主要沿用 usage-keeper 的七个区域,并加入本项目的计费/路由管理: + +### 3.1 总览 Overview + +首屏回答“现在系统是否正常、花了多少钱、谁在使用”: + +- 今日/本周期总金额、请求数、成功率; +- RPM、金额/分钟;管理员可切换查看 TPM/Token; +- 日均请求、日均金额; +- 金额、请求、缓存命中率时间序列; +- Recent Activity 活动格; +- 实时请求速率、金额速率、TTFT/Latency P50/P95; +- 当前模型、下游账户/Key、上游账户 Top; +- 上游账户即将耗尽、Key 已阻断、未定价事件、采集延迟告警。 + +参考组件:`StatCards`、`RecentActivityPanel`、`OverviewRealtimePanel`。 + +### 3.2 分析 Analysis + +- 金额和 Token 时间序列; +- 金额分项:输入、cache read、cache write、output; +- 模型效率:每请求金额、输出量、缓存率; +- Top models; +- TTFT/Latency 分布和异常点; +- 下游账户/Key、模型、Auth File、AI Provider 构成; +- Key×模型热力图; +- Fast 使用金额与占比; +- strict/preferred 路由命中和 fallback 分析。 + +参考 `analysis/AnalysisPanel.tsx`。金额始终使用已经入账的 `ChargedAmount`,不能按当前价格重算历史。 + +### 3.3 请求 Events + +可分页、筛选、排序和导出。管理员可配置显示列: + +- 时间、RequestID、Key/账户; +- requested/upstream/billing model 与 alias; +- requested/reported/effective service tier; +- success/failure/canceled/rejected; +- endpoint、stream、reasoning effort; +- TTFT、Latency、速度; +- Token 各分项与缓存率; +- 最终金额、价格版本、倍率、核算质量; +- 期望上游账户、实际账户、retry/fallback; +- 请求日志受控查看/下载入口。 + +参考 `RequestEventsDetailsCard` 的列偏好、分页、筛选、导出和 request log 交互。默认不展示 Prompt/Response;查看 CPA 请求日志必须显式授权、短期 token、审计记录和脱敏。 + +### 3.4 下游账户与 Key + +这是本项目相对 keeper 的核心新增页: + +- 账户名称、状态、套餐、本期额度/已用/剩余金额; +- 所属 Key、preview、alias、状态、有效期、最近使用; +- 创建/轮换/禁用/撤销 Key; +- 绑定/解绑套餐、充值、扣减、重置周期; +- 模型、endpoint、Fast 使用权限; +- route mode 与指定上游账户/池; +- 实际路由与 fallback 最近记录; +- 批量操作和 CPA Key 同步。 + +额度操作必须二次确认,并展示变更前后金额及将写入的账本原因。 + +### 3.5 上游账户 Auth Files / Providers + +沿用 usage-keeper 两个独立 tab: + +- Auth Files:OAuth/Codex 账户; +- AI Provider:配置型 API Key provider。 + +展示身份别名、provider、状态、priority、disabled、订阅层级/有效期、首次/最近使用、成功失败、用量、当前配额窗口和重置时间。支持: + +- provider 筛选、分页、排序; +- 编辑本地别名; +- 刷新单个或当前页配额; +- Codex quota inspection; +- 可用时执行 quota reset; +- 查看哪些下游 Key 严格/优先绑定到该账户。 +- 显示 `scheduler-visible/bindable`;MVP 阻止绑定不在最高可用 priority tier 的 Auth,并解释当前 CPA scheduler 只能看到该 tier。 + +不要在 UI 返回上游 token、完整文件内容或 API Key。 + +### 3.6 价格与套餐 Settings + +- 模型价格目录、来源、版本和生效时间; +- 同步预览、确认发布、管理员覆盖; +- Fast 2.5x、long-context 和条件规则; +- 未定价模型告警; +- 套餐金额、周期、绑定账户数量; +- 安全/readiness 设置、数据库备份和保留策略;当前无插件管理 session,不把 Management Key 持久化。 + +参考 keeper 的 `PriceSettingsCard`、pricing rules,以及 key-billing 的 Plans/Prices 页面。禁止后台同步后无确认地改变生产计价。 + +### 3.7 系统诊断 + +- 插件版本、CPA ABI/RPC schema 与兼容状态; +- SQLite 路径、大小、WAL、最近备份和 integrity; +- runtime topology(callback-driven/sidecar/worker soak status)、required-plugin readiness 和 sentinel/gateway 状态(不显示 secret); +- pending request 数、采集/写入队列深度; +- 各 aggregation checkpoint lag; +- 未定价、missing、partial、inconsistent 计数; +- CPA identity/model/quota 最近同步结果; +- 插件运行事件和错误。 + +诊断日志与账本分开;清理诊断日志不会清除金额、请求事实或累计。 + +## 4. 用户页面布局 + +用户页面保持极简: + +```text +[当前 Key / 状态] [退出] + +[本周期额度 $20.00] [已消费 $7.25] [剩余 $12.75] [周期结束 8/31] + +[金额趋势:今天 / 7 天 / 本月 / 自定义] + +[最近请求] +时间 | 模型 | 状态 | 金额 +``` + +可以参考 usage-keeper 的 `KeyOverviewPage`、`StatCards`、`RecentActivityPanel` 与 realtime 交互,但将 Token/RPM/TPM/cache 等内容替换成用户需要的金额、请求和可用状态。 + +## 5. 交互原则 + +- 时间范围统一支持 today、yesterday、7/30 天、本月和自定义; +- Overview 可在页面可见时每 10 秒刷新,隐藏时暂停; +- Events 第一页可自动刷新,其他页保持稳定; +- 大表支持列显示/排序偏好、分页和导出; +- 空状态、加载、部分加载、数据延迟和价格不可用必须分别表达; +- Analysis 的慢查询分区并行加载,某一区失败不遮挡其他已完成卡片; +- 桌面和移动端都可用,表格横向滚动、tooltip 可键盘访问; +- 支持明暗主题和中英文,但第一版中文优先; +- 金额、时间、百分比由统一 formatter 处理。 + +## 6. 技术承载方式 + +第一版建议构建静态 React/Vite bundle,使用 `go:embed` 打入动态库,通过 CPA `management_api` 注册: + +- 受 CPA Management Key 保护的 JSON API 位于 `/v0/management/plugins//...`; +- browser resource 位于 `/v0/resource/plugins//...`;CPA 当前只支持未经过 Management Key 的精确 GET resource 路径; +- 管理员数据必须由受保护 Management API 获取,不能嵌入公开 resource HTML; +- MVP 用户页只注册少量只读 resource GET JSON 路径,由插件自行校验 `Authorization: Bearer ` 并按 Credential scope 返回金额投影; +- 用户 Key 只保存在页面内存,不进入 URL、cookie 日志或 localStorage;刷新后需要重新输入; +- 当前 CPA plugin route 不足以安全实现完整的用户 POST API 和 HttpOnly 登录 session。需要这些能力时应增加独立 sidecar/public API 或扩展 CPA 的 authenticated user plugin routes; +- HTML/JSON/CSV 导出全部转义,响应设置 CSP、`nosniff` 和 `no-store`(敏感接口)。 + +resource 只支持 exact GET,因此前端构建必须遵守: + +- Vite 使用相对 `base`,build manifest 中每个 JS/CSS/font 都注册成独立 ResourceRoute; +- 页面路由使用 HashRouter,不依赖 history fallback; +- 第一版不注册 service worker,避免公开资源或敏感 JSON 被离线缓存; +- CSP 禁止 inline 时使用独立资源或固定 hash; +- 用户 JSON 设置 `Cache-Control: no-store` 和 `Vary: Authorization`。 + +公开 resource shell 不会自动获得 CPA Management Key。管理员首次进入时手工输入 Key,只保存在当前页面 JS 内存,刷新即失效;不得读取或复用 CPA 控制面板的 localStorage/sessionStorage。用户 Key 采用相同的“当前页面内存”原则,但只调用用户只读 resource GET。 + +当前 CPA Management adapter 会对 JSON 字符串做 HTML entity 转义。客户端对服务端 JSON 字符串最多执行一次兼容性 entity decode,然后仍由 React text node 渲染;禁止 `dangerouslySetInnerHTML`。测试必须覆盖 `A & B ` 写入/读取往返。CSV 导出还要防 `= + - @` 开头的公式注入,不能把 HTML escape 当成 CSV 安全。 + +不能照搬 key-billing 在浏览器 session state 中借用 Management Key 的方式作为最终安全模型。 + +CPA `home.enabled` 时 Management 和 resource route 都返回 404,因此 cpa-ext 第一版整体不支持 Home;页面不能把它表现成普通“暂无数据”。 + +## 7. API 投影原则 + +- 用户 API 只返回自己的金额投影,不先返回管理员对象再让前端隐藏字段; +- 管理员 API 返回 Token/价格/上游身份等完整审计字段; +- 所有列表使用服务端分页、筛选和稳定游标; +- 图表 API 返回预聚合,不把几十万条事件交给浏览器计算; +- API 金额返回 currency + micros + display,前端不得自行从 Token 算钱; +- realtime、overview、analysis、events 独立接口,避免一个慢请求阻塞整个页面。 + +## 8. MVP 与后续 + +MVP 页面: + +1. 用户金额总览; +2. 管理员 Overview; +3. 下游账户/Key/套餐/账户路由; +4. 请求明细; +5. 上游 Codex 账户与配额; +6. 价格; +7. 系统诊断。 + +后续补齐 Analysis、复杂热力图、排名、更多 provider、导出与 request log。底层数据/API 契约从第一版就应容纳它们。 + +## 9. 参考路径 + +`cpa-usage-keeper` 为主要 UI 参考: + +- `web/src/pages/UsagePage.tsx`; +- `web/src/pages/KeyOverviewPage.tsx`; +- `web/src/components/usage/StatCards.tsx`; +- `RecentActivityPanel.tsx`、`OverviewRealtimePanel.tsx`; +- `RequestEventsDetailsCard.tsx`; +- `analysis/AnalysisPanel.tsx`; +- `credentials/`; +- `PriceSettingsCard.tsx`、`ApiKeySettingsCard.tsx`; +- `web/src/lib/api.ts`、`types.ts`。 + +`cpa-plugin-key-billing` 参考: + +- `internal/plugin/ui.html`; +- `internal/plugin/management.go`; +- `internal/billing/keys.go`、`log.go`。 + +## 10. 验收标准 + +- 用户登录后只能看到本账户且只有金额计费单位; +- 管理员能完成 Key、套餐、余额、路由、价格和上游账户管理; +- Overview、Events、上游配额与账本数字一致; +- 历史金额不因当前价格变化而改变; +- 权限校验在服务端执行; +- 页面不泄露下游/上游秘密、Prompt 或完整 Response; +- Management Key/用户 Key 只驻留当前页面内存,刷新失效,用户 JSON 不缓存; +- exact resource assets、HashRouter、CSP 和 entity-decode 往返测试通过; +- Home 模式由 readiness 明确拒绝,不显示误导性的空页面; +- 大数据量下使用分页和聚合,UI 不冻结; +- 关键动作有确认、审计、错误反馈和可恢复路径。 diff --git a/docs/modules/upstream-accounts.md b/docs/modules/upstream-accounts.md new file mode 100644 index 0000000..91e9fd2 --- /dev/null +++ b/docs/modules/upstream-accounts.md @@ -0,0 +1,395 @@ +# 上游账户与配额模块 + +## 1. 定位 + +本模块把 CPA 管理的 Codex OAuth/Auth 记录抽象成 cpa-ext 可查询、可绑定、可审计的上游账户目录。 + +它负责: + +- 同步 CPA Auth 的稳定标识、provider、状态、priority 和展示元数据; +- 维护插件内部 `UpstreamAccount` 与 CPA Auth 引用的对应关系; +- 记录 scheduler 实际可见性、模型可用性和最近选择结果; +- 按需刷新 Codex 配额、订阅和 reset credits,并标记新鲜度; +- 生成路由模块使用的不可变账户快照; +- 管理上游账户池的成员目录; +- 处理 Auth 新增、替换、消失和重新出现时的 reconciliation。 + +它不负责: + +- 创建或保存 OAuth Token,CPA 仍是上游凭证权威所有者; +- 决定某个下游 Key 最终选择哪个账户,该职责属于 [access-routing.md](access-routing.md); +- 按上游订阅剩余量给用户计费,用户计费只使用 [billing.md](billing.md) 定义的金额; +- 把管理员看到的 Token/百分比/credits 暴露为用户余额; +- 在 scheduler 热路径执行 SQL、网络或配额刷新。 + +## 2. 三种身份不能混用 + +### 2.1 `UpstreamAccountID` + +cpa-ext 自己生成的内部稳定 ID。所有 Key binding、账户池、统计和审计引用它,而不是文件名、邮箱或数组下标。 + +### 2.2 `CPAAuthID` + +CPA `auth.Auth.ID`,scheduler candidate 使用该值选择账户。当前 CPA 源码说明它应跨重启稳定,但 cpa-ext 仍将它当作外部引用,不直接作为数据库主键。 + +### 2.3 `CPAAuthIndex` + +CPA 从 Auth 文件/凭证身份派生的稳定 runtime index。`host.auth.list`、`host.auth.get` 和 Management `/auth-files` 使用该值。它适合同步和配额查询,但 scheduler 返回的是 `CPAAuthID`,两者必须同时保存并显式映射。 + +路径、文件名、email、label、account ID 都不是安全主键。OAuth 重新登录、文件迁移或用户改名不能让历史费用转移到另一账户。 + +## 3. 数据来源 + +### 3.1 CPA host auth callbacks + +当前 CPA `v7.2.130` 提供: + +- `host.auth.list`:返回 `HostAuthFileEntry[]`; +- `host.auth.get_runtime`:按 `auth_index` 返回最新 runtime 元数据; +- `host.auth.get`:返回物理 Auth JSON,包含秘密,只能用于受限 provider adapter; +- `host.auth.save`:写入 Auth 文件,本项目 MVP 不使用。 + +`HostAuthFileEntry` 可提供: + +- `id`、`auth_index`、name、type、provider、label; +- status、status_message、disabled、unavailable、runtime_only; +- source、updated/refresh/retry 时间; +- email、project/account 展示元数据; +- priority、note、websockets; +- recent success/failed buckets。 + +主目录同步优先使用 `host.auth.list`,不读取原始 Auth JSON。 + +### 3.2 Scheduler candidates + +`scheduler.pick` 每次只提供当前请求可选的候选: + +- Auth ID; +- provider; +- priority; +- host-visible status; +- 经过宿主过滤的非敏感 attributes。 + +这不是全量账户目录。更关键的是,CPA 只把**最高可用 priority tier** 交给 plugin scheduler。较低 priority 的健康账户不会出现在 candidates 中,不能因为目录显示“active”就声称 strict binding 可用。 + +每次 scheduler 调用应更新有界的可见性观察: + +```text +(upstream_account_id, provider, model, priority, observed_at, candidate=true) +``` + +它用于诊断和 bindability 投影,不在请求热路径写大对象或同步刷新全目录。 + +### 3.3 After-auth 与 Usage + +- request after-auth 提供实际选中的 Auth ID,用来验证 strict/pool 约束; +- `UsageRecord.AuthID/AuthIndex/AuthType` 用于费用和统计归属交叉校验; +- Request、Execution 和 Usage 仍按 [data.md](data.md) 的关联规则持久化。 + +### 3.4 Codex 配额与订阅 + +参考 usage-keeper 可取得: + +- `plan_type` / subscription plan; +- `allowed`、`limit_reached`; +- primary/secondary window; +- used percent、window seconds、reset after/reset at; +- window usage tokens/cost(若上游返回); +- additional rate limits; +- reset credits available count、到期时间。 + +这些是上游运营信息,不是 cpa-ext 用户计费事实。缺失字段必须保持 unknown,不能转换为 0。 + +## 4. 核心领域对象 + +### 4.1 `UpstreamAccount` + +```text +UpstreamAccount +├─ upstream_account_id +├─ provider # MVP: codex +├─ display_name +├─ identity_fingerprint # 不可逆、非秘密 +├─ lifecycle_status +├─ operator_enabled +├─ created_at / updated_at +└─ metadata_quality +``` + +`display_name` 可以来自 label 或脱敏 email,但修改它不改变身份。`identity_fingerprint` 仅用于人工比对,不能由可枚举的短邮箱直接做无密钥 hash。 + +### 4.2 `CPAAuthRef` + +```text +CPAAuthRef +├─ upstream_account_id +├─ cpa_auth_id +├─ cpa_auth_index +├─ provider +├─ source_kind # file/runtime +├─ source_name_preview +├─ priority +├─ host_status +├─ disabled / unavailable +├─ next_retry_after +├─ first_seen_at / last_seen_at +├─ ref_status # active/missing/replaced +└─ observation_revision +``` + +不能持久化 Auth 文件绝对路径、access token、cookie、api_key 或完整物理 JSON。 + +### 4.3 `QuotaSnapshot` + +```text +QuotaSnapshot +├─ upstream_account_id +├─ snapshot_id / revision +├─ provider +├─ subscription +├─ windows[] +├─ reset_credits +├─ observed_at +├─ expires_at +├─ source +├─ quality # fresh/stale/partial/error/unknown +└─ error_class # 脱敏 +``` + +每个窗口保存原始单位和规范化展示值。上游百分比不能用 cpa-ext 自己统计的金额反推。 + +### 4.4 `SchedulerVisibility` + +```text +SchedulerVisibility +├─ upstream_account_id +├─ provider / model +├─ visible_priority +├─ last_candidate_at +├─ last_selected_at +├─ scheduler_visible +├─ bindable +└─ reason +``` + +常见 reason: + +- `active`; +- `priority_shadowed`; +- `disabled`; +- `host_unavailable`; +- `model_unavailable`; +- `quota_exhausted`; +- `not_seen_for_model`; +- `auth_ref_missing`; +- `snapshot_stale`。 + +### 4.5 `UpstreamPool` + +账户池只保存内部账户成员关系和展示信息: + +```text +UpstreamPool +├─ pool_id +├─ name +├─ enabled +├─ members[] # UpstreamAccountID +└─ revision +``` + +池内 round-robin/fill-first、fallback 和 strict 语义仍由 Access/Route 执行。本模块只确保成员存在并给出 bindability。 + +## 5. 账户同步与 reconciliation + +同步流程: + +```text +host.auth.list + → adapter 删除敏感/路径字段 + → normalize CPAAuthRef observations + → match existing exact CPAAuthID/AuthIndex refs + → produce add/update/missing candidates + → transactional reconcile + → publish immutable directory snapshot + → emit audit/diagnostic events +``` + +匹配规则: + +1. 同一 provider 下 exact `CPAAuthID` 命中现有 active ref; +2. 否则 exact `CPAAuthIndex` 命中,记录 ID 变化诊断并等待规则确认; +3. 仅 email/label/文件名相同不得自动合并; +4. 新引用创建新 `UpstreamAccount` 或进入人工关联候选; +5. 一次扫描未出现只标 `missing_candidate`,经过配置的连续次数/时间后才转 `missing`; +6. missing 账户历史和 bindings 保留,不能级联删除; +7. 替换 Auth 必须由管理员显式把旧账户绑定迁到新 ref,并写审计原因。 + +同步写入后一次性替换内存目录 Snapshot。scheduler 只读 Snapshot;不得在 Pick 内触发同步。 + +## 6. 状态、健康和可绑定性 + +账户状态至少拆成四层: + +| 层 | 回答的问题 | +| --- | --- | +| lifecycle | 这个内部账户是否 active/missing/retired | +| host eligibility | CPA 是否 disabled/unavailable/cooldown | +| scheduler visibility | 目标模型当前最高 priority candidates 是否包含它 | +| quota freshness | 配额是否 fresh/stale/unknown/exhausted | + +`bindable=true` 至少需要: + +- internal account/operator enabled; +- active CPA Auth ref; +- provider/model 符合 Codex MVP allowlist; +- host 未 disabled/unavailable; +- 对目标模型最近可见,且不处于较低 priority shadow; +- 若策略要求配额保护,quota fresh 且未明确 exhausted。 + +配额 unknown/stale 的默认行为: + +- strict binding:如果 CPA candidate 仍存在可以继续选择,但 UI 显示 quota unknown; +- quota-aware pool(后续能力):不得把 stale 当作“余额充足”; +- host 已明确 unavailable/limit reached:不可绑定/不可选择; +- 任何情况下 after-auth 都要验证实际 Auth,不能只信目录快照。 + +MVP 不修改 CPA Auth 的 `disabled` 状态。管理员禁用上游账户只影响 cpa-ext 路由目录;真正停用 OAuth 凭证仍在 CPA 管理中心完成,避免两个系统争夺同一权威字段。 + +## 7. Priority 的产品规则 + +当前宿主只向 plugin scheduler 提供最高可用 priority tier,因此第一版固定: + +1. 所有希望被 cpa-ext 互相路由的 Codex Auth 应配置为相同 CPA priority; +2. UI 显示每个账户的 priority、`scheduler_visible` 和 `bindable`; +3. strict/preferred/pool 保存前执行模型维度的 bindability 检查; +4. 较低 priority 账户返回 `priority_shadowed`,不允许管理员误以为 strict binding 会生效; +5. priority 变化触发目录 revision 和绑定影响预览; +6. 若未来 CPA 向 scheduler 暴露跨 priority candidates,再通过 capability/version gate 开启跨层路由,不能靠插件猜测隐藏候选。 + +## 8. 配额刷新方式 + +### 8.1 触发 + +MVP 支持: + +- 管理员手动刷新单个或小批账户; +- management/callback-driven maintenance 在有预算时刷新到期项; +- sidecar 拓扑下的有界低频自动刷新。 + +双 Go runtime soak gate 通过前,c-shared 动态库不启动长期 quota worker。刷新必须有: + +- 全局和 per-account 并发上限; +- 超时、冷却和短期错误缓存; +- 同一 AuthIndex 去重; +- 最大批量; +- 明确状态 queued/running/completed/failed; +- shutdown/配置切换时不遗留永久 queued 状态。 + +可参考 usage-keeper `internal/quota/refresh.go` 的任务去重、worker token、错误 TTL 和批量限制,但后台 goroutine 拓扑不能原样搬入 DLL。 + +### 8.2 凭证处理 + +目录同步永远不需要 raw Auth JSON。只有 provider-specific quota adapter 在一次受保护的刷新命令内可能调用 `host.auth.get`: + +1. 由已通过 CPA Management 鉴权的固定 route 触发; +2. 读取指定 `auth_index`,不允许调用方传 URL 或 Header; +3. 在 adapter 内提取最小 Token/Account 字段; +4. 仅调用代码内 allowlist 的 HTTPS provider endpoint; +5. 通过 `host.http.do` 使用宿主网络策略; +6. 立即覆盖/释放 raw JSON 和 Authorization 临时缓冲; +7. 只把规范化 quota DTO 交给领域层。 + +禁止把入站 Management Authorization 转发到 CPA loopback `/api-call`,也禁止把任意 `url/header/body` 暴露成 cpa-ext 配额接口,否则会形成 SSRF 和凭证代理。 + +更理想的长期方案是 CPA 提供 provider-scoped quota host callback,让插件永远不读取 OAuth 原文;如果实现该宿主契约,应优先替换 `host.auth.get` 方案。 + +## 9. 持久化与快照 + +建议表: + +| 表 | 用途 | +| --- | --- | +| `upstream_accounts` | 内部稳定账户 | +| `cpa_auth_refs` | CPA AuthID/AuthIndex 外部引用 | +| `upstream_observations` | 同步 revision 和状态变化 | +| `quota_snapshots` | 版本化配额事实 | +| `scheduler_visibility` | 模型/priority 可见性投影 | +| `upstream_pools` | 池定义 | +| `upstream_pool_members` | 成员关系 | + +quota snapshot 是时间点事实,不能用新值覆盖旧审计。高频 scheduler candidate observation 可以先按账户/模型 upsert 投影并输出重要变化事件,不需要把每次 pick 都永久保存。 + +## 10. API 与 UI 输出 + +管理员账户列表至少返回: + +- internal account ID、脱敏名称、provider; +- CPA Auth ID 的安全 preview、AuthIndex 的安全 preview; +- priority、host status、disabled/unavailable; +- scheduler visible/bindable 和 reason; +- subscription、primary/secondary/additional windows; +- quota observed/expires 时间和 quality; +- 绑定的下游 Credential 数、池和最近选择时间; +- missing/replacement 诊断。 + +默认不返回路径、raw metadata、token、cookie、API key 或完整 Auth ID。需要复制精确 ID 的管理员操作使用内部 `upstream_account_id`。 + +用户 API 不返回上游账户身份、配额、邮箱、订阅或 priority;最多返回“服务当前可用/暂不可用”的非敏感业务状态。 + +接口定义见 [api.md](api.md),展示结构见 [ui.md](ui.md)。 + +## 11. 故障策略 + +- 目录同步失败:保留 LKG Snapshot,标 stale 并告警; +- quota 刷新失败:保留最后 snapshot 和明确 `stale/error`,不写 0; +- scheduler snapshot 不可用:已受控 strict/pool 请求 fail closed; +- strict 目标不在当前 candidates:返回 unavailable,不委托 CPA fallback; +- after-auth 发现实际 Auth 不在允许集合:interceptor 以正常 RPC success + `Terminate=true` 拒绝; +- Auth 消失:冻结新绑定,保留历史和现有 policy 供管理员迁移; +- identity 冲突:进入人工 review,不能自动合并费用归属; +- quota 依赖失败不应让已经可靠计费的普通请求免费穿透。 + +## 12. 如何参考现有项目 + +### 12.1 CLIProxyAPI + +协议权威: + +- `sdk/pluginapi/types.go`:`HostAuthFileEntry`、host auth callback DTO、scheduler candidate; +- `internal/pluginhost/auth_callbacks.go`:Auth 列表构造、priority 和敏感字段边界; +- `sdk/cliproxy/auth/types.go`:Auth ID/Index、状态和 quota runtime; +- `sdk/cliproxy/auth/conductor_selection.go`:priority tier 与 scheduler candidate; +- `examples/plugin/host-callback-auth-files/`:host callback 调用形状。 + +官方 auth-files example 同时演示了在**未鉴权 resource GET** 上执行 `host.auth.get/save`。那只是能力样例,生产安全模型绝不能复制。 + +### 12.2 `cpa-usage-keeper` + +优先参考: + +- `internal/cpa/dto/authfiles/`:Auth 文件元数据兼容读取; +- `internal/service/metadata_auth_files.go`:身份同步; +- `internal/quota/types.go`、`codex.go`、`normalize.go`:Codex quota DTO 与规范化; +- `internal/quota/subscription_codex.go`:订阅信息; +- `internal/quota/refresh.go`、`auto_refresh.go`:任务、缓存和节流; +- `web/src/components/usage/credentials/`:账户、订阅和 quota 展示。 + +复用 DTO 语义、规范化和错误分类;不能复制其独立服务常驻 worker、持有 CPA Management Key 的拓扑,或把 provider 原始 secret 写入通用实体。 + +参考基线:CPA `f43aad7637ad813745bf7d341acb5663617570c5`,usage-keeper `d62cad3f345ae574089a14a4ac75cca023c7ead6`。 + +## 13. 验收标准 + +- CPA AuthID、AuthIndex 和内部 UpstreamAccountID 不会混用; +- OAuth 重登、文件改名、缺失和恢复不会把历史账务归到错误账户; +- 目录同步不读取或持久化 raw Auth secret; +- scheduler 只读内存 Snapshot,不做 SQL/网络; +- 较低 priority 健康账户明确显示 `priority_shadowed` 且不能误绑定; +- strict/pool 每次 after-auth 验证实际账户; +- Codex 主/次窗口、额外限制、订阅和 reset credits 保持 unknown/partial/fresh/stale 语义; +- quota 失败不会被显示为 0 或“无限”; +- quota 刷新去重、限流、超时且不形成 SSRF/凭证代理; +- 用户响应不泄露上游账户信息; +- 账户消失不级联删除 bindings、Execution、Usage 或账本; +- 重启后目录、池、配额 snapshot 和绑定关系可恢复; +- 所有 identity/reconciliation 管理动作都有审计记录。