mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a436b76a | ||
|
|
849aa73a28 | ||
|
|
fb89bda177 | ||
|
|
9e6c8c9734 | ||
|
|
3f69bc861c | ||
|
|
cea4e1d8e9 | ||
|
|
539764f7eb | ||
|
|
c9be17778d | ||
|
|
d8bc91efce | ||
|
|
e8e2f18927 | ||
|
|
0977de3713 | ||
|
|
eaa37d9faa | ||
|
|
4e588ffa1d | ||
|
|
b9e919f510 | ||
|
|
32f9f3675d | ||
|
|
221d557d5e | ||
|
|
4105c1ece6 | ||
|
|
4d59687cbc | ||
|
|
8621e24e7c |
@@ -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`。
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
|
||||
|
||||
Generated
+214
-37
@@ -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("a(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("a(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,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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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, ¤t_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
@@ -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
@@ -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
@@ -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 统一监督:
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+119
-135
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
+4
-13
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
version: "3.6.0"
|
||||
version: "3.7.0"
|
||||
|
||||
Reference in New Issue
Block a user