feat: add account egress and client key USD budgets

Import sub2api proxy bindings, enforce durable daily and seven-day budgets alongside existing key concurrency, and integrate matching admin controls and audited reconciliation.
This commit is contained in:
Codex
2026-09-10 23:41:35 +08:00
parent 74318af570
commit 1359eaa894
109 changed files with 3783 additions and 150 deletions
+100
View File
@@ -0,0 +1,100 @@
# 账号代理与 Key 限额
## 关联与范围
Client Key 绑定账号分组,网关在已启用分组的账号并集中选择符合模型、状态和账号并发条件的账号。
代理绑定到账号;Key 的日金额、七天金额、并发和 RPM 在所有账号与 Provider 之间合计。
```mermaid
flowchart LR
K[Client Key] --> L[日限额 / 七天限额 / 并发 / RPM]
L --> G[已启用分组的账号并集]
G --> A[账号 A]
G --> B[账号 B]
A --> P[代理 A]
B --> Q[代理 B]
```
未绑定分组的 Key 可使用全部账号,包括以后新增的账号。绑定了分组但所有分组停用或为空时,
没有可用账号,不会扩大为全部账号。需要一对一绑定时,为一个账号创建独立分组并只给 Key 绑定该组。
## 设置出口
账号编辑、批量编辑及新 OAuth 授权复用同一出站隧道控件:保持当前、直连、指定代理。
支持 `http://`、`https://`、`socks5://`、`socks5h://`,例如 `socks5h://user:password@proxy.example:1080`。
用户名和密码中的保留字符应使用 URL 编码;SOCKS 必须显式指定端口。
`socks5` 在网关解析目标 DNS,`socks5h` 交给代理解析。
HTTP 的 SOCKS 连接沿用 reqwest;其当前版本会把 IPv6 字面量目标编码为域名,配置自定义上游时请使用
域名或 IPv4 目标。官方 OpenAI/xAI 域名不受影响,WebSocket 路径支持 IPv6 目标。
管理 API 的 `outboundProxyUrl` 省略或 `null` 保持当前值,`""` 清除代理。
列表、详情、Debug 与普通审计不包含代理认证信息;敏感账号导出包含完整代理 URL,应按凭据保存。
代理 URL 与现有 OAuth 凭据一样持久化到数据库,数据库备份也包含这些秘密。
OpenAI 的 HTTP/SSE、WebSocket、图片、搜索、OAuth 交换/刷新、额度、模型目录和资料请求使用账号出口。
xAI 推理、OAuth、JWKS、userinfo 及辅助请求同样携带出口。
指定代理不可用时请求失败,不会通过直连重试。账号代理变更推进凭据版本与配置快照,
新选择的账号不会复用旧代理的连接池;已经执行的请求可沿原连接完成。
未配置代理的生产客户端直连,不继承进程的全局代理环境变量。TLS 继续验证证书。
第三方授权页由用户浏览器访问,其网络出口不由网关代理设置控制。
## 从 sub2api 导入
1. 在 sub2api 导出所需 OpenAI OAuth 账号及它们引用的代理。
2. 在账号管理中选择 OpenAI 的 JSON 文件导入,将导出 object 交给已有导入接口。
3. 导入器从账号的 `proxy_key` 查找同一文档顶层 `proxies`;`data.accounts` 包装也受支持。
4. 导入前检查所有 OpenAI 条目的代理引用,再在各自出口上执行必要的 token 刷新。
5. 导入后检查账号出口和分组,并用账号连接测试验证实际部署网络。
sub2api 的 SOCKS5 代理按远端 DNS 语义转换为 `socks5h`。引用不存在、引用重复、代理停用、
代理配置了到期时间或回退策略时,导入会报错,需要先处理这些不兼容配置。
代理按 URL 保存在账号上,不创建额外的共享代理资源。
CPR 的 OpenAI 与 xAI 导出/导入也支持账号条目的 `outboundProxyUrl`。
重新导入按已识别的上游账号身份更新,保留既有分组与调度配置。
导入时未提供代理或提供空值,会保留既有账号的代理;清除已有代理请使用账号编辑中的“直连”。
首次导入的账号没有分组。未迁移 sub2api 的下游 Key、用户余额、订阅、历史用量、
分组权限及其他 Provider 账号;账号导出文件不是完整实例备份。
## 限额与计费
Key 创建/编辑可设置日限额、周限额(USD)、最大并发和 RPM。零表示不限。
金额以十进制字符串存储,最多十位小数;编辑限额不重置已经结算的费用。
- 日窗口:北京时间当天零点到次日零点。
- 周窗口:首次准入请求当天零点起七天;到期后由下一次使用开启新的七天窗口,不固定为周一。
- 费用归属:按网关请求完成时间;跨日请求结算到完成日。延迟重试写入保留原完成时间。
- 计费来源:优先 Provider 上报 USD,否则使用现有模型价格计算。订阅账号的估算 USD 不等于订阅账单。
- 内部重试:累加各次实际获得的费用;一次未知计费不能被后续成功响应覆盖。
- 并发:同一个 Key 在所有连接上执行中的请求总数。SSE 持有名额直到终态;空闲 WebSocket 不占名额,
每个 `response.create` 独立准入。切换账号或内部重试继续使用同一个名额。Key 与账号上限同时生效。
金额检查使用已结算费用。达到日或周阈值后拒绝新请求;已经准入的一个或多个请求仍可完成并超出阈值。
这不提供绝不超支的预占保证。费用受限、并发受限都不会把 Key 改为管理员禁用状态。
错误字段及接口形状见 [Client Key API](api.md#7-client-key)。
## 故障与核账
每次计费请求在发送上游前持久化一笔待结算记录;并发名额在预算拒绝或启动失败时释放。
完成、失败、取消、断连进入同一结算路径,以网关请求 ID 保证重复结算不会重复扣费。
明确未发送的失败按零结算;已发送或可能已发送而未得到可信费用时保留未知状态。
没有价格或 Usage 的模型和端点(包括无法提供独立费用的搜索请求)也可能产生未知费用。
受限 Key 遇到未知费用,或超过请求期限的未结算记录,会停止接受新的计费请求。
管理员通过“费用核账”对照上游确认金额并填写原因;核账有审计,零金额也必须显式确认。
原请求在旧窗口完成时,事后核账不会计入当前窗口,但会解除该笔记录的未知状态。
结算失败时,进程内保留精确费用,在同一 Key 下次请求前重试。进程崩溃丢失内存后,
数据库中的待结算记录仍在,超过请求期限后要求核账;不会把遗失费用直接按零放行。
PostgreSQL 不可用时,所有新的计费请求拒绝准入;Redis 的并发/RPM 租约沿用项目现有恢复策略。
费用账本不依赖可能丢弃或清理的请求观测日志,也不受 `usageRetentionDays` 控制。
本版保留所有费用事件直到删除 Key;运维需将其纳入数据库容量与备份规划。
## 升级
服务启动自动执行 `0002_client_key_budgets.sql` 和 `0003_account_outbound_proxy.sql`。
已发布的 `0001_initial.sql` 不变。旧账号默认直连,旧 Key 的日/周金额默认零,原并发和 RPM 配置保留。
已有请求日志不会回填到新账本,使用本版本后的请求开始记录预算费用。
升级前备份数据库;先在隔离环境验证账号出口和导入文件。启用新策略后,旧二进制不识别代理与金额限额,
不能仅通过回退镜像来承接要求这些策略的流量。新增迁移的冻结校验见 [数据库迁移](../backend/migrations/README.md)。
+38 -6
View File
@@ -175,8 +175,8 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 |
| `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 管理员显式清除该账号的本地错误/额度/cooldown 事实并重新启用,不访问上游 |
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token |
| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds }` | 一次更新账号调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)与所属分组 |
| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled, concurrencyLimit, weight, groupIds }` | 一次事务统一更新所选账号的全部调度字段与完整分组集合 |
| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, outboundProxyUrl? }` | 一次更新账号调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 |
| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled, concurrencyLimit, weight, groupIds, outboundProxyUrl? }` | 一次事务统一更新所选账号的调度字段、完整分组集合与可选代理 |
| `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 |
| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 |
| `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 访问 Provider 并刷新额度,同时同步额度所属状态 |
@@ -186,7 +186,7 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 |
| `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖 cache |
| `GET` | `/api/admin/accounts/connection-test` | `accountId`、`modelId` | 通过 SSE 返回实时连接测试事件,不作为业务 Responses 用量记录 |
| `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, accountId? }` | 创建 OpenAI 或 xAI OAuth flow;`accountId` 表示重新授权 |
| `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, accountId?, outboundProxyUrl? }` | 创建 OpenAI 或 xAI OAuth flow;`accountId` 表示重新授权 |
| `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl }` | 消费 OAuth callback;新账号保持未分组,重新授权保留所属分组 |
账号列表支持以下稳定值:
@@ -197,6 +197,13 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
- `sortBy`: `email`、`status`、`planType`、`usage`、`lastUsedAt`、`expiresAt`;
- `sortDirection`: `asc`、`desc`。
`outboundProxyUrl` 接受 HTTP、HTTPS、SOCKS5、SOCKS5H 代理 URL,可带用户名和密码。
编辑时省略或 `null` 表示保持原配置,空字符串表示清除代理并直连。列表和详情只返回
不含认证信息的 `outboundProxyEndpoint`(直连时为 `null`);只有显式敏感导出包含完整 URL。
指定代理后,推理、OAuth 服务端交换/刷新及账号辅助请求使用同一出口;代理失败不会退回直连。
浏览器打开的第三方 OAuth 授权页仍使用浏览器自身网络。
账号与 Key 的关系及迁移步骤见 [账号代理与 Key 限额](account-egress-key-limits.md)。
### 账号连接测试 SSE
`GET /api/admin/accounts/connection-test` 固定探测请求指定的账号,不参与普通账号轮换。成功流沿用
@@ -227,12 +234,14 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
导入的 `data` 必须是 JSON object,Admin API 请求上限为 64 MiB;Provider 可以收紧限制,
当前 xAI 导入上限为 16 MiB。内部 schema 由目标 Provider 独占解释:
- OpenAI 接受单账号 OAuth 文档、`accounts` 数组(最多 200 项)和 CPR 账号 bundle;
- OpenAI 接受单账号 OAuth 文档、`accounts` 数组(最多 200 项)、CPR 账号 bundle 和含代理引用的 sub2api 导出;
- OpenAI OAuth token 字段接受 `accessToken`、`refreshToken`、`idToken`,以及官方
`auth.json` 中的 `access_token`、`refresh_token`、`id_token`,可以嵌套在 `tokens` 等账号 object 内;
每项至少包含 AT 或 RT。仅含 `OPENAI_API_KEY` 的客户端代理配置不是 OAuth 账号导入材料;
RT-only 会在导入时换取 AT,AT-only 不具备自动续期能力;
- xAI 从单账号 object 或 `accounts` 数组中提取 OAuth token;包装中的代理、并发、优先级等字段不参与认证;
- OpenAI 与 xAI 的账号条目接受 `outboundProxyUrl`;OpenAI 还会解析 sub2api 的 `proxy_key` 和顶层 `proxies`。
代理在 token 刷新前绑定。缺失、重复、停用、带到期时间或配置回退策略的 sub2api 代理会拒绝导入;
- xAI 从单账号 object 或 `accounts` 数组中提取 OAuth token;并发、优先级等字段不参与认证;
- xAI 批量导入逐条独立校验:失败条目跳过并记录日志,不中断其余条目,仅当没有任何条目成功时整个导入才报错;
- xAI API Key 不是受支持的账号 credential;
- 导入不会只凭文件外形写入账号;目标 Provider 使用认证材料完成必要的 token exchange 或已认证账号资料补全。
@@ -409,13 +418,36 @@ PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用
| `POST` | `/api/admin/client-keys/enable` | `{ id }` | 启用 |
| `POST` | `/api/admin/client-keys/disable` | `{ id }` | 禁用 |
| `POST` | `/api/admin/client-keys/delete` | `{ id }` | 删除 |
| `GET` | `/api/admin/client-keys/unresolved-charges` | `id` | 按时间读取最早的 200 笔待核账请求 |
| `POST` | `/api/admin/client-keys/reconcile-charge` | `{ id, requestId, amountUsd, reason }` | 管理员确认未知费用并记录审计 |
创建字段为 `name`、可选 `label`、`groupIds`、`maxConcurrency`、`requestsPerMinute`,更新请求再增加
创建字段为 `name`、可选 `label`、`groupIds`、`maxConcurrency`、`requestsPerMinute`、可选
`dailyLimitUsd` 和 `weeklyLimitUsd`,更新请求再增加
`id`。`groupIds` 必须显式提交:空数组派生 `routingScope: "all"`,非空数组派生
`routingScope: "groups"`。响应同时返回分组引用 `groups`,以及从当前有效账号池派生、仅供展示的
`providerKinds`;Client Key 不再保存 `providerKind`。创建和 reveal 响应会返回完整明文 Key,调用方
必须立即安全保存。
金额字段为非负十进制字符串,最多 10 位整数与 10 位小数,`"0"` 表示不限额。
创建时省略金额字段默认为零;更新时省略或 `null` 保留当前值,修改限额不会清空已用金额。
`maxConcurrency` 和 `requestsPerMinute` 是非负整数,零表示不限。
列表增加 `dailyLimitUsd`、`weeklyLimitUsd`、`dailyUsedUsd`、`weeklyUsedUsd`(均为字符串)、
`dailyResetsAt`、`weeklyResetsAt`(RFC3339 或 `null`)与 `unresolvedRequests`。
日窗口按北京时间零点重置;周窗口从首次准入当天零点起持续七天,到期后在下一次使用时重新开启。
费用按请求完成时间归属窗口。并发按同一 Key 的执行中请求累计,包含 SSE 与每个 WebSocket
`response.create`;空闲连接不占名额,内部重试不重复占用。
任一已结算金额达到限额后拒绝新请求,已准入请求可完成并使金额超过阈值。
HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly_budget_exceeded`,
并附 `Retry-After`;WebSocket 每次 `response.create` 执行相同检查并返回协议错误事件。
已发送但费用不明、或超过请求期限仍未结算的请求使受限 Key 返回 `key_budget_unresolved`。
预算存储不可用时返回 `503`、`key_budget_unavailable`。
核账的 `amountUsd` 使用相同金额格式,`reason` 为 1–1024 字节的非空原因。只能处理该 Key 下未知或
已超过期限的请求;相同金额重复提交不重复计费,修改已结算金额返回 `409`。账本独立于使用统计日志,
记录保留至删除 Key,不受 `usageRetentionDays` 影响。
## 8. 运行设置
| 方法 | 路由 | 说明 |