Compare commits

...
19 Commits
Author SHA1 Message Date
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
75 changed files with 3488 additions and 665 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
+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:
+1 -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
+1 -1
View File
@@ -52,7 +52,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、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
+214 -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",
@@ -740,7 +739,7 @@ dependencies = [
"provider-xai",
"serde",
"serde_json",
"syn 3.0.4",
"syn 3.0.5",
"tempfile",
"thiserror",
"tokio",
@@ -749,9 +748,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 +807,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 +1201,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 +1300,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 +1407,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -1879,6 +1903,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 +1936,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2236,6 +2278,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 +2350,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 +2717,7 @@ dependencies = [
"base64 0.23.1",
"bytes",
"chrono",
"chrono-tz",
"cookie",
"flate2",
"futures",
@@ -2615,6 +2729,9 @@ dependencies = [
"hyper",
"hyper-rustls",
"hyper-util",
"native-tls",
"openssl",
"openssl-sys",
"reqwest",
"roxmltree",
"rustls",
@@ -2741,7 +2858,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2840,9 +2957,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 +3036,7 @@ dependencies = [
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
@@ -2927,9 +3045,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 +3062,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -3011,7 +3133,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3032,21 +3154,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 +3252,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 +3272,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 +3340,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -3663,9 +3798,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 +3827,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 +3869,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3742,7 +3898,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -3811,9 +3967,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 +3992,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 +4350,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 +4585,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"
@@ -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")]
@@ -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;
@@ -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]
+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 = "0.10"
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,
@@ -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,
@@ -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,
}
@@ -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;
@@ -3,11 +3,14 @@
use std::io;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use chrono::{DateTime, Utc};
use chrono_tz::America::New_York;
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 sha2::{Digest, Sha256};
@@ -20,6 +23,12 @@ 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";
// 请求中的地区画像以 PORTS 所在的 Piketon 为基准;epoch 时间戳仍保持绝对时间原值。
const TARGET_COUNTRY: &str = "US";
const TARGET_REGION: &str = "Ohio";
const TARGET_CITY: &str = "Piketon";
const TARGET_TIMEZONE: &str = "America/New_York";
const UNSUPPORTED_CODEX_RESPONSES_FIELDS: &[&str] = &["max_output_tokens", "temperature"];
const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[
@@ -113,6 +122,128 @@ fn adapt_codex_responses_body(body: &mut Map<String, Value>, upstream_model: &st
for field in UNSUPPORTED_CODEX_RESPONSES_FIELDS {
body.remove(*field);
}
align_structured_location_fields(body, Utc::now());
}
fn align_structured_location_fields(body: &mut Map<String, Value>, now: DateTime<Utc>) {
let current_date = now.with_timezone(&New_York).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);
}
}
if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) {
for tool in tools {
align_web_search_location(tool);
}
}
}
fn align_environment_context(item: &mut Value, current_date: &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) {
*text = aligned;
}
}
}
fn aligned_environment_context(text: &str, current_date: &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>{TARGET_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) {
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(),
Value::Object(Map::from_iter([
("type".to_owned(), Value::String("approximate".to_owned())),
(
"country".to_owned(),
Value::String(TARGET_COUNTRY.to_owned()),
),
("region".to_owned(), Value::String(TARGET_REGION.to_owned())),
("city".to_owned(), Value::String(TARGET_CITY.to_owned())),
(
"timezone".to_owned(),
Value::String(TARGET_TIMEZONE.to_owned()),
),
])),
);
}
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",
@@ -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};
@@ -59,6 +59,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 +232,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;
@@ -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")
);
}
@@ -465,6 +465,8 @@ 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",
]
.into_iter()
.map(|id| {
@@ -3124,6 +3126,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 +4899,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 +5297,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"})]
),
@@ -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
@@ -60,6 +60,7 @@ mod profile_contract;
mod protocol;
mod request;
mod reset_credits;
mod subscription;
mod tls;
mod usage;
mod websocket;
@@ -1,3 +1,5 @@
use chrono::{Datelike as _, Utc};
use chrono_tz::America::New_York;
use gateway_core::operation::{GenerateRequest, ProtocolPayload};
use serde_json::{Map, Value, json};
@@ -117,6 +119,120 @@ 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").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([
@@ -146,6 +146,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")),
] {
@@ -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
+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. 版本、更新与重启
+14 -3
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 只在账号范围和能力都允许,且请求尚未到达上游或已被证明可安全重放时发生。
- 可恢复观测写入失败不能替换已经确定的客户端协议结果。
@@ -383,6 +385,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 +400,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 +429,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'
+18 -3
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
@@ -420,9 +435,9 @@ export function recoverAccount(data: AccountIdParam, options: RequestOptions = {
})
}
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,
@@ -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,
}
}
@@ -1,57 +0,0 @@
import type { Ref } from 'vue'
import { shallowRef, watch } from 'vue'
import { getAccountProfileStatistics } from '@/api'
import { useRequestState } from '@/composables/useRequestState'
export function useAccountProfileStatistics(accountId: Ref<string>, open: Ref<boolean>) {
const profile = shallowRef<Awaited<ReturnType<typeof getAccountProfileStatistics>> | null>(null)
const loadedAccountId = shallowRef('')
const request = useRequestState()
const { loading, error } = request
async function load(force = false) {
const targetAccountId = accountId.value
if (!targetAccountId)
return
if (!force && profile.value && loadedAccountId.value === targetAccountId)
return
const version = request.start()
try {
const result = await getAccountProfileStatistics({ accountId: targetAccountId }, { signal: request.signal })
if (!request.isCurrent(version))
return
profile.value = result
loadedAccountId.value = targetAccountId
}
catch (cause) {
request.fail(version, cause)
}
finally {
request.finish(version)
}
}
watch([open, accountId], ([isOpen, nextAccountId], previousValues) => {
const previousAccountId = previousValues?.[1]
if (nextAccountId !== previousAccountId) {
request.invalidate()
profile.value = null
loadedAccountId.value = ''
loading.value = false
error.value = ''
}
if (isOpen)
void load()
else
request.invalidate()
}, { immediate: true })
return {
profile,
loading,
error,
load,
}
}
@@ -12,8 +12,8 @@ import { errorMessage } from '@/utils/async'
interface PendingResetCreditOperation {
accountId: string
creditId: string
credit: AccountResetCredit
creditId?: string
credit?: AccountResetCredit
redeemRequestId: string
hasTransportFailure: boolean
}
@@ -104,17 +104,21 @@ export function useAccountResetCredits(options: {
availableCredits.value.find(credit => credit.id === selectedCreditId.value),
)
const consumptionCredit = computed(() =>
pendingOperation.value?.credit ?? selectedCredit.value,
pendingOperation.value ? pendingOperation.value.credit : selectedCredit.value,
)
const ambiguous = computed(() => pendingOperation.value?.hasTransportFailure === true)
const canStartConsume = computed(() =>
hasSnapshot.value && !loadError.value && availableCount.value > 0
&& (selectedCredit.value !== undefined || availableCredits.value.length === 0),
)
const canRequestConsume = computed(() =>
ambiguous.value || (availableCount.value > 0 && selectedCredit.value !== undefined),
ambiguous.value || canStartConsume.value,
)
function reconcileSelectedCredit() {
const operation = pendingOperation.value
if (operation) {
selectedCreditId.value = operation.creditId
selectedCreditId.value = operation.creditId ?? ''
return
}
@@ -163,10 +167,10 @@ export function useAccountResetCredits(options: {
const credit = selectedCredit.value
// 确认发送时才建立操作;仅打开或取消确认弹窗不占用账号的消费状态。
const operation = target.pendingOperation ?? (credit && availableCount.value > 0
const operation = target.pendingOperation ?? (canStartConsume.value
? {
accountId: target.accountId,
creditId: credit.id,
creditId: credit?.id,
credit,
redeemRequestId: generateRedeemRequestId(),
hasTransportFailure: false,
+1 -1
View File
@@ -48,7 +48,7 @@ export const accountColumns = defineTableColumns<AccountRow>([
},
{
key: 'accessTokenExpiresAtDisplay',
label: '过期时间',
label: '令牌过期',
kind: 'datetime',
sortable: 'expiresAt',
format: value => optionalAccountCell(value),
@@ -61,7 +61,7 @@ export function buildProfileActivityGrid(
})
weeks.push({
key: dateKey(weekStart),
monthLabel: monthLabel(weekDates, weekIndex),
monthLabel: monthLabel(weekDates),
cells,
})
}
@@ -117,12 +117,10 @@ function activityLevel(value: number, maxValue: number): ProfileActivityLevel {
return Math.min(4, Math.max(1, Math.ceil(value / maxValue * 4))) as ProfileActivityLevel
}
function monthLabel(weekDates: Date[], weekIndex: number) {
function monthLabel(weekDates: Date[]) {
const firstDayOfMonth = weekDates.find(date => date.getUTCDate() === 1)
if (weekIndex !== 0 && !firstDayOfMonth)
return null
const date = firstDayOfMonth ?? weekDates[0]
return `${(date?.getUTCMonth() ?? 0) + 1}月`
// 窗口开头不补不完整月份的标签,避免与紧接着的月份挤在一起。
return firstDayOfMonth ? `${firstDayOfMonth.getUTCMonth() + 1}月` : null
}
function localCalendarDate(value: Date) {
+13 -14
View File
@@ -1,25 +1,24 @@
# v3.6.0
# v3.7.0
## 新增功能
- 创建客户端密钥时支持填写自定义 Key,便于沿用旧平台凭据;留空仍自动生成。自定义值支持不同前缀、长度和标点,沿用本平台的分组权限、限额与并发规则。
- 密钥列表、请求记录和错误列表支持按密钥名称搜索;创建和修改名称时校验重名,忽略大小写和首尾空白。
- 账号「个人信息」按需展示 OpenAI 订阅周期、续费状态与计费信息,并汇聚个人资料、累计统计和活动洞察。资料或订阅单项查询失败时,仍保留另一项可用结果。
- 账号列表将凭据有效期明确标为「令牌过期」,与订阅周期区分;订阅仅在打开个人信息或手动刷新时查询,不影响额度刷新和账号调度。
## 问题修复
- 修复 Codex 模型目录丢失原生元数据的问题,完整保留上游提示词、工具能力、上下文窗口及 `service_tiers`,使支持的客户端可按上游声明显示 `/fast` 等服务档位。目录遵守密钥的账号范围,模型别名保留目标模型的完整元数据。
- 修复同源 HTTP 管理端无法保持登录及复制失败的问题;会话 Cookie 随浏览器来源区分 HTTP 与 HTTPS,HTTPS 反向代理使用 HTTP 回源时仍保留安全属性。
- 将 OpenAI 的 `generate: false` 预热请求排除出推理用量和额度预测统计,保留审计记录与额度观测,避免预热记录干扰预测;预测面板使用实际额度观测时间。
- 修复上游仅返回可用重置次数、未提供卡片明细时无法使用额度重置的问题。现在可确认使用一次重置,由上游选择卡片;结果未知时重试继续复用原幂等标识。
- OpenAI WebSocket 遇到额度耗尽、限流等账号级拒绝时,在允许安全重放的前提下交由调度换号,避免因长 `Retry-After` 在同账号等待。
- OpenAI 明确返回模型容量不足或过载时,在尚未交付输出且允许安全重放的前提下,先进行最多 3 次同账号退避重试,再尝试换号。退避受总尝试次数和请求截止时间约束,容量不足不扣账号健康分、不触发全局熔断。
- OpenAI 用量档位与本地费用估算统一采用最终发送给上游的 `service_tier`,避免请求为 `priority`、响应回显 `default` 时显示及估算为标准档。客户端收到的原始响应保持不变。
## 体验优化
## 协议与兼容性
- 精简额度预测面板,聚焦容量与剩余估算,减少采样明细和重复提示。
- 精简请求诊断面板,缺少时间线时使用空状态展示,保留诊断包导出。
- 简化部署初始化命令,合并目录创建、配置复制和权限设置。
- 调整 HTTP / WebSocket 的 TLS 依赖与 WebSocket 握手头部顺序,对齐项目采用的 Codex 客户端传输实现。
- Codex 自动生成的结构化环境上下文使用 `America/New_York` 时区及对应当前日期,网页搜索位置统一为美国 Ohio / Piketon;用户编写的正文和绝对时间戳保持原值。
## 升级说明
- 从 v3.5.0 升级时,服务启动自动执行 `0006_custom_client_keys.sql`,保留已有密钥;无需新增配置项。历史重名密钥仍可使用,再次保存时需调整为未被占用的名称。
- 密钥相关搜索不再匹配 Key 值或可见前缀,请改用密钥名称;请求记录仍支持按 Client Key ID 查询。
- 额度预测管理接口移除了 `generatedAtDisplay`、`method` / `methodDisplay` 及部分采样诊断字段。自建管理客户端需按 [当前 API 合同](https://github.com/zyycn/codex-proxy-rs/blob/v3.6.0/docs/api.md#周月额度预测) 调整。
- Codex 客户端需重新加载模型目录,才能取得更新后的服务档位与能力信息。
- 从 v3.6.1 升级无需新增配置或数据库迁移,前后端应同步更新。
- 管理员接口 `GET /api/admin/accounts/profile-statistics` 替换为 `GET /api/admin/accounts/personal-info`,响应汇聚为 `profile`、`profileError` 和 `subscription`。直接调用旧接口的脚本或自定义管理端需同步调整。
- 档位与费用估算口径仅作用于新记录,不回填历史档位或重算已存储费用;Fast 表示发送档位,不代表已确认上游实际加速,本地估算不能代替官方账单。
+1 -1
View File
@@ -1 +1 @@
version: "3.6.0"
version: "3.7.0"