Compare commits

..
12 Commits
Author SHA1 Message Date
zyycnandCodex bfd05722d5 chore: bump version to 2.4.0
Co-authored-by: Codex <noreply@openai.com>
2026-07-16 02:22:13 +08:00
dependabot[bot]andCodex 9dc2ea4a2f chore(deps): update backend dependency group
Update redis to 1.4.0, rustls to 0.23.42, and uuid to 1.23.5 after full backend, security, container, and Trivy validation.

Co-authored-by: Codex <noreply@openai.com>
2026-07-16 02:12:01 +08:00
dependabot[bot]andCodex 6fb27e869e chore(deps): refresh Rust 1.97 builder digest
Refresh the pinned rust:1.97-bookworm digest after a successful container build and Trivy scan.

Co-authored-by: Codex <noreply@openai.com>
2026-07-16 02:01:08 +08:00
dependabot[bot]andCodex 81c58b1cdc chore(deps): bump setup-node to 7.0.0
Update the pinned setup-node action to 7.0.0 across quality and security workflows.

Co-authored-by: Codex <noreply@openai.com>
2026-07-16 01:51:31 +08:00
zyycnandCodex c8dc2e293a fix(transport): eliminate websocket fallback race
Notify foreground and shared waiters before awaiting pool cleanup so an observed WebSocket opening error cannot be replaced by a fast-path timeout and HTTP fallback.

Drive the half-open breaker test through explicit state transitions instead of timing sleeps.

Co-authored-by: Codex <noreply@openai.com>
2026-07-16 01:46:33 +08:00
dependabot[bot] 25081de913 chore(deps): bump action-gh-release to 3.0.2
Update the pinned GitHub Release action from 3.0.1 to 3.0.2.\n\nCo-authored-by: Codex <noreply@openai.com>
2026-07-16 01:23:07 +08:00
zyycnandCodex 6eb0917ff1 feat(frontend): enrich interface micro-interactions
Add responsive hover reactions across dashboard, account, usage, settings, login, and navigation surfaces. Keep brand motion distinct, pair enter and leave states, and respect reduced-motion preferences.

Refine sidebar transitions, dashboard heartbeat, trend feedback, and 15-minute metric buckets without animating page content.

Co-authored-by: Codex <noreply@openai.com>
2026-07-16 01:07:33 +08:00
zyycnandCodex 72d892d5e3 feat(dashboard): improve request health visibility
Classify final request outcomes into success, failure, cancellation, and caller-error buckets so availability reflects only eligible requests. Expose structured 15-minute health points with focused coverage.

Redesign the health timeline, trend, wire-profile, and diagnostic surfaces with responsive shared interactions, and standardize configuration schema version 1.

Co-authored-by: Codex <noreply@openai.com>
2026-07-15 23:52:35 +08:00
ZyyandCodex 0b2da3087b refactor: replace fingerprint with wire profile
Co-authored-by: Codex <noreply@openai.com>
2026-07-15 18:23:59 +08:00
zyycnandCodex 5391fdb31c fix(lifecycle): eliminate async finalization races
Co-authored-by: Codex <noreply@openai.com>
2026-07-15 12:57:54 +08:00
zyycnandCodex 24036f1f84 docs: refine project README
Co-authored-by: Codex <noreply@openai.com>
2026-07-15 12:27:27 +08:00
zyycnandCodex b043fced0c docs: streamline project guidance
Keep the public README terse and operational, and align the repository skills with the current lifecycle, transport, validation, and release boundaries.

