Compare commits

..
31 Commits
Author SHA1 Message Date
ZyyandCodex c8fdc2b5cd chore(release): prepare v3.7.2
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:55:09 +08:00
2han9wen71an dfabf0f9dd fix(openai): recover quota after same-window usage drops (#95)
OpenAI 周窗口的 used_percent 可以在同一窗口内回落(滚动计量),而
reset_at 固定到窗口边界才变化。恢复规则要求「reset 前进 + 用量低于
10%」,两个条件在滑动周窗口上都无法成立:耗尽账号被钉在旧 reset
日期(最长一周)无法参与调度,尽管 /usage 已显式报告 allowed=true /
limit_reached=false 且 5h 窗口全新。

新增第二条解除路径:同一窗口(reset 未滚动)内连续两次观测都未触顶
即解除。候选记录持久化在 _quota_recovery,跨 worker 周期与重启累积;
连续两次的要求保持对「耗尽后立刻拉到的旧快照」竞态的防护(单次旧
快照撑不过两次观测)。原有「reset 前进 + 用量<10%」快路径不变。
2026-09-15 15:38:46 +08:00
ZyyandCodex 817b0f2d6d chore(release): commit version and notes together
Allow staged or unstaged release notes and commit their final contents with the version update before tagging. Continue rejecting unrelated tracked changes and require notes to remain uncommitted.

Update the release skill, skip duplicate push CI for release metadata alone, and check the publish script syntax in CI. Validated publication behavior in temporary Git repositories, Bash syntax, and Actionlint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:14:18 +08:00
ZyyandCodex a975868046 fix(release): publish version-matched deployment assets
Publish the configuration template and Compose file with release checksums, pin the packaged Compose image to the release version, and include the template in platform archives.

Download deployment assets from one release tag, link versioned deployment documentation, and translate the release installation notes into Chinese.

Validated Actionlint, stable and prerelease packaging for all platforms, SHA-256 checksums, and resolved Docker Compose image versions.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:02:49 +08:00
ZyyandCodex cc2dfab19a fix(openai): preserve client location when unset
Accept omitted and null request locations without rewriting web search location or environment dates and timezones. Keep complete configured overrides validated and document the passthrough behavior.

Validated 626 OpenAI tests, 38 gateway tests including architecture checks, Rustfmt, and strict workspace Clippy.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:02:36 +08:00
ZyyandCodex 040a1e82a6 chore: bump version to 3.7.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:32:29 +08:00
ZyyandCodex 4f39bfc5a8 docs(release): prepare v3.7.1 notes
Document configurable request location, WebSocket SSE error delivery, relaxed Compose resource limits, and the required image/config version alignment.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:32:20 +08:00
ZyyandCodex 3e3616863d fix(deploy): remove fixed container resource limits
Allow the default Compose deployment to run on hosts with fewer than two CPUs and leave CPU and memory policy to the deployment environment.

Validated the rendered Docker Compose configuration.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:01:21 +08:00
1bbae67040 fix(gateway-api): translate upstream WebSocket error events into SSE response.failed (#90)
* fix(gateway-api): translate upstream WebSocket error events into SSE response.failed

WS 上游协议用 `error` 事件报告流中途失败(如 OpenAI 的 server_is_overloaded),
SSE 协议的对应形态是 `response.failed`。SSE 交付边界此前把 `error` 事件按
未知事件原样透传,而 codex 等 SSE 客户端会静默忽略未知事件类型:客户端只看到
无原因的流 EOF("stream closed before response.completed"),丢失了上游的
完整错误负载(message/code/type),也无法据此分类重试。

现在 SSE 编码器在交付边界把 `error` wire 事件翻译成 `response.failed`:
错误负载(type/code/message/param)原样保留,response id 沿用已交付的
response.created。WS 客户端交付不变(WS 协议原生支持 error 帧)。
原有的会话级失败兜底(finish_with_gateway_error 合成 response.failed)不变。

* fix(gateway-api): preserve upstream response failures

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex fc07ac0803 fix(ci): scope rustls security waiver
Temporarily waive RUSTSEC-2026-0285 until 2026-09-21 UTC to preserve the pinned Codex TLS fingerprint. Keep all other audit failures blocking.

Validated cargo audit and waiver expiry behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex c7823a7821 feat(openai): make request location configurable
Group country, region, city, and timezone under openai.wire_profile.location while retaining the existing defaults. Share the configured location across web search and environment context, preserve epoch timestamps and release updates, and add validation, regression tests, and deployment documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex 61d1bcb405 refactor(api): standardize request parameters and trim unused options
Use data for POST bodies and params: data for GET queries. Remove request options that have no callers while retaining cancellation, timeout, and silent polling support. Update affected backup and client-download call sites and document the convention.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:25 +08:00
ZyyandCodex 21a436b76a chore: bump version to 3.7.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 17:33:59 +08:00
ZyyandCodex 849aa73a28 docs(release): prepare v3.7.0 notes
Summarize account personal information, reset credit fixes, OpenAI retry and transport changes, and service tier accounting. Document the admin API replacement and upgrade scope.

Co-authored-by: Codex <noreply@openai.com>
2026-09-14 17:33:52 +08:00
ZyyandCodex fb89bda177 docs: clarify documentation scope and required development skills
Centralize documentation update rules in CONTRIBUTING and reference them from the development and PR skills. Require frontend-design for frontend development and rust-best-practices for Rust development.

Co-authored-by: Codex <noreply@openai.com>
2026-09-14 17:28:07 +08:00
ZyyandCodex 9e6c8c9734 fix(openai): retry capacity rejections before rotating accounts
Retry explicit model capacity rejections on the same account up to three
times with bounded backoff, then reuse ordinary account rotation. Honor
the existing replay, delivery, cancellation and total request budgets.

Keep capacity rejections out of account health scores and provider
circuits, preserve original client errors, and extend the WebSocket
rejection boundary introduced by #89. Cover HTTP and WebSocket behavior
and document the policy.

Co-authored-by: Codex <noreply@openai.com>
2026-09-14 17:19:38 +08:00
2han9wen71an 3f69bc861c fix(openai): rotate accounts instead of same-socket retry on WebSocket account-level rejections (#89)
WebSocket 升级或交换被上游账号级拒绝(额度耗尽、限流、封禁等)时,恢复策略
仍会把错误交给同账号传输重试,且退避直接采用上游 retry-after(额度窗口通常
是小时级)。请求在 deadline 内无法恢复,错误原样返回客户端,Core 也失去了
换号机会——而同错误在 HTTP 路径上因回放安全会立即换号重试。

现在回放安全且带账号级失败记录的 WebSocket 拒绝不进入传输恢复:保留账号失败
记录(健康度与额度标记立即生效),把换号决策交还 Core,与 HTTP 路径语义一致。
会话 WebSocket 预算与禁用标记也不被账号级拒绝消耗。
2026-09-14 17:15:47 +08:00
ZyyandCodex cea4e1d8e9 fix(openai): use outbound service tier for usage and billing
Keep response-reported tiers as diagnostic metadata without overriding outbound-tier usage or cost estimates. Cover HTTP and WebSocket behavior and document the updated accounting policy.

Co-authored-by: Codex <noreply@openai.com>
2026-09-14 16:41:02 +08:00
539764f7eb feat(accounts): aggregate personal information on demand (#83)
Load profile statistics and OpenAI subscription information concurrently through one on-demand admin endpoint. Keep subscription queries out of quota refreshes, preserve partial results and account identity checks, and show subscriptions in the approved personal-information dialog layout.

Update the current API and architecture contracts while leaving README unchanged.

Co-authored-by: Yanshan Chen <16632048+chenyanshan@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 15:48:12 +08:00
c9be17778d fix(accounts): consume reset credits without card details (#81)
Allow count-only reset inventories to request upstream card selection without a credit ID. Preserve confirmation and idempotent retry guards, and place the fallback action in the shared modal footer with balanced inventory spacing.

Co-authored-by: Yanshan Chen <16632048+chenyanshan@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 15:47:32 +08:00
d8bc91efce fix(openai): align structured location metadata (#88)
Rewrite only Codex-generated environment context and web search location fields to Piketon, Ohio. Preserve user-authored text and absolute timestamps.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 14:14:08 +08:00
ZyyandZyy e8e2f18927 fix(openai): align WebSocket wire fingerprint (#87)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-09-14 12:52:06 +08:00
0977de3713 chore(deps): align Codex transport dependencies (#86)
Align HTTP and WebSocket TLS dependencies with the locally installed official Codex 0.154.0 client, preserve the hyper-util SOCKS IPv6 fix, and lock captured ClientHello behavior in tests.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 12:02:44 +08:00
dependabot[bot] eaa37d9faa chore(deps): bump the minor-and-patch group in /backend with 4 updates (#85)
Bumps the minor-and-patch group in /backend with 4 updates: [syn](https://github.com/dtolnay/syn), [uuid](https://github.com/uuid-rs/uuid), [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust) and [redis](https://github.com/redis-rs/redis-rs).


Updates `syn` from 3.0.4 to 3.0.5
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/3.0.4...3.0.5)

Updates `uuid` from 1.26.0 to 1.26.1
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.26.0...v1.26.1)

Updates `aws-sdk-s3` from 1.144.0 to 1.146.0
- [Release notes](https://github.com/awslabs/aws-sdk-rust/releases)
- [Commits](https://github.com/awslabs/aws-sdk-rust/commits)

Updates `redis` from 1.6.0 to 1.7.0
- [Release notes](https://github.com/redis-rs/redis-rs/releases)
- [Commits](https://github.com/redis-rs/redis-rs/compare/redis-1.6.0...redis-1.7.0)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 3.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: uuid
  dependency-version: 1.26.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.146.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: redis
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 11:48:32 +08:00
4e588ffa1d chore(deps): ignore Codex-aligned dependencies (#84)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 11:01:34 +08:00
dependabot[bot] b9e919f510 chore(deps): bump rust from 82150a5 to 9a73a50 in /deploy (#80)
Bumps rust from `82150a5` to `9a73a50`.

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.98-bookworm
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:58:58 +08:00
dependabot[bot] 32f9f3675d chore(deps): bump the minor-and-patch group in /frontend with 5 updates (#79)
Bumps the minor-and-patch group in /frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.41.0` | `1.45.0` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.14` | `3.4.15` |
| [marked](https://github.com/markedjs/marked) | `18.0.11` | `18.0.13` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.4.1` | `26.5.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.2` | `8.3.0` |


Updates `@lucide/vue` from 1.41.0 to 1.45.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.45.0/packages/vue)

Updates `dompurify` from 3.4.14 to 3.4.15
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.14...3.4.15)

Updates `marked` from 18.0.11 to 18.0.13
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.11...v18.0.13)

Updates `@types/node` from 26.4.1 to 26.5.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `vite` from 8.2.2 to 8.3.0
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.3.0/packages/vite)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:58:33 +08:00
dependabot[bot] 221d557d5e chore(deps): bump pnpm/action-setup in the minor-and-patch group (#76)
Bumps the minor-and-patch group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `pnpm/action-setup` from 6.0.10 to 6.1.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0977fd99725f1db4007ccb2928dbb4e90d06cc86...ea17c68df8912ef543352723c149a84f56e3d413)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:57:06 +08:00
ZyyandCodex 4105c1ece6 chore: bump version to 3.6.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-13 04:30:39 +08:00
ZyyandCodex 4d59687cbc docs(release): prepare v3.6.1 notes
Co-authored-by: Codex <noreply@openai.com>
2026-09-13 04:30:35 +08:00
ZyyandCodex 8621e24e7c fix: tolerate partial quota usage and remove retired model pricing
Keep quota capacity and remaining estimates based on recorded usage when some token or cost facts are missing. Preserve unknown values and explain potential underestimation without blocking usable forecasts.

Remove OpenAI pricing rules for shutdown models and the GPT-4-0314 alias while preserving historical costs and active regular prices. Document actual tier fallback and model-specific pricing behavior.

Validated with backend test targets, strict workspace Clippy, Rustfmt, frontend lint/typecheck/build, and mocked browser checks for partial, missing, complete, weekly/monthly, and mobile dark forecast states.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 04:27:49 +08:00
109 changed files with 4459 additions and 841 deletions
+6 -3
View File
@@ -10,12 +10,14 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
## 按任务读取
| 任务 | 文档入口 |
| 任务 | 技能与文档入口 |
| --- | --- |
| 用户能力、快速开始 | [README](../../../README.md) |
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
| 管理端组件、主题 Token、视觉验证 | [管理端主题](../../../docs/theme.md) |
| 前端开发、管理端组件、主题 Token、视觉验证 | 加载并使用 `$frontend-design`,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
@@ -29,7 +31,8 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;同步受影响的说明。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
5. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
+2 -12
View File
@@ -39,18 +39,8 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
## 文档职责与更新条件
本仓库的常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
- 按 [开发指南的文档入口](../dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
不按每次 PR 追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue。版本变更与升级影响按发版任务维护
`release/notes.md`,不为普通 PR 自动追加发布说明。
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
按 [贡献与审查的文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否需要更新文档。
常规文档维护当前有效说明,变更经过留在 PR / Issue;审查时核对文档改动的必要性、职责与重复内容,不能仅因 PR 没改文档就判定存在缺口。
## 创建 PR
+4 -3
View File
@@ -53,8 +53,9 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
## 执行正式发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
当前脚本要求先提交 `release/notes.md`,再由脚本更新 `release/version.yaml` 并创建版本提交,
不要提前把版本文件改成目标版本,否则脚本会判定该版本已设置。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): prepare vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权正式发布后,将 `release_version` 设为已确定的版本,执行:
@@ -63,7 +64,7 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
release/publish "$release_version"
```
该命令会创建版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
## 恢复与验收
+17 -1
View File
@@ -21,11 +21,27 @@ updates:
day: monday
time: '09:10'
timezone: Asia/Shanghai
# 固定官方 Codex Desktop 的 TLS 指纹;升级前必须抓取握手并比对扩展顺序。
# 按官方 Codex 锁文件、协议指纹或已审计 fork 固定;完成对应核对与实测后再手动升级。
ignore:
- dependency-name: aws-lc-rs
- dependency-name: hyper
- dependency-name: hyper-rustls
- dependency-name: hyper-tls
- dependency-name: hyper-util
- dependency-name: native-tls
- dependency-name: openssl
- dependency-name: openssl-src
- dependency-name: openssl-sys
- dependency-name: reqwest
- dependency-name: rustls
- dependency-name: rustls-native-certs
- dependency-name: rustls-pki-types
- dependency-name: tokio
- dependency-name: tokio-native-tls
- dependency-name: tokio-rustls
- dependency-name: tokio-tungstenite
- dependency-name: tungstenite
- dependency-name: zstd
groups:
minor-and-patch:
update-types:
+4 -1
View File
@@ -159,7 +159,7 @@ jobs:
ref: ${{ inputs.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 11.7.0
@@ -205,3 +205,6 @@ jobs:
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
-color
.github/workflows/*.yml
- name: Check release script syntax
run: bash -n release/publish
+1
View File
@@ -6,6 +6,7 @@ on:
- main
paths-ignore:
- 'release/version.yaml'
- 'release/notes.md'
pull_request:
branches:
- main
+31 -14
View File
@@ -315,6 +315,7 @@ jobs:
shell: bash
env:
VERSION: ${{ needs.release-info.outputs.version }}
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
run: |
set -euo pipefail
ruby <<'RUBY' > release-platforms.json
@@ -349,7 +350,18 @@ jobs:
shutil.rmtree(release_dir, ignore_errors=True)
release_dir.mkdir(parents=True)
archives = []
config_template = pathlib.Path("deploy/config.example.yaml")
shutil.copy2(config_template, release_dir / config_template.name)
compose = pathlib.Path("deploy/compose.yaml").read_text()
image = f"{os.environ['REGISTRY']}/{os.environ['IMAGE_NAME']}"
default_image = "${CPR_IMAGE:-" + image + ":latest}"
if compose.count(default_image) != 1:
raise SystemExit("Expected exactly one default application image in deploy/compose.yaml")
# 发布附件固定镜像版本,防止后续 pull 与随包配置模板脱节。
versioned_image = "${CPR_IMAGE:-" + f"{image}:{os.environ['DOCKER_VERSION']}" + "}"
compose = compose.replace(default_image, versioned_image)
(release_dir / "compose.yaml").write_text(compose)
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
for platform in platforms:
platform_name = f"{platform['os']}-{platform['arch']}"
@@ -367,6 +379,9 @@ jobs:
shutil.copy2(binary_src, binary_dest)
binary_dest.chmod(0o755)
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
deploy_dir = package_root / "deploy"
deploy_dir.mkdir()
shutil.copy2(config_template, deploy_dir / config_template.name)
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
archive_path = release_dir / archive_name
@@ -375,12 +390,12 @@ jobs:
archive.add(package_root, arcname=APP_NAME)
else:
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
archives.append(archive_path)
assets.append(archive_path)
checksum_path = release_dir / "checksums.txt"
with checksum_path.open("w", encoding="utf-8") as file:
for archive in archives:
file.write(f"{sha256(archive)} {archive.name}\n")
for asset in assets:
file.write(f"{sha256(asset)} {asset.name}\n")
PY
- name: Generate release asset provenance
@@ -655,25 +670,27 @@ jobs:
env:
INSTALLATION_NOTES: |
> [!WARNING]
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
> **跨大版本升级:** 不支持跨大版本在线升级。请使用全新的 `.runtime/` 数据目录重新部署,再重新导入或重新授权服务商账号,并重新导入客户端密钥。详见[升级说明](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md#镜像升级与源码构建)。
## 📥 Installation
## 📥 安装
**Docker:**
**Docker 部署:**
```bash
# GitHub Container Registry
# 从 GitHub 容器镜像仓库拉取
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
```
**Manual download:**
Download the appropriate archive for your platform from the assets below.
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
Release archives are covered by GitHub artifact attestations. Docker images are published with SBOM/provenance attestations and keyless cosign signatures.
**手动下载:**
请从下方附件中下载适合当前平台的归档包。
## 📚 Documentation
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
- [GitHub Repository](https://github.com/${{ github.repository }})
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
## 📚 文档
- [GitHub 仓库](https://github.com/${{ github.repository }})
- [部署指南](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md)
run: |
python3 <<'PY'
import os
+11 -2
View File
@@ -40,7 +40,16 @@ jobs:
- name: Run cargo audit
working-directory: backend
run: cargo audit
shell: bash
run: |
# 临时接受此中危风险以保持 Codex 0.154.0 的 TLS 指纹;直接升级会新增 ML-DSA 签名算法。
# 仅豁免本条公告,2026-09-21 UTC 起恢复阻断;兼容升级验证完成后应提前删除豁免。
# https://github.com/rustls/rustls/security/advisories/GHSA-2mjx-qc3c-rqvc
audit_advisory_args=()
if [[ "$(date -u +%F)" < "2026-09-21" ]]; then
audit_advisory_args+=(--ignore RUSTSEC-2026-0285)
fi
cargo audit "${audit_advisory_args[@]}"
frontend-security:
runs-on: ubuntu-latest
@@ -52,7 +61,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 11.7.0
+19 -2
View File
@@ -76,15 +76,32 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
## 项目约定
- Rust 模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式。
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。改动使现有说明失真或缺少必要信息时,按文档职责修订对应内容;变更经过留在 PR,发布说明按维护者的发布安排更新。
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。
## 文档职责与更新条件
常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
- 按 [开发指南的文档入口](.agents/skills/dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue 或任务报告。版本变更与升级影响按维护者的发版安排维护
`release/notes.md`,不为普通开发任务或 PR 自动追加发布说明。
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
## 界面验证
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
+9 -3
View File
@@ -21,7 +21,7 @@
## 快速开始
使用 Docker Compose 部署发布镜像 `ghcr.io/zyycn/codex-proxy-rs:latest`,同时启动 PostgreSQL 和 Redis。
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
@@ -30,9 +30,12 @@
```bash
mkdir -p codex-proxy-rs/deploy && cd codex-proxy-rs
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/compose.yaml \
# 只解析一次最新正式版本,确保两个文件来自同一 Release。
CPR_RELEASE_URL="$(curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/zyycn/codex-proxy-rs/releases/latest)"
CPR_RELEASE_TAG="${CPR_RELEASE_URL##*/}"
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/compose.yaml" \
-o deploy/compose.yaml
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/config.example.yaml \
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/config.example.yaml" \
-o deploy/config.example.yaml
install -d -m 0750 .runtime/postgres .runtime/redis
@@ -40,6 +43,9 @@ sudo install -d -m 0770 -o "$(id -u)" -g 10001 .runtime/data .runtime/logs
sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
```
也可将 `CPR_RELEASE_TAG` 设置为指定的发布标签。Release 附带的 `compose.yaml` 默认使用该版本镜像,
配置模板和部署文件均包含在 `checksums.txt` 中;不要混用 `main` 分支模板与已发布镜像。
分别生成数据库和 Redis 密码:
```bash
+215 -37
View File
@@ -116,7 +116,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -154,9 +154,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.18.1"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -165,22 +165,21 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.45.0"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "aws-runtime"
version = "1.9.1"
version = "1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb"
checksum = "ef47857a1d4488b528f4a5d5715fa7c3300820897824152234d3fa22b1426657"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -206,9 +205,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.144.0"
version = "1.146.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30dc8bf6baaf7d46336a0ca2c69f223d9b90d7a801fb3e28f7ea17b00dc6b1de"
checksum = "37b5ffaae346b9bd486ebdc3eb7053ec8ab8a1ad0f19a332eefeff036ed559e6"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -479,9 +478,9 @@ dependencies = [
[[package]]
name = "aws-types"
version = "1.5.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94"
checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
@@ -709,6 +708,7 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
"serde",
]
[[package]]
@@ -740,7 +740,7 @@ dependencies = [
"provider-xai",
"serde",
"serde_json",
"syn 3.0.4",
"syn 3.0.5",
"tempfile",
"thiserror",
"tokio",
@@ -749,9 +749,9 @@ dependencies = [
[[package]]
name = "combine"
version = "4.6.7"
version = "4.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"bytes",
"futures-core",
@@ -808,6 +808,16 @@ dependencies = [
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -1192,7 +1202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1291,6 +1301,21 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1383,7 +1408,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -1879,6 +1904,22 @@ dependencies = [
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1896,9 +1937,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2236,6 +2279,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe 0.1.6",
"openssl-sys",
"schannel",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@@ -2291,12 +2351,66 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-src"
version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
[[package]]
name = "outref"
version = "0.5.2"
@@ -2604,6 +2718,7 @@ dependencies = [
"base64 0.23.1",
"bytes",
"chrono",
"chrono-tz",
"cookie",
"flate2",
"futures",
@@ -2615,6 +2730,9 @@ dependencies = [
"hyper",
"hyper-rustls",
"hyper-util",
"native-tls",
"openssl",
"openssl-sys",
"reqwest",
"roxmltree",
"rustls",
@@ -2741,7 +2859,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2840,9 +2958,9 @@ dependencies = [
[[package]]
name = "redis"
version = "1.6.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f"
checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed"
dependencies = [
"arc-swap",
"arcstr",
@@ -2919,6 +3037,7 @@ dependencies = [
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
@@ -2927,9 +3046,12 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -2941,6 +3063,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -3011,7 +3134,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3032,21 +3155,21 @@ dependencies = [
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"openssl-probe 0.2.1",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.7.0",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
@@ -3130,6 +3253,19 @@ dependencies = [
"zeroize",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -3137,7 +3273,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -3205,7 +3341,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -3663,9 +3799,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.4"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
@@ -3692,6 +3828,27 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tar"
version = "0.4.46"
@@ -3713,7 +3870,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3742,7 +3899,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -3811,9 +3968,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.53.1"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
@@ -3836,6 +3993,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -4184,9 +4351,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.26.0"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -4419,6 +4586,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
+33
View File
@@ -310,6 +310,39 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replace("desktop_build: '8109'", "desktop_build: 'build'"));
}
#[test]
fn config_loader_should_accept_empty_and_custom_request_locations() {
let original = valid_config();
let omitted = original
.lines()
.filter(|line| !line.trim_start().starts_with("location:"))
.collect::<Vec<_>>()
.join("\n");
assert_ne!(original, omitted);
parse_config(&omitted).expect("location passthrough when omitted");
let location_line = original
.lines()
.find(|line| line.trim_start().starts_with("location:"))
.expect("example location");
for empty in [" location:", " location: null", " location: ~"] {
parse_config(&original.replace(location_line, empty)).expect("empty YAML location");
}
let custom = original.replace(
"location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
"location: { country: 'NZ', region: 'Auckland', city: 'Auckland', timezone: 'Pacific/Auckland' }",
);
assert_ne!(original, custom);
parse_config(&custom).expect("custom location from YAML");
}
#[test]
fn config_loader_should_reject_invalid_request_location_timezones() {
let original = valid_config();
let invalid = original.replace("timezone: 'America/New_York'", "timezone: 'Not/A_Timezone'");
assert_ne!(original, invalid);
assert_rejected(invalid);
}
fn assert_rejected(config: String) {
assert!(parse_config(&config).is_err());
}
@@ -757,6 +757,24 @@ pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
})
}
/// 按需查询的订阅周期,不参与额度或账号可用性判断。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderSubscription {
pub starts_at: Option<DateTime<Utc>>,
pub expires_at: DateTime<Utc>,
pub will_renew: Option<bool>,
pub billing_period: Option<String>,
pub billing_currency: Option<String>,
pub observed_at: DateTime<Utc>,
}
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果。
#[derive(Debug, Clone, PartialEq)]
pub struct AccountPersonalInfo {
pub profile: Result<ProviderProfileStatistics, AdminError>,
pub subscription: Option<ProviderSubscription>,
}
/// Provider 官方个人资料中的累计摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileStatisticsSummary {
@@ -173,22 +173,19 @@ impl AccountQuotaForecast {
};
self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT
|| (method == QuotaForecastMethod::Incremental && sample.block_count < 2);
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗。
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。
let capacity_factor = 100.0 / sample.sampled_percent;
let factor = capacity_factor * self.target_seconds as f64 / seconds as f64;
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0 && !self.incomplete_tokens);
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0);
self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor));
self.estimated_usd = usd
.filter(|_| !self.incomplete_cost)
.and_then(|value| estimate(value, factor));
self.estimated_usd = usd.and_then(|value| estimate(value, factor));
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
self.remaining_tokens = tokens.and_then(|value| estimate_tokens(value, remaining_factor));
self.remaining_usd = usd
.filter(|_| !self.incomplete_cost)
.and_then(|value| estimate(value, remaining_factor));
self.remaining_usd = usd.and_then(|value| estimate(value, remaining_factor));
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
{
Some("本周期缺少可用的 Token 或完整费用记录,暂时无法预测额度。")
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
} else {
None
};
@@ -19,7 +19,7 @@ use crate::model::provider_credentials::{
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderResetCreditResult, ProviderResetCredits, explicit_plan_type,
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
};
use crate::model::{
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
@@ -185,6 +185,14 @@ pub trait ProviderAdmin: Send + Sync {
}
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
/// 只由个人信息显式查询,不在额度刷新或后台任务中预取。
async fn subscription(
&self,
_account_id: &ProviderAccountId,
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
async fn profile_statistics(
&self,
_account_id: &ProviderAccountId,
@@ -22,9 +22,9 @@ use crate::{
},
observability::TimeRange,
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
AccountRefreshResult, ConsumeProviderResetCredit, PrepareCredentialRefresh,
ProviderModels, ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
QuotaLocalUsageAttribution,
},
@@ -90,11 +90,11 @@ pub trait AccountsService: Send + Sync {
account_id: &ProviderAccountId,
) -> Result<AccountQuotaForecastReport, AdminError>;
async fn profile_statistics(
async fn personal_info(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持账号统计"))
) -> Result<AccountPersonalInfo, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持个人信息"))
}
async fn profile_avatar(
@@ -680,15 +680,36 @@ impl AccountsService for DefaultAccountsService {
})
}
async fn profile_statistics(
async fn personal_info(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.profile_statistics(account_id)
.await
.map_err(|error| map_provider_error(error, "provider profile statistics"))
) -> Result<AccountPersonalInfo, AdminError> {
let (initial, provider) = self.provider_for_account(account_id).await?;
// 两项读取相互独立;不因其中一项失败而取消另一项,也不触发凭据或额度刷新。
let (profile, subscription) = futures::join!(
provider.profile_statistics(account_id),
provider.subscription(account_id),
);
let profile =
profile.map_err(|error| map_provider_error(error, "provider profile statistics"));
let subscription = subscription
.map_err(|error| map_provider_error(error, "provider subscription"))
.ok()
.flatten();
// 汇聚等待期间发生重新授权、换绑或删除时,不返回混合身份的数据。
let current = self.load_account(account_id).await?;
if current.account.provider_kind != initial.account.provider_kind
|| current.account.credential_revision != initial.account.credential_revision
|| current.account.upstream_user_id != initial.account.upstream_user_id
|| current.account.upstream_account_id != initial.account.upstream_account_id
{
return Err(AdminError::conflict("账号身份已变化,请刷新信息后重试"));
}
Ok(AccountPersonalInfo {
profile,
subscription,
})
}
async fn profile_avatar(
@@ -246,14 +246,15 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
}
#[test]
fn missing_tokens_suppress_token_estimates_without_discarding_complete_costs() {
fn missing_tokens_keep_estimates_from_recorded_usage() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
sample.usage.missing_token_count = 1;
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.incomplete_tokens);
assert_eq!(week.estimated_tokens, None);
assert_eq!(week.remaining_tokens, None);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.source.unwrap().tokens, Some(1_000));
}
@@ -269,7 +270,7 @@ fn discontinuous_samples_never_fall_back_to_cumulative_predictions() {
}
#[test]
fn partial_or_unknown_costs_suppress_only_money_estimates() {
fn partial_costs_keep_estimates_from_known_amounts() {
for coverage in [
CostCoverage {
calculated_count: 9,
@@ -281,19 +282,77 @@ fn partial_or_unknown_costs_suppress_only_money_estimates() {
unavailable_count: 1,
..Default::default()
},
CostCoverage::default(),
] {
let mut sample = window("week", 7);
sample.local_usage.as_mut().unwrap().cost_coverage = coverage;
let [week, _] = forecast(&quota(vec![sample]));
assert!(week.incomplete_cost);
assert!(week.unavailable_reason.is_none());
assert!(week.estimated_usd.is_none());
assert!(week.remaining_usd.is_none());
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_usd, Some(8.0));
assert_eq!(week.estimated_tokens, Some(5_000));
}
}
#[test]
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
sample.usage.request_count += 2;
sample.usage.missing_token_count = 2;
sample.usage.unavailable_cost_count = 2;
sample.usage.excluded_request_count = 1;
sample.pending_request_count = 1;
let [week, month] =
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.incomplete_tokens);
assert!(week.incomplete_cost);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.remaining_usd, Some(8.0));
assert_eq!(month.estimated_tokens, Some(21_429));
assert_eq!(month.remaining_tokens, week.remaining_tokens);
assert_eq!(month.remaining_usd, week.remaining_usd);
}
#[test]
fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
let mut sample = window("week", 7);
let usage = sample.local_usage.as_mut().unwrap();
usage.cost_coverage = CostCoverage {
unavailable_count: usage.request_count,
..Default::default()
};
usage.costs.clear();
let [week, _] = forecast(&quota(vec![sample]));
assert!(week.incomplete_cost);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, None);
assert_eq!(week.remaining_usd, None);
}
#[test]
fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
sample.usage = QuotaForecastUsage {
request_count: 2,
missing_token_count: 2,
unavailable_cost_count: 2,
..Default::default()
};
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.unavailable_reason.is_some());
assert_eq!(week.estimated_tokens, None);
assert_eq!(week.estimated_usd, None);
assert_eq!(week.remaining_tokens, None);
assert_eq!(week.remaining_usd, None);
}
#[test]
fn zero_known_cost_is_valid_but_missing_usd_is_not_invented() {
let mut sample = window("week", 7);
@@ -38,8 +38,10 @@ use gateway_admin::{
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileStatistics,
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow, ProviderResetCreditResult, ProviderSubscription,
QuotaLocalUsageAttribution,
},
quota_forecast_sampling::{QuotaForecastHistory, QuotaForecastUsage},
@@ -77,6 +79,9 @@ pub(super) struct FakeProviderAdmin {
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
current_credential_revision: Mutex<Revision>,
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
profile_result: Mutex<Result<ProviderProfileStatistics, ProviderAdminErrorKind>>,
subscription_result: Mutex<Result<Option<ProviderSubscription>, ProviderAdminErrorKind>>,
personal_info_barrier: Mutex<Option<Arc<tokio::sync::Barrier>>>,
}
impl FakeProviderAdmin {
@@ -97,6 +102,9 @@ impl FakeProviderAdmin {
quota_refresh_account: Mutex::new(None),
current_credential_revision: Mutex::new(revision(1)),
reset_credit_commands: Mutex::new(Vec::new()),
profile_result: Mutex::new(Ok(empty_profile_statistics())),
subscription_result: Mutex::new(Ok(None)),
personal_info_barrier: Mutex::new(None),
})
}
@@ -104,6 +112,14 @@ impl FakeProviderAdmin {
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
}
async fn wait_personal_info_queries(&self) {
let barrier = self.personal_info_barrier.lock().unwrap().clone();
if let Some(barrier) = barrier {
barrier.wait().await;
barrier.wait().await;
}
}
fn fail_next_with_message(&self, kind: ProviderAdminErrorKind, message: &str) {
*self.failure.lock().expect("provider failure") =
Some(ProviderAdminError::new(kind).with_message(message));
@@ -237,6 +253,32 @@ impl ProviderAdmin for FakeProviderAdmin {
&self.kind
}
async fn profile_statistics(
&self,
_: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
self.record("provider.profile_statistics");
self.wait_personal_info_queries().await;
self.profile_result
.lock()
.unwrap()
.clone()
.map_err(ProviderAdminError::new)
}
async fn subscription(
&self,
_: &ProviderAccountId,
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
self.record("provider.subscription");
self.wait_personal_info_queries().await;
self.subscription_result
.lock()
.unwrap()
.clone()
.map_err(ProviderAdminError::new)
}
fn plan_type_display(&self, plan_type: &str) -> String {
format!("{} display: {plan_type}", self.kind)
}
@@ -2429,6 +2471,167 @@ fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResul
}
}
fn empty_profile_statistics() -> ProviderProfileStatistics {
ProviderProfileStatistics {
display_name: Some("Preview user".to_owned()),
username: None,
image_url: None,
has_stats_error: false,
summary: ProviderProfileStatisticsSummary {
total_text_tokens: Some(123),
peak_tokens: None,
longest_task_duration_ms: None,
current_streak_days: None,
longest_streak_days: None,
},
daily_usage: None,
activity_insights: ProviderProfileActivityInsights {
fast_mode_percent: None,
reasoning_effort: None,
reasoning_effort_percent: None,
skills_explored: None,
total_skills_used: None,
total_threads: None,
invocations: None,
},
}
}
fn personal_info_subscription() -> ProviderSubscription {
ProviderSubscription {
starts_at: None,
expires_at: Utc::now(),
will_renew: Some(true),
billing_period: Some("monthly".to_owned()),
billing_currency: Some("USD".to_owned()),
observed_at: Utc::now(),
}
}
#[tokio::test]
async fn personal_info_should_query_both_parts_concurrently_once_on_every_request() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let subscription = personal_info_subscription();
*provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone()));
*provider.personal_info_barrier.lock().unwrap() = Some(Arc::new(tokio::sync::Barrier::new(2)));
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider, store).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
for _ in 0..2 {
let info = tokio::time::timeout(
std::time::Duration::from_secs(1),
services.accounts().personal_info(&account_id),
)
.await
.expect("两项查询应并发执行")
.unwrap();
assert_eq!(info.profile.unwrap(), empty_profile_statistics());
assert_eq!(info.subscription, Some(subscription.clone()));
}
assert_eq!(
recorded(&events),
[
"store.load_account",
"provider.profile_statistics",
"provider.subscription",
"store.load_account",
"store.load_account",
"provider.profile_statistics",
"provider.subscription",
"store.load_account",
]
);
}
#[tokio::test]
async fn personal_info_should_keep_profile_when_subscription_is_missing_or_fails() {
for subscription in [Ok(None), Err(ProviderAdminErrorKind::Unavailable)] {
let provider = FakeProviderAdmin::new("openai", events());
*provider.subscription_result.lock().unwrap() = subscription;
let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await;
let info = services
.accounts()
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
assert_eq!(info.profile.unwrap(), empty_profile_statistics());
assert_eq!(info.subscription, None);
}
}
#[tokio::test]
async fn personal_info_should_keep_subscription_when_profile_fails() {
let provider = FakeProviderAdmin::new("openai", events());
*provider.profile_result.lock().unwrap() = Err(ProviderAdminErrorKind::BadGateway);
let subscription = personal_info_subscription();
*provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone()));
let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await;
let info = services
.accounts()
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
assert_eq!(
info.profile.unwrap_err().kind(),
gateway_admin::model::AdminErrorKind::BadGateway
);
assert_eq!(info.subscription, Some(subscription));
}
#[tokio::test]
async fn personal_info_should_reject_missing_account_before_querying_provider() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
store.set_accounts(Vec::new());
let services = accounts_service(provider, store).await;
let error = services
.accounts()
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap_err();
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::NotFound);
assert_eq!(recorded(&events), ["store.load_account"]);
}
#[tokio::test]
async fn personal_info_should_discard_results_when_account_changes_during_query() {
for change in 0..5 {
let provider = FakeProviderAdmin::new("openai", events());
let barrier = Arc::new(tokio::sync::Barrier::new(3));
*provider.personal_info_barrier.lock().unwrap() = Some(barrier.clone());
let store = FakeAccountStore::new("openai", events());
let services = accounts_service(provider, store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
let mutate = async {
barrier.wait().await;
let mut accounts = store.accounts.lock().unwrap().clone();
match change {
0 => accounts[0].credential_revision = revision(99),
1 => accounts[0].upstream_account_id = Some("changed-account".to_owned()),
2 => accounts[0].upstream_user_id = Some("changed-user".to_owned()),
3 => accounts[0].provider_kind = ProviderKind::new("xai").unwrap(),
_ => accounts.clear(),
}
store.set_accounts(accounts);
barrier.wait().await;
};
let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(1), async {
tokio::join!(services.accounts().personal_info(&account_id), mutate)
})
.await
.unwrap();
let expected = if change == 4 {
gateway_admin::model::AdminErrorKind::NotFound
} else {
gateway_admin::model::AdminErrorKind::Conflict
};
assert_eq!(result.unwrap_err().kind(), expected);
}
}
async fn accounts_service(
provider: Arc<FakeProviderAdmin>,
store: Arc<FakeAccountStore>,
@@ -23,12 +23,12 @@ where
)
.route("/api/admin/accounts/quota", get(account_quota::<S>))
.route(
"/api/admin/accounts/quota-forecast",
get(account_quota_forecast::<S>),
"/api/admin/accounts/personal-info",
get(account_personal_info::<S>),
)
.route(
"/api/admin/accounts/profile-statistics",
get(account_profile_statistics::<S>),
"/api/admin/accounts/quota-forecast",
get(account_quota_forecast::<S>),
)
.route(
"/api/admin/accounts/profile-avatar",
@@ -389,7 +389,7 @@ where
))
}
async fn account_profile_statistics<S>(
async fn account_personal_info<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
@@ -401,12 +401,12 @@ where
let result = state
.admin_services()
.accounts()
.profile_statistics(&account_id)
.personal_info(&account_id)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountProfileStatisticsData::from(result)),
AdminEnvelope::ok(AccountPersonalInfoData::from(result)),
))
}
@@ -25,14 +25,15 @@ use gateway_admin::model::{
AccountWeight, AccountsUpdateResult, BatchUpdateAccounts, SortDirection, UpdateAccount,
},
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
AccountRefreshResult, AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult,
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
ProviderDocument, ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar,
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaWindow, ProviderResetCredit,
ProviderResetCreditResult, ProviderResetCredits, RotateCredential, StartAuthorization,
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, RotateCredential,
StartAuthorization,
},
};
use gateway_core::{
@@ -653,6 +653,54 @@ pub struct AccountQuotaData {
pub account: AccountView,
}
/// 个人资料、累计统计与订阅的统一响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountPersonalInfoData {
pub profile: Option<AccountProfileStatisticsData>,
pub profile_error: Option<String>,
pub subscription: Option<AccountSubscriptionData>,
}
impl From<AccountPersonalInfo> for AccountPersonalInfoData {
fn from(info: AccountPersonalInfo) -> Self {
let (profile, profile_error) = match info.profile {
Ok(profile) => (Some(AccountProfileStatisticsData::from(profile)), None),
Err(error) => (None, Some(error.message().to_owned())),
};
Self {
profile,
profile_error,
subscription: info.subscription.map(AccountSubscriptionData::from),
}
}
}
/// 按需读取的订阅安全字段,不暴露原始上游响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountSubscriptionData {
pub starts_at: Option<String>,
pub expires_at: String,
pub will_renew: Option<bool>,
pub billing_period: Option<String>,
pub billing_currency: Option<String>,
pub observed_at: String,
}
impl From<ProviderSubscription> for AccountSubscriptionData {
fn from(subscription: ProviderSubscription) -> Self {
Self {
starts_at: subscription.starts_at.map(|value| value.to_rfc3339()),
expires_at: subscription.expires_at.to_rfc3339(),
will_renew: subscription.will_renew,
billing_period: subscription.billing_period,
billing_currency: subscription.billing_currency,
observed_at: subscription.observed_at.to_rfc3339(),
}
}
}
/// Provider 官方个人资料统计响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -2,7 +2,9 @@
use bytes::Bytes;
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent};
use gateway_protocol::openai::sse::encode_sse_event_with_metadata;
use gateway_protocol::openai::sse::{
encode_sse_event_with_metadata, response_failed_sse_data_from_error_event,
};
use serde_json::Value;
use super::error::ResponseEncodeError;
@@ -17,6 +19,7 @@ const OPENAI_PROTOCOL: &str = "openai";
#[derive(Debug, Default)]
pub struct OpenAiResponsesEncoder {
response_id: Option<String>,
response_snapshot: Option<Value>,
wire_terminal: Option<Value>,
wire_failure: bool,
}
@@ -27,6 +30,7 @@ impl OpenAiResponsesEncoder {
pub const fn new() -> Self {
Self {
response_id: None,
response_snapshot: None,
wire_terminal: None,
wire_failure: false,
}
@@ -39,6 +43,22 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因。
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`。
if wire.event_type() == Some("error")
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
)
{
return vec![Bytes::from(encode_sse_event_with_metadata(
"response.failed",
&data.to_string(),
wire.sse_id(),
wire.sse_retry(),
))];
}
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
return vec![raw_sse_frame.clone()];
}
@@ -110,6 +130,16 @@ impl OpenAiResponsesEncoder {
let effective_type = wire
.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str));
if matches!(
effective_type,
Some("response.created" | "response.in_progress" | "response.queued")
) && let Some(response) = wire
.data()
.get("response")
.filter(|value| value.is_object())
{
self.response_snapshot = Some(response.clone());
}
if matches!(
effective_type,
Some("response.completed" | "response.incomplete")
@@ -7,6 +7,42 @@ use tower::ServiceExt as _;
use super::super::{AdminTestFixture, AdminTestState};
#[tokio::test]
async fn personal_info_requires_admin_and_a_valid_account_query() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (query, authenticated, expected) in [
("?accountId=acct_test", false, StatusCode::UNAUTHORIZED),
("", true, StatusCode::BAD_REQUEST),
("?accountId=bad", true, StatusCode::BAD_REQUEST),
(
"?accountId=acct_test&refresh=true",
true,
StatusCode::BAD_REQUEST,
),
// 此夹具未提供账号 Store,合法查询应透传服务不可用,而非绕过查询。
(
"?accountId=acct_test",
true,
StatusCode::SERVICE_UNAVAILABLE,
),
] {
let mut request = Request::builder()
.uri(format!("/api/admin/accounts/personal-info{query}"))
.header("x-request-id", "req_personal_info");
if authenticated {
request = request.header(header::COOKIE, "cpr_admin_session=valid-session");
}
let response = admin::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), expected, "{query}");
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
}
}
#[tokio::test]
async fn quota_forecast_requires_admin_and_valid_account_query() {
let fixture = AdminTestFixture::new().await;
@@ -1,6 +1,77 @@
mod handlers;
mod presenter;
mod personal_info {
use chrono::{TimeZone as _, Utc};
use gateway_admin::model::{
AdminError,
provider_credentials::{AccountPersonalInfo, ProviderSubscription},
};
use gateway_api::admin::accounts::{AccountPersonalInfoData, AccountSubscriptionData};
use serde_json::json;
#[test]
fn subscription_response_exposes_only_display_fields_and_preserves_unknowns() {
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
let response = AccountSubscriptionData::from(ProviderSubscription {
starts_at: None,
expires_at: observed,
will_renew: None,
billing_period: None,
billing_currency: Some("USD".to_owned()),
observed_at: observed,
});
assert_eq!(
serde_json::to_value(response).unwrap(),
json!({
"startsAt": null, "expiresAt": "2026-09-14T00:00:00+00:00",
"willRenew": null, "billingPeriod": null, "billingCurrency": "USD",
"observedAt": "2026-09-14T00:00:00+00:00"
})
);
}
#[test]
fn personal_info_response_preserves_subscription_when_profile_fails() {
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
profile: Err(AdminError::bad_gateway("上游服务请求失败")),
subscription: Some(ProviderSubscription {
starts_at: None,
expires_at: observed,
will_renew: None,
billing_period: None,
billing_currency: None,
observed_at: observed,
}),
});
let value = serde_json::to_value(response).unwrap();
assert!(value["profile"].is_null());
assert_eq!(value["profileError"], "上游服务请求失败");
assert_eq!(
value["subscription"]["expiresAt"],
"2026-09-14T00:00:00+00:00"
);
assert_eq!(value.as_object().unwrap().len(), 3);
}
#[test]
fn personal_info_response_preserves_unknown_subscription_and_profile_error() {
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
profile: Err(AdminError::unavailable("Provider 服务暂不可用")),
subscription: None,
});
assert_eq!(
serde_json::to_value(response).unwrap(),
json!({
"profile": null,
"profileError": "Provider 服务暂不可用",
"subscription": null,
})
);
}
}
mod query {
use gateway_admin::model::accounts::{AccountSortField, AccountStatus, SortDirection};
use gateway_api::admin::accounts::ListQuery;
@@ -34,6 +34,7 @@ use gateway_core::event::{
use gateway_core::operation::{Operation, OperationKind};
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::{OpaqueUpstreamValue, UpstreamSendState};
use gateway_protocol::openai::sse::{encode_sse_event, parse_sse_events};
use serde_json::{Value, json};
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
@@ -1572,6 +1573,113 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
);
}
#[tokio::test]
async fn streaming_upstream_error_event_should_be_translated_to_response_failed_for_sse() {
let trace = Arc::new(Trace::default());
let error_data = json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
let error_wire = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some("error".to_owned()),
error_data,
projected_error_frame,
None,
None,
)
.expect("valid upstream error wire"),
);
let started_wire = ProviderEvent::canonical_with_wire(
vec![started()],
ProtocolWireEvent::json(
"openai",
Some("response.created".to_owned()),
json!({
"type": "response.created",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "in_progress",
"future_response_field": {"keep": true}
}
}),
)
.expect("valid upstream started wire"),
);
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(CoordinatedEvent::single(
started_wire,
CommitRequirement::CommitBeforeDelivery,
)),
NextStep::Event(CoordinatedEvent::single(
error_wire,
CommitRequirement::AlreadyCommitted,
)),
NextStep::Error(EngineError::Provider(ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
))),
],
);
let response = stream_execution_response(Box::new(session), None).await;
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read SSE body");
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
let events = parse_sse_events(&body).expect("translated SSE should parse");
let failed = events
.iter()
.find(|event| event.event.as_deref() == Some("response.failed"))
.expect("translated response.failed event");
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
assert_eq!(
(
failed,
body.matches("event: response.failed").count(),
body.contains("event: error\n"),
body.ends_with("data: [DONE]\n\n"),
),
(
json!({
"type": "response.failed",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"future_response_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
}),
1,
false,
true,
)
);
}
#[tokio::test]
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
let trace = Arc::new(Trace::default());
@@ -3,7 +3,7 @@
use crate::diagnostics::TraceContext;
use serde_json::json;
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::num::NonZeroU32;
use std::sync::Arc;
@@ -192,7 +192,8 @@ where
excluded_accounts: BTreeSet::new(),
credential_recovery_attempted_accounts: BTreeSet::new(),
recovery_account: None,
transport_recovery: None,
pending_retry: None,
transient_retry_counts: BTreeMap::new(),
current: None,
send_state_watermark: UpstreamSendState::NotSent,
downstream_committed_at: None,
@@ -224,6 +225,7 @@ struct CurrentAttempt {
stream: ProviderStream,
metadata: ProviderCallMetadata,
trigger: AttemptTrigger,
transport: AttemptTransport,
index: NonZeroU32,
started_at: SystemTime,
send_observed: bool,
@@ -253,10 +255,11 @@ struct FailureFinalization {
}
#[derive(Debug, Clone)]
struct PendingTransportRecovery {
struct PendingAttemptRetry {
account: crate::account::ProviderAccountId,
transport: AttemptTransport,
delay: Duration,
transport_recovery: bool,
}
/// API 可逐事件消费的 Core 执行会话。
@@ -294,9 +297,10 @@ pub struct ResponseExecutionSession<S: ?Sized> {
/// attempt 建立时即被消费,后续可重试错误仍可换号消耗剩余重试预算。
/// 与 `required_account`(外部指定、贯穿整个请求)语义不同,不可合并。
recovery_account: Option<crate::account::ProviderAccountId>,
/// 上游传输在提交边界前失败后的一次性同账号重试或备用传输钉选。
/// 与凭据恢复分开保存,避免把 transport recovery 误记为 OAuth 恢复。
transport_recovery: Option<PendingTransportRecovery>,
/// 提交前同账号退避/传输恢复共用的等待与钉选状态;业务重试消耗路由预算。
pending_retry: Option<PendingAttemptRetry>,
/// 请求内按账号累计的瞬时拒绝重试次数,不能跨请求污染账号健康状态。
transient_retry_counts: BTreeMap<crate::account::ProviderAccountId, u32>,
current: Option<CurrentAttempt>,
/// 请求级发送状态水位;跨 attempt 单调不降,终态写回不得低于此档。
send_state_watermark: UpstreamSendState,
@@ -693,11 +697,14 @@ where
}
async fn prepare_attempt(&mut self) -> Result<Option<PullOutcome>, EngineError> {
let transport_recovery = self.transport_recovery.take();
if transport_recovery.is_none() && self.routing_attempts >= self.plan.max_attempts().get() {
let pending_retry = self.pending_retry.take();
let is_transport_recovery = pending_retry
.as_ref()
.is_some_and(|retry| retry.transport_recovery);
if !is_transport_recovery && self.routing_attempts >= self.plan.max_attempts().get() {
return Err(EngineError::EmptyRoutingPlan);
}
if let Some(recovery) = transport_recovery.as_ref()
if let Some(recovery) = pending_retry.as_ref()
&& !recovery.delay.is_zero()
{
match poll_retry_delay(recovery.delay, self.cancellation.clone(), self.deadline).await {
@@ -738,8 +745,7 @@ where
.ok_or(EngineError::EmptyRoutingPlan)?;
// 请求局部恢复钉选在此被一次性消费,只绑定本次 replay attempt;
// 外部 required_account 每次 attempt 都重新生效。
let is_transport_recovery = transport_recovery.is_some();
let (pinned_account, attempt_transport) = if let Some(recovery) = transport_recovery {
let (pinned_account, attempt_transport) = if let Some(recovery) = pending_retry {
(Some(recovery.account), recovery.transport)
} else {
match &self.account_selection {
@@ -1018,6 +1024,7 @@ where
stream,
metadata,
trigger,
transport: attempt_transport,
index: next_attempt,
started_at: SystemTime::now(),
send_observed: false,
@@ -1190,6 +1197,27 @@ where
&& attempt_send_state != UpstreamSendState::Ambiguous
&& provider_proved_replay_safe
&& self.routing_attempts < self.plan.max_attempts().get();
let transient_retry = match error.pre_delivery_retry() {
Some(crate::error::PreDeliveryRetry::SameAccountTransientRetry {
max_retries,
initial_delay,
max_delay,
}) if ordinary_retry => {
let retries = self
.transient_retry_counts
.entry(current.metadata.provider_account_id().clone())
.or_default();
if *retries < max_retries.get() {
let multiplier = 1_u32.checked_shl(*retries).unwrap_or(u32::MAX);
let delay = initial_delay.saturating_mul(multiplier).min(max_delay);
*retries = retries.saturating_add(1);
Some(delay)
} else {
None
}
}
_ => None,
};
let same_account_retry = error.retries_same_account()
&& provider_proved_replay_safe
&& self.downstream_committed_at.is_none()
@@ -1209,7 +1237,8 @@ where
"retryable": retryable, "continuationRetry": continuation_retry,
"sameAccountRetry": same_account_retry, "accountRotationRetry": account_rotation_retry,
"ordinaryRetry": ordinary_retry, "transportRecovery": transport_recovery.is_some(),
"delayMs": transport_recovery.map(|(_, delay)| duration_ms(delay)),
"transientRetry": transient_retry.is_some(),
"delayMs": transient_retry.or(transport_recovery.map(|(_, delay)| delay)).map(duration_ms),
"downstreamCommitted": self.downstream_committed_at.is_some(),
"sendState": format!("{attempt_send_state:?}"),
}));
@@ -1229,11 +1258,19 @@ where
// 只钉住紧随其后的 replay attempt;replay 再遇可重试错误时,
// ordinary/continuation 重试门不受影响,仍可换号。
self.recovery_account = Some(account);
} else if let Some(delay) = transient_retry {
self.pending_retry = Some(PendingAttemptRetry {
account: current.metadata.provider_account_id().clone(),
transport: current.transport,
delay,
transport_recovery: false,
});
} else if let Some((transport, delay)) = transport_recovery {
self.transport_recovery = Some(PendingTransportRecovery {
self.pending_retry = Some(PendingAttemptRetry {
account: current.metadata.provider_account_id().clone(),
transport,
delay,
transport_recovery: true,
});
} else if !continuation_retry {
self.excluded_accounts
+46 -16
View File
@@ -46,6 +46,8 @@ pub enum ProviderErrorKind {
Protocol,
/// Provider 暂不可用。
Unavailable,
/// 上游明确拒绝当前请求的模型容量;不证明整个 Provider 的基础设施故障。
UpstreamCapacityUnavailable,
/// 请求被取消。
Cancelled,
/// 进程终止后由恢复流程收敛。
@@ -71,6 +73,7 @@ impl ProviderErrorKind {
Self::Transport => "transport",
Self::Protocol => "protocol",
Self::Unavailable => "unavailable",
Self::UpstreamCapacityUnavailable => "upstream_capacity_unavailable",
Self::Cancelled => "cancelled",
Self::ProcessTerminated => "process_terminated",
}
@@ -105,6 +108,15 @@ pub enum ContinuationRecoveryDisposition {
pub enum PreDeliveryRetry {
/// 排除本次账号,按普通调度策略重新选号。
AccountRotation,
/// 上游明确拒绝后的同账号退避;耗尽后仍由普通安全重放规则决定是否换号。
SameAccountTransientRetry {
/// 每个账号在本请求中的重试上限,同时受总路由预算约束。
max_retries: NonZeroU32,
/// 指数退避的起始间隔。
initial_delay: Duration,
/// 单次等待上限,避免上游提示把请求拖入长时间等待。
max_delay: Duration,
},
/// 固定本次账号,并按 Provider 给出的序号重试当前传输。
SameAccountTransportRetry {
/// Provider-owned 传输重试序号,从 1 开始。
@@ -409,7 +421,7 @@ pub struct ProviderError {
continuation_recovery_disposition: Option<ContinuationRecoveryDisposition>,
failure_observation: Option<Box<ProviderErrorFailureObservation>>,
replay_safe: bool,
pre_delivery_retry: Option<PreDeliveryRetry>,
pre_delivery_retry: Option<Box<PreDeliveryRetry>>,
credential_recovery_required: bool,
retry_same_account: bool,
sensitive_context_redacted: bool,
@@ -557,8 +569,8 @@ impl ProviderError {
/// Provider 选择了“客户端无感恢复优先”的传输策略。Core 在 continuation、
/// 指定账号或下游已经进入提交状态时必须忽略它。
#[must_use]
pub const fn with_pre_delivery_retry(mut self) -> Self {
self.pre_delivery_retry = Some(PreDeliveryRetry::AccountRotation);
pub fn with_pre_delivery_retry(mut self) -> Self {
self.pre_delivery_retry = Some(Box::new(PreDeliveryRetry::AccountRotation));
self
}
@@ -567,14 +579,14 @@ impl ProviderError {
/// 与普通换号恢复相同,Core 仍会拒绝 continuation、指定账号、预算耗尽或
/// 下游已提交后的隐藏重放。
#[must_use]
pub const fn with_pre_delivery_transport_fallback(mut self) -> Self {
pub fn with_pre_delivery_transport_fallback(mut self) -> Self {
self.set_pre_delivery_transport_fallback();
self
}
/// 原地设置同账号备用传输恢复,保留当前错误携带的原客户端响应。
pub const fn set_pre_delivery_transport_fallback(&mut self) {
self.pre_delivery_retry = Some(PreDeliveryRetry::SameAccountTransportFallback);
pub fn set_pre_delivery_transport_fallback(&mut self) {
self.pre_delivery_retry = Some(Box::new(PreDeliveryRetry::SameAccountTransportFallback));
}
/// 允许 Core 在首个客户端事件前固定原账号并重试当前 Provider 传输。
@@ -582,7 +594,7 @@ impl ProviderError {
/// 重试预算和退避策略由 Provider 所属协议定义;Core 只负责同账号钉选、
/// deadline/取消边界以及下游 commit barrier。
#[must_use]
pub const fn with_pre_delivery_transport_retry(
pub fn with_pre_delivery_transport_retry(
mut self,
retry_index: NonZeroU32,
delay: Duration,
@@ -592,13 +604,11 @@ impl ProviderError {
}
/// 原地设置同账号当前传输重试,保留当前错误携带的原客户端响应。
pub const fn set_pre_delivery_transport_retry(
&mut self,
retry_index: NonZeroU32,
delay: Duration,
) {
self.pre_delivery_retry =
Some(PreDeliveryRetry::SameAccountTransportRetry { retry_index, delay });
pub fn set_pre_delivery_transport_retry(&mut self, retry_index: NonZeroU32, delay: Duration) {
self.pre_delivery_retry = Some(Box::new(PreDeliveryRetry::SameAccountTransportRetry {
retry_index,
delay,
}));
}
/// 要求 Core 在账号凭据已恢复后仅对原账号重放一次。
@@ -608,6 +618,22 @@ impl ProviderError {
self
}
/// 请求有界同账号退避;该意图本身不提供安全重放证据。
#[must_use]
pub fn with_transient_retry(
mut self,
max_retries: NonZeroU32,
initial_delay: Duration,
max_delay: Duration,
) -> Self {
self.pre_delivery_retry = Some(Box::new(PreDeliveryRetry::SameAccountTransientRetry {
max_retries,
initial_delay,
max_delay,
}));
self
}
/// 标记上游明确拒绝应先触发 OAuth 恢复,而不是直接废弃账号。
#[must_use]
pub const fn with_credential_recovery(mut self) -> Self {
@@ -748,7 +774,10 @@ impl ProviderError {
/// 返回下游提交前由 Provider 选择的隐藏恢复方式。
#[must_use]
pub const fn pre_delivery_retry(&self) -> Option<PreDeliveryRetry> {
self.pre_delivery_retry
match self.pre_delivery_retry.as_ref() {
Some(retry) => Some(**retry),
None => None,
}
}
/// 返回 Provider 是否已完成凭据恢复并要求原账号重放。
@@ -822,7 +851,7 @@ impl Clone for ProviderError {
continuation_recovery_disposition: self.continuation_recovery_disposition,
failure_observation: self.failure_observation.clone(),
replay_safe: self.replay_safe,
pre_delivery_retry: self.pre_delivery_retry,
pre_delivery_retry: self.pre_delivery_retry.clone(),
credential_recovery_required: self.credential_recovery_required,
retry_same_account: self.retry_same_account,
sensitive_context_redacted: self.sensitive_context_redacted,
@@ -1064,6 +1093,7 @@ impl GatewayError {
ProviderErrorKind::Transport
| ProviderErrorKind::Protocol
| ProviderErrorKind::Unavailable
| ProviderErrorKind::UpstreamCapacityUnavailable
| ProviderErrorKind::ProcessTerminated => Self::new(
GatewayErrorKind::UpstreamUnavailable,
"upstream service is unavailable",
+3 -3
View File
@@ -521,7 +521,7 @@ impl fmt::Debug for ProviderResponseHeader {
}
}
/// Core 消费的实际上游响应事实。
/// Core 消费的 Provider 执行观测,不会原样进入客户端响应。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResponseObservation {
transport: UpstreamTransport,
@@ -575,7 +575,7 @@ impl ProviderResponseObservation {
self
}
/// 附加 Provider 在响应中确认的服务档位。
/// 附加 Provider 确定的统计档位,Core 不重新解释其来源。
///
/// # Errors
///
@@ -590,7 +590,7 @@ impl ProviderResponseObservation {
Ok(self)
}
/// 附加合法服务档位;不适合持久化的上游观测值会被忽略。
/// 附加 Provider 确定的统计档位;不适合持久化的值会被忽略。
#[must_use]
pub fn with_service_tier_if_valid(mut self, service_tier: impl Into<String>) -> Self {
let service_tier = service_tier.into();
@@ -3318,6 +3318,284 @@ fn provider_owned_transport_retries_keep_the_same_account_until_fallback() {
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
}
fn transient_rejection() -> ProviderError {
ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_replay_safe()
.with_transient_retry(
NonZeroU32::new(3).expect("retry budget"),
Duration::ZERO,
Duration::ZERO,
)
}
#[test]
fn transient_retries_back_off_on_the_same_account_then_rotate_with_a_fresh_budget() {
let operation = generate_operation();
let route_plan = plan(&operation);
let mut scripts = (0..4)
.map(|_| Script::Stream {
account_id: "acct_first",
items: vec![Err(transient_rejection())],
})
.collect::<Vec<_>>();
scripts.push(Script::Stream {
account_id: "acct_second",
items: vec![Err(transient_rejection())],
});
scripts.push(Script::Stream {
account_id: "acct_second",
items: complete_stream(None),
});
let (coordinator, store, provider) = coordinator(scripts);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
block_on(session.collect_uncommitted()).expect("second account succeeds");
block_on(session.commit_downstream(Some(200))).expect("commit");
let first = ProviderAccountId::new("acct_first").expect("account");
let second = ProviderAccountId::new("acct_second").expect("account");
let contexts = provider.contexts.lock().expect("contexts");
assert_eq!(contexts.len(), 6);
for context in &contexts[1..4] {
assert_eq!(context.required_account(), Some(&first));
assert!(!context.excluded_accounts().contains(&first));
}
assert_eq!(contexts[4].required_account(), None);
assert!(contexts[4].excluded_accounts().contains(&first));
assert_eq!(contexts[5].required_account(), Some(&second));
assert!(!contexts[5].excluded_accounts().contains(&second));
assert!(
contexts
.iter()
.all(|context| context.transport() == AttemptTransport::Default)
);
assert_eq!(
store.state.lock().expect("store").finalizations[0].attempt_count,
6
);
}
#[test]
fn transient_retries_consume_the_total_routing_budget() {
let operation = generate_operation();
let route_plan = plan(&operation);
let max_attempts = route_plan.max_attempts();
let scripts = (0..max_attempts.get())
.map(|_| Script::Stream {
account_id: "acct_first",
items: vec![Err(transient_rejection().with_transient_retry(
max_attempts,
Duration::ZERO,
Duration::ZERO,
))],
})
.collect();
let (coordinator, store, provider) = coordinator(scripts);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
assert!(matches!(
block_on(session.collect_uncommitted()),
Err(EngineError::Provider(_))
));
assert_eq!(
provider.contexts.lock().expect("contexts").len(),
max_attempts.get() as usize
);
assert_eq!(
store.state.lock().expect("store").finalizations[0].attempt_count,
max_attempts.get()
);
}
#[test]
fn transient_retry_intent_does_not_supply_replay_proof_or_override_ambiguous_send() {
for (send_state, prove_safe) in [
(UpstreamSendState::Sent, false),
(UpstreamSendState::Ambiguous, true),
] {
let operation = generate_operation();
let route_plan = plan(&operation);
let mut error = ProviderError::new(ProviderErrorKind::Unavailable, send_state)
.with_transient_retry(NonZeroU32::MIN, Duration::ZERO, Duration::ZERO);
if prove_safe {
error = error.with_replay_safe();
}
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
account_id: "acct_first",
items: vec![Err(error)],
}]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
assert!(matches!(
block_on(session.collect_uncommitted()),
Err(EngineError::Provider(_))
));
assert_eq!(provider.contexts.lock().expect("contexts").len(), 1);
}
}
#[test]
fn transient_backoff_obeys_request_deadline() {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
account_id: "acct_first",
items: vec![Err(transient_rejection().with_transient_retry(
NonZeroU32::MIN,
Duration::from_secs(60),
Duration::from_secs(60),
))],
}]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_millis(100)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
assert!(matches!(
block_on(session.collect_uncommitted()),
Err(EngineError::Deadline)
));
assert_eq!(provider.contexts.lock().expect("contexts").len(), 1);
}
#[test]
fn transient_retry_keeps_http_fallback_for_the_pinned_account() {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, _, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
items: vec![Err(ProviderError::new(
ProviderErrorKind::Transport,
UpstreamSendState::NotSent,
)
.with_pre_delivery_transport_fallback())],
},
Script::Stream {
account_id: "acct_first",
items: vec![Err(transient_rejection())],
},
Script::Stream {
account_id: "acct_first",
items: complete_stream(None),
},
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
block_on(session.collect_uncommitted()).expect("fallback succeeds after backoff");
let contexts = provider.contexts.lock().expect("contexts");
assert_eq!(contexts.len(), 3);
assert_eq!(contexts[2].transport(), AttemptTransport::Fallback);
}
#[test]
fn transient_retry_is_suppressed_for_required_accounts_and_committed_output() {
for committed in [false, true] {
let operation = generate_operation();
let route_plan = plan(&operation);
let mut items = Vec::new();
if committed {
items.push(Ok(GatewayEvent::Started(ResponseMeta::new(
"response-visible",
"gpt-5",
))));
}
items.push(Err(transient_rejection()));
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
account_id: "acct_first",
items,
}]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
(!committed).then(|| ProviderAccountId::new("acct_first").expect("account")),
None,
CancellationToken::new(),
))
.expect("start execution");
if committed {
block_on(session.next_event())
.expect("first event")
.expect("visible event");
block_on(session.commit_downstream(Some(200))).expect("commit output");
}
assert!(matches!(
block_on(session.next_event()),
Err(EngineError::Provider(_))
));
assert_eq!(provider.contexts.lock().expect("contexts").len(), 1);
}
}
#[test]
fn transient_backoff_can_be_cancelled_before_the_next_attempt() {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
account_id: "acct_first",
items: vec![Err(transient_rejection().with_transient_retry(
NonZeroU32::MIN,
Duration::from_secs(60),
Duration::from_secs(60),
))],
}]);
let cancellation = CancellationToken::new();
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
cancellation.clone(),
))
.expect("start execution");
let cancel = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
cancellation.cancel();
});
assert!(matches!(
block_on(session.collect_uncommitted()),
Err(EngineError::Cancelled)
));
cancel.join().expect("cancel task");
assert_eq!(provider.contexts.lock().expect("contexts").len(), 1);
}
#[test]
fn final_capacity_exhaustion_returns_the_last_retryable_upstream_failure() {
let operation = generate_operation();
@@ -863,6 +863,9 @@ fn only_provider_attributable_failures_should_affect_circuit() {
assert!(!provider_failure_affects_circuit(
ProviderErrorKind::ContinuationRecoveryRequired
));
assert!(!provider_failure_affects_circuit(
ProviderErrorKind::UpstreamCapacityUnavailable
));
}
#[test]
@@ -2,7 +2,7 @@
use std::fmt;
use serde_json::json;
use serde_json::{Value, json};
use thiserror::Error;
/// 单条 SSE 事件。
@@ -277,12 +277,7 @@ pub fn response_failed_sse_data_with_id(
"code": code,
"message": message,
});
let response_id = response_id
.filter(|value| !value.trim().is_empty())
.map_or_else(
|| format!("resp_proxy_{}", uuid::Uuid::new_v4().simple()),
ToString::to_string,
);
let response_id = response_id_or_generated(response_id);
json!({
"type": "response.failed",
"response": {
@@ -294,6 +289,50 @@ pub fn response_failed_sse_data_with_id(
})
}
/// 将携带嵌套错误的上游事件投影为 OpenAI Responses `response.failed` 数据。
///
/// 该投影供客户端 SSE 兼容转换使用。原始错误对象和事件级元数据保持不透明;
/// 已有 response 快照只更新失败终态字段。
pub fn response_failed_sse_data_from_error_event(
response: Option<&Value>,
response_id: Option<&str>,
error_event: &Value,
) -> Option<Value> {
let mut event = error_event.as_object()?.clone();
let error = event.remove("error").filter(Value::is_object)?;
let mut response = response
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let has_response_id = response
.get("id")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
if !has_response_id {
response.insert(
"id".to_owned(),
Value::String(response_id_or_generated(response_id)),
);
}
response.insert("status".to_owned(), Value::String("failed".to_owned()));
response.insert("error".to_owned(), error);
event.insert(
"type".to_owned(),
Value::String("response.failed".to_owned()),
);
event.insert("response".to_owned(), Value::Object(response));
Some(Value::Object(event))
}
fn response_id_or_generated(response_id: Option<&str>) -> String {
response_id
.filter(|value| !value.trim().is_empty())
.map_or_else(
|| format!("resp_proxy_{}", uuid::Uuid::new_v4().simple()),
ToString::to_string,
)
}
/// 返回下一个完整 SSE 帧结束位置(含分隔符)。
pub fn sse_frame_end(bytes: &[u8]) -> Option<usize> {
sse_frame_separator_bytes(bytes).map(|(position, separator_len)| position + separator_len)
@@ -1,8 +1,10 @@
use gateway_protocol::openai::sse::{
DONE_SSE_FRAME, MAX_SSE_EVENT_BUFFER_BYTES, SseError, SseEvent, SseEventDecoder,
encode_sse_event, encode_sse_event_with_metadata, parse_sse_events, response_failed_sse_event,
encode_sse_event, encode_sse_event_with_metadata, parse_sse_events,
response_failed_sse_data_from_error_event, response_failed_sse_event,
response_failed_sse_event_with_id, sse_frame_end, sse_frame_is_done,
};
use serde_json::json;
fn sse_body_has_done(body: &str) -> bool {
body.trim_end_matches(['\r', '\n'])
@@ -386,6 +388,57 @@ fn response_failed_event_should_preserve_existing_response_id() {
assert_eq!(data["response"]["id"], "resp_existing");
}
#[test]
fn response_failed_projection_should_preserve_response_error_and_event_metadata() {
let response = json!({
"id": "resp_existing",
"model": "gpt-test",
"status": "in_progress",
"future_response_field": {"keep": true}
});
let error_event = json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "overloaded",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected = response_failed_sse_data_from_error_event(
Some(&response),
Some("ignored_fallback"),
&error_event,
)
.expect("wrapped error event should project");
assert_eq!(
projected,
json!({
"type": "response.failed",
"response": {
"id": "resp_existing",
"model": "gpt-test",
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "overloaded",
"param": null,
"future_error_field": {"keep": true}
},
"future_response_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
})
);
}
#[test]
fn done_frame_detection_should_require_exact_single_data_field() {
assert_eq!(
+14 -6
View File
@@ -13,6 +13,7 @@ async-trait = "0.1.89"
base64 = "0.23.0"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["clock", "serde"] }
chrono-tz = { version = "0.10", features = ["serde"] }
cookie = "0.18.1"
futures = "0.3.32"
flate2 = "1.1.9"
@@ -22,27 +23,30 @@ gateway-protocol = { path = "../../gateway-protocol" }
getrandom = "0.4.3"
hex = "0.4.3"
# TLS 依赖按官方 Core 锁文件固定,包含用于约束传递依赖的直接声明。
# hyper 受 workspace 的 SOCKS IPv6 修复补丁约束,保留补丁要求的兼容版本。
# 升级前须重复采样 ClientHello,核对算法、扩展集合及排序约束、key_share 与 ALPN。
aws-lc-rs = "=1.18.1"
aws-lc-rs = "=1.16.2"
hyper = { version = "=1.11.1", default-features = false }
hyper-rustls = "=0.27.7"
hyper-util = { version = "0.1.20", features = ["client-legacy", "http1", "tokio"] }
# 与官方 Codex Desktop 一致:reqwest 不启用压缩特性,上游请求不带 accept-encoding。
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "http2", "socks"] }
# 与官方 Codex Desktop 一致:HTTP 默认走 native-tls,rustls 保留给自定义 CA 回退;
# vendored OpenSSL 固定 Linux 发布版的 TLS 画像,且不启用压缩特性。
native-tls = "=0.2.14"
reqwest = { version = "=0.12.28", features = ["json", "stream", "cookies", "rustls-tls-native-roots", "socks"] }
roxmltree = "0.21.1"
semver = "1.0.27"
# 与官方 rustls 路径一致:aws-lc 支持 P-521,优先提供 X25519MLKEM768。
# reqwest 的 roots feature 仍会编译 ring;实际 provider 由 TLS 初始化显式选择。
rustls = { version = "=0.23.36", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std", "tls12"] }
rustls-native-certs = "=0.8.4"
rustls-pki-types = "=1.15.1"
rustls-native-certs = "=0.8.3"
rustls-pki-types = "=1.14.0"
secrecy = "0.10.3"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
sha2 = "0.11.0"
subtle = "2.6.1"
thiserror = "2.0.18"
tokio = { version = "=1.53.1", default-features = false, features = ["fs", "macros", "rt", "sync", "time"] }
tokio = { version = "=1.52.3", default-features = false, features = ["fs", "macros", "rt", "sync", "time"] }
tokio-rustls = { version = "=0.26.4", default-features = false }
tokio-util = { version = "0.7.18", features = ["rt"] }
tower-service = "0.3.3"
@@ -56,6 +60,10 @@ uuid = { version = "1.23.3", features = ["v4", "v7"] }
# 并携带 Content-Encoding: zstd;保持同版本同级别以对齐上游指纹。
zstd = "=0.13.3"
[target.'cfg(target_os = "linux")'.dependencies]
openssl = "=0.10.75"
openssl-sys = { version = "=0.9.111", features = ["vendored"] }
[dev-dependencies]
tempfile = "3.27.0"
tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-thread", "test-util"] }
+22 -1
View File
@@ -24,7 +24,7 @@ use gateway_admin::model::provider_credentials::{
ProviderProfileAvatar, ProviderProfileAvatarStreamError, ProviderProfileDailyUsage,
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole,
ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription,
QuotaLocalUsageAttribution,
};
use gateway_admin::model::quota_forecast_sampling::QuotaForecastObservation;
@@ -513,6 +513,27 @@ impl ProviderAdmin for OpenAiAdminProvider {
})
}
async fn subscription(
&self,
account_id: &ProviderAccountId,
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
self.account(account_id).await?;
self.profile_statistics
.subscription(account_id)
.await
.map(|subscription| {
subscription.map(|subscription| ProviderSubscription {
starts_at: subscription.starts_at,
expires_at: subscription.expires_at,
will_renew: subscription.will_renew,
billing_period: subscription.billing_period,
billing_currency: subscription.billing_currency,
observed_at: subscription.observed_at,
})
})
.map_err(map_profile_statistics_error)
}
async fn profile_statistics(
&self,
account_id: &ProviderAccountId,
+31 -1
View File
@@ -8,7 +8,9 @@ use serde::Deserialize;
use url::{Host, Url};
use crate::credential::CodexQuotaRefreshPolicy;
use crate::transport::profile::{CodexResidency, CodexWireProfile, CodexWireProfileState};
use crate::transport::profile::{
CodexRequestLocation, CodexResidency, CodexWireProfile, CodexWireProfileState,
};
use crate::transport::session::{CodexSessionIdentity, CodexSessionIdentityError};
use crate::transport::websocket::CodexWebSocketPoolConfig;
use crate::{
@@ -290,6 +292,9 @@ pub struct CodexWireProfileConfig {
pub terminal: String,
#[serde(default)]
pub residency: Option<CodexResidency>,
/// 为空时保留客户端的地区、环境日期和时区。
#[serde(default)]
pub location: Option<CodexRequestLocation>,
pub verified_at: DateTime<Utc>,
}
@@ -305,6 +310,7 @@ impl Default for CodexWireProfileConfig {
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
residency: None,
location: None,
// 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。
verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084),
}
@@ -336,6 +342,29 @@ impl CodexWireProfileConfig {
return Err(OpenAiConfigError::InvalidField(field));
}
}
if let Some(location) = &self.location {
for (field, value) in [
(
"openai.wire_profile.location.region",
location.region.as_str(),
),
("openai.wire_profile.location.city", location.city.as_str()),
] {
if value.trim().is_empty() {
return Err(OpenAiConfigError::InvalidField(field));
}
}
if location.country.len() != 2
|| !location
.country
.bytes()
.all(|byte| byte.is_ascii_uppercase())
{
return Err(OpenAiConfigError::InvalidField(
"openai.wire_profile.location.country",
));
}
}
if semver::Version::parse(&self.codex_version).is_err() {
return Err(OpenAiConfigError::InvalidField(
"openai.wire_profile.codex_version",
@@ -367,6 +396,7 @@ impl From<CodexWireProfileConfig> for CodexWireProfile {
arch: value.arch,
terminal: value.terminal,
residency: value.residency,
location: value.location,
verified_at: value.verified_at,
}
}
@@ -13,6 +13,7 @@ use thiserror::Error;
use uuid::Uuid;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::subscription::CodexSubscription;
use crate::transport::{
CodexBackendClient, CodexClientError, CodexProfileAvatar, CodexProfileAvatarFetchError,
CodexProfileStatistics, CodexRequestContext, fetch_profile_avatar,
@@ -129,6 +130,53 @@ impl CodexCredentialProfileService {
}
}
/// 订阅只按需读取;不缓存、不刷新凭据,也不更新额度状态。
pub async fn subscription(
&self,
account_id: &ProviderAccountId,
) -> Result<Option<CodexSubscription>, CodexProfileStatisticsError> {
let (authorization, upstream_account_id, account) =
self.account_authentication(account_id).await?;
let Some(upstream_account_id) = upstream_account_id else {
return Ok(None);
};
let request_id = format!("subscription_{}", Uuid::now_v7().simple());
let subscription = CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
self.profile.clone(),
)
.for_account(&account)
.map_err(map_client_error)?
.fetch_subscription(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
Some(&upstream_account_id),
&request_id,
None,
),
&upstream_account_id,
)
.await;
// 请求期间重新授权、换绑或删除账号时,丢弃旧身份的结果。
let current = self
.repository
.store()
.get_account(account_id)
.await
.map_err(|error| CodexProfileStatisticsError::Store {
detail: error.to_string(),
})?;
if !current.is_some_and(|current| {
current.provider() == account.provider()
&& current.revision() == account.revision()
&& current.upstream_account_id() == Some(upstream_account_id.as_str())
}) {
return Ok(None);
}
Ok(subscription)
}
pub async fn profile_statistics(
&self,
account_id: &ProviderAccountId,
@@ -18,6 +18,10 @@ pub(super) struct QuotaRecovery {
// 绑定本次耗尽事实,避免真实推理成功后再次耗尽时复用上次的恢复进度。
exhausted_at_micros: i64,
pending: BTreeMap<String, Option<DateTime<Utc>>>,
// 已连续观测到"未触顶"的待解除窗口;第二次确认后解除。旧文档没有该
// 字段时反序列化为空集,从当前耗尽事实重新积累证据。
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
candidates: BTreeMap<String, ()>,
}
impl QuotaRecovery {
@@ -59,23 +63,44 @@ impl QuotaRecovery {
Self {
exhausted_at_micros: exhaustion_time(state),
pending,
candidates: BTreeMap::new(),
}
}
fn observe(&mut self, refreshed: &CodexAccountQuotaSnapshot) -> bool {
let had_baseline = !self.pending.is_empty();
let mut candidates = BTreeMap::new();
for window in refreshed.windows().iter().filter(|w| w.is_account_wide()) {
if let Some(previous_reset) = self.pending.get_mut(window.key()) {
if window_reset_recovered(window, *previous_reset) {
self.pending.remove(window.key());
} else if previous_reset.is_none() {
*previous_reset = window.reset_at();
let key = window.key();
if let Some(previous_reset) = self.pending.get(key).copied() {
if window_reset_recovered(window, previous_reset) {
self.pending.remove(key);
continue;
}
if previous_reset.is_none() {
// 首次观测到该窗口只锚定 reset 基线,不构成解除证据。
self.pending.insert(key.to_owned(), window.reset_at());
continue;
}
// 同一窗口内用量回落(reset 不滚动)时"reset 前进"永远不会
// 成立,滑动窗口的正常用量也长期高于低用量阈值;以上游连续
// 两次观测都报告未触顶为解除证据。单次观测无法排除耗尽后
// 立刻拉到的旧快照(reset 未滚动、用量尚未触顶)。
let rolled_forward = window
.reset_at()
.is_some_and(|current| Some(current) > previous_reset);
if !rolled_forward && !window.limit_reached() {
if self.candidates.contains_key(key) {
self.pending.remove(key);
} else {
candidates.insert(key.to_owned(), ());
}
}
} else if window.limit_reached() {
self.pending
.insert(window.key().to_owned(), window.reset_at());
self.pending.insert(key.to_owned(), window.reset_at());
}
}
self.candidates = candidates;
had_baseline && self.pending.is_empty()
}
}
@@ -85,15 +110,24 @@ fn exhaustion_time(state: QuotaState) -> i64 {
}
fn window_reset_recovered(window: &CodexQuotaWindow, previous: Option<DateTime<Utc>>) -> bool {
previous
// reset 必须前进:防止耗尽后立刻拉到的旧快照(reset 未滚动)误解锁。
if !previous
.zip(window.reset_at())
.is_some_and(|(previous, current)| current > previous)
&& window
.used_percent()
.is_some_and(|used| used < RESET_RECOVERY_MAX_USED_PERCENT)
{
return false;
}
// reset 前进说明已进入新窗口,新窗口用量应接近零;高用量说明 reset 基线
// 不可信(例如滑动窗口),保持锁定,等待"同窗口用量回落"的连续观测解除。
window
.used_percent()
.is_some_and(|used| used < RESET_RECOVERY_MAX_USED_PERCENT)
}
/// 统一手动刷新和 worker 的恢复规则;上游 allowed 不参与已耗尽窗口的解除判断。
/// 统一手动刷新和 worker 的恢复规则。已耗尽窗口的解除有两条证据路径:
/// reset 前进且用量低于低用量阈值(窗口已滚动),或同一窗口内连续两次观测
/// 都未触顶(滑动窗口的用量回落,reset 不滚动)。后者防止账号被钉在旧
/// reset 日期上;连续两次的要求排除耗尽后立刻拉到的旧快照。
pub(super) fn reconcile_refresh(
current: QuotaState,
refreshed: &mut CodexAccountQuotaSnapshot,
@@ -320,7 +320,6 @@ impl OpenAiTokenClient {
return Ok(self.clone());
};
let builder = Client::builder()
.use_rustls_tls()
.no_proxy()
.proxy(reqwest::Proxy::all(proxy.expose_url()).map_err(|_| TokenClientBuildError)?)
.redirect(Policy::none())
@@ -418,7 +417,6 @@ pub fn openai_token_client(
) -> Result<OpenAiTokenClient, TokenClientBuildError> {
ensure_rustls_provider();
let builder = Client::builder()
.use_rustls_tls()
.no_proxy()
.redirect(Policy::none())
.connect_timeout(TOKEN_CONNECT_TIMEOUT)
@@ -1,14 +1,13 @@
//! OpenAI 上游失败分类、恢复决策与稳定错误投影。
use super::*;
use crate::transport::diagnostics::is_capacity_error;
/// OpenAI 失败对 Smart 账号分数的结构化 reason 闭集。
///
/// 已归一的上游 code 优先;code 缺失时才读取结构化客户端错误的 code/type。
/// 新增错误、裸 HTTP 状态和内部错误 kind 默认都不会进入该闭集。
const OPENAI_ACCOUNT_SCORE_FAILURE_REASONS: &[&str] = &[
"server_is_overloaded",
"slow_down",
"rate_limit_exceeded",
"rate_limit_error",
"server_error",
@@ -33,6 +32,17 @@ fn openai_account_score_failure_reason(error: &ProviderError) -> Option<&str> {
/// 返回该 OpenAI 失败是否属于 Smart 账号计分闭集。
#[doc(hidden)]
pub fn openai_failure_affects_account_score(error: &ProviderError) -> bool {
let client_error = error.client_visible_upstream_error();
if is_capacity_error(
error
.upstream_code()
.map(OpaqueUpstreamValue::as_str)
.or_else(|| client_error.and_then(ClientVisibleUpstreamError::code)),
client_error.and_then(ClientVisibleUpstreamError::error_type),
client_error.map(ClientVisibleUpstreamError::message),
) {
return false;
}
openai_account_score_failure_reason(error).is_some_and(is_openai_account_score_failure_reason)
}
@@ -506,6 +516,30 @@ pub(super) fn apply_websocket_recovery_policy(
failure: &mut MappedProviderFailure,
context: WebSocketRecoveryContext<'_>,
) {
// 明确账号拒绝走已有换号路径,容量拒绝走请求内退避;两者都不消耗 WS 传输预算。
if failure.error.replay_is_safe()
&& (failure.account_failure.is_some()
|| matches!(
failure.error.pre_delivery_retry(),
Some(gateway_core::error::PreDeliveryRetry::SameAccountTransientRetry { .. })
))
{
tracing::warn!(
request_id = context.request_id,
attempt_index = context.attempt_index,
account_id = context.account_id,
websocket_failure_kind = failure.error.kind().as_str(),
websocket_failure_code = failure
.error
.upstream_code()
.map_or("", OpaqueUpstreamValue::as_str),
upstream_status_code = failure.error.upstream_status().unwrap_or_default(),
upstream_status_code_present = failure.error.upstream_status().is_some(),
transport_requirement = context.requirement.as_str(),
"OpenAI upstream WebSocket returned a business rejection; deferring to request retry"
);
return;
}
let session_budget_exhausted = context.session_affinity_key.is_some_and(|key| {
context
.session_transport_recovery
@@ -1134,6 +1168,7 @@ pub(super) fn map_upstream_failure(
replay_boundary: ReplayBoundary,
) -> MappedProviderFailure {
let category = failure.category();
let capacity_unavailable = category == CodexFailureCategory::CapacityUnavailable;
let cyber_policy_failure = failure
.status
.is_some_and(|status| status.is_client_error())
@@ -1193,6 +1228,17 @@ pub(super) fn map_upstream_failure(
if let Some(retry_after) = failure.retry_after_seconds.map(Duration::from_secs) {
error = error.with_retry_after(retry_after);
}
if capacity_unavailable && error.replay_is_safe() {
let max_delay = Duration::from_secs(8);
error = error.with_transient_retry(
NonZeroU32::new(3).unwrap_or(NonZeroU32::MIN),
failure
.retry_after_seconds
.map_or(Duration::from_millis(500), Duration::from_secs)
.min(max_delay),
max_delay,
);
}
if let Some(code) = failure.persistable_code() {
error = error.with_upstream_code(OpaqueUpstreamValue::new(code.to_owned()));
}
@@ -1260,6 +1306,7 @@ pub(super) const fn provider_error_kind(category: CodexFailureCategory) -> Provi
CodexFailureCategory::CloudflareChallenge
| CodexFailureCategory::CloudflarePathBlocked
| CodexFailureCategory::Unavailable => ProviderErrorKind::Unavailable,
CodexFailureCategory::CapacityUnavailable => ProviderErrorKind::UpstreamCapacityUnavailable,
CodexFailureCategory::InvalidRequest => ProviderErrorKind::InvalidRequest,
CodexFailureCategory::PermissionDenied => ProviderErrorKind::PermissionDenied,
CodexFailureCategory::Timeout => ProviderErrorKind::Timeout,
@@ -1311,6 +1358,7 @@ pub(super) fn account_failure(
| CodexFailureCategory::InvalidRequest
| CodexFailureCategory::PermissionDenied
| CodexFailureCategory::Timeout
| CodexFailureCategory::CapacityUnavailable
| CodexFailureCategory::Unavailable
| CodexFailureCategory::Transport => None,
}
@@ -72,7 +72,7 @@ use crate::transport::diagnostics::{
CodexFailureCategory, CodexUpstreamFailure, CodexUpstreamSendPhase,
};
use crate::transport::profile::{
APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexWireProfileState,
APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexRequestLocation, CodexWireProfileState,
};
use crate::transport::protocol::responses::{
CodexResponsesRequest, PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
@@ -141,6 +141,7 @@ pub struct CodexProvider {
quota: Arc<CodexCredentialQuotaService>,
account_feedback: Arc<AccountFeedbackStats>,
client: CodexBackendClient,
location: Option<CodexRequestLocation>,
responses_url: Url,
image_generations_url: Url,
image_edits_url: Url,
@@ -173,6 +174,7 @@ impl CodexProvider {
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let search_url = Url::parse(&endpoint_url(&base_url, CODEX_ALPHA_SEARCH_PATH))
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let location = profile.snapshot().location;
let client =
CodexBackendClient::new(http, base_url, profile).with_websocket_pool(websocket_pool);
Ok(Self {
@@ -181,6 +183,7 @@ impl CodexProvider {
quota,
account_feedback,
client,
location,
responses_url,
image_generations_url,
image_edits_url,
@@ -253,7 +256,8 @@ impl Provider for CodexProvider {
..Default::default()
};
};
let Ok(encoded) = encode_generate_request(request, "observability") else {
let Ok(encoded) = encode_generate_request(request, "observability", self.location.as_ref())
else {
return ProviderRequestObservation::default();
};
let semantics = encoded.semantics();
@@ -354,8 +358,9 @@ impl Provider for CodexProvider {
};
let previous_session = decode_openai_session_state(generate);
let continuation_requested = generate.native_continuation_requested();
let mut upstream_request = encode_generate_request(generate, upstream_model.as_str())
.map_err(map_request_error)?;
let mut upstream_request =
encode_generate_request(generate, upstream_model.as_str(), self.location.as_ref())
.map_err(map_request_error)?;
if let Some(conversation_id) = previous_session
.as_ref()
.and_then(|state| state.conversation_id.as_ref())
@@ -117,8 +117,9 @@ impl OpenAiResponseObservationState {
if let Some(metadata) = self.provider_metadata() {
observation = observation.with_provider_metadata(metadata);
}
if let Some(service_tier) = self.effective_service_tier() {
observation = observation.with_service_tier_if_valid(service_tier.to_owned());
// 统计与计费采用最终发送档位;响应回显只作为独立诊断事实保留。
if let Some(service_tier) = &self.requested_service_tier {
observation = observation.with_service_tier_if_valid(service_tier.clone());
}
Some(observation)
}
@@ -161,12 +162,6 @@ impl OpenAiResponseObservationState {
true
}
pub(super) fn effective_service_tier(&self) -> Option<&str> {
self.upstream_service_tier
.as_deref()
.or(self.requested_service_tier.as_deref())
}
pub(super) fn merge_rate_limit_headers(&mut self, updates: &[(String, String)]) -> bool {
let mut changed = false;
for (name, value) in selected_observation_headers(updates) {
@@ -158,7 +158,7 @@ impl CodexCanonicalDecoder {
self
}
/// 响应未报告实际档位时,才使用请求档位估算费用。
/// 使用最终发送给上游的请求档位估算费用,不由响应回显覆盖。
#[must_use]
pub fn with_requested_service_tier(mut self, service_tier: Option<&str>) -> Self {
self.requested_service_tier = normalize_service_tier(service_tier);
@@ -217,7 +217,7 @@ impl CodexCanonicalDecoder {
std::mem::take(&mut self.timing_signals)
}
/// 返回本 attempt 已从上游响应生命周期帧观察到的实际服务档位。
/// 返回本 attempt 的上游响应回显档位,仅供诊断,不作为本地计费依据。
#[must_use]
pub fn response_service_tier(&self) -> Option<&str> {
self.response_service_tier.as_deref()
@@ -861,10 +861,8 @@ impl CodexCanonicalDecoder {
.filter(|model| !model.is_empty())
.unwrap_or(&self.fallback_model)
.to_owned();
let service_tier = self
.response_service_tier
.as_deref()
.or(self.requested_service_tier.as_deref());
// 与用量统计统一按最终发送档位估算,响应回显不改变本地计价口径。
let service_tier = self.requested_service_tier.as_deref();
let tool_calls = billable_tool_calls(response);
if let Some(breakdown) = usage
.filter(|usage| billable_usage_is_complete(response, *usage))
@@ -70,7 +70,6 @@ pub fn build_account_http_client(
}
let mut builder = Client::builder()
.use_rustls_tls()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.pool_max_idle_per_host(4)
@@ -102,6 +102,8 @@ pub enum CodexFailureCategory {
InvalidRequest,
PermissionDenied,
Timeout,
/// 模型容量暂时不足,只影响当前请求,不证明账号健康异常。
CapacityUnavailable,
Unavailable,
Transport,
}
@@ -243,6 +245,7 @@ impl CodexUpstreamFailure {
| CodexFailureCategory::QuotaExhausted
| CodexFailureCategory::CloudflareChallenge
| CodexFailureCategory::CloudflarePathBlocked
| CodexFailureCategory::CapacityUnavailable
),
}
}
@@ -428,6 +431,15 @@ fn classify_upstream_failure(
let message = fields.message.to_ascii_lowercase();
let body = body.to_ascii_lowercase();
// 容量拒绝可能带 400/429/503;仅用结构化错误字段识别,不能扫描任意正文。
if is_capacity_error(
fields.code.as_deref(),
fields.error_type.as_deref(),
fields.client_message.as_deref(),
) {
return CodexFailureCategory::CapacityUnavailable;
}
// 与官方 Codex HTTP/WS 路径一致:429 只有结构化
// `error.type=usage_limit_reached` 才能确认额度窗口耗尽;其余 429 都是临时限流。
// SSE `response.failed` 的结构化字段形态不同,保留其 code/type 语义单独分类。
@@ -523,9 +535,6 @@ fn classify_upstream_failure(
if status == Some(StatusCode::TOO_MANY_REQUESTS) {
return CodexFailureCategory::RateLimited;
}
if is_upstream_overload(&code) || is_upstream_overload(&message) {
return CodexFailureCategory::Unavailable;
}
match status.map(|status| status.as_u16()) {
Some(status) => match status {
400 | 404 | 409 | 422 => CodexFailureCategory::InvalidRequest,
@@ -662,6 +671,40 @@ fn is_cloudflare_challenge(value: &str) -> bool {
|| value.contains("just a moment")
}
fn is_upstream_overload(value: &str) -> bool {
matches!(value, "server_is_overloaded" | "slow_down") || value.contains("server_overloaded")
pub(crate) fn is_capacity_error(
code: Option<&str>,
error_type: Option<&str>,
message: Option<&str>,
) -> bool {
let code = normalized(code);
let error_type = normalized(error_type);
// 明确额度窗口/账号错误保留原分类,不能被描述中的容量词覆盖。
if !matches!(
error_type.as_str(),
"" | "server_error"
| "service_unavailable_error"
| "invalid_request_error"
| "rate_limit_error"
| "server_is_overloaded"
| "slow_down"
) {
return false;
}
if matches!(code.as_str(), "server_is_overloaded" | "slow_down") {
return true;
}
if !matches!(
code.as_str(),
"" | "server_error" | "service_unavailable_error"
) {
return false;
}
if matches!(error_type.as_str(), "server_is_overloaded" | "slow_down") {
return true;
}
let message = normalized(message);
matches!(message.as_str(), "server_is_overloaded" | "slow_down")
|| message.contains("selected model is at capacity")
|| message.contains("server is overloaded")
|| message.contains("server_overloaded")
}
@@ -254,5 +254,32 @@ fn header_pairs(headers: &HeaderMap) -> Vec<(String, String)> {
}
pub(super) fn websocket_header_pairs(headers: &HeaderMap) -> Vec<(String, String)> {
header_pairs(headers)
let mut pairs = header_pairs(headers);
// WebSocket opening 的 HeaderMap 先由业务头构造,再被 tungstenite 插入协议头;
// 这里复现官方 HeaderMap 交给 tungstenite 时的迭代顺序,最终序列化后的
// 线级顺序由 fingerprint 测试锁定。未知扩展头仍保持相对顺序。
pairs.sort_by_key(|(name, _)| websocket_header_order(name));
pairs
}
fn websocket_header_order(name: &str) -> usize {
const OFFICIAL_INSERTION_ORDER: &[&str] = &[
"version",
"x-codex-beta-features",
"x-client-request-id",
"session-id",
"thread-id",
"x-codex-window-id",
"x-codex-turn-metadata",
"x-codex-routing-hint",
"openai-beta",
"originator",
"user-agent",
"authorization",
"chatgpt-account-id",
];
OFFICIAL_INSERTION_ORDER
.iter()
.position(|candidate| name.eq_ignore_ascii_case(candidate))
.unwrap_or(OFFICIAL_INSERTION_ORDER.len())
}
@@ -16,6 +16,7 @@ pub mod request;
pub mod reset_credits;
mod response_meta;
pub(crate) mod session;
pub mod subscription;
mod time;
pub mod tls;
pub mod usage;
@@ -39,6 +39,27 @@ pub enum CodexResidency {
Us,
}
/// 环境上下文与 Web Search 共用的请求地区画像。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CodexRequestLocation {
pub country: String,
pub region: String,
pub city: String,
pub timezone: chrono_tz::Tz,
}
impl Default for CodexRequestLocation {
fn default() -> Self {
Self {
country: "US".to_owned(),
region: "Ohio".to_owned(),
city: "Piketon".to_owned(),
timezone: chrono_tz::America::New_York,
}
}
}
/// Codex Desktop 上游请求身份。
///
/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。运行时只会使用
@@ -63,6 +84,8 @@ pub struct CodexWireProfile {
pub terminal: String,
/// 未配置时不发送 residency 头;不随制品版本更新而改变。
pub residency: Option<CodexResidency>,
/// 可选的地区覆盖;为空时透传,不随制品版本更新而改变。
pub location: Option<CodexRequestLocation>,
/// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验。
pub verified_at: DateTime<Utc>,
}
@@ -3,15 +3,18 @@
use std::io;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use chrono::{DateTime, Utc};
use gateway_core::operation::GenerateRequest;
use gateway_protocol::openai::{
WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY, is_transport_managed_request_header,
};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use roxmltree::Document;
use serde::Serialize as _;
use serde_json::{Map, Value};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
use crate::transport::profile::CodexRequestLocation;
use crate::transport::protocol::responses::{
CodexResponsesRequest, X_CODEX_TURN_STATE_CLIENT_METADATA_KEY,
};
@@ -20,6 +23,7 @@ const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
const TURN_ID_CLIENT_METADATA_KEY: &str = "turn_id";
const THREAD_SPAWN_SUBAGENT_KIND: &str = "thread_spawn";
const THREAD_SPAWN_CONVERSATION_PREFIX: &str = "thread-spawn:";
const ENVIRONMENT_CONTEXT_CONTENT_KIND: &str = "environments.environment_context";
const UNSUPPORTED_CODEX_RESPONSES_FIELDS: &[&str] = &["max_output_tokens", "temperature"];
const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[
@@ -93,13 +97,14 @@ pub enum CodexRequestEncodeError {
pub fn encode_generate_request(
request: &GenerateRequest,
upstream_model: &str,
location: Option<&CodexRequestLocation>,
) -> Result<CodexResponsesRequest, CodexRequestEncodeError> {
let payload = request.protocol_payload();
if payload.protocol() != "openai" {
return Err(CodexRequestEncodeError::InvalidProtocolPayload);
}
let mut body = payload.body().clone();
adapt_codex_responses_body(&mut body, upstream_model);
adapt_codex_responses_body(&mut body, upstream_model, location);
let mut encoded = CodexResponsesRequest::from_body(body);
encoded.explicit_prompt_cache_key = encoded.prompt_cache_key().is_some();
@@ -108,11 +113,141 @@ pub fn encode_generate_request(
Ok(encoded)
}
fn adapt_codex_responses_body(body: &mut Map<String, Value>, upstream_model: &str) {
fn adapt_codex_responses_body(
body: &mut Map<String, Value>,
upstream_model: &str,
location: Option<&CodexRequestLocation>,
) {
body.insert("model".to_owned(), Value::String(upstream_model.to_owned()));
for field in UNSUPPORTED_CODEX_RESPONSES_FIELDS {
body.remove(*field);
}
if let Some(location) = location {
align_structured_location_fields(body, Utc::now(), location);
}
}
fn align_structured_location_fields(
body: &mut Map<String, Value>,
now: DateTime<Utc>,
location: &CodexRequestLocation,
) {
// 只改写带环境标记的日期和时区;epoch 时间戳保持绝对时间原值。
let current_date = now
.with_timezone(&location.timezone)
.format("%Y-%m-%d")
.to_string();
if let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) {
for item in input {
align_environment_context(item, &current_date, location.timezone.name());
}
}
if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) {
for tool in tools {
align_web_search_location(tool, location);
}
}
}
fn align_environment_context(item: &mut Value, current_date: &str, timezone: &str) {
let Some(item) = item.as_object_mut() else {
return;
};
if item.get("role").and_then(Value::as_str) != Some("user") {
return;
}
let content_kinds = item
.get("internal_chat_message_metadata_passthrough")
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("content_item_kinds"))
.and_then(Value::as_array)
.map(|kinds| {
kinds
.iter()
.map(|kind| kind.as_str().map(str::to_owned))
.collect::<Vec<_>>()
});
let Some(content) = item.get_mut("content").and_then(Value::as_array_mut) else {
return;
};
for (index, part) in content.iter_mut().enumerate() {
if content_kinds
.as_ref()
.and_then(|kinds| kinds.get(index))
.and_then(Option::as_deref)
!= Some(ENVIRONMENT_CONTEXT_CONTENT_KIND)
{
continue;
}
let Some(part) = part.as_object_mut() else {
continue;
};
if part.get("type").and_then(Value::as_str) != Some("input_text") {
continue;
}
let Some(Value::String(text)) = part.get_mut("text") else {
continue;
};
if let Some(aligned) = aligned_environment_context(text, current_date, timezone) {
*text = aligned;
}
}
}
fn aligned_environment_context(text: &str, current_date: &str, timezone: &str) -> Option<String> {
let trimmed = text.trim();
if !trimmed.starts_with("<environment_context>") || !trimmed.ends_with("</environment_context>")
{
return None;
}
let document = Document::parse(text).ok()?;
let root = document.root_element();
if !root.has_tag_name("environment_context") {
return None;
}
let mut replacements = root
.children()
.filter(|node| node.is_element())
.filter_map(|node| {
let replacement = match node.tag_name().name() {
"current_date" => format!("<current_date>{current_date}</current_date>"),
"timezone" => format!("<timezone>{timezone}</timezone>"),
_ => return None,
};
Some((node.range(), replacement))
})
.collect::<Vec<_>>();
if replacements.is_empty() {
return None;
}
replacements.sort_unstable_by_key(|(range, _)| std::cmp::Reverse(range.start));
let mut aligned = text.to_owned();
for (range, replacement) in replacements {
aligned.replace_range(range, &replacement);
}
Some(aligned)
}
fn align_web_search_location(tool: &mut Value, location: &CodexRequestLocation) {
let Some(tool) = tool.as_object_mut() else {
return;
};
let Some(tool_type) = tool.get("type").and_then(Value::as_str) else {
return;
};
if tool_type != "web_search" && !tool_type.starts_with("web_search_") {
return;
}
tool.insert(
"user_location".to_owned(),
json!({
"type": "approximate",
"country": location.country,
"region": location.region,
"city": location.city,
"timezone": location.timezone.name(),
}),
);
}
fn extract_request_context(request: &mut CodexResponsesRequest) {
@@ -0,0 +1,97 @@
//! 当前绑定账号的订阅周期安全投影,与令牌有效期和额度状态无关。
use std::time::Duration;
use chrono::{DateTime, Utc};
use super::{CodexBackendClient, CodexRequestContext, client::read_capped_response_body};
const MAX_SUBSCRIPTION_BODY_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexSubscription {
pub starts_at: Option<DateTime<Utc>>,
pub expires_at: DateTime<Utc>,
pub will_renew: Option<bool>,
pub billing_period: Option<String>,
pub billing_currency: Option<String>,
pub observed_at: DateTime<Utc>,
}
impl CodexBackendClient {
/// 可选展示查询失败只返回未知,不改变凭据或额度状态,也不重试。
pub async fn fetch_subscription(
&self,
context: CodexRequestContext<'_>,
account_id: &str,
) -> Option<CodexSubscription> {
if account_id.is_empty() || account_id.len() > 512 {
return None;
}
tokio::time::timeout(Duration::from_secs(5), async {
let mut url = reqwest::Url::parse(&self.base_url).ok()?;
let base_path = url.path().trim_end_matches('/');
let subscription_path = if base_path.ends_with("/backend-api") {
format!("{base_path}/subscriptions")
} else {
"/backend-api/subscriptions".to_owned()
};
url.set_path(&subscription_path);
url.set_query(None);
url.set_fragment(None);
url.query_pairs_mut().append_pair("account_id", account_id);
let origin = url.origin().ascii_serialization();
let headers = self.account_request_headers(context).ok()?;
let response = self
.client
.get(url)
.headers(headers)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::ORIGIN, &origin)
.header(reqwest::header::REFERER, format!("{origin}/"))
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let body = read_capped_response_body(response, MAX_SUBSCRIPTION_BODY_BYTES)
.await
.ok()?;
if body.limit_exceeded() {
return None;
}
let value: serde_json::Value = serde_json::from_str(&body.into_string()).ok()?;
let expires_at =
DateTime::parse_from_rfc3339(value.get("active_until")?.as_str()?.trim())
.ok()?
.with_timezone(&Utc);
Some(CodexSubscription {
starts_at: value
.get("active_start")
.and_then(serde_json::Value::as_str)
.and_then(|value| DateTime::parse_from_rfc3339(value.trim()).ok())
.map(|value| value.with_timezone(&Utc)),
expires_at,
will_renew: value.get("will_renew").and_then(serde_json::Value::as_bool),
billing_period: optional_short_text(&value, "billing_period"),
billing_currency: optional_short_text(&value, "billing_currency"),
observed_at: Utc::now(),
})
})
.await
.ok()
.flatten()
}
}
fn optional_short_text(value: &serde_json::Value, key: &str) -> Option<String> {
value
.get(key)?
.as_str()
.map(str::trim)
.filter(|value| {
!value.is_empty() && value.len() <= 64 && !value.chars().any(char::is_control)
})
.map(str::to_owned)
}
@@ -222,6 +222,8 @@ struct PricingRule {
// 价格来源:https://developers.openai.com/api/docs/pricing,核验日期 2026-09-09。
// 使用常规价,不采用 Sol 的临时 $4/$20 优惠;缓存、Flex、Fast 和长上下文
// 档位也统一按常规价计算。
// 已按 https://developers.openai.com/api/docs/deprecations 核验至 2026-09-13,
// 移除已关闭的型号;仅宣布弃用但尚未到关闭日期的型号继续保留。
const PRICING_RULES: &[PricingRule] = &[
// Astra:https://developers.openai.com/api/docs/models/gpt-6-astra
// 已于 2026-09-09 对照官方价目表核验。
@@ -428,14 +430,6 @@ const PRICING_RULES: &[PricingRule] = &[
},
// 各变体独立采用已公开的价格与档位,不能继承父型号的所有档位。
// Cyber 长上下文的官方来源存在差异,该区间暂不估价。
PricingRule {
model: "gpt-5.1-codex-mini",
pricing: ModelPricing::new(2_500, 20_000, 250),
},
PricingRule {
model: "gpt-5.3-chat-latest",
pricing: ModelPricing::new(17_500, 140_000, 1_750),
},
PricingRule {
model: "gpt-5.6-cyber",
pricing: ModelPricing::new(125_000, 750_000, 12_500)
@@ -450,34 +444,6 @@ const PRICING_RULES: &[PricingRule] = &[
model: "chat-latest",
pricing: ModelPricing::new(50_000, 300_000, 5_000),
},
PricingRule {
model: "gpt-5-codex",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5.1-codex",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5.1-codex-max",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5-chat-latest",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5.1-chat-latest",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5.2-codex",
pricing: ModelPricing::new(17_500, 140_000, 1_750),
},
PricingRule {
model: "gpt-5.2-chat-latest",
pricing: ModelPricing::new(17_500, 140_000, 1_750),
},
];
#[derive(Clone, Copy)]
@@ -784,7 +750,6 @@ fn normalize_model_name(model: &str) -> String {
fn pricing_model_name(model: &str) -> &str {
match model {
"gpt-3.5-turbo-0125" => "gpt-3.5-turbo",
"gpt-4-0314" => "gpt-4",
"gpt-4-0613" => "gpt-4",
"gpt-4-turbo-2024-04-09" => "gpt-4-turbo",
"gpt-4.1-2025-04-14" => "gpt-4.1",
@@ -660,7 +660,7 @@ async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export
let Operation::Generate(request) = operation else {
panic!("connection test must be a generate operation");
};
let encoded = provider_openai::encode_generate_request(&request, "gpt-5.4")
let encoded = provider_openai::encode_generate_request(&request, "gpt-5.4", None)
.expect("official OpenAI request");
assert_eq!(
encoded.body().get("model").and_then(Value::as_str),
@@ -1347,6 +1347,7 @@ fn valid_config() -> TestOpenAiConfig {
arch: "arm64".to_owned(),
terminal: "xterm-256color".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 19, 0, 0, 0)
.single()
@@ -3,7 +3,74 @@ use std::path::Path;
use chrono::{TimeZone as _, Utc};
use provider_openai::config::{
CodexWireProfileConfig, DEFAULT_STREAM_MAX_RETRIES, MAX_STREAM_MAX_RETRIES, OpenAiConfig,
OpenAiConfigError,
};
use provider_openai::transport::profile::CodexRequestLocation;
use serde_json::json;
#[test]
fn openai_location_should_default_to_passthrough() {
let profile = OpenAiConfig::default().wire_profile_state().snapshot();
assert_eq!(profile.location, None);
}
#[test]
fn openai_config_should_preserve_a_custom_location_in_the_runtime_profile() {
let location: CodexRequestLocation = serde_json::from_value(json!({
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
}))
.expect("location configuration");
let mut config = valid_config();
config.wire_profile.location = Some(location.clone());
config
.resolve_and_validate(Path::new("/srv/gateway"))
.expect("valid location");
assert_eq!(
config.wire_profile_state().snapshot().location,
Some(location)
);
}
#[test]
fn openai_config_should_reject_invalid_location_fields() {
for (field, value) in [
("country", "USA"),
("country", "us"),
("country", ""),
("region", " "),
("city", ""),
] {
let mut config = valid_config();
let location = config
.wire_profile
.location
.insert(CodexRequestLocation::default());
let (target, expected) = match field {
"country" => (
&mut location.country,
"openai.wire_profile.location.country",
),
"region" => (&mut location.region, "openai.wire_profile.location.region"),
_ => (&mut location.city, "openai.wire_profile.location.city"),
};
*target = value.to_owned();
assert_eq!(
config.resolve_and_validate(Path::new("/srv/gateway")),
Err(OpenAiConfigError::InvalidField(expected))
);
}
}
#[test]
fn location_config_should_reject_unknown_timezones_and_incomplete_groups() {
for value in [
json!({ "country": "US", "region": "Ohio", "city": "Piketon", "timezone": "Not/A_Timezone" }),
json!({ "timezone": "Pacific/Auckland" }),
json!({ "country": "US", "region": "Ohio", "city": "Piketon", "timezone": "America/New_York", "extra": true }),
] {
assert!(serde_json::from_value::<CodexRequestLocation>(value).is_err());
}
}
#[test]
fn openai_config_builds_the_audited_wire_profile() {
@@ -154,6 +221,7 @@ fn valid_config() -> OpenAiConfig {
arch: "arm64".to_owned(),
terminal: "xterm-256color".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 19, 0, 0, 0)
.single()
@@ -65,6 +65,7 @@ fn wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "catalog-contract".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -161,6 +161,7 @@ fn selector_with_runtime(
arch: "x86_64".to_owned(),
terminal: "selector-contract".to_owned(),
residency: None,
location: Default::default(),
verified_at: chrono::Utc::now(),
});
let http = reqwest::Client::builder().build().expect("HTTP client");
@@ -6,7 +6,7 @@ use gateway_core::account::ProviderAccountId;
use provider_openai::credential::{CodexCredentialProfileService, ImportCodexOAuthCredential};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use serde_json::json;
use wiremock::matchers::{header, method, path};
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{MemoryAccountStore, profile, secret};
@@ -22,6 +22,7 @@ fn wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "profile-statistics-contract".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 8, 28, 0, 0, 0)
.single()
@@ -59,6 +60,11 @@ async fn service(
#[tokio::test]
async fn profile_statistics_uses_one_profile_request_and_preserves_official_fields() {
let server = MockServer::start().await;
Mock::given(path("/backend-api/subscriptions"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/profiles/me"))
.and(header(
@@ -227,3 +233,66 @@ async fn profile_avatar_reuses_cached_source_with_the_accounts_rotated_token() {
"chatgpt-acct_profile_avatar"
);
}
#[tokio::test]
async fn subscription_is_read_only_and_makes_one_bound_account_request_per_query() {
let server = MockServer::start().await;
let (store, service) = service("acct_subscription", &server).await;
let account = store.account("acct_subscription").expect("account");
Mock::given(method("GET"))
.and(path("/backend-api/subscriptions"))
.and(query_param("account_id", "chatgpt-acct_subscription"))
.and(header("chatgpt-account-id", "chatgpt-acct_subscription"))
.and(header("authorization", "Bearer token-acct_subscription"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"active_until": "2026-10-01T00:00:00Z", "will_renew": true
})))
.expect(2)
.mount(&server)
.await;
for _ in 0..2 {
let subscription = service.subscription(account.id()).await.unwrap().unwrap();
assert_eq!(subscription.will_renew, Some(true));
}
let after = store.account("acct_subscription").expect("account");
assert_eq!(after.revision(), account.revision());
assert_eq!(after.quota(), account.quota());
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn subscription_discards_response_when_credential_rotates_during_query() {
let server = MockServer::start().await;
let (store, service) = service("acct_subscription_rotation", &server).await;
let account = store
.account("acct_subscription_rotation")
.expect("account");
let requested = Arc::new(tokio::sync::Notify::new());
let notify = Arc::clone(&requested);
Mock::given(path("/backend-api/subscriptions"))
.respond_with(move |_: &wiremock::Request| {
notify.notify_one();
ResponseTemplate::new(200)
.set_body_json(json!({"active_until": "2026-10-01T00:00:00Z"}))
.set_delay(std::time::Duration::from_millis(100))
})
.expect(1)
.mount(&server)
.await;
let (result, ()) = tokio::join!(service.subscription(account.id()), async {
tokio::time::timeout(std::time::Duration::from_secs(3), requested.notified())
.await
.unwrap();
store
.repository()
.rotate_refreshed_oauth_secret(
&account,
secret("rotated-subscription-token"),
None,
None,
)
.await
.unwrap();
});
assert!(result.unwrap().is_none());
}
@@ -5,6 +5,7 @@ mod refresh_timing;
mod scheduling;
mod slots;
mod snapshot;
mod subscription;
use std::sync::Arc;
use std::time::SystemTime;
@@ -37,6 +38,7 @@ fn wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "quota-contract".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -561,6 +561,102 @@ async fn an_unknown_slot_establishes_a_baseline_before_it_can_recover() {
}
}
#[tokio::test]
async fn weekly_usage_falling_back_within_the_same_window_recovers_after_two_observations() {
// 滑动周窗口的用量可以在同一窗口内回落:reset 不滚动、用量从 100% 降到
// 正常水平。此时"reset 前进 + 低用量"永远不会成立,解除依赖连续两次
// 观测都未触顶;单次观测无法排除耗尽后立刻拉到的旧快照,保持锁定。
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_weekly_fallout").await;
let account = store.account("acct_weekly_fallout").expect("account");
let server = MockServer::start().await;
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
let mut old = usage((100, SHORT_RESET), (100, WEEK_RESET));
old["rate_limit"]["allowed"] = json!(false);
old["rate_limit"]["limit_reached"] = json!(true);
mount_usage(&server, old).await;
service
.refresh_account(account.id())
.await
.expect("seed exhausted quota");
// 5h 窗口滚动前进,周窗口 reset 不变但用量回落到正常水平:只标记候选。
let mut first_observation = usage((0, SHORT_RESET + 18_000), (18, WEEK_RESET));
first_observation["rate_limit"]["allowed"] = json!(true);
mount_usage(&server, first_observation).await;
let first = service
.refresh_account(account.id())
.await
.expect("first fallout observation");
assert!(first.quota().is_exhausted());
// 第二次连续观测仍未触顶:解除周窗口。
let mut second_observation = usage((9, SHORT_RESET + 36_000), (18, WEEK_RESET));
second_observation["rate_limit"]["allowed"] = json!(true);
mount_usage(&server, second_observation).await;
let second = service
.refresh_account(account.id())
.await
.expect("second fallout observation");
assert_eq!(second.quota().access(), QuotaAccessState::Allowed);
}
#[tokio::test]
async fn a_single_same_window_fallout_observation_cannot_break_the_reset_baseline() {
// 连续性中断(观测到重新触顶)后必须重新积累两次未触顶证据。
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_weekly_fallout_gap").await;
let account = store.account("acct_weekly_fallout_gap").expect("account");
let server = MockServer::start().await;
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
let mut old = usage((100, SHORT_RESET), (100, WEEK_RESET));
old["rate_limit"]["allowed"] = json!(false);
mount_usage(&server, old).await;
service
.refresh_account(account.id())
.await
.expect("seed exhausted quota");
mount_usage(&server, usage((0, SHORT_RESET + 18_000), (18, WEEK_RESET))).await;
assert!(
service
.refresh_account(account.id())
.await
.expect("first candidate")
.quota()
.is_exhausted()
);
// 观测到重新触顶,候选证据清零。
mount_usage(&server, usage((0, SHORT_RESET + 36_000), (100, WEEK_RESET))).await;
assert!(
service
.refresh_account(account.id())
.await
.expect("reached again")
.quota()
.is_exhausted()
);
mount_usage(&server, usage((0, SHORT_RESET + 54_000), (18, WEEK_RESET))).await;
assert!(
service
.refresh_account(account.id())
.await
.expect("candidate restarts")
.quota()
.is_exhausted()
);
mount_usage(&server, usage((0, SHORT_RESET + 72_000), (18, WEEK_RESET))).await;
assert_eq!(
service
.refresh_account(account.id())
.await
.expect("second consecutive observation")
.quota()
.access(),
QuotaAccessState::Allowed
);
}
#[tokio::test]
async fn monthly_and_arbitrary_periods_use_the_same_recovery_rule() {
for seconds in [18_000, 86_400, 604_800, 864_000, 2_592_000, 7_776_000] {
@@ -0,0 +1,37 @@
//! 订阅展示不得随手工或后台额度检查预取。
use super::*;
#[tokio::test]
async fn quota_refresh_and_background_synchronize_do_not_query_subscription() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_quota_without_subscription").await;
let account = store.account("acct_quota_without_subscription").unwrap();
let server = MockServer::start().await;
Mock::given(path("/backend-api/subscriptions"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
Mock::given(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"allowed": true,
"primary_window": { "used_percent": 20, "limit_window_seconds": 18000 }
}
})))
.expect(2..)
.mount(&server)
.await;
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
service.synchronize().await.unwrap();
service.refresh_account(account.id()).await.unwrap();
assert!(
server
.received_requests()
.await
.unwrap()
.iter()
.all(|request| request.url.path() == "/api/codex/usage")
);
}
@@ -138,6 +138,9 @@ fn wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "provider-contract".to_owned(),
residency: None,
location: serde_json::from_value(json!({
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
})).expect("configured request location"),
verified_at: Utc::now(),
})
}
@@ -465,6 +468,9 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
"acct_websocket_busy_replay",
"acct_websocket_metadata_close",
"acct_websocket_turn_state",
"acct_ws_quota_a",
"acct_ws_quota_b",
"acct_ws_midstream_overload",
]
.into_iter()
.map(|id| {
@@ -662,6 +668,47 @@ async fn capture_scoped_http_request(
requests.pop().expect("single scoped request")
}
#[tokio::test]
async fn provider_should_send_the_configured_location_to_the_upstream() {
let body = json!({
"model": "gpt-5.4",
"input": [{
"role": "user",
"content": [{"type": "input_text", "text": "<environment_context><timezone>UTC</timezone></environment_context>"}],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": ["environments.environment_context"],
"create_time": 1789293131.822
}
}],
"tools": [{"type": "web_search"}]
}).as_object().expect("request object").clone();
let captured = capture_scoped_http_request(
"req_configured_location",
"acct_provider_contract",
"acct_provider_contract",
body,
Map::new(),
)
.await;
let body = captured_request_body(&captured);
assert_eq!(
body.pointer("/tools/0/user_location"),
Some(&json!({
"type": "approximate", "country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
}))
);
assert_eq!(
body.pointer("/input/0/content/0/text"),
Some(&json!(
"<environment_context><timezone>Pacific/Auckland</timezone></environment_context>"
))
);
assert_eq!(
body.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"),
Some(&json!(1789293131.822))
);
}
async fn capture_turn_state_request(
request_id: &str,
previous_turn_id: Option<&str>,
@@ -2327,6 +2374,114 @@ async fn abrupt_websocket_disconnect_preserves_diagnosis_and_ambiguous_send_stat
);
}
#[tokio::test]
async fn websocket_midstream_error_frame_surfaces_upstream_message_after_delivery() {
const ACCOUNT_ID: &str = "acct_ws_midstream_overload";
const RESPONSE_ID: &str = "resp_midstream_overload";
const OVERLOAD_MESSAGE: &str = "Our servers are currently overloaded. Please try again later.";
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, ACCOUNT_ID).await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept WebSocket");
let mut websocket = accept_codex_test_websocket(stream).await;
let _request = websocket
.next()
.await
.expect("WebSocket request")
.expect("valid WebSocket request");
// 真实生产观察:OpenAI 在流已开始后发送带原话的 `error` 帧再断连。
websocket
.send(Message::Text(
json!({
"type": "response.created",
"response": {"id": RESPONSE_ID, "model": "gpt-5.4", "status": "in_progress"}
})
.to_string()
.into(),
))
.await
.expect("send response.created");
websocket
.send(Message::Text(
json!({
"type": "response.in_progress",
"response": {"id": RESPONSE_ID, "model": "gpt-5.4", "status": "in_progress"}
})
.to_string()
.into(),
))
.await
.expect("send response.in_progress");
tokio::time::sleep(Duration::from_millis(1_600)).await;
websocket
.send(Message::Text(
json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": OVERLOAD_MESSAGE,
"param": null
},
"sequence_number": 2
})
.to_string()
.into(),
))
.await
.expect("send error frame");
// 不发送任何终止事件,直接断开,模拟上游发完错误帧后的真实行为。
});
let provider = provider_with_base_url(&store, base_url);
let mut stream = provider
.execute(
planned_request("openai", generate_operation()),
context("req_ws_midstream_overload", CancellationToken::new()),
)
.await
.expect("prepare midstream-failure stream");
let mut failure = loop {
match stream.next().await {
Some(Ok(_)) => {}
Some(Err(error)) => break error,
None => panic!("midstream error frame must surface a typed failure"),
}
};
server.abort();
assert_eq!(
failure.kind(),
ProviderErrorKind::UpstreamCapacityUnavailable
);
assert_eq!(failure.send_state(), UpstreamSendState::Sent);
// 交给 Core 的失败必须保留上游原话:客户端只能靠它知道失败原因。
let visible = failure
.client_visible_upstream_error()
.expect("overload frame must carry a client-visible upstream error");
assert_eq!(visible.code(), Some("server_is_overloaded"));
assert_eq!(visible.message(), OVERLOAD_MESSAGE);
// 原始错误帧必须随失败一起交给交付边界(SSE 侧据此翻译成 response.failed)。
let atomic = failure.take_atomic_client_events();
let error_frame = atomic
.iter()
.find_map(|event| {
let wire = event.wire_event()?;
(wire.event_type() == Some("error")).then(|| wire.data().clone())
})
.expect("atomic batch must carry the upstream error frame");
assert_eq!(
error_frame
.get("error")
.and_then(|error| error.get("message"))
.and_then(Value::as_str),
Some(OVERLOAD_MESSAGE)
);
}
#[tokio::test]
async fn websocket_idle_timeout_diagnosis_survives_ambiguous_send_wrapping() {
const ACCOUNT_ID: &str = "acct_abrupt_disconnect";
@@ -3124,6 +3279,60 @@ async fn downstream_websocket_new_chain_should_override_session_and_attempt_http
server.await.unwrap();
}
#[tokio::test]
async fn websocket_opening_account_rejection_keeps_replay_safe_without_transport_retry() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_ws_quota_a").await;
create_account(&store, "acct_ws_quota_b").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut opening, _) = listener.accept().await.unwrap();
let request = capture_http_request(&mut opening).await;
assert!(String::from_utf8_lossy(&request).starts_with("GET /codex/responses"));
// 额度耗尽拒绝通常携带小时级的 retry-after;同账号重试注定再次命中。
let body = r#"{"error":{"message":"You have reached your usage limit.","type":"rate_limit_error"}}"#;
opening
.write_all(
format!(
"HTTP/1.1 429 Too Many Requests\r\nretry-after: 129600\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
)
.await
.unwrap();
});
let provider = provider_with_base_url(&store, base_url);
let mut stream = provider
.execute(
planned_request("openai", generate_operation()),
context("req_ws_quota_rotation", CancellationToken::new()),
)
.await
.expect("prepare quota-rejected stream");
let error = loop {
match stream.next().await {
Some(Ok(_)) => {}
Some(Err(error)) => break error,
None => panic!("quota rejection must surface as a terminal error"),
}
};
server.await.expect("upstream server");
assert_eq!(error.kind(), ProviderErrorKind::RateLimited);
assert_eq!(error.upstream_status(), Some(429));
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
assert!(
error.replay_is_safe(),
"before-payload rejection is replay safe"
);
// 回放安全的账号级拒绝必须把换号决策留给 Core,不得钉死同账号传输重试
// (旧行为会携带小时级 retry-after 的同账号重试标记,请求必然超时)。
assert_eq!(error.pre_delivery_retry(), None);
}
#[tokio::test]
async fn websocket_turn_state_metadata_is_exposed_through_response_observation() {
let store = Arc::new(MemoryAccountStore::default());
@@ -4843,10 +5052,157 @@ async fn completed_response_persists_session_affinity_before_stream_consumer_sto
drop(stream);
assert_eq!(affinity.binding_count(), 1);
assert_eq!(observed_service_tier.as_deref(), Some("default"));
assert_eq!(observed_service_tier.as_deref(), Some("priority"));
assert_eq!(upstream_service_tier.as_deref(), Some("default"));
}
#[tokio::test]
async fn responses_should_observe_and_bill_the_outbound_service_tier_on_both_transports() {
for use_websocket in [false, true] {
for (requested, reported, expected_cost) in [
(Some("priority"), Some("default"), 6_875_000),
(Some("priority"), None, 6_875_000),
(Some("default"), Some("priority"), 3_437_500),
(None, Some("priority"), 3_437_500),
] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let mut created =
json!({"type":"response.created","response":{"id":"resp_tier","model":"gpt-5.4"}});
let mut completed = json!({
"type":"response.completed",
"response":{
"id":"resp_tier","model":"gpt-5.4","status":"completed","output":[],
"usage":{"input_tokens":100,"output_tokens":10,"input_tokens_details":{"cached_tokens":25,"cache_write_tokens":0},"total_tokens":110}
}
});
if let Some(reported) = reported {
created["response"]["service_tier"] = json!("auto");
completed["response"]["service_tier"] = json!(reported);
}
let response_frames = format!(
"event: response.created\ndata: {created}\n\nevent: response.completed\ndata: {completed}\n\n"
);
let (base_url, http_server, websocket_server) = if use_websocket {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener");
let base_url = format!("http://{}", listener.local_addr().expect("address"));
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.expect("accept WebSocket");
let mut websocket = accept_codex_test_websocket(socket).await;
let request = websocket.next().await.expect("request").expect("frame");
let request: Value = serde_json::from_str(request.to_text().expect("text"))
.expect("request JSON");
for event in [created, completed] {
websocket
.send(Message::Text(event.to_string().into()))
.await
.expect("response");
}
request
});
(base_url, None, Some(server))
} else {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(response_frames.clone(), "text/event-stream"),
)
.expect(1)
.mount(&server)
.await;
(server.uri(), Some(server), None)
};
let mut body = Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("hello")),
]);
if let Some(requested) = requested {
body.insert("service_tier".to_owned(), json!(requested));
}
let payload = ProtocolPayload::json_object("openai", body)
.expect("payload")
.with_context(Map::from_iter([(
"use_websocket".to_owned(),
json!(use_websocket),
)]));
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request(
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
),
context("req_service_tier", CancellationToken::new()),
)
.await
.expect("provider stream");
let mut observations = Vec::new();
let mut costs = Vec::new();
let mut raw_response = Vec::new();
while let Some(event) = stream.next().await {
let event = event.expect("provider event");
if let Some(observation) = event.response_observation() {
observations.push(observation.clone());
}
for fact in event.canonical_facts() {
if let GatewayEvent::CalculatedCost(cost) = fact {
costs.push(cost.total().amount().scaled());
}
}
if let Some(frame) = event.wire_event().and_then(|wire| wire.raw_sse_frame()) {
raw_response.extend_from_slice(frame);
}
}
let outbound = if let Some(server) = http_server {
server.verify().await;
let requests = server.received_requests().await.expect("upstream requests");
captured_request_body(&requests[0])
} else {
websocket_server
.expect("WebSocket server")
.await
.expect("server task")
};
assert_eq!(
outbound.get("service_tier").and_then(Value::as_str),
requested
);
assert!(!observations.is_empty());
for observation in &observations {
assert_eq!(
observation.service_tier(),
requested,
"WebSocket={use_websocket}"
);
}
let metadata: Value = serde_json::from_str(
observations
.last()
.expect("final observation")
.provider_metadata()
.expect("metadata")
.as_json(),
)
.expect("metadata JSON");
assert_eq!(
metadata.get("requestedServiceTier").and_then(Value::as_str),
requested
);
assert_eq!(
metadata.get("upstreamServiceTier").and_then(Value::as_str),
reported
);
assert_eq!(
costs,
vec![expected_cost],
"WebSocket={use_websocket}, requested={requested:?}, reported={reported:?}"
);
assert_eq!(raw_response, response_frames.as_bytes());
}
}
}
#[tokio::test]
async fn response_failed_before_semantic_output_is_atomic_and_persists_quota_lock() {
let store = Arc::new(MemoryAccountStore::default());
@@ -5094,6 +5450,218 @@ async fn official_usage_limit_failure_persists_fact_without_fabricating_usage()
);
}
#[tokio::test]
async fn capacity_http_and_websocket_opening_rejections_use_bounded_business_retry() {
for use_websocket in [false, true] {
for status in [400, 429, 503] {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_provider_contract";
create_account(&store, account_id).await;
let server = MockServer::start().await;
let body = json!({"error": {
"code": if status == 503 { Some("server_is_overloaded") } else { None },
"type": "server_error",
"message": "Selected model is at capacity. Please try a different model.",
"extension": {"preserved": true}
}});
Mock::given(method(if use_websocket { "GET" } else { "POST" }))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(status)
.insert_header("retry-after", "129600")
.set_body_json(body.clone()),
)
.expect(1)
.mount(&server)
.await;
let operation = if use_websocket {
generate_operation()
} else {
http_generate_operation()
};
let mut stream = provider_with_base_url(&store, server.uri())
.execute(
planned_request("openai", operation),
context("req_capacity", CancellationToken::new()),
)
.await
.expect("prepare stream");
let error = loop {
match stream.next().await {
Some(Ok(_)) => {}
Some(Err(error)) => break error,
None => panic!("expected capacity rejection"),
}
};
assert_eq!(error.kind(), ProviderErrorKind::UpstreamCapacityUnavailable);
assert_eq!(error.upstream_status(), Some(status));
assert!(error.replay_is_safe());
assert!(
matches!(error.pre_delivery_retry(), Some(PreDeliveryRetry::SameAccountTransientRetry {
max_retries, initial_delay, max_delay,
}) if max_retries.get() == 3 && initial_delay == Duration::from_secs(8) && max_delay == Duration::from_secs(8))
);
assert!(!provider_openai::openai_failure_affects_account_score(
&error
));
assert_eq!(
serde_json::from_str::<Value>(
error.raw_upstream_error().expect("original error").as_str()
)
.expect("JSON"),
body
);
let response = error
.client_visible_upstream_response()
.expect("client response");
assert_eq!(response.status(), status);
assert_eq!(response.body().as_ref(), body.to_string().as_bytes());
let account = store.account(account_id).expect("account");
assert_eq!(account.quota().access(), QuotaAccessState::Unknown);
assert_eq!(account.credential_state(), CredentialState::Ready);
}
}
}
#[tokio::test]
async fn websocket_usage_limit_rejection_preserves_quota_state_for_account_rotation() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_provider_contract";
create_account(&store, account_id).await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/responses"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "129600")
.set_body_json(json!({"error": {"type": "usage_limit_reached", "message": "limit reached", "resets_at": 1_900_000_000}})))
.expect(1).mount(&server).await;
let mut stream = provider_with_base_url(&store, server.uri())
.execute(
planned_request("openai", generate_operation()),
context("req_ws_quota", CancellationToken::new()),
)
.await
.expect("prepare stream");
let error = loop {
match stream.next().await {
Some(Ok(_)) => {}
Some(Err(error)) => break error,
None => panic!("expected quota rejection"),
}
};
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
assert!(error.replay_is_safe());
assert_eq!(error.pre_delivery_retry(), None);
assert_eq!(error.retry_after(), Some(Duration::from_secs(129_600)));
assert_eq!(error.kind(), ProviderErrorKind::QuotaExhausted);
assert_eq!(
store.account(account_id).expect("account").quota().access(),
QuotaAccessState::Exhausted
);
}
#[tokio::test]
async fn capacity_stream_rejection_only_retries_before_semantic_output_on_both_transports() {
for use_websocket in [false, true] {
for semantic_output in [false, true] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let mut events = vec![
json!({"type": "response.created", "response": {"id": "resp_capacity", "model": "gpt-5.4"}}),
];
if semantic_output {
events.push(json!({"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "hello"}));
}
let original = json!({"type": "response.failed", "response": {"id": "resp_capacity", "error": {"code": "server_is_overloaded", "message": "Selected model is at capacity. Please try a different model."}}});
events.push(original.clone());
let (base_url, _http_server, websocket_server) = if use_websocket {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener");
let base_url = format!("http://{}", listener.local_addr().expect("address"));
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.expect("accept WebSocket");
let mut websocket = accept_codex_test_websocket(socket).await;
websocket.next().await.expect("request").expect("frame");
for event in events {
websocket
.send(Message::Text(event.to_string().into()))
.await
.expect("response");
}
});
(base_url, None, Some(server))
} else {
let server = MockServer::start().await;
let frames = events
.iter()
.map(|event| {
format!(
"event: {}\ndata: {event}\n\n",
event["type"].as_str().expect("type")
)
})
.collect::<String>();
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(frames, "text/event-stream"),
)
.expect(1)
.mount(&server)
.await;
(server.uri(), Some(server), None)
};
let operation = if use_websocket {
generate_operation()
} else {
http_generate_operation()
};
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", operation),
context("req_capacity_stream", CancellationToken::new()),
)
.await
.expect("prepare stream");
let mut client_events = Vec::new();
let mut error = loop {
match stream.next().await {
Some(Ok(event)) => {
if event.has_client_event() {
client_events.push(event);
}
}
Some(Err(error)) => break error,
None => panic!("expected capacity failure"),
}
};
assert_eq!(error.replay_is_safe(), !semantic_output);
assert_eq!(error.kind(), ProviderErrorKind::UpstreamCapacityUnavailable);
assert_eq!(error.pre_delivery_retry().is_some(), !semantic_output);
assert!(!provider_openai::openai_failure_affects_account_score(
&error
));
assert_eq!(
serde_json::from_str::<Value>(
error.raw_upstream_error().expect("raw error").as_str()
)
.expect("JSON"),
original
);
if !semantic_output {
assert!(client_events.is_empty());
}
client_events.extend(error.take_atomic_client_events());
let wire = client_events
.last()
.and_then(|event| event.wire_event())
.expect("failure wire");
assert_eq!(wire.data(), &original);
if let Some(server) = websocket_server {
server.await.expect("server");
}
}
}
}
#[tokio::test]
async fn ordinary_request_should_hold_created_until_later_failure_can_rotate() {
let store = Arc::new(MemoryAccountStore::default());
@@ -45,17 +45,21 @@ fn deliver_error_with_openai_feedback(
}
#[test]
fn account_score_failure_filter_should_include_server_overload_as_a_regular_reason() {
let error = sent_error(ProviderErrorKind::Unavailable, Some("server_is_overloaded"));
assert!(openai_failure_affects_account_score(&error));
fn account_score_failure_filter_should_exclude_request_scoped_capacity_errors() {
for code in ["server_is_overloaded", "slow_down", "server_error"] {
let error = sent_error(ProviderErrorKind::Unavailable, Some(code))
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"Selected model is at capacity. Please try a different model.",
Some("server_error".to_owned()),
Some("server_error".to_owned()),
));
assert!(!openai_failure_affects_account_score(&error));
}
}
#[test]
fn account_score_failure_filter_should_accept_the_closed_reason_list() {
for code in [
"server_is_overloaded",
"slow_down",
"rate_limit_exceeded",
"rate_limit_error",
"server_error",
@@ -181,7 +185,7 @@ fn openai_feedback_should_not_amplify_repeated_client_configuration_errors() {
}
#[test]
fn openai_feedback_should_score_server_overload_as_one_regular_failure() {
fn openai_feedback_should_not_penalize_an_account_for_server_overload() {
let feedback = Arc::new(AccountFeedbackStats::default());
let provider = ProviderKind::new("openai").expect("provider");
let account = ProviderAccountId::new("acct_overloaded").expect("account");
@@ -193,8 +197,5 @@ fn openai_feedback_should_score_server_overload_as_one_regular_failure() {
sent_error(ProviderErrorKind::Unavailable, Some("server_is_overloaded")),
);
assert_eq!(
feedback.scheduling_signals(&provider, &account).0,
Some(2_000)
);
assert_eq!(feedback.scheduling_signals(&provider, &account).0, None);
}
@@ -219,7 +219,7 @@ fn decoder_should_emit_calculated_cost_for_complete_known_model_usage() {
}
#[test]
fn decoder_should_prefer_response_service_tier_over_requested_tier_for_billing() {
fn decoder_should_bill_requested_service_tier_despite_default_response() {
let body = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_fast_cost\",\"model\":\"gpt-5.4\",\"service_tier\":\"default\"}}\n\n",
@@ -236,12 +236,12 @@ fn decoder_should_prefer_response_service_tier_over_requested_tier_for_billing()
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
event,
GatewayEvent::CalculatedCost(cost)
if cost.total().amount().scaled() == 3_437_500
if cost.total().amount().scaled() == 6_875_000
)));
}
#[test]
fn decoder_should_use_response_service_tier_when_request_omits_it() {
fn decoder_should_bill_standard_when_request_omits_service_tier() {
let body = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_response_tier_cost\",\"model\":\"gpt-5.4\",\"service_tier\":\"priority\"}}\n\n",
@@ -257,7 +257,7 @@ fn decoder_should_use_response_service_tier_when_request_omits_it() {
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
event,
GatewayEvent::CalculatedCost(cost)
if cost.total().amount().scaled() == 6_875_000
if cost.total().amount().scaled() == 3_437_500
)));
}
@@ -346,7 +346,7 @@ fn decoder_should_fail_closed_for_fixed_block_web_search_content() {
}
#[test]
fn websocket_decoder_should_prefer_response_service_tier_over_requested_tier_for_billing() {
fn websocket_decoder_should_bill_requested_service_tier_despite_default_response() {
let created = websocket_event_to_sse_frame(
r#"{"type":"response.created","response":{"id":"resp_ws_fast_cost","model":"gpt-5.4","service_tier":"default"}}"#,
)
@@ -366,7 +366,7 @@ fn websocket_decoder_should_prefer_response_service_tier_over_requested_tier_for
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
event,
GatewayEvent::CalculatedCost(cost)
if cost.total().amount().scaled() == 3_437_500
if cost.total().amount().scaled() == 6_875_000
)));
}
@@ -719,6 +719,36 @@ fn decoder_should_classify_official_server_overloaded_failure() {
assert_failed_event("server_is_overloaded", "server-secret-marker");
}
#[test]
fn capacity_failure_preserves_original_wire_and_diagnostics() {
for path in ["error", "response.failed"] {
let data = if path == "error" {
r#"{"type":"error","error":{"code":"slow_down","message":"busy","extra":123456789012345678901234567890},"extension":true}"#
} else {
r#"{"type":"response.failed","response":{"id":"resp_capacity","error":{"code":"server_is_overloaded","message":"busy","extra":123456789012345678901234567890}},"extension":true}"#
};
let raw = format!("event: {path}\r\nid: upstream-id\r\nretry: 123\r\ndata: {data}\r\n\r\n");
let failure = CodexCanonicalDecoder::new("fallback")
.with_raw_sse_passthrough()
.push(raw.as_bytes())
.expect_err("capacity failure");
let wire = failure.events()[0].wire_event().expect("client wire");
let expected: serde_json::Value = serde_json::from_str(data).expect("original JSON");
assert_eq!(wire.data(), &expected);
assert_eq!(wire.sse_id(), Some("upstream-id"));
assert_eq!(wire.sse_retry(), Some(123));
assert_eq!(
wire.raw_sse_frame().map(AsRef::as_ref),
Some(raw.as_bytes())
);
let CodexCanonicalError::Upstream(upstream) = failure.error() else {
panic!("typed failure")
};
assert_eq!(upstream.raw_body(), data);
assert_ne!(upstream.upstream_code.as_deref(), Some("server_error"));
}
}
#[test]
fn decoder_should_classify_official_cyber_policy_as_an_invalid_request() {
assert_failed_event("cyber_policy", "policy-secret-marker");
@@ -975,14 +1005,19 @@ fn pricing_response_cost(
}
#[test]
fn billing_should_follow_actual_tier_and_only_fall_back_when_it_is_absent() {
fn billing_should_follow_requested_tier_independently_of_response_tier() {
for (requested, actual, expected) in [
(Some("fast"), Some("default"), Some(1_000_000_000)),
(Some("auto"), Some("fast"), Some(2_000_000_000)),
(Some("default"), Some("flex"), Some(500_000_000)),
(Some("priority"), Some("default"), Some(2_000_000_000)),
(Some("fast"), Some("default"), Some(2_000_000_000)),
(Some("auto"), Some("fast"), None),
(Some("default"), Some("flex"), Some(1_000_000_000)),
(Some("flex"), Some("priority"), Some(500_000_000)),
(Some("fast"), None, Some(2_000_000_000)),
(Some("auto"), None, None),
(Some("default"), Some("future"), None),
(Some("default"), Some("future"), Some(1_000_000_000)),
(Some("future"), Some("default"), None),
(None, Some("priority"), Some(1_000_000_000)),
(None, None, Some(1_000_000_000)),
] {
assert_eq!(
pricing_response_cost(requested, actual, json!([]), &[]),
@@ -1002,8 +1037,8 @@ fn billing_should_add_file_and_web_search_fees_without_tier_markup() {
for (tier, expected) in [("default", 1_150_000_000), ("fast", 2_150_000_000)] {
assert_eq!(
pricing_response_cost(
None,
Some(tier),
Some("default"),
output.clone(),
&[json!({"type": "web_search"})]
),
@@ -419,6 +419,7 @@ fn profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "xterm".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -20,6 +20,69 @@ fn classify(status: u16, body: &str) -> CodexFailureCategory {
.category()
}
#[test]
fn capacity_rejections_are_request_scoped_across_http_statuses() {
for status in [400, 429, 503] {
for body in [
r#"{"error":{"code":"server_is_overloaded","message":"busy"}}"#,
r#"{"error":{"code":"slow_down","type":"server_error"}}"#,
r#"{"error":{"type":"server_is_overloaded"}}"#,
r#"{"error":{"message":"slow_down"}}"#,
r#"{"error":{"type":"invalid_request_error","message":"Selected model is at capacity. Please try a different model."}}"#,
] {
assert_eq!(
classify(status, body),
CodexFailureCategory::CapacityUnavailable
);
for (phase, safe) in [
(CodexUpstreamSendPhase::BeforePayload, true),
(CodexUpstreamSendPhase::AfterPayload, true),
(CodexUpstreamSendPhase::Ambiguous, false),
] {
let failure = CodexUpstreamFailure::from_response(
reqwest::StatusCode::from_u16(status).expect("status"),
body,
Some(129_600),
&CodexUpstreamDiagnostics::default(),
None,
&[],
&[],
phase,
);
assert_eq!(failure.replay_is_safe(), safe);
}
}
}
}
#[test]
fn capacity_words_do_not_override_quota_policy_or_arbitrary_body_fields() {
for (status, body, expected) in [
(
429,
r#"{"error":{"type":"usage_limit_reached","message":"Selected model is at capacity"}}"#,
CodexFailureCategory::UsageLimitExhausted,
),
(
400,
r#"{"error":{"code":"cyber_policy","message":"Selected model is at capacity"}}"#,
CodexFailureCategory::InvalidRequest,
),
(
400,
r#"{"error":{"message":"bad input"},"input":"Selected model is at capacity"}"#,
CodexFailureCategory::InvalidRequest,
),
(
429,
r#"{"error":{"code":"rate_limit_exceeded","message":"retry later"}}"#,
CodexFailureCategory::RateLimited,
),
] {
assert_eq!(classify(status, body), expected);
}
}
fn trace_header<'a>(diagnostics: &'a CodexUpstreamDiagnostics, name: &str) -> Option<&'a str> {
diagnostics
.trace_headers
@@ -94,6 +94,7 @@ fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest {
let mut request = encode_generate_request(
&GenerateRequest::from_protocol_payload(payload),
"gpt-routed",
None,
)
.expect("opaque request headers");
request.use_websocket = use_websocket;
@@ -60,6 +60,7 @@ mod profile_contract;
mod protocol;
mod request;
mod reset_credits;
mod subscription;
mod tls;
mod usage;
mod websocket;
@@ -130,6 +131,7 @@ fn test_wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "transport-test".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc::now(),
})
}
@@ -141,6 +141,7 @@ fn wire_profile_should_generate_bundled_core_app_server_user_agent() {
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
.single()
@@ -207,7 +208,11 @@ fn desktop_appcast_should_reject_invalid_version_and_build() {
#[tokio::test]
async fn bundled_release_update_should_change_core_and_desktop_identity_atomically() {
let original = wire_profile();
let mut original = wire_profile();
original.location = serde_json::from_value(serde_json::json!({
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
}))
.expect("configured location");
let original_user_agent = original.user_agent();
let state = CodexWireProfileState::new(original.clone());
let transport = Arc::new(ReleaseTransport::new(
@@ -228,6 +233,7 @@ async fn bundled_release_update_should_change_core_and_desktop_identity_atomical
assert_eq!(updated.os_version, original.os_version);
assert_eq!(updated.arch, original.arch);
assert_eq!(updated.terminal, original.terminal);
assert_eq!(updated.location, original.location);
assert!(updated.verified_at > original.verified_at);
assert_ne!(updated.user_agent(), original_user_agent);
assert_eq!(
@@ -344,6 +350,7 @@ fn wire_profile() -> CodexWireProfile {
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
.single()
@@ -1,7 +1,65 @@
use chrono::{Datelike as _, Utc};
use chrono_tz::America::New_York;
use gateway_core::operation::{GenerateRequest, ProtocolPayload};
use serde_json::{Map, Value, json};
use provider_openai::encode_generate_request;
use provider_openai::transport::profile::CodexRequestLocation;
#[test]
fn encoder_should_use_the_configured_location_for_tools_and_environment_dates() {
let location: CodexRequestLocation = serde_json::from_value(json!({
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
}))
.expect("configured location");
let body = json!({
"input": [{
"role": "user",
"content": [{"type": "input_text", "text": "<environment_context><current_date>2020-01-01</current_date><timezone>UTC</timezone></environment_context>"}],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": ["environments.environment_context"],
"create_time": 1789293131.822
}
}],
"tools": [
{"type": "web_search", "user_location": {"type": "approximate", "country": "US"}},
{"type": "web_search_preview"}
]
}).as_object().expect("request object").clone();
let before = Utc::now()
.with_timezone(&location.timezone)
.format("%Y-%m-%d")
.to_string();
let encoded =
encode_generate_request(&request(body), "gpt-test", Some(&location)).expect("encode");
let after = Utc::now()
.with_timezone(&location.timezone)
.format("%Y-%m-%d")
.to_string();
let encoded = Value::Object(encoded.body().clone());
let environment = encoded
.pointer("/input/0/content/0/text")
.and_then(Value::as_str)
.expect("environment");
assert!(environment.contains("<timezone>Pacific/Auckland</timezone>"));
assert!(
[before, after]
.iter()
.any(|date| environment.contains(&format!("<current_date>{date}</current_date>")))
);
for index in 0..2 {
assert_eq!(
encoded.pointer(&format!("/tools/{index}/user_location")),
Some(&json!({
"type": "approximate", "country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
}))
);
}
assert_eq!(
encoded.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"),
Some(&json!(1789293131.822))
);
}
fn request(body: Map<String, Value>) -> GenerateRequest {
GenerateRequest::from_protocol_payload(
@@ -9,6 +67,31 @@ fn request(body: Map<String, Value>) -> GenerateRequest {
)
}
#[test]
fn encoder_should_preserve_location_fields_when_no_override_is_configured() {
let body = json!({
"input": [{
"role": "user",
"content": [{"type": "input_text", "text": "<environment_context><current_date>2020-01-01</current_date><timezone>Asia/Shanghai</timezone></environment_context>"}],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": ["environments.environment_context"],
"create_time": 1789293131.822
}
}],
"tools": [
{"type": "web_search", "user_location": {"type": "approximate", "country": "CN", "region": "Shanghai", "city": "Shanghai", "timezone": "Asia/Shanghai"}},
{"type": "web_search_preview", "user_location": null},
{"type": "web_search_preview_2025_03_11"},
{"type": "function", "name": "keep_timezone", "parameters": {}}
]
}).as_object().expect("request object").clone();
let encoded = encode_generate_request(&request(body.clone()), "gpt-test", None)
.expect("encode without location override");
assert_eq!(encoded.body().get("input"), body.get("input"));
assert_eq!(encoded.body().get("tools"), body.get("tools"));
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test")));
}
#[test]
fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_identity() {
let body = Map::from_iter([
@@ -30,7 +113,8 @@ fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_
]);
let request = request(body);
let encoded = encode_generate_request(&request, "gpt-routed").expect("encode wire payload");
let encoded =
encode_generate_request(&request, "gpt-routed", None).expect("encode wire payload");
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-routed")));
assert!(encoded.body().get("stream").is_none());
@@ -64,7 +148,7 @@ fn encoder_should_never_hash_prompt_content_into_an_accountless_pool_identity()
for input in ["private stable prompt", "different private prompt"] {
let encoded =
encode_generate_request(&request(input), "gpt-routed").expect("encoded request");
encode_generate_request(&request(input), "gpt-routed", None).expect("encoded request");
assert!(encoded.local_conversation_id.is_none());
}
}
@@ -85,7 +169,7 @@ fn encoder_should_patch_model_and_preserve_supported_generate_semantics() {
),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test")));
assert!(encoded.body().get("stream").is_none());
assert_eq!(encoded.body().get("store"), Some(&json!(false)));
@@ -105,7 +189,7 @@ fn encoder_should_remove_unsupported_fields_from_upstream_body() {
("temperature".to_owned(), json!(0.2)),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
Value::Object(encoded.body().clone()),
@@ -117,6 +201,125 @@ fn encoder_should_remove_unsupported_fields_from_upstream_body() {
);
}
#[test]
fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() {
let normal_chat = "<environment_context>\n <current_date>2026-09-13</current_date>\n \
<timezone>Asia/Shanghai</timezone>\n</environment_context>";
let body = json!({
"model": "client-model",
"input": [
{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "<environment_context>\n <cwd>/Users/mike/Personal/workspace/PORTAL2</cwd>\n <shell>zsh</shell>\n <current_date>2026-09-13</current_date>\n <timezone>Asia/Shanghai</timezone>\n <filesystem><file_system type=\"unrestricted\" /></filesystem>\n</environment_context>"
}],
"internal_chat_message_metadata_passthrough": {
"create_time": 1789293131.822,
"content_item_kinds": ["environments.environment_context"]
}
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": normal_chat}],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": ["user.text"]
}
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": normal_chat}]
}
],
"tools": [
{"type": "web_search"},
{
"type": "function",
"name": "remember_timezone",
"description": "Keep Asia/Shanghai unchanged"
}
],
"client_metadata": {
"x-codex-turn-metadata": "{\"turn_started_at_unix_ms\":1789293131822}",
"x-codex-ws-stream-request-start-ms": "1789293132000"
}
})
.as_object()
.expect("request object")
.clone();
let before = Utc::now().with_timezone(&New_York);
let encoded = encode_generate_request(
&request(body),
"gpt-test",
Some(&CodexRequestLocation::default()),
)
.expect("encode");
let after = Utc::now().with_timezone(&New_York);
let encoded = Value::Object(encoded.body().clone());
let environment = encoded
.pointer("/input/0/content/0/text")
.and_then(Value::as_str)
.expect("environment context text");
let expected_dates = [
format!(
"{:04}-{:02}-{:02}",
before.year(),
before.month(),
before.day()
),
format!(
"{:04}-{:02}-{:02}",
after.year(),
after.month(),
after.day()
),
];
assert!(
expected_dates
.iter()
.any(|date| environment.contains(&format!("<current_date>{date}</current_date>")))
);
assert!(environment.contains("<timezone>America/New_York</timezone>"));
assert_eq!(
encoded.pointer("/tools/0/user_location"),
Some(&json!({
"type": "approximate",
"country": "US",
"region": "Ohio",
"city": "Piketon",
"timezone": "America/New_York"
}))
);
assert_eq!(
encoded.pointer("/input/1/content/0/text"),
Some(&json!(normal_chat))
);
assert_eq!(
encoded.pointer("/input/2/content/0/text"),
Some(&json!(normal_chat))
);
assert_eq!(
encoded.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"),
Some(&json!(1789293131.822))
);
assert_eq!(
encoded.pointer("/tools/1/description"),
Some(&json!("Keep Asia/Shanghai unchanged"))
);
assert_eq!(
encoded.pointer("/client_metadata"),
Some(&json!({
"x-codex-turn-metadata": "{\"turn_started_at_unix_ms\":1789293131822}",
"x-codex-ws-stream-request-start-ms": "1789293132000"
}))
);
}
#[test]
fn encoder_should_preserve_client_store_intent() {
let request = request(Map::from_iter([
@@ -125,7 +328,7 @@ fn encoder_should_preserve_client_store_intent() {
("store".to_owned(), json!(true)),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.body().get("store"), Some(&json!(true)));
}
@@ -138,7 +341,7 @@ fn encoder_should_forward_the_client_prompt_cache_key() {
("prompt_cache_key".to_owned(), json!("cache-route")),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
encoded.body().get("prompt_cache_key"),
@@ -154,7 +357,7 @@ fn encoder_should_restore_conversation_fallback_after_body_and_context_values()
("prompt_cache_key".to_owned(), json!("cache-conversation")),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
encoded.client_conversation_id.as_deref(),
@@ -205,7 +408,7 @@ fn encoder_should_accept_metadata_session_and_thread_ids_but_ignore_legacy_conte
),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
(
@@ -251,7 +454,7 @@ fn encoder_should_extract_official_websocket_context_projection() {
),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.turn_state.as_deref(), Some("metadata-turn-state"));
assert_eq!(encoded.client_turn_id.as_deref(), Some("metadata-turn-id"));
@@ -292,7 +495,7 @@ fn header_context_should_win_over_body_topline_aliases() {
]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.turn_state.as_deref(), Some("header-turn-state"));
assert_eq!(
@@ -318,7 +521,7 @@ fn encoder_should_preserve_downstream_websocket_connection_identity_outside_wire
)]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
encoded.downstream_websocket_connection_id.as_deref(),
@@ -348,7 +551,7 @@ fn body_topline_alias_should_only_fill_an_absent_header_context() {
)]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.turn_state.as_deref(), Some("body-turn-state"));
assert_eq!(
@@ -373,7 +576,7 @@ fn encoder_should_project_explicit_websocket_transport_without_touching_body() {
)]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert!(encoded.use_websocket);
assert!(!encoded.force_http_sse);
@@ -396,7 +599,7 @@ fn encoder_should_project_explicit_http_transport_without_touching_body() {
)]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert!(!encoded.use_websocket);
assert!(encoded.force_http_sse);
@@ -422,7 +625,8 @@ fn encoder_should_preserve_opaque_provider_options_without_interpreting_them() {
.expect("OpenAI payload");
let request = GenerateRequest::from_protocol_payload(payload);
let encoded = encode_generate_request(&request, "gpt-test").expect("opaque options encode");
let encoded =
encode_generate_request(&request, "gpt-test", None).expect("opaque options encode");
assert_eq!(
encoded.body().get("provider_options"),
@@ -459,7 +663,7 @@ fn encoder_should_project_lite_and_memgen_options_to_transport_state_only() {
]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.responses_lite.as_deref(), Some("true"));
assert_eq!(encoded.memgen_request.as_deref(), Some("true"));
@@ -483,7 +687,7 @@ fn observability_semantics_should_reuse_codex_turn_metadata() {
)]));
let request = GenerateRequest::from_protocol_payload(payload);
let semantics = encode_generate_request(&request, "observability")
let semantics = encode_generate_request(&request, "observability", None)
.expect("observability request should encode")
.semantics();
@@ -502,8 +706,8 @@ fn encoder_should_extract_subagent_kind_from_wire_or_turn_metadata() {
json!({"x-openai-subagent": "review"}),
),
]));
let metadata_encoded =
encode_generate_request(&metadata_request, "gpt-test").expect("encode metadata request");
let metadata_encoded = encode_generate_request(&metadata_request, "gpt-test", None)
.expect("encode metadata request");
assert_eq!(metadata_encoded.subagent_kind().as_deref(), Some("review"));
let turn_metadata_payload = ProtocolPayload::json_object(
@@ -519,7 +723,7 @@ fn encoder_should_extract_subagent_kind_from_wire_or_turn_metadata() {
Value::String(r#"{"subagent_kind":"worker"}"#.to_owned()),
)]));
let turn_metadata_request = GenerateRequest::from_protocol_payload(turn_metadata_payload);
let turn_metadata_encoded = encode_generate_request(&turn_metadata_request, "gpt-test")
let turn_metadata_encoded = encode_generate_request(&turn_metadata_request, "gpt-test", None)
.expect("encode turn metadata request");
assert_eq!(
turn_metadata_encoded.subagent_kind().as_deref(),
@@ -553,7 +757,7 @@ fn observability_semantics_should_use_the_transparent_openai_payload() {
.expect("OpenAI payload");
let request = GenerateRequest::from_protocol_payload(payload);
let semantics = encode_generate_request(&request, "observability")
let semantics = encode_generate_request(&request, "observability", None)
.expect("transparent OpenAI request should encode")
.semantics();
@@ -27,6 +27,7 @@ fn client(base_url: &str) -> CodexBackendClient {
arch: "arm64".to_owned(),
terminal: "xterm-256color".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 8, 20, 0, 0, 0)
.single()
@@ -146,6 +147,50 @@ async fn consume_should_send_exact_credit_and_redeem_request_id_without_retry()
);
}
#[tokio::test]
async fn count_only_credits_should_allow_upstream_selected_consumption() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/rate-limit-reset-credits"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"available_count": 1,
"credits": []
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/codex/rate-limit-reset-credits/consume"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "code": "reset" })))
.expect(1)
.mount(&server)
.await;
let client = client(&server.uri());
let credits = client
.list_rate_limit_reset_credits(context())
.await
.expect("count-only reset credits");
assert_eq!(credits.available_count, 1);
assert!(credits.credits.is_empty());
let redeem_request_id =
Uuid::parse_str("8fbf302d-11df-4bd5-82e4-08e4b3df7874").expect("redeem request ID");
let result = client
.consume_rate_limit_reset_credit(context(), None, redeem_request_id)
.await
.expect("upstream-selected reset-credit consume");
assert_eq!(result.code, "reset");
let requests = server.received_requests().await.expect("received requests");
let request = requests
.iter()
.find(|request| request.method == Method::POST)
.expect("consume request");
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&request.body).expect("consume JSON"),
json!({ "redeem_request_id": "8fbf302d-11df-4bd5-82e4-08e4b3df7874" })
);
}
#[tokio::test]
async fn consume_should_preserve_raw_upstream_error_and_never_retry() {
let server = MockServer::start().await;
@@ -0,0 +1,148 @@
use super::{CodexBackendClient, CodexRequestContext, test_wire_profile};
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{header, method, path, query_param},
};
fn client(base: &str) -> CodexBackendClient {
CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
base,
test_wire_profile(),
)
}
#[tokio::test]
async fn subscription_uses_exact_bound_account_and_projects_only_safe_facts() {
let server = MockServer::start().await;
let account = "personal/account + A";
Mock::given(method("GET"))
.and(path("/backend-api/subscriptions"))
.and(query_param("account_id", account))
.and(header("chatgpt-account-id", account))
.and(header("authorization", "Bearer fixture"))
.and(header("accept", "application/json"))
.and(header("origin", server.uri()))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id":"subscription-not-account", "plan_type":"enterprise", "secret":"ignored",
"active_start":"2019-12-02T08:00:00+08:00",
"active_until":" 2020-01-02T08:00:00+08:00 ", "will_renew":true,
"billing_period":"monthly", "billing_currency":"USD"
})))
.expect(1)
.mount(&server)
.await;
let result = client(&format!("{}/backend-api", server.uri()))
.fetch_subscription(
CodexRequestContext::auxiliary(
"Bearer fixture",
Some(account),
"subscription_test",
None,
),
account,
)
.await
.unwrap();
assert_eq!(
result.starts_at.unwrap().to_rfc3339(),
"2019-12-02T00:00:00+00:00"
);
assert_eq!(result.billing_period.as_deref(), Some("monthly"));
assert_eq!(result.billing_currency.as_deref(), Some("USD"));
assert_eq!(result.expires_at.to_rfc3339(), "2020-01-02T00:00:00+00:00");
assert_eq!(result.will_renew, Some(true));
}
#[tokio::test]
async fn subscription_failures_are_unknown_without_retry() {
for response in [
ResponseTemplate::new(204),
ResponseTemplate::new(401),
ResponseTemplate::new(403),
ResponseTemplate::new(404),
ResponseTemplate::new(429),
ResponseTemplate::new(500),
ResponseTemplate::new(200).set_body_string("not json"),
ResponseTemplate::new(200)
.set_body_json(json!({"active_until":"invalid","will_renew":false})),
ResponseTemplate::new(200).set_body_json(json!({"will_renew":true})),
ResponseTemplate::new(200).set_body_string("x".repeat(65537)),
ResponseTemplate::new(200).set_delay(std::time::Duration::from_secs(6)),
] {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(response)
.expect(1)
.mount(&server)
.await;
assert!(
client(&server.uri())
.fetch_subscription(
CodexRequestContext::auxiliary("Bearer fixture", Some("account"), "req", None),
"account"
)
.await
.is_none()
);
}
}
#[tokio::test]
async fn subscription_unknown_renewal_is_not_false_and_empty_account_does_not_request() {
let server = MockServer::start().await;
for renewal in [json!(null), json!("false"), json!(false)] {
server.reset().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200).set_body_json(
json!({"active_until":"2026-10-01T00:00:00Z","will_renew":renewal}),
),
)
.expect(1)
.mount(&server)
.await;
let result = client(&server.uri())
.fetch_subscription(
CodexRequestContext::auxiliary("Bearer fixture", Some("account"), "req", None),
"account",
)
.await
.unwrap();
assert_eq!(result.will_renew, renewal.as_bool());
}
server.reset().await;
assert!(
client(&server.uri())
.fetch_subscription(
CodexRequestContext::auxiliary("Bearer fixture", None, "req", None),
""
)
.await
.is_none()
);
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn subscription_preserves_configured_backend_path_prefix() {
let server = MockServer::start().await;
Mock::given(path("/proxy/backend-api/subscriptions"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"active_until":"2026-10-01T00:00:00Z"})),
)
.expect(1)
.mount(&server)
.await;
assert!(
client(&format!("{}/proxy/backend-api/", server.uri()))
.fetch_subscription(
CodexRequestContext::auxiliary("Bearer fixture", Some("account"), "req", None),
"account"
)
.await
.is_some()
);
}
@@ -4,8 +4,8 @@ use bytes::{Buf as _, Bytes};
use super::*;
// Captured from Desktop 26.901.51231 / Core 0.153.4 on 2026-09-06.
// Compare wire semantics: rustls randomizes extension order and ephemeral key bytes.
// 2026-09-14 从 Desktop 26.908.40834 / Core 0.154.0 抓取。
// HTTP/native-tls 的扩展顺序固定;WebSocket/rustls 会随机化扩展顺序和临时密钥。
#[derive(Debug, PartialEq, Eq)]
struct ClientHello {
cipher_suites: Vec<u16>,
@@ -16,28 +16,66 @@ struct ClientHello {
alpn: Vec<String>,
}
#[tokio::test]
async fn http_client_hello_should_match_official_rustls_transport() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!(
"https://localhost:{}/",
listener.local_addr().unwrap().port()
);
let client = provider_openai::transport::build_reqwest_client().unwrap();
let (hello, response) = timeout(Duration::from_secs(10), async {
tokio::join!(read_client_hello(listener), client.get(url).send())
})
.await
.expect("HTTP ClientHello within timeout");
assert!(
response.is_err(),
"capture endpoint rejects TLS after ClientHello"
);
#[test]
fn http_client_hello_should_match_official_native_tls_transport() {
const CASE_ENV: &str = "CODEX_PROXY_TEST_NATIVE_TLS_CLIENT_HELLO";
const CASE_COMPLETED: &str = "native-tls-client-hello-case-completed";
let mut expected = official_websocket_hello();
expected.extensions.insert(5, 16);
expected.alpn = vec!["h2".to_owned(), "http/1.1".to_owned()];
assert_eq!(hello, expected);
if std::env::var_os(CASE_ENV).is_some() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let hello = runtime.block_on(async {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!(
"https://localhost:{}/",
listener.local_addr().unwrap().port()
);
let client = provider_openai::transport::build_reqwest_client().unwrap();
let (hello, response) = timeout(Duration::from_secs(10), async {
tokio::join!(read_client_hello(listener), client.get(url).send())
})
.await
.expect("HTTP ClientHello within timeout");
assert!(
response.is_err(),
"capture endpoint rejects TLS after ClientHello"
);
hello
});
assert_eq!(hello, official_http_hello());
println!("\n{CASE_COMPLETED}");
return;
}
// Cargo 运行测试时可能注入 SSL_CERT_FILE,生产代码会把它视作自定义 CA 并切到 rustls;
// 因此在清理相关环境变量的子进程里验证默认路径。
let current_exe = std::env::current_exe().expect("current test binary path");
let output = Command::new(current_exe)
.arg("--exact")
.arg("transport::tls::http_client_hello_should_match_official_native_tls_transport")
.arg("--nocapture")
.env(CASE_ENV, "1")
.env_remove(provider_openai::transport::tls::CODEX_CA_CERT_ENV)
.env_remove(provider_openai::transport::tls::SSL_CERT_FILE_ENV)
.output()
.expect("run isolated native TLS ClientHello case");
assert!(
output.status.success(),
"isolated native TLS ClientHello case failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(
stdout
.lines()
.filter(|line| *line == CASE_COMPLETED)
.count(),
1,
"isolated native TLS ClientHello case did not complete exactly once\nstdout:\n{stdout}"
);
}
#[tokio::test]
@@ -60,7 +98,27 @@ async fn websocket_client_hello_should_match_official_rustls_transport() {
response.is_err(),
"capture endpoint rejects TLS after ClientHello"
);
assert_eq!(hello, official_websocket_hello());
let mut normalized = hello;
normalized.extensions.sort_unstable();
assert_eq!(normalized, official_websocket_hello());
}
fn official_http_hello() -> ClientHello {
ClientHello {
cipher_suites: vec![
4866, 4867, 4865, 49196, 49200, 159, 52393, 52392, 52394, 49195, 49199, 158, 49188,
49192, 107, 49187, 49191, 103, 49162, 49172, 57, 49161, 49171, 51, 157, 156, 61, 60,
53, 47,
],
extensions: vec![65281, 0, 11, 10, 35, 22, 23, 13, 43, 45, 51],
groups: vec![4588, 29, 23, 30, 24, 25, 256, 257],
signature_algorithms: vec![
2309, 2310, 2308, 1027, 1283, 1539, 2055, 2056, 2074, 2075, 2076, 2057, 2058, 2059,
2052, 2053, 2054, 1025, 1281, 1537, 771, 769, 770, 1026, 1282, 1538,
],
key_shares: vec![(4588, 1216), (29, 32)],
alpn: Vec::new(),
}
}
fn official_websocket_hello() -> ClientHello {
@@ -96,9 +154,11 @@ async fn read_client_hello(listener: TcpListener) -> ClientHello {
let compression_len = usize::from(hello.get_u8());
hello.advance(compression_len);
let mut extensions = take_vector(&mut hello);
let mut extension_order = Vec::new();
let mut values = BTreeMap::new();
while extensions.has_remaining() {
let kind = extensions.get_u16();
extension_order.push(kind);
assert!(values.insert(kind, take_vector(&mut extensions)).is_none());
}
@@ -124,7 +184,7 @@ async fn read_client_hello(listener: TcpListener) -> ClientHello {
}
ClientHello {
cipher_suites,
extensions: values.into_keys().collect(),
extensions: extension_order,
groups,
signature_algorithms,
key_shares,
@@ -243,15 +243,11 @@ async fn exact_limit_success_body_should_be_accepted() {
fn billing_should_use_explicit_variant_prices_and_verified_snapshots() {
// 每个用例包含一个普通输入 token 和一个输出 token,金额单位为 USD ticks。
for (model, expected) in [
("gpt-5.1-codex-mini", 22_500),
("gpt-5.3-chat-latest", 157_500),
("gpt-5.6-cyber", 875_000),
("gpt-5.5-cyber", 875_000),
("gpt-daybreak-red-latest", 875_000),
("gpt-daybreak-blue-latest", 350_000),
("chat-latest", 350_000),
("gpt-5.1-codex-max", 112_500),
("gpt-5.2-codex", 157_500),
("gpt-5.4-2026-03-05", 175_000),
(" OpenAI/GPT-4o-2024-08-06 ", 125_000),
("gpt-4o-2024-05-13", 200_000),
@@ -268,6 +264,53 @@ fn billing_should_use_explicit_variant_prices_and_verified_snapshots() {
}
}
#[test]
fn billing_should_not_price_shutdown_models_or_restore_them_through_aliases() {
// 官方关闭日期已过;同族仍在服务的基础型号不能为这些旧型号兜底。
for model in [
"gpt-4-0314",
" OpenAI/GPT-4-0314 ",
"gpt-5-codex",
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2-codex",
"gpt-5-chat-latest",
"gpt-5.1-chat-latest",
"gpt-5.2-chat-latest",
"gpt-5.3-chat-latest",
] {
for tier in [None, Some("flex"), Some("fast")] {
assert!(
openai_billing_breakdown(model, billing_usage(100, 10, 20, 0), tier).is_none(),
"{model} {tier:?}"
);
}
}
}
#[test]
fn billing_should_keep_deprecated_models_before_their_shutdown_dates() {
// 截至 2026-09-13,这些型号尚未到官方关闭日期,仍按已公布单价计费。
for model in [
"gpt-3.5-turbo-instruct",
"gpt-3.5-turbo-1106",
"gpt-4-0613",
"gpt-4-turbo-2024-04-09",
"gpt-4o-2024-05-13",
"gpt-4.1-nano",
"o1",
"o1-pro",
"o3-mini",
"o4-mini",
] {
assert!(
openai_billing_breakdown(model, billing_usage(100, 10, 20, 0), None).is_some(),
"{model}"
);
}
}
#[test]
fn billing_should_not_inherit_prices_for_unknown_models_or_tiers() {
for model in [
@@ -291,7 +334,7 @@ fn billing_should_not_inherit_prices_for_unknown_models_or_tiers() {
("gpt-5.6-cyber", 272_001, None),
("gpt-5.5-cyber", 272_001, None),
("gpt-5.6-cyber", 1, Some("fast")),
("gpt-5.1-codex-mini", 1, Some("flex")),
("gpt-5.3-codex", 1, Some("flex")),
("gpt-6-astra", 1, Some("auto")),
("gpt-6-astra", 1, Some("ultrafast")),
] {
@@ -662,6 +705,7 @@ fn profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "xterm".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -0,0 +1,276 @@
use base64::{Engine as _, engine::general_purpose::STANDARD};
use flate2::{Decompress, FlushDecompress};
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
use super::*;
#[derive(Debug)]
struct ClientFrame {
first_byte: u8,
masked: bool,
mask_len: usize,
payload: Vec<u8>,
}
#[tokio::test]
async fn official_websocket_wire_fingerprint_should_remain_aligned() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind fingerprint server");
let address = listener.local_addr().expect("fingerprint server address");
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept fingerprint client");
let opening = read_http_request(&mut stream).await;
let websocket_key = read_header_value(&opening, "sec-websocket-key")
.expect("WebSocket key")
.to_owned();
let response = format!(
concat!(
"HTTP/1.1 101 Switching Protocols\r\n",
"Connection: Upgrade\r\n",
"Upgrade: websocket\r\n",
"Sec-WebSocket-Accept: {}\r\n",
"Sec-WebSocket-Extensions: permessage-deflate\r\n",
"\r\n"
),
derive_accept_key(websocket_key.as_bytes())
);
stream
.write_all(response.as_bytes())
.await
.expect("write upgrade response");
let frame = read_client_frame(&mut stream).await;
write_server_text_frame(
&mut stream,
&completed_websocket_response("resp_wire_fingerprint", 1, 1),
)
.await;
(opening, websocket_key, frame)
});
let mut request = CodexResponsesRequest::from_body(
json!({
"model": "gpt-5.5",
"instructions": "be brief",
"input": [],
"tools": [],
"tool_choice": "auto",
"parallel_tool_calls": true,
"reasoning": {"effort": "medium", "summary": "auto"},
"store": false,
"stream": true,
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": "conversation-fingerprint",
"generate": false,
"client_metadata": {"request_kind": "prewarm"}
})
.as_object()
.expect("request object")
.clone(),
);
request.use_websocket = true;
request.local_conversation_id = Some("conversation-fingerprint".to_owned());
request.turn_metadata = Some(r#"{"kind":"fingerprint"}"#.to_owned());
request.beta_features = Some("feature-a".to_owned());
request.version = Some("26.908.40834".to_owned());
request.codex_window_id = Some("window-fingerprint".to_owned());
let client = CodexBackendClient::new(
reqwest::Client::builder()
.no_proxy()
.build()
.expect("HTTP client"),
format!("http://{address}/backend-api"),
test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let result = client
.create_response(
&request,
CodexRequestContext {
trace: None,
turn_metadata: request.turn_metadata.as_deref(),
beta_features: request.beta_features.as_deref(),
version: request.version.as_deref(),
codex_window_id: request.codex_window_id.as_deref(),
session_id: Some("session-fingerprint"),
thread_id: Some("thread-fingerprint"),
client_request_id: Some("request-fingerprint"),
..request_context("req_wire_fingerprint", Some("account-fingerprint"))
},
)
.await;
result.unwrap_or_else(|error| panic!("fingerprint WebSocket response: {error}"));
let (opening, websocket_key, frame) = server.await.expect("fingerprint server task");
assert_eq!(
opening.lines().next(),
Some("GET /backend-api/codex/responses HTTP/1.1")
);
// 官方 Codex Core 0.154.0(Desktop 26.908.40834)的原始 opening 抓包顺序。
assert_eq!(
read_header_names(&opening),
vec![
"host",
"connection",
"upgrade",
"sec-websocket-version",
"sec-websocket-key",
"chatgpt-account-id",
"authorization",
"user-agent",
"originator",
"openai-beta",
"version",
"x-codex-beta-features",
"x-client-request-id",
"session-id",
"thread-id",
"x-codex-window-id",
"x-codex-turn-metadata",
"x-codex-routing-hint",
"sec-websocket-extensions",
]
);
assert_eq!(
read_header_value(&opening, "sec-websocket-extensions"),
Some("permessage-deflate; client_max_window_bits")
);
assert_eq!(
(
read_header_value(&opening, "chatgpt-account-id"),
read_header_value(&opening, "authorization"),
read_header_value(&opening, "originator"),
read_header_value(&opening, "version"),
read_header_value(&opening, "openai-beta"),
read_header_value(&opening, "x-client-request-id"),
read_header_value(&opening, "x-codex-routing-hint"),
),
(
Some("account-fingerprint"),
Some("Bearer access-token"),
Some("codex_cli_rs"),
Some("1.2.3"),
Some("responses_websockets=2026-02-06"),
Some("request-fingerprint"),
Some("model=gpt-5.5"),
)
);
assert_eq!(
STANDARD
.decode(websocket_key)
.expect("base64 WebSocket key")
.len(),
16
);
assert_eq!(frame.first_byte, 0b1100_0001, "FIN + RSV1 + text opcode");
assert!(frame.masked, "official client frames are masked");
assert_eq!(frame.mask_len, 4);
let payload = decompress_permessage_deflate(&frame.payload);
let payload: Value = serde_json::from_slice(&payload).expect("response.create JSON");
assert_eq!(payload["type"], "response.create");
assert_eq!(payload["generate"], false);
assert_eq!(
payload
.as_object()
.expect("response.create object")
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
vec![
"type",
"model",
"instructions",
"input",
"tools",
"tool_choice",
"parallel_tool_calls",
"reasoning",
"store",
"stream",
"include",
"prompt_cache_key",
"generate",
"client_metadata",
]
);
}
async fn read_client_frame(stream: &mut TcpStream) -> ClientFrame {
let first_byte = stream.read_u8().await.expect("frame first byte");
let second_byte = stream.read_u8().await.expect("frame second byte");
let payload_len = match second_byte & 0x7f {
len @ 0..=125 => u64::from(len),
126 => u64::from(stream.read_u16().await.expect("16-bit frame length")),
127 => stream.read_u64().await.expect("64-bit frame length"),
_ => unreachable!(),
};
let masked = second_byte & 0x80 != 0;
let mut mask = [0_u8; 4];
if masked {
stream.read_exact(&mut mask).await.expect("frame mask");
}
let mut payload = vec![0_u8; usize::try_from(payload_len).expect("frame length fits usize")];
stream
.read_exact(&mut payload)
.await
.expect("frame payload");
if masked {
for (index, byte) in payload.iter_mut().enumerate() {
*byte ^= mask[index % mask.len()];
}
}
ClientFrame {
first_byte,
masked,
mask_len: if masked { mask.len() } else { 0 },
payload,
}
}
fn decompress_permessage_deflate(payload: &[u8]) -> Vec<u8> {
let mut compressed = Vec::with_capacity(payload.len() + 4);
compressed.extend_from_slice(payload);
compressed.extend_from_slice(&[0x00, 0x00, 0xff, 0xff]);
let mut output = vec![0_u8; 64 * 1024];
let mut decompressor = Decompress::new(false);
decompressor
.decompress(&compressed, &mut output, FlushDecompress::Sync)
.expect("decompress response.create frame");
output.truncate(
usize::try_from(decompressor.total_out()).expect("decompressed length fits usize"),
);
output
}
async fn write_server_text_frame(stream: &mut TcpStream, payload: &str) {
let payload = payload.as_bytes();
let mut frame = Vec::with_capacity(payload.len() + 10);
frame.push(0x81);
match payload.len() {
len @ 0..=125 => frame.push(u8::try_from(len).expect("short frame length")),
len @ 126..=65_535 => {
frame.push(126);
frame.extend_from_slice(
&u16::try_from(len)
.expect("16-bit frame length")
.to_be_bytes(),
);
}
len => {
frame.push(127);
frame.extend_from_slice(
&u64::try_from(len)
.expect("64-bit frame length")
.to_be_bytes(),
);
}
}
frame.extend_from_slice(payload);
stream
.write_all(&frame)
.await
.expect("write completed frame");
}
@@ -5,6 +5,7 @@ use provider_openai::transport::{
};
mod diagnostics;
mod fingerprint;
mod metadata;
fn rate_limit_event(used_percent: u64) -> String {
+1 -1
View File
@@ -13,7 +13,7 @@ RUN npm install --global pnpm@11.7.0 \
COPY frontend ./
RUN pnpm build
FROM rust:1.98-bookworm@sha256:82150a52ec202c1b14d7817e14516c392bb7f5cfebd88f1ed531cb37ebd39922 AS backend-builder
FROM rust:1.98-bookworm@sha256:9a73a5088750b4c95158ab26629c854c3d6fc4b173cb7bc8079ad252d8ed7bfa AS backend-builder
WORKDIR /app/backend
ARG TARGETARCH
+14 -1
View File
@@ -48,6 +48,13 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和
模板不重复列出这些默认项。运行后,Provider 检查官方版本并更新运行时请求画像,
不回写 `config.yaml`;检查失败时继续使用上一份有效画像。版本检查不等于重新核验 TLS。
`openai.wire_profile.location` 可选覆盖请求地区。省略、留空(`location:`)或设为 `null` 时,
透传客户端原有的 Web Search `user_location`、环境日期和时区;客户端未提供的字段也不会补写。
模板显式填写 `US / Ohio / Piketon / America/New_York`,需要透传时清空或删除该配置项。
自定义时完整填写 `country`(两位大写国家代码)、`region`、`city` 和 `timezone`(IANA 时区);修改后重启生效。
它统一 Responses 的 Web Search 地区与带环境标记的日期、时区,不修改普通聊天内容或 epoch 时间戳,
也不替代 `residency` 约束或随官方版本检查变化。
## 启动
```bash
@@ -325,7 +332,13 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr
> 以下命令只适用于同一大版本内的升级,不支持跨大版本在线升级。跨大版本请使用全新的
> `.runtime/` 数据目录重新部署,并重新导入或重新授权 Provider 账号与客户端 Key。
Docker 安装从安装目录拉取发布镜像并重建应用容器:
每个 Release 独立提供 `config.example.yaml`、默认镜像固定到该版本的 `compose.yaml` 和校验和;
各平台归档也包含 `deploy/config.example.yaml`。配置模板来自构建该版本的同一提交。
使用二进制归档手动部署时,将模板中的 `api.asset_directory` 改为 `../web/dist`,指向归档内的静态资源。
升级时先阅读目标版本说明,下载同一 Release 的部署附件,对比模板并合并必要配置,保留已有凭据
和 Compose 自定义项。不要用模板覆盖 `config.yaml`,也不要从 `main` 下载模板搭配旧镜像。
更新部署文件后,从安装目录拉取目标版本镜像并重建应用容器:
```bash
docker compose -f deploy/compose.yaml pull codex-proxy-rs
-2
View File
@@ -86,8 +86,6 @@ services:
redis:
condition: service_healthy
pids_limit: 256
cpus: '2.0'
mem_limit: 1g
ports:
- '127.0.0.1:8080:8080'
volumes:
+3
View File
@@ -74,6 +74,9 @@ openai:
terminal: 'unknown'
# 部署选择的 US residency;省略或 null 时使用官方无地域约束的默认分支。
residency: us
# 请求地区覆盖;省略或留空(location: / location: null)时透传客户端原值。
# 配置时完整填写四个字段;时区同时用于环境日期,不改变 epoch 时间戳。
location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }
verified_at: '2026-09-06T11:26:12.084+08:00'
xai:
+66 -10
View File
@@ -228,6 +228,14 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
上游结构化错误的 message/code/type 会透传给客户端,其中内嵌的账号指纹 UUID 已脱敏。模型映射是
全局精确映射,未命中时模型名原样交给候选 Provider;分组只限定账号集合,不参与模型改名。
OpenAI 明确返回 `server_is_overloaded`、`slow_down` 或模型容量不足错误时,代理在允许安全重放且
尚未交付输出的前提下,先做最多 3 次同账号指数退避,再通过现有调度换号。默认间隔从 500ms 开始,
上游 `Retry-After` 参与退避计算,单次等待不超过 8 秒;重试同时受请求总尝试次数和截止时间约束。
容量不足不扣 Smart 账号健康分,不触发 Provider 全局熔断,也不作为账号额度耗尽写入冷却状态。
最终交付的上游错误仍按上述透明边界保留原始状态码、错误码和正文。
明确额度耗尽继续走现有账号隔离与安全换号流程,
包括 WebSocket 握手返回的 429;不会因其长 `Retry-After` 而转入同账号传输恢复等待。
## 4. 管理员认证
| 方法 | 路由 | 请求 | 说明 |
@@ -256,7 +264,7 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 |
| `GET` | `/api/admin/accounts/quota-forecast` | `accountId` | 按需读取周/月容量预测、源窗口剩余估算与采样依据,不刷新上游额度 |
| `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 访问 Provider 并刷新额度,同时同步额度所属状态 |
| `GET` | `/api/admin/accounts/profile-statistics` | `accountId` | 实时查询 OpenAI/Codex 官方个人资料中的累计活动与使用洞察 |
| `GET` | `/api/admin/accounts/personal-info` | `accountId` | 按需汇聚 OpenAI/Codex 官方个人资料、累计活动与订阅信息,不更新额度或 credential |
| `GET` | `/api/admin/accounts/reset-credits` | `accountId` | 查询 OpenAI 上游主动额度重置卡,不读取本地库存 |
| `POST` | `/api/admin/accounts/reset-credits` | `{ accountId, creditId?, redeemRequestId }` | 使用 UUIDv4 幂等键消费一张 OpenAI 上游重置卡 |
| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 |
@@ -490,7 +498,10 @@ OAuth start 使用:
`observedAt` 表示额度观测时间,与本次查询的 `generatedAt` 不同。
- `unavailableReason`:不能估算时的说明,正常为 `null`。有效进度少于 5 个百分点不预测;
`lowSample` 表示有效进度不足 10 个百分点,或增量采样少于 2 个完整段,仅是质量提示,不承诺精度。
`incompleteCost` 仅抑制费用预测;`incompleteTokens` 仅抑制 Token 预测。未知值不按确定的零消耗外推。
`incompleteCost` / `incompleteTokens` 仅提示费用 / Token 记录存在缺口,不阻断已有数据的预测。
Token 使用已记录的正数用量,费用使用已知的有效 USD 金额;对应数据完全缺失时该项为 `null`,
两项均不可估算时才返回无可用数据的说明。明确记录的零 USD 仍可估算为零,未知费用不能当成零。
不按请求数量补齐未知消耗;漏记、失败或缺少计量可能使估算偏低,不代表完整账单或真实剩余额度。
内部采样优先采用近期分段,条件不足时才使用窗口累计估算。同一额度段内,以历史额度和截至相应
完成时间的累计用量建立基线,每累计至少 5 个百分点形成一段,使用最近 3 个完整段及未满一段的尾部。
@@ -511,10 +522,26 @@ OpenAI 复用已有限流协议解析器匹配额度桶、槽位、时长和明
上游消耗和模型组合变化仍可能造成误差;等价 USD 费用不是官方订阅价格或固定额度承诺,
30 天折算也不是自然月额度。记录覆盖率不等于预测准确率,不输出未经校准的置信区间。
### OpenAI 官方个人资料统计
### OpenAI 个人信息
`GET /api/admin/accounts/profile-statistics?accountId=...` 仅支持 OpenAI/Codex OAuth 账号。每次查询直接
访问官方个人资料端点,不读取本地 usage/billing 记录,也不缓存或估算统计结果。响应 `data` 包含:
`GET /api/admin/accounts/personal-info?accountId=...` 需要管理员会话,当前由 OpenAI/Codex OAuth
账号提供。后端并发读取资料统计与订阅,一次返回;每次请求均重新查询,不自动重试或
刷新 credential,不读取本地 usage/billing 记录,也不缓存或估算统计结果。
响应 `data` 包含:
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `profile` | object 或 null | 官方个人资料、累计统计与活动洞察;查询失败为 null |
| `profileError` | string 或 null | 资料查询失败时的安全错误提示;成功为 null |
| `subscription` | object 或 null | 当前绑定账号的订阅周期;无可用周期或查询失败为 null |
两部分的查询结果独立:资料失败时仍返回可用订阅,订阅失败时仍返回资料。账号不存在、查询参数不合法或
无管理员会话时,仍返回标准错误;请求期间账号身份或 credential revision 改变时拒绝整份结果。
#### 官方资料与累计统计
`profile` 包含:
- `displayName`、`username`、`imageUrl`:官方账号资料;
- `summary`:累计文本 Token、单日峰值 Token、最长任务时长、当前连续天数和最长连续天数;
@@ -523,7 +550,27 @@ OpenAI 复用已有限流协议解析器匹配额度桶、槽位、时长和明
以及插件与 Skill 调用排行。
官方未返回的字段保持 `null`,不使用本地数据补齐;`hasStatsError: true` 表示账号资料可用,但官方统计
部分不可用。access token 已过期或官方返回 401 时,接口要求先刷新 credential 或重新授权。
部分不可用。access token 已过期或官方返回 401 时,通过 `profileError` 提示先刷新 credential 或重新授权。
#### 订阅信息
后端使用当前凭据、绑定的上游账号 ID 和账号出站代理访问 `/backend-api/subscriptions?account_id=...`,
不枚举其他账号,不返回上游订阅 ID 或原始响应。
`subscription` 为 `null`(未获得可用订阅周期),或包含以下字段:
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `startsAt` | RFC 3339 字符串或 null | 上游本期开始时间 |
| `expiresAt` | RFC 3339 字符串 | 上游本期结束时间,不代表自动续费账号最终失效 |
| `willRenew` | boolean 或 null | 自动续费状态;未知不推断为 false |
| `billingPeriod` | string 或 null | 上游计费周期标识 |
| `billingCurrency` | string 或 null | 上游计费币种 |
| `observedAt` | RFC 3339 字符串 | 本次查询时间 |
订阅不写入额度快照或数据库,不参与账号状态或调度;单次上游查询最多 5 秒、响应最多 64 KiB,不重试。
上游失败或未提供有效周期时返回未知,不据此标记免费、过期或禁用;请求期间账号身份或 credential
revision 变化时丢弃结果。
### OpenAI 主动额度重置卡
@@ -845,10 +892,19 @@ Dashboard 的 `accountUsage[]` 由后端提供 `usageWindow`、`metricLabel`、`
和本地用量由 Provider/Admin 投影。前端不得从套餐缺失推断免费套餐,也不得从显示时舍入的百分比推断
触顶。滚动窗口使用相应时间范围的本地用量,独立于 Dashboard 的今日统计范围。
OpenAI 的 `serviceTier` 只接受上游响应生命周期事件确认的实际 `response.service_tier`;请求里的
期望档位只保留在 request summary,不能冒充响应事实。计费展示把 `priority`/`fast` 映射为 `Fast`,
`flex` 映射为 `Flex`,缺失或 `default` 映射为 `Default`;未知非空值原样展示。Fast 优先使用模型的
priority 价格,缺少专用价格时回退到标准价格的 `2.00x`;Flex 为 `0.50x`,Default 为 `1.00x`。
OpenAI Responses 用量记录的 `serviceTier` 与本地费用估算统一采用 Provider 最终发给上游的请求
`service_tier`,不使用响应档位覆盖或回退。例如发送 `priority`、响应回显 `default` 时,仍显示
`Fast` 并按 Priority 价格估算。未发送档位时,`serviceTier` 保持缺失,展示与估算按标准档处理。
计费展示把 `priority`/`fast` 映射为 `Fast`,`flex` 映射为 `Flex`,缺失或 `default`/`standard`
映射为 `Standard`;其他非空值以首字母大写展示。各模型、档位及长上下文区间使用明确登记的价格;缺少对应
价格时不估算(包括 `auto` 和未知档位),不以固定倍数兜底。展示的倍率由所选档位与标准档位费用之比
计算,托管工具调用费不随 Token 档位倍增。
Provider metadata 分别保留 `requestedServiceTier` 与 `upstreamServiceTier` 供诊断;发送给客户端的
原始 `response.service_tier` 不变。用量中的 Fast 仅表示发送档位,不能证明上游实际加速,本地费用
估算也不能代替官方账单。该口径仅作用于新记录,不回填历史档位或重算已存储费用。
本地计价规则只保留尚在服务的型号;已过官方关闭日期的型号不再新增本地估价。清理计价规则不删除或
重新计算已存储的历史费用;缺少当前计价规则时,历史总额仍保留,但无法再据此补充费用拆分。
## 11. 版本、更新与重启
+16 -4
View File
@@ -170,6 +170,8 @@ Client Key 鉴权完成后,API adapter 从有界请求头识别 Codex Desktop/
- Provider 的一次 `execute` 只选择一个 credential 并返回一个冷流;换号、重试和 fallback 由 Core 决定。
- `not_sent`、`sent`、`ambiguous` 是单调的上游发送边界;结果不明确时不能假定上游未收到请求。
- downstream commit 是不可撤回的交付承诺。commit 后禁止换号、重试和 fallback。
- Provider 可将明确容量拒绝标记为有界同账号退避,Core 在既有安全重放边界内执行,按账号维护请求内
预算,耗尽后复用普通换号路径。该退避消耗总路由预算,与 WS 传输恢复、OAuth 刷新及账号额度冷却分开。
- 跨 Provider 只在账号范围和能力都允许,且请求尚未到达上游或已被证明可安全重放时发生。
- 可恢复观测写入失败不能替换已经确定的客户端协议结果。
@@ -268,7 +270,8 @@ Vue 普通管理请求的错误提示由 `api/request.ts` 响应拦截器统一
规范化异常保留 `status`、`code`、`requestId` 与 `kind`。页面和 `useAsyncAction` 不重复弹出接口错误;
查询可以保留失败状态与重试入口,本地校验、文件操作、SSE 诊断和成功响应中的业务结果仍归各自 owner。
API 模块通过统一的 `RequestOptions` 传递 `signal`、`timeout` 和 `silent`。取消或已被新查询取代的请求
API 模块按 `url`、`method`、`data`(POST)或 `params: data`(GET)排列请求配置;
仅在实际调用方需要时提供 `RequestOptions`(`signal`、`timeout`、`silent`),并放在请求配置末尾。取消或已被新查询取代的请求
不弹提示;后台轮询、重启探测等显式使用 `silent`,它只关闭提示,不吞异常,也不跳过会话失效处理。
批量操作的部分成功汇总、不可逆操作的结果未知等必要业务处理先将对应请求静默,再由业务 owner 提供
一次有上下文的反馈;不得为普通失败重新维护一套消息或业务码映射。
@@ -383,6 +386,12 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
- quota refresh、正常推理返回的 rate-limit headers 和后台健康任务汇入同一额度事实;套餐只用于展示与
目录 cache 隔离,不创建套餐专属状态机。
OpenAI 订阅周期属于按需个人信息,不是额度事实。Admin 账号用例通过现有 Provider 管理端口并发读取
个人资料统计与订阅,汇聚为一次只读响应;Provider 继续拥有各自的认证、出站代理与上游协议处理。
订阅不由 quota、导入或后台任务触发,不持久化。汇聚结果返回前核对账号身份与 credential revision,
避免重新授权期间展示混合身份信息;单项失败不丢弃另一项可用结果。前端只在打开「个人信息」或手动
刷新信息时请求,关闭后取消等待,不维护两套请求状态。
主动额度重置是 OpenAI Provider 的不可逆上游操作:列表查询和消费都直接使用当前 Desktop 请求画像;
卡片不写 PostgreSQL/Redis。消费请求携带调用方生成的 UUIDv4 幂等键,同一账号的消费在进程内串行;
发送结果不明确时必须复用原键。确认成功后管理端再显式刷新卡片与 quota,不能直接改本地重置时间。
@@ -392,8 +401,9 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
账号容量预测属于 Admin 的只读派生规则,不参与 quota 权威状态、调度或金额结算。Store 通过专用采样端口
在同一 SQL 快照内返回截至观测时间的累计数值及有界历史 Provider 文档;文档仅由具体 Provider 复用协议
解析器解释,Admin 按中立的额度事实选择近期进度段。该采样不改变全站完整交付用量口径,不创建第二份
持久化额度状态。未知 Token/美元费用分别抑制对应预测;历史请求完成时间只是额度时间的近似,不承诺
严格扣额归因或预测准确率。公开字段与采样门槛见 [周/月额度预测](api.md#周月额度预测)。
持久化额度状态。部分 Token/美元费用缺失仅提示精度限制,继续按已记录数值估算;对应数值完全不可用时
才不返回该项预测,不按请求数量补齐未知消耗。历史请求完成时间只是额度时间的近似,不承诺严格扣额归因
或预测准确率。公开字段与采样门槛见 [周/月额度预测](api.md#周月额度预测)。
请求观测通过有界进程内队列异步投影到 PostgreSQL。队列满、Store 暂不可用或进程退出超时时,允许丢失
可恢复观测并累计指标,但不允许改变客户端响应。Usage 详情中的 attempt 因此是 best-effort,并通过
@@ -420,7 +430,9 @@ observation、调用 metadata;最终失败的关联头不与 opening 身份混
`generate: false` 将连接与上下文准备归类为 `prewarm`,不信任客户端单独声明的同名 metadata。
Store 的共享用量口径排除这些预热记录,账号用量与额度预测复用同一规则;原始请求审计、响应额度
观测和费用事实仍保留,不将未知费用改写为零,也不影响 Client Key 结算账本。
实际 `service_tier` 只接受上游响应事件确认,不能用请求期望值替代。
OpenAI Responses 的统计档位与本地费用估算统一使用 Provider 最终发给上游的请求 `service_tier`,
不由响应回显覆盖;未发送档位时保留缺失值,展示与估算按标准档处理。上游响应档位独立保留在
Provider metadata 的 `upstreamServiceTier`,不改写客户端收到的响应,也不据此断言实际加速效果。
Worker 由各 Bundle 贡献、由 Host 统一监督:
+5 -5
View File
@@ -19,15 +19,15 @@
"@boxicons/vue": "1.0.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.41.0",
"@lucide/vue": "^1.45.0",
"@vueuse/core": "^14.4.0",
"axios": "^1.20.0",
"dayjs": "^1.11.23",
"dompurify": "^3.4.14",
"dompurify": "^3.4.15",
"echarts": "^6.1.0",
"es-toolkit": "^1.52.0",
"gsap": "^3.15.0",
"marked": "^18.0.11",
"marked": "^18.0.13",
"pinia": "^4.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.42",
@@ -36,7 +36,7 @@
"devDependencies": {
"@antfu/eslint-config": "^9.5.1",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.4.1",
"@types/node": "^26.5.1",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.10.0",
@@ -44,7 +44,7 @@
"eslint-plugin-vuejs-accessibility": "^2.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.2.2",
"vite": "^8.3.0",
"vue-tsc": "^3.3.11"
}
}
+119 -135
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^5.3.0
version: 5.3.0
'@lucide/vue':
specifier: ^1.41.0
version: 1.41.0(vue@3.5.42(typescript@6.0.3))
specifier: ^1.45.0
version: 1.45.0(vue@3.5.42(typescript@6.0.3))
'@vueuse/core':
specifier: ^14.4.0
version: 14.4.0(vue@3.5.42(typescript@6.0.3))
@@ -36,8 +36,8 @@ importers:
specifier: ^1.11.23
version: 1.11.23
dompurify:
specifier: ^3.4.14
version: 3.4.14
specifier: ^3.4.15
version: 3.4.15
echarts:
specifier: ^6.1.0
version: 6.1.0
@@ -48,8 +48,8 @@ importers:
specifier: ^3.15.0
version: 3.15.0
marked:
specifier: ^18.0.11
version: 18.0.11
specifier: ^18.0.13
version: 18.0.13
pinia:
specifier: ^4.0.3
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3))
@@ -61,20 +61,20 @@ importers:
version: 3.5.42(typescript@6.0.3)
vue-router:
specifier: ^5.3.1
version: 5.3.1(@vue/compiler-sfc@3.5.42)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3)))(rolldown@1.2.5)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))
version: 5.3.1(@vue/compiler-sfc@3.5.42)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3)))(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))
devDependencies:
'@antfu/eslint-config':
specifier: ^9.5.1
version: 9.5.1(@typescript-eslint/typescript-estree@8.69.0(typescript@6.0.3))(@typescript-eslint/utils@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.42)(eslint-plugin-format@2.0.1(eslint@10.10.0(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.10.0(jiti@2.7.0))(globals@17.12.0))(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)
'@tailwindcss/vite':
specifier: ^4.3.3
version: 4.3.3(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))
version: 4.3.3(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))
'@types/node':
specifier: ^26.4.1
version: 26.4.1
specifier: ^26.5.1
version: 26.5.1
'@vitejs/plugin-vue':
specifier: ^6.0.8
version: 6.0.8(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))
version: 6.0.8(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))
'@vue/tsconfig':
specifier: ^0.9.1
version: 0.9.1(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3))
@@ -94,8 +94,8 @@ importers:
specifier: ^6.0.3
version: 6.0.3
vite:
specifier: ^8.2.2
version: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)
specifier: ^8.3.0
version: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0)
vue-tsc:
specifier: ^3.3.11
version: 3.3.11(typescript@6.0.3)
@@ -358,8 +358,8 @@ packages:
'@keyv/serialize@1.1.1':
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
'@lucide/vue@1.41.0':
resolution: {integrity: sha512-ciHtudHTeqMDzJKPI36AGboypCk8Jz3qFin3oU0nJfPc5MwXmqLXIEQvXE+yI6Sz5SnxVqREgm8rVN4KdDlnVQ==}
'@lucide/vue@1.45.0':
resolution: {integrity: sha512-tbMlusqK6brViI1xj8nsXAgNDyJOvNeOdOQlBI0hOWCAbnBrPRlanoQ6EygDEMv3dqwXfCZPb4a0/ImtI5KAmQ==}
peerDependencies:
vue: '>=3.0.1'
@@ -367,8 +367,8 @@ packages:
resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@oxc-project/types@0.146.0':
resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==}
'@oxc-project/types@0.149.0':
resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==}
'@oxfmt/binding-android-arm-eabi@0.35.0':
resolution: {integrity: sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA==}
@@ -496,98 +496,98 @@ packages:
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
engines: {node: ^14.18.0 || >=16.0.0}
'@rolldown/binding-android-arm-eabi@1.2.5':
resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==}
'@rolldown/binding-android-arm-eabi@1.2.8':
resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@rolldown/binding-android-arm64@1.2.5':
resolution: {integrity: sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==}
'@rolldown/binding-android-arm64@1.2.8':
resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.2.5':
resolution: {integrity: sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==}
'@rolldown/binding-darwin-arm64@1.2.8':
resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.2.5':
resolution: {integrity: sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==}
'@rolldown/binding-darwin-x64@1.2.8':
resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.2.5':
resolution: {integrity: sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==}
'@rolldown/binding-freebsd-x64@1.2.8':
resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.2.5':
resolution: {integrity: sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==}
'@rolldown/binding-linux-arm-gnueabihf@1.2.8':
resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.2.5':
resolution: {integrity: sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==}
'@rolldown/binding-linux-arm64-gnu@1.2.8':
resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.2.5':
resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==}
'@rolldown/binding-linux-arm64-musl@1.2.8':
resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.2.5':
resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==}
'@rolldown/binding-linux-ppc64-gnu@1.2.8':
resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.2.5':
resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==}
'@rolldown/binding-linux-s390x-gnu@1.2.8':
resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.2.5':
resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==}
'@rolldown/binding-linux-x64-gnu@1.2.8':
resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.2.5':
resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==}
'@rolldown/binding-linux-x64-musl@1.2.8':
resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.2.5':
resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==}
'@rolldown/binding-openharmony-arm64@1.2.8':
resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-win32-arm64-msvc@1.2.5':
resolution: {integrity: sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==}
'@rolldown/binding-win32-arm64-msvc@1.2.8':
resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.2.5':
resolution: {integrity: sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==}
'@rolldown/binding-win32-x64-msvc@1.2.8':
resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -723,8 +723,8 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@26.4.1':
resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==}
'@types/node@26.5.1':
resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -1092,8 +1092,8 @@ packages:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dompurify@3.4.14:
resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==}
dompurify@3.4.15:
resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
@@ -1780,8 +1780,8 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@18.0.11:
resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==}
marked@18.0.13:
resolution: {integrity: sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==}
engines: {node: '>= 20'}
hasBin: true
@@ -1945,8 +1945,8 @@ packages:
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
nanoid@3.3.19:
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -2031,10 +2031,6 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.5:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
@@ -2080,10 +2076,6 @@ packages:
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'}
postcss@8.5.26:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
@@ -2143,8 +2135,8 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
rolldown@1.2.5:
resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==}
rolldown@1.2.8:
resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -2252,8 +2244,8 @@ packages:
ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
undici-types@8.9.0:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@@ -2323,13 +2315,13 @@ packages:
resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==}
engines: {node: '>=18.12.0'}
vite@8.2.2:
resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==}
vite@8.3.0:
resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.4.0 || ^0.5.0
'@vitejs/devtools': ^0.7.1
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
@@ -2698,13 +2690,13 @@ snapshots:
'@keyv/serialize@1.1.1': {}
'@lucide/vue@1.41.0(vue@3.5.42(typescript@6.0.3))':
'@lucide/vue@1.45.0(vue@3.5.42(typescript@6.0.3))':
dependencies:
vue: 3.5.42(typescript@6.0.3)
'@ota-meshi/ast-token-store@0.3.0': {}
'@oxc-project/types@0.146.0': {}
'@oxc-project/types@0.149.0': {}
'@oxfmt/binding-android-arm-eabi@0.35.0':
optional: true
@@ -2765,49 +2757,49 @@ snapshots:
'@pkgr/core@0.3.6': {}
'@rolldown/binding-android-arm-eabi@1.2.5':
'@rolldown/binding-android-arm-eabi@1.2.8':
optional: true
'@rolldown/binding-android-arm64@1.2.5':
'@rolldown/binding-android-arm64@1.2.8':
optional: true
'@rolldown/binding-darwin-arm64@1.2.5':
'@rolldown/binding-darwin-arm64@1.2.8':
optional: true
'@rolldown/binding-darwin-x64@1.2.5':
'@rolldown/binding-darwin-x64@1.2.8':
optional: true
'@rolldown/binding-freebsd-x64@1.2.5':
'@rolldown/binding-freebsd-x64@1.2.8':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.2.5':
'@rolldown/binding-linux-arm-gnueabihf@1.2.8':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.2.5':
'@rolldown/binding-linux-arm64-gnu@1.2.8':
optional: true
'@rolldown/binding-linux-arm64-musl@1.2.5':
'@rolldown/binding-linux-arm64-musl@1.2.8':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.2.5':
'@rolldown/binding-linux-ppc64-gnu@1.2.8':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.2.5':
'@rolldown/binding-linux-s390x-gnu@1.2.8':
optional: true
'@rolldown/binding-linux-x64-gnu@1.2.5':
'@rolldown/binding-linux-x64-gnu@1.2.8':
optional: true
'@rolldown/binding-linux-x64-musl@1.2.5':
'@rolldown/binding-linux-x64-musl@1.2.8':
optional: true
'@rolldown/binding-openharmony-arm64@1.2.5':
'@rolldown/binding-openharmony-arm64@1.2.8':
optional: true
'@rolldown/binding-win32-arm64-msvc@1.2.5':
'@rolldown/binding-win32-arm64-msvc@1.2.8':
optional: true
'@rolldown/binding-win32-x64-msvc@1.2.5':
'@rolldown/binding-win32-x64-msvc@1.2.8':
optional: true
'@rolldown/pluginutils@1.0.1': {}
@@ -2885,12 +2877,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
'@tailwindcss/vite@4.3.3(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))':
'@tailwindcss/vite@4.3.3(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))':
dependencies:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)
vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0)
'@types/debug@4.1.13':
dependencies:
@@ -2914,9 +2906,9 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node@26.4.1':
'@types/node@26.5.1':
dependencies:
undici-types: 8.3.0
undici-types: 8.9.0
'@types/trusted-types@2.0.7':
optional: true
@@ -3016,10 +3008,10 @@ snapshots:
'@typescript-eslint/types': 8.69.0
eslint-visitor-keys: 5.0.1
'@vitejs/plugin-vue@6.0.8(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))':
'@vitejs/plugin-vue@6.0.8(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)
vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0)
vue: 3.5.42(typescript@6.0.3)
'@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)':
@@ -3311,7 +3303,7 @@ snapshots:
diff-sequences@29.6.3: {}
dompurify@3.4.14:
dompurify@3.4.15:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -3850,7 +3842,7 @@ snapshots:
jsdoc-type-pratt-parser@9.2.1:
dependencies:
'@types/estree': 1.0.9
'@types/node': 26.4.1
'@types/node': 26.5.1
jsesc@3.1.0: {}
@@ -4003,7 +3995,7 @@ snapshots:
markdown-table@3.0.4: {}
marked@18.0.11: {}
marked@18.0.13: {}
math-intrinsics@1.1.0: {}
@@ -4365,7 +4357,7 @@ snapshots:
muggle-string@0.4.1: {}
nanoid@3.3.18: {}
nanoid@3.3.19: {}
natural-compare@1.4.0: {}
@@ -4452,8 +4444,6 @@ snapshots:
picocolors@1.1.1: {}
picomatch@4.0.5: {}
picomatch@4.0.7: {}
pinia-plugin-persistedstate@4.7.1(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3))):
@@ -4493,15 +4483,9 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss@8.5.26:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.28:
dependencies:
nanoid: 3.3.18
nanoid: 3.3.19
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -4544,26 +4528,26 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
rolldown@1.2.5:
rolldown@1.2.8:
dependencies:
'@oxc-project/types': 0.146.0
'@oxc-project/types': 0.149.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm-eabi': 1.2.5
'@rolldown/binding-android-arm64': 1.2.5
'@rolldown/binding-darwin-arm64': 1.2.5
'@rolldown/binding-darwin-x64': 1.2.5
'@rolldown/binding-freebsd-x64': 1.2.5
'@rolldown/binding-linux-arm-gnueabihf': 1.2.5
'@rolldown/binding-linux-arm64-gnu': 1.2.5
'@rolldown/binding-linux-arm64-musl': 1.2.5
'@rolldown/binding-linux-ppc64-gnu': 1.2.5
'@rolldown/binding-linux-s390x-gnu': 1.2.5
'@rolldown/binding-linux-x64-gnu': 1.2.5
'@rolldown/binding-linux-x64-musl': 1.2.5
'@rolldown/binding-openharmony-arm64': 1.2.5
'@rolldown/binding-win32-arm64-msvc': 1.2.5
'@rolldown/binding-win32-x64-msvc': 1.2.5
'@rolldown/binding-android-arm-eabi': 1.2.8
'@rolldown/binding-android-arm64': 1.2.8
'@rolldown/binding-darwin-arm64': 1.2.8
'@rolldown/binding-darwin-x64': 1.2.8
'@rolldown/binding-freebsd-x64': 1.2.8
'@rolldown/binding-linux-arm-gnueabihf': 1.2.8
'@rolldown/binding-linux-arm64-gnu': 1.2.8
'@rolldown/binding-linux-arm64-musl': 1.2.8
'@rolldown/binding-linux-ppc64-gnu': 1.2.8
'@rolldown/binding-linux-s390x-gnu': 1.2.8
'@rolldown/binding-linux-x64-gnu': 1.2.8
'@rolldown/binding-linux-x64-musl': 1.2.8
'@rolldown/binding-openharmony-arm64': 1.2.8
'@rolldown/binding-win32-arm64-msvc': 1.2.8
'@rolldown/binding-win32-x64-msvc': 1.2.8
scslre@0.3.0:
dependencies:
@@ -4648,7 +4632,7 @@ snapshots:
ufo@1.6.4: {}
undici-types@8.3.0: {}
undici-types@8.9.0: {}
unist-util-is@6.0.1:
dependencies:
@@ -4679,14 +4663,14 @@ snapshots:
pathe: 2.0.3
picomatch: 4.0.7
unplugin@3.3.0(rolldown@1.2.5)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)):
unplugin@3.3.0(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0)):
dependencies:
'@jridgewell/remapping': 2.3.5
picomatch: 4.0.7
webpack-virtual-modules: 0.6.2
optionalDependencies:
rolldown: 1.2.5
vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)
rolldown: 1.2.8
vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0)
update-browserslist-db@1.3.2(browserslist@4.28.9):
dependencies:
@@ -4702,15 +4686,15 @@ snapshots:
verkit@0.3.2: {}
vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0):
vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
postcss: 8.5.26
rolldown: 1.2.5
picomatch: 4.0.7
postcss: 8.5.28
rolldown: 1.2.8
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.4.1
'@types/node': 26.5.1
fsevents: 2.3.3
jiti: 2.7.0
yaml: 2.9.0
@@ -4729,7 +4713,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-router@5.3.1(@vue/compiler-sfc@3.5.42)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3)))(rolldown@1.2.5)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3)):
vue-router@5.3.1(@vue/compiler-sfc@3.5.42)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3)))(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3)):
dependencies:
'@vue-macros/common': 3.1.4(vue@3.5.42(typescript@6.0.3))
'@vue/devtools-api': 8.2.1
@@ -4745,13 +4729,13 @@ snapshots:
picomatch: 4.0.7
scule: 1.3.0
tinyglobby: 0.2.17
unplugin: 3.3.0(rolldown@1.2.5)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))
unplugin: 3.3.0(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))
unplugin-utils: 0.3.2
vue: 3.5.42(typescript@6.0.3)
optionalDependencies:
'@vue/compiler-sfc': 3.5.42
pinia: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3))
vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)
vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0)
transitivePeerDependencies:
- '@farmfe/core'
- '@rspack/core'
+4 -8
View File
@@ -88,39 +88,35 @@ export function getAccountGroups(data: AccountGroupListParams, options: RequestO
})
}
export function createAccountGroup(data: AccountGroupCreateParam, options: RequestOptions = {}) {
export function createAccountGroup(data: AccountGroupCreateParam) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/create',
method: 'POST',
data,
...options,
})
}
export function updateAccountGroup(data: AccountGroupUpdateParam, options: RequestOptions = {}) {
export function updateAccountGroup(data: AccountGroupUpdateParam) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/update',
method: 'POST',
data,
...options,
})
}
export function enableAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) {
export function enableAccountGroup(data: AccountGroupIdParam) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/enable',
method: 'POST',
data,
...options,
})
}
export function disableAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) {
export function disableAccountGroup(data: AccountGroupIdParam) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/disable',
method: 'POST',
data,
...options,
})
}
+25 -17
View File
@@ -242,6 +242,15 @@ export interface AccountProfileActivityInsights {
invocations: AccountProfileInvocation[] | null
}
export interface AccountSubscription {
startsAt: string | null
expiresAt: string
willRenew: boolean | null
billingPeriod: string | null
billingCurrency: string | null
observedAt: string
}
export interface AccountProfileStatisticsResponse {
displayName: string | null
username: string | null
@@ -252,6 +261,12 @@ export interface AccountProfileStatisticsResponse {
activityInsights: AccountProfileActivityInsights
}
export interface AccountPersonalInfoResponse {
profile: AccountProfileStatisticsResponse | null
profileError: string | null
subscription: AccountSubscription | null
}
export interface AccountResetCredit {
id: string
status: string | null
@@ -393,36 +408,33 @@ export function getAccounts(data: AccountListParams, options: RequestOptions = {
})
}
export function exportAccounts(data: AccountExportParam, options: RequestOptions = {}) {
export function exportAccounts(data: AccountExportParam) {
return request<unknown>({
url: '/api/admin/accounts/export',
method: 'GET',
params: data,
...options,
})
}
export function refreshAccount(data: AccountIdParam, options: RequestOptions = {}) {
export function refreshAccount(data: AccountIdParam) {
return request<AccountRefreshResponse>({
url: '/api/admin/accounts/refresh',
method: 'POST',
data,
...options,
})
}
export function recoverAccount(data: AccountIdParam, options: RequestOptions = {}) {
export function recoverAccount(data: AccountIdParam) {
return request<AccountRefreshResponse>({
url: '/api/admin/accounts/recover',
method: 'POST',
data,
...options,
})
}
export function getAccountProfileStatistics(data: AccountIdParam, options: RequestOptions = {}) {
return request<AccountProfileStatisticsResponse>({
url: '/api/admin/accounts/profile-statistics',
export function getAccountPersonalInfo(data: AccountIdParam, options: RequestOptions = {}) {
return request<AccountPersonalInfoResponse>({
url: '/api/admin/accounts/personal-info',
method: 'GET',
params: data,
...options,
@@ -507,21 +519,19 @@ export function importAccounts(data: AccountImportParam, options: RequestOptions
})
}
export function updateAccount(data: AccountUpdateParam, options: RequestOptions = {}) {
export function updateAccount(data: AccountUpdateParam) {
return request<AccountUpdateResponse>({
url: '/api/admin/accounts/update',
method: 'POST',
data,
...options,
})
}
export function batchUpdateAccounts(data: AccountBatchUpdateParam, options: RequestOptions = {}) {
export function batchUpdateAccounts(data: AccountBatchUpdateParam) {
return request<AccountBatchUpdateResponse>({
url: '/api/admin/accounts/batch-update',
method: 'POST',
data,
...options,
})
}
@@ -534,20 +544,18 @@ export function deleteAccounts(data: AccountDeleteParams, options: RequestOption
})
}
export function startAccountOAuth(data: AccountOAuthStartParam, options: RequestOptions = {}) {
export function startAccountOAuth(data: AccountOAuthStartParam) {
return request<AccountOAuthStartResponse>({
url: '/api/admin/accounts/oauth/start',
method: 'POST',
data,
...options,
})
}
export function completeAccountOAuth(data: AccountOAuthCompleteParam, options: RequestOptions = {}) {
export function completeAccountOAuth(data: AccountOAuthCompleteParam) {
return request<AccountOAuthCompleteResponse>({
url: '/api/admin/accounts/oauth/complete',
method: 'POST',
data,
...options,
})
}
+6 -12
View File
@@ -87,56 +87,50 @@ export function getApiKeys(data: ApiKeyListParams, options: RequestOptions = {})
})
}
export function createApiKey(data: ApiKeyCreateParam, options: RequestOptions = {}) {
export function createApiKey(data: ApiKeyCreateParam) {
return request<ApiKeyCreateResponse>({
url: '/api/admin/client-keys/create',
method: 'POST',
data,
...options,
})
}
export function updateApiKey(data: ApiKeyUpdateParam, options: RequestOptions = {}) {
export function updateApiKey(data: ApiKeyUpdateParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/update',
method: 'POST',
data,
...options,
})
}
export function revealApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
export function revealApiKey(data: ApiKeyIdParam) {
return request<ApiKeyRevealResponse>({
url: '/api/admin/client-keys/reveal',
method: 'GET',
params: data,
...options,
})
}
export function deleteApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
export function deleteApiKey(data: ApiKeyIdParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/delete',
method: 'POST',
data,
...options,
})
}
export function disableApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
export function disableApiKey(data: ApiKeyIdParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/disable',
method: 'POST',
data,
...options,
})
}
export function enableApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
export function enableApiKey(data: ApiKeyIdParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/enable',
method: 'POST',
data,
...options,
})
}
+1 -2
View File
@@ -18,12 +18,11 @@ interface LoginParam {
password: string
}
export function login(data: LoginParam, options: RequestOptions = {}) {
export function login(data: LoginParam) {
return request<LoginResponse>({
url: '/api/admin/auth/login',
method: 'POST',
data,
...options,
})
}
+11 -18
View File
@@ -96,71 +96,64 @@ export interface DownloadUrlResult {
expiresInSeconds: number
}
export function getBackupSettings(options: RequestOptions = {}) {
export function getBackupSettings() {
return request<BackupSettingsView>({
url: '/api/admin/settings/backups',
method: 'GET',
...options,
})
}
export function updateBackupStorage(data: UpdateBackupStoragePayload, options: RequestOptions = {}) {
export function updateBackupStorage(data: UpdateBackupStoragePayload) {
return request<BackupSettingsView>({
url: '/api/admin/settings/backups/storage/update',
method: 'POST',
data,
...options,
})
}
export function testBackupStorage(options: RequestOptions = {}) {
export function testBackupStorage() {
return request<ConnectionTestResult>({
url: '/api/admin/settings/backups/storage/test',
method: 'POST',
...options,
})
}
export function updateBackupSchedule(data: UpdateBackupSchedulePayload, options: RequestOptions = {}) {
export function updateBackupSchedule(data: UpdateBackupSchedulePayload) {
return request<BackupSettingsView>({
url: '/api/admin/settings/backups/schedule/update',
method: 'POST',
data,
...options,
})
}
export function getBackupRecords(params: BackupRecordsParams, options: RequestOptions = {}) {
export function getBackupRecords(data: BackupRecordsParams, options: RequestOptions = {}) {
return request<BackupRecordPage>({
url: '/api/admin/settings/backups/records',
method: 'GET',
params,
params: data,
...options,
})
}
export function createBackup(options: RequestOptions = {}) {
export function createBackup() {
return request<BackupRecord>({
url: '/api/admin/settings/backups/create',
method: 'POST',
...options,
})
}
export function getBackupDownloadUrl(backupId: string, options: RequestOptions = {}) {
export function getBackupDownloadUrl(data: { backupId: string }) {
return request<DownloadUrlResult>({
url: '/api/admin/settings/backups/download-url',
method: 'POST',
data: { backupId },
...options,
data,
})
}
export function deleteBackup(backupId: string, options: RequestOptions = {}) {
export function deleteBackup(data: { backupId: string }) {
return request<BackupRecord>({
url: '/api/admin/settings/backups/delete',
method: 'POST',
data: { backupId },
...options,
data,
})
}
+10 -16
View File
@@ -44,21 +44,20 @@ interface ProxyAccountPage {
page: ProxyPage['page']
}
export function getProxyAccounts(params: { proxyId: string, page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
export function getProxyAccounts(data: { proxyId: string, page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
return request<ProxyAccountPage>({
url: '/api/admin/proxies/accounts',
method: 'GET',
params,
params: data,
...options,
})
}
export function removeProxyAccount(data: { proxyId: string, accountId: string }, options: RequestOptions = {}) {
export function removeProxyAccount(data: { proxyId: string, accountId: string }) {
return request<{ configRevision: number }>({
url: '/api/admin/proxies/accounts/remove',
method: 'POST',
data,
...options,
})
}
@@ -67,58 +66,53 @@ interface ProxyMutation {
configRevision: number
}
export function getProxies(params: { page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
export function getProxies(data: { page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
return request<ProxyPage>({
url: '/api/admin/proxies',
method: 'GET',
params,
params: data,
...options,
})
}
export function createProxy(data: { name: string, proxyUrl: string }, options: RequestOptions = {}) {
export function createProxy(data: { name: string, proxyUrl: string }) {
return request<ProxyMutation>({
url: '/api/admin/proxies/create',
method: 'POST',
data,
...options,
})
}
export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }, options: RequestOptions = {}) {
export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }) {
return request<ProxyMutation>({
url: '/api/admin/proxies/update',
method: 'POST',
data,
...options,
})
}
export function deleteProxy(data: { id: string, revision: number }, options: RequestOptions = {}) {
export function deleteProxy(data: { id: string, revision: number }) {
return request<{ configRevision: number }>({
url: '/api/admin/proxies/delete',
method: 'POST',
data,
...options,
})
}
export function testProxy(data: { id: string, revision: number }, options: RequestOptions = {}) {
export function testProxy(data: { id: string, revision: number }) {
return request<OutboundProxyRecord>({
url: '/api/admin/proxies/test',
method: 'POST',
data,
timeout: 25000,
...options,
})
}
export function probeProxy(data: { proxyUrl: string }, options: RequestOptions = {}) {
export function probeProxy(data: { proxyUrl: string }) {
return request<OutboundProxyTest>({
url: '/api/admin/proxies/probe',
method: 'POST',
data,
timeout: 25000,
...options,
})
}
+6 -11
View File
@@ -60,44 +60,39 @@ export function getSettings(options: RequestOptions = {}) {
type UpdateSettingsParam = Omit<RuntimeSettings, 'updatedAt'>
export function updateSettings(data: UpdateSettingsParam, options: RequestOptions = {}) {
export function updateSettings(data: UpdateSettingsParam) {
return request<RuntimeSettings>({
url: '/api/admin/settings/update',
method: 'POST',
data,
...options,
})
}
export function getAdminApiKeyStatus(options: RequestOptions = {}) {
export function getAdminApiKeyStatus() {
return request<AdminApiKeyStatus>({
url: '/api/admin/settings/admin-api-key',
method: 'GET',
...options,
})
}
export function regenerateAdminApiKey(options: RequestOptions = {}) {
export function regenerateAdminApiKey() {
return request<RegeneratedAdminApiKey>({
url: '/api/admin/settings/admin-api-key/regenerate',
method: 'POST',
...options,
})
}
export function deleteAdminApiKey(options: RequestOptions = {}) {
export function deleteAdminApiKey() {
return request<DeletedAdminApiKey>({
url: '/api/admin/settings/admin-api-key/delete',
method: 'POST',
...options,
})
}
export function getCodexDesktopWindowsDownloads(refresh = false, options: RequestOptions = {}) {
export function getCodexDesktopWindowsDownloads(data: { refresh?: boolean } = {}) {
return request<CodexDesktopWindowsDownloads>({
url: '/api/admin/settings/client-downloads/codex-desktop/windows',
method: 'GET',
params: refresh ? { refresh: true } : undefined,
...options,
params: data,
})
}
+2 -4
View File
@@ -59,21 +59,19 @@ interface SystemUpdateTarget {
targetVersion?: string
}
export function getSystemUpdateDetail(data: SystemUpdateDetailQuery, options: RequestOptions = {}) {
export function getSystemUpdateDetail(data: SystemUpdateDetailQuery) {
return request<SystemUpdateDetail>({
url: '/api/admin/system/update/detail',
method: 'GET',
params: data,
...options,
})
}
export function performSystemUpdate(data: SystemUpdateTarget, options: RequestOptions = {}) {
export function performSystemUpdate(data: SystemUpdateTarget) {
return request<SystemUpdateAccepted>({
url: '/api/admin/system/update',
method: 'POST',
data,
...options,
})
}
@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { AccountProfileStatisticsResponse } from '@/api'
import { computed } from 'vue'
import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
import { formatCompactNumber, formatInteger } from '@/utils/number'
const props = defineProps<{
profile: AccountProfileStatisticsResponse | null
loading: boolean
}>()
const metrics = computed(() => [
{
label: '累计 Token 数',
value: formatMetric(props.profile?.summary.totalTextTokens),
title: formatExact(props.profile?.summary.totalTextTokens),
},
{
label: '峰值 Token 数',
value: formatMetric(props.profile?.summary.peakTokens),
title: formatExact(props.profile?.summary.peakTokens),
},
{
label: '最长聊天时长',
value: formatDuration(props.profile?.summary.longestTaskDurationMs),
},
{
label: '当前连续天数',
value: formatDays(props.profile?.summary.currentStreakDays),
},
{
label: '最长连续天数',
value: formatDays(props.profile?.summary.longestStreakDays),
},
])
function formatMetric(value: number | null | undefined) {
return value == null ? '—' : formatCompactNumber(value)
}
function formatExact(value: number | null | undefined) {
return value == null ? undefined : `${formatInteger(value)} Tokens`
}
function formatDays(value: number | null | undefined) {
return value == null ? '—' : `${formatInteger(value)} 天`
}
function formatDuration(value: number | null | undefined) {
if (value == null)
return '—'
const totalMinutes = Math.floor(value / 60_000)
const days = Math.floor(totalMinutes / (24 * 60))
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
const minutes = totalMinutes % 60
if (days > 0)
return hours > 0 ? `${days} 天 ${hours} 小时` : `${days} 天`
if (hours > 0)
return minutes > 0 ? `${hours} 小时 ${minutes} 分` : `${hours} 小时`
if (totalMinutes > 0)
return `${totalMinutes} 分`
return `${Math.max(0, Math.floor(value / 1000))} 秒`
}
</script>
<template>
<section class="min-w-0 overflow-x-auto py-1" aria-label="累计活动摘要" :aria-busy="loading">
<dl class="grid min-w-130 grid-cols-5">
<div v-for="metric in metrics" :key="metric.label" class="min-w-0 px-2 text-center sm:px-3">
<dd class="m-0 font-mono text-cp-lg leading-tight font-heavy tabular-nums text-cp-text" :title="metric.title">
<BaseSkeleton v-if="loading && !profile" shape="text" class="mx-auto h-4 w-14" />
<template v-else>
{{ metric.value }}
</template>
</dd>
<dt class="mt-1.5 text-cp-xs font-semibold text-cp-text-secondary">
{{ metric.label }}
</dt>
</div>
</dl>
</section>
</template>
@@ -5,93 +5,39 @@ import { UserRound } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { accountProfileAvatarUrl } from '@/api'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import { stablePresetVisualToneClass } from '../../utils/visualTone'
import AccountPlanBadge from '../AccountPlanBadge.vue'
const props = defineProps<{
account: AccountRow
profile: AccountProfileStatisticsResponse
profile: AccountProfileStatisticsResponse | null
}>()
const imageFailed = shallowRef(false)
const displayName = computed(
() => props.profile.displayName || props.profile.username || props.account.email || props.account.name || 'Codex 用户',
() => props.profile?.displayName || props.profile?.username || props.account.email || props.account.name || 'Codex 用户',
)
const username = computed(() => (props.profile.username ? `@${props.profile.username.replace(/^@/, '')}` : null))
const username = computed(() => (props.profile?.username ? `@${props.profile?.username.replace(/^@/, '')}` : null))
const accountIdentity = computed(() => props.account.email?.trim() || props.account.accountId?.trim())
const initial = computed(() => Array.from(displayName.value.trim())[0]?.toUpperCase() || null)
const avatarToneClass = computed(() =>
stablePresetVisualToneClass(props.account.id || accountIdentity.value || displayName.value),
)
const avatarUrl = computed(() => {
const sourceUrl = props.profile.imageUrl?.trim()
const sourceUrl = props.profile?.imageUrl?.trim()
return sourceUrl ? accountProfileAvatarUrl(props.account.id, sourceUrl) : null
})
const metrics = computed(() => [
{
label: '累计 Token 数',
value: formatMetric(props.profile.summary.totalTextTokens),
title: formatExact(props.profile.summary.totalTextTokens),
},
{
label: '峰值 Token 数',
value: formatMetric(props.profile.summary.peakTokens),
title: formatExact(props.profile.summary.peakTokens),
},
{
label: '最长聊天时长',
value: formatDuration(props.profile.summary.longestTaskDurationMs),
},
{
label: '当前连续天数',
value: formatDays(props.profile.summary.currentStreakDays),
},
{
label: '最长连续天数',
value: formatDays(props.profile.summary.longestStreakDays),
},
])
watch(
avatarUrl,
() => {
imageFailed.value = false
},
)
function formatMetric(value: number | null) {
return value === null ? '—' : formatCompactNumber(value)
}
function formatExact(value: number | null) {
return value === null ? undefined : `${formatInteger(value)} Tokens`
}
function formatDays(value: number | null) {
return value === null ? '—' : `${formatInteger(value)} 天`
}
function formatDuration(value: number | null) {
if (value === null)
return '—'
const totalMinutes = Math.floor(value / 60_000)
const days = Math.floor(totalMinutes / (24 * 60))
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
const minutes = totalMinutes % 60
if (days > 0)
return hours > 0 ? `${days} 天 ${hours} 小时` : `${days} 天`
if (hours > 0)
return minutes > 0 ? `${hours} 小时 ${minutes} 分` : `${hours} 小时`
if (totalMinutes > 0)
return `${totalMinutes} 分`
return `${Math.max(0, Math.floor(value / 1000))} 秒`
}
</script>
<template>
<section
class="grid gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center lg:gap-4"
class="flex min-w-0 items-center"
aria-labelledby="profile-display-name"
>
<div class="flex min-w-0 items-center gap-3.5">
@@ -127,18 +73,5 @@ function formatDuration(value: number | null) {
</div>
</div>
</div>
<div class="min-w-0 overflow-x-auto py-1">
<dl class="grid min-w-130 grid-cols-5">
<div v-for="metric in metrics" :key="metric.label" class="min-w-0 px-2 text-center sm:px-3">
<dd class="m-0 font-mono text-cp-lg leading-tight font-heavy tabular-nums text-cp-text" :title="metric.title">
{{ metric.value }}
</dd>
<dt class="mt-1.5 text-cp-xs font-semibold text-cp-text-secondary">
{{ metric.label }}
</dt>
</div>
</dl>
</div>
</section>
</template>
@@ -4,34 +4,9 @@ import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
<template>
<div class="grid gap-8 sm:gap-10" role="status" aria-live="polite" aria-busy="true">
<span class="sr-only">正在加载官方个人资料</span>
<span class="sr-only">正在加载累计活动</span>
<section
class="grid gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center lg:gap-4"
>
<div class="flex min-w-0 items-center gap-3.5">
<BaseSkeleton shape="circle" class="size-14 shrink-0" />
<div class="grid min-w-0 flex-1 gap-1.5">
<div class="flex items-center gap-2">
<BaseSkeleton shape="text" class="h-4 w-20" />
<BaseSkeleton class="h-5 w-9 rounded-full opacity-70" />
</div>
<BaseSkeleton shape="text" class="w-24 opacity-60" />
<BaseSkeleton shape="text" class="w-full max-w-64 opacity-60" />
</div>
</div>
<div class="min-w-0 overflow-x-auto py-1">
<div class="grid min-w-130 grid-cols-5">
<div v-for="metric in 5" :key="metric" class="grid justify-items-center gap-2 px-2 sm:px-3">
<BaseSkeleton shape="text" class="h-4 w-14" />
<BaseSkeleton shape="text" class="w-20 opacity-60" />
</div>
</div>
</div>
</section>
<section class="grid gap-4">
<section class="grid min-w-0 gap-4 overflow-hidden">
<div class="flex items-center justify-between">
<BaseSkeleton shape="text" class="h-4 w-20" />
<BaseSkeleton class="h-7 w-32 rounded-cp opacity-70" />
@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { AccountSubscription } from '@/api'
import { computed } from 'vue'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
subscription: AccountSubscription
}>()
const renewal = computed(() => {
if (props.subscription?.willRenew === true)
return '自动续费'
if (props.subscription?.willRenew === false)
return '不自动续费'
return '未知'
})
const billingPeriod = computed(() => {
const period = props.subscription?.billingPeriod
if (!period)
return null
const labels: Record<string, string> = { month: '按月', monthly: '按月', year: '按年', yearly: '按年', annual: '按年' }
return labels[period.toLowerCase()] ?? period
})
const billing = computed(() => [billingPeriod.value, props.subscription?.billingCurrency].filter(Boolean).join(' · ') || '未知')
const dates = computed(() => [
{ label: '订阅开始', value: props.subscription?.startsAt },
{ label: '订阅结束', value: props.subscription?.expiresAt },
])
</script>
<template>
<section class="min-w-0 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5" aria-label="订阅">
<dl class="m-0 grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-[1.3fr_1.3fr_1fr_1fr]">
<div v-for="date in dates" :key="date.label" class="min-w-0">
<dt class="text-cp-xs text-cp-text-secondary">
{{ date.label }}
</dt>
<dd class="m-0 mt-1.5 font-mono text-cp-sm leading-relaxed tabular-nums text-cp-text">
<time v-if="date.value" :datetime="date.value">{{ formatDateTime(date.value, '未知', 'Asia/Shanghai') }}</time>
<span v-else>未知</span>
</dd>
</div>
<div class="min-w-0">
<dt class="text-cp-xs text-cp-text-secondary">
续费方式
</dt>
<dd class="m-0 mt-1.5 text-cp-sm leading-relaxed" :class="subscription.willRenew === true ? 'text-cp-success' : 'text-cp-text'">
{{ renewal }}
</dd>
</div>
<div class="min-w-0">
<dt class="text-cp-xs text-cp-text-secondary">
计费
</dt>
<dd class="m-0 mt-1.5 text-cp-sm leading-relaxed break-words text-cp-text">
{{ billing }}
</dd>
</div>
</dl>
</section>
</template>
@@ -34,7 +34,7 @@ const monthLabels = computed(() =>
</script>
<template>
<section aria-labelledby="profile-token-activity-title">
<section class="min-w-0" aria-labelledby="profile-token-activity-title">
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h3 id="profile-token-activity-title" class="m-0 text-cp-lg font-heavy text-cp-text">
@@ -47,7 +47,7 @@ const monthLabels = computed(() =>
<BaseEmpty
v-if="dailyUsage === null"
title="暂无 Token 活动"
description="官方个人资料没有返回每日 Token 活动。"
description="本次未获取到每日 Token 活动。"
size="sm"
surface="none"
/>
@@ -55,10 +55,11 @@ const monthLabels = computed(() =>
<div v-else class="overflow-x-auto pb-1" role="img" :aria-label="`Token 活动热力图,${rangeLabel}`">
<div class="min-w-190">
<div class="mb-1.5 grid gap-1" style="grid-template-columns: repeat(52, minmax(0, 1fr))">
<!-- 首尾月份可能只占一周;标签固定在同一行,避免跨列占位触发自动换行。 -->
<span
v-for="(month, monthIndex) in monthLabels"
:key="`${month.weekIndex}-${month.label}`"
class="h-4 whitespace-nowrap text-[10px] leading-4 font-semibold text-cp-text-quaternary"
class="row-start-1 h-4 whitespace-nowrap text-[10px] leading-4 font-semibold text-cp-text-quaternary"
:class="monthIndex === monthLabels.length - 1 ? 'text-right' : undefined"
:style="{
gridColumn: `${month.weekIndex + (monthIndex === monthLabels.length - 1 ? 0 : 1)} / span 2`,
@@ -6,72 +6,69 @@ import { toRef } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { useAccountProfileStatistics } from '../../composables/useAccountProfileStatistics'
import { useAccountPersonalInfo } from '../../composables/useAccountPersonalInfo'
import AccountProfileActivityInsights from './ActivityInsights.vue'
import AccountProfileMetrics from './Metrics.vue'
import AccountProfileHero from './ProfileHero.vue'
import AccountProfileSkeleton from './Skeleton.vue'
import AccountSubscription from './Subscription.vue'
import AccountProfileTokenActivity from './TokenActivity.vue'
const props = defineProps<{
account: AccountRow
}>()
const props = defineProps<{ account: AccountRow }>()
const open = defineModel<boolean>({ required: true })
const accountId = toRef(() => props.account.id)
const { profile, loading, error, load } = useAccountProfileStatistics(accountId, open)
const { profile, subscription, loading, error, load } = useAccountPersonalInfo(accountId, open)
</script>
<template>
<BaseModal
v-model="open"
title="个人资料"
description="来自 Codex 官方个人资料的累计活动与使用洞察"
size="xl"
>
<AccountProfileSkeleton v-if="loading && !profile" />
<BaseModal v-model="open" title="个人信息" size="xl">
<div class="flex min-h-0 min-w-0 flex-col gap-6 pb-2">
<div class="grid min-w-0 grid-cols-1 gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center">
<AccountProfileHero :account="account" :profile="profile" />
<AccountProfileMetrics :profile="profile" :loading="loading" />
</div>
<BaseEmpty
v-else-if="error && !profile"
title="个人资料加载失败"
:description="error"
:icon="TriangleAlert"
surface="none"
>
<template #action>
<BaseButton size="sm" :loading="loading" @click="load(true)">
重试
</BaseButton>
</template>
</BaseEmpty>
<div v-else-if="profile" class="flex min-h-0 flex-col gap-8 pb-2 sm:gap-10">
<AccountProfileHero :account="account" :profile="profile" />
<section v-if="subscription" class="grid min-w-0 grid-cols-1 gap-4" aria-labelledby="profile-subscription-title">
<h3 id="profile-subscription-title" class="m-0 text-cp font-heavy text-cp-text">
订阅信息
</h3>
<AccountSubscription :subscription="subscription" />
</section>
<AccountProfileSkeleton v-if="loading && !profile" />
<BaseEmpty
v-if="profile.hasStatsError"
title="个人统计暂不可用"
description="官方个人资料已加载,但本次没有返回统计数据。"
v-else-if="error && !profile"
title="个人资料加载失败"
:description="error"
:icon="TriangleAlert"
surface="none"
/>
<template v-else>
<AccountProfileTokenActivity :daily-usage="profile.dailyUsage" />
<AccountProfileActivityInsights :insights="profile.activityInsights" />
<template v-else-if="profile">
<BaseEmpty
v-if="profile.hasStatsError"
title="个人统计暂不可用"
description="本次未获取到统计数据,可以点击刷新信息重试。"
:icon="TriangleAlert"
surface="none"
/>
<template v-else>
<AccountProfileTokenActivity :daily-usage="profile.dailyUsage" />
<AccountProfileActivityInsights :insights="profile.activityInsights" />
</template>
<p v-if="error" role="status" class="m-0 text-cp-sm text-cp-warning">
本次信息刷新未完成,保留已获取的结果。
</p>
</template>
</div>
<template #footer>
<BaseButton variant="secondary" @click="open = false">
关闭
</BaseButton>
<BaseButton :loading="loading" @click="load(true)">
<template #loading>
<RefreshCw class="size-4 animate-spin motion-reduce:animate-none" />
</template>
<BaseButton :aria-busy="loading" :aria-disabled="loading" @click="load">
<template #icon>
<RefreshCw class="size-4" />
<RefreshCw class="size-4" :class="loading ? 'animate-spin motion-reduce:animate-none' : undefined" />
</template>
刷新资料
刷新信息
</BaseButton>
</template>
</BaseModal>
@@ -1,19 +1,10 @@
<script setup lang="ts">
import type { AccountQuotaForecast } from '@/api'
import { ArrowRight, Info, TriangleAlert } from '@lucide/vue'
import { ArrowRight, Info } from '@lucide/vue'
import { computed } from 'vue'
const props = defineProps<{ forecast: AccountQuotaForecast }>()
const source = computed(() => props.forecast.source)
const incompleteNotice = computed(() => {
if (props.forecast.incompleteTokens && props.forecast.incompleteCost)
return '用量和费用数据不完整,暂时无法预测。'
if (props.forecast.incompleteTokens)
return '用量数据不完整,暂时无法预测 Token 容量。'
if (props.forecast.incompleteCost)
return '费用数据不完整,暂时无法估算金额。'
return null
})
const metrics = computed(() => [
{
label: 'Token 容量',
@@ -89,9 +80,9 @@ const metrics = computed(() => [
{{ forecast.remainingUsdDisplay }}
</span>
</div>
<p v-if="incompleteNotice" class="m-0 flex items-start gap-1.5 text-cp-xs leading-relaxed text-cp-warning-text">
<TriangleAlert class="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
{{ incompleteNotice }}
<p v-if="forecast.incompleteTokens || forecast.incompleteCost" class="m-0 flex items-start gap-1.5 text-cp-xs leading-relaxed text-cp-text-secondary">
<Info class="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
按已记录数据估算,缺失的用量或费用可能使结果偏低。
</p>
</div>
</section>
@@ -123,7 +123,7 @@ function handleExplanationKeydown(event: KeyboardEvent) {
仅供参考
</h4>
<p class="m-0">
根据已记录用量估算,结果会随使用的模型和方式变化,并非官方承诺额度。
根据已记录用量估算,漏记或失败请求的消耗可能使结果偏低;结果会随使用的模型和方式变化,并非官方承诺额度。
</p>
<p v-if="forecast?.lowSample" class="m-0">
目前数据还较少,结果可能有较大波动。
@@ -60,12 +60,18 @@ const triggerLabel = computed(() => {
return hasSnapshot.value ? `查看主动重置卡,最近查询 ${availableCount.value} 张可用` : '查看主动重置卡'
})
const showTriggerCount = computed(() => hasSnapshot.value && availableCount.value > 0)
const confirmCreditTitle = computed(() => creditTitle(consumptionCredit.value))
const confirmCreditTitle = computed(() => consumptionCredit.value
? creditTitle(consumptionCredit.value)
: '使用一次重置(由上游选择)')
const creditItems = computed(() => availableCredits.value.map(credit => ({
id: credit.id,
title: creditTitle(credit),
expiry: expiryLabel(credit.expiresAt),
})))
const showCountOnlyAction = computed(() => !loadError.value
&& hasSnapshot.value
&& availableCount.value > 0
&& creditItems.value.length === 0)
const countLabel = computed(() => {
if (!hasSnapshot.value)
return loading.value ? '查询中' : '待查询'
@@ -159,7 +165,10 @@ function handleRequestConsume(creditId: string) {
</section>
<section class="overflow-hidden rounded-cp bg-cp-fill-quaternary" aria-label="使用限额重置">
<div class="flex items-center gap-3 pt-4 pr-3 pb-1 pl-4">
<div
class="flex items-center gap-3 pt-4 pr-3 pl-4"
:class="showCountOnlyAction ? 'pb-4' : 'pb-1'"
>
<h3 class="m-0 min-w-0 flex-1 text-cp-sm font-heavy text-cp-text">
使用限额重置
</h3>
@@ -227,7 +236,7 @@ function handleRequestConsume(creditId: string) {
</ul>
<BaseEmpty
v-else
v-else-if="!showCountOnlyAction"
:icon="TicketCheck"
size="sm"
surface="none"
@@ -238,12 +247,23 @@ function handleRequestConsume(creditId: string) {
</section>
</div>
<template v-if="showConfirm" #footer>
<BaseButton variant="secondary" :disabled="consuming" @click="cancelConsume">
返回
</BaseButton>
<BaseButton variant="primary" :loading="consuming" @click="confirmConsume">
{{ ambiguous ? '再次确认' : '确认重置' }}
<template v-if="showConfirm || showCountOnlyAction" #footer>
<template v-if="showConfirm">
<BaseButton variant="secondary" :disabled="consuming" @click="cancelConsume">
返回
</BaseButton>
<BaseButton variant="primary" :loading="consuming" :disabled="loading || !canRequestConsume" @click="confirmConsume">
{{ ambiguous ? '再次确认' : '确认重置' }}
</BaseButton>
</template>
<BaseButton
v-else
size="sm"
variant="primary"
:disabled="loading || consuming || ambiguous || !canRequestConsume"
@click="requestConsume"
>
使用一次重置(由上游选择)
</BaseButton>
</template>
</BaseModal>
@@ -48,7 +48,7 @@ const profileOpen = shallowRef(false)
<div class="flex shrink-0 items-center gap-0.5">
<BaseIconButton
v-if="account.provider === 'openai'"
label="查看个人资料"
label="查看个人信息"
size="sm"
variant="ghost"
:pressed="profileOpen"
@@ -0,0 +1,53 @@
import type { Ref } from 'vue'
import type { AccountPersonalInfoResponse } from '@/api'
import { computed, shallowRef, watch } from 'vue'
import { getAccountPersonalInfo } from '@/api'
import { useRequestState } from '@/composables/useRequestState'
export function useAccountPersonalInfo(accountId: Ref<string>, open: Ref<boolean>) {
const info = shallowRef<AccountPersonalInfoResponse | null>(null)
const request = useRequestState()
const { loading } = request
const profile = computed(() => info.value?.profile ?? null)
const subscription = computed(() => info.value?.subscription ?? null)
const error = computed(() => request.error.value || info.value?.profileError || '')
async function load() {
const targetAccountId = accountId.value
if (!open.value || !targetAccountId || loading.value)
return
const version = request.start()
try {
const result = await getAccountPersonalInfo({ accountId: targetAccountId }, { signal: request.signal })
if (!request.isCurrent(version))
return
// 单项失败仍更新其他信息;仅在当前打开周期保留上次可用的统计。
info.value = { ...result, profile: result.profile ?? info.value?.profile ?? null }
}
catch (cause) {
request.fail(version, cause)
}
finally {
request.finish(version)
}
}
// 打开或切换账号只请求一次;关闭取消等待,刷新按钮复用同一入口。
watch([open, accountId], ([isOpen]) => {
request.invalidate()
info.value = null
request.error.value = ''
if (isOpen)
void load()
}, { immediate: true })
return {
profile,
subscription,
loading,
error,
load,
}
}

Some files were not shown because too many files have changed in this diff Show More