mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfd05722d5 | ||
|
|
9dc2ea4a2f | ||
|
|
6fb27e869e | ||
|
|
81c58b1cdc | ||
|
|
c8dc2e293a | ||
|
|
25081de913 | ||
|
|
6eb0917ff1 | ||
|
|
72d892d5e3 | ||
|
|
0b2da3087b | ||
|
|
5391fdb31c | ||
|
|
24036f1f84 | ||
|
|
b043fced0c |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 多账号网关代理**
|
||||
|
||||
[](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/releases)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://vite.dev/)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
[](#开源许可)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/releases)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
[](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) 开源。
|
||||
|
||||
Generated
+16
-6
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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,
|
||||
"a_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(¤t_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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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 定期版本检查失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,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, "清理超限指纹更新历史失败"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 != ¤t_version)
|
||||
|| build.as_ref().is_some_and(|value| value != ¤t_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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
(
|
||||
|
||||
@@ -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());
|
||||
(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(¤t_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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
pub(crate) mod account_usage;
|
||||
mod dashboard;
|
||||
pub(crate) mod ops;
|
||||
pub(crate) mod rebuild;
|
||||
pub(crate) mod usage;
|
||||
|
||||
@@ -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 +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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 生命周期。
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user