Co-authored-by: Codex <noreply@openai.com>
2026-07-15 12:20:00 +08:00
114 changed files with 3437 additions and 3031 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ jobs:
version: 11.7.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: pnpm
+1 -1
View File
@@ -621,7 +621,7 @@ jobs:
cosign sign --yes "${images[@]}"
- name: Publish GitHub Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
version: 11.7.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: pnpm
+67 -122
View File
@@ -1,41 +1,41 @@
<!-- prettier-ignore -->
<div align="center">
<img src="frontend/public/favicon.svg" alt="Codex Proxy RS" width="88" height="88" />
<img src="frontend/public/favicon.svg" alt="Codex Proxy RS" width="80" height="80" />
# Codex Proxy RS
**基于 Rust 的高性能 Codex 多账号网关代理**
[![CI](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/zyycn/codex-proxy-rs?display_name=tag&sort=semver)](https://github.com/zyycn/codex-proxy-rs/releases)
[![Rust 1.97](https://img.shields.io/badge/Rust-1.97-000?logo=rust&logoColor=white)](https://www.rust-lang.org/)
[![Vue 3.5](https://img.shields.io/badge/Vue-3.5-42b883?logo=vuedotjs&logoColor=white)](https://vuejs.org/)
[![Vite 8](https://img.shields.io/badge/Vite-8-646cff?logo=vite&logoColor=white)](https://vite.dev/)
[![GHCR](https://img.shields.io/badge/GHCR-codex--proxy--rs-2496ED?logo=docker&logoColor=white)](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
[![MIT](https://img.shields.io/badge/License-MIT-blue.svg)](#开源许可)
[![Release](https://img.shields.io/github/v/release/zyycn/codex-proxy-rs?display_name=tag&sort=semver&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/releases)
[![GHCR](https://img.shields.io/badge/GHCR-codex--proxy--rs-2496ED?logo=docker&logoColor=white&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
[![MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/license/mit)
[快速开始](#快速开始) · [首次使用](#首次使用) · [客户端接入](#客户端接入) · [常见问题](#常见问题) · [部署文档](deploy/README.md)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署文档](deploy/README.md) · [架构](docs/architecture.md)
</div>
> [!NOTE]
> 当前兼容 OpenAI Responses API,不提供 `/v1/chat/completions`。
> 只支持 OpenAI Responses API,不提供 `/v1/chat/completions`。
## 功能
## 能力
- 支持 Responses HTTP、SSE 和官方 WebSocket 请求。
- 提供智能调度、额度重置优先、轮询和粘滞四种账号策略。
- 某个账号遇到额度、认证、模型或传输错误时,继续尝试其他账号。
- 在网页中管理账号、客户端 Key、模型别名和运行参数。
- 按请求、模型、账号或客户端 Key 查看 Token、费用、缓存、TTFT、延迟和错误。
- 支持账号 OAuth、Token 导入以及 CPR、Sub2API、CLIProxyAPI JSON 导入。
- 可直接生成 Codex CLI 配置,或通过 deeplink 导入 CCSwitch。
| 领域 | 实现 |
| -------- | ------------------------------------------------ |
| 协议 | Responses HTTP、SSE、官方 WebSocket |
| 传输 | 会话级 WebSocket 池、共享 HTTP/2 后备 |
| 调度 | 智能调度、额度优先、轮询、粘滞 |
| 故障处理 | 额度、认证、封禁、模型与传输故障切换 |
| 身份 | 账号身份隔离、稳定 installation ID、会话拓扑透传 |
| 管理 | 账号、客户端 Key、模型别名、运行设置与在线更新 |
| 遥测 | Token、费用、缓存、TTFT、延迟与错误 |
## 快速开始
Docker Compose 是最简单的启动方式。开始前请安装 Docker Engine 和 Docker Compose Plugin。
需要 Docker Engine 与 Docker Compose Plugin。
### 1. 准备配置
### 1. 准备
```bash
git clone https://github.com/zyycn/codex-proxy-rs.git
@@ -48,74 +48,63 @@ sudo chown "$(id -u):10001" deploy/config.yaml
chmod 0640 deploy/config.yaml
```
分别执行三次以下命令生成密码:
分别执行三次:
```bash
openssl rand -hex 24
```
把结果填入 `deploy/config.yaml` 的 `database.password`、`redis.password` 和
`admin.default_password`。PostgreSQL 与 Redis 密码必须是 48 位十六进制字符;管理员密码
至少 12 位且不能包含 `$`。
将结果写入 `deploy/config.yaml`:
> [!IMPORTANT]
> 应用容器以非 root 用户 `10001:10001` 运行。Linux Bind Mount 会保留宿主机权限,因此需要允许容器用户组读取配置并写入数据和日志目录:
>
> ```bash
> sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
> chmod 0770 .runtime/data .runtime/logs
> ```
>
> 配置文件通过 Compose `configs` 只读挂载;普通 Compose 会保留宿主机权限,因此由
> 当前用户持有、仅向容器组开放读取权限。
> macOS 和 Windows 的 Docker Desktop 通常也不需要调整数据目录权限。
| 配置 | 约束 |
| ------------------------------ | ------------------------ |
| `x-cpr.database.password` | 48 位十六进制 |
| `x-cpr.redis.password` | 48 位十六进制 |
| `x-cpr.admin.default_password` | 至少 12 位,不能包含 `$` |
### 2. 启动服务
Linux 需要允许容器组写入运行目录:
```bash
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
chmod 0770 .runtime/data .runtime/logs
```
### 2. 启动
```bash
docker compose -f deploy/compose.yaml config --quiet
docker compose -f deploy/compose.yaml pull
docker compose -f deploy/compose.yaml up -d --no-build
```
启动后检查:
```bash
docker compose -f deploy/compose.yaml ps
curl -i http://127.0.0.1:8080/healthz
```
`/healthz` 返回 `204 No Content` 即表示服务正常。管理端地址为 <http://127.0.0.1:8080>。
`204 No Content` 表示应用、PostgreSQL 与 Redis 均可用。管理端地址:
`http://127.0.0.1:8080`。
## 首次使用
### 3. 初始化
1. 使用 `admin@cpr.local` 和 `x-cpr.admin.default_password` 中设置的密码登录。
2. 打开“账号”,用 OAuth、Access/Refresh Token 或 JSON 文件添加账号。
3. 点一次连接测试,能看到模型和额度即可。
4. 打开“API 密钥”,创建一个 `sk_...` 客户端 Key。
5. 点击“使用密钥”复制 Codex CLI 配置,或一键导入 CCSwitch。
1. 使用 `admin@cpr.local` 与初始密码登录。
2. 添加 Codex 账号并执行连接测试。
3. 创建 `sk_...` 客户端 Key。
4. 复制 Codex CLI 配置,或导入 CCSwitch。
> [!TIP]
> `admin.default_password` 只用于首次创建管理员。已有管理员后修改该字段不会重置登录密码。
> [!NOTE]
> `admin.default_password` 只在首次创建管理员时生效。
完整部署、权限和密码轮换规则见 [deploy/README.md](deploy/README.md)。
## 客户端接入
| 配置 | 值 |
| -------- | ---------------------------------------- |
| Base URL | `http://127.0.0.1:8080/v1` |
| API Key | 管理端创建的 `sk_...` 客户端 Key |
| API Key | 管理端创建的 `sk_...` |
| 鉴权 | `Authorization: Bearer <client-api-key>` |
先查询当前可用模型:
```bash
curl http://127.0.0.1:8080/v1/models \
-H 'Authorization: Bearer <client-api-key>'
```
发送一个非流式 Responses 请求:
```bash
curl http://127.0.0.1:8080/v1/responses \
-H 'Authorization: Bearer <client-api-key>' \
-H 'Content-Type: application/json' \
@@ -127,89 +116,45 @@ curl http://127.0.0.1:8080/v1/responses \
```
<details>
<summary>查看全部客户端 API 路由</summary>
<summary>API 路由</summary>
| 路由 | 说明 |
| -------------------------------- | ------------------------------- |
| `POST /v1/responses` | Responses 非流式或 SSE 流式请求 |
| `GET /v1/responses` | 官方 Responses WebSocket |
| `POST /v1/responses/review` | Review 请求 |
| `GET /v1/models` | OpenAI 格式的模型列表 |
| `GET /v1/models/catalog` | 完整模型目录 |
| `GET /v1/models/{model_id}` | 模型详情 |
| `GET /v1/models/{model_id}/info` | 模型运行信息 |
| 路由 | 用途 |
| --------------------------- | ------------------- |
| `POST /v1/responses` | JSON 或 SSE |
| `GET /v1/responses` | Responses WebSocket |
| `POST /v1/responses/review` | Review |
| `GET /v1/models` | 模型列表 |
| `GET /v1/models/catalog` | 模型目录 |
| `GET /v1/models/{model_id}` | 模型详情 |
所有 `/v1/*` 路由都需要客户端 API Key。
</details>
## 管理端
| 页面 | 内容 |
| -------- | ---------------------------------------------------------------------- |
| 概览 | 查看服务状态、请求趋势、Token、账号池容量、请求健康和最近调用 |
| 账号 | 添加、导入、导出、测试、启停账号,以及刷新 Token 和额度 |
| API 密钥 | 创建、复制、启停或删除 Key,生成 Codex CLI 配置并导入 CCSwitch |
| 使用统计 | 按时间、模型、账号和客户端 Key 查看用量、费用、缓存、延迟和错误 |
| 系统设置 | 配置模型别名、调度策略、单账号并发、请求间隔、刷新参数和管理员 API Key |
| 系统更新 | 查看版本和发布说明;Release 版本可以在线更新并重启 |
## 常用操作
### 升级
## 运维
```bash
# 升级
docker compose -f deploy/compose.yaml pull codex-proxy-rs
docker compose -f deploy/compose.yaml up -d --no-build
```
### 查看日志
```bash
# 日志
docker compose -f deploy/compose.yaml logs -f codex-proxy-rs
```
### 从当前源码构建
```bash
# 从源码构建
docker compose -f deploy/compose.yaml build codex-proxy-rs
docker compose -f deploy/compose.yaml up -d
```
> [!WARNING]
> PostgreSQL、Redis、应用数据和文件日志都保存在仓库根目录 `.runtime/`。普通
> `docker compose down` 不会删除这些绑定目录;删除 `.runtime/` 才会清除数据。升级前请备份
> 整个目录,数据库备份中包含客户端凭据,应按敏感数据保管。
> [!IMPORTANT]
> `.runtime/` 保存数据库、Redis、身份密钥和日志。删除该目录会永久清除运行状态。
## 常见问题
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket
upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
### `/healthz` 返回 `503`
## 文档
检查 PostgreSQL 和 Redis 容器是否健康:
```bash
docker compose -f deploy/compose.yaml ps
docker compose -f deploy/compose.yaml logs postgres redis
```
### 容器反复重启
检查 `deploy/config.yaml` 的三个密码是否填写完整,以及 `.runtime/data`、`.runtime/logs`
的权限是否正确。
### 需要从其他设备访问
Compose 默认仅绑定 `127.0.0.1`。请在服务前配置 HTTPS 反向代理,并正确转发 WebSocket 升级和真实客户端 IP 请求头。不要直接暴露 PostgreSQL 或 Redis。
## 相关链接
- [发布版本](https://github.com/zyycn/codex-proxy-rs/releases)
- [部署](deploy/README.md)
- [架构](docs/architecture.md)
- [Release](https://github.com/zyycn/codex-proxy-rs/releases)
- [容器镜像](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
- [详细部署说明](deploy/README.md)
- [架构说明](docs/architecture.md)
Docker 镜像支持 Linux amd64/arm64。Release 压缩包另有 macOS arm64 和 Windows amd64 版本。
## 开源许可
Codex Proxy RS 基于 [MIT License](https://opensource.org/license/mit) 开源。
+16 -6
View File
@@ -371,6 +371,7 @@ dependencies = [
"rand_core 0.6.4",
"redis",
"reqwest",
"roxmltree",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
@@ -1784,9 +1785,9 @@ dependencies = [
[[package]]
name = "redis"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fa6f8e4b491d7a8ef3a9550a4d71969bd0064f46e32b8dbbcc7fc60dad94fed"
checksum = "3cb5358643f48330db5c78856982faf39c93ba50c60d682b43d0344a3b649769"
dependencies = [
"arc-swap",
"arcstr",
@@ -1905,6 +1906,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -1926,9 +1936,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.36"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"once_cell",
"ring",
@@ -2882,9 +2892,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.23.4"
version = "1.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a"
dependencies = [
"getrandom 0.4.3",
"js-sys",
+2 -1
View File
@@ -25,9 +25,10 @@ rand = "0.10.1"
# Enables password_hash::rand_core::OsRng for argon2 0.5.
rand_core = { version = "0.6.4", features = ["getrandom"] }
redis = { version = "1.3.0", default-features = false, features = ["tokio-comp", "connection-manager", "script"] }
roxmltree = "0.21.1"
# Pinned to the Codex Desktop 26.707.41301 HTTP stack; re-audit a newer Desktop before upgrading.
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate", "http2"] }
rustls = { version = "=0.23.36", default-features = false, features = ["ring", "std", "tls12"] }
rustls = { version = "=0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
rustls-native-certs = "0.8.4"
rustls-pki-types = "1.14.1"
secrecy = { version = "0.10.3", features = ["serde"] }
+111 -102
View File
@@ -28,10 +28,7 @@ use crate::{
},
infra::{
format::{format_duration_ms, format_tokens},
time::{
china_datetime, china_datetime_rfc3339_str, china_day_start, china_quarter_hour_start,
china_relative_time,
},
time::{china_datetime, china_day_start, china_quarter_hour_start, china_relative_time},
},
telemetry::{
account_usage::query::AccountUsageTimeBucket,
@@ -41,11 +38,11 @@ use crate::{
dashboard_health_timeline_data, dashboard_trend_data,
},
usage::{
insights::RequestHealthTimeBucket,
query::{UsageQueryFilter, UsageRecordAccountUsage},
types::{UsageRecord, metadata_string},
},
},
upstream::openai::fingerprint::Fingerprint,
};
const DASHBOARD_ACCOUNT_USAGE_LIMIT: u32 = 4;
@@ -69,7 +66,7 @@ struct DashboardSummaryData {
trend: DashboardTrendData,
health_timeline: DashboardHealthTimelineData,
account_usage: Vec<DashboardAccountUsageData>,
service_statuses: Vec<DashboardServiceStatusData>,
wire_profile: DashboardWireProfileData,
usage_records: Vec<DashboardUsageRecordData>,
pool_summary: AccountPoolDiagnostics,
capacity_info: AccountCapacityDiagnostics,
@@ -114,11 +111,50 @@ struct DashboardUsageRecordData {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DashboardServiceStatusData {
label: String,
value: String,
detail: String,
tone: String,
struct DashboardWireProfileData {
originator: String,
codex_version: String,
desktop_version: String,
desktop_build: String,
target: DashboardWireTargetData,
user_agent: String,
verified_at: DateTime<Utc>,
release: DashboardDesktopReleaseData,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DashboardWireTargetData {
os_type: String,
os_version: String,
arch: String,
terminal: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DashboardDesktopReleaseData {
status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
checked_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
latest_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
latest_build: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
published_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
minimum_system_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
hardware_requirements: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
download_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
download_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
signature_present: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Default, Serialize)]
@@ -142,35 +178,6 @@ struct AccountCapacityDiagnostics {
available_slots: usize,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct FingerprintDiagnostics {
source: &'static str,
originator: String,
app_version: String,
build_number: String,
platform: String,
arch: String,
chromium_version: String,
user_agent: String,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at: Option<String>,
}
fn fingerprint_diagnostics(fingerprint: &Fingerprint) -> FingerprintDiagnostics {
FingerprintDiagnostics {
source: "runtime",
originator: fingerprint.originator.clone(),
app_version: fingerprint.app_version.clone(),
build_number: fingerprint.build_number.clone(),
platform: fingerprint.platform.clone(),
arch: fingerprint.arch.clone(),
chromium_version: fingerprint.chromium_version.clone(),
user_agent: fingerprint.user_agent(),
updated_at: fingerprint.updated_at.clone(),
}
}
impl From<AccountCapacitySummary> for AccountCapacityDiagnostics {
fn from(summary: AccountCapacitySummary) -> Self {
Self {
@@ -224,7 +231,10 @@ pub(crate) async fn dashboard_summary(
.await
.map_err(|error| dashboard_data_error("recent usage records", &error))?;
let time_buckets = dashboard_time_buckets(&state, now).await?;
let (time_buckets, health_buckets) = tokio::try_join!(
dashboard_time_buckets(&state, now),
dashboard_health_buckets(&state, now),
)?;
let account_ids = accounts
.iter()
.map(|account| account.id.clone())
@@ -259,14 +269,14 @@ pub(crate) async fn dashboard_summary(
&retained_usage,
),
trend,
health_timeline: dashboard_health_timeline_data(&time_buckets),
health_timeline: dashboard_health_timeline_data(&health_buckets),
account_usage: account_usage_data(
&accounts,
&account_usage_records,
&quota_used_by_account,
now,
),
service_statuses: service_status_data(&state),
wire_profile: wire_profile_data(&state),
usage_records: dashboard_usage_records,
pool_summary,
capacity_info: AccountCapacityDiagnostics::from(capacity),
@@ -315,6 +325,18 @@ async fn dashboard_time_buckets(
.map_err(|error| dashboard_data_error("time buckets", &error))
}
async fn dashboard_health_buckets(
state: &AppState,
now: DateTime<Utc>,
) -> Result<Vec<RequestHealthTimeBucket>, AdminError> {
state
.services
.usage_records
.health_timeline(china_day_start(now), now)
.await
.map_err(|error| dashboard_data_error("health timeline", &error))
}
fn dashboard_account_counts(accounts: &[Account]) -> DashboardAccountCounts {
DashboardAccountCounts {
total: accounts.len() as u64,
@@ -554,66 +576,53 @@ fn dashboard_usage_record_data(
}
}
fn service_status_data(state: &AppState) -> Vec<DashboardServiceStatusData> {
let current_fingerprint = state.services.fingerprint.snapshot();
let fingerprint = fingerprint_diagnostics(&current_fingerprint);
vec![
DashboardServiceStatusData {
label: "客户端版本".to_string(),
value: empty_dash(fingerprint.app_version),
detail: if fingerprint.build_number.trim().is_empty() {
"-".to_string()
} else {
format!("Build {}", fingerprint.build_number)
},
tone: "info".to_string(),
},
DashboardServiceStatusData {
label: "平台架构".to_string(),
value: empty_dash(fingerprint.platform),
detail: empty_dash(fingerprint.arch),
tone: "info".to_string(),
},
DashboardServiceStatusData {
label: "Chromium".to_string(),
value: if fingerprint.chromium_version.trim().is_empty() {
"-".to_string()
} else {
format!("v{}", fingerprint.chromium_version)
},
detail: empty_dash(fingerprint.originator),
tone: "normal".to_string(),
},
DashboardServiceStatusData {
label: "更新时间".to_string(),
value: format_fingerprint_updated_at(fingerprint.updated_at),
detail: String::new(),
tone: "normal".to_string(),
},
DashboardServiceStatusData {
label: "User Agent".to_string(),
value: fingerprint.user_agent,
detail: String::new(),
tone: "normal".to_string(),
},
]
}
fn empty_dash(value: String) -> String {
if value.trim().is_empty() {
"-".to_string()
fn wire_profile_data(state: &AppState) -> DashboardWireProfileData {
let profile = &state.services.wire_profile;
let release_snapshot = state.services.desktop_release.snapshot();
let release_status = if release_snapshot.last_error.is_some() {
"check_failed"
} else if let Some(latest) = &release_snapshot.latest {
if latest.version == profile.desktop_version && latest.build == profile.desktop_build {
"aligned"
} else {
"review_required"
}
} else {
value
}
}
fn format_fingerprint_updated_at(value: Option<String>) -> String {
let Some(value) = value else {
return "-".to_string();
"unchecked"
};
let value = value.trim();
if value.is_empty() {
return "-".to_string();
let latest = release_snapshot.latest;
DashboardWireProfileData {
originator: profile.originator.clone(),
codex_version: profile.codex_version.clone(),
desktop_version: profile.desktop_version.clone(),
desktop_build: profile.desktop_build.clone(),
target: DashboardWireTargetData {
os_type: profile.os_type.clone(),
os_version: profile.os_version.clone(),
arch: profile.arch.clone(),
terminal: profile.terminal.clone(),
},
user_agent: profile.user_agent(),
verified_at: profile.verified_at,
release: DashboardDesktopReleaseData {
status: release_status,
checked_at: release_snapshot.checked_at,
latest_version: latest.as_ref().map(|release| release.version.clone()),
latest_build: latest.as_ref().map(|release| release.build.clone()),
published_at: latest.as_ref().and_then(|release| release.published_at),
minimum_system_version: latest
.as_ref()
.and_then(|release| release.minimum_system_version.clone()),
hardware_requirements: latest
.as_ref()
.and_then(|release| release.hardware_requirements.clone()),
download_url: latest
.as_ref()
.and_then(|release| release.download_url.clone()),
download_size: latest.as_ref().and_then(|release| release.download_size),
signature_present: latest.as_ref().map(|release| release.signature_present),
error: release_snapshot.last_error,
},
}
china_datetime_rfc3339_str(value)
}
+6 -2
View File
@@ -27,7 +27,10 @@ use crate::{
usage::query::UsageQueryService,
},
update::service::SystemUpdateService,
upstream::openai::{fingerprint::RuntimeFingerprint, token_client::OpenAiTokenClient},
upstream::openai::{
desktop_release::DesktopReleaseStatus, profile::CodexWireProfile,
token_client::OpenAiTokenClient,
},
};
/// HTTP API 所需的领域服务集合。
@@ -48,7 +51,8 @@ pub struct ApiServices {
pub token_refresh: Arc<TokenRefreshService<OpenAiTokenClient>>,
pub responses: Arc<ResponseDispatchService>,
pub session_affinity: Arc<SessionAffinityService>,
pub fingerprint: RuntimeFingerprint,
pub wire_profile: Arc<CodexWireProfile>,
pub desktop_release: DesktopReleaseStatus,
pub process_control: Arc<dyn ProcessControl>,
pub system_update: Arc<SystemUpdateService>,
pub connection_drain: crate::api::middleware::connection_drain::ConnectionDrain,
+55 -71
View File
@@ -1,10 +1,10 @@
use std::{
collections::{BTreeMap, HashSet},
collections::BTreeMap,
env,
path::{Path, PathBuf},
};
use axum::http::HeaderName;
use chrono::{DateTime, Utc};
use secrecy::{ExposeSecret, SecretBox};
use serde::{Deserialize, de::IgnoredAny};
use tracing_subscriber::EnvFilter;
@@ -106,7 +106,7 @@ impl BootstrapConfig {
runtime,
quota,
ws_pool,
fingerprint,
wire_profile,
admin,
logging,
telemetry,
@@ -122,7 +122,7 @@ impl BootstrapConfig {
redis: RedisConfig { url: redis.url },
runtime,
ws_pool,
fingerprint,
wire_profile,
admin: AdminConfig {
session_ttl_minutes: admin.session_ttl_minutes,
default_username: admin.default_username,
@@ -241,8 +241,8 @@ pub struct AppConfig {
pub runtime: RuntimePathsConfig,
/// WebSocket 连接池启动设置。
pub ws_pool: WebSocketPoolSettings,
/// 上游请求指纹默认配置。
pub fingerprint: FingerprintConfig,
/// 经审计固定的 Codex Desktop 上游请求画像。
pub wire_profile: WireProfileConfig,
/// 管理员初始化配置,不包含密码。
pub admin: AdminConfig,
/// 日志配置。
@@ -354,38 +354,28 @@ pub struct WebSocketPoolSettings {
pub initial_event_timeout_ms: u64,
}
/// 上游请求指纹默认配置。
/// 经审计固定的 Codex Desktop 上游请求画像配置。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct FingerprintConfig {
/// 客户端来源名。
pub struct WireProfileConfig {
/// `originator` 请求头及 User-Agent 产品名。
pub originator: String,
/// 应用版本。
pub app_version: String,
/// 构建号。
pub build_number: String,
/// 平台名。
pub platform: String,
/// 架构名。
/// bundled Codex Core 版本。
pub codex_version: String,
/// Desktop 应用版本。
pub desktop_version: String,
/// Desktop 制品构建号。
pub desktop_build: String,
/// Codex Core UA 中的目标操作系统类型。
pub os_type: String,
/// Codex Core UA 中的目标操作系统版本。
pub os_version: String,
/// Codex Core UA 中的目标架构。
pub arch: String,
/// Chromium 主版本。
pub chromium_version: String,
/// User-Agent 模板。
pub user_agent_template: String,
/// 默认请求头。
pub default_headers: Vec<FingerprintHeaderConfig>,
/// 请求头排序优先级。
pub header_order: Vec<String>,
}
/// 指纹默认请求头配置项。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct FingerprintHeaderConfig {
/// 请求头名称。
pub name: String,
/// 请求头值。
pub value: String,
/// Codex Core UA 中的终端标记。
pub terminal: String,
/// 此画像最后一次经制品与源码核验的时间。
pub verified_at: DateTime<Utc>,
}
/// 管理员初始化与会话配置,不包含初始化密码。
@@ -453,7 +443,7 @@ struct FileAppConfig {
runtime: RuntimePathsConfig,
quota: QuotaConfig,
ws_pool: WebSocketPoolSettings,
fingerprint: FingerprintConfig,
wire_profile: WireProfileConfig,
admin: FileAdminConfig,
logging: LoggingConfig,
telemetry: TelemetryConfig,
@@ -549,7 +539,7 @@ fn validate_file_config(config: &FileAppConfig) -> Result<(), ConfigError> {
if config.ws_pool.max_per_account == 0 {
return Err(ConfigError::InvalidField("ws_pool.max_per_account"));
}
validate_fingerprint(&config.fingerprint)?;
validate_wire_profile(&config.wire_profile)?;
validate_logging(&config.logging)?;
Ok(())
}
@@ -594,54 +584,48 @@ fn validate_admin_password(password: &str) -> Result<(), ConfigError> {
Ok(())
}
fn validate_fingerprint(config: &FingerprintConfig) -> Result<(), ConfigError> {
fn validate_wire_profile(config: &WireProfileConfig) -> Result<(), ConfigError> {
for (field, value) in [
("fingerprint.originator", config.originator.as_str()),
("fingerprint.app_version", config.app_version.as_str()),
("fingerprint.build_number", config.build_number.as_str()),
("fingerprint.platform", config.platform.as_str()),
("fingerprint.arch", config.arch.as_str()),
("wire_profile.originator", config.originator.as_str()),
("wire_profile.codex_version", config.codex_version.as_str()),
(
"fingerprint.chromium_version",
config.chromium_version.as_str(),
),
(
"fingerprint.user_agent_template",
config.user_agent_template.as_str(),
"wire_profile.desktop_version",
config.desktop_version.as_str(),
),
("wire_profile.desktop_build", config.desktop_build.as_str()),
("wire_profile.os_type", config.os_type.as_str()),
("wire_profile.os_version", config.os_version.as_str()),
("wire_profile.arch", config.arch.as_str()),
("wire_profile.terminal", config.terminal.as_str()),
] {
validate_nonempty(field, value)?;
}
if config.default_headers.is_empty() || config.header_order.is_empty() {
return Err(ConfigError::InvalidField("fingerprint"));
if semver::Version::parse(&config.codex_version).is_err() {
return Err(ConfigError::InvalidField("wire_profile.codex_version"));
}
let mut header_names = HashSet::new();
for header in &config.default_headers {
let name = header.name.trim().to_ascii_lowercase();
if name.is_empty()
|| header.value.is_empty()
|| HeaderName::from_bytes(name.as_bytes()).is_err()
|| !header_names.insert(name)
{
return Err(ConfigError::InvalidField("fingerprint.default_headers"));
}
if !is_numeric_dotted_version(&config.desktop_version) {
return Err(ConfigError::InvalidField("wire_profile.desktop_version"));
}
let mut ordered_headers = HashSet::new();
for header in &config.header_order {
let header = header.trim().to_ascii_lowercase();
if header.is_empty()
|| HeaderName::from_bytes(header.as_bytes()).is_err()
|| !ordered_headers.insert(header)
{
return Err(ConfigError::InvalidField("fingerprint.header_order"));
}
if !config
.desktop_build
.bytes()
.all(|byte| byte.is_ascii_digit())
{
return Err(ConfigError::InvalidField("wire_profile.desktop_build"));
}
Ok(())
}
fn is_numeric_dotted_version(value: &str) -> bool {
let mut parts = value.split('.');
let valid_parts = parts
.by_ref()
.filter(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
.count();
valid_parts >= 2 && valid_parts == value.split('.').count()
}
fn validate_logging(config: &LoggingConfig) -> Result<(), ConfigError> {
EnvFilter::try_new(&config.level).map_err(|_| ConfigError::InvalidField("logging.level"))?;
if !config.stdout && !config.file.enabled {
+31 -37
View File
@@ -50,7 +50,8 @@ use crate::{
},
update::service::SystemUpdateService,
upstream::openai::{
fingerprint::{Fingerprint, PgFingerprintStore, RuntimeFingerprint},
desktop_release::DesktopReleaseStatus,
profile::CodexWireProfile,
token_client::{OpenAiTokenClient, TokenClientConfig, default_openai_token_client},
transport::{
CodexBackendClient, CodexModelCatalogClient, CodexWebSocketPool,
@@ -80,7 +81,6 @@ pub struct BackgroundTaskStores {
pub admin_users: PgAdminUserStore,
pub admin_sessions: RedisAdminSessionStore,
pub cookies: PgCookieStore,
pub fingerprints: PgFingerprintStore,
pub session_affinity: RedisSessionAffinityStore,
pub refresh_leases: RedisRefreshLeaseStore,
pub model_snapshots: RedisModelSnapshotStore,
@@ -117,7 +117,8 @@ pub struct Services {
pub session_affinity: Arc<SessionAffinityService>,
pub codex: Arc<CodexBackendClient>,
pub websocket_pool: Option<Arc<CodexWebSocketPool>>,
pub fingerprint: RuntimeFingerprint,
pub wire_profile: Arc<CodexWireProfile>,
pub desktop_release: DesktopReleaseStatus,
pub account_pseudonymizer: Arc<AccountPseudonymizer>,
pub system_update: Arc<SystemUpdateService>,
pub connection_drain: crate::api::middleware::connection_drain::ConnectionDrain,
@@ -145,21 +146,21 @@ impl Services {
pub fn new(
config: &AppConfig,
stores: BackgroundTaskStores,
fingerprint: RuntimeFingerprint,
wire_profile: Arc<CodexWireProfile>,
) -> Self {
Self::try_new(config, stores, fingerprint)
Self::try_new(config, stores, wire_profile)
.expect("failed to build runtime services with configured TLS transport")
}
pub fn try_new(
config: &AppConfig,
stores: BackgroundTaskStores,
fingerprint: RuntimeFingerprint,
wire_profile: Arc<CodexWireProfile>,
) -> Result<Self, CustomCaError> {
Self::try_with_usage_record_options(
config,
stores,
fingerprint,
wire_profile,
UsageRecordOptions::from_config(config),
)
}
@@ -167,7 +168,7 @@ impl Services {
pub fn try_with_usage_record_options(
config: &AppConfig,
stores: BackgroundTaskStores,
fingerprint: RuntimeFingerprint,
wire_profile: Arc<CodexWireProfile>,
usage_record_options: UsageRecordOptions,
) -> Result<Self, CustomCaError> {
let mut identity_secret = [0u8; 32];
@@ -175,7 +176,7 @@ impl Services {
Self::try_with_identity_secret_and_usage_record_options(
config,
stores,
fingerprint,
wire_profile,
identity_secret,
usage_record_options,
)
@@ -184,7 +185,7 @@ impl Services {
fn try_with_identity_secret_and_usage_record_options(
config: &AppConfig,
stores: BackgroundTaskStores,
fingerprint: RuntimeFingerprint,
wire_profile: Arc<CodexWireProfile>,
identity_secret: [u8; 32],
usage_record_options: UsageRecordOptions,
) -> Result<Self, CustomCaError> {
@@ -214,7 +215,7 @@ impl Services {
let client = CodexBackendClient::new(
build_reqwest_client()?,
config.api.base_url.clone(),
fingerprint.clone(),
wire_profile.clone(),
)
.with_websocket_initial_event_timeout(websocket_initial_event_timeout);
if let Some(pool) = &websocket_pool {
@@ -313,6 +314,8 @@ impl Services {
}));
let system_update = Arc::new(SystemUpdateService::from_env());
let desktop_release = DesktopReleaseStatus::default();
Ok(Self {
database,
redis,
@@ -333,7 +336,8 @@ impl Services {
session_affinity,
codex,
websocket_pool,
fingerprint,
wire_profile,
desktop_release,
account_pseudonymizer,
system_update,
connection_drain,
@@ -370,7 +374,8 @@ impl From<&Services> for crate::api::router::ApiServices {
token_refresh: services.token_refresh.clone(),
responses: services.responses.clone(),
session_affinity: services.session_affinity.clone(),
fingerprint: services.fingerprint.clone(),
wire_profile: services.wire_profile.clone(),
desktop_release: services.desktop_release.clone(),
process_control: Arc::new(crate::bootstrap::shutdown::RuntimeProcessControl),
system_update: services.system_update.clone(),
connection_drain: services.connection_drain.clone(),
@@ -419,24 +424,19 @@ pub fn apply_settings_to_config(config: &mut AppConfig, settings: &SettingsSnaps
config.auth.rotation_strategy = settings.rotation_strategy.clone();
}
pub fn fingerprint_from_config(
config: &crate::bootstrap::config::FingerprintConfig,
) -> Fingerprint {
Fingerprint {
pub fn wire_profile_from_config(
config: &crate::bootstrap::config::WireProfileConfig,
) -> CodexWireProfile {
CodexWireProfile {
originator: config.originator.clone(),
app_version: config.app_version.clone(),
build_number: config.build_number.clone(),
platform: config.platform.clone(),
codex_version: config.codex_version.clone(),
desktop_version: config.desktop_version.clone(),
desktop_build: config.desktop_build.clone(),
os_type: config.os_type.clone(),
os_version: config.os_version.clone(),
arch: config.arch.clone(),
chromium_version: config.chromium_version.clone(),
user_agent_template: config.user_agent_template.clone(),
default_headers: config
.default_headers
.iter()
.map(|header| (header.name.clone(), header.value.clone()))
.collect(),
header_order: config.header_order.clone(),
updated_at: None,
terminal: config.terminal.clone(),
verified_at: config.verified_at,
}
}
@@ -560,14 +560,12 @@ async fn build_router(
SettingsService::load_or_initialize(settings_snapshot_from_config(&config), &pool).await?;
apply_settings_to_config(&mut config, &settings);
let fingerprint_store = PgFingerprintStore::new(pool.clone());
let stores = BackgroundTaskStores {
redis: redis.clone(),
accounts: PgAccountStore::new(pool.clone()),
admin_users: PgAdminUserStore::new(pool.clone()),
admin_sessions: RedisAdminSessionStore::new(redis.clone()),
cookies: PgCookieStore::new(pool.clone()),
fingerprints: fingerprint_store.clone(),
session_affinity: RedisSessionAffinityStore::new(redis.clone()),
refresh_leases: RedisRefreshLeaseStore::new(redis.clone()),
model_snapshots: RedisModelSnapshotStore::new(redis.clone()),
@@ -580,16 +578,12 @@ async fn build_router(
let data_dir = ensure_data_dir(&config.runtime.data_directory)?;
let identity_secret = load_or_create_identity_secret(&data_dir)?;
let default_fingerprint = fingerprint_from_config(&config.fingerprint);
let runtime_fingerprint = fingerprint_store
.ensure_current_seed(&default_fingerprint)
.await?;
let runtime_fingerprint = RuntimeFingerprint::new(runtime_fingerprint);
let wire_profile = Arc::new(wire_profile_from_config(&config.wire_profile));
let runtime_config = crate::bootstrap::state::RuntimeConfig::from(&config);
let services = Services::try_with_identity_secret_and_usage_record_options(
&config,
stores,
runtime_fingerprint,
wire_profile,
identity_secret,
UsageRecordOptions::from_config(&config),
)?;
+11 -22
View File
@@ -1,19 +1,13 @@
//! 后台任务协调器。
use std::{future::Future, path::PathBuf, sync::Arc, time::Duration};
use std::{future::Future, sync::Arc, time::Duration};
use tokio::task::JoinHandle;
use super::{
cookie_cleanup::CookieCleanupTask,
fingerprint_update::{
CODEX_DESKTOP_APPCAST_URL, DEFAULT_EXTRACTED_FINGERPRINT_PATH,
start_fingerprint_update_task,
},
model_refresh::ModelRefreshTask,
quota_refresh::QuotaRefreshTask,
retention_trim::RetentionTrimTask,
token_refresh::TokenRefreshTask,
cookie_cleanup::CookieCleanupTask, desktop_release_update::start_desktop_release_update_task,
model_refresh::ModelRefreshTask, quota_refresh::QuotaRefreshTask,
retention_trim::RetentionTrimTask, token_refresh::TokenRefreshTask,
};
/// 后台任务协调器。
@@ -46,7 +40,6 @@ impl TaskCoordinator {
stores.usage_records.clone(),
stores.ops_errors.clone(),
stores.request_buckets.clone(),
stores.fingerprints.clone(),
)
.start(),
);
@@ -94,17 +87,13 @@ impl TaskCoordinator {
.with_cookie_store(stores.cookies.clone())
.start(),
);
coordinator.push("fingerprint_update", {
let fingerprint = services.fingerprint.snapshot();
start_fingerprint_update_task(
stores.fingerprints.clone(),
services.fingerprint.clone(),
CODEX_DESKTOP_APPCAST_URL.to_string(),
PathBuf::from(DEFAULT_EXTRACTED_FINGERPRINT_PATH),
fingerprint.app_version,
fingerprint.build_number,
)
});
coordinator.push(
"desktop_release_update",
start_desktop_release_update_task(
services.desktop_release.clone(),
crate::upstream::openai::desktop_release::CODEX_DESKTOP_APPCAST_URL.to_string(),
),
);
if let Some(pool) = &services.websocket_pool {
coordinator.push(
"websocket_pool",
@@ -0,0 +1,57 @@
//! Codex Desktop 官方发布观测任务接线。
use tracing::{info, warn};
use crate::upstream::openai::desktop_release::{
APPCAST_POLL_INTERVAL, DesktopReleaseChecker, DesktopReleaseStatus,
};
use super::{
coordinator::SchedulerHandle,
periodic::{PeriodicTaskConfig, PeriodicTaskRunner, spawn_periodic_task},
};
/// 启动 Desktop 官方发布检查后台任务。
pub fn start_desktop_release_update_task(
status: DesktopReleaseStatus,
appcast_url: String,
) -> SchedulerHandle {
let checker = DesktopReleaseChecker::with_client(reqwest::Client::new(), appcast_url, status);
spawn_periodic_task(
DesktopReleaseUpdateTask {
checker,
first_tick: true,
},
PeriodicTaskConfig::new(
APPCAST_POLL_INTERVAL.as_secs(),
"Codex Desktop 发布检查器已启动",
"Codex Desktop 发布检查器已关闭",
),
)
}
struct DesktopReleaseUpdateTask {
checker: DesktopReleaseChecker,
first_tick: bool,
}
impl PeriodicTaskRunner for DesktopReleaseUpdateTask {
fn tick(&mut self) -> super::periodic::TaskFuture<'_, ()> {
Box::pin(async move {
let first_tick = std::mem::take(&mut self.first_tick);
match self.checker.check_and_record().await {
Ok(release) => info!(
version = %release.version,
build = %release.build,
"Codex Desktop 最新发布信息已刷新"
),
Err(error) if first_tick => {
warn!(error = %error, "Codex Desktop 首次发布检查失败");
}
Err(error) => {
warn!(error = %error, "Codex Desktop 定期发布检查失败");
}
}
})
}
}
@@ -1,85 +0,0 @@
//! 指纹更新任务接线。
use std::path::PathBuf;
use tracing::{info, warn};
use crate::upstream::openai::fingerprint::{
APPCAST_POLL_INTERVAL, PgFingerprintStore, RuntimeFingerprint, UpdateChecker,
};
use super::{
coordinator::SchedulerHandle,
periodic::{PeriodicTaskConfig, PeriodicTaskRunner, spawn_periodic_task},
};
/// Codex Desktop 官方 appcast 地址。
pub const CODEX_DESKTOP_APPCAST_URL: &str =
"https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
/// 本地可选完整指纹提取文件路径。
pub const DEFAULT_EXTRACTED_FINGERPRINT_PATH: &str = "data/extracted-fingerprint.json";
/// 启动指纹自动更新后台任务。
pub fn start_fingerprint_update_task(
store: PgFingerprintStore,
runtime_fingerprint: RuntimeFingerprint,
appcast_url: String,
extracted_fingerprint_path: PathBuf,
current_version: String,
current_build: String,
) -> SchedulerHandle {
let update_checker = UpdateChecker::with_client(
store,
reqwest::Client::new(),
appcast_url,
extracted_fingerprint_path,
current_version,
current_build,
);
spawn_periodic_task(
FingerprintUpdateTask {
checker: update_checker,
runtime_fingerprint,
first_tick: true,
},
PeriodicTaskConfig::new(
APPCAST_POLL_INTERVAL.as_secs(),
"Fingerprint 后台版本检查器已启动",
"Fingerprint 后台版本检查器已关闭",
),
)
}
struct FingerprintUpdateTask {
checker: UpdateChecker,
runtime_fingerprint: RuntimeFingerprint,
first_tick: bool,
}
impl PeriodicTaskRunner for FingerprintUpdateTask {
fn tick(&mut self) -> super::periodic::TaskFuture<'_, ()> {
Box::pin(async move {
let first_tick = std::mem::take(&mut self.first_tick);
match self.checker.check_and_apply_update().await {
Ok(Some(fingerprint)) => {
let app_version = fingerprint.app_version.clone();
let build_number = fingerprint.build_number.clone();
self.runtime_fingerprint.replace(fingerprint);
info!(
app_version = %app_version,
build_number = %build_number,
"Fingerprint 运行时版本已更新"
);
}
Ok(None) => {}
Err(error) => {
if first_tick {
warn!(error = %error, "Fingerprint 首次版本检查失败");
} else {
warn!(error = %error, "Fingerprint 定期版本检查失败");
}
}
}
})
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
mod cleanup;
pub mod cookie_cleanup;
pub mod coordinator;
pub mod fingerprint_update;
pub mod desktop_release_update;
pub mod model_refresh;
mod periodic;
pub mod quota_refresh;
+3 -21
View File
@@ -3,12 +3,9 @@
use chrono::Utc;
use tracing::{info, warn};
use crate::{
telemetry::{
buckets::store::PgRequestBucketStore, ops::store::PgOpsErrorLogStore,
usage::store::PgUsageRecordStore,
},
upstream::openai::fingerprint::PgFingerprintStore,
use crate::telemetry::{
buckets::store::PgRequestBucketStore, ops::store::PgOpsErrorLogStore,
usage::store::PgUsageRecordStore,
};
use super::{
@@ -17,13 +14,11 @@ use super::{
};
const RETENTION_TRIM_INTERVAL_SECS: u64 = 60 * 60;
const FINGERPRINT_HISTORY_LIMIT: std::num::NonZeroU32 = std::num::NonZeroU32::new(100).unwrap();
pub struct RetentionTrimTask {
usage_records: PgUsageRecordStore,
ops_errors: PgOpsErrorLogStore,
buckets: PgRequestBucketStore,
fingerprints: PgFingerprintStore,
}
impl RetentionTrimTask {
@@ -31,13 +26,11 @@ impl RetentionTrimTask {
usage_records: PgUsageRecordStore,
ops_errors: PgOpsErrorLogStore,
buckets: PgRequestBucketStore,
fingerprints: PgFingerprintStore,
) -> Self {
Self {
usage_records,
ops_errors,
buckets,
fingerprints,
}
}
@@ -70,17 +63,6 @@ impl RetentionTrimTask {
Ok(_) => {}
Err(error) => warn!(error = %error, "清理请求时间桶失败"),
}
match self
.fingerprints
.trim_update_history_to_limit(FINGERPRINT_HISTORY_LIMIT)
.await
{
Ok(deleted) if deleted > 0 => {
info!(deleted, "已清理超限指纹更新历史")
}
Ok(_) => {}
Err(error) => warn!(error = %error, "清理超限指纹更新历史失败"),
}
}
}
+5 -2
View File
@@ -69,14 +69,17 @@ impl StreamFinalizer {
controllers
.leave_stream(controller_scope, controller_context, &summary, &body)
.await;
if timeout(LEASE_FINALIZE_TIMEOUT, account_lease.complete())
let mut lease_completion = Box::pin(account_lease.complete());
if timeout(LEASE_FINALIZE_TIMEOUT, lease_completion.as_mut())
.await
.is_err()
{
tracing::warn!(
timeout_ms = LEASE_FINALIZE_TIMEOUT.as_millis(),
"Timed out finalizing response account lease"
"Response account lease is continuing finalization in the background"
);
// 超时只约束客户端收尾,不取消已经开始的 slot 释放与请求用量持久化。
drop(tokio::spawn(lease_completion));
}
finish_client_stream(sender, event_sender, &mut summary).await;
}
@@ -231,35 +231,3 @@ create index idx_account_cookies_account_domain
on account_cookies(account_id, domain);
create index idx_account_cookies_expires
on account_cookies(expires_at) where expires_at is not null;
create table fingerprints (
id text primary key,
originator text not null,
app_version text not null,
build_number text not null,
platform text not null,
arch text not null,
chromium_version text not null,
user_agent_template text not null,
default_headers_json jsonb not null,
header_order_json jsonb not null,
source text not null,
created_at timestamptz not null,
updated_at timestamptz not null
);
create table fingerprint_update_history (
id text primary key,
current_fingerprint_id text not null references fingerprints(id) on delete cascade,
app_version text not null,
build_number text not null,
chromium_version text,
source text not null,
manifest_json jsonb,
created_at timestamptz not null
);
create index idx_fingerprint_update_history_created_id
on fingerprint_update_history(created_at desc, id desc);
create index idx_fingerprint_update_history_fingerprint
on fingerprint_update_history(current_fingerprint_id);
+122 -37
View File
@@ -12,10 +12,13 @@ use crate::infra::{
};
use super::account_usage::query::{AccountUsageTimeBucket, RetainedUsageSummary};
use super::usage::insights::RequestHealthTimeBucket;
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4;
const HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD: u64 = 3;
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 99.0;
const TREND_SLOT_MINUTES: i64 = 15;
#[derive(Debug, Clone, Copy, Default)]
@@ -40,7 +43,35 @@ pub struct DashboardHealthTimelineData {
title: String,
description: String,
reliability_display: String,
points: String,
status: DashboardHealthTimelineStatus,
success_requests: u64,
failed_requests: u64,
cancelled_requests: u64,
caller_error_requests: u64,
points: Vec<DashboardHealthTimelinePointData>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DashboardHealthTimelinePointData {
time: String,
status: DashboardHealthTimelineStatus,
reliability_display: String,
success_requests: u64,
failed_requests: u64,
cancelled_requests: u64,
caller_error_requests: u64,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
enum DashboardHealthTimelineStatus {
Future,
NoData,
Unavailable,
Unstable,
LowSample,
Stable,
}
#[derive(Debug, Serialize)]
@@ -148,6 +179,14 @@ struct UsageWindow {
min_first_token_bucket_latency: u64,
}
#[derive(Debug, Clone, Copy, Default)]
struct HealthWindow {
success_requests: u64,
failed_requests: u64,
cancelled_requests: u64,
caller_error_requests: u64,
}
impl UsageWindow {
fn tokens(self) -> u64 {
self.input_tokens + self.output_tokens
@@ -320,13 +359,13 @@ pub fn dashboard_trend_data_at(
}
pub fn dashboard_health_timeline_data(
buckets: &[AccountUsageTimeBucket],
buckets: &[RequestHealthTimeBucket],
) -> DashboardHealthTimelineData {
dashboard_health_timeline_data_at(buckets, Utc::now())
}
pub fn dashboard_health_timeline_data_at(
records: &[AccountUsageTimeBucket],
records: &[RequestHealthTimeBucket],
now: DateTime<Utc>,
) -> DashboardHealthTimelineData {
let current_slot = china_quarter_hour_start(now);
@@ -335,7 +374,7 @@ pub fn dashboard_health_timeline_data_at(
.map(|index| {
(
start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index),
UsageWindow::default(),
HealthWindow::default(),
)
})
.collect::<Vec<_>>();
@@ -348,36 +387,62 @@ pub fn dashboard_health_timeline_data_at(
.iter_mut()
.find(|(bucket_start, _)| *bucket_start == record_slot)
{
apply_bucket(bucket, record);
bucket.success_requests = bucket
.success_requests
.saturating_add(record.success_requests);
bucket.failed_requests = bucket
.failed_requests
.saturating_add(record.failed_requests);
bucket.cancelled_requests = bucket
.cancelled_requests
.saturating_add(record.cancelled_requests);
bucket.caller_error_requests = bucket
.caller_error_requests
.saturating_add(record.caller_error_requests);
}
}
let (requests, errors) = buckets
let totals = buckets
.iter()
.filter(|(bucket_start, _)| *bucket_start <= current_slot)
.fold((0, 0), |(requests, errors), (_, bucket)| {
(requests + bucket.requests, errors + bucket.errors)
.fold(HealthWindow::default(), |mut totals, (_, bucket)| {
totals.success_requests = totals
.success_requests
.saturating_add(bucket.success_requests);
totals.failed_requests = totals
.failed_requests
.saturating_add(bucket.failed_requests);
totals.cancelled_requests = totals
.cancelled_requests
.saturating_add(bucket.cancelled_requests);
totals.caller_error_requests = totals
.caller_error_requests
.saturating_add(bucket.caller_error_requests);
totals
});
let reliability = (requests > 0)
.then(|| ((requests - errors) as f64 / requests as f64 * 1000.0).round() / 10.0);
DashboardHealthTimelineData {
title: "请求健康时间线".to_string(),
description: "请求可靠性".to_string(),
reliability_display: reliability
.map(|value| format!("{value:.1}%"))
.unwrap_or_else(|| "-".to_string()),
description: "有效请求可用性".to_string(),
reliability_display: format_health_reliability(totals),
status: health_status(totals, false),
success_requests: totals.success_requests,
failed_requests: totals.failed_requests,
cancelled_requests: totals.cancelled_requests,
caller_error_requests: totals.caller_error_requests,
points: buckets
.into_iter()
.map(|(bucket_start, bucket)| {
let success_rate = if bucket.requests > 0 {
(bucket.requests.saturating_sub(bucket.errors) as f64 / bucket.requests as f64
* 1000.0)
.round()
/ 10.0
} else {
0.0
};
health_tone(bucket, success_rate, bucket_start > current_slot)
.enumerate()
.map(|(index, (bucket_start, bucket))| {
let elapsed_minutes = index as i64 * HEALTH_TIMELINE_SLOT_MINUTES;
DashboardHealthTimelinePointData {
time: format!("{:02}:{:02}", elapsed_minutes / 60, elapsed_minutes % 60),
status: health_status(bucket, bucket_start > current_slot),
reliability_display: format_health_reliability(bucket),
success_requests: bucket.success_requests,
failed_requests: bucket.failed_requests,
cancelled_requests: bucket.cancelled_requests,
caller_error_requests: bucket.caller_error_requests,
}
})
.collect(),
}
@@ -510,23 +575,43 @@ fn first_token_bucket_latency(record: &AccountUsageTimeBucket) -> Option<u64> {
.filter(|latency| *latency > 0)
}
fn health_tone(bucket: UsageWindow, success_rate: f64, is_future: bool) -> char {
let successes = bucket.requests.saturating_sub(bucket.errors);
fn health_status(bucket: HealthWindow, is_future: bool) -> DashboardHealthTimelineStatus {
let eligible_requests = bucket
.success_requests
.saturating_add(bucket.failed_requests);
if is_future {
'0'
} else if bucket.requests == 0 {
'1'
} else if successes == 0 {
'2'
} else if success_rate < 90.0 {
'3'
} else if successes < HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD {
'4'
DashboardHealthTimelineStatus::Future
} else if eligible_requests == 0 {
DashboardHealthTimelineStatus::NoData
} else if bucket.success_requests == 0
&& bucket.failed_requests >= HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD
{
DashboardHealthTimelineStatus::Unavailable
} else if eligible_requests < HEALTH_TIMELINE_MIN_SAMPLE_SIZE {
DashboardHealthTimelineStatus::LowSample
} else if health_reliability(bucket)
.is_some_and(|reliability| reliability < HEALTH_TIMELINE_STABLE_RELIABILITY)
{
DashboardHealthTimelineStatus::Unstable
} else {
'5'
DashboardHealthTimelineStatus::Stable
}
}
fn health_reliability(bucket: HealthWindow) -> Option<f64> {
let eligible_requests = bucket
.success_requests
.saturating_add(bucket.failed_requests);
(eligible_requests > 0)
.then(|| bucket.success_requests as f64 / eligible_requests as f64 * 100.0)
}
fn format_health_reliability(bucket: HealthWindow) -> String {
health_reliability(bucket)
.map(|value| format!("{value:.1}%"))
.unwrap_or_else(|| "-".to_string())
}
fn summary_cache_hit_rate(summary: &RetainedUsageSummary) -> Option<f64> {
let input_tokens = nonnegative_i64_to_u64(summary.totals.input_tokens);
(input_tokens > 0)
+95 -5
View File
@@ -19,7 +19,8 @@ success_terminals as (
select distinct on (request_key)
request_key, id, request_id, terminal_at, true as is_success,
client_api_key_id, provider, account_id, transport, model_name,
null::text as failure_class, latency_ms, first_token_ms, billing_model,
null::text as failure_class, false as is_client_cancelled,
false as is_caller_error, latency_ms, first_token_ms, billing_model,
service_tier, input_tokens, output_tokens, cached_tokens
from (
select
@@ -40,7 +41,8 @@ error_terminals as (
select distinct on (request_key)
request_key, id, request_id, terminal_at, false as is_success,
client_api_key_id, provider, account_id, transport, model_name,
failure_class, latency_ms, null::bigint as first_token_ms,
failure_class, is_client_cancelled, is_caller_error,
latency_ms, null::bigint as first_token_ms,
null::text as billing_model, null::text as service_tier,
null::bigint as input_tokens, null::bigint as output_tokens,
null::bigint as cached_tokens
@@ -56,6 +58,23 @@ error_terminals as (
nullif(metadata_json->>'terminal', ''),
'未分类错误'
) as failure_class,
coalesce((
coalesce(
nullif(failure_class, ''),
nullif(metadata_json->>'failureClass', ''),
nullif(metadata_json->>'upstreamCode', ''),
nullif(metadata_json->>'terminal', ''),
''
) in ('cancelled', 'downstream_closed', 'client_cancelled', 'consumer_dropped')
or (
(status_code = 499 or client_status_code = 499)
and (
metadata_json->>'cancelled' = 'true'
or message = 'v1 responses stream cancelled'
)
)
), false) as is_client_cancelled,
coalesce(metadata_json->>'failureSource' = 'client', false) as is_caller_error,
latency_ms
from ops_error_logs
where created_at >= $1 and created_at < $2
@@ -209,6 +228,8 @@ struct UsageHealthInsights {
total_requests: u64,
success_requests: u64,
failed_requests: u64,
cancelled_requests: u64,
caller_error_requests: u64,
success_rate: f64,
request_change_rate: Option<f64>,
success_rate_change: Option<f64>,
@@ -222,9 +243,21 @@ struct UsageHealthPoint {
label: String,
success_requests: u64,
failed_requests: u64,
cancelled_requests: u64,
caller_error_requests: u64,
error_rate: f64,
}
/// Dashboard 请求健康时间桶,仅统计每个请求的最终终态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestHealthTimeBucket {
pub bucket_start: DateTime<Utc>,
pub success_requests: u64,
pub failed_requests: u64,
pub cancelled_requests: u64,
pub caller_error_requests: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UsagePerformanceInsights {
@@ -387,6 +420,47 @@ pub async fn diagnostics(
})
}
/// 按 15 分钟聚合最终请求终态;客户端取消与调用方错误保持独立,不污染可用性。
pub async fn health_timeline(
pool: &PgPool,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> PgUsageRecordStoreResult<Vec<RequestHealthTimeBucket>> {
let bucket = InsightsGranularity::QuarterHour.bucket_expression("terminal_at");
let sql = format!(
"with {TERMINAL_REQUESTS_CTE}\n\
select {bucket} as bucket_start,\n\
count(*) filter (where is_success)::bigint as success_requests,\n\
count(*) filter (\n\
where not is_success and not is_client_cancelled and not is_caller_error\n\
)::bigint as failed_requests,\n\
count(*) filter (where is_client_cancelled)::bigint as cancelled_requests,\n\
count(*) filter (\n\
where is_caller_error and not is_client_cancelled\n\
)::bigint as caller_error_requests\n\
from terminal_requests\n\
group by bucket_start\n\
order by bucket_start"
);
// SAFETY: 时间桶表达式与 CTE 都是模块内固定 SQL,时间范围仍使用绑定参数。
let rows = sqlx::query(AssertSqlSafe(sql))
.bind(start)
.bind(end)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| RequestHealthTimeBucket {
bucket_start: row.get("bucket_start"),
success_requests: nonnegative(row.get("success_requests")),
failed_requests: nonnegative(row.get("failed_requests")),
cancelled_requests: nonnegative(row.get("cancelled_requests")),
caller_error_requests: nonnegative(row.get("caller_error_requests")),
})
.collect())
}
async fn load_health(
pool: &PgPool,
start: DateTime<Utc>,
@@ -401,7 +475,13 @@ async fn load_health(
select case when terminal_at >= $3 then 'current' else 'previous' end as period,\n\
{bucket} as bucket_start,\n\
count(*) filter (where is_success)::bigint as success_requests,\n\
count(*) filter (where not is_success)::bigint as failed_requests\n\
count(*) filter (\n\
where not is_success and not is_client_cancelled and not is_caller_error\n\
)::bigint as failed_requests,\n\
count(*) filter (where is_client_cancelled)::bigint as cancelled_requests,\n\
count(*) filter (\n\
where is_caller_error and not is_client_cancelled\n\
)::bigint as caller_error_requests\n\
from terminal_requests\n\
group by period, bucket_start\n\
order by bucket_start"
@@ -417,17 +497,23 @@ async fn load_health(
let mut current = HashMap::new();
let mut current_success = 0_u64;
let mut current_failed = 0_u64;
let mut current_cancelled = 0_u64;
let mut current_caller_error = 0_u64;
let mut previous_success = 0_u64;
let mut previous_failed = 0_u64;
for row in rows {
let success = nonnegative(row.get("success_requests"));
let failed = nonnegative(row.get("failed_requests"));
let cancelled = nonnegative(row.get("cancelled_requests"));
let caller_error = nonnegative(row.get("caller_error_requests"));
if row.get::<String, _>("period") == "current" {
current_success = current_success.saturating_add(success);
current_failed = current_failed.saturating_add(failed);
current_cancelled = current_cancelled.saturating_add(cancelled);
current_caller_error = current_caller_error.saturating_add(caller_error);
current.insert(
row.get::<DateTime<Utc>, _>("bucket_start"),
(success, failed),
(success, failed, cancelled, caller_error),
);
} else {
previous_success = previous_success.saturating_add(success);
@@ -441,7 +527,7 @@ async fn load_health(
let points = bucket_starts(start, end, granularity)
.into_iter()
.map(|bucket| {
let (success_requests, failed_requests) =
let (success_requests, failed_requests, cancelled_requests, caller_error_requests) =
current.get(&bucket).copied().unwrap_or_default();
let point_total = success_requests.saturating_add(failed_requests);
UsageHealthPoint {
@@ -449,6 +535,8 @@ async fn load_health(
label: bucket_label(bucket, granularity),
success_requests,
failed_requests,
cancelled_requests,
caller_error_requests,
error_rate: rate(failed_requests, point_total),
}
})
@@ -457,6 +545,8 @@ async fn load_health(
total_requests: total,
success_requests: current_success,
failed_requests: current_failed,
cancelled_requests: current_cancelled,
caller_error_requests: current_caller_error,
success_rate,
request_change_rate: relative_change(total, previous_total),
success_rate_change: (previous_total > 0).then_some(success_rate - previous_success_rate),
+13 -2
View File
@@ -11,8 +11,8 @@ use crate::{
telemetry::{
billing,
usage::insights::{
UsageDiagnosticsDimension, UsageDiagnosticsInsights, UsageInsightsOverview,
diagnostics, overview,
RequestHealthTimeBucket, UsageDiagnosticsDimension, UsageDiagnosticsInsights,
UsageInsightsOverview, diagnostics, health_timeline, overview,
},
usage::store::{
PgUsageRecordStore, PgUsageRecordStoreResult, UsageRecordFilter, push_filter,
@@ -239,6 +239,17 @@ impl UsageQueryService {
.map_err(|_| UsageQueryError::List)
}
/// 读取按最终请求终态去重的 15 分钟健康时间桶。
pub async fn health_timeline(
&self,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<Vec<RequestHealthTimeBucket>, UsageQueryError> {
health_timeline(self.store.pool(), start, end)
.await
.map_err(|_| UsageQueryError::List)
}
/// 按维度读取请求热点诊断。
pub async fn insights_diagnostics(
&self,
@@ -0,0 +1,204 @@
//! Codex Desktop 官方发布制品观测。
use std::{
sync::{Arc, RwLock},
time::Duration,
};
use chrono::{DateTime, Utc};
use roxmltree::{Document, Node};
const APPCAST_TIMEOUT: Duration = Duration::from_secs(30);
/// Codex Desktop 官方 appcast 地址。
pub const CODEX_DESKTOP_APPCAST_URL: &str =
"https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
/// 官方发布检查周期。
pub const APPCAST_POLL_INTERVAL: Duration = Duration::from_hours(24);
/// appcast 中的最新 Desktop 完整制品信息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DesktopRelease {
pub version: String,
pub build: String,
pub published_at: Option<DateTime<Utc>>,
pub minimum_system_version: Option<String>,
pub hardware_requirements: Option<String>,
pub download_url: Option<String>,
pub download_size: Option<u64>,
pub signature_present: bool,
}
/// 最近一次 Desktop 发布检查的内存快照。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DesktopReleaseSnapshot {
pub checked_at: Option<DateTime<Utc>>,
pub latest: Option<DesktopRelease>,
pub last_error: Option<String>,
}
/// Dashboard 与后台检查器共享的 Desktop 发布观测状态。
#[derive(Debug, Clone, Default)]
pub struct DesktopReleaseStatus {
snapshot: Arc<RwLock<DesktopReleaseSnapshot>>,
}
impl DesktopReleaseStatus {
pub fn snapshot(&self) -> DesktopReleaseSnapshot {
self.snapshot
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn record_success(&self, checked_at: DateTime<Utc>, latest: DesktopRelease) {
*self
.snapshot
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = DesktopReleaseSnapshot {
checked_at: Some(checked_at),
latest: Some(latest),
last_error: None,
};
}
pub fn record_failure(&self, checked_at: DateTime<Utc>, error: String) {
let mut snapshot = self
.snapshot
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
snapshot.checked_at = Some(checked_at);
snapshot.last_error = Some(error);
}
}
/// 读取官方 appcast 并更新发布观测状态。
#[derive(Clone)]
pub struct DesktopReleaseChecker {
client: reqwest::Client,
appcast_url: String,
status: DesktopReleaseStatus,
}
impl DesktopReleaseChecker {
pub fn with_client(
client: reqwest::Client,
appcast_url: impl Into<String>,
status: DesktopReleaseStatus,
) -> Self {
Self {
client,
appcast_url: appcast_url.into(),
status,
}
}
pub async fn check_and_record(&self) -> Result<DesktopRelease, DesktopReleaseError> {
let checked_at = Utc::now();
let result = self.fetch_latest().await;
match result {
Ok(release) => {
self.status.record_success(checked_at, release.clone());
Ok(release)
}
Err(error) => {
self.status.record_failure(checked_at, error.to_string());
Err(error)
}
}
}
async fn fetch_latest(&self) -> Result<DesktopRelease, DesktopReleaseError> {
let response = self
.client
.get(&self.appcast_url)
.timeout(APPCAST_TIMEOUT)
.send()
.await?;
if !response.status().is_success() {
return Err(DesktopReleaseError::AppcastFetch(
response.status().as_u16(),
));
}
parse_latest_desktop_release(&response.text().await?)
}
}
/// 解析 appcast 中按顺序出现的首个完整发布项。
pub fn parse_latest_desktop_release(xml: &str) -> Result<DesktopRelease, DesktopReleaseError> {
let document = Document::parse(xml)?;
let item = document
.descendants()
.find(|node| node.is_element() && node.tag_name().name() == "item")
.ok_or(DesktopReleaseError::MissingItem)?;
let enclosure = item
.children()
.find(|node| node.is_element() && node.tag_name().name() == "enclosure");
let version = child_text(item, "shortVersionString")
.or_else(|| local_attribute(enclosure, "shortVersionString"))
.ok_or(DesktopReleaseError::MissingField("shortVersionString"))?;
let build = child_text(item, "version")
.or_else(|| local_attribute(enclosure, "version"))
.ok_or(DesktopReleaseError::MissingField("version"))?;
let published_at = child_text(item, "pubDate")
.map(|value| {
DateTime::parse_from_rfc2822(&value)
.map(|value| value.to_utc())
.map_err(|_| DesktopReleaseError::InvalidField("pubDate"))
})
.transpose()?;
let download_size = local_attribute(enclosure, "length")
.map(|value| {
value
.parse::<u64>()
.map_err(|_| DesktopReleaseError::InvalidField("enclosure.length"))
})
.transpose()?;
Ok(DesktopRelease {
version,
build,
published_at,
minimum_system_version: child_text(item, "minimumSystemVersion"),
hardware_requirements: child_text(item, "hardwareRequirements"),
download_url: local_attribute(enclosure, "url"),
download_size,
signature_present: local_attribute(enclosure, "edSignature").is_some(),
})
}
fn child_text(parent: Node<'_, '_>, local_name: &str) -> Option<String> {
parent
.children()
.find(|node| node.is_element() && node.tag_name().name() == local_name)
.and_then(|node| node.text())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn local_attribute(node: Option<Node<'_, '_>>, local_name: &str) -> Option<String> {
node?
.attributes()
.find(|attribute| attribute.name() == local_name)
.map(|attribute| attribute.value().trim())
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
#[derive(Debug, thiserror::Error)]
pub enum DesktopReleaseError {
#[error("Codex Desktop appcast request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Codex Desktop appcast returned HTTP {0}")]
AppcastFetch(u16),
#[error("Codex Desktop appcast XML is invalid: {0}")]
Xml(#[from] roxmltree::Error),
#[error("Codex Desktop appcast contains no release item")]
MissingItem,
#[error("Codex Desktop appcast release is missing `{0}`")]
MissingField(&'static str),
#[error("Codex Desktop appcast field `{0}` is invalid")]
InvalidField(&'static str),
}
@@ -1,11 +0,0 @@
//! OpenAI 客户端指纹。
mod runtime;
mod store;
mod types;
mod updater;
pub use runtime::*;
pub use store::*;
pub use types::*;
pub use updater::*;
@@ -1,40 +0,0 @@
//! OpenAI 客户端运行时指纹快照。
use std::sync::{Arc, RwLock};
use super::types::Fingerprint;
#[derive(Debug, Clone)]
pub struct RuntimeFingerprint {
current: Arc<RwLock<Arc<Fingerprint>>>,
}
impl RuntimeFingerprint {
pub fn new(fingerprint: Fingerprint) -> Self {
Self {
current: Arc::new(RwLock::new(Arc::new(fingerprint))),
}
}
pub fn current(&self) -> Arc<Fingerprint> {
self.current
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn snapshot(&self) -> Fingerprint {
self.current().as_ref().clone()
}
pub fn replace(&self, fingerprint: Fingerprint) {
*self
.current
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::new(fingerprint);
}
}
// ---------------------------------------------------------------------------
// 指纹历史记录与仓储
// ---------------------------------------------------------------------------
@@ -1,245 +0,0 @@
//! OpenAI 客户端指纹 PostgreSQL 存储。
use std::num::NonZeroU32;
use chrono::{DateTime, Utc};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row};
use uuid::Uuid;
use super::types::Fingerprint;
pub const CURRENT_FINGERPRINT_ID: &str = "current";
const AUTO_UPDATE_SOURCE: &str = "auto_update";
const CONFIG_SEED_SOURCE: &str = "config_seed";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredHeader {
name: String,
value: String,
}
/// 指纹自动更新状态。
#[derive(Clone)]
pub struct PgFingerprintStore {
pool: PgPool,
}
impl PgFingerprintStore {
/// 使用给定连接池构造仓储。
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
/// 写入当前指纹默认值;如果当前槽位已存在,直接读取数据库值。
pub async fn ensure_current_seed(
&self,
default_fingerprint: &Fingerprint,
) -> Result<Fingerprint, sqlx::Error> {
if let Some(fingerprint) = self.load_current().await? {
return Ok(fingerprint);
}
let now = Utc::now();
sqlx::query(
r"
insert into fingerprints (
id,
originator,
app_version,
build_number,
platform,
arch,
chromium_version,
user_agent_template,
default_headers_json,
header_order_json,
source,
created_at,
updated_at
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)
on conflict (id) do nothing
",
)
.bind(CURRENT_FINGERPRINT_ID)
.bind(&default_fingerprint.originator)
.bind(&default_fingerprint.app_version)
.bind(&default_fingerprint.build_number)
.bind(&default_fingerprint.platform)
.bind(&default_fingerprint.arch)
.bind(&default_fingerprint.chromium_version)
.bind(&default_fingerprint.user_agent_template)
.bind(sqlx::types::Json(encode_default_headers(
&default_fingerprint.default_headers,
)))
.bind(sqlx::types::Json(&default_fingerprint.header_order))
.bind(CONFIG_SEED_SOURCE)
.bind(now)
.execute(&self.pool)
.await?;
self.load_current().await?.ok_or(sqlx::Error::RowNotFound)
}
/// 更新当前指纹中的自动更新版本字段。
pub async fn update_current_version(
&self,
app_version: &str,
build_number: &str,
chromium_version: Option<&str>,
) -> Result<(), sqlx::Error> {
let resolved_chromium_version = match chromium_version {
Some(version) => version.to_string(),
None => {
sqlx::query_scalar::<_, String>(
"select chromium_version from fingerprints where id = $1",
)
.bind(CURRENT_FINGERPRINT_ID)
.fetch_one(&self.pool)
.await?
}
};
let result = sqlx::query(
"update fingerprints set app_version = $1, build_number = $2, chromium_version = $3, source = $4, updated_at = $5 where id = $6",
)
.bind(app_version)
.bind(build_number)
.bind(resolved_chromium_version)
.bind(AUTO_UPDATE_SOURCE)
.bind(Utc::now())
.bind(CURRENT_FINGERPRINT_ID)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(sqlx::Error::RowNotFound);
}
Ok(())
}
/// 读取当前运行时指纹。
pub async fn load_current(&self) -> Result<Option<Fingerprint>, sqlx::Error> {
let row = sqlx::query(
r"
select
originator,
app_version,
build_number,
platform,
arch,
chromium_version,
user_agent_template,
default_headers_json,
header_order_json,
updated_at
from fingerprints
where id = $1
",
)
.bind(CURRENT_FINGERPRINT_ID)
.fetch_optional(&self.pool)
.await?;
row.map(|row| fingerprint_from_row(&row)).transpose()
}
/// 记录自动更新历史。
pub async fn insert_update_history(
&self,
app_version: &str,
build_number: &str,
chromium_version: Option<&str>,
manifest_json: Option<&str>,
) -> Result<(), sqlx::Error> {
let manifest = manifest_json
.map(serde_json::from_str::<serde_json::Value>)
.transpose()
.map_err(json_error)?;
sqlx::query(
"insert into fingerprint_update_history (id, current_fingerprint_id, app_version, build_number, chromium_version, source, manifest_json, created_at) values ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.bind(Uuid::new_v4().to_string())
.bind(CURRENT_FINGERPRINT_ID)
.bind(app_version)
.bind(build_number)
.bind(chromium_version)
.bind(AUTO_UPDATE_SOURCE)
.bind(manifest.map(sqlx::types::Json))
.bind(Utc::now())
.execute(&self.pool)
.await?;
Ok(())
}
/// 将指纹更新历史裁剪为最新的指定条数。
pub async fn trim_update_history_to_limit(
&self,
max_entries: NonZeroU32,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
r"
delete from fingerprint_update_history as target
using (
select id
from (
select
id,
row_number() over (order by created_at desc, id desc) as position
from fingerprint_update_history
) as ranked
where position > $1
) as expired
where target.id = expired.id
",
)
.bind(i64::from(max_entries.get()))
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}
fn fingerprint_from_row(row: &sqlx::postgres::PgRow) -> Result<Fingerprint, sqlx::Error> {
let default_headers = row
.try_get::<sqlx::types::Json<Vec<StoredHeader>>, _>("default_headers_json")?
.0;
let header_order = row
.try_get::<sqlx::types::Json<Vec<String>>, _>("header_order_json")?
.0;
let updated_at = row.try_get::<DateTime<Utc>, _>("updated_at")?;
Ok(Fingerprint {
originator: row.try_get("originator")?,
app_version: row.try_get("app_version")?,
build_number: row.try_get("build_number")?,
platform: row.try_get("platform")?,
arch: row.try_get("arch")?,
chromium_version: row.try_get("chromium_version")?,
user_agent_template: row.try_get("user_agent_template")?,
default_headers: decode_default_headers(default_headers),
header_order,
updated_at: Some(updated_at.to_rfc3339()),
})
}
fn encode_default_headers(headers: &IndexMap<String, String>) -> Vec<StoredHeader> {
headers
.iter()
.map(|(name, value)| StoredHeader {
name: name.clone(),
value: value.clone(),
})
.collect()
}
fn decode_default_headers(headers: Vec<StoredHeader>) -> IndexMap<String, String> {
headers
.into_iter()
.map(|header| (header.name, header.value))
.collect()
}
fn json_error(error: serde_json::Error) -> sqlx::Error {
sqlx::Error::Decode(Box::new(error))
}
@@ -1,92 +0,0 @@
//! OpenAI 客户端指纹领域类型。
use chrono::{DateTime, Utc};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Fingerprint {
/// 客户端来源名。
pub originator: String,
/// 应用版本。
pub app_version: String,
/// 构建号。
pub build_number: String,
/// 平台名。
pub platform: String,
/// 架构名。
pub arch: String,
/// Chromium 主版本。
pub chromium_version: String,
/// User-Agent 模板。
pub user_agent_template: String,
/// 默认请求头。
pub default_headers: IndexMap<String, String>,
/// 请求头顺序。
pub header_order: Vec<String>,
/// DB 最后更新时间。
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
impl Fingerprint {
/// 根据模板展开最终 User-Agent。
pub fn user_agent(&self) -> String {
self.user_agent_template
.replace("{version}", &self.app_version)
.replace("{platform}", &self.platform)
.replace("{arch}", &self.arch)
}
/// 生成 `sec-ch-ua` 头值。
pub fn sec_ch_ua(&self) -> String {
format!(
"\"Chromium\";v=\"{}\", \"Not:A-Brand\";v=\"24\"",
self.chromium_version
)
}
}
/// 运行时共享指纹快照。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateState {
/// 最近检查时间。
pub last_check: DateTime<Utc>,
/// 最新版本。
pub latest_version: Option<String>,
/// 最新构建号。
pub latest_build: Option<String>,
/// 下载地址。
pub download_url: Option<String>,
/// 是否有可用更新。
pub update_available: bool,
/// 当前版本。
pub current_version: String,
/// 当前构建号。
pub current_build: String,
}
/// Appcast 检查错误。
#[derive(Debug, Error)]
pub enum UpdateError {
/// HTTP 请求失败。
#[error("HTTP 请求失败: {0}")]
Http(#[from] reqwest::Error),
/// 获取 appcast 失败。
#[error("获取 appcast 失败,状态码: {0}")]
AppcastFetch(u16),
/// 解析 appcast 失败。
#[error("解析 appcast 失败")]
AppcastParse,
/// JSON 序列化失败。
#[error("JSON 序列化失败: {0}")]
Json(#[from] serde_json::Error),
/// 文件操作失败。
#[error("文件操作失败: {0}")]
Io(#[from] std::io::Error),
/// 数据库存储失败。
#[error("数据库操作失败: {0}")]
Database(#[from] sqlx::Error),
}
@@ -1,215 +0,0 @@
//! OpenAI 客户端指纹更新检查器。
use std::{path::PathBuf, sync::Arc, time::Duration};
use chrono::Utc;
use tokio::sync::Mutex;
use tracing::info;
use super::{
store::PgFingerprintStore,
types::{Fingerprint, UpdateError, UpdateState},
};
const APPCAST_TIMEOUT: Duration = Duration::from_secs(30);
pub const APPCAST_POLL_INTERVAL: Duration = Duration::from_hours(24);
type AppcastFields = (Option<String>, Option<String>, Option<String>);
/// Appcast 指纹更新检查器。
#[derive(Clone)]
pub struct UpdateChecker {
store: PgFingerprintStore,
client: reqwest::Client,
appcast_url: String,
extracted_fingerprint_path: PathBuf,
state: Arc<Mutex<InternalState>>,
}
struct InternalState {
current_version: String,
current_build: String,
}
impl UpdateChecker {
/// 使用自定义 client 和路径构造检查器。
pub fn with_client(
store: PgFingerprintStore,
client: reqwest::Client,
appcast_url: impl Into<String>,
extracted_fingerprint_path: PathBuf,
current_version: impl Into<String>,
current_build: impl Into<String>,
) -> Self {
Self {
store,
client,
appcast_url: appcast_url.into(),
extracted_fingerprint_path,
state: Arc::new(Mutex::new(InternalState {
current_version: current_version.into(),
current_build: current_build.into(),
})),
}
}
/// 检查 appcast 是否存在更新。
pub async fn check_for_update(&self) -> Result<UpdateState, UpdateError> {
let state = self.state.lock().await;
let current_version = state.current_version.clone();
let current_build = state.current_build.clone();
drop(state);
let xml = self.fetch_appcast().await?;
let (version, build, download_url) = parse_appcast(&xml)?;
let update_available = version
.as_ref()
.is_some_and(|value| value != &current_version)
|| build.as_ref().is_some_and(|value| value != &current_build);
Ok(UpdateState {
last_check: Utc::now(),
latest_version: version,
latest_build: build,
download_url,
update_available,
current_version,
current_build,
})
}
/// 检查并在需要时应用更新。
pub async fn check_and_apply_update(&self) -> Result<Option<Fingerprint>, UpdateError> {
let update_state = self.check_for_update().await?;
if !update_state.update_available {
return Ok(None);
}
let (Some(version), Some(build)) = (
update_state.latest_version.as_deref(),
update_state.latest_build.as_deref(),
) else {
return Ok(None);
};
let state = self.state.lock().await;
let current_version = state.current_version.clone();
let current_build = state.current_build.clone();
drop(state);
info!(
version = %version,
build = %build,
current_version = %current_version,
current_build = %current_build,
"发现新的 fingerprint 版本"
);
let updated_fingerprint = self.apply_version_update(version, build).await?;
{
let mut state = self.state.lock().await;
state.current_version = version.to_string();
state.current_build = build.to_string();
}
Ok(Some(updated_fingerprint))
}
async fn fetch_appcast(&self) -> Result<String, UpdateError> {
let response = self
.client
.get(&self.appcast_url)
.timeout(APPCAST_TIMEOUT)
.send()
.await?;
if !response.status().is_success() {
return Err(UpdateError::AppcastFetch(response.status().as_u16()));
}
response.text().await.map_err(UpdateError::from)
}
async fn apply_version_update(
&self,
version: &str,
build: &str,
) -> Result<Fingerprint, UpdateError> {
let chromium_version =
load_matching_chromium_version(&self.extracted_fingerprint_path, version, build);
self.store
.update_current_version(version, build, chromium_version.as_deref())
.await?;
self.store
.insert_update_history(version, build, chromium_version.as_deref(), None)
.await?;
let updated = self
.store
.load_current()
.await?
.ok_or(sqlx::Error::RowNotFound)?;
Ok(updated)
}
}
fn parse_appcast(xml: &str) -> Result<AppcastFields, UpdateError> {
let item_start = xml.find("<item>").ok_or(UpdateError::AppcastParse)?;
let item_end = xml.find("</item>").ok_or(UpdateError::AppcastParse)?;
let item = &xml[item_start..item_end];
let version = extract_sparkle_field(item, "shortVersionString");
let build = extract_sparkle_field(item, "version");
let download_url = item.find("url=\"").and_then(|position| {
let start = position + 5;
item[start..]
.find('"')
.map(|end| item[start..start + end].to_string())
});
Ok((version, build, download_url))
}
fn extract_sparkle_field(item: &str, field: &str) -> Option<String> {
let attr_pattern = format!("sparkle:{}=\"", field);
if let Some(position) = item.find(&attr_pattern) {
let start = position + attr_pattern.len();
if let Some(end) = item[start..].find('"') {
return Some(item[start..start + end].to_string());
}
}
let elem_start = format!("<sparkle:{}>", field);
let elem_end = format!("</sparkle:{}>", field);
if let Some(start_position) = item.find(&elem_start) {
let content_start = start_position + elem_start.len();
if let Some(end_position) = item[content_start..].find(&elem_end) {
return Some(item[content_start..content_start + end_position].to_string());
}
}
None
}
fn load_matching_chromium_version(
extracted_fingerprint_path: &PathBuf,
version: &str,
build: &str,
) -> Option<String> {
let content = std::fs::read_to_string(extracted_fingerprint_path).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
let extracted_version = parsed.get("app_version")?.as_str()?;
let extracted_build = parsed.get("build_number")?.as_str()?;
if extracted_version == version && extracted_build == build {
parsed
.get("chromium_version")?
.as_str()
.map(ToString::to_string)
} else {
None
}
}
+2 -1
View File
@@ -1,7 +1,8 @@
//! OpenAI/Codex 上游客户端。
pub mod desktop_release;
pub mod failure;
pub mod fingerprint;
pub mod profile;
pub mod protocol;
pub mod token_client;
pub mod transport;
+46
View File
@@ -0,0 +1,46 @@
//! Codex Desktop 上游请求画像。
use chrono::{DateTime, Utc};
/// 经制品与 Codex 源码核验后固定的上游请求身份。
///
/// 该画像是启动配置,不会被在线发布检查自动改写。Desktop 发布变化后应先重新审计
/// bundled Codex Core,再整体更新此画像。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexWireProfile {
/// `originator` 请求头及 User-Agent 产品名。
pub originator: String,
/// bundled Codex Core 版本;同时用于 `/codex/models?client_version=`。
pub codex_version: String,
/// Desktop 应用版本,用于 app-server `clientInfo.version` 对应的 UA 后缀。
pub desktop_version: String,
/// Desktop 制品构建号,仅用于发布对齐诊断。
pub desktop_build: String,
/// Codex Core UA 中的目标操作系统类型。
pub os_type: String,
/// Codex Core UA 中的目标操作系统版本。
pub os_version: String,
/// Codex Core UA 中的目标架构。
pub arch: String,
/// Codex Core UA 中的终端标记。
pub terminal: String,
/// 此画像最后一次经制品与源码核验的时间。
pub verified_at: DateTime<Utc>,
}
impl CodexWireProfile {
/// 按 Codex Core 的官方格式生成最终 User-Agent。
pub fn user_agent(&self) -> String {
format!(
"{}/{} ({} {}; {}) {} ({}; {})",
self.originator,
self.codex_version,
self.os_type,
self.os_version,
self.arch,
self.terminal,
self.originator,
self.desktop_version,
)
}
}
@@ -13,15 +13,15 @@ use futures::{Stream, StreamExt, TryStreamExt};
use reqwest::{
Client, Response as ReqwestResponse, StatusCode,
header::{
ACCEPT, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, COOKIE, HeaderMap, HeaderName,
HeaderValue, RETRY_AFTER, USER_AGENT,
ACCEPT, AUTHORIZATION, CONTENT_TYPE, COOKIE, HeaderMap, HeaderName, HeaderValue,
RETRY_AFTER, USER_AGENT,
},
};
use serde_json::{Value, map::Map};
use thiserror::Error;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use crate::upstream::openai::fingerprint::{Fingerprint, RuntimeFingerprint};
use crate::upstream::openai::profile::CodexWireProfile;
use crate::upstream::openai::protocol::events::{extract_sse_usage, retry_after_seconds_from_body};
use crate::upstream::openai::protocol::responses::{
CodexResponsesRequest, TransportRequirement, transport_requirement,
@@ -33,10 +33,7 @@ use crate::upstream::openai::protocol::websocket::{
use super::diagnostics::CodexUpstreamDiagnostics;
use super::endpoints::{CODEX_RESPONSES_PATH, endpoint_url};
use super::headers::{
build_ordered_codex_base_headers, insert_optional_header, insert_ordered_headers,
websocket_header_pairs,
};
use super::headers::{build_codex_base_headers, insert_optional_header, websocket_header_pairs};
use super::response_meta;
use super::tls::{CustomCaError, build_reqwest_client_with_custom_ca, custom_ca_env_cache_key};
use super::websocket::{
@@ -385,7 +382,7 @@ pub struct CodexBackendStreamingResponse {
pub struct CodexBackendClient {
pub(super) client: Client,
pub(super) base_url: String,
fingerprint: RuntimeFingerprint,
profile: Arc<CodexWireProfile>,
websocket_pool: Option<Arc<CodexWebSocketPool>>,
websocket_initial_event_timeout: Option<Duration>,
websocket_fast_path_budget: Duration,
@@ -5,14 +5,14 @@ impl CodexBackendClient {
pub fn new(
client: Client,
base_url: impl Into<String>,
fingerprint: RuntimeFingerprint,
profile: Arc<CodexWireProfile>,
) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
Self {
client,
websocket_origin_key: websocket_origin_key(&base_url),
base_url,
fingerprint,
profile,
websocket_pool: None,
websocket_initial_event_timeout: Some(DEFAULT_INITIAL_EVENT_TIMEOUT),
websocket_fast_path_budget: WEBSOCKET_FAST_PATH_BUDGET,
@@ -500,13 +500,12 @@ impl CodexBackendClient {
&self,
context: CodexRequestContext<'_>,
) -> CodexClientResult<Vec<Value>> {
let fingerprint = self.fingerprint.current();
let endpoint = endpoint_url(&self.base_url, "codex/models");
let headers = self.auxiliary_request_headers(&fingerprint, context)?;
let headers = self.auxiliary_request_headers(context)?;
let response = self
.client
.get(endpoint)
.query(&[("client_version", fingerprint.app_version.as_str())])
.query(&[("client_version", self.profile.codex_version.as_str())])
.headers(headers)
.send()
.await?;
@@ -571,16 +570,9 @@ impl CodexBackendClient {
}
fn request_headers(&self, context: CodexRequestContext<'_>) -> CodexClientResult<HeaderMap> {
let fingerprint = self.fingerprint.current();
let request_id = context.client_request_id.unwrap_or(context.request_id);
let ordered_headers = build_ordered_codex_base_headers(
&fingerprint,
context.access_token,
context.account_id,
);
let mut headers = HeaderMap::new();
insert_ordered_headers(&mut headers, &ordered_headers)?;
let mut headers =
build_codex_base_headers(&self.profile, context.access_token, context.account_id)?;
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
@@ -625,19 +617,14 @@ impl CodexBackendClient {
fn auxiliary_request_headers(
&self,
fingerprint: &Fingerprint,
context: CodexRequestContext<'_>,
) -> CodexClientResult<HeaderMap> {
let ordered_headers =
build_ordered_codex_base_headers(fingerprint, context.access_token, context.account_id);
let mut headers = HeaderMap::new();
insert_ordered_headers(&mut headers, &ordered_headers)?;
let mut headers =
build_codex_base_headers(&self.profile, context.access_token, context.account_id)?;
if let Some(cookie_header) = context.cookie_header {
headers.insert(COOKIE, HeaderValue::from_str(cookie_header)?);
}
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("gzip, deflate"));
insert_optional_header(
&mut headers,
"x-codex-installation-id",
@@ -650,11 +637,10 @@ impl CodexBackendClient {
&self,
context: CodexRequestContext<'_>,
) -> CodexClientResult<HeaderMap> {
let fingerprint = self.fingerprint.current();
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_str(&fingerprint.user_agent())?,
HeaderValue::from_str(&self.profile.user_agent())?,
);
headers.insert(
AUTHORIZATION,
@@ -662,7 +648,7 @@ impl CodexBackendClient {
);
headers.insert(
HeaderName::from_static("originator"),
HeaderValue::from_str(&fingerprint.originator)?,
HeaderValue::from_str(&self.profile.originator)?,
);
insert_optional_header(&mut headers, "chatgpt-account-id", context.account_id)?;
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
@@ -1,102 +1,49 @@
use indexmap::IndexMap;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT};
use crate::upstream::openai::fingerprint::Fingerprint;
use crate::upstream::openai::profile::CodexWireProfile;
use super::client::CodexClientResult;
/// 构造标准 Codex 请求头。
/// 构造 Codex Core 为模型请求设置的稳定身份请求头。
pub fn build_codex_base_headers(
fingerprint: &Fingerprint,
profile: &CodexWireProfile,
access_token: &str,
account_id: Option<&str>,
) -> IndexMap<String, String> {
let mut headers = IndexMap::new();
) -> CodexClientResult<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(
"authorization".to_string(),
format!("Bearer {access_token}"),
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {access_token}"))?,
);
if let Some(account_id) = account_id {
headers.insert("chatgpt-account-id".to_string(), account_id.to_string());
}
headers.insert("originator".to_string(), fingerprint.originator.clone());
headers.insert("user-agent".to_string(), fingerprint.user_agent());
headers.insert("sec-ch-ua".to_string(), fingerprint.sec_ch_ua());
for (key, value) in &fingerprint.default_headers {
let key_lower = key.to_ascii_lowercase();
if !headers.contains_key(&key_lower) {
headers.insert(key_lower, value.clone());
}
}
headers
insert_optional_header(&mut headers, "chatgpt-account-id", account_id)?;
headers.insert(
HeaderName::from_static("originator"),
HeaderValue::from_str(&profile.originator)?,
);
headers.insert(USER_AGENT, HeaderValue::from_str(&profile.user_agent())?);
Ok(headers)
}
/// 构造包含标准请求头和额外请求头值的完整请求头集合。
/// 构造含通用请求上下文的 Codex 模型请求头。
pub fn build_codex_headers(
fingerprint: &Fingerprint,
profile: &CodexWireProfile,
access_token: &str,
account_id: Option<&str>,
turn_state: Option<&str>,
request_id: &str,
) -> IndexMap<String, String> {
let mut headers = build_codex_base_headers(fingerprint, access_token, account_id);
) -> CodexClientResult<HeaderMap> {
let mut headers = build_codex_base_headers(profile, access_token, account_id)?;
headers.insert(
"x-openai-internal-codex-residency".to_string(),
"us".to_string(),
HeaderName::from_static("x-openai-internal-codex-residency"),
HeaderValue::from_static("us"),
);
headers.insert("x-client-request-id".to_string(), request_id.to_string());
if let Some(turn_state) = turn_state {
headers.insert("x-codex-turn-state".to_string(), turn_state.to_string());
}
headers.insert("accept".to_string(), "text/event-stream".to_string());
headers
}
/// 按给定顺序重排请求头。
pub fn order_headers(
headers: IndexMap<String, String>,
order: &[String],
) -> IndexMap<String, String> {
let mut ordered = IndexMap::new();
for key in order {
if let Some(value) = headers.get(key) {
ordered.insert(key.clone(), value.clone());
}
}
for (key, value) in headers {
if !ordered.contains_key(&key) {
ordered.insert(key, value);
}
}
ordered
}
/// 构造并按指纹顺序重排完整请求头。
pub fn build_ordered_codex_headers(
fingerprint: &Fingerprint,
access_token: &str,
account_id: Option<&str>,
turn_state: Option<&str>,
request_id: &str,
) -> IndexMap<String, String> {
let headers = build_codex_headers(
fingerprint,
access_token,
account_id,
turn_state,
request_id,
headers.insert(
HeaderName::from_static("x-client-request-id"),
HeaderValue::from_str(request_id)?,
);
order_headers(headers, &fingerprint.header_order)
}
/// 构造并按指纹顺序重排基础请求头(不含请求 ID 和 turn state)。
pub fn build_ordered_codex_base_headers(
fingerprint: &Fingerprint,
access_token: &str,
account_id: Option<&str>,
) -> IndexMap<String, String> {
let headers = build_codex_base_headers(fingerprint, access_token, account_id);
order_headers(headers, &fingerprint.header_order)
insert_optional_header(&mut headers, "x-codex-turn-state", turn_state)?;
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
Ok(headers)
}
pub(super) fn insert_optional_header(
@@ -111,19 +58,6 @@ pub(super) fn insert_optional_header(
Ok(())
}
pub(super) fn insert_ordered_headers(
headers: &mut HeaderMap,
ordered_headers: &IndexMap<String, String>,
) -> CodexClientResult<()> {
for (name, value) in ordered_headers {
headers.insert(
HeaderName::from_bytes(name.as_bytes())?,
HeaderValue::from_str(value)?,
);
}
Ok(())
}
fn header_pairs(headers: &HeaderMap) -> Vec<(String, String)> {
headers
.iter()
+1 -4
View File
@@ -26,10 +26,7 @@ pub use self::{
CODEX_RESPONSES_PATH, CODEX_USAGE_API_PATH, CODEX_USAGE_PATH, WHAM_USAGE_PATH,
endpoint_request_path, endpoint_url, usage_endpoint_urls,
},
headers::{
build_codex_base_headers, build_codex_headers, build_ordered_codex_base_headers,
build_ordered_codex_headers, order_headers,
},
headers::{build_codex_base_headers, build_codex_headers},
websocket_pool::{
CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision,
},
@@ -666,8 +666,8 @@ fn start_pooled_websocket_connect(
biased;
_ = cancellation.cancelled() => {
permit.cancel();
connect_lease.failed().await;
let _ = sender.send(Err(CodexWebSocketExchangeError::SharedConnectFailed));
connect_lease.failed().await;
tracing::info!(
account_id = task_key.account_id(),
conversation_id_hash = task_key.conversation_id_hash(),
@@ -710,8 +710,9 @@ fn start_pooled_websocket_connect(
}
Err(error) => {
let error_message = error.to_string();
connect_lease.failed().await;
// 先交付 opening 原始错误,避免连接池清理侵占前台 fast-path 预算。
let foreground_waiting = sender.send(Err(error)).is_ok();
connect_lease.failed().await;
tracing::warn!(
account_id = task_key.account_id(),
conversation_id_hash = task_key.conversation_id_hash(),
@@ -657,9 +657,10 @@ impl WebSocketPoolConnectLease {
pub(crate) async fn failed(mut self) {
self.cancellation.cancel();
self.pool.fail_connect(&self.key, self.id).await;
// 先唤醒共享等待者,pool mutex 清理不得延迟前台 transport 决策。
self.outcome
.send_replace(WebSocketPoolConnectOutcome::Failed);
self.pool.fail_connect(&self.key, self.id).await;
self.armed = false;
}
}
@@ -18,12 +18,8 @@ async fn admin_accounts_import_should_store_cpr_account_tokens() {
);
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
@@ -618,12 +614,8 @@ async fn admin_accounts_import_should_fetch_usage_to_complete_missing_plan_and_q
let mut config = test_config(test_database_url());
config.api.base_url = format!("{}/backend-api", server.uri());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
let access_token = test_jwt_with_exp(
@@ -830,12 +822,8 @@ async fn admin_accounts_import_should_require_admin_session_cookie() {
let redis = create_test_redis("admin-accounts-import-auth").await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
@@ -11,12 +11,8 @@ async fn admin_accounts_lifecycle_should_update_and_delete_accounts() {
seed_account_related_rows(&pool, "acct_lifecycle").await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis.clone());
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let session_affinity =
codex_proxy_rs::dispatch::affinity::RedisSessionAffinityStore::new(redis);
@@ -873,12 +869,8 @@ async fn admin_account_create_should_derive_claims_and_store_plain_tokens() {
);
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state.clone());
@@ -907,12 +899,8 @@ async fn admin_account_manual_create_should_reject_missing_invalid_expired_or_un
seed_admin_session(&pool, &redis, "session_1").await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
@@ -15,12 +15,8 @@ async fn admin_accounts_list_should_not_expose_account_tokens() {
.execute(&pool).await.unwrap();
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
@@ -23,9 +23,9 @@ use crate::support::jwt::unsigned_jwt;
use crate::support::{
admin::seed_admin_session,
config::test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
mod export_routes;
@@ -196,12 +196,8 @@ async fn admin_accounts_test_app_with_overrides(
config.auth.oauth_token_endpoint = oauth_token_endpoint;
}
let stores = background_task_stores(pool.clone(), redis.clone());
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state.clone());
(
@@ -9,9 +9,9 @@ use tower::util::ServiceExt;
use crate::support::{
config::test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
#[tokio::test]
@@ -237,11 +237,11 @@ async fn admin_login_test_app(
seed_admin_user(&pool, password).await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(codex_proxy_rs::bootstrap::services::Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
wire_profile(profile),
));
let state = codex_proxy_rs::api::AppState::from(services.as_ref());
(
+72 -109
View File
@@ -8,7 +8,7 @@ use codex_proxy_rs::{
bootstrap::services::Services,
infra::time::{china_datetime, china_day_start, china_quarter_hour_start},
telemetry::{usage::store::PgUsageRecordStore, usage::types::UsageRecord},
upstream::openai::fingerprint::Fingerprint,
upstream::openai::profile::CodexWireProfile,
};
use serde_json::{Value, json};
use sqlx::PgPool;
@@ -16,49 +16,54 @@ use tower::util::ServiceExt;
use crate::support::{
admin::seed_admin_session,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::test_wire_profile,
};
#[tokio::test]
async fn dashboard_summary_should_render_fingerprint_updated_at_as_beijing_time() {
let (app, _store, _pool, _dir) = dashboard_test_app(
"dashboard-fingerprint-updated",
crate::support::fingerprint::test_fingerprint_with_updated_at(Some(
"2026-06-23T11:36:46.965574590+00:00",
)),
)
.await;
async fn dashboard_summary_should_expose_effective_wire_profile() {
let (app, _store, _pool, _dir) =
dashboard_test_app("dashboard-wire-profile", test_wire_profile()).await;
let body = dashboard_summary(app).await;
assert_eq!(
service_status_value(&body, "更新时间"),
"2026-06-23 19:36:46"
body["data"]["wireProfile"]["userAgent"],
"Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)"
);
assert_eq!(body["data"]["wireProfile"]["codexVersion"], "0.144.2");
assert_eq!(
body["data"]["wireProfile"]["desktopVersion"],
"26.707.72221"
);
assert_eq!(body["data"]["wireProfile"]["desktopBuild"], "5307");
assert_eq!(body["data"]["wireProfile"]["target"]["osType"], "Mac OS");
assert_eq!(body["data"]["wireProfile"]["target"]["arch"], "arm64");
assert_eq!(
body["data"]["wireProfile"]["verifiedAt"],
"2026-07-14T18:25:50Z"
);
}
#[tokio::test]
async fn dashboard_summary_should_render_dash_when_fingerprint_updated_at_is_missing() {
let (app, _store, _pool, _dir) = dashboard_test_app(
"dashboard-fingerprint-missing",
crate::support::fingerprint::test_fingerprint(),
)
.await;
async fn dashboard_summary_should_keep_release_observation_separate_from_wire_profile() {
let (app, _store, _pool, _dir) =
dashboard_test_app("dashboard-release-unchecked", test_wire_profile()).await;
let body = dashboard_summary(app).await;
assert_eq!(service_status_value(&body, "更新时间"), "-");
assert_eq!(
body["data"]["wireProfile"]["release"]["status"],
"unchecked"
);
assert!(body["data"].get("serviceStatuses").is_none());
}
#[tokio::test]
async fn dashboard_summary_should_report_database_failure() {
let (app, _store, pool, _dir) = dashboard_test_app(
"dashboard-summary-database-failure",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, _store, pool, _dir) =
dashboard_test_app("dashboard-summary-database-failure", test_wire_profile()).await;
pool.close().await;
let response = dashboard_response(app, "/api/admin/dashboard/summary").await;
@@ -72,11 +77,8 @@ async fn dashboard_summary_should_report_database_failure() {
#[tokio::test]
async fn dashboard_trend_should_report_database_failure() {
let (app, _store, pool, _dir) = dashboard_test_app(
"dashboard-trend-database-failure",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, _store, pool, _dir) =
dashboard_test_app("dashboard-trend-database-failure", test_wire_profile()).await;
pool.close().await;
let response = dashboard_response(app, "/api/admin/dashboard/trend?kind=usage").await;
@@ -91,11 +93,8 @@ async fn dashboard_trend_should_report_database_failure() {
#[tokio::test]
async fn dashboard_summary_should_calculate_today_traffic_and_average_first_token_latency_from_time_buckets()
{
let (app, store, pool, _dir) = dashboard_test_app(
"dashboard-traffic-latency",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, pool, _dir) =
dashboard_test_app("dashboard-traffic-latency", test_wire_profile()).await;
let now = Utc::now();
let today_start = china_day_start(now);
let today_record_at = if now - today_start > Duration::minutes(1) {
@@ -151,7 +150,7 @@ async fn dashboard_summary_should_calculate_today_traffic_and_average_first_toke
async fn dashboard_summary_should_keep_trend_after_usage_records_are_cleared() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-time-buckets-survive-usage-record-clear",
crate::support::fingerprint::test_fingerprint(),
test_wire_profile(),
)
.await;
let mut record = usage_record_with_tokens(Utc::now(), 12);
@@ -177,11 +176,8 @@ async fn dashboard_summary_should_keep_trend_after_usage_records_are_cleared() {
#[tokio::test]
async fn dashboard_trend_should_bucket_usage_by_china_quarter_hour() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-trend-china-quarter-hour",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, _pool, _dir) =
dashboard_test_app("dashboard-trend-china-quarter-hour", test_wire_profile()).await;
let record_at = Utc::now();
store
.append(&usage_record_with_token_parts(record_at, 40, 60, 10))
@@ -205,11 +201,8 @@ async fn dashboard_trend_should_bucket_usage_by_china_quarter_hour() {
#[tokio::test]
async fn dashboard_trend_should_only_include_china_today() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-trend-china-today",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, _pool, _dir) =
dashboard_test_app("dashboard-trend-china-today", test_wire_profile()).await;
let now = Utc::now();
let today_start = china_day_start(now);
store
@@ -237,11 +230,8 @@ async fn dashboard_trend_should_only_include_china_today() {
#[tokio::test]
async fn dashboard_latency_trend_should_use_first_token_latency() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-trend-first-token-latency",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, _pool, _dir) =
dashboard_test_app("dashboard-trend-first-token-latency", test_wire_profile()).await;
let now = Utc::now();
let mut slow_completion = usage_record_with_tokens(now, 10);
slow_completion.latency_ms = Some(10_000);
@@ -277,11 +267,8 @@ async fn dashboard_latency_trend_should_use_first_token_latency() {
#[tokio::test]
async fn dashboard_latency_trend_should_serialize_missing_samples_as_null() {
let (app, _store, _pool, _dir) = dashboard_test_app(
"dashboard-trend-empty-latency",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, _store, _pool, _dir) =
dashboard_test_app("dashboard-trend-empty-latency", test_wire_profile()).await;
let body = dashboard_trend(app, "latency").await;
let point = &body["data"]["points"][0];
@@ -312,11 +299,8 @@ async fn dashboard_latency_trend_should_serialize_missing_samples_as_null() {
#[tokio::test]
async fn dashboard_error_trend_should_serialize_missing_success_rate_as_null() {
let (app, _store, _pool, _dir) = dashboard_test_app(
"dashboard-trend-empty-success-rate",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, _store, _pool, _dir) =
dashboard_test_app("dashboard-trend-empty-success-rate", test_wire_profile()).await;
let body = dashboard_trend(app, "errors").await;
let point = &body["data"]["points"][0];
@@ -346,7 +330,7 @@ async fn dashboard_error_trend_should_serialize_missing_success_rate_as_null() {
async fn dashboard_summary_should_return_requested_trend_kind() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-summary-requested-trend-kind",
crate::support::fingerprint::test_fingerprint(),
test_wire_profile(),
)
.await;
let now = Utc::now();
@@ -443,11 +427,8 @@ async fn dashboard_summary_should_match_versioned_model_names_for_billing() {
#[tokio::test]
async fn dashboard_summary_should_return_backend_formatted_time_fields() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-backend-formatted-time",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, _pool, _dir) =
dashboard_test_app("dashboard-backend-formatted-time", test_wire_profile()).await;
let record_at = Utc::now() - Duration::seconds(1);
let mut record = usage_record_with_tokens(record_at, 10);
record.metadata = json!({
@@ -470,11 +451,8 @@ async fn dashboard_summary_should_return_backend_formatted_time_fields() {
#[tokio::test]
async fn dashboard_summary_should_only_return_today_account_usage_and_usage_records() {
let (app, store, pool, _dir) = dashboard_test_app(
"dashboard-summary-today-records",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, pool, _dir) =
dashboard_test_app("dashboard-summary-today-records", test_wire_profile()).await;
seed_dashboard_account(&pool, "acct_today", "today@example.com").await;
seed_dashboard_account(&pool, "acct_yesterday", "yesterday@example.com").await;
let now = Utc::now();
@@ -519,11 +497,8 @@ async fn dashboard_summary_should_only_return_today_account_usage_and_usage_reco
#[tokio::test]
async fn dashboard_summary_should_order_account_usage_by_recent_usage() {
let (app, store, pool, _dir) = dashboard_test_app(
"dashboard-account-usage-recent-order",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, pool, _dir) =
dashboard_test_app("dashboard-account-usage-recent-order", test_wire_profile()).await;
seed_dashboard_account(&pool, "acct_old_heavy", "old-heavy@example.com").await;
seed_dashboard_account(&pool, "acct_recent_light", "recent-light@example.com").await;
let now = Utc::now();
@@ -563,7 +538,7 @@ async fn dashboard_summary_should_order_account_usage_by_recent_usage() {
async fn dashboard_summary_should_use_five_hour_quota_window_for_account_usage() {
let (app, store, pool, _dir) = dashboard_test_app(
"dashboard-account-usage-five-hour-quota",
crate::support::fingerprint::test_fingerprint(),
test_wire_profile(),
)
.await;
seed_dashboard_account(&pool, "acct_quota_five_hour", "quota-five-hour@example.com").await;
@@ -612,11 +587,8 @@ async fn dashboard_summary_should_use_five_hour_quota_window_for_account_usage()
#[tokio::test]
async fn dashboard_summary_should_fall_back_to_weekly_quota_window_when_five_hour_missing() {
let (app, store, pool, _dir) = dashboard_test_app(
"dashboard-account-usage-weekly-quota",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, pool, _dir) =
dashboard_test_app("dashboard-account-usage-weekly-quota", test_wire_profile()).await;
seed_dashboard_account(&pool, "acct_quota_weekly", "quota-weekly@example.com").await;
store
.append(&usage_record_with_account_tokens(
@@ -663,11 +635,8 @@ async fn dashboard_summary_should_fall_back_to_weekly_quota_window_when_five_hou
#[tokio::test]
async fn dashboard_summary_should_return_today_health_timeline() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-health-timeline",
crate::support::fingerprint::test_fingerprint(),
)
.await;
let (app, store, _pool, _dir) =
dashboard_test_app("dashboard-health-timeline", test_wire_profile()).await;
store
.append(&usage_record_with_tokens(Utc::now(), 10))
.await
@@ -681,19 +650,28 @@ async fn dashboard_summary_should_return_today_health_timeline() {
.unwrap();
let body = dashboard_summary(app).await;
let points = body["data"]["healthTimeline"]["points"].as_str().unwrap();
let points = body["data"]["healthTimeline"]["points"].as_array().unwrap();
assert_eq!(body["data"]["healthTimeline"]["title"], "请求健康时间线");
assert_eq!(body["data"]["healthTimeline"]["description"], "请求可靠性");
assert_eq!(
body["data"]["healthTimeline"]["description"],
"有效请求可用性"
);
assert_eq!(points.len(), 96);
assert_eq!(points.matches('4').count(), 1);
assert_eq!(
points
.iter()
.filter(|point| point["status"] == "low_sample")
.count(),
1
);
}
#[tokio::test]
async fn dashboard_summary_should_include_retained_history_in_total_billing() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-total-billing-retained-history",
crate::support::fingerprint::test_fingerprint(),
test_wire_profile(),
)
.await;
let metadata = json!({
@@ -731,8 +709,7 @@ async fn dashboard_summary_total_billing_for_usage_record(
model: &str,
metadata: Value,
) -> String {
let (app, store, _pool, _dir) =
dashboard_test_app(db_name, crate::support::fingerprint::test_fingerprint()).await;
let (app, store, _pool, _dir) = dashboard_test_app(db_name, test_wire_profile()).await;
let mut record = usage_record(Utc::now(), model, metadata.clone());
record.input_tokens = metadata
.pointer("/usage/inputTokens")
@@ -761,7 +738,7 @@ async fn dashboard_summary_total_billing_for_usage_record(
async fn dashboard_test_app(
db_name: &str,
fingerprint: Fingerprint,
wire_profile: std::sync::Arc<CodexWireProfile>,
) -> (
axum::Router,
PgUsageRecordStore,
@@ -773,11 +750,7 @@ async fn dashboard_test_app(
seed_admin_session(&pool, &redis, "session_1").await;
let config = crate::support::config::test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile));
let state = AppState::from(services.as_ref());
(
codex_proxy_rs::api::router::router().with_state(state),
@@ -888,16 +861,6 @@ async fn set_account_quota_json(pool: &PgPool, account_id: &str, quota_json: Val
.unwrap();
}
fn service_status_value<'a>(body: &'a Value, label: &str) -> &'a str {
body["data"]["serviceStatuses"]
.as_array()
.unwrap()
.iter()
.find(|status| status["label"] == label)
.and_then(|status| status["value"].as_str())
.unwrap()
}
fn usage_record_with_tokens(created_at: chrono::DateTime<Utc>, total_tokens: u64) -> UsageRecord {
usage_record_with_token_parts(created_at, total_tokens, 0, 0)
}
+3 -7
View File
@@ -10,9 +10,9 @@ use tower::util::ServiceExt;
use crate::support::{
admin::seed_admin_session,
config::test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
mod authorization;
@@ -30,12 +30,8 @@ async fn admin_client_key_test_app(
seed_admin_session(&pool, &redis, "session_1").await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
(
codex_proxy_rs::api::router::router().with_state(state),
@@ -25,9 +25,9 @@ use tower::util::ServiceExt;
use crate::support::{
admin::seed_admin_session,
config::test_config as base_test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
#[tokio::test]
@@ -261,8 +261,8 @@ async fn admin_settings_update_should_persist_runtime_settings_to_database() {
let config = test_config(test_database_url());
seed_admin_session(&pool, &redis, "session_1").await;
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = Services::new(&config, stores, runtime_fingerprint(fingerprint));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = Services::new(&config, stores, wire_profile(profile));
let services = std::sync::Arc::new(services);
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
@@ -356,12 +356,8 @@ async fn admin_settings_update_should_apply_runtime_services() {
seed_schedulable_account(&pool, "acct_runtime_a").await;
seed_schedulable_account(&pool, "acct_runtime_b").await;
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile)));
let settings_tasks = TaskCoordinator::start_settings_subscriptions(&services);
services.account_pool.restore_from_store().await.unwrap();
let state = AppState::from(services.as_ref());
@@ -466,12 +462,8 @@ async fn admin_settings_update_should_reject_unsupported_or_invalid_fields() {
let config = test_config(test_database_url());
seed_admin_session(&pool, &redis, "session_1").await;
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
let app = codex_proxy_rs::api::router::router().with_state(state);
@@ -501,12 +493,8 @@ async fn admin_settings_test_app(
seed_admin_session(&pool, &redis, "session_1").await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile)));
let state = AppState::from(services.as_ref());
(codex_proxy_rs::api::router::router().with_state(state), dir)
}
+2 -2
View File
@@ -28,8 +28,8 @@ use wiremock::{
use crate::support::{
admin::seed_admin_session,
config::test_config,
fingerprint::{runtime_fingerprint, test_fingerprint},
http::response_json,
wire_profile::{test_wire_profile_value, wire_profile},
};
const VERSION_OLD: &str = "0.1.0";
@@ -1269,7 +1269,7 @@ async fn admin_system_test_app_with_process_control(
let services = Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(test_fingerprint()),
wire_profile(test_wire_profile_value()),
));
let mut state = AppState::from(services.as_ref());
state.services.system_update = Arc::new(SystemUpdateService::new(update_config));
+2 -2
View File
@@ -16,9 +16,9 @@ use tower::util::ServiceExt;
use crate::support::{
admin::seed_admin_session,
config::test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
#[tokio::test]
@@ -365,7 +365,7 @@ async fn monitoring_test_app(
let services = Services::new(
&config,
stores,
runtime_fingerprint(crate::support::fingerprint::test_fingerprint()),
wire_profile(crate::support::wire_profile::test_wire_profile_value()),
);
let app = codex_proxy_rs::api::router::router().with_state(AppState::from(&services));
(
+3 -3
View File
@@ -12,12 +12,12 @@ use tower::ServiceExt;
use crate::support::{
config::test_config as base_test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{
TestDatabaseGuard, background_task_stores, create_test_redis, init_test_db,
test_database_url,
},
wire_profile::wire_profile,
};
#[tokio::test]
@@ -185,11 +185,11 @@ async fn router_with_dist_and_database(
let redis = create_test_redis(label).await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(codex_proxy_rs::bootstrap::services::Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
wire_profile(profile),
));
let state = codex_proxy_rs::api::AppState::from(services.as_ref());
(
+3 -7
View File
@@ -11,9 +11,9 @@ use tower::util::ServiceExt;
use crate::support::{
client_keys::insert_client_api_key,
config::test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
#[tokio::test]
@@ -154,12 +154,8 @@ async fn build_test_app(
let redis = create_test_redis(label).await;
seed_model_snapshot(&redis).await;
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile)));
services
.initialize_hot_path_state()
.await
+3 -7
View File
@@ -8,9 +8,9 @@ use tower::util::ServiceExt;
use crate::support::{
client_keys::insert_client_api_key,
config::test_config,
fingerprint::runtime_fingerprint,
http::response_json,
storage::{background_task_stores, create_test_redis, init_test_db, test_database_url},
wire_profile::wire_profile,
};
mod websocket;
@@ -165,12 +165,8 @@ pub(super) async fn test_app_with_client_api_key() -> (
insert_model_snapshot(&redis).await;
let config = test_config(test_database_url());
let stores = background_task_stores(pool.clone(), redis);
let fingerprint = crate::support::fingerprint::test_fingerprint();
let services = std::sync::Arc::new(Services::new(
&config,
stores,
runtime_fingerprint(fingerprint),
));
let profile = crate::support::wire_profile::test_wire_profile_value();
let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile)));
services
.initialize_hot_path_state()
.await
+20 -11
View File
@@ -22,8 +22,9 @@ fn config_loader_should_load_complete_example() {
config.server.host.as_str(),
config.server.port,
config.api.base_url.as_str(),
config.fingerprint.app_version.as_str(),
config.fingerprint.build_number.as_str(),
config.wire_profile.codex_version.as_str(),
config.wire_profile.desktop_version.as_str(),
config.wire_profile.desktop_build.as_str(),
config.quota.refresh_interval_minutes,
config.ws_pool.initial_event_timeout_ms,
config.admin.default_username.as_str(),
@@ -33,8 +34,9 @@ fn config_loader_should_load_complete_example() {
"127.0.0.1",
8080,
"https://chatgpt.com/backend-api",
"26.707.51957",
"5175",
"0.144.2",
"26.707.72221",
"5307",
5,
20_000,
"admin@cpr.local",
@@ -158,7 +160,7 @@ fn config_loader_should_reject_missing_explicit_fields() {
#[test]
fn config_loader_should_reject_unsupported_schema_version() {
let yaml = complete_config().replace("schema_version: 1", "schema_version: 2");
let yaml = complete_config().replace("schema_version: 1", "schema_version: 0");
let directory = write_config(&yaml);
let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err();
@@ -223,18 +225,25 @@ fn config_loader_should_reject_admin_password_with_compose_interpolation() {
}
#[test]
fn config_loader_should_reject_duplicate_fingerprint_headers() {
let yaml = complete_config().replace(
" - name: 'Accept-Language'",
" - name: 'Accept-Encoding'",
);
fn config_loader_should_reject_legacy_fingerprint_section() {
let yaml = complete_config().replace(" wire_profile:", " fingerprint:");
let directory = write_config(&yaml);
let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err();
assert!(matches!(error, ConfigError::InvalidDocument { .. }));
}
#[test]
fn config_loader_should_reject_invalid_codex_core_version() {
let yaml = complete_config().replace("codex_version: '0.144.2'", "codex_version: 'Desktop'");
let directory = write_config(&yaml);
let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err();
assert_eq!(
error,
ConfigError::InvalidField("fingerprint.default_headers")
ConfigError::InvalidField("wire_profile.codex_version")
);
}
+3 -3
View File
@@ -21,7 +21,7 @@ async fn services_try_new_should_use_configured_tls_transport_builder() {
let Err(error) = Services::try_new(
&config,
stores,
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
) else {
panic!("invalid custom CA should fail service transport construction");
};
@@ -59,7 +59,7 @@ async fn services_try_new_should_expose_websocket_pool_when_enabled() {
let services = Services::try_new(
&config,
background_task_stores(pool, redis),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.expect("services should build");
@@ -75,7 +75,7 @@ async fn services_try_new_should_not_expose_websocket_pool_when_disabled() {
let services = Services::try_new(
&config,
background_task_stores(pool, redis),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.expect("services should build");
@@ -0,0 +1,86 @@
use super::*;
#[tokio::test]
async fn desktop_release_update_task_should_start_background_checker() {
let status = DesktopReleaseStatus::default();
let handle =
codex_proxy_rs::bootstrap::tasks::desktop_release_update::start_desktop_release_update_task(
status,
"https://example.invalid/appcast.xml".to_string(),
);
handle.shutdown().await;
}
#[tokio::test]
async fn desktop_release_update_task_should_record_initial_appcast_observation() {
let server = MockServer::start().await;
mount_appcast(
&server,
r#"
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<item>
<pubDate>Tue, 14 Jul 2026 07:56:40 +0000</pubDate>
<sparkle:shortVersionString>26.900.1</sparkle:shortVersionString>
<sparkle:version>7001</sparkle:version>
<sparkle:minimumSystemVersion>12.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<enclosure url="https://example.invalid/download" length="1234"
sparkle:edSignature="signature" />
</item>
</channel>
</rss>
"#,
)
.await;
let status = DesktopReleaseStatus::default();
let handle =
codex_proxy_rs::bootstrap::tasks::desktop_release_update::start_desktop_release_update_task(
status.clone(),
format!("{}/appcast.xml", server.uri()),
);
let snapshot = wait_for_latest_desktop_release(&status, "26.900.1").await;
handle.shutdown().await;
let release = snapshot.latest.expect("latest release");
assert_eq!(release.build, "7001");
assert_eq!(release.minimum_system_version.as_deref(), Some("12.0"));
assert_eq!(release.hardware_requirements.as_deref(), Some("arm64"));
assert_eq!(release.download_size, Some(1234));
assert!(release.signature_present);
assert!(snapshot.checked_at.is_some());
assert!(snapshot.last_error.is_none());
}
#[tokio::test]
async fn desktop_release_update_task_should_record_fetch_failure_without_mutating_wire_profile() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/appcast.xml"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let profile = crate::support::wire_profile::test_wire_profile();
let original_user_agent = profile.user_agent();
let status = DesktopReleaseStatus::default();
let handle =
codex_proxy_rs::bootstrap::tasks::desktop_release_update::start_desktop_release_update_task(
status.clone(),
format!("{}/appcast.xml", server.uri()),
);
wait_for_appcast_requests(&server, 1).await;
let deadline = tokio::time::Instant::now() + StdDuration::from_secs(2);
while status.snapshot().last_error.is_none() {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(StdDuration::from_millis(25)).await;
}
handle.shutdown().await;
assert_eq!(profile.user_agent(), original_user_agent);
assert!(status.snapshot().latest.is_none());
}
@@ -1,131 +0,0 @@
use super::*;
#[tokio::test]
async fn fingerprint_update_task_should_start_background_checker() {
let dir = tempfile::tempdir().expect("temp dir");
let (pool, _guard) = init_test_db("fingerprint-task-start").await;
let repo = PgFingerprintStore::new(pool.clone());
repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.expect("seed current fingerprint");
let runtime_fingerprint = runtime_fingerprint_from_repo(&repo).await;
let handle =
codex_proxy_rs::bootstrap::tasks::fingerprint_update::start_fingerprint_update_task(
repo,
runtime_fingerprint,
"https://example.invalid/appcast.xml".to_string(),
dir.path().join("extracted-fingerprint.json"),
"26.800.1".to_string(),
"6001".to_string(),
);
handle.shutdown().await;
}
#[tokio::test]
async fn fingerprint_update_task_should_apply_initial_appcast_update_to_store() {
let server = MockServer::start().await;
mount_appcast(
&server,
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let (pool, _guard) = init_test_db("fingerprint-task-update").await;
let repo = PgFingerprintStore::new(pool.clone());
repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.expect("seed current fingerprint");
let runtime_fingerprint = runtime_fingerprint_from_repo(&repo).await;
let extracted_path = dir.path().join("extracted-fingerprint.json");
std::fs::write(
&extracted_path,
r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#,
)
.expect("extracted fingerprint should be written");
let handle =
codex_proxy_rs::bootstrap::tasks::fingerprint_update::start_fingerprint_update_task(
repo.clone(),
runtime_fingerprint.clone(),
format!("{}/appcast.xml", server.uri()),
extracted_path,
"26.800.1".to_string(),
"6001".to_string(),
);
let stored = wait_for_current_fingerprint_version(&repo).await;
handle.shutdown().await;
assert_eq!(
(
stored.app_version.as_str(),
stored.build_number.as_str(),
stored.chromium_version.as_str()
),
("26.900.1", "7001", "147")
);
let runtime_snapshot = runtime_fingerprint.snapshot();
assert_eq!(
(
runtime_snapshot.app_version.as_str(),
runtime_snapshot.build_number.as_str(),
runtime_snapshot.chromium_version.as_str()
),
("26.900.1", "7001", "147")
);
}
#[tokio::test]
async fn fingerprint_update_task_should_not_persist_when_appcast_matches_current_fingerprint() {
let server = MockServer::start().await;
mount_appcast(
&server,
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let (pool, _guard) = init_test_db("fingerprint-task-no-update").await;
let repo = PgFingerprintStore::new(pool.clone());
repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.expect("seed current fingerprint");
let runtime_fingerprint = runtime_fingerprint_from_repo(&repo).await;
let handle =
codex_proxy_rs::bootstrap::tasks::fingerprint_update::start_fingerprint_update_task(
repo.clone(),
runtime_fingerprint,
format!("{}/appcast.xml", server.uri()),
dir.path().join("missing-extracted-fingerprint.json"),
"26.900.1".to_string(),
"7001".to_string(),
);
wait_for_appcast_requests(&server, 1).await;
handle.shutdown().await;
let history_count: i64 = sqlx::query_scalar("select count(*) from fingerprint_update_history")
.fetch_one(&pool)
.await
.expect("fingerprint history count should load");
assert_eq!(history_count, 0);
}
+16 -22
View File
@@ -11,7 +11,9 @@ use codex_proxy_rs::telemetry::account_usage::store::{
AccountUsageDelta, AccountUsageSnapshot, AccountUsageStore, AccountUsageStoreError,
AccountUsageWindow,
};
use codex_proxy_rs::upstream::openai::fingerprint::{PgFingerprintStore, RuntimeFingerprint};
use codex_proxy_rs::upstream::openai::desktop_release::{
DesktopReleaseSnapshot, DesktopReleaseStatus,
};
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
@@ -21,7 +23,7 @@ use crate::support::storage::init_test_db;
mod cleanup;
mod coordinator;
mod fingerprint_update;
mod desktop_release_update;
mod model_refresh;
mod retention_trim;
@@ -124,37 +126,29 @@ async fn mount_appcast(server: &MockServer, body: &str) {
.await;
}
async fn wait_for_current_fingerprint_version(
repo: &PgFingerprintStore,
) -> codex_proxy_rs::upstream::openai::fingerprint::Fingerprint {
async fn wait_for_latest_desktop_release(
status: &DesktopReleaseStatus,
expected_version: &str,
) -> DesktopReleaseSnapshot {
let deadline = tokio::time::Instant::now() + StdDuration::from_secs(2);
loop {
if let Some(stored) = repo
.load_current()
.await
.expect("current fingerprint should load")
.filter(|stored| stored.app_version == "26.900.1")
let snapshot = status.snapshot();
if snapshot
.latest
.as_ref()
.is_some_and(|release| release.version == expected_version)
{
return stored;
return snapshot;
}
assert!(
tokio::time::Instant::now() < deadline,
"fingerprint update task did not persist auto update before timeout"
"Desktop release task did not refresh appcast state before timeout"
);
tokio::time::sleep(StdDuration::from_millis(25)).await;
}
}
async fn runtime_fingerprint_from_repo(repo: &PgFingerprintStore) -> RuntimeFingerprint {
RuntimeFingerprint::new(
repo.load_current()
.await
.expect("current fingerprint should load")
.expect("current fingerprint should exist"),
)
}
async fn wait_for_appcast_requests(server: &MockServer, expected_count: usize) {
let deadline = tokio::time::Instant::now() + StdDuration::from_secs(2);
loop {
@@ -171,7 +165,7 @@ async fn wait_for_appcast_requests(server: &MockServer, expected_count: usize) {
assert!(
tokio::time::Instant::now() < deadline,
"fingerprint update task did not request appcast before timeout"
"Desktop release task did not request appcast before timeout"
);
tokio::time::sleep(StdDuration::from_millis(25)).await;
}
@@ -6,7 +6,6 @@ use codex_proxy_rs::{
ops::{store::PgOpsErrorLogStore, types::OpsErrorLog},
usage::{store::PgUsageRecordStore, types::UsageRecord},
},
upstream::openai::fingerprint::PgFingerprintStore,
};
use crate::support::storage::init_test_db;
@@ -28,11 +27,6 @@ async fn retention_trim_task_should_trim_all_growth_tables_in_one_run() {
let usage_store = PgUsageRecordStore::new(pool.clone());
let ops_store = PgOpsErrorLogStore::new(pool.clone());
let bucket_store = PgRequestBucketStore::new(pool.clone());
let fingerprint_store = PgFingerprintStore::new(pool.clone());
fingerprint_store
.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.unwrap();
let now = Utc::now();
let mut old_usage = UsageRecord::new("request", "old", "acct", "gpt-old", 200);
@@ -55,19 +49,7 @@ async fn retention_trim_task_should_trim_all_growth_tables_in_one_run() {
current_error.created_at = now;
ops_store.append(&current_error).await.unwrap();
sqlx::query(
"insert into fingerprint_update_history (
id, current_fingerprint_id, app_version, build_number, source, created_at
)
select 'history_' || value, 'current', '1.0.' || value, value::text, 'test',
now() + value * interval '1 microsecond'
from generate_series(1, 101) as value",
)
.execute(&pool)
.await
.unwrap();
RetentionTrimTask::new(usage_store, ops_store, bucket_store, fingerprint_store)
RetentionTrimTask::new(usage_store, ops_store, bucket_store)
.run_once()
.await;
@@ -85,13 +67,7 @@ async fn retention_trim_task_should_trim_all_growth_tables_in_one_run() {
.fetch_one(&pool)
.await
.unwrap();
let history_count: i64 = sqlx::query_scalar("select count(*) from fingerprint_update_history")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(usage_ids, vec!["usage_current".to_string()]);
assert_eq!(ops_ids, vec!["ops_current".to_string()]);
assert_eq!(old_buckets, 0);
assert_eq!(history_count, 100);
}
+2 -1
View File
@@ -143,7 +143,8 @@ fn controller_best_effort_io_should_share_one_bounded_owner() {
"controller I/O {operation} must use the shared best-effort timeout"
);
}
assert!(finalizer.contains("timeout(LEASE_FINALIZE_TIMEOUT, account_lease.complete())"));
assert!(finalizer.contains("timeout(LEASE_FINALIZE_TIMEOUT, lease_completion.as_mut())"));
assert!(finalizer.contains("tokio::spawn(lease_completion)"));
}
#[test]
+3 -1
View File
@@ -181,8 +181,10 @@ fn live_stream_exit_paths_should_share_one_consuming_finalizer() {
.find(".leave_stream(")
.expect("controllers should leave from the finalizer");
let lease = finalizer
.find("timeout(LEASE_FINALIZE_TIMEOUT, account_lease.complete())")
.find("let mut lease_completion = Box::pin(account_lease.complete())")
.expect("the account lease should finalize");
assert!(finalizer.contains("timeout(LEASE_FINALIZE_TIMEOUT, lease_completion.as_mut())"));
assert!(finalizer.contains("tokio::spawn(lease_completion)"));
let client = finalizer
.find("finish_client_stream(")
.expect("client termination should run after internal finalization");
+27 -1
View File
@@ -243,7 +243,7 @@ async fn test_app_state_with_storage(
let services = Services::try_with_usage_record_options(
&config,
stores,
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
usage_record_options,
)
.expect("failed to build runtime services with configured TLS transport");
@@ -1501,6 +1501,32 @@ async fn response_usage_record_count(pool: &PgPool) -> i64 {
count
}
async fn wait_for_account_usage(
pool: &PgPool,
account_id: &str,
expected: (i64, i64, i64),
) -> (i64, i64, i64) {
let deadline = tokio::time::Instant::now() + StdDuration::from_secs(5);
loop {
let usage: Option<(i64, i64, i64)> = sqlx::query_as(
"select request_count, input_tokens, output_tokens from account_usage where account_id = $1",
)
.bind(account_id)
.fetch_optional(pool)
.await
.unwrap();
let usage = usage.unwrap_or_default();
if usage == expected {
return usage;
}
assert!(
tokio::time::Instant::now() < deadline,
"account usage for {account_id} did not settle: expected {expected:?}, got {usage:?}"
);
tokio::time::sleep(StdDuration::from_millis(10)).await;
}
}
fn assert_rate_limit_header(metadata: &Value, name: &str, value: &str) {
let headers = metadata["rateLimitHeaders"]
.as_array()
@@ -2010,20 +2010,8 @@ async fn responses_stream_should_fallback_to_next_account_after_rate_limit() {
.fetch_one(&pool)
.await
.unwrap();
let primary_usage: (i64, i64, i64) = sqlx::query_as(
"select request_count, input_tokens, output_tokens from account_usage where account_id = $1",
)
.bind("acct_primary")
.fetch_one(&pool)
.await
.unwrap();
let secondary_usage: (i64, i64, i64) = sqlx::query_as(
"select request_count, input_tokens, output_tokens from account_usage where account_id = $1",
)
.bind("acct_secondary")
.fetch_one(&pool)
.await
.unwrap();
let primary_usage = wait_for_account_usage(&pool, "acct_primary", (1, 0, 0)).await;
let secondary_usage = wait_for_account_usage(&pool, "acct_secondary", (1, 4, 1)).await;
assert_eq!(status, StatusCode::OK);
assert!(content_type.starts_with("text/event-stream"));
@@ -553,7 +553,7 @@ async fn responses_should_record_empty_response_attempt() {
}
#[tokio::test]
async fn responses_stream_should_proxy_sse_and_record_usage() {
async fn responses_stream_should_proxy_sse_and_finish_slow_usage_persistence() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
@@ -568,6 +568,27 @@ async fn responses_stream_should_proxy_sse_and_record_usage() {
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
// 强制请求计数持久化越过 stream finalizer 的 100ms 客户端收尾上限,
// 验证超时后原 future 会继续完成,而不是被取消并永久丢失计数。
sqlx::raw_sql(
r#"
create function delay_request_count_update() returns trigger as $$
begin
if new.request_count > old.request_count then
perform pg_sleep(0.2);
end if;
return new;
end;
$$ language plpgsql;
create trigger delay_request_count_update
before update of request_count on account_usage
for each row execute function delay_request_count_update();
"#,
)
.execute(&pool)
.await
.unwrap();
let response = app
.oneshot(
Request::builder()
@@ -596,13 +617,7 @@ async fn responses_stream_should_proxy_sse_and_record_usage() {
.unwrap_or_default()
.to_string();
let body = response_text(response).await;
let usage: (i64, i64, i64) = sqlx::query_as(
"select request_count, input_tokens, output_tokens from account_usage where account_id = $1",
)
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
let usage = wait_for_account_usage(&pool, "acct_chat", (1, 3, 5)).await;
assert_eq!(status, StatusCode::OK);
assert!(content_type.starts_with("text/event-stream"));
+8 -8
View File
@@ -327,7 +327,7 @@ async fn quota_refresh_service_should_send_usage_cookie_when_cookie_store_is_con
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::new(
store,
@@ -410,7 +410,7 @@ async fn quota_refresh_service_should_fetch_usage_for_quota_locked_accounts_and_
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::new(
store.clone(),
@@ -468,7 +468,7 @@ async fn quota_refresh_service_should_ban_deactivated_workspace() {
let codex = Arc::new(CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
));
let service = QuotaRefreshService::new(
store,
@@ -530,7 +530,7 @@ async fn quota_refresh_service_should_refresh_quota_exhausted_accounts() {
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::new(
store,
@@ -567,7 +567,7 @@ async fn quota_refresh_service_should_skip_recent_locked_account_before_cooldown
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::with_min_refresh_interval_secs(
store,
@@ -608,7 +608,7 @@ async fn quota_refresh_service_should_skip_recent_locked_account_inside_cooldown
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::with_min_refresh_interval_secs(
store,
@@ -666,7 +666,7 @@ async fn quota_refresh_service_should_bypass_recent_skip_after_cooldown_grace()
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::with_min_refresh_interval_secs(
store,
@@ -722,7 +722,7 @@ async fn quota_refresh_service_should_stagger_multiple_locked_account_requests()
let codex = CodexBackendClient::new(
reqwest::Client::new(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let service = QuotaRefreshService::new(
store,
-2
View File
@@ -25,8 +25,6 @@ async fn postgres_schema_is_terminal_v1_without_unused_storage() {
"accounts",
"admin_users",
"client_api_keys",
"fingerprint_update_history",
"fingerprints",
"ops_error_logs",
"request_time_buckets",
"runtime_settings",
+1 -1
View File
@@ -42,7 +42,7 @@ pub(crate) fn test_config(database_url: String) -> AppConfig {
max_per_account: 8,
initial_event_timeout_ms: 20_000,
},
fingerprint: crate::support::fingerprint::test_fingerprint_config(),
wire_profile: crate::support::wire_profile::test_wire_profile_config(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
-75
View File
@@ -1,75 +0,0 @@
use codex_proxy_rs::{
bootstrap::{
config::{FingerprintConfig, FingerprintHeaderConfig},
services::fingerprint_from_config,
},
upstream::openai::fingerprint::{Fingerprint, RuntimeFingerprint},
};
pub(crate) fn test_fingerprint_config() -> FingerprintConfig {
let header = |name: &str, value: &str| FingerprintHeaderConfig {
name: name.to_string(),
value: value.to_string(),
};
FingerprintConfig {
originator: "Codex Desktop".to_string(),
app_version: "26.707.51957".to_string(),
build_number: "5175".to_string(),
platform: "darwin".to_string(),
arch: "arm64".to_string(),
chromium_version: "146".to_string(),
user_agent_template: "Codex Desktop/{version} ({platform}; {arch})".to_string(),
default_headers: vec![
header("Accept-Encoding", "gzip, deflate, br, zstd"),
header("Accept-Language", "en-US,en;q=0.9"),
header("sec-ch-ua-mobile", "?0"),
header("sec-ch-ua-platform", "\"macOS\""),
header("sec-fetch-site", "same-origin"),
header("sec-fetch-mode", "cors"),
header("sec-fetch-dest", "empty"),
],
header_order: [
"authorization",
"chatgpt-account-id",
"originator",
"x-openai-internal-codex-residency",
"x-client-request-id",
"x-codex-installation-id",
"x-codex-turn-state",
"openai-beta",
"user-agent",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"accept-encoding",
"accept-language",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"content-type",
"accept",
"cookie",
]
.map(str::to_string)
.to_vec(),
}
}
pub(crate) fn test_fingerprint() -> Fingerprint {
fingerprint_from_config(&test_fingerprint_config())
}
pub(crate) fn test_fingerprint_with_updated_at(updated_at: Option<&str>) -> Fingerprint {
let mut fingerprint = test_fingerprint();
fingerprint.updated_at = updated_at.map(ToString::to_string);
fingerprint
}
pub(crate) fn runtime_test_fingerprint() -> RuntimeFingerprint {
RuntimeFingerprint::new(test_fingerprint())
}
pub(crate) fn runtime_fingerprint(fingerprint: Fingerprint) -> RuntimeFingerprint {
RuntimeFingerprint::new(fingerprint)
}
+1 -1
View File
@@ -3,7 +3,7 @@ pub(crate) mod admin;
pub(crate) mod assertions;
pub(crate) mod client_keys;
pub(crate) mod config;
pub(crate) mod fingerprint;
pub(crate) mod http;
pub(crate) mod jwt;
pub(crate) mod storage;
pub(crate) mod wire_profile;
-2
View File
@@ -13,7 +13,6 @@ use codex_proxy_rs::{
account_usage::store::PgAccountUsageStore, buckets::store::PgRequestBucketStore,
ops::store::PgOpsErrorLogStore, usage::store::PgUsageRecordStore,
},
upstream::openai::fingerprint::PgFingerprintStore,
};
use sqlx::{Connection, PgConnection, PgPool, postgres::PgPoolOptions};
use uuid::Uuid;
@@ -174,7 +173,6 @@ pub(crate) fn background_task_stores(pool: PgPool, redis: RedisConnection) -> Ba
admin_users: PgAdminUserStore::new(pool.clone()),
admin_sessions: RedisAdminSessionStore::new(redis.clone()),
cookies: PgCookieStore::new(pool.clone()),
fingerprints: PgFingerprintStore::new(pool.clone()),
session_affinity: RedisSessionAffinityStore::new(redis.clone()),
refresh_leases: RedisRefreshLeaseStore::new(redis.clone()),
model_snapshots: RedisModelSnapshotStore::new(redis),
+34
View File
@@ -0,0 +1,34 @@
use std::sync::Arc;
use codex_proxy_rs::{
bootstrap::{config::WireProfileConfig, services::wire_profile_from_config},
upstream::openai::profile::CodexWireProfile,
};
use crate::support::storage::timestamp;
pub(crate) fn test_wire_profile_config() -> WireProfileConfig {
WireProfileConfig {
originator: "Codex Desktop".to_string(),
codex_version: "0.144.2".to_string(),
desktop_version: "26.707.72221".to_string(),
desktop_build: "5307".to_string(),
os_type: "Mac OS".to_string(),
os_version: "15.7.1".to_string(),
arch: "arm64".to_string(),
terminal: "unknown".to_string(),
verified_at: timestamp("2026-07-14T18:25:50Z"),
}
}
pub(crate) fn test_wire_profile_value() -> CodexWireProfile {
wire_profile_from_config(&test_wire_profile_config())
}
pub(crate) fn test_wire_profile() -> Arc<CodexWireProfile> {
Arc::new(test_wire_profile_value())
}
pub(crate) fn wire_profile(profile: CodexWireProfile) -> Arc<CodexWireProfile> {
Arc::new(profile)
}
+65
View File
@@ -0,0 +1,65 @@
use chrono::{Duration, TimeZone, Utc};
use codex_proxy_rs::{
infra::time::china_day_start,
telemetry::{
dashboard::dashboard_health_timeline_data_at, usage::insights::RequestHealthTimeBucket,
},
};
use serde_json::json;
#[test]
fn dashboard_health_timeline_should_classify_only_eligible_final_requests() {
let now = Utc.with_ymd_and_hms(2026, 7, 15, 5, 0, 0).unwrap();
let start = china_day_start(now);
let records = vec![
health_bucket(start, 0, 0, 7, 2),
health_bucket(start + Duration::minutes(15), 9, 0, 0, 0),
health_bucket(start + Duration::minutes(30), 0, 3, 0, 0),
health_bucket(start + Duration::minutes(45), 98, 2, 0, 0),
health_bucket(start + Duration::minutes(60), 99, 1, 0, 0),
];
let value = serde_json::to_value(dashboard_health_timeline_data_at(&records, now)).unwrap();
let points = value["points"].as_array().unwrap();
assert_eq!(
json!({
"description": value["description"],
"reliabilityDisplay": value["reliabilityDisplay"],
"status": value["status"],
"successRequests": value["successRequests"],
"failedRequests": value["failedRequests"],
"cancelledRequests": value["cancelledRequests"],
"callerErrorRequests": value["callerErrorRequests"],
"pointCount": points.len(),
"statuses": points.iter().take(5).map(|point| &point["status"]).collect::<Vec<_>>(),
}),
json!({
"description": "有效请求可用性",
"reliabilityDisplay": "97.2%",
"status": "unstable",
"successRequests": 206,
"failedRequests": 6,
"cancelledRequests": 7,
"callerErrorRequests": 2,
"pointCount": 96,
"statuses": ["no_data", "low_sample", "unavailable", "unstable", "stable"],
})
);
}
fn health_bucket(
bucket_start: chrono::DateTime<Utc>,
success_requests: u64,
failed_requests: u64,
cancelled_requests: u64,
caller_error_requests: u64,
) -> RequestHealthTimeBucket {
RequestHealthTimeBucket {
bucket_start,
success_requests,
failed_requests,
cancelled_requests,
caller_error_requests,
}
}
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod account_usage;
mod dashboard;
pub(crate) mod ops;
pub(crate) mod rebuild;
pub(crate) mod usage;
+108
View File
@@ -0,0 +1,108 @@
use chrono::{Duration, TimeZone, Utc};
use codex_proxy_rs::telemetry::{
ops::{store::PgOpsErrorLogStore, types::OpsErrorLog},
usage::{
insights::{RequestHealthTimeBucket, health_timeline},
store::PgUsageRecordStore,
types::UsageRecord,
},
};
use serde_json::json;
use crate::support::storage::init_test_db;
#[tokio::test]
async fn health_timeline_should_use_final_request_outcomes_and_keep_neutral_counts_separate() {
let (pool, _guard) = init_test_db("usage-health-timeline-final-outcomes").await;
let usage_store = PgUsageRecordStore::new(pool.clone());
let error_store = PgOpsErrorLogStore::new(pool.clone());
let start = Utc.with_ymd_and_hms(2026, 7, 15, 12, 0, 0).unwrap();
error_store
.append(&service_error(
"ops_recovered",
"req_recovered",
start + Duration::minutes(1),
))
.await
.unwrap();
usage_store
.append(&success(
"usage_recovered",
"req_recovered",
start + Duration::minutes(2),
))
.await
.unwrap();
usage_store
.append(&success(
"usage_success",
"req_success",
start + Duration::minutes(3),
))
.await
.unwrap();
for (id, minute) in [("ops_failed_first", 4), ("ops_failed_latest", 5)] {
error_store
.append(&service_error(
id,
"req_failed",
start + Duration::minutes(minute),
))
.await
.unwrap();
}
let mut cancelled = service_error(
"ops_cancelled",
"req_cancelled",
start + Duration::minutes(6),
);
cancelled.status_code = Some(499);
cancelled.failure_class = None;
cancelled.metadata = json!({ "terminal": "cancelled" });
error_store.append(&cancelled).await.unwrap();
let mut caller_error = service_error(
"ops_caller_error",
"req_caller_error",
start + Duration::minutes(7),
);
caller_error.status_code = Some(400);
caller_error.metadata = json!({ "failureSource": "client" });
error_store.append(&caller_error).await.unwrap();
let buckets = health_timeline(&pool, start, start + Duration::minutes(15))
.await
.unwrap();
assert_eq!(
buckets,
vec![RequestHealthTimeBucket {
bucket_start: start,
success_requests: 2,
failed_requests: 1,
cancelled_requests: 1,
caller_error_requests: 1,
}]
);
}
fn success(id: &str, request_id: &str, created_at: chrono::DateTime<Utc>) -> UsageRecord {
let mut record = UsageRecord::new("v1.response", "completed", "acct_health", "gpt-5", 200);
record.id = id.to_string();
record.request_id = Some(request_id.to_string());
record.created_at = created_at;
record
}
fn service_error(id: &str, request_id: &str, created_at: chrono::DateTime<Utc>) -> OpsErrorLog {
let mut error = OpsErrorLog::new("v1.response", "request failed");
error.id = id.to_string();
error.request_id = Some(request_id.to_string());
error.status_code = Some(503);
error.failure_class = Some("upstream_unavailable".to_string());
error.created_at = created_at;
error
}
+1
View File
@@ -1 +1,2 @@
mod insights;
mod store;
@@ -0,0 +1,94 @@
use codex_proxy_rs::upstream::openai::desktop_release::{
APPCAST_POLL_INTERVAL, DesktopReleaseChecker, DesktopReleaseStatus,
parse_latest_desktop_release,
};
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
const APPCAST: &str = r#"
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<item>
<title>26.707.72221</title>
<pubDate>Tue, 14 Jul 2026 07:56:40 +0000</pubDate>
<sparkle:version>5307</sparkle:version>
<sparkle:shortVersionString>26.707.72221</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>12.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<enclosure url="https://example.invalid/Codex.zip" length="565564803"
sparkle:edSignature="signature" />
<sparkle:deltas>
<enclosure url="https://example.invalid/Codex.delta" length="12" />
</sparkle:deltas>
</item>
<item>
<sparkle:version>5263</sparkle:version>
<sparkle:shortVersionString>26.707.71524</sparkle:shortVersionString>
</item>
</channel>
</rss>
"#;
#[test]
fn desktop_release_poll_interval_should_be_one_day() {
assert_eq!(APPCAST_POLL_INTERVAL, std::time::Duration::from_hours(24));
}
#[test]
fn appcast_parser_should_return_first_full_release_and_ignore_deltas() {
let release = parse_latest_desktop_release(APPCAST).expect("parse appcast");
assert_eq!(release.version, "26.707.72221");
assert_eq!(release.build, "5307");
assert_eq!(
release.published_at,
Some(crate::support::storage::timestamp("2026-07-14T07:56:40Z"))
);
assert_eq!(release.minimum_system_version.as_deref(), Some("12.0"));
assert_eq!(release.hardware_requirements.as_deref(), Some("arm64"));
assert_eq!(
release.download_url.as_deref(),
Some("https://example.invalid/Codex.zip")
);
assert_eq!(release.download_size, Some(565_564_803));
assert!(release.signature_present);
}
#[test]
fn appcast_parser_should_reject_release_without_codex_desktop_version() {
let error = parse_latest_desktop_release("<rss><channel><item /></channel></rss>")
.expect_err("missing version should fail");
assert!(error.to_string().contains("shortVersionString"));
}
#[tokio::test]
async fn desktop_release_checker_should_update_observation_only() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/appcast.xml"))
.respond_with(ResponseTemplate::new(200).set_body_raw(APPCAST, "application/xml"))
.mount(&server)
.await;
let status = DesktopReleaseStatus::default();
let checker = DesktopReleaseChecker::with_client(
reqwest::Client::new(),
format!("{}/appcast.xml", server.uri()),
status.clone(),
);
checker.check_and_record().await.expect("check appcast");
let snapshot = status.snapshot();
assert_eq!(
snapshot
.latest
.as_ref()
.map(|release| release.version.as_str()),
Some("26.707.72221")
);
assert!(snapshot.checked_at.is_some());
assert!(snapshot.last_error.is_none());
}
@@ -1,190 +0,0 @@
use std::{num::NonZeroU32, time::Duration};
use codex_proxy_rs::upstream::openai::fingerprint::{
APPCAST_POLL_INTERVAL, PgFingerprintStore, UpdateChecker,
};
use crate::support::storage::init_test_db;
#[test]
fn fingerprint_appcast_poll_interval_should_be_one_day() {
assert_eq!(APPCAST_POLL_INTERVAL, Duration::from_hours(24));
}
#[tokio::test]
async fn fingerprint_store_should_update_current_record() {
let (pool, _guard) = init_test_db("fingerprint-store").await;
let repo = PgFingerprintStore::new(pool.clone());
let default_fingerprint = crate::support::fingerprint::test_fingerprint();
repo.ensure_current_seed(&default_fingerprint)
.await
.expect("seed current fingerprint");
repo.update_current_version("26.800.1", "6001", Some("147"))
.await
.expect("first update");
repo.update_current_version("26.800.2", "6002", None)
.await
.expect("second update");
let stored = repo
.load_current()
.await
.expect("load current")
.expect("stored fingerprint");
let count: (i64,) = sqlx::query_as("select count(*) from fingerprints where id = 'current'")
.fetch_one(&pool)
.await
.expect("count row");
assert_eq!(count.0, 1);
assert_eq!(stored.app_version, "26.800.2");
assert_eq!(stored.build_number, "6002");
assert_eq!(stored.chromium_version, "147");
}
#[tokio::test]
async fn fingerprint_store_should_keep_latest_update_history_rows() {
let (pool, _guard) = init_test_db("fingerprint-history-limit").await;
let repo = PgFingerprintStore::new(pool.clone());
repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.expect("seed current fingerprint");
sqlx::query(
"insert into fingerprint_update_history (
id, current_fingerprint_id, app_version, build_number, source, created_at
)
select
'history-' || lpad(sequence::text, 3, '0'),
'current',
'26.900.' || sequence::text,
sequence::text,
'auto_update',
$1 + sequence * interval '1 second'
from generate_series(0, 101) as sequence",
)
.bind(chrono::Utc::now())
.execute(&pool)
.await
.expect("insert fingerprint history rows");
let deleted = repo
.trim_update_history_to_limit(NonZeroU32::new(100).unwrap())
.await
.expect("trim fingerprint history rows");
let (remaining, oldest_remaining): (i64, i64) = sqlx::query_as(
"select
count(*),
count(*) filter (where id in ('history-000', 'history-001'))
from fingerprint_update_history",
)
.fetch_one(&pool)
.await
.expect("count retained fingerprint history rows");
assert_eq!((deleted, remaining, oldest_remaining), (2, 100, 0));
}
#[tokio::test]
async fn update_checker_should_report_available_update_from_appcast() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/appcast.xml"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_raw(
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
"application/xml",
))
.mount(&server)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let (pool, _guard) = init_test_db("fingerprint-update-check").await;
let repo = PgFingerprintStore::new(pool);
repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.expect("seed current fingerprint");
let checker = UpdateChecker::with_client(
repo,
reqwest::Client::new(),
format!("{}/appcast.xml", server.uri()),
dir.path().join("extracted-fingerprint.json"),
"26.800.1",
"6001",
);
let state = checker.check_for_update().await.expect("update state");
assert!(state.update_available);
assert_eq!(state.latest_version.as_deref(), Some("26.900.1"));
assert_eq!(state.latest_build.as_deref(), Some("7001"));
}
#[tokio::test]
async fn update_checker_should_apply_available_update_to_store() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/appcast.xml"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_raw(
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
"application/xml",
))
.mount(&server)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let (pool, _guard) = init_test_db("fingerprint-update-apply").await;
let repo = PgFingerprintStore::new(pool);
repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint())
.await
.expect("seed current fingerprint");
let extracted_path = dir.path().join("extracted-fingerprint.json");
std::fs::write(
&extracted_path,
r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#,
)
.expect("write extracted fingerprint");
let checker = UpdateChecker::with_client(
repo.clone(),
reqwest::Client::new(),
format!("{}/appcast.xml", server.uri()),
extracted_path,
"26.800.1",
"6001",
);
let updated = checker
.check_and_apply_update()
.await
.expect("apply update")
.expect("available update should return current fingerprint");
let stored = repo
.load_current()
.await
.expect("load current")
.expect("stored fingerprint");
assert_eq!(updated.app_version, "26.900.1");
assert_eq!(updated.build_number, "7001");
assert_eq!(updated.chromium_version, "147");
assert_eq!(stored.app_version, "26.900.1");
assert_eq!(stored.build_number, "7001");
assert_eq!(stored.chromium_version, "147");
}
+2 -1
View File
@@ -1,4 +1,5 @@
pub(crate) mod fingerprint;
mod desktop_release;
mod profile;
pub(crate) mod protocol;
pub(crate) mod token_client;
pub(crate) mod transport;
+9
View File
@@ -0,0 +1,9 @@
#[test]
fn wire_profile_should_generate_codex_core_user_agent() {
let profile = crate::support::wire_profile::test_wire_profile_value();
assert_eq!(
profile.user_agent(),
"Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)"
);
}
@@ -59,7 +59,7 @@ async fn codex_backend_client_should_apply_configured_websocket_pool() {
let backend = CodexBackendClient::new(
reqwest::Client::new(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(pool));
let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", Vec::new());
@@ -1,28 +1,46 @@
use super::*;
use codex_proxy_rs::upstream::openai::fingerprint::RuntimeFingerprint;
use codex_proxy_rs::upstream::openai::transport::build_ordered_codex_headers;
use codex_proxy_rs::upstream::openai::transport::build_codex_headers;
use codex_proxy_rs::upstream::openai::transport::websocket::CodexWebSocketConnection;
use serde_json::Value;
#[test]
fn ordered_codex_headers_should_preserve_fingerprint_priority_and_request_fields() {
let fingerprint = crate::support::fingerprint::test_fingerprint();
fn codex_headers_should_use_wire_profile_without_browser_metadata() {
let profile = crate::support::wire_profile::test_wire_profile_value();
let headers = build_ordered_codex_headers(
&fingerprint,
let headers = build_codex_headers(
&profile,
"access-token",
Some("acct-1"),
Some("turn-1"),
"req-1",
);
let keys = headers.keys().cloned().collect::<Vec<_>>();
)
.expect("valid profile headers");
assert_eq!(headers["authorization"], "Bearer access-token");
assert_eq!(headers["chatgpt-account-id"], "acct-1");
assert_eq!(headers["x-client-request-id"], "req-1");
assert_eq!(headers["x-codex-turn-state"], "turn-1");
assert_eq!(headers["accept"], "text/event-stream");
assert_eq!(keys.first().map(String::as_str), Some("authorization"));
let value = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
assert_eq!(value("authorization"), Some("Bearer access-token"));
assert_eq!(value("chatgpt-account-id"), Some("acct-1"));
assert_eq!(value("originator"), Some("Codex Desktop"));
assert_eq!(
value("user-agent"),
Some("Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)")
);
assert_eq!(value("x-client-request-id"), Some("req-1"));
assert_eq!(value("x-codex-turn-state"), Some("turn-1"));
assert_eq!(value("accept"), Some("text/event-stream"));
for browser_header in [
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"accept-language",
] {
assert!(
headers.get(browser_header).is_none(),
"sent {browser_header}"
);
}
}
#[test]
@@ -282,7 +300,7 @@ async fn codex_backend_client_websocket_should_forward_headers_and_preserve_payl
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
@@ -433,7 +451,7 @@ async fn codex_backend_client_should_send_desktop_headers_and_capture_response_m
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
@@ -490,7 +508,7 @@ async fn codex_backend_client_should_send_desktop_headers_and_capture_response_m
}
#[tokio::test]
async fn codex_backend_client_should_use_latest_runtime_fingerprint_for_new_requests() {
async fn codex_backend_client_should_send_effective_wire_profile_user_agent() {
let server = wiremock::MockServer::start().await;
let sse_body = include_str!("../../../fixtures/responses/http_sse/completed_usage_basic.sse");
wiremock::Mock::given(wiremock::matchers::method("POST"))
@@ -503,13 +521,12 @@ async fn codex_backend_client_should_use_latest_runtime_fingerprint_for_new_requ
.mount(&server)
.await;
let mut initial = crate::support::fingerprint::test_fingerprint();
initial.app_version = "26.800.1".to_string();
let runtime_fingerprint = RuntimeFingerprint::new(initial);
let profile = crate::support::wire_profile::test_wire_profile();
let expected_user_agent = profile.user_agent();
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
runtime_fingerprint.clone(),
profile,
);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
@@ -538,19 +555,9 @@ async fn codex_backend_client_should_use_latest_runtime_fingerprint_for_new_requ
};
client
.create_response(&request, request_context("req_fingerprint_1"))
.create_response(&request, request_context("req_profile"))
.await
.expect("first response should succeed");
let mut updated = runtime_fingerprint.snapshot();
updated.app_version = "26.900.1".to_string();
updated.build_number = "7001".to_string();
runtime_fingerprint.replace(updated);
client
.create_response(&request, request_context("req_fingerprint_2"))
.await
.expect("second response should succeed");
.expect("response should succeed");
let user_agents = server
.received_requests()
@@ -568,13 +575,7 @@ async fn codex_backend_client_should_use_latest_runtime_fingerprint_for_new_requ
})
.collect::<Vec<_>>();
assert_eq!(
user_agents,
vec![
"Codex Desktop/26.800.1 (darwin; arm64)".to_string(),
"Codex Desktop/26.900.1 (darwin; arm64)".to_string(),
]
);
assert_eq!(user_agents, vec![expected_user_agent]);
}
#[tokio::test]
@@ -592,7 +593,7 @@ async fn codex_backend_client_usage_should_use_wham_usage_headers() {
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let usage = client
@@ -636,7 +637,7 @@ async fn codex_backend_client_usage_should_use_wham_usage_headers() {
headers
.get("user-agent")
.and_then(|value| value.to_str().ok()),
Some("Codex Desktop/26.707.51957 (darwin; arm64)")
Some("Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)")
);
assert_eq!(
headers.get("accept").and_then(|value| value.to_str().ok()),
@@ -679,12 +680,12 @@ async fn codex_backend_client_models_should_use_original_auxiliary_headers() {
})))
.mount(&server)
.await;
let fingerprint = crate::support::fingerprint::runtime_test_fingerprint();
let client_version = fingerprint.current().app_version.clone();
let profile = crate::support::wire_profile::test_wire_profile();
let client_version = profile.codex_version.clone();
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
fingerprint,
profile,
);
let models = client
@@ -732,7 +733,7 @@ async fn codex_backend_client_models_should_use_original_auxiliary_headers() {
}
#[tokio::test]
async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order() {
async fn codex_backend_client_should_send_codex_context_without_browser_headers() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
@@ -758,7 +759,7 @@ async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order(
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
client
@@ -788,36 +789,38 @@ async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order(
let raw_request = server.await.unwrap();
let header_names = read_header_names(&raw_request);
assert_header_subsequence(
&header_names,
&[
"authorization",
"chatgpt-account-id",
"originator",
"user-agent",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"accept-encoding",
"accept-language",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"content-type",
"cookie",
"accept",
"openai-beta",
"x-openai-internal-codex-residency",
"x-client-request-id",
"x-codex-installation-id",
"session-id",
"x-codex-window-id",
"x-codex-turn-state",
"x-codex-turn-metadata",
"x-codex-beta-features",
"x-responsesapi-include-timing-metrics",
"version",
"x-codex-parent-thread-id",
],
);
for required_header in [
"authorization",
"chatgpt-account-id",
"originator",
"user-agent",
"content-type",
"cookie",
"accept",
"openai-beta",
"x-openai-internal-codex-residency",
"x-client-request-id",
"x-codex-installation-id",
"session-id",
"x-codex-window-id",
"x-codex-turn-state",
"x-codex-turn-metadata",
"x-codex-beta-features",
"x-responsesapi-include-timing-metrics",
"version",
"x-codex-parent-thread-id",
] {
assert!(header_names.iter().any(|name| name == required_header));
}
for browser_header in [
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"accept-language",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
] {
assert!(!header_names.iter().any(|name| name == browser_header));
}
}
@@ -126,7 +126,7 @@ async fn codex_backend_client_should_cap_non_success_error_body_at_one_mib() {
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
@@ -183,7 +183,7 @@ async fn codex_backend_client_should_parse_retry_after_from_rate_limit_error_bod
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
@@ -247,7 +247,7 @@ async fn codex_backend_http_sse_should_fail_after_five_minutes_without_stream_da
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
base_url,
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
@@ -298,7 +298,7 @@ async fn codex_backend_client_should_capture_only_safe_response_metadata() {
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
server.uri(),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
@@ -18,6 +18,18 @@ fn new_chain_request(conversation_id: &str) -> CodexResponsesRequest {
request
}
fn explicit_websocket_warmup_request(conversation_id: &str) -> CodexResponsesRequest {
let mut body = serde_json::Map::new();
body.insert("model".to_string(), json!("gpt-5.5"));
body.insert("input".to_string(), json!([]));
body.insert("generate".to_string(), json!(false));
body.insert("store".to_string(), json!(false));
let mut request = CodexResponsesRequest::from_body(body);
request.use_websocket = true;
request.local_conversation_id = Some(conversation_id.to_string());
request
}
#[tokio::test]
async fn cold_websocket_should_fall_back_without_recording_a_successful_connect() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -36,7 +48,7 @@ async fn cold_websocket_should_fall_back_without_recording_a_successful_connect(
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool))
.with_websocket_fast_path_budget(Duration::from_millis(30));
@@ -91,7 +103,7 @@ async fn timed_out_websocket_should_finish_in_background_and_serve_the_next_requ
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool))
.with_websocket_fast_path_budget(Duration::from_millis(30));
@@ -146,7 +158,7 @@ async fn shared_websocket_opening_should_keep_the_original_fast_path_deadline()
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool))
.with_websocket_fast_path_budget(Duration::from_millis(200));
@@ -215,7 +227,7 @@ async fn account_eviction_should_cancel_a_background_websocket_opening() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool))
.with_websocket_fast_path_budget(Duration::from_millis(30));
@@ -280,7 +292,7 @@ async fn pool_shutdown_should_cancel_and_join_a_background_websocket_opening() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool))
.with_websocket_fast_path_budget(Duration::from_millis(30));
@@ -323,17 +335,10 @@ async fn store_false_warmup_should_never_fall_back_to_http() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
let mut body = serde_json::Map::new();
body.insert("model".to_string(), json!("gpt-5.5"));
body.insert("input".to_string(), json!([]));
body.insert("generate".to_string(), json!(false));
body.insert("store".to_string(), json!(false));
let mut request = CodexResponsesRequest::from_body(body);
request.use_websocket = true;
request.local_conversation_id = Some("conversation-warmup-required".to_string());
let request = explicit_websocket_warmup_request("conversation-warmup-required");
let error = backend
.create_response(
@@ -380,7 +385,7 @@ async fn websocket_opening_upstream_error_should_not_fall_back_to_http() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
@@ -438,7 +443,7 @@ async fn connection_local_continuation_should_use_the_exact_socket() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
let first_request = new_chain_request("conversation-exact");
@@ -478,7 +483,7 @@ async fn missing_exact_socket_should_fail_without_opening_a_connection() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
let mut request = new_chain_request("conversation-exact-missing");
@@ -532,7 +537,7 @@ async fn concurrent_same_key_should_singleflight_websocket_opening() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()))
.with_websocket_fast_path_budget(Duration::from_millis(200));
@@ -584,7 +589,7 @@ async fn payload_send_failure_should_not_open_http_fallback() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
@@ -731,7 +736,7 @@ async fn fast_path_miss_and_late_failure_should_count_as_one_breaker_failure() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool))
.with_websocket_fast_path_budget(Duration::from_millis(30))
@@ -784,21 +789,18 @@ async fn fast_path_miss_and_late_failure_should_count_as_one_breaker_failure() {
async fn half_open_upstream_response_should_close_origin_breaker() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let origin_key = format!("http://{addr}");
let breaker = WebSocketOriginBreaker::with_config(WebSocketOriginBreakerConfig {
failure_threshold: 1,
failure_window: Duration::from_secs(1),
open_duration: Duration::from_millis(20),
open_duration: Duration::ZERO,
});
let WebSocketOriginBreakerDecision::Allowed(permit) = breaker.try_acquire(&origin_key) else {
panic!("closed breaker should grant the initial permit");
};
permit.fast_timeout();
let server = tokio::spawn(async move {
let (mut stalled_websocket, _) = listener.accept().await.unwrap();
let opening = read_http_request(&mut stalled_websocket).await;
assert!(opening.starts_with("GET /codex/responses HTTP/1.1"));
let (mut http, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut http).await;
assert!(request.starts_with("POST /codex/responses HTTP/1.1"));
write_completed_sse_response(&mut http).await;
let (mut probe, _) = listener.accept().await.unwrap();
let opening = read_http_request(&mut probe).await;
assert!(opening.starts_with("GET /codex/responses HTTP/1.1"));
@@ -811,21 +813,13 @@ async fn half_open_upstream_response_should_close_origin_breaker() {
});
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
origin_key.clone(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()))
.with_websocket_fast_path_budget(Duration::from_millis(100))
.with_websocket_fast_path_budget(Duration::from_secs(1))
.with_websocket_origin_breaker(breaker.clone());
backend
.create_response(
&new_chain_request("conversation-breaker-open"),
request_context("req_breaker_open", Some("chatgpt-account")),
)
.await
.expect("first timeout should use HTTP");
tokio::time::sleep(Duration::from_millis(50)).await;
let error = backend
.create_response(
&new_chain_request("conversation-breaker-probe"),
@@ -844,7 +838,7 @@ async fn half_open_upstream_response_should_close_origin_breaker() {
} if status == reqwest::StatusCode::UNAUTHORIZED
);
assert!(matches!(
breaker.try_acquire(&format!("http://{addr}")),
breaker.try_acquire(&origin_key),
WebSocketOriginBreakerDecision::Allowed(_)
));
}
@@ -113,19 +113,6 @@ pub(crate) fn read_header_names(request: &str) -> Vec<String> {
.collect()
}
pub(crate) fn assert_header_subsequence(actual: &[String], expected: &[&str]) {
let mut offset = 0;
for expected_name in expected {
let Some(position) = actual[offset..]
.iter()
.position(|actual_name| actual_name == expected_name)
else {
panic!("missing header {expected_name}; actual order: {actual:?}");
};
offset += position + 1;
}
}
pub(crate) async fn read_http_request(stream: &mut TcpStream) -> String {
let mut request = Vec::new();
let mut buffer = [0u8; 1024];
@@ -130,7 +130,7 @@ async fn codex_backend_client_should_decode_permessage_deflate_context_takeover_
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let mut request =
@@ -728,7 +728,7 @@ async fn codex_backend_client_should_not_reuse_websocket_rate_limit_headers() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let request = pooled_websocket_request("conversation-rate-limit-reuse");
@@ -986,7 +986,7 @@ async fn codex_backend_client_should_timeout_when_upstream_is_silent() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_initial_event_timeout(Some(Duration::from_millis(30)));
let mut request =
@@ -1154,7 +1154,7 @@ async fn codex_backend_client_stream_should_reject_binary_websocket_event() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut response = backend
@@ -1228,7 +1228,7 @@ async fn codex_backend_client_stream_should_keep_socket_after_structural_activit
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_initial_event_timeout(Some(Duration::from_millis(30)));
let request = pooled_websocket_request("conversation-structural-no-pool");
@@ -1289,7 +1289,7 @@ async fn codex_backend_client_stream_should_error_when_websocket_closes_before_t
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut response = backend
@@ -1386,7 +1386,7 @@ async fn codex_backend_client_stream_should_preserve_burst_during_downstream_bac
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let request = pooled_websocket_request("conversation-backpressure");
@@ -1476,7 +1476,7 @@ async fn codex_backend_client_stream_should_cancel_while_inbound_is_backpressure
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let request = pooled_websocket_request("conversation-backpressure-cancel");
@@ -1536,7 +1536,7 @@ async fn codex_backend_client_stream_should_wait_for_terminal_after_active_webso
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut response = backend
@@ -1603,7 +1603,7 @@ async fn codex_backend_client_stream_should_timeout_when_active_websocket_stalls
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
);
let mut response = backend
@@ -1700,7 +1700,7 @@ async fn codex_backend_client_should_use_websocket_when_previous_response_id_is_
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
@@ -39,7 +39,7 @@ async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -121,7 +121,7 @@ async fn codex_backend_client_should_open_fresh_socket_when_idle_pooled_websocke
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -215,7 +215,7 @@ async fn codex_backend_client_stream_should_keep_fresh_socket_after_structural_a
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let request = pooled_websocket_request("conversation-structural-fresh");
@@ -304,7 +304,7 @@ async fn codex_backend_client_stream_should_keep_reused_socket_after_structural_
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let request = pooled_websocket_request("conversation-structural-reuse");
@@ -362,7 +362,7 @@ async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let request = pooled_websocket_request("conversation-structural-bypass");
@@ -417,7 +417,7 @@ async fn codex_backend_client_should_not_reuse_pooled_websocket_across_local_acc
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -496,7 +496,7 @@ async fn websocket_pool_should_use_http_while_exact_key_is_busy() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let request = pooled_websocket_request("conversation-busy");
@@ -589,7 +589,7 @@ async fn websocket_pool_should_release_slot_when_client_drops_stream() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let request = pooled_websocket_request("conversation-drop");
@@ -669,7 +669,7 @@ async fn websocket_pool_should_use_http_for_new_keys_after_account_cap() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let first_request = pooled_websocket_request("conversation-cap-one");
@@ -715,6 +715,9 @@ async fn websocket_pool_should_use_http_for_new_keys_after_account_cap() {
#[tokio::test]
async fn websocket_pool_should_replace_idle_connection_after_pong_deadline() {
const PING_INTERVAL: Duration = Duration::from_secs(30);
const PONG_TIMEOUT: Duration = Duration::from_secs(30);
// 服务端读取 Ping 后暂不继续 poll,避免 tungstenite 自动回 Pong;pump 必须在独立
// deadline 到期时主动关闭连接,acquire 随后只读 closed 状态并直接新建连接。
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -735,7 +738,7 @@ async fn websocket_pool_should_replace_idle_connection_after_pong_deadline() {
))
.await
.unwrap();
let ping = timeout(Duration::from_secs(1), first_websocket.next())
let ping = timeout(Duration::from_secs(120), first_websocket.next())
.await
.expect("pump should send a keepalive ping")
.expect("keepalive ping should be present")
@@ -749,7 +752,7 @@ async fn websocket_pool_should_replace_idle_connection_after_pong_deadline() {
inspect_close_rx
.await
.expect("test should release the server after the Pong deadline");
let close = timeout(Duration::from_secs(1), first_websocket.next())
let close = timeout(Duration::from_secs(120), first_websocket.next())
.await
.expect("Pong deadline should close the idle websocket")
.expect("Pong deadline should send a close frame")
@@ -770,8 +773,8 @@ async fn websocket_pool_should_replace_idle_connection_after_pong_deadline() {
second_websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
ping_interval: Some(Duration::from_millis(10)),
ping_timeout: Duration::from_millis(30),
ping_interval: Some(PING_INTERVAL),
ping_timeout: PONG_TIMEOUT,
liveness_timeout: None,
maintenance_interval: None,
..websocket_pool_config_for_tests(None, None, None)
@@ -779,7 +782,7 @@ async fn websocket_pool_should_replace_idle_connection_after_pong_deadline() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let request = pooled_websocket_request("conversation-no-pong");
@@ -791,16 +794,20 @@ async fn websocket_pool_should_replace_idle_connection_after_pong_deadline() {
)
.await
.expect("first websocket response should succeed");
timeout(Duration::from_secs(1), ping_seen_rx)
tokio::time::pause();
tokio::time::advance(PING_INTERVAL).await;
tokio::task::yield_now().await;
ping_seen_rx
.await
.expect("server should observe the keepalive ping")
.expect("server should report the keepalive ping");
tokio::time::sleep(Duration::from_millis(80)).await;
tokio::time::advance(PONG_TIMEOUT + Duration::from_secs(1)).await;
tokio::task::yield_now().await;
tokio::task::yield_now().await;
inspect_close_tx.send(()).unwrap();
timeout(Duration::from_secs(1), pong_timeout_closed_rx)
pong_timeout_closed_rx
.await
.expect("server should observe the Pong-timeout close")
.expect("server should report the Pong-timeout close");
tokio::time::resume();
let second = backend
.create_response(
&request,
@@ -818,6 +825,8 @@ async fn websocket_pool_should_replace_idle_connection_after_pong_deadline() {
#[tokio::test]
async fn websocket_pool_should_gc_expired_idle_connections() {
const MAX_AGE: Duration = Duration::from_secs(30);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accepted_connections = Arc::new(AtomicUsize::new(0));
@@ -833,7 +842,7 @@ async fn websocket_pool_should_gc_expired_idle_connections() {
))
.await
.unwrap();
let close = timeout(Duration::from_secs(1), first_websocket.next())
let close = timeout(Duration::from_secs(60), first_websocket.next())
.await
.expect("gc sweep should close the expired idle websocket")
.expect("gc sweep should send a close frame")
@@ -853,7 +862,7 @@ async fn websocket_pool_should_gc_expired_idle_connections() {
second_websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
max_age: Duration::from_millis(5),
max_age: MAX_AGE,
maintenance_interval: None,
ping_interval: None,
liveness_timeout: None,
@@ -862,7 +871,7 @@ async fn websocket_pool_should_gc_expired_idle_connections() {
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let request = pooled_websocket_request("conversation-gc");
@@ -874,8 +883,10 @@ async fn websocket_pool_should_gc_expired_idle_connections() {
)
.await
.expect("first websocket response should succeed");
tokio::time::sleep(Duration::from_millis(15)).await;
tokio::time::pause();
tokio::time::advance(MAX_AGE).await;
pool.maintain_idle_connections().await;
tokio::time::resume();
let second = backend
.create_response(
&request,
@@ -896,6 +907,7 @@ async fn codex_backend_client_should_keep_idle_pooled_websocket_alive_across_rep
let addr = listener.local_addr().unwrap();
let ping_count = Arc::new(AtomicUsize::new(0));
let ping_count_for_server = Arc::clone(&ping_count);
let (pings_observed_tx, pings_observed_rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
@@ -924,6 +936,7 @@ async fn codex_backend_client_should_keep_idle_pooled_websocket_alive_across_rep
// pump 会在 idle 期间反复发送 keepalive ping;服务端计数并回 pong,
// 直到下一个业务请求(response.create)到达为止。
let mut previous_ping = None;
let mut pings_observed_tx = Some(pings_observed_tx);
loop {
let message = timeout(Duration::from_secs(1), websocket.next())
.await
@@ -937,7 +950,12 @@ async fn codex_backend_client_should_keep_idle_pooled_websocket_alive_across_rep
assert_ne!(previous, &payload, "keepalive ping sequence must be unique");
}
previous_ping = Some(payload.clone());
ping_count_for_server.fetch_add(1, Ordering::SeqCst);
let ping_count = ping_count_for_server.fetch_add(1, Ordering::SeqCst) + 1;
if ping_count == 2
&& let Some(pings_observed_tx) = pings_observed_tx.take()
{
pings_observed_tx.send(()).unwrap();
}
websocket.send(Message::Pong(payload)).await.unwrap();
}
Message::Text(_) => break,
@@ -972,7 +990,7 @@ async fn codex_backend_client_should_keep_idle_pooled_websocket_alive_across_rep
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -992,8 +1010,10 @@ async fn codex_backend_client_should_keep_idle_pooled_websocket_alive_across_rep
)
.await
.expect("first pooled websocket response should succeed");
// 让 pump 有时间发出多轮 keepalive ping。
tokio::time::sleep(Duration::from_millis(80)).await;
timeout(Duration::from_secs(2), pings_observed_rx)
.await
.expect("pump should emit repeated keepalive pings")
.expect("server should observe repeated keepalive pings");
let second = backend
.create_response(
&request,
@@ -1049,7 +1069,7 @@ async fn codex_backend_client_should_treat_active_business_frames_as_ping_livene
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let request = pooled_websocket_request("conversation-active-without-pong");
@@ -1138,7 +1158,7 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_when_account_is
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -1223,7 +1243,7 @@ async fn codex_backend_client_should_stop_reusing_pooled_websockets_after_shutdo
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -1265,6 +1285,8 @@ async fn codex_backend_client_should_stop_reusing_pooled_websockets_after_shutdo
#[tokio::test]
async fn codex_backend_client_should_close_idle_pooled_websocket_after_liveness_timeout() {
const LIVENESS_TIMEOUT: Duration = Duration::from_secs(30);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accepted_connections = Arc::new(AtomicUsize::new(0));
@@ -1295,7 +1317,7 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_after_liveness_
))
.await
.unwrap();
let close = timeout(Duration::from_secs(1), first_websocket.next())
let close = timeout(Duration::from_secs(60), first_websocket.next())
.await
.expect("liveness timeout should close the idle websocket")
.expect("liveness timeout should send a close frame")
@@ -1330,12 +1352,12 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_after_liveness_
second_websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::with_config(
websocket_pool_config_for_tests(None, None, Some(Duration::from_millis(1))),
websocket_pool_config_for_tests(None, None, Some(LIVENESS_TIMEOUT)),
));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut request =
@@ -1355,10 +1377,14 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_after_liveness_
)
.await
.expect("first pooled websocket response should succeed");
tokio::time::pause();
tokio::time::advance(LIVENESS_TIMEOUT).await;
tokio::task::yield_now().await;
liveness_closed_rx
.await
.expect("liveness watchdog should close the idle connection");
pool.maintain_idle_connections().await;
tokio::time::resume();
let second = backend
.create_response(
&request,
@@ -1430,7 +1456,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_error_termin
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let mut request =
@@ -1512,7 +1538,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_unknown_resp
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
crate::support::fingerprint::runtime_test_fingerprint(),
crate::support::wire_profile::test_wire_profile(),
)
.with_websocket_pool(pool);
let request = pooled_websocket_request("conversation-pool-unknown-failed");
+1 -1
View File
@@ -9,7 +9,7 @@ RUN npm install --global pnpm@11.7.0 \
COPY frontend ./
RUN pnpm build
FROM rust:1.97-bookworm@sha256:7d0723df719e7f213b69dc7c8c595985c3f4b060cfbee4f7bc0e347a86fe3b6a AS backend-builder
FROM rust:1.97-bookworm@sha256:8fa55b2f3ddf97471ab6a767bfa3f37e6bad0986ba823e75fea57e2a2a5c3073 AS backend-builder
WORKDIR /app/backend
ARG TARGETARCH
+11 -45
View File
@@ -47,53 +47,19 @@ x-cpr:
# 首个真实输出的绝对超时,单位毫秒;0 表示禁用。
initial_event_timeout_ms: 20000
fingerprint:
# PostgreSQL 中不存在当前指纹时,使用这里的数据初始化。
wire_profile:
# 该画像来自 Desktop 制品与 bundled Codex Core 的联合审计。
# appcast 只报告新 Desktop 制品,不会自动改写这里的请求身份。
originator: 'Codex Desktop'
app_version: '26.707.51957'
build_number: '5175'
platform: 'darwin'
codex_version: '0.144.2'
desktop_version: '26.707.72221'
desktop_build: '5307'
# 固定参考主机画像;必须在重新抓包核验后再修改。
os_type: 'Mac OS'
os_version: '15.7.1'
arch: 'arm64'
chromium_version: '146'
user_agent_template: 'Codex Desktop/{version} ({platform}; {arch})'
default_headers:
- name: 'Accept-Encoding'
value: 'gzip, deflate, br, zstd'
- name: 'Accept-Language'
value: 'en-US,en;q=0.9'
- name: 'sec-ch-ua-mobile'
value: '?0'
- name: 'sec-ch-ua-platform'
value: '"macOS"'
- name: 'sec-fetch-site'
value: 'same-origin'
- name: 'sec-fetch-mode'
value: 'cors'
- name: 'sec-fetch-dest'
value: 'empty'
header_order:
- authorization
- chatgpt-account-id
- originator
- x-openai-internal-codex-residency
- x-client-request-id
- x-codex-installation-id
- x-codex-turn-state
- openai-beta
- user-agent
- sec-ch-ua
- sec-ch-ua-mobile
- sec-ch-ua-platform
- accept-encoding
- accept-language
- sec-fetch-site
- sec-fetch-mode
- sec-fetch-dest
- content-type
- accept
- cookie
terminal: 'unknown'
verified_at: '2026-07-15T02:25:50+08:00'
admin:
session_ttl_minutes: 1440
+7 -6
View File
@@ -18,7 +18,7 @@ Codex Proxy RS 是一个单进程 Rust 网关,同时提供:
| 依赖 | 责任 |
| --- | --- |
| PostgreSQL | 账号、Key、设置、Cookie、指纹和遥测事实的权威存储 |
| PostgreSQL | 账号、Key、设置、Cookie 和遥测事实的权威存储 |
| Redis | 管理会话、刷新租约、模型快照、响应归属和会话级短期状态 |
| ChatGPT Codex | Responses、模型目录、配额和 OAuth token 刷新 |
| `.runtime/` | 身份派生密钥、更新状态、日志及本地 PostgreSQL/Redis 数据 |
@@ -105,6 +105,8 @@ backend/src/
upstream/openai/
├── token_client.rs
├── failure.rs
├── profile.rs
├── desktop_release.rs
├── protocol/
│ ├── responses.rs
│ ├── events.rs
@@ -124,13 +126,13 @@ upstream/openai/
│ ├── websocket_pool.rs
│ ├── websocket_breaker.rs
│ └── websocket_pump.rs
└── fingerprint/
```
- `protocol` 定义 Responses body、canonical SSE/WS 事实和序列化,不包含账号策略。
- `failure.rs` 将各 transport 错误规范化为稳定的 `UpstreamFailureFacts`,不解释账号状态。
- `transport` 拥有 HTTP/SSE、WebSocket、TLS、header、连接池、熔断和 transport metrics。
- `fingerprint` 拥有 Codex Desktop 指纹的存储、运行时快照和更新。
- `profile.rs` 定义配置驱动、启动后不可变的 Codex Desktop 上游请求画像。
- `desktop_release.rs` 只观测官方 appcast;发布变化不会自动改写生效请求画像。
- `token_client.rs` 只实现 OAuth token 刷新协议。
### fleet
@@ -409,7 +411,6 @@ PostgreSQL 保存:
| 失败请求事实 | `ops_error_logs` |
| 请求聚合桶 | `request_time_buckets` |
| Cloudflare Cookie | `account_cookies` |
| Codex 指纹 | `fingerprints`、`fingerprint_update_history` |
| 迁移记录 | `schema_migrations` |
时间列使用 `timestamptz`,应用按 UTC 时间点读写,中国自然日和展示时区由 `infra::time` 定义。迁移版本严格递增并校验名称与 SQL checksum。
@@ -459,7 +460,7 @@ Compose 将应用配置只读挂载到 `/app/deploy/config.yaml`。应用数据
6. 读取 `runtime_settings`。
7. 创建领域 store、service、OpenAI client 和账号池。
8. 读取或创建 `identity_hmac_secret`。
9. 初始化指纹、管理员和模型快照。
9. 构造上游请求画像并初始化管理员和模型快照。
10. 启动后台任务并挂载 HTTP router。
后台任务由 `TaskCoordinator` 管理:
@@ -467,7 +468,7 @@ Compose 将应用配置只读挂载到 `/app/deploy/config.yaml`。应用数据
- Cookie 清理和事实表 retention。
- 模型目录刷新。
- token 与 quota 刷新。
- Codex 指纹更新。
- Codex Desktop 官方发布观测。
- settings 订阅。
- WebSocket pool 生命周期。
+2 -2
View File
@@ -70,11 +70,11 @@ const loadingIconSize: Record<ButtonSize, number> = {
}
const classes = computed(() => [
'inline-flex items-center justify-center border-0 font-[720] leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none',
'inline-flex items-center justify-center border-0 font-[720] leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
props.iconOnly ? '' : 'rounded-(--cp-button-radius-base)',
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
'disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
props.iconOnly && (props.variant === 'default' || props.variant === 'primary')
? iconOnlyDefaultClasses
: variantClasses[props.variant],
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { usePreferredReducedMotion } from '@vueuse/core'
import { gsap } from 'gsap'
import { onBeforeUnmount, useTemplateRef, watch } from 'vue'
type MotionIconVariant = 'random' | 'brand'
type MotionPhase = 'enter' | 'leave'
type MotionTimeline = ReturnType<typeof gsap.timeline>
type ReactionBuilder = (root: HTMLElement, glyph: HTMLElement) => MotionTimeline
interface MotionReaction {
enter: ReactionBuilder
leave: ReactionBuilder
}
const props = withDefaults(
defineProps<{
as?: keyof HTMLElementTagNameMap
variant?: MotionIconVariant
}>(),
{
as: 'span',
variant: 'random',
},
)
const preferredMotion = usePreferredReducedMotion()
const icon = useTemplateRef<HTMLElement>('icon')
const glyph = useTemplateRef<HTMLElement>('glyph')
const randomReactions: MotionReaction[] = [
{ enter: liftAndBloomEnter, leave: liftAndBloomLeave },
{ enter: lookAroundEnter, leave: lookAroundLeave },
{ enter: doubleTapEnter, leave: doubleTapLeave },
{ enter: counterSwingEnter, leave: counterSwingLeave },
{ enter: shortOrbitEnter, leave: shortOrbitLeave },
]
const brandReaction: MotionReaction = {
enter: brandGreetingEnter,
leave: brandGreetingLeave,
}
let previousReactionIndex = -1
let activeReaction: MotionReaction | undefined
let activeTimeline: MotionTimeline | undefined
let isPointerInside = false
function motionTargets() {
return [icon.value, glyph.value].filter((target): target is HTMLElement => Boolean(target))
}
function clearMotionStyles(element: HTMLElement) {
gsap.set(element, { clearProps: 'transform,transformOrigin,willChange' })
}
function stopActiveTimeline(targets: HTMLElement[]) {
activeTimeline?.kill()
activeTimeline = undefined
gsap.killTweensOf(targets)
}
function resetMotion() {
const targets = motionTargets()
stopActiveTimeline(targets)
activeReaction = undefined
for (const target of targets) clearMotionStyles(target)
}
function createMotionTimeline(root: HTMLElement, glyphElement: HTMLElement, phase: MotionPhase) {
const targets = [root, glyphElement]
stopActiveTimeline(targets)
gsap.set(targets, { willChange: 'transform' })
let timeline: MotionTimeline
timeline = gsap.timeline({
defaults: { ease: 'power2.out' },
onComplete: () => {
if (activeTimeline !== timeline) return
activeTimeline = undefined
if (phase === 'leave' && !isPointerInside) {
activeReaction = undefined
for (const target of targets) clearMotionStyles(target)
return
}
gsap.set(targets, { clearProps: 'willChange' })
},
})
activeTimeline = timeline
return timeline
}
function nextRandomReaction() {
const index =
previousReactionIndex < 0
? Math.floor(Math.random() * randomReactions.length)
: (previousReactionIndex + 1 + Math.floor(Math.random() * (randomReactions.length - 1))) %
randomReactions.length
previousReactionIndex = index
return randomReactions[index]!
}
function liftAndBloomEnter(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'enter')
timeline
.to(root, { y: -3, scale: 1.055, duration: 0.22 }, 0)
.to(
glyphElement,
{ y: -0.5, rotation: -4, scale: 1.13, duration: 0.26, ease: 'back.out(2.1)' },
0.02,
)
return timeline
}
function liftAndBloomLeave(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'leave')
timeline
.to(glyphElement, { y: 0, rotation: 0, scale: 1, duration: 0.24, ease: 'back.out(1.6)' }, 0)
.to(root, { y: 0, scale: 1, duration: 0.22, ease: 'power3.out' }, 0.02)
return timeline
}
function lookAroundEnter(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'enter')
timeline
.to(root, { y: -1, scale: 1.025, duration: 0.2 }, 0)
.to(glyphElement, { x: -2.5, rotation: -7, duration: 0.13 }, 0)
.to(glyphElement, { x: 2.5, rotation: 7, duration: 0.17, ease: 'power2.inOut' }, 0.13)
.to(
glyphElement,
{ x: 1, rotation: 3, scale: 1.06, duration: 0.16, ease: 'back.out(1.7)' },
0.3,
)
return timeline
}
function lookAroundLeave(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'leave')
timeline
.to(glyphElement, { x: 0, rotation: 0, scale: 1, duration: 0.24, ease: 'back.out(1.7)' }, 0)
.to(root, { y: 0, scale: 1, duration: 0.22, ease: 'power3.out' }, 0.02)
return timeline
}
function doubleTapEnter(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'enter')
timeline
.to(root, { y: 1, scale: 0.965, duration: 0.09, ease: 'power2.in' }, 0)
.to(glyphElement, { y: 1.5, scale: 0.92, duration: 0.09, ease: 'power2.in' }, 0)
.to(root, { y: -3, scale: 1.055, duration: 0.17, ease: 'back.out(2.2)' }, 0.09)
.to(glyphElement, { y: -2, scale: 1.12, duration: 0.19, ease: 'back.out(2.4)' }, 0.09)
return timeline
}
function doubleTapLeave(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'leave')
timeline
.to(root, { y: 0.5, scale: 0.985, duration: 0.1, ease: 'power2.in' }, 0)
.to(glyphElement, { y: 0.75, scale: 0.97, duration: 0.1, ease: 'power2.in' }, 0)
.to(root, { y: 0, scale: 1, duration: 0.17, ease: 'back.out(1.8)' }, 0.1)
.to(glyphElement, { y: 0, scale: 1, duration: 0.18, ease: 'back.out(1.9)' }, 0.1)
return timeline
}
function counterSwingEnter(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'enter')
timeline
.to(root, { x: -1.5, y: -1.5, rotation: -4, scale: 1.035, duration: 0.14 }, 0)
.to(glyphElement, { x: 2, rotation: 8, duration: 0.14 }, 0)
.to(root, { x: 1, y: -1, rotation: 2, duration: 0.15, ease: 'power2.inOut' }, 0.14)
.to(glyphElement, { x: -1, rotation: -5, duration: 0.15, ease: 'power2.inOut' }, 0.14)
return timeline
}
function counterSwingLeave(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'leave')
timeline
.to(root, { x: 0, y: 0, rotation: 0, scale: 1, duration: 0.23, ease: 'back.out(1.6)' }, 0)
.to(glyphElement, { x: 0, rotation: 0, duration: 0.21, ease: 'back.out(1.8)' }, 0.02)
return timeline
}
function shortOrbitEnter(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'enter')
timeline
.to(root, { y: -1.5, scale: 1.025, duration: 0.18 }, 0)
.to(glyphElement, { x: 2.5, y: -2, rotation: 5, duration: 0.13 }, 0)
.to(glyphElement, { x: -2, y: -0.5, rotation: -5, duration: 0.15 }, 0.13)
.to(
glyphElement,
{ x: 0.75, y: -1, rotation: 2, scale: 1.08, duration: 0.18, ease: 'back.out(1.8)' },
0.28,
)
return timeline
}
function shortOrbitLeave(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'leave')
timeline
.to(glyphElement, {
x: 0,
y: 0,
rotation: 0,
scale: 1,
duration: 0.24,
ease: 'back.out(1.7)',
})
.to(root, { y: 0, scale: 1, duration: 0.22, ease: 'power3.out' }, 0.02)
return timeline
}
function brandGreetingEnter(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'enter')
timeline
.to(root, { scale: 0.965, duration: 0.09, ease: 'power2.in' }, 0)
.to(glyphElement, { y: 1, scale: 0.92, duration: 0.09, ease: 'power2.in' }, 0)
.to(root, { scale: 1, duration: 0.18, ease: 'back.out(2.4)' }, 0.09)
.to(
glyphElement,
{ y: -1.5, rotation: -5, scale: 1.28, duration: 0.22, ease: 'back.out(3)' },
0.08,
)
.to(glyphElement, { x: 1, rotation: 4, scale: 1.2, duration: 0.13, ease: 'power1.inOut' }, 0.3)
.to(
glyphElement,
{ x: -0.5, rotation: -2, scale: 1.24, duration: 0.11, ease: 'power1.inOut' },
0.43,
)
return timeline
}
function brandGreetingLeave(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'leave')
timeline
.to(glyphElement, {
x: 0.35,
y: -0.5,
rotation: 1,
scale: 1.08,
duration: 0.14,
ease: 'power2.in',
})
.to(root, { scale: 0.985, duration: 0.14, ease: 'power2.in' }, 0)
.to(
glyphElement,
{ x: 0, y: 0, rotation: 0, scale: 1, duration: 0.2, ease: 'back.out(1.8)' },
0.12,
)
.to(root, { scale: 1, duration: 0.2, ease: 'back.out(1.7)' }, 0.12)
return timeline
}
function playEnterReaction() {
isPointerInside = true
if (preferredMotion.value === 'reduce') {
resetMotion()
return
}
const root = icon.value
const glyphElement = glyph.value
if (!root || !glyphElement) return
activeReaction = props.variant === 'brand' ? brandReaction : nextRandomReaction()
activeReaction.enter(root, glyphElement)
}
function playLeaveReaction() {
isPointerInside = false
if (preferredMotion.value === 'reduce') {
resetMotion()
return
}
const root = icon.value
const glyphElement = glyph.value
if (!root || !glyphElement || !activeReaction) {
resetMotion()
return
}
activeReaction.leave(root, glyphElement)
}
watch(preferredMotion, (motion) => {
if (motion === 'reduce') resetMotion()
})
onBeforeUnmount(resetMotion)
</script>
<template>
<component
:is="props.as"
ref="icon"
@pointerenter="playEnterReaction"
@pointerleave="playLeaveReaction"
>
<span ref="glyph" class="inline-flex items-center justify-center">
<slot />
</span>
</component>
</template>
+35 -16
View File
@@ -1,13 +1,7 @@
<script setup lang="ts">
import {
onClickOutside,
onKeyStroke,
useEventListener,
useThrottleFn,
whenever,
} from '@vueuse/core'
import { onClickOutside, useEventListener, useThrottleFn, whenever } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useAttrs } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useAttrs, watch } from 'vue'
import type { CSSProperties } from 'vue'
type PopoverPlacement =
@@ -36,6 +30,9 @@ const props = withDefaults(
width?: number | string
disabled?: boolean
panelClass?: string
triggerClass?: string
anchorElement?: HTMLElement | null
animatePosition?: boolean
}>(),
{
placement: 'bottom-end',
@@ -43,6 +40,9 @@ const props = withDefaults(
offset: 6,
disabled: false,
panelClass: '',
triggerClass: '',
anchorElement: null,
animatePosition: false,
},
)
@@ -55,9 +55,13 @@ const popoverRef = ref<HTMLElement | null>(null)
const popoverStyle = ref<CSSProperties>({})
const popoverArrowStyle = ref<CSSProperties>({})
const hoverCloseTimer = shallowRef<number>()
const viewportTarget = computed(() => (open.value && typeof window !== 'undefined' ? window : null))
const popoverClasses = computed(() => [
'fixed z-50 overflow-visible rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1.5 text-left shadow-(--cp-shadow-popover)',
props.animatePosition
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
: undefined,
props.panelClass,
])
@@ -76,10 +80,11 @@ function toCssLength(value?: number | string) {
}
function updatePopoverPosition() {
if (!open.value || !triggerRef.value) return
const anchorElement = props.anchorElement ?? triggerRef.value
if (!open.value || !anchorElement) return
const viewportPadding = 8
const triggerRect = triggerRef.value.getBoundingClientRect()
const triggerRect = anchorElement.getBoundingClientRect()
const panelRect = popoverRef.value?.getBoundingClientRect()
const panelWidth = Math.max(
panelRect?.width ?? 0,
@@ -314,15 +319,23 @@ whenever(open, async () => {
await nextTick()
updatePopoverPosition()
})
watch(
() => props.anchorElement,
async () => {
if (!open.value) return
await nextTick()
updatePopoverPosition()
},
)
onClickOutside(rootRef, closePopover, { ignore: [popoverRef] })
onKeyStroke('Escape', () => {
if (open.value) {
useEventListener(viewportTarget, 'keydown', (event) => {
if (event instanceof KeyboardEvent && event.key === 'Escape') {
closePopover()
}
})
useEventListener(window, 'resize', updatePopoverPositionThrottled)
useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: true })
useEventListener(viewportTarget, 'resize', updatePopoverPositionThrottled)
useEventListener(viewportTarget, 'scroll', updatePopoverPositionThrottled, { capture: true })
onBeforeUnmount(clearHoverCloseTimer)
</script>
@@ -331,6 +344,7 @@ onBeforeUnmount(clearHoverCloseTimer)
<div
ref="triggerRef"
class="inline-flex"
:class="props.triggerClass"
@click.stop="togglePopover"
@keydown="handleTriggerKeydown"
@mouseenter="handleHoverEnter"
@@ -341,10 +355,10 @@ onBeforeUnmount(clearHoverCloseTimer)
<Teleport to="body">
<Transition
enter-active-class="transition-[opacity,transform] duration-150 ease-out"
enter-active-class="transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"
enter-from-class="-translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-active-class="transition-opacity duration-150 ease-in motion-reduce:transition-none"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
@@ -358,6 +372,11 @@ onBeforeUnmount(clearHoverCloseTimer)
>
<span
class="pointer-events-none absolute size-2 rotate-45 bg-inherit"
:class="
props.animatePosition
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
: undefined
"
:style="popoverArrowStyle"
/>
<slot :open="open" :close="closePopover" :toggle="togglePopover" />
@@ -10,6 +10,7 @@ export interface BaseTableColumn {
minWidth?: number | string
maxWidth?: number | string
flex?: number
fixed?: 'left' | 'right' | false
align?: 'left' | 'right' | 'center'
ellipsis?: boolean
emptyText?: string
@@ -24,7 +25,7 @@ export interface BaseTableSort {
direction: BaseTableSortDirection
}
export type ResolvedTableColumn = BaseTableColumn & {
export type ResolvedTableColumn = Omit<BaseTableColumn, 'fixed'> & {
fixed?: 'left' | 'right'
resolvedWidth: string
resolvedMinWidth?: string
@@ -51,6 +52,8 @@ export function resolveColumns(
return columns.map((column, index) => {
const flex = column.flex ?? 1
const automaticallyFixed =
index === fixedColumnIndex ? (actionColumnIndex >= 0 ? 'right' : 'left') : undefined
const width =
column.width === undefined
? minWidthPixels > 0 && fixedPercentTotal === 0
@@ -64,7 +67,7 @@ export function resolveColumns(
return {
...column,
fixed: index === fixedColumnIndex ? (actionColumnIndex >= 0 ? 'right' : 'left') : undefined,
fixed: column.fixed === false ? undefined : (column.fixed ?? automaticallyFixed),
resolvedWidth: width,
resolvedMinWidth: column.minWidth === undefined ? undefined : normalizeWidth(column.minWidth),
resolvedMaxWidth: column.maxWidth === undefined ? undefined : normalizeWidth(column.maxWidth),
+118 -8
View File
@@ -1,7 +1,16 @@
<script setup lang="ts">
import { usePreferredReducedMotion } from '@vueuse/core'
import { usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
import { gsap } from 'gsap'
import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
useTemplateRef,
watch,
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import {
@@ -21,6 +30,7 @@ import {
} from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
import { useSystemUpdate } from '@/composables/useSystemUpdate'
import { useAuthStore } from '@/stores/modules/auth'
import { useUiStore } from '@/stores/modules/ui'
@@ -67,7 +77,30 @@ const isActive = (path: string) => {
return route.path.startsWith(path)
}
const activeNavIndex = computed(() => {
const index = navItems.findIndex((item) => isActive(item.path))
return Math.max(0, index)
})
const activeNavIndicatorStyle = computed(() => ({
transform: `translate3d(0, ${activeNavIndex.value * 58}px, 0)`,
}))
const navFeedbackMuted = shallowRef(false)
const { start: restoreNavFeedback, stop: stopNavFeedbackRestore } = useTimeoutFn(
() => {
navFeedbackMuted.value = false
},
300,
{ immediate: false },
)
function muteNavFeedbackDuringMove() {
navFeedbackMuted.value = true
stopNavFeedbackRestore()
restoreNavFeedback()
}
function navigate(path: string) {
muteNavFeedbackDuringMove()
void router.push(path)
emit('navigate')
}
@@ -98,6 +131,7 @@ async function handleLogout() {
const sidebarEl = ref<HTMLElement | null>(null)
const brandLabelEl = ref<HTMLElement | null>(null)
const navSignalEl = useTemplateRef<HTMLElement>('navSignal')
const systemUpdateOpen = shallowRef(false)
const aboutOpen = shallowRef(false)
const isCollapsed = computed(() => !props.mobile && Boolean(props.collapsed))
@@ -217,6 +251,29 @@ function animateSidebarWidth(collapsed: boolean) {
})
}
function animateNavSignal() {
const signal = navSignalEl.value
if (!signal) return
gsap.killTweensOf(signal)
if (prefersReducedMotion()) {
gsap.set(signal, { opacity: 0, xPercent: -70 })
return
}
gsap.fromTo(
signal,
{ opacity: 0.48, xPercent: -70 },
{
opacity: 0,
xPercent: 120,
duration: 0.52,
ease: 'power2.out',
overwrite: true,
},
)
}
onMounted(() => {
gsap.set(sidebarEl.value, {
width: sidebarWidth.value,
@@ -231,6 +288,7 @@ onMounted(() => {
})
}
animateSidebarLabels(isCollapsed.value)
gsap.set(navSignalEl.value, { opacity: 0, xPercent: -70 })
void loadVersion().catch(() => undefined)
})
@@ -251,6 +309,30 @@ watch(
animateSidebarLabels(Boolean(collapsed))
},
)
watch(
() => route.path,
async (path, previousPath) => {
if (path === previousPath) return
muteNavFeedbackDuringMove()
await nextTick()
animateNavSignal()
},
{ flush: 'post' },
)
onBeforeUnmount(() => {
stopNavFeedbackRestore()
const targets = [sidebarEl.value, brandLabelEl.value, navSignalEl.value].filter(
(target): target is HTMLElement => Boolean(target),
)
gsap.killTweensOf(targets)
const labels = sidebarEl.value?.querySelectorAll<HTMLElement>('.sidebar-label')
if (labels?.length) {
gsap.killTweensOf(labels)
}
})
</script>
<template>
@@ -266,11 +348,13 @@ watch(
class="mt-6 grid h-12 grid-cols-[44px_minmax(0,1fr)] items-center"
:class="isCollapsed ? 'w-11 justify-start' : 'w-full gap-3'"
>
<span
<BaseMotionIcon
aria-hidden="true"
variant="brand"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-primary)"
>
<Cat :size="27" stroke-width="2" />
</span>
</BaseMotionIcon>
<span
v-show="brandLabelVisible"
ref="brandLabelEl"
@@ -304,17 +388,32 @@ watch(
</span>
</div>
<nav class="mt-7 grid gap-3" :class="isCollapsed ? '' : 'w-full'" aria-label="主导航">
<nav
class="relative mt-7 grid gap-3"
:class="isCollapsed ? 'w-11.5' : 'w-full'"
aria-label="主导航"
>
<span
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-(--cp-icon-button-radius) bg-(--cp-bg-nav-active) transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:style="activeNavIndicatorStyle"
>
<span ref="navSignal" class="sidebar-active-signal absolute inset-y-0 left-0 w-2/3" />
</span>
<button
v-for="item in navItems"
:key="item.label"
type="button"
class="inline-flex h-11.5 cursor-pointer items-center rounded-(--cp-icon-button-radius) border-0 text-sm leading-[1.15] transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-(--cp-icon-button-radius) border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
:class="[
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? 'bg-(--cp-bg-nav-active) font-bold text-(--cp-text-primary)'
: 'bg-transparent font-semibold text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) hover:text-(--cp-text-primary)',
? navFeedbackMuted
? 'bg-transparent font-bold text-(--cp-text-primary) transition-none'
: 'bg-transparent font-bold text-(--cp-text-primary) transition-colors duration-200'
: navFeedbackMuted
? 'bg-transparent font-semibold text-(--cp-text-secondary) transition-none'
: 'bg-transparent font-semibold text-(--cp-text-secondary) transition-colors duration-200 hover:bg-(--cp-bg-subtle) hover:text-(--cp-text-primary)',
]"
@click="navigate(item.path)"
>
@@ -420,3 +519,14 @@ watch(
<AppAboutModal v-model="aboutOpen" />
<SystemUpdateModal v-model="systemUpdateOpen" />
</template>
<style scoped>
.sidebar-active-signal {
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--cp-info) 9%, transparent),
transparent
);
}
</style>
@@ -3,6 +3,7 @@ import { computed } from 'vue'
import { AlertTriangle, Gauge, ShieldCheck, Users } from '@lucide/vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
const props = defineProps<{
summary: any
@@ -80,12 +81,13 @@ function overviewIconClass(tone: string) {
{{ item.caption }}
</p>
</div>
<span
<BaseMotionIcon
aria-hidden="true"
class="inline-flex size-9 shrink-0 items-center justify-center self-start rounded-lg"
:class="overviewIconClass(item.tone)"
>
<component :is="item.icon" class="size-4.5" />
</span>
</BaseMotionIcon>
</div>
</BaseCard>
</div>
@@ -3,9 +3,9 @@ import { clamp } from 'es-toolkit'
import { computed } from 'vue'
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
import AccountIdentityCell from '../../accounts/components/AccountIdentityCell.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import AccountIdentityCell from '@/views/accounts/components/AccountIdentityCell.vue'
const props = defineProps<{
accounts: any[]
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { usePreferredReducedMotion } from '@vueuse/core'
import { gsap } from 'gsap'
import { computed, nextTick, onBeforeUnmount, useTemplateRef, watch } from 'vue'
const props = withDefaults(
defineProps<{
updatedAt?: string
}>(),
{
updatedAt: '',
},
)
const preferredMotion = usePreferredReducedMotion()
const heartbeatDot = useTemplateRef<HTMLElement>('heartbeatDot')
const heartbeatRing = useTemplateRef<HTMLElement>('heartbeatRing')
const statusLabel = computed(() =>
props.updatedAt ? `自动刷新 30s,最近刷新 ${props.updatedAt}` : '自动刷新 30s,等待首次刷新',
)
function pulse() {
const dot = heartbeatDot.value
const ring = heartbeatRing.value
if (!dot || !ring || preferredMotion.value === 'reduce') return
gsap.killTweensOf([dot, ring])
gsap.fromTo(
dot,
{ scale: 0.72, opacity: 0.55 },
{
scale: 1,
opacity: 1,
duration: 0.42,
ease: 'back.out(2.2)',
clearProps: 'transform,opacity',
},
)
gsap.fromTo(
ring,
{ scale: 0.55, opacity: 0.34 },
{
scale: 2.65,
opacity: 0,
duration: 0.65,
ease: 'power2.out',
clearProps: 'transform,opacity',
},
)
}
watch(
() => props.updatedAt,
async (updatedAt, previousUpdatedAt) => {
if (!updatedAt || updatedAt === previousUpdatedAt) return
await nextTick()
pulse()
},
)
onBeforeUnmount(() => {
gsap.killTweensOf([heartbeatDot.value, heartbeatRing.value])
})
</script>
<template>
<span class="inline-flex items-center gap-1.75" :aria-label="statusLabel" :title="statusLabel">
<span
aria-hidden="true"
class="relative inline-flex size-2 shrink-0 items-center justify-center"
>
<span ref="heartbeatRing" class="absolute size-2 rounded-full bg-(--cp-success) opacity-0" />
<span ref="heartbeatDot" class="relative size-1.5 rounded-full bg-(--cp-success)" />
</span>
<span aria-hidden="true">自动刷新 30s</span>
</span>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
formatHealthCount,
healthReliabilityValueClass,
healthStatusMeta,
type HealthTimelinePoint,
} from '../constants'
const props = defineProps<{
point: HealthTimelinePoint
}>()
const status = computed(() => healthStatusMeta[props.point.status])
const eligibleRequests = computed(() => props.point.successRequests + props.point.failedRequests)
const observedRequests = computed(
() => eligibleRequests.value + props.point.cancelledRequests + props.point.callerErrorRequests,
)
const outcomeSegments = computed(() => {
const total = observedRequests.value
if (total === 0) return []
return [
{
label: '成功',
value: props.point.successRequests,
className: 'bg-(--cp-success)',
},
{
label: '服务失败',
value: props.point.failedRequests,
className: 'bg-(--cp-danger)',
},
{
label: '客户端取消',
value: props.point.cancelledRequests,
className: 'bg-(--cp-normal)',
},
{
label: '调用方错误',
value: props.point.callerErrorRequests,
className: 'bg-(--cp-info)',
},
]
.filter((item) => item.value > 0)
.map((item) => ({
...item,
percentage: (item.value / total) * 100,
}))
})
const metricItems = computed(() => [
{
label: '成功',
value: props.point.successRequests,
dotClass: 'bg-(--cp-success)',
valueClass: 'text-(--cp-success-text)',
},
{
label: '服务失败',
value: props.point.failedRequests,
dotClass: 'bg-(--cp-danger)',
valueClass: 'text-(--cp-danger-text)',
},
{
label: '客户端取消',
value: props.point.cancelledRequests,
dotClass: 'bg-(--cp-normal)',
valueClass: 'text-(--cp-normal-text)',
},
{
label: '调用方错误',
value: props.point.callerErrorRequests,
dotClass: 'bg-(--cp-info)',
valueClass: 'text-(--cp-info-text)',
},
])
</script>
<template>
<section
role="dialog"
:aria-label="`${point.time} 请求健康详情`"
class="overflow-hidden rounded-(--cp-popover-radius)"
>
<header class="flex items-center justify-between gap-3 bg-(--cp-bg-subtle) px-3.5 py-3">
<div class="flex min-w-0 items-center gap-2">
<span class="size-2 shrink-0 rounded-full" :class="status.cellClass" />
<strong class="font-mono text-[13px] leading-none font-[780] text-(--cp-text-primary)">
{{ point.time }}
</strong>
<span
class="inline-flex h-5 items-center rounded-full px-2 text-[10px] leading-none font-[760]"
:class="status.badgeClass"
>
{{ status.label }}
</span>
</div>
<span class="shrink-0 text-[10px] leading-none font-[650] text-(--cp-text-muted)">
15 分钟
</span>
</header>
<div class="grid gap-3.5 px-3.5 py-3.5">
<div class="flex items-end justify-between gap-4">
<div>
<p class="m-0 text-[11px] leading-none font-[650] text-(--cp-text-muted)">
有效请求可用性
</p>
<strong
class="mt-2 block font-mono text-[25px] leading-none font-[790] tabular-nums"
:class="healthReliabilityValueClass(point.successRequests, point.failedRequests)"
>
{{ point.reliabilityDisplay }}
</strong>
</div>
<div class="text-right">
<span class="block text-[10px] leading-none font-[650] text-(--cp-text-muted)">
有效请求
</span>
<strong
class="mt-2 block font-mono text-[15px] leading-none font-[760] tabular-nums text-(--cp-text-primary)"
>
{{ formatHealthCount(eligibleRequests) }}
</strong>
</div>
</div>
<div
class="flex h-1.5 w-full overflow-hidden rounded-full bg-(--cp-bg-muted)"
aria-hidden="true"
>
<span
v-for="segment in outcomeSegments"
:key="segment.label"
class="h-full"
:class="segment.className"
:style="{ flexBasis: `${segment.percentage}%` }"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div
v-for="item in metricItems"
:key="item.label"
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-2.5 py-2.5"
>
<span class="size-1.5 rounded-full" :class="item.dotClass" />
<span class="truncate text-[10px] leading-none font-[650] text-(--cp-text-secondary)">
{{ item.label }}
</span>
<strong
class="font-mono text-[12px] leading-none font-[760] tabular-nums"
:class="item.valueClass"
>
{{ formatHealthCount(item.value) }}
</strong>
</div>
</div>
<p
class="m-0 border-t border-(--cp-divider-subtle) pt-3 text-[10px] leading-[1.45] font-[650] text-(--cp-text-muted)"
>
客户端取消与调用方错误单独记录,不计入可用性。
</p>
</div>
</section>
</template>
@@ -3,8 +3,9 @@ import type { EChartsOption } from 'echarts'
import type { Component } from 'vue'
import { computed } from 'vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseChart from '../../../components/charts/BaseChart.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import AnimatedMetricValue from './AnimatedMetricValue.vue'
@@ -110,12 +111,13 @@ const sparklineOption = computed<EChartsOption | null>(() => {
<template>
<BaseCard as="article" :padded="false" class="relative h-38.5 w-full px-6 pt-5">
<div class="flex items-start gap-3">
<span
<BaseMotionIcon
aria-hidden="true"
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-[10px]"
:class="iconToneClasses[metric.tone]"
>
<component :is="metric.icon" :size="18" />
</span>
</BaseMotionIcon>
<span class="mt-1 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">{{
metric.title
}}</span>
@@ -1,39 +1,201 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePreferredReducedMotion } from '@vueuse/core'
import { gsap } from 'gsap'
import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BasePopover from '@/components/base/BasePopover.vue'
import HealthTimelinePointPopover from './HealthTimelinePointPopover.vue'
import {
formatHealthCount,
healthLegend,
healthReliabilityValueClass,
healthStatusMeta,
type HealthTimeline,
type HealthTimelinePoint,
} from '../constants'
const props = defineProps<{
timeline: any
timeline: HealthTimeline
}>()
const healthLegend = [
{ code: '0', label: '未来' },
{ code: '1', label: '无请求' },
{ code: '2', label: '不可达' },
{ code: '3', label: '不稳定' },
{ code: '4', label: '低样本' },
{ code: '5', label: '稳定' },
]
const timelineGrid = useTemplateRef<HTMLElement>('timelineGrid')
const preferredMotion = usePreferredReducedMotion()
const points = computed(() => props.timeline.points)
const points = computed(() => String(props.timeline.points ?? '').split(''))
const pointGroups = computed(() => {
const groups: string[][] = []
for (let index = 0; index < points.value.length; index += 4) {
groups.push(points.value.slice(index, index + 4))
const activePoint = shallowRef<HealthTimelinePoint>()
const activeAnchor = shallowRef<HTMLElement | null>(null)
const popoverOpen = shallowRef(false)
const highlightedPointTime = shallowRef<string>()
let wavedCellIndexes = new Set<number>()
function observedRequests(point: HealthTimelinePoint) {
return (
point.successRequests +
point.failedRequests +
point.cancelledRequests +
point.callerErrorRequests
)
}
function isInteractivePoint(point: HealthTimelinePoint) {
return point.status !== 'future' && observedRequests(point) > 0
}
function isActivePoint(point: HealthTimelinePoint) {
return popoverOpen.value && highlightedPointTime.value === point.time
}
function activatePoint(point: HealthTimelinePoint, pointIndex: number, event: Event) {
if (!(event.currentTarget instanceof HTMLElement)) return
if (!isInteractivePoint(point)) {
closePointPopover()
return
}
return groups
activePoint.value = point
activeAnchor.value = event.currentTarget
popoverOpen.value = true
highlightedPointTime.value = point.time
animatePointWave(pointIndex)
}
function closePointPopover() {
popoverOpen.value = false
resetPointInteraction()
}
function resetPointInteraction() {
highlightedPointTime.value = undefined
activePoint.value = undefined
activeAnchor.value = null
releasePointWave()
}
function timelineButtons() {
return Array.from(
timelineGrid.value?.querySelectorAll<HTMLButtonElement>('[data-health-timeline-point]') ?? [],
)
}
function timelineCell(button?: HTMLButtonElement) {
return button?.querySelector<HTMLElement>('[data-health-timeline-cell]')
}
function animatePointWave(centerIndex: number) {
const buttons = timelineButtons()
const centerButton = buttons[centerIndex]
if (!centerButton) return
const centerRow = centerButton.offsetTop
const nextCellIndexes = new Set<number>()
const cellsByDistance: HTMLElement[][] = [[], [], []]
for (let distance = 0; distance <= 2; distance += 1) {
const candidateIndexes =
distance === 0 ? [centerIndex] : [centerIndex - distance, centerIndex + distance]
for (const index of candidateIndexes) {
const button = buttons[index]
const point = points.value[index]
const cell = timelineCell(button)
if (
!button ||
!point ||
!cell ||
button.offsetTop !== centerRow ||
!isInteractivePoint(point)
) {
continue
}
nextCellIndexes.add(index)
cellsByDistance[distance]?.push(cell)
}
}
const cellsToRelease = [...wavedCellIndexes]
.filter((index) => !nextCellIndexes.has(index))
.map((index) => timelineCell(buttons[index]))
.filter((cell): cell is HTMLElement => Boolean(cell))
if (preferredMotion.value === 'reduce') {
const cells = cellsByDistance.flat()
gsap.set([...cells, ...cellsToRelease], { clearProps: 'transform,willChange' })
wavedCellIndexes = nextCellIndexes
return
}
animateWaveCells(cellsByDistance[0] ?? [], -6, 1.16, 0.26, 'back.out(1.45)')
animateWaveCells(cellsByDistance[1] ?? [], -3.25, 1.08, 0.34, 'power3.out')
animateWaveCells(cellsByDistance[2] ?? [], -1.25, 1.03, 0.42, 'power3.out')
settleWaveCells(cellsToRelease)
wavedCellIndexes = nextCellIndexes
}
function animateWaveCells(
cells: HTMLElement[],
y: number,
scaleY: number,
duration: number,
ease: string,
) {
if (cells.length === 0) return
gsap.set(cells, { willChange: 'transform' })
gsap.to(cells, {
y,
scaleY,
duration,
ease,
overwrite: 'auto',
})
}
function settleWaveCells(cells: HTMLElement[]) {
if (cells.length === 0) return
gsap.to(cells, {
y: 0,
scaleY: 1,
duration: 0.5,
ease: 'power3.out',
overwrite: 'auto',
clearProps: 'transform,willChange',
})
}
function releasePointWave() {
const buttons = timelineButtons()
const cells = [...wavedCellIndexes]
.map((index) => timelineCell(buttons[index]))
.filter((cell): cell is HTMLElement => Boolean(cell))
if (preferredMotion.value === 'reduce') {
gsap.set(cells, { clearProps: 'transform,willChange' })
} else {
settleWaveCells(cells)
}
wavedCellIndexes = new Set<number>()
}
function pointAccessibilityLabel(point: HealthTimelinePoint) {
const eligibleRequests = point.successRequests + point.failedRequests
return `${point.time},${healthStatusMeta[point.status].label},有效请求 ${formatHealthCount(eligibleRequests)},可用性 ${point.reliabilityDisplay}`
}
watch(popoverOpen, (open) => {
if (open) return
resetPointInteraction()
})
function cellClass(point: string) {
if (point === '0') return 'bg-(--cp-disabled-bg) opacity-60'
if (point === '1') return 'bg-(--cp-default-border-hover)'
if (point === '2') return 'bg-(--cp-danger)'
if (point === '3') return 'bg-(--cp-warning)'
if (point === '4') return 'bg-(--cp-normal)'
if (point === '5') return 'bg-(--cp-success)'
return 'bg-(--cp-default-border-hover)'
}
onBeforeUnmount(() => {
const cells = timelineButtons()
.map((button) => timelineCell(button))
.filter((cell): cell is HTMLElement => Boolean(cell))
gsap.killTweensOf(cells)
})
</script>
<template>
@@ -52,41 +214,91 @@ function cellClass(point: string) {
>
<span
v-for="item in healthLegend"
:key="item.code"
:key="item.status"
class="inline-flex h-3.5 items-center gap-1 align-middle leading-none"
>
<span
aria-hidden="true"
class="block size-2 shrink-0 rounded-xs"
:class="cellClass(item.code)"
:class="healthStatusMeta[item.status].cellClass"
/>
<span class="block leading-none">{{ item.label }}</span>
</span>
</div>
<strong
class="shrink-0 font-mono text-sm leading-none font-[760] tabular-nums text-(--cp-success-text)"
>
{{ timeline.reliabilityDisplay }}
</strong>
<div class="flex shrink-0 items-center gap-3">
<strong
class="font-mono text-sm leading-none font-[760] tabular-nums"
:class="healthReliabilityValueClass(timeline.successRequests, timeline.failedRequests)"
>
{{ timeline.reliabilityDisplay }}
</strong>
</div>
</div>
</template>
<template #body>
<div class="mt-4.25">
<div class="grid w-full grid-cols-24 items-end gap-0.75">
<div
v-for="(group, groupIndex) in pointGroups"
:key="groupIndex"
class="grid grid-cols-4 items-end gap-0.5"
>
<i
v-for="(point, pointIndex) in group"
:key="groupIndex * 4 + pointIndex"
class="h-3.5 min-w-0.5 rounded-xs"
:class="cellClass(point)"
/>
</div>
</div>
<BasePopover
v-model="popoverOpen"
trigger="hover"
placement="top"
:offset="12"
width="288px"
:anchor-element="activeAnchor"
:disabled="!activePoint"
animate-position
panel-class="!p-0 text-(--cp-text-primary)"
trigger-class="w-full"
class="block! min-w-0 w-full"
>
<template #trigger>
<div
ref="timelineGrid"
class="grid w-full grid-cols-48 items-end gap-x-0.5 gap-y-1 sm:grid-cols-96"
>
<button
v-for="(point, pointIndex) in points"
:key="point.time"
data-health-timeline-point
type="button"
:aria-disabled="!isInteractivePoint(point)"
:tabindex="isInteractivePoint(point) ? 0 : -1"
:aria-expanded="isActivePoint(point)"
aria-haspopup="dialog"
:aria-label="pointAccessibilityLabel(point)"
class="group relative flex h-5 w-full min-w-0.5 items-center border-0 bg-transparent p-0 outline-none"
:class="isInteractivePoint(point) ? 'cursor-pointer' : 'cursor-default'"
@mouseenter="activatePoint(point, pointIndex, $event)"
@focus="activatePoint(point, pointIndex, $event)"
@blur="closePointPopover"
@click="activatePoint(point, pointIndex, $event)"
>
<span
aria-hidden="true"
data-health-timeline-cell
class="block h-3.5 w-full origin-bottom rounded-xs transition-[filter,box-shadow] duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none group-focus-visible:shadow-[0_0_0_2px_var(--cp-bg-surface),0_0_0_4px_var(--cp-info-border)]"
:class="[
healthStatusMeta[point.status].cellClass,
isActivePoint(point)
? 'brightness-105 shadow-[0_5px_10px_-5px_var(--cp-shadow-sticky)]'
: isInteractivePoint(point)
? 'group-hover:brightness-95'
: undefined,
]"
/>
<span
aria-hidden="true"
class="pointer-events-none absolute bottom-0 left-1/2 h-0.75 w-1.5 -translate-x-1/2 rounded-full bg-(--cp-text-primary) transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:class="
isActivePoint(point) ? 'translate-y-0 opacity-60' : 'translate-y-1 opacity-0'
"
/>
</button>
</div>
</template>
<HealthTimelinePointPopover v-if="activePoint" :point="activePoint" />
</BasePopover>
</div>
</template>
</BaseCard>
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePreferredReducedMotion } from '@vueuse/core'
import type { EChartsOption } from 'echarts'
import { storeToRefs } from 'pinia'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseChart from '../../../components/charts/BaseChart.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
import BaseSegmented from '../../../components/base/BaseSegmented.vue'
import { useUiStore } from '../../../stores/modules/ui'
import { computed, shallowRef, watch } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useUiStore } from '@/stores/modules/ui'
type TrendKind = 'usage' | 'latency' | 'errors'
@@ -27,6 +28,10 @@ const tabs = [
]
const activeKind = defineModel<TrendKind>('kind', { required: true })
const { themeRevision } = storeToRefs(useUiStore())
const preferredMotion = usePreferredReducedMotion()
const hoveredSummaryLabel = shallowRef<string>()
const pinnedSummaryLabel = shallowRef<string>()
const activeSummaryLabel = computed(() => hoveredSummaryLabel.value ?? pinnedSummaryLabel.value)
const hasSamples = computed(() =>
props.points.some(
@@ -54,14 +59,13 @@ const chartOption = computed<EChartsOption>(() => {
return {
...coordinate,
series,
axisPointer:
activeKind.value === 'usage'
? {
link: [{ xAxisIndex: [0, 1, 2] }],
}
: undefined,
animationDuration: preferredMotion.value === 'reduce' ? 0 : 420,
animationDurationUpdate: preferredMotion.value === 'reduce' ? 0 : 220,
animationEasing: 'cubicOut',
animationEasingUpdate: 'cubicOut',
tooltip: {
trigger: 'axis',
confine: true,
backgroundColor: themeColor('--cp-bg-surface', '#fff'),
borderColor: 'transparent',
borderWidth: 0,
@@ -84,7 +88,8 @@ const chartOption = computed<EChartsOption>(() => {
function getCoordinateSystem(times: string[]) {
const muted = themeColor('--cp-text-muted', '#94A3B8')
const gridLine = themeColor('--cp-bg-muted', '#F1F5F9')
const gridLine = themeColor('--cp-divider-subtle', '#E2E8F0')
const axisLine = themeColor('--cp-default-border', '#E2E8F0')
const timeLabels = {
color: muted,
fontSize: 10,
@@ -93,80 +98,85 @@ function getCoordinateSystem(times: string[]) {
interval: showTwoHourLabel,
}
if (activeKind.value === 'usage') {
return getUsageCoordinateSystem(times, timeLabels, gridLine)
}
const xAxis = {
type: 'category' as const,
data: times,
axisLine: { show: false },
axisTick: { show: false },
}
const yAxis = {
type: 'value' as const,
min: 0,
splitNumber: 2,
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: gridLine } },
}
return {
grid: { left: 4, right: 0, top: 8, bottom: 24 },
grid: {
left: 8,
right: activeKind.value === 'latency' ? 8 : 10,
top: 10,
bottom: 2,
containLabel: true,
},
xAxis: {
...xAxis,
boundaryGap: activeKind.value !== 'latency',
type: 'category' as const,
data: times,
boundaryGap: activeKind.value === 'errors',
axisLine: { show: true, lineStyle: { color: axisLine } },
axisTick: { show: false },
axisLabel: timeLabels,
},
yAxis: [
yAxis,
{
type: 'value' as const,
min: 0,
max: 100,
splitNumber: 3,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: muted,
fontSize: 9,
fontFamily: 'JetBrains Mono Variable, JetBrains Mono',
formatter: formatPrimaryAxisValue,
},
splitLine: {
show: true,
lineStyle: { color: gridLine, type: 'dashed' as const, opacity: 0.72 },
},
},
{
type: 'value' as const,
min: 0,
max: activeKind.value === 'errors' ? 100 : undefined,
splitNumber: 3,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
show: activeKind.value !== 'latency',
color:
activeKind.value === 'usage'
? trendColor('输出', '--cp-success', '#10B981')
: trendColor('成功', '--cp-success', '#10B981'),
fontSize: 9,
fontFamily: 'JetBrains Mono Variable, JetBrains Mono',
formatter: formatSecondaryAxisValue,
},
splitLine: { show: false },
axisLabel: { show: false },
},
],
}
}
function getUsageCoordinateSystem(times: string[], timeLabels: any, gridLine: string) {
const xAxis = (gridIndex: number, showLabels = false) => ({
type: 'category' as const,
data: times,
gridIndex,
boundaryGap: false,
axisLine: {
show: true,
lineStyle: { color: gridLine },
},
axisTick: { show: false },
axisLabel: showLabels ? timeLabels : { show: false },
axisPointer: { show: true },
})
const yAxis = (gridIndex: number) => ({
type: 'value' as const,
gridIndex,
min: 0,
max: (range: any) => (range.max > 0 ? range.max * 1.12 : 1),
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
})
return {
grid: [
{ left: 4, right: 0, top: 0, height: 76 },
{ left: 4, right: 0, top: 91, height: 66 },
{ left: 4, right: 0, top: 172, bottom: 18 },
],
xAxis: [xAxis(0), xAxis(1), xAxis(2, true)],
yAxis: [yAxis(0), yAxis(1), yAxis(2)],
function formatPrimaryAxisValue(value: number) {
if (activeKind.value === 'latency') {
return value >= 1000 ? `${formatAxisNumber(value / 1000)}s` : `${Math.round(value)}ms`
}
return formatAxisCompact(value)
}
function formatSecondaryAxisValue(value: number) {
if (activeKind.value === 'errors') return `${Math.round(value)}%`
return formatAxisCompact(value)
}
function formatAxisCompact(value: number) {
const normalized = Math.abs(value)
if (normalized >= 1_000_000_000) return `${formatAxisNumber(value / 1_000_000_000)}B`
if (normalized >= 1_000_000) return `${formatAxisNumber(value / 1_000_000)}M`
if (normalized >= 1_000) return `${formatAxisNumber(value / 1_000)}K`
return `${Math.round(value)}`
}
function formatAxisNumber(value: number) {
return value >= 10 ? value.toFixed(0) : value.toFixed(1).replace(/\.0$/, '')
}
function showTwoHourLabel(_index: number, value: string) {
@@ -248,77 +258,67 @@ function tooltipIndex(source: unknown) {
function getSeries() {
if (activeKind.value === 'usage') {
const cacheColor = trendColor('缓存', '--cp-text-tertiary', '#94A3B8')
const inputColor = trendColor('输入', '--cp-info', '#2563EB')
const outputColor = trendColor('输出', '--cp-success', '#10B981')
return [
lineSeries(
'输入',
activeSeriesValues('inputTokensValue'),
trendColor('输入', '--cp-info', '#2563EB'),
{
area: true,
smooth: 0.22,
width: 2,
xAxisIndex: 0,
yAxisIndex: 0,
},
),
lineSeries(
'输出',
activeSeriesValues('outputTokensValue'),
trendColor('输出', '--cp-success', '#10B981'),
{
area: true,
smooth: 0.22,
width: 1.9,
xAxisIndex: 1,
yAxisIndex: 1,
},
),
lineSeries(
'缓存',
activeSeriesValues('cachedTokensValue'),
trendColor('缓存', '--cp-text-tertiary', '#94A3B8'),
{
area: true,
smooth: 0.22,
width: 1.8,
xAxisIndex: 2,
yAxisIndex: 2,
},
),
lineSeries('缓存', activeSeriesValues('cachedTokensValue'), cacheColor, {
area: true,
areaStartAlpha: '30',
areaEndAlpha: '08',
lineType: 'dashed',
smooth: 0.26,
stack: 'input-composition',
width: 1.25,
z: 1,
}),
lineSeries('输入', activeSeriesValues('uncachedInputTokensValue'), inputColor, {
area: true,
areaStartAlpha: '2A',
areaEndAlpha: '05',
smooth: 0.26,
stack: 'input-composition',
width: 2.3,
z: 3,
}),
lineSeries('输出', activeSeriesValues('outputTokensValue'), outputColor, {
smooth: 0.24,
width: 2.1,
yAxisIndex: 1,
z: 4,
}),
]
}
if (isSparseTrend.value) return getSparseSeries()
if (activeKind.value === 'latency') {
const averageColor = trendColor('平均', '--cp-normal', '#0F9F9A')
const maximumColor = trendColor('最高', '--cp-warning', '#F59E0B')
const minimumColor = trendColor('最低', '--cp-success', '#10B981')
return [
lineSeries(
'平均',
seriesValues('latencyValue'),
trendColor('平均', '--cp-normal', '#0F9F9A'),
{
area: true,
width: 2.6,
},
),
lineSeries(
'最高',
seriesValues('maxLatencyValue'),
trendColor('最高', '--cp-warning', '#F59E0B'),
{
lineType: 'dashed',
width: 1.8,
},
),
lineSeries(
'最低',
seriesValues('minLatencyValue'),
trendColor('最低', '--cp-success', '#10B981'),
{
lineType: 'dotted',
width: 1.8,
},
),
lineSeries('最低', activeSeriesValues('minLatencyValue'), minimumColor, {
lineType: 'dotted',
smooth: 0.2,
stack: 'latency-range',
width: 1.2,
z: 1,
}),
lineSeries('最高', latencyRangeValues(), maximumColor, {
area: true,
areaStartAlpha: '26',
areaEndAlpha: '10',
lineType: 'dashed',
smooth: 0.2,
stack: 'latency-range',
width: 1.2,
z: 1,
}),
lineSeries('平均', activeSeriesValues('latencyValue'), averageColor, {
smooth: 0.24,
width: 2.5,
z: 4,
}),
]
}
return [
@@ -344,61 +344,6 @@ function getSeries() {
]
}
function getSparseSeries() {
if (activeKind.value === 'latency') {
return [
barSeries(
'平均',
activeSeriesValues('latencyValue'),
trendColor('平均', '--cp-normal', '#0F9F9A'),
{
maxWidth: 9,
opacity: 0.78,
},
),
barSeries(
'最高',
activeSeriesValues('maxLatencyValue'),
trendColor('最高', '--cp-warning', '#F59E0B'),
{ maxWidth: 9, opacity: 0.58 },
),
barSeries(
'最低',
activeSeriesValues('minLatencyValue'),
trendColor('最低', '--cp-success', '#10B981'),
{ maxWidth: 9, opacity: 0.58 },
),
]
}
return [
barSeries(
'错误数',
activeSeriesValues('errorsValue'),
trendColor('错误', '--cp-danger', '#EF4444'),
{
maxWidth: 10,
opacity: 0.72,
},
),
pulseSeries(
'成功率',
activeSeriesValues('successRateValue'),
trendColor('成功', '--cp-success', '#10B981'),
{ yAxisIndex: 1 },
),
barSeries(
'总请求',
activeSeriesValues('requestsValue'),
trendColor('请求', '--cp-info', '#2563EB'),
{
maxWidth: 10,
opacity: 0.5,
},
),
]
}
function seriesValues(key: string) {
return props.points.map((point) => point[key] ?? null)
}
@@ -409,8 +354,21 @@ function activeSeriesValues(key: string) {
)
}
function latencyRangeValues() {
return props.points.map((point) => {
if (Number(point.requestsValue) <= 0) return null
if (point.minLatencyValue === null || point.maxLatencyValue === null) return null
if (point.minLatencyValue === undefined || point.maxLatencyValue === undefined) return null
const minimum = Number(point.minLatencyValue)
const maximum = Number(point.maxLatencyValue)
if (!Number.isFinite(minimum) || !Number.isFinite(maximum)) return null
return Math.max(0, maximum - minimum)
})
}
function lineSeries(name: string, data: (number | null)[], color: string, options: any = {}) {
const area = options.area ?? false
const muted = isSeriesMuted(name)
return {
name,
@@ -418,18 +376,29 @@ function lineSeries(name: string, data: (number | null)[], color: string, option
data,
connectNulls: false,
smooth: options.smooth ?? true,
symbol: options.symbol ?? 'none',
symbol: options.symbol ?? (isSparseTrend.value ? 'circle' : 'none'),
symbolSize: options.symbolSize ?? 5,
showSymbol: options.showSymbol ?? isSparseTrend.value,
stack: options.stack,
xAxisIndex: options.xAxisIndex ?? 0,
yAxisIndex: options.yAxisIndex ?? 0,
z: options.z ?? 2,
lineStyle: {
color,
type: options.lineType ?? 'solid',
width: options.width ?? 2.2,
opacity: muted ? 0.18 : 1,
},
itemStyle: { color, opacity: muted ? 0.18 : 1 },
emphasis: { focus: 'series' as const },
blur: {
lineStyle: { opacity: 0.2 },
itemStyle: { opacity: 0.2 },
areaStyle: { opacity: 0.05 },
},
itemStyle: { color },
areaStyle: area
? {
opacity: muted ? 0.12 : 1,
color: {
type: 'linear' as const,
x: 0,
@@ -437,8 +406,8 @@ function lineSeries(name: string, data: (number | null)[], color: string, option
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${color}18` },
{ offset: 1, color: `${color}02` },
{ offset: 0, color: `${color}${options.areaStartAlpha ?? '18'}` },
{ offset: 1, color: `${color}${options.areaEndAlpha ?? '02'}` },
],
},
}
@@ -446,22 +415,9 @@ function lineSeries(name: string, data: (number | null)[], color: string, option
}
}
function pulseSeries(name: string, data: (number | null)[], color: string, options: any = {}) {
return {
name,
type: 'line' as const,
data,
connectNulls: false,
smooth: false,
symbol: 'roundRect',
symbolSize: [5, 16],
yAxisIndex: options.yAxisIndex ?? 0,
lineStyle: { opacity: 0 },
itemStyle: { color },
}
}
function barSeries(name: string, data: (number | null)[], color: string, options: any = {}) {
const muted = isSeriesMuted(name)
return {
name,
type: 'bar' as const,
@@ -473,9 +429,11 @@ function barSeries(name: string, data: (number | null)[], color: string, options
z: options.z ?? 2,
itemStyle: {
color,
opacity: options.opacity ?? 0.72,
opacity: muted ? 0.14 : (options.opacity ?? 0.72),
borderRadius: options.borderRadius ?? [3, 3, 0, 0],
},
emphasis: { focus: 'series' as const },
blur: { itemStyle: { opacity: 0.18 } },
}
}
@@ -491,6 +449,31 @@ function trendColor(label: string, fallbackVar: string, fallback: string) {
function handleTrendChange(value: string) {
emit('trendChange', value as TrendKind)
}
function focusSummarySeries(label: string) {
hoveredSummaryLabel.value = label
}
function blurSummarySeries() {
hoveredSummaryLabel.value = undefined
}
function toggleSummarySeries(label: string) {
pinnedSummaryLabel.value = pinnedSummaryLabel.value === label ? undefined : label
}
function isSeriesMuted(name: string) {
return Boolean(activeSummaryLabel.value && activeSummaryLabel.value !== name)
}
function isSummarySeriesActive(label: string) {
return activeSummaryLabel.value === label
}
watch(activeKind, () => {
hoveredSummaryLabel.value = undefined
pinnedSummaryLabel.value = undefined
})
</script>
<template>
@@ -505,11 +488,54 @@ function handleTrendChange(value: string) {
</template>
<template #body>
<div
class="mt-4.75 grid grid-cols-1 gap-5 lg:h-70 lg:grid-cols-[minmax(0,1fr)_minmax(150px,180px)] lg:gap-7.5"
>
<div class="relative h-70 w-full overflow-hidden lg:h-full">
<BaseChart v-if="hasSamples" :option="chartOption" :height="280" />
<div class="mt-4.5 grid gap-3.5">
<div
class="grid h-14.25 min-w-0 grid-cols-3 gap-1.5 rounded-xl bg-(--cp-bg-subtle)/45 p-1.5"
>
<button
v-for="item in props.summary"
:key="item.label"
type="button"
:aria-label="`突出显示${item.label}曲线`"
:aria-pressed="pinnedSummaryLabel === item.label"
class="group grid min-w-0 grid-cols-[8px_minmax(0,1fr)] items-center gap-x-2 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
@mouseenter="focusSummarySeries(item.label)"
@mouseleave="blurSummarySeries"
@focus="focusSummarySeries(item.label)"
@blur="blurSummarySeries"
@click="toggleSummarySeries(item.label)"
>
<i
aria-hidden="true"
class="size-2 rounded-full transition-transform duration-200 ease-out group-hover:scale-125 motion-reduce:transition-none"
:style="summaryMarkerStyle(item)"
:class="[
isSummarySeriesActive(item.label) ? 'scale-125' : undefined,
{
'bg-(--cp-info)': item.tone === 'info',
'bg-(--cp-success)': item.tone === 'success',
'bg-(--cp-warning)': item.tone === 'warning',
'bg-(--cp-danger)': item.tone === 'danger',
'bg-(--cp-normal)': item.tone === 'normal',
},
]"
/>
<span class="grid min-w-0 gap-1">
<span class="truncate text-[10px] leading-none font-[680] text-(--cp-text-secondary)">
{{ item.label }}
</span>
<strong
class="truncate font-mono text-[15px] leading-none font-[760] tabular-nums text-(--cp-text-primary)"
:title="item.value"
>
{{ item.value }}
</strong>
</span>
</button>
</div>
<div class="relative h-55 w-full overflow-hidden">
<BaseChart v-if="hasSamples" :option="chartOption" :height="220" />
<BaseEmpty
v-if="!hasSamples"
compact
@@ -518,37 +544,6 @@ function handleTrendChange(value: string) {
class="h-full place-content-center bg-transparent"
/>
</div>
<aside
class="grid h-70 w-full grid-rows-3 rounded-2xl bg-(--cp-bg-subtle) px-5 py-4.5 lg:h-full"
>
<div
v-for="item in props.summary"
:key="item.label"
class="grid grid-cols-[minmax(0,1fr)_8px] items-center gap-x-3 py-2"
>
<span class="grid gap-1.75">
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">{{
item.label
}}</span>
<strong
class="font-mono text-2xl leading-[1.15] font-[760] tabular-nums text-(--cp-text-primary)"
>{{ item.value }}</strong
>
</span>
<i
class="size-2 justify-self-end rounded-full"
:style="summaryMarkerStyle(item)"
:class="{
'bg-(--cp-info)': item.tone === 'info',
'bg-(--cp-success)': item.tone === 'success',
'bg-(--cp-warning)': item.tone === 'warning',
'bg-(--cp-danger)': item.tone === 'danger',
'bg-(--cp-normal)': item.tone === 'normal',
}"
/>
</div>
</aside>
</div>
</template>
</BaseCard>
@@ -1,82 +0,0 @@
<script setup lang="ts">
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
defineProps<{
items: any[]
}>()
const iconToneClasses: Record<string, string> = {
normal: 'bg-(--cp-normal-bg) text-(--cp-normal)',
info: 'bg-(--cp-info-bg) text-(--cp-info)',
success: 'bg-(--cp-success-bg) text-(--cp-success)',
warning: 'bg-(--cp-warning-bg) text-(--cp-warning)',
danger: 'bg-(--cp-danger-bg) text-(--cp-danger)',
}
const valueToneClasses: Record<string, string> = {
normal: 'text-(--cp-normal-text)',
info: 'text-(--cp-info-text)',
success: 'text-(--cp-success-text)',
warning: 'text-(--cp-warning-text)',
danger: 'text-(--cp-danger-text)',
}
</script>
<template>
<BaseCard as="article" variant="dashboard" title="指纹信息" class="h-95 w-full">
<template #body>
<div class="mt-5.75 grid gap-2">
<BaseEmpty
v-if="items.length === 0"
compact
title="暂无指纹数据"
class="h-72 place-content-center"
/>
<template v-else>
<div
v-for="item in items"
:key="item.label"
class="grid h-12.5 w-full grid-cols-[22px_12px_minmax(128px,0.82fr)_minmax(0,1.08fr)_minmax(72px,0.92fr)] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
>
<span
class="inline-flex size-6 items-center justify-center rounded-lg"
:class="iconToneClasses[item.tone]"
>
<component :is="item.icon" :size="14" />
</span>
<template v-if="item.label === 'User Agent' || item.label === '更新时间'">
<strong
class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)"
>{{ item.label }}</strong
>
<span
class="col-start-4 col-span-2 text-[13px] leading-[1.15] font-mono font-semibold text-(--cp-text-secondary) truncate"
>{{ item.value }}</span
>
</template>
<template v-else>
<strong
class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)"
>{{ item.label }}</strong
>
<div
class="col-start-4 col-span-2 grid min-w-0 grid-cols-1 justify-items-end gap-1.5 sm:grid-cols-[minmax(0,1.08fr)_minmax(72px,0.92fr)] sm:items-center sm:justify-items-stretch sm:gap-0"
>
<span
class="max-w-full min-w-0 wrap-break-word text-right text-[13px] leading-[1.15] font-bold sm:text-left"
:class="valueToneClasses[item.tone]"
>{{ item.value }}</span
>
<span
class="text-right font-mono text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
>{{ item.detail }}</span
>
</div>
</template>
</div>
</template>
</div>
</template>
</BaseCard>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { Box, CheckCircle2, Monitor, RefreshCw, Terminal, TriangleAlert } from '@lucide/vue'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
profile: any
}>()
const releaseLabel = computed(() => {
const release = props.profile?.release
if (!release?.latestVersion) return '尚未检查'
return release.latestBuild
? `${release.latestVersion} · Build ${release.latestBuild}`
: release.latestVersion
})
const releaseStatus = computed(() => {
const status = props.profile?.release?.status ?? 'unchecked'
if (status === 'aligned') {
return {
label: '制品一致',
title: '当前 Desktop 版本与官方发布一致',
tone: 'bg-(--cp-success-bg) text-(--cp-success-text)',
icon: CheckCircle2,
}
}
if (status === 'review_required') {
return {
label: '发现新版',
title: `官方最新版本 ${releaseLabel.value}`,
tone: 'bg-(--cp-warning-bg) text-(--cp-warning-text)',
icon: TriangleAlert,
}
}
if (status === 'check_failed') {
return {
label: '检查失败',
title: props.profile?.release?.error || 'Desktop 发布检查失败',
tone: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
icon: TriangleAlert,
}
}
return {
label: '待检查',
title: '尚未检查 Desktop 官方发布',
tone: 'bg-(--cp-normal-bg) text-(--cp-normal-text)',
icon: RefreshCw,
}
})
const verifiedAt = computed(() =>
props.profile?.verifiedAt ? formatDateTime(props.profile.verifiedAt) : '—',
)
const checkedAt = computed(() =>
props.profile?.release?.checkedAt ? formatDateTime(props.profile.release.checkedAt) : '—',
)
</script>
<template>
<BaseCard
as="article"
variant="dashboard"
title="上游请求身份"
body-class="flex min-h-0 flex-1 flex-col"
class="flex min-h-95 w-full flex-col"
>
<template #actions>
<span
class="inline-flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-[12px] leading-none font-[720]"
:class="releaseStatus.tone"
:title="releaseStatus.title"
>
<component :is="releaseStatus.icon" aria-hidden="true" class="size-3.5" />
{{ releaseStatus.label }}
</span>
</template>
<template #body>
<BaseEmpty
v-if="!profile"
compact
title="暂无请求身份"
class="mt-5 min-h-[287px] flex-1 place-content-center"
/>
<div v-else class="mt-5 grid flex-1 gap-3 sm:grid-rows-[minmax(0,1fr)_24px]">
<section
aria-label="请求身份组成"
class="grid min-w-0 content-between gap-6 rounded-[14px] bg-(--cp-bg-subtle) px-5 py-5.5 sm:h-full sm:grid-rows-[auto_minmax(0,1fr)_auto] sm:px-6 sm:py-5"
>
<div class="flex min-w-0 items-center">
<div class="flex min-w-0 items-center gap-2 text-(--cp-text-primary)">
<span
class="inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-(--cp-bg-muted)"
>
<Box aria-hidden="true" class="size-3.75 text-(--cp-text-secondary)" />
</span>
<span class="truncate text-[11px] leading-none font-[760]">Codex Desktop</span>
</div>
</div>
<div class="grid min-h-0 content-center">
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1.5">
<strong
class="block max-w-full wrap-break-word font-mono text-[27px] leading-[1.05] font-[790] tabular-nums text-(--cp-text-primary)"
:title="profile.desktopVersion"
>
{{ profile.desktopVersion }}
</strong>
<span
class="shrink-0 font-mono text-[10px] leading-none font-[650] tabular-nums text-(--cp-text-muted)"
>
Build {{ profile.desktopBuild }}
</span>
</div>
</div>
<dl class="m-0 grid min-w-0 gap-5 sm:grid-cols-[0.72fr_1.28fr] sm:gap-7">
<div class="min-w-0">
<dt
class="flex items-center gap-1.5 text-[10px] leading-none font-[720] text-(--cp-text-muted)"
>
<Terminal aria-hidden="true" class="size-3.25 text-(--cp-info)" />
Codex Core
</dt>
<dd
class="mt-2 mb-0 truncate font-mono text-[16px] leading-none font-[760] tabular-nums text-(--cp-text-primary)"
:title="profile.codexVersion"
>
{{ profile.codexVersion }}
</dd>
</div>
<div class="min-w-0">
<dt
class="flex items-center gap-1.5 text-[10px] leading-none font-[720] text-(--cp-text-muted)"
>
<Monitor aria-hidden="true" class="size-3.25 text-(--cp-normal)" />
模拟运行环境
</dt>
<dd
class="mt-2 mb-0 truncate font-mono text-[14px] leading-none font-[720] tabular-nums text-(--cp-text-primary)"
:title="`${profile.target.osType} ${profile.target.osVersion} · ${profile.target.arch}`"
>
{{ profile.target.osType }} {{ profile.target.osVersion }}
<span class="text-[11px] font-[650] text-(--cp-text-secondary)">
· {{ profile.target.arch }}
</span>
</dd>
</div>
</dl>
</section>
<footer
class="flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-1 text-[10px] leading-none font-[650] text-(--cp-text-muted)"
>
<time :datetime="profile.verifiedAt">源码审计 {{ verifiedAt }}</time>
<time :datetime="profile.release?.checkedAt">发布检查 {{ checkedAt }}</time>
</footer>
</div>
</template>
</BaseCard>
</template>
@@ -2,16 +2,7 @@ import { useIntervalFn } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { onMounted, ref } from 'vue'
import {
Activity,
CloudCheck,
FileText,
Gauge,
MonitorCheck,
RefreshCw,
Timer,
Users,
} from '@lucide/vue'
import { Activity, FileText, Timer, Users } from '@lucide/vue'
import { getDashboardSummary, getDashboardTrend } from '@/api'
import { withMinimumDuration } from '@/utils/async'
@@ -19,6 +10,8 @@ import { formatDateTime } from '@/utils/date'
type DashboardTrendKind = 'usage' | 'latency' | 'errors'
const METRIC_SPARKLINE_BUCKETS = 12
export function useDashboard(): any {
const activeTrendKind = ref<DashboardTrendKind>('usage')
const metrics = ref<any[]>(metricCards(emptyDashboardSummary()))
@@ -26,7 +19,7 @@ export function useDashboard(): any {
const trendSummary = ref<any[]>([])
const healthTimeline = ref<any>(emptyDashboardSummary().healthTimeline)
const accountUsage = ref<any[]>([])
const serviceStatuses = ref<any[]>([])
const wireProfile = ref<any>(null)
const usageRecords = ref<any[]>([])
const poolSummary = ref<any>(null)
const capacityInfo = ref<any>(null)
@@ -109,7 +102,7 @@ export function useDashboard(): any {
metrics.value = metricCards(summary)
healthTimeline.value = summary.healthTimeline
accountUsage.value = summary.accountUsage.map(accountUsageItem)
serviceStatuses.value = summary.serviceStatuses.map(serviceStatusItem)
wireProfile.value = summary.wireProfile ?? null
usageRecords.value = summary.usageRecords
poolSummary.value = summary.poolSummary
capacityInfo.value = summary.capacityInfo
@@ -157,12 +150,17 @@ export function useDashboard(): any {
},
healthTimeline: {
title: '请求健康时间线',
description: '请求可靠性',
description: '有效请求可用性',
reliabilityDisplay: '-',
points: '',
status: 'no_data',
successRequests: 0,
failedRequests: 0,
cancelledRequests: 0,
callerErrorRequests: 0,
points: [],
},
accountUsage: [],
serviceStatuses: [],
wireProfile: null,
usageRecords: [],
poolSummary: {
total: 0,
@@ -288,7 +286,10 @@ export function useDashboard(): any {
}
if (lastActiveIndex < 0) return []
return points.slice(Math.max(0, lastActiveIndex - 8), lastActiveIndex + 1)
return points.slice(
Math.max(0, lastActiveIndex - (METRIC_SPARKLINE_BUCKETS - 1)),
lastActiveIndex + 1,
)
}
function formatDashboardCompactNumber(value: number) {
@@ -453,17 +454,6 @@ export function useDashboard(): any {
return 'normal'
}
function serviceStatusItem(item: any, index: number) {
const icons = [MonitorCheck, MonitorCheck, CloudCheck, RefreshCw, Gauge]
return {
label: item.label,
value: item.value || '-',
detail: item.detail || '-',
tone: item.tone,
icon: icons[index] ?? MonitorCheck,
}
}
function trendState(current: number, previous: number, fallbackTone: string) {
if (current > previous) return { direction: 'up', tone: 'success' }
if (current < previous) return { direction: 'down', tone: 'danger' }
@@ -524,7 +514,7 @@ export function useDashboard(): any {
trendSummary,
healthTimeline,
accountUsage,
serviceStatuses,
wireProfile,
usageRecords,
poolSummary,
capacityInfo,
+86
View File
@@ -0,0 +1,86 @@
export type HealthStatus =
'future' | 'no_data' | 'unavailable' | 'unstable' | 'low_sample' | 'stable'
export interface HealthTimelinePoint {
time: string
status: HealthStatus
reliabilityDisplay: string
successRequests: number
failedRequests: number
cancelledRequests: number
callerErrorRequests: number
}
export interface HealthTimeline {
title: string
description: string
reliabilityDisplay: string
status: HealthStatus
successRequests: number
failedRequests: number
cancelledRequests: number
callerErrorRequests: number
points: HealthTimelinePoint[]
}
interface HealthStatusMeta {
label: string
cellClass: string
badgeClass: string
}
export const healthLegend = [
{ status: 'no_data', label: '无有效样本' },
{ status: 'unavailable', label: '不可达' },
{ status: 'unstable', label: '不稳定' },
{ status: 'low_sample', label: '低样本' },
{ status: 'stable', label: '稳定' },
] satisfies { status: HealthStatus; label: string }[]
export const healthStatusMeta: Record<HealthStatus, HealthStatusMeta> = {
future: {
label: '未来',
cellClass: 'bg-(--cp-disabled-bg) opacity-60',
badgeClass: 'bg-(--cp-bg-muted) text-(--cp-text-muted)',
},
no_data: {
label: '无有效样本',
cellClass: 'bg-(--cp-default-border-hover)',
badgeClass: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
},
unavailable: {
label: '不可达',
cellClass: 'bg-(--cp-danger)',
badgeClass: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
},
unstable: {
label: '不稳定',
cellClass: 'bg-(--cp-warning)',
badgeClass: 'bg-(--cp-warning-bg) text-(--cp-warning-text)',
},
low_sample: {
label: '低样本',
cellClass: 'bg-(--cp-normal)',
badgeClass: 'bg-(--cp-normal-bg) text-(--cp-normal-text)',
},
stable: {
label: '稳定',
cellClass: 'bg-(--cp-success)',
badgeClass: 'bg-(--cp-success-bg) text-(--cp-success-text)',
},
}
export function healthReliabilityValueClass(successRequests: number, failedRequests: number) {
const eligibleRequests = Math.max(0, successRequests) + Math.max(0, failedRequests)
if (eligibleRequests === 0) return 'text-(--cp-text-muted)'
const reliability = (Math.max(0, successRequests) / eligibleRequests) * 100
if (reliability >= 99.5) return 'text-(--cp-success-text)'
if (reliability >= 98) return 'text-(--cp-normal-text)'
if (reliability >= 95) return 'text-(--cp-warning-text)'
return 'text-(--cp-danger-text)'
}
export function formatHealthCount(value: number) {
return Math.max(0, value).toLocaleString('zh-CN')
}
+10 -5
View File
@@ -4,11 +4,12 @@ import { RefreshCw } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import AccountOverviewCard from './components/AccountOverviewCard.vue'
import DashboardHeartbeat from './components/DashboardHeartbeat.vue'
import UsageRecordCard from './components/UsageRecordCard.vue'
import MetricCard from './components/MetricCard.vue'
import RequestHealthTimelineCard from './components/RequestHealthTimelineCard.vue'
import RequestTrendCard from './components/RequestTrendCard.vue'
import ServiceStatusCard from './components/ServiceStatusCard.vue'
import WireProfileCard from './components/WireProfileCard.vue'
import { useDashboard } from './composables/useDashboard'
const {
@@ -21,11 +22,12 @@ const {
trendSummary,
healthTimeline,
accountUsage,
serviceStatuses,
wireProfile,
usageRecords,
poolSummary,
capacityInfo,
rotationStrategy,
lastRefreshedAt,
refresh,
loadTrend,
} = useDashboard()
@@ -38,8 +40,11 @@ const {
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
系统概览
</h1>
<p class="mt-2.5 text-[15px] leading-[1.15] font-semibold mb-0 text-(--cp-text-secondary)">
当日统计 · 自动刷新 30s
<p
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-(--cp-text-secondary)"
>
<span>当日统计</span>
<DashboardHeartbeat :updated-at="lastRefreshedAt" />
</p>
</div>
@@ -72,7 +77,7 @@ const {
:loading="trendLoading"
@trend-change="loadTrend"
/>
<ServiceStatusCard :items="serviceStatuses" />
<WireProfileCard :profile="wireProfile" />
</section>
<AccountOverviewCard

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