Compare commits

...
15 Commits
Author SHA1 Message Date
ZyyandCodex debba0e13c chore(release): v3.13.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-21 01:06:20 +08:00
ZyyandCodex a0836ec3f2 feat(key-usage): add API key budgets to CC Switch imports (#202)
Closes #198

Co-authored-by: Codex <noreply@openai.com>
2026-09-21 00:26:36 +08:00
ZyyandCodex dc0cdf2930 fix(openai): normalize rejected reasoning replay fields
Refs #194, #200.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 23:09:23 +08:00
ZyyandCodex d76bb57b8c fix(usage): show current account notes below email
Read account notes by internal account ID for usage lists and Dashboard recent
requests. Show nonempty notes as secondary text using the existing label style.

Cover same-email accounts, note changes and removal, deleted-account history,
and the admin API and client key visibility boundaries.

Refs #190

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 19:13:52 +08:00
ZyyandCodex 6434edd554 fix(accounts): preserve quota when recovering disabled accounts
Enable scheduling without clearing observed quota, credential errors, or cooldowns for disabled accounts. Keep explicit recovery for enabled accounts and document both paths.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 18:41:22 +08:00
ZyyandCodex 3a90d8d0b6 fix(accounts): forecast quota totals within the current cycle
Preserve recorded cycle usage and estimate only remaining capacity from
recent samples. Restart cycle totals and sampling after quota resets,
including resets following an exhausted observation.

Clarify cycle totals and duration conversions in the quota modal and API
documentation, and prevent narrow layouts from overflowing.

Validated with the full backend suite using isolated PostgreSQL and Redis,
strict Clippy, Rustfmt, frontend lint and build, and browser checks.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 18:25:17 +08:00
ZyyandCodex f3236d0410 feat(key-usage): add about dialog with build information
Reuse the shared About dialog from a toolbar icon and expose version and commit identifiers through an authenticated key-only endpoint. Align the About and refresh button backgrounds with the configuration button in dark mode.

Validate the response field allowlist and session boundaries, and document the new endpoint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 16:27:55 +08:00
ZyyandCodex ccb7357914 fix(usage): indicate long-context billing with a yellow info icon
Replace the inline long-context badge with a themed yellow detail trigger and retain the billing context in its title and accessible label.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 15:44:46 +08:00
ZyyandCodex c0ad2ae656 fix(openai): restrict capacity cooldown to explicit overload
Count only confirmed capacity rejections toward account cooldown, excluding
generic server failures and unrecognized upstream errors. Classify streamed
invalid_prompt failures as invalid requests, matching official Codex.

Cover HTTP and WebSocket failures around the cooldown threshold while
preserving retry boundaries, upstream error payloads, and Smart scoring.
Update the capacity cooldown contract in the API and architecture docs.

Validation: Rustfmt, strict workspace Clippy, and all backend suites with
isolated PostgreSQL 18 / Redis 8 (--test-threads=4).

Reference: openai/codex@7498521d28,
codex-rs/codex-api/src/sse/responses.rs.

Refs #177

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 14:01:29 +08:00
ZyyandCodex 798d4f518e fix(update): run updates in background and retain release notes (#188)
Detach online updates from HTTP request lifetimes and recover their persisted status in the admin UI. Retain current release notes when no upgrade is available while preserving upgrade restrictions.

Fixes #185

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 13:29:48 +08:00
ZyyandCodex 6819682176 docs(ui): define concise copy and layout guidance
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 11:28:17 +08:00
ZyyandCodex b2d4a7e833 docs(readme): simplify punctuation and community guidance
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 11:13:39 +08:00
ZyyandCodex 01006380ee chore(release): v3.12.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 01:18:37 +08:00
ZyyandCodex c79e032575 fix(ui): refine usage page layout and controls
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 01:14:13 +08:00
Akide LiuandClaude Fable 5 96cf46cca8 fix(openai): expand string input into a user message item (#179)
The public Responses API accepts `input` as a plain string, but the Codex
backend only accepts an item list and rejects such requests with
400 "Input must be a list". Expand a string `input` into a single user
message item in the downstream body compatibility layer so that HTTP/SSE
and WebSocket share the same rule; arrays and other shapes pass through
untouched.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-20 00:58:58 +08:00
82 changed files with 2720 additions and 494 deletions
+7 -1
View File
@@ -16,7 +16,7 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
| 前端开发、管理端组件、主题 Token、视觉验证 | 加载并使用 `$frontend-design`,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
@@ -25,6 +25,12 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
| 整理或提交 Issue | [github-issue](../github-issue/SKILL.md) |
| 发布说明、版本规划与发版 | [release](../release/SKILL.md) |
## 前端任务
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
- 开发前按该章节的参考入口和相邻页面核对布局、文案密度与操作位置,再选择共用组件
- 交付前按该章节检查界面文案中的句号、分号,以及过大的标题、重复说明和说明块占用的空间,结合 [界面验证](../../../CONTRIBUTING.md#界面验证) 核对实际页面
## 执行顺序
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
+20 -21
View File
@@ -17,13 +17,13 @@
</div>
> [!NOTE]
> 本项目提供 Responses API,不支持 `/v1/chat/completions`。接入前请确认客户端支持 Responses 协议。
> 本项目提供 Responses API,不支持 `/v1/chat/completions`,接入前请确认客户端支持 Responses 协议
## 快速预览
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计。
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计
| 登录信息 | 值 |
| 登录信息 | 默认值 |
| --- | --- |
| 地址 | <https://codex-proxy-rs.ainz.cc> |
| 登录身份 | 管理员 |
@@ -31,17 +31,17 @@
| 密码 | `039c18de2aeac46d23ead7766bb07bbbe3747233c66a128e` |
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据。
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息。
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息
## 快速开始
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
### 一键安装
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限。
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限
```bash
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/install.sh -o install.sh && bash install.sh
@@ -49,7 +49,7 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
自动生成密码、设置目录权限并启动服务。完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
[添加账号与客户端密钥](#添加账号与客户端密钥)。
[添加账号与客户端密钥](#添加账号与客户端密钥)
可在执行 `bash install.sh` 时传入环境变量:
@@ -66,33 +66,33 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
```
重复运行时请使用同一安装目录。检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
忽略传入的管理员密码,也不执行版本升级。
忽略传入的管理员密码,也不执行版本升级
### 手动安装
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)。
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)
### 登录管理端
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch。
页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch,导入时同时启用当前 Key 的日/周额度查询,默认刷新间隔为 30 分钟
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)。
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)
### 添加账号与客户端密钥
1. 在「账号」中添加账号,完成授权或导入。
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
3. 打开密钥的「使用密钥」,复制客户端配置。
1. 在「账号」中添加账号,完成授权或导入
2. 按需建立账号分组,再创建客户端密钥并选择可用分组,**不选分组表示可使用全部账号**
3. 打开密钥的「使用密钥」,复制客户端配置
## 客户端接入
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)。
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
**其他 Responses API 客户端**:填写以下信息。
**其他 Responses API 客户端**:填写以下信息:
| 配置 | 值 |
| --- | --- |
@@ -123,11 +123,10 @@ curl http://127.0.0.1:8080/v1/models \
[使用问答](https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答)、
[想法讨论](https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论)、
[实验反馈](https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈)与
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)。
明确的 Bug 或功能需求请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose),无需先发讨论。
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台。
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台
## 许可证
本项目基于 [Apache License 2.0](LICENSE) 开源。
本项目基于 [Apache License 2.0](LICENSE) 开源
+5 -2
View File
@@ -346,7 +346,7 @@ pub async fn initialize(
config.session_ttl_minutes,
client_config.session_ttl_minutes,
store.auth(),
client_key_verifier,
client_key_verifier.clone(),
));
auth.ensure_default_admin(config.default_password.expose())
.await?;
@@ -370,10 +370,13 @@ pub async fn initialize(
backup_ports.dump(),
backup_ports.object_store(),
);
let system = Arc::new(DefaultSystemService::new(system));
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
auth.clone(),
client_key_verifier,
store.client_keys(),
store.observability(),
system.clone(),
));
let openai = Arc::new(DefaultOpenAiService::new(
openai,
@@ -423,7 +426,7 @@ pub async fn initialize(
registry,
pricing_source,
)),
system: Arc::new(DefaultSystemService::new(system)),
system,
openai,
xai,
import_tasks,
@@ -556,6 +556,8 @@ pub struct UsageListRecord {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
pub provider_account_notes: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -108,7 +108,7 @@ impl AccountQuotaForecast {
let percent = window
.used_percent
.filter(|p| p.is_finite() && (0.0..=100.0).contains(p));
let usage = sample.map(|sample| &sample.usage);
let usage = sample.map(|sample| &sample.cycle_usage);
let usd = usage
.filter(|usage| usage.known_cost_count > 0)
.map(|usage| usage.usd)
@@ -175,14 +175,21 @@ impl AccountQuotaForecast {
|| (method == QuotaForecastMethod::Incremental && sample.block_count < 2);
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗。
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。
let capacity_factor = 100.0 / sample.sampled_percent;
let factor = capacity_factor * self.target_seconds as f64 / seconds as f64;
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0);
self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor));
self.estimated_usd = usd.and_then(|value| estimate(value, factor));
// 已发生的用量保持本周期累计,只把近期消耗比例用于尚未使用的额度。
let factor = self.target_seconds as f64 / seconds as f64;
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
self.remaining_tokens = tokens.and_then(|value| estimate_tokens(value, remaining_factor));
self.remaining_usd = usd.and_then(|value| estimate(value, remaining_factor));
let remaining_tokens = Some(sample.usage.tokens)
.filter(|tokens| *tokens > 0)
.and_then(|value| estimate(value as f64, remaining_factor));
self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0));
self.estimated_tokens = remaining_tokens
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
self.remaining_usd = Some(&sample.usage)
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
.and_then(|usage| estimate(usage.usd, remaining_factor));
self.estimated_usd = usd
.zip(self.remaining_usd)
.and_then(|(used, remaining)| estimate(used + remaining, factor));
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
{
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
@@ -197,8 +204,8 @@ fn estimate(value: f64, factor: f64) -> Option<f64> {
(estimate.is_finite() && estimate >= 0.0).then_some(estimate)
}
fn estimate_tokens(value: u64, factor: f64) -> Option<u64> {
estimate(value as f64, factor)
fn estimate_tokens(value: f64, factor: f64) -> Option<u64> {
estimate(value, factor)
.filter(|value| value.round() < u64::MAX as f64)
.map(|value| value.round() as u64)
}
@@ -101,6 +101,8 @@ pub struct QuotaForecastSample {
pub observation_count: usize,
pub pending_request_count: u64,
pub discontinuous: bool,
/// 本周期已记录用量;近期采样仅用于预测剩余量,不能覆盖这份累计值。
pub cycle_usage: QuotaForecastUsage,
pub usage: QuotaForecastUsage,
}
@@ -113,12 +115,13 @@ pub fn select_forecast_sample(
current: QuotaForecastPoint,
mut history: Vec<QuotaForecastPoint>,
pending_request_count: u64,
interrupted: bool,
) -> QuotaForecastSample {
history.retain(|point| {
start_at <= point.observed_at
&& point.observed_at < current.observed_at
&& point.used_percent.is_finite()
&& (0.0..100.0).contains(&point.used_percent)
&& (0.0..=100.0).contains(&point.used_percent)
});
history.sort_by_key(|point| point.observed_at);
let mut sample = QuotaForecastSample {
@@ -131,10 +134,13 @@ pub fn select_forecast_sample(
block_count: 0,
observation_count: history.len(),
pending_request_count,
discontinuous: false,
discontinuous: interrupted,
cycle_usage: current.usage.clone(),
usage: current.usage.clone(),
};
history.push(current);
// 重置边界不在查询起点时,从首个新观测重新计数,避免把旧周期累计带入总量。
let mut cycle_baseline = interrupted.then(|| &history[0]);
let mut anchors: Vec<&QuotaForecastPoint> = Vec::new();
let mut high_water: Option<&QuotaForecastPoint> = None;
for point in &history {
@@ -149,6 +155,7 @@ pub fn select_forecast_sample(
}
sample.discontinuous = true;
anchors.clear();
cycle_baseline = Some(point);
}
}
high_water = Some(point);
@@ -162,6 +169,13 @@ pub fn select_forecast_sample(
let Some(current) = history.last() else {
return sample;
};
if let Some(baseline) = cycle_baseline {
let Some(usage) = current.usage.difference(&baseline.usage) else {
sample.discontinuous = true;
return sample;
};
sample.cycle_usage = usage;
}
if anchors.len() < 2 {
return sample;
}
@@ -71,6 +71,7 @@ pub struct SystemOperationState {
pub struct SystemUpdateStatus {
pub previous_version: Option<String>,
pub current_version: Option<String>,
pub need_restart: bool,
pub operation: SystemOperationState,
}
@@ -103,7 +104,6 @@ pub enum SystemOperationAccepted {
operation_id: String,
deployment_mode: String,
message: String,
need_restart: bool,
target_version: String,
},
Rollback {
@@ -548,19 +548,39 @@ impl AccountsService for DefaultAccountsService {
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<AccountRefreshResult, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
let result = self
.accounts
.recover_account(&account_id, context)
.await
.map_err(|error| map_store_error(error, "provider account recovery"))?;
let (stored, provider) = self.provider_for_account(&account_id).await?;
let config_revision = if stored.account.enabled {
self.accounts
.recover_account(&account_id, context)
.await
.map_err(|error| map_store_error(error, "provider account recovery"))?
.config_revision
} else {
// 停用只表示不参与调度,重新启用不能抹除已观测的额度、凭据或冷却事实。
self.accounts
.batch_update_accounts(
BatchUpdateAccounts {
account_ids: vec![account_id.to_string()],
enabled: Some(true),
concurrency_limit: None,
weight: None,
model_access: None,
group_ids: None,
outbound_proxy: None,
},
context,
)
.await
.map_err(|error| map_store_error(error, "enable provider account"))?
.config_revision
};
provider
.account_facts_changed(std::slice::from_ref(&account_id))
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
let account = self.load_directory_item(&account_id, false).await?;
Ok(AccountRefreshResult {
config_revision: result.config_revision,
config_revision,
account,
})
}
@@ -740,7 +760,7 @@ impl AccountsService for DefaultAccountsService {
usage: point.usage,
});
}
let mut sample = select_forecast_sample(
let sample = select_forecast_sample(
window.key.clone(),
query.range.start,
QuotaForecastPoint {
@@ -750,13 +770,8 @@ impl AccountsService for DefaultAccountsService {
},
points,
history.pending_request_count,
interrupted,
);
if interrupted
&& sample.method
== crate::model::quota_forecast_sampling::QuotaForecastMethod::Cumulative
{
sample.discontinuous = true;
}
samples.push(sample);
}
Ok(AccountQuotaForecastReport {
@@ -1,4 +1,4 @@
//! Key 自助查询只从统一会话取得范围,复用现有观测和额度账本。
//! Key 自助查询从统一会话或只读 Key 校验取得范围,复用现有观测和额度账本。
use std::sync::Arc;
@@ -6,7 +6,7 @@ use async_trait::async_trait;
use chrono::Utc;
use crate::{
AuthService,
AuthService, SystemService,
model::{
AdminError, AdminErrorKind,
auth::SessionSubject,
@@ -18,15 +18,27 @@ use crate::{
observability::{
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
},
system::SystemVersion,
},
ports::store::{ClientKeyStore, ObservabilityStore},
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::{
engine::{
budget::ClientBudgetStatus,
execution::{ClientAuthenticationError, ClientKeyVerifier},
},
policy::ClientApiKeyId,
};
use super::{map_store_error, observability::health_timeline_at};
#[async_trait]
pub trait KeyUsageService: Send + Sync {
/// 验证 Key 并只读查询当前额度,不记录 Key 使用或执行推理准入。
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, AdminError>;
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError>;
async fn config(&self, session_id: Option<&str>)
-> Result<Option<ClientKeySecret>, AdminError>;
@@ -45,20 +57,26 @@ pub trait KeyUsageService: Send + Sync {
pub(crate) struct DefaultKeyUsageService {
auth: Arc<dyn AuthService>,
verifier: Arc<dyn ClientKeyVerifier>,
keys: Arc<dyn ClientKeyStore>,
observations: Arc<dyn ObservabilityStore>,
system: Arc<dyn SystemService>,
}
impl DefaultKeyUsageService {
pub(crate) fn new(
auth: Arc<dyn AuthService>,
verifier: Arc<dyn ClientKeyVerifier>,
keys: Arc<dyn ClientKeyStore>,
observations: Arc<dyn ObservabilityStore>,
system: Arc<dyn SystemService>,
) -> Self {
Self {
auth,
verifier,
keys,
observations,
system,
}
}
@@ -89,6 +107,31 @@ fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
#[async_trait]
impl KeyUsageService for DefaultKeyUsageService {
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, AdminError> {
let id = match self.verifier.verify_client_key(plaintext) {
Ok(id) => id,
Err(ClientAuthenticationError::InvalidKey) => return Ok(None),
Err(ClientAuthenticationError::SnapshotUnavailable) => {
return Err(AdminError::new(
AdminErrorKind::Unavailable,
"密钥验证暂时不可用",
));
}
};
self.keys
.get_client_key(&id)
.await
.map(|key| key.filter(|key| key.enabled).map(|key| key.budget))
.map_err(|error| map_store_error(error, "key usage budget"))
}
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError> {
if self.key_id(session_id).await?.is_none() {
return Ok(None);
}
self.system.version().await.map(Some)
}
async fn config(
&self,
session_id: Option<&str>,
@@ -76,6 +76,23 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
.filter_map(|window| {
let usage = window.local_usage.as_ref()?;
let usd = usage.costs.iter().find(|cost| cost.currency == "USD");
let usage = QuotaForecastUsage {
request_count: usage.request_count,
tokens: usage.total_tokens.unwrap_or(0),
input_tokens: usage.input_tokens.unwrap_or(0),
output_tokens: usage.output_tokens.unwrap_or(0),
cached_tokens: usage.cached_tokens.unwrap_or(0),
missing_token_count: u64::from(usage.total_tokens.is_none()),
known_cost_count: if usd.is_some() {
usage.cost_coverage.known_count()
} else {
0
},
unavailable_cost_count: usage.cost_coverage.partial_count
+ usage.cost_coverage.unavailable_count,
usd: usd.map_or(0.0, |cost| cost.amount.as_str().parse().unwrap()),
excluded_request_count: 0,
};
Some(QuotaForecastSample {
key: window.key.clone(),
method: QuotaForecastMethod::Cumulative,
@@ -89,23 +106,8 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
observation_count: 0,
pending_request_count: 0,
discontinuous: false,
usage: QuotaForecastUsage {
request_count: usage.request_count,
tokens: usage.total_tokens.unwrap_or(0),
input_tokens: usage.input_tokens.unwrap_or(0),
output_tokens: usage.output_tokens.unwrap_or(0),
cached_tokens: usage.cached_tokens.unwrap_or(0),
missing_token_count: u64::from(usage.total_tokens.is_none()),
known_cost_count: if usd.is_some() {
usage.cost_coverage.known_count()
} else {
0
},
unavailable_cost_count: usage.cost_coverage.partial_count
+ usage.cost_coverage.unavailable_count,
usd: usd.map_or(0.0, |cost| cost.amount.as_str().parse().unwrap()),
excluded_request_count: 0,
},
cycle_usage: usage.clone(),
usage,
})
})
.collect()
@@ -238,7 +240,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
sample.block_count = 2;
let [week, month] =
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
assert_eq!(week.estimated_tokens, Some(10_000));
assert_eq!(week.estimated_tokens, Some(9_000));
assert_eq!(week.remaining_tokens, Some(8_000));
assert_eq!(month.remaining_tokens, Some(8_000));
assert!(week.unavailable_reason.is_none());
@@ -250,6 +252,7 @@ fn missing_tokens_keep_estimates_from_recorded_usage() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
sample.usage.missing_token_count = 1;
sample.cycle_usage = sample.usage.clone();
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.incomplete_tokens);
assert!(week.unavailable_reason.is_none());
@@ -304,6 +307,7 @@ fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimate
sample.usage.unavailable_cost_count = 2;
sample.usage.excluded_request_count = 1;
sample.pending_request_count = 1;
sample.cycle_usage = sample.usage.clone();
let [week, month] =
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.incomplete_tokens);
@@ -345,6 +349,7 @@ fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
unavailable_cost_count: 2,
..Default::default()
};
sample.cycle_usage = sample.usage.clone();
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.unavailable_reason.is_some());
assert_eq!(week.estimated_tokens, None);
@@ -380,3 +385,50 @@ fn oversized_window_or_estimate_is_unavailable_without_panics_or_saturation() {
assert!(week.estimated_tokens.is_none());
assert_eq!(week.estimated_usd, Some(10.0));
}
#[test]
fn slower_recent_consumption_preserves_recorded_cycle_usage_in_total_forecast() {
let mut source = quota(vec![window("week", 7)]);
source.windows[0].used_percent = Some(92.0);
let mut sample = samples(&source).remove(0);
sample.method = QuotaForecastMethod::Incremental;
sample.start_at = now() - Duration::hours(20);
sample.baseline_percent = 75.0;
sample.sampled_percent = 17.0;
sample.block_count = 3;
sample.usage.tokens = 141_226_903;
sample.usage.usd = 237.21007884;
sample.cycle_usage.tokens = 1_084_786_565;
sample.cycle_usage.usd = 1_412.57957384;
let [cycle, month] =
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(1_084_786_565));
assert_eq!(cycle.source.as_ref().unwrap().usd, Some(1_412.57957384));
assert_eq!(cycle.remaining_tokens, Some(66_459_719));
assert_eq!(cycle.estimated_tokens, Some(1_151_246_284));
assert!((cycle.remaining_usd.unwrap() - 111.628272395294).abs() < 1e-8);
assert!((cycle.estimated_usd.unwrap() - 1_524.207846235294).abs() < 1e-8);
assert_eq!(month.remaining_tokens, cycle.remaining_tokens);
assert!(
(month.estimated_usd.unwrap() - cycle.estimated_usd.unwrap() * 30.0 / 7.0).abs() < 1e-8
);
}
#[test]
fn exhausted_cycle_total_is_recorded_usage_even_when_recent_sample_is_small() {
let mut source = quota(vec![window("week", 7)]);
source.windows[0].used_percent = Some(100.0);
let mut sample = samples(&source).remove(0);
sample.method = QuotaForecastMethod::Incremental;
sample.start_at = now() - Duration::hours(2);
sample.baseline_percent = 85.0;
sample.sampled_percent = 15.0;
sample.block_count = 3;
sample.usage.tokens = 100;
sample.usage.usd = 0.1;
let [cycle, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert_eq!(cycle.estimated_tokens, Some(1_000));
assert_eq!(cycle.estimated_usd, Some(2.0));
assert_eq!(cycle.remaining_tokens, Some(0));
assert_eq!(cycle.remaining_usd, Some(0.0));
}
@@ -24,7 +24,7 @@ fn point(minute: i64, percent: f64, tokens: u64) -> QuotaForecastPoint {
}
fn select(points: Vec<QuotaForecastPoint>, current: QuotaForecastPoint) -> QuotaForecastSample {
select_forecast_sample("week".to_owned(), time(0), current, points, 2)
select_forecast_sample("week".to_owned(), time(0), current, points, 2, false)
}
#[test]
@@ -55,6 +55,7 @@ fn recent_blocks_and_tail_replace_old_workload_without_averaging_individual_rati
assert_eq!(sample.baseline_percent, 10.0);
assert_eq!(sample.sampled_percent, 17.0);
assert_eq!(sample.usage.tokens, 700);
assert_eq!(sample.cycle_usage.tokens, 900);
assert_eq!(sample.block_count, 3);
}
@@ -77,6 +78,7 @@ fn large_percentage_reversal_restarts_sampling_without_crossing_old_consumption(
);
assert_eq!(sample.baseline_percent, 1.0);
assert_eq!(sample.usage.tokens, 70);
assert_eq!(sample.cycle_usage.tokens, 70);
assert_eq!(sample.sampled_percent, 7.0);
assert!(!sample.discontinuous);
let waiting = select(
@@ -122,3 +124,41 @@ fn counter_regressions_fail_closed_and_future_points_are_not_used() {
assert_eq!(future.method, QuotaForecastMethod::Cumulative);
assert_eq!(future.observation_count, 0);
}
#[test]
fn exhausted_history_is_retained_to_detect_a_reset_with_an_unchanged_deadline() {
let sample = select(
vec![point(1, 100.0, 10_000), point(2, 0.0, 10_000)],
point(3, 10.0, 10_100),
);
assert_eq!(sample.cycle_usage.tokens, 100);
assert_eq!(sample.usage.tokens, 100);
assert_eq!(sample.sampled_percent, 10.0);
assert!(!sample.discontinuous);
}
#[test]
fn changed_window_restarts_cycle_totals_at_first_matching_observation() {
let sample = select_forecast_sample(
"week".to_owned(),
time(0),
point(4, 12.0, 10_120),
vec![point(2, 0.0, 10_000), point(3, 5.0, 10_050)],
0,
true,
);
assert_eq!(sample.cycle_usage.tokens, 120);
assert!((sample.cycle_usage.usd - 0.12).abs() < 1e-10);
assert_eq!(sample.usage.tokens, 120);
assert!(!sample.discontinuous);
let waiting = select_forecast_sample(
"week".to_owned(),
time(0),
point(4, 2.0, 10_020),
vec![],
0,
true,
);
assert_eq!(waiting.cycle_usage.tokens, 0);
assert!(waiting.discontinuous);
}
@@ -935,6 +935,13 @@ impl AccountStore for FakeAccountStore {
self.record("store.batch_update_accounts");
self.record_context(context);
self.require_commit()?;
if let Some(enabled) = command.enabled {
for account in self.accounts.lock().expect("accounts").iter_mut() {
if command.account_ids.contains(&account.id) {
account.enabled = enabled;
}
}
}
Ok(AccountsUpdateResult {
config_revision: revision(2),
account_ids: command
@@ -1360,7 +1367,6 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let mut account = account_record("openai");
account.enabled = false;
account.credential_state = CredentialState::Invalid;
account.quota = QuotaState::exhausted(
QuotaEvidence::UsageLimitReached,
@@ -1407,6 +1413,110 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
assert_eq!(store.audit_requests(), ["recover-request"]);
}
#[tokio::test]
async fn accounts_recover_disabled_should_only_enable_scheduling_and_preserve_quota() {
for has_error in [false, true] {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let mut account = account_record("openai");
account.enabled = false;
if has_error {
account.credential_state = CredentialState::Invalid;
account.last_error_reason =
Some(gateway_core::account::AccountErrorReason::CredentialInvalid);
account.last_error_message = Some("invalid credential".to_owned());
account.quota = QuotaState::exhausted(
QuotaEvidence::UsageLimitReached,
std::time::SystemTime::now(),
None,
);
}
let quota = ProviderQuota {
observed_at: Some(Utc::now()),
windows: vec![ProviderQuotaWindow {
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(92.0),
reset_at: Some(Utc::now() + TimeDelta::hours(1)),
limit_reached: false,
local_usage: None,
provider_data: None,
}],
..empty_quota()
};
provider.set_quota(quota.clone());
let store = FakeAccountStore::with_account(account.clone(), events.clone());
let services = accounts_service(provider.clone(), store.clone()).await;
let result = services
.accounts()
.recover(
&context("enable-request"),
ProviderAccountId::new("acct_test").expect("account ID"),
)
.await
.expect("enable disabled account");
account.enabled = true;
assert_eq!(result.config_revision, revision(2));
assert_eq!(result.account.account, account);
assert_eq!(result.account.quota, quota);
assert_eq!(
result.account.projection.status,
if has_error {
gateway_admin::model::accounts::AccountStatus::Error
} else {
gateway_admin::model::accounts::AccountStatus::Normal
}
);
assert_eq!(
recorded(&events),
[
"store.load_account",
"store.batch_update_accounts",
"provider.account_facts_changed",
"store.load_account",
]
);
assert_eq!(store.audit_requests(), ["enable-request"]);
let requests = provider.quota_requests();
assert_eq!(requests.len(), 1);
assert!(!requests[0].refresh);
}
}
#[tokio::test]
async fn accounts_recover_disabled_should_not_publish_when_enabling_fails() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let mut account = account_record("openai");
account.enabled = false;
let store = FakeAccountStore::with_account(account.clone(), events.clone());
store.fail_next_commit();
let services = accounts_service(provider, store.clone()).await;
let result = services
.accounts()
.recover(
&context("enable-failed"),
ProviderAccountId::new("acct_test").expect("account ID"),
)
.await;
assert!(result.is_err());
assert_eq!(*store.accounts.lock().expect("accounts"), [account]);
assert_eq!(
recorded(&events),
["store.load_account", "store.batch_update_accounts"]
);
}
#[tokio::test]
async fn accounts_update_should_commit_then_release_disabled_account_and_publish_facts() {
let events = events();
@@ -2040,6 +2150,29 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
.unwrap()
.contains("不连续")
);
// 新额度段已有足够观测后恢复预测,但总量不能带回重置前的累计用量。
let mut first = make_point(1, 5.0, 1_750, 0);
first.completed_at = now - TimeDelta::minutes(45);
first.started_at = first.completed_at - TimeDelta::seconds(10);
let mut next = make_point(1, 20.0, 1_900, 0);
next.completed_at = now - TimeDelta::minutes(20);
next.started_at = next.completed_at - TimeDelta::seconds(10);
store
.quota_forecast_history
.lock()
.unwrap()
.points
.extend([first, next]);
let result = services
.accounts()
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
let cycle = &result.forecasts[0];
assert!(cycle.unavailable_reason.is_none());
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(250));
assert_eq!(cycle.remaining_tokens, Some(429));
assert_eq!(cycle.estimated_tokens, Some(679));
}
#[tokio::test]
@@ -880,6 +880,7 @@ fn total_record(
provider_account_ref: None,
provider_account_name: None,
provider_account_email: None,
provider_account_notes: None,
provider_account_authentication_kind: None,
upstream_model_id: Some("gpt-5.5".to_owned()),
upstream_transport: None,
@@ -60,13 +60,13 @@ impl SystemOperations for RecordingSystemOperations {
operation_id: "operation-update".to_owned(),
deployment_mode: "source".to_owned(),
message: "accepted".to_owned(),
need_restart: true,
target_version: target_version.unwrap_or_else(|| "latest".to_owned()),
})
}
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
Ok(SystemUpdateStatus {
need_restart: false,
previous_version: None,
current_version: Some("1.0.0".to_owned()),
operation: SystemOperationState {
@@ -286,6 +286,7 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi
account_id: record.provider_account_ref,
account_email: record.provider_account_email,
account_name: record.provider_account_name,
account_notes: record.provider_account_notes,
route: record.endpoint,
model,
requested_model: record.requested_model_id,
@@ -93,6 +93,7 @@ pub struct UsageListRecordView {
pub account_id: Option<String>,
pub account_email: Option<String>,
pub account_name: Option<String>,
pub account_notes: Option<String>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
@@ -163,6 +163,7 @@ impl From<SystemOperationState> for SystemOperationStateView {
struct SystemUpdateStatusView {
previous_version: Option<String>,
current_version: Option<String>,
need_restart: bool,
operation: SystemOperationStateView,
}
@@ -171,6 +172,7 @@ impl From<SystemUpdateStatus> for SystemUpdateStatusView {
Self {
previous_version: status.previous_version,
current_version: status.current_version,
need_restart: status.need_restart,
operation: status.operation.into(),
}
}
@@ -182,7 +184,6 @@ struct UpdateAcceptedView {
operation_id: String,
deployment_mode: String,
message: String,
need_restart: bool,
target_version: String,
}
@@ -308,19 +309,17 @@ where
operation_id,
deployment_mode,
message,
need_restart,
target_version,
} = result
else {
return Err(AdminError::internal());
};
Ok(AdminResponse::new(
StatusCode::OK,
StatusCode::ACCEPTED,
AdminEnvelope::ok(UpdateAcceptedView {
operation_id,
deployment_mode,
message,
need_restart,
target_version,
}),
))
@@ -23,6 +23,7 @@ where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/key-usage/version", get(version::<S>))
.route("/api/key-usage/config", get(config::<S>))
.route("/api/key-usage/overview", get(overview::<S>))
.route("/api/key-usage/records", get(records::<S>))
@@ -32,10 +33,31 @@ where
.layer(middleware::map_response(no_store))
}
async fn version<S>(
State(state): State<S>,
headers: HeaderMap,
AdminQuery(_): AdminQuery<query::EmptyQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let version = state
.admin_services()
.key_usage()
.version(session_cookie::value(&headers).as_deref())
.await
.map_err(map_admin_service_error)?
.ok_or_else(AdminError::session_required)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(presenter::version(version)),
))
}
async fn config<S>(
State(state): State<S>,
headers: HeaderMap,
AdminQuery(_): AdminQuery<query::ConfigQuery>,
AdminQuery(_): AdminQuery<query::EmptyQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
@@ -7,6 +7,7 @@ use gateway_admin::model::{
observability::{
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
},
system::SystemVersion,
};
use serde::Serialize;
@@ -15,6 +16,21 @@ use crate::admin::observability::{
health_timeline_view, usage_list_token_details,
};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct VersionView {
version: String,
git_sha: String,
}
pub(super) fn version(version: SystemVersion) -> VersionView {
// 密钥用户仅查看构建标识,不暴露部署环境、更新状态或内部诊断。
VersionView {
version: version.version,
git_sha: version.git_sha,
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ConfigView {
@@ -13,7 +13,7 @@ use crate::admin::AdminError;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ConfigQuery {}
pub(super) struct EmptyQuery {}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -9,6 +9,7 @@ pub mod responses;
pub mod router;
pub mod search;
pub(crate) mod service;
mod usage;
/// 客户端可读的 ID 必须能按现有请求记录检索;不以无关入口 ID 补位。
fn with_model_request_id(
@@ -11,6 +11,7 @@ use super::{
models::{model_detail, models},
responses::{responses, responses_websocket},
search::standalone_search,
usage,
};
use crate::ApiState;
@@ -25,6 +26,7 @@ pub(crate) fn router() -> Router<ApiState> {
.route("/v1/models", get(models))
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
.route("/v1/models/{model_id}", get(model_detail))
.merge(usage::router())
// OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝
// 上游本可接受的未来 payload。
.layer(DefaultBodyLimit::disable())
@@ -0,0 +1,90 @@
//! Bearer Key 的只读额度查询,与自助页面共用当前预算账本。
use std::time::SystemTime;
use axum::{
Json, Router,
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
middleware,
response::{IntoResponse, Response},
routing::get,
};
use chrono::{DateTime, Utc};
use gateway_core::metering::Decimal;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{ApiState, auth::SessionState as _};
use super::{
auth::{bearer_client_api_key, client_access_error_response},
error::{missing_client_api_key_response, openai_error_response},
};
pub(super) fn router() -> Router<ApiState> {
Router::new()
.route("/v1/usage", get(usage))
.layer(middleware::map_response(no_store))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct UsageQuery {}
async fn usage(
State(state): State<ApiState>,
headers: HeaderMap,
uri: axum::http::Uri,
) -> Response {
let key = match bearer_client_api_key(&headers) {
Ok(key) => key,
Err(error) => return client_access_error_response(error.into()),
};
// 范围只能来自 Bearer 身份,不接受调用者指定 Key、账号或时间窗口。
if Query::<UsageQuery>::try_from_uri(&uri).is_err() {
return openai_error_response(
StatusCode::BAD_REQUEST,
"Usage query does not accept query parameters",
"invalid_request_error",
"invalid_usage_query",
)
.into_response();
}
let budget = match state.admin_services().key_usage().budget(key).await {
Ok(Some(budget)) => budget,
Ok(None) => return missing_client_api_key_response().into_response(),
Err(_) => {
return openai_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Key usage is temporarily unavailable",
"server_error",
"usage_unavailable",
)
.into_response();
}
};
Json(json!({
"unit": "USD",
"daily": window(budget.limits.daily_usd, budget.daily_used_usd, budget.daily_resets_at),
"weekly": window(budget.limits.weekly_usd, budget.weekly_used_usd, budget.weekly_resets_at),
}))
.into_response()
}
fn window(total: Decimal, used: Decimal, resets_at: Option<SystemTime>) -> Value {
let limited = total != Decimal::ZERO;
json!({
"total": limited.then(|| total.canonical()),
"used": used.canonical(),
"remaining": limited.then(|| total.saturating_sub(used).canonical()),
"resetsAt": resets_at.map(DateTime::<Utc>::from),
})
}
async fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
@@ -966,6 +966,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
provider_account_ref: Some("acct_snapshot".to_owned()),
provider_account_name: Some("Snapshot Alpha".to_owned()),
provider_account_email: Some("alpha@example.invalid".to_owned()),
provider_account_notes: Some("Team workspace".to_owned()),
provider_account_authentication_kind: Some("oauth".to_owned()),
upstream_model_id: Some("grok-4.5".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
@@ -1057,6 +1058,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
let value: serde_json::Value = serde_json::from_slice(&body).expect("usage response JSON");
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
assert_eq!(value["data"]["items"][0]["accountNotes"], "Team workspace");
assert_eq!(
value["data"]["items"][0]["billing"]["longContextBillingApplied"],
true
@@ -4,14 +4,15 @@ use async_trait::async_trait;
use axum::{
Router,
body::{Body, to_bytes},
http::{Request, header},
http::{Request, StatusCode, header},
};
use chrono::Utc;
use futures::stream;
use gateway_admin::{
model::system::{
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel,
SystemUpdateStatus, SystemVersion,
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdateStatus,
SystemVersion,
},
ports::system::{SystemOperationError, SystemOperations, SystemUpdateEventStream},
};
@@ -67,6 +68,51 @@ async fn update_event_stream_should_preserve_event_id_and_download_progress_perc
);
}
#[tokio::test]
async fn update_should_return_accepted_and_report_restart_only_through_status() {
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem)).await;
fixture.auth.insert_session("valid-session");
let router = app(fixture.state());
let response = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/system/update")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_system_update")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"targetVersion":"0.2.0"}"#))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.expect("body"))
.expect("JSON");
assert_eq!(body["data"]["operationId"], "update-1");
assert_eq!(body["data"]["targetVersion"], "0.2.0");
assert!(body["data"].get("needRestart").is_none());
let response = router
.oneshot(
Request::builder()
.uri("/api/admin/system/update/status")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_system_update")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.expect("body"))
.expect("JSON");
assert_eq!(body["data"]["operation"]["status"], "succeeded");
assert_eq!(body["data"]["needRestart"], true);
}
fn app(state: AdminTestState) -> Router {
system::router::<AdminTestState>().with_state(state)
}
@@ -100,13 +146,32 @@ impl SystemOperations for ProgressSystem {
async fn perform_update(
&self,
_: Option<String>,
target: Option<String>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
Ok(SystemOperationAccepted::Update {
operation_id: "update-1".to_owned(),
deployment_mode: "docker".to_owned(),
message: "更新已开始".to_owned(),
target_version: target.expect("target"),
})
}
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
Err(unavailable_system())
Ok(SystemUpdateStatus {
previous_version: Some("0.1.0".to_owned()),
current_version: Some("0.2.0".to_owned()),
need_restart: true,
operation: SystemOperationState {
operation_id: Some("update-1".to_owned()),
kind: Some(SystemOperationKind::Update),
status: SystemOperationStatus::Succeeded,
target_version: Some("0.2.0".to_owned()),
message: Some("done".to_owned()),
error: None,
started_at: Some(Utc::now()),
finished_at: Some(Utc::now()),
},
})
}
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
@@ -81,6 +81,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/openai/search/http.rs",
"src/openai/search/mod.rs",
"src/openai/service.rs",
"src/openai/usage.rs",
"src/session_cookie.rs",
];
expected.sort_unstable();
@@ -132,6 +133,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/openai/responses/websocket/protocol.rs",
"tests/openai/router.rs",
"tests/openai/search/mod.rs",
"tests/openai/usage.rs",
];
expected.sort_unstable();
@@ -100,6 +100,7 @@ fn usage_record() -> UsageListRecord {
provider_account_ref: Some("private-sentinel".to_owned()),
provider_account_name: Some("private-sentinel".to_owned()),
provider_account_email: Some("private-sentinel".to_owned()),
provider_account_notes: Some("private-sentinel".to_owned()),
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
@@ -40,8 +40,8 @@ async fn login(app: &Router, mode: &str) -> String {
}
async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Response {
let path = if resource == "config" {
format!("/api/key-usage/config{suffix}")
let path = if matches!(resource, "config" | "version") {
format!("/api/key-usage/{resource}{suffix}")
} else {
format!("/api/key-usage/{resource}?{RANGE}{suffix}")
};
@@ -54,6 +54,33 @@ async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Respon
response
}
#[tokio::test]
async fn version_exposes_only_build_identifiers_for_key_sessions() {
let fixture = fixtures::fixture().await;
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
let response = get(&app, "version", "", &cookie).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response_json(response).await["data"],
json!({"version": "3.7.0", "gitSha": "internal-revision"})
);
assert_eq!(
get(&app, "version", "?keyId=other", &cookie).await.status(),
StatusCode::BAD_REQUEST
);
let response = app
.clone()
.oneshot(cookie_request(
Method::GET,
"/api/admin/system/version",
&cookie,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn config_reveals_only_the_session_keys_name_and_plaintext() {
let fixture = fixtures::fixture().await;
@@ -288,7 +315,7 @@ async fn missing_admin_and_revoked_sessions_cannot_read_key_usage() {
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let admin = login(&app, "admin").await;
let key = login(&app, "key").await;
for resource in ["overview", "records", "config"] {
for resource in ["overview", "records", "config", "version"] {
assert_eq!(
get(&app, resource, "", "").await.status(),
StatusCode::UNAUTHORIZED
@@ -427,6 +454,11 @@ async fn namespace_errors_remain_json_and_uncacheable() {
"/api/key-usage/config",
StatusCode::METHOD_NOT_ALLOWED,
),
(
Method::POST,
"/api/key-usage/version",
StatusCode::METHOD_NOT_ALLOWED,
),
] {
let response = app
.clone()
@@ -6,6 +6,7 @@ mod models;
mod responses;
mod router;
mod search;
mod usage;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
@@ -0,0 +1,189 @@
use axum::{
Router,
http::{Method, StatusCode, header},
response::Response,
};
use chrono::{Duration, Utc};
use gateway_core::{
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
policy::ClientApiKeyId,
};
use serde_json::json;
use tower::ServiceExt as _;
use crate::{
admin::AdminTestFixture,
support::{RAW_KEY, empty_request, key_fixture, response_json},
};
const KEY: &str = RAW_KEY;
async fn fixture() -> (AdminTestFixture, Router) {
let fixture = key_fixture().await;
let id = ClientApiKeyId::new("key-42").unwrap();
let mut key = fixture
.services
.client_keys()
.reveal(&id)
.await
.unwrap()
.record;
key.id = id;
key.label = Some("private-usage-sentinel".to_owned());
key.budget = ClientBudgetStatus {
limits: ClientBudgetLimits {
daily_usd: "1".parse().unwrap(),
weekly_usd: "5".parse().unwrap(),
},
daily_used_usd: "0.6400000001".parse().unwrap(),
weekly_used_usd: "2.35".parse().unwrap(),
daily_resets_at: Some((Utc::now() + Duration::days(1)).into()),
weekly_resets_at: Some((Utc::now() + Duration::days(7)).into()),
};
*fixture.client_key.lock().unwrap() = Some(key);
let app = super::api_router_with_admin(fixture.services.clone());
(fixture, app)
}
async fn query(app: &Router, path: &str, authorization: Option<&str>) -> Response {
let mut request = empty_request(Method::GET, path);
if let Some(value) = authorization {
request
.headers_mut()
.insert(header::AUTHORIZATION, value.parse().unwrap());
}
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
response
}
#[tokio::test]
async fn usage_returns_current_key_budget_without_exposing_private_data_or_writing_usage() {
let (fixture, app) = fixture().await;
let before = fixture.client_key.lock().unwrap().clone().unwrap();
let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(
body,
json!({
"unit": "USD",
"daily": {
"total": "1", "used": "0.6400000001", "remaining": "0.3599999999",
"resetsAt": before.budget.daily_resets_at.map(chrono::DateTime::<Utc>::from),
},
"weekly": {
"total": "5", "used": "2.35", "remaining": "2.65",
"resetsAt": before.budget.weekly_resets_at.map(chrono::DateTime::<Utc>::from),
},
})
);
assert!(!body.to_string().contains("private-usage-sentinel"));
assert_eq!(fixture.client_key.lock().unwrap().as_ref(), Some(&before));
assert!(fixture.observations.lock().unwrap().summaries.is_empty());
}
#[tokio::test]
async fn usage_remains_readable_when_exhausted_and_never_returns_negative_remaining() {
let (fixture, app) = fixture().await;
{
let mut key = fixture.client_key.lock().unwrap();
let budget = &mut key.as_mut().unwrap().budget;
budget.daily_used_usd = "1".parse().unwrap();
budget.weekly_used_usd = "5.75".parse().unwrap();
}
let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["daily"]["remaining"], "0");
assert_eq!(body["weekly"]["remaining"], "0");
assert_eq!(body["weekly"]["used"], "5.75");
}
#[tokio::test]
async fn usage_distinguishes_unlimited_and_unused_windows_from_exhausted_budgets() {
let (fixture, app) = fixture().await;
for (daily, weekly) in [("0", "0"), ("1", "0"), ("0", "5")] {
fixture.client_key.lock().unwrap().as_mut().unwrap().budget = ClientBudgetStatus {
limits: ClientBudgetLimits {
daily_usd: daily.parse().unwrap(),
weekly_usd: weekly.parse().unwrap(),
},
..Default::default()
};
let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
for (period, limit) in [("daily", daily), ("weekly", weekly)] {
assert_eq!(
body[period],
json!({
"total": (limit != "0").then_some(limit),
"used": "0",
"remaining": (limit != "0").then_some(limit),
"resetsAt": null,
})
);
}
}
}
#[tokio::test]
async fn usage_rejects_missing_invalid_disabled_deleted_and_other_keys() {
let (fixture, app) = fixture().await;
for authorization in [None, Some("Basic invalid"), Some("Bearer unknown-key")] {
let response = query(&app, "/v1/usage", authorization).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(response_json(response).await.get("error").is_some());
}
let original = fixture.client_key.lock().unwrap().clone().unwrap();
for mode in ["disabled", "deleted", "other"] {
{
let mut key = fixture.client_key.lock().unwrap();
*key = Some(original.clone());
match mode {
"disabled" => key.as_mut().unwrap().enabled = false,
"deleted" => *key = None,
_ => key.as_mut().unwrap().id = ClientApiKeyId::new("other-key").unwrap(),
}
}
let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{mode}");
}
}
#[tokio::test]
async fn usage_does_not_accept_sessions_admin_keys_or_caller_selected_scope() {
let (fixture, app) = fixture().await;
fixture.auth.insert_session("valid-admin");
for (name, value) in [
(header::COOKIE.as_str(), "cpr_session=valid-admin"),
("x-api-key", KEY),
] {
let mut request = empty_request(Method::GET, "/v1/usage");
request.headers_mut().insert(
axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
value.parse().unwrap(),
);
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
for suffix in [
"?keyId=other",
"?clientApiKeyRef=other",
"?startTime=2026-01-01",
"?api_key=test",
] {
let response = query(
&app,
&format!("/v1/usage{suffix}"),
Some(&format!("Bearer {KEY}")),
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
response_json(response).await["error"]["code"],
"invalid_usage_query"
);
}
}
@@ -39,6 +39,12 @@ impl Decimal {
self.0
}
/// 非负金额相减,超支时返回零。
#[must_use]
pub const fn saturating_sub(self, other: Self) -> Self {
Self(self.0.saturating_sub(other.0))
}
#[must_use]
pub fn checked_add(self, other: Self) -> Option<Self> {
self.0
@@ -10,6 +10,7 @@ mod swap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
@@ -36,7 +37,8 @@ use self::release::{
select_archive, validate_update_target, version_channel,
};
use self::state::{
OperationFileLock, UpdateTempDir, finish, operation_id, read_status, set_running,
OperationFileLock, UpdateOperation, UpdateTempDir, finish, operation_id, read_status,
recover_interrupted, set_running,
};
use self::swap::{replace_release_files, rollback_release};
@@ -212,12 +214,13 @@ impl SystemUpdateConfig {
}
/// gateway-admin 消费的真实进程/文件系统操作实现。
#[derive(Clone)]
pub struct ProcessSystemOperations {
cancellation: CancellationToken,
events: UpdateEvents,
config: SystemUpdateConfig,
operation_lock: AsyncMutex<()>,
release_cache: ReleaseCache,
events: Arc<UpdateEvents>,
config: Arc<SystemUpdateConfig>,
operation_lock: Arc<AsyncMutex<()>>,
release_cache: Arc<ReleaseCache>,
}
impl ProcessSystemOperations {
@@ -225,21 +228,23 @@ impl ProcessSystemOperations {
pub fn new(cancellation: CancellationToken, config: SystemUpdateConfig) -> Self {
Self {
cancellation,
events: UpdateEvents::default(),
config,
operation_lock: AsyncMutex::const_new(()),
release_cache: ReleaseCache::default(),
events: Arc::new(UpdateEvents::default()),
config: Arc::new(config),
operation_lock: Arc::new(AsyncMutex::new(())),
release_cache: Arc::new(ReleaseCache::default()),
}
}
async fn perform_update_inner(
fn start_update(
&self,
target_version: Option<String>,
) -> Result<SystemOperationAccepted, OperationError> {
let _operation = self
.operation_lock
.try_lock()
let operation_lock = Arc::clone(&self.operation_lock)
.try_lock_owned()
.map_err(|_| conflict("system operation is already running"))?;
if self.cancellation.is_cancelled() {
return Err(conflict("服务正在关闭"));
}
if let Some(reason) = self.config.update_support_error() {
self.events
.error_terminal(None, Some("preflight"), reason.clone());
@@ -251,89 +256,89 @@ impl ProcessSystemOperations {
.error_terminal(None, Some("preflight"), error.to_string());
return Err(error);
}
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
let status = read_status(&self.config.update_state_file, &self.config.version)?;
if status.need_restart {
return Err(conflict("更新已完成,请先重启服务"));
}
let operation_id = operation_id("update");
let mut operation = UpdateOperation::start(
&self.config.update_state_file,
&operation_id,
&target,
&self.config.version,
file_lock,
)?;
let accepted = SystemOperationAccepted::Update {
operation_id: operation_id.clone(),
deployment_mode: self.config.deployment_mode.clone(),
message: "更新已开始".to_owned(),
target_version: target.clone(),
};
let service = self.clone();
// 任务持有互斥锁和落盘守卫,HTTP 断开不会取消更新;Host 关闭仍会收敛终态。
drop(tokio::spawn(async move {
let _operation_lock = operation_lock;
let result = tokio::select! {
biased;
() = service.cancellation.cancelled() => Err(conflict("服务关闭,更新已中断")),
result = service.perform_update_inner(&target, &operation_id) => result,
};
if let Err(error) = operation.complete(&result) {
tracing::warn!(error = %error, "系统更新终态落盘失败");
return;
}
match result {
Ok(()) => service.events.success_terminal(
Some(&operation_id),
Some("done"),
"更新文件已替换,等待服务重启生效",
),
Err(error) => service.events.error_terminal(
Some(&operation_id),
Some("failed"),
error.to_string(),
),
}
}));
Ok(accepted)
}
async fn perform_update_inner(
&self,
target: &str,
operation_id: &str,
) -> Result<(), OperationError> {
let repository = self
.config
.update_repository
.as_deref()
.ok_or_else(|| conflict("update repository is not configured"))?;
self.events
.info(None, Some("release"), "正在获取最新 Release 信息");
let release = match fetch_latest(
self.events.info(
Some(operation_id),
Some("release"),
"正在获取最新 Release 信息",
);
let release = fetch_latest(
&self.config.github_api_base,
repository,
&self.config.version,
)
.await
{
Ok(Some(release)) => release,
Ok(None) => {
let reason = "当前发行通道没有可用更新";
self.events.warning_terminal(None, Some("release"), reason);
return Err(conflict(reason));
}
Err(error) => {
self.events
.error_terminal(None, Some("release"), error.to_string());
return Err(error);
}
};
.await?
.ok_or_else(|| conflict("当前发行通道没有可用更新"))?;
let detail = detail_from_release(&self.config, &release);
if detail.latest_version != target {
self.events.warning_terminal(
None,
Some("release"),
"remote latest version changed; confirm again",
);
return Err(conflict("remote latest version changed"));
return Err(conflict("远端最新版本已变化,请重新确认"));
}
if !detail.has_update {
self.events
.warning_terminal(None, Some("release"), "already up to date");
return Err(conflict("already up to date"));
return Err(conflict("当前没有可用更新"));
}
let operation_id = operation_id("update");
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
set_running(
&self.config.update_state_file,
&operation_id,
SystemOperationKind::Update,
Some(&target),
&self.config.version,
)?;
self.events.info(
Some(&operation_id),
Some(operation_id),
Some("prepare"),
format!("准备更新到 v{target}"),
);
let result = self.install_release(&release, &target, &operation_id).await;
match &result {
Ok(()) => self.events.success_terminal(
Some(&operation_id),
Some("done"),
"更新文件已替换,等待服务重启生效",
),
Err(error) => {
self.events
.error_terminal(Some(&operation_id), Some("failed"), error.to_string())
}
}
finish(
&self.config.update_state_file,
&operation_id,
SystemOperationKind::Update,
result.as_ref().ok().map(|()| target.clone()),
result.as_ref().err().map(ToString::to_string),
);
drop(file_lock);
result?;
Ok(SystemOperationAccepted::Update {
operation_id,
deployment_mode: self.config.deployment_mode.clone(),
message: "更新完成,请重启服务。".to_owned(),
need_restart: true,
target_version: target,
})
self.install_release(&release, target, operation_id).await
}
async fn install_release(
@@ -474,11 +479,17 @@ impl SystemOperations for ProcessSystemOperations {
&self,
target_version: Option<String>,
) -> Result<SystemOperationAccepted, OperationError> {
self.perform_update_inner(target_version).await
self.start_update(target_version)
}
async fn update_status(&self) -> Result<SystemUpdateStatus, OperationError> {
read_status(&self.config.update_state_file)
if let Ok(_operation) = self.operation_lock.try_lock() {
recover_interrupted(
&self.config.update_state_file,
&self.config.update_lock_file,
)?;
}
read_status(&self.config.update_state_file, &self.config.version)
}
async fn rollback(&self) -> Result<SystemOperationAccepted, OperationError> {
@@ -499,6 +510,13 @@ impl SystemOperations for ProcessSystemOperations {
&self.config.version,
)?;
let result = rollback_release(&self.config);
finish(
&self.config.update_state_file,
&operation_id,
SystemOperationKind::Rollback,
None,
result.as_ref().err().map(ToString::to_string),
)?;
match &result {
Ok(()) => self.events.success_terminal(
Some(&operation_id),
@@ -510,13 +528,6 @@ impl SystemOperations for ProcessSystemOperations {
.error_terminal(Some(&operation_id), Some("failed"), error.to_string())
}
}
finish(
&self.config.update_state_file,
&operation_id,
SystemOperationKind::Rollback,
None,
result.as_ref().err().map(ToString::to_string),
);
drop(file_lock);
result?;
Ok(SystemOperationAccepted::Rollback {
@@ -617,22 +628,6 @@ impl UpdateEvents {
);
}
fn warning_terminal(
&self,
operation_id: Option<&str>,
step: Option<&str>,
message: impl Into<String>,
) {
self.emit(
SystemUpdateEventLevel::Warning,
operation_id,
step,
message,
None,
true,
);
}
fn success_terminal(
&self,
operation_id: Option<&str>,
@@ -115,6 +115,7 @@ impl ReleaseCache {
}
}
/// 优先返回可升级的最高版本,没有候选时保留当前版本的发布信息。
pub(crate) async fn fetch_latest(
api_base: &str,
repository: &str,
@@ -166,7 +167,7 @@ fn eligible_release_version(
let version = semver::Version::parse(&normalize_version(&release.tag_name)).ok()?;
(!release.draft
&& release.prerelease != version.pre.is_empty()
&& update_target_allowed(current, &version))
&& (version == *current || update_target_allowed(current, &version)))
.then_some(version)
}
@@ -198,15 +199,17 @@ pub(crate) fn detail_from_release(
release: &GitHubRelease,
) -> SystemUpdateDetail {
let unsupported_reason = config.update_support_error();
let latest_version = normalize_version(&release.tag_name);
let has_update = validate_update_target(&config.version, &latest_version).is_ok();
if unsupported_reason.is_some()
|| validate_update_target(&config.version, &release.tag_name).is_err()
|| (!has_update && latest_version != normalize_version(&config.version))
{
return super::base_update_detail(config, unsupported_reason, None);
}
SystemUpdateDetail {
current_version: config.version.clone(),
latest_version: normalize_version(&release.tag_name),
has_update: true,
latest_version,
has_update,
deployment_mode: config.deployment_mode.clone(),
build_type: config.build_type.clone(),
release_url: release.html_url.clone(),
@@ -3,6 +3,7 @@
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use chrono::{DateTime, Utc};
@@ -154,8 +155,101 @@ impl Drop for UpdateTempDir {
}
}
/// 更新任务拥有持久化终态的责任,Future 在取消或 panic 时析构也必须收尾。
pub(crate) struct UpdateOperation {
path: PathBuf,
operation_id: String,
target: String,
completed: bool,
_file_lock: OperationFileLock,
}
impl UpdateOperation {
pub(crate) fn start(
path: &Path,
operation_id: &str,
target: &str,
current_version: &str,
file_lock: OperationFileLock,
) -> Result<Self, OperationError> {
set_running(
path,
operation_id,
SystemOperationKind::Update,
Some(target),
current_version,
)?;
Ok(Self {
path: path.to_owned(),
operation_id: operation_id.to_owned(),
target: target.to_owned(),
completed: false,
_file_lock: file_lock,
})
}
pub(crate) fn complete(
&mut self,
result: &Result<(), OperationError>,
) -> Result<(), OperationError> {
finish(
&self.path,
&self.operation_id,
SystemOperationKind::Update,
result.as_ref().ok().map(|()| self.target.clone()),
result.as_ref().err().map(ToString::to_string),
)?;
self.completed = true;
Ok(())
}
}
impl Drop for UpdateOperation {
fn drop(&mut self) {
if !self.completed
&& let Err(error) = finish(
&self.path,
&self.operation_id,
SystemOperationKind::Update,
None,
Some("更新任务已中断,请重新发起更新".to_owned()),
)
{
tracing::warn!(error = %error, "收敛中断的系统更新状态失败");
}
}
}
/// 只在拿到进程内锁后调用,旧版本断连遗留且已无执行者的 running 不能永久保留。
pub(crate) fn recover_interrupted(path: &Path, lock_path: &Path) -> Result<(), OperationError> {
let state = read_persisted(path)?;
if !matches!(state.operation.status, PersistedStatus::Running) {
return Ok(());
}
let _lock = match OperationFileLock::acquire(lock_path) {
Ok(lock) => lock,
Err(error) if error.kind() == super::SystemOperationErrorKind::Conflict => return Ok(()),
Err(error) => return Err(error),
};
if let (Some(operation_id), Some(kind)) = (state.operation.operation_id, state.operation.kind) {
finish(
path,
&operation_id,
kind.into(),
None,
Some("更新任务已中断,请重新发起更新".to_owned()),
)?;
}
Ok(())
}
pub(crate) fn operation_id(kind: &str) -> String {
format!("sysop-{kind}-{}", Utc::now().timestamp_millis())
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
format!(
"sysop-{kind}-{}-{}",
Utc::now().timestamp_millis(),
SEQUENCE.fetch_add(1, Ordering::Relaxed)
)
}
pub(crate) fn set_running(
@@ -188,16 +282,10 @@ pub(crate) fn finish(
kind: SystemOperationKind,
version: Option<String>,
error: Option<String>,
) {
let mut state = match read_persisted(path) {
Ok(state) => state,
Err(error) => {
tracing::warn!(error = %error, "读取系统更新状态失败");
return;
}
};
) -> Result<(), OperationError> {
let mut state = read_persisted(path)?;
if state.operation.operation_id.as_deref() != Some(operation_id) {
return;
return Ok(());
}
if let Some(error) = error {
state.operation.status = PersistedStatus::Failed;
@@ -222,15 +310,20 @@ pub(crate) fn finish(
state.operation.target_version = version;
}
state.operation.finished_at = Some(Utc::now().to_rfc3339());
if let Err(error) = write_persisted(path, &state) {
tracing::warn!(error = %error, "写入系统更新状态失败");
}
write_persisted(path, &state)
}
pub(crate) fn read_status(path: &Path) -> Result<SystemUpdateStatus, OperationError> {
pub(crate) fn read_status(
path: &Path,
running_version: &str,
) -> Result<SystemUpdateStatus, OperationError> {
let state = read_persisted(path)?;
let operation = state.operation;
Ok(SystemUpdateStatus {
need_restart: matches!(operation.status, PersistedStatus::Succeeded)
&& state.current_version.as_deref().is_some_and(|version| {
version.trim_start_matches('v') != running_version.trim_start_matches('v')
}),
previous_version: state.previous_version,
current_version: state.current_version,
operation: SystemOperationState {
@@ -7,7 +7,8 @@ use filetime::FileTime;
use flate2::{Compression, write::GzEncoder};
use futures::StreamExt as _;
use gateway_admin::model::system::{
SystemOperationKind, SystemOperationStatus, SystemUpdateEventLevel,
SystemOperationAccepted, SystemOperationKind, SystemOperationStatus, SystemUpdateEventLevel,
SystemUpdateStatus,
};
use gateway_admin::ports::system::{SystemOperationErrorKind, SystemOperations};
use gateway_core::lifecycle::CancellationToken;
@@ -90,7 +91,7 @@ async fn restart_should_conflict_while_another_system_operation_is_running() {
.expect("release listener");
let api_base = format!("http://{}/repos", listener.local_addr().expect("addr"));
let (connected_tx, connected_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let upstream = tokio::spawn(async move {
let connection = listener.accept().await;
let _ = connected_tx.send(());
tokio::time::sleep(Duration::from_secs(30)).await;
@@ -118,8 +119,13 @@ async fn restart_should_conflict_while_another_system_operation_is_running() {
assert_eq!(error.kind(), SystemOperationErrorKind::Conflict);
assert!(!shutdown.is_cancelled());
update.abort();
let _ = update.await;
update.await.expect("request").expect("accepted");
assert!(fixture.lock().exists());
upstream.abort();
assert_eq!(
wait_for_update(&service).await.operation.status,
SystemOperationStatus::Failed
);
service.restart().await.expect("restart after lock release");
}
@@ -136,10 +142,13 @@ async fn rollback_should_restore_binary_web_and_version_state() {
)
.await;
let service = fixture.service(&server);
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect("update");
assert_eq!(
complete_update(&service, TARGET_VERSION)
.await
.operation
.status,
SystemOperationStatus::Succeeded
);
service.rollback().await.expect("rollback");
assert_eq!(
@@ -353,10 +362,13 @@ async fn experimental_update_should_install_same_channel_release() {
config.build_type = "experimental".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
service
.perform_update(Some("3.10.0-exp.3".to_owned()))
.await
.expect("same-channel update");
assert_eq!(
complete_update(&service, "3.10.0-exp.3")
.await
.operation
.status,
SystemOperationStatus::Succeeded
);
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"new-binary"
@@ -468,6 +480,164 @@ async fn update_checks_should_follow_stage_promotion_and_experimental_isolation(
}
}
#[tokio::test]
async fn update_detail_should_keep_current_release_notes_without_allowing_reinstallation() {
for current in [
"3.12.1",
"3.12.1-alpha.1",
"3.12.1-beta.1",
"3.12.1-rc.1",
"3.12.1-exp.1",
"3.12.1+build.1",
] {
let server = MockServer::start().await;
let fixture = Fixture::new();
let release_url = format!("https://github.com/owner/repository/releases/tag/v{current}");
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"tag_name": format!("v{current}"),
"prerelease": !semver::Version::parse(current).expect("version").pre.is_empty(),
"body": "## 当前版本\n\n- 修复更新状态",
"html_url": release_url,
}
])))
.expect(1)
.mount(&server)
.await;
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = current.to_owned();
if current.contains("-exp.") {
config.build_type = "experimental".to_owned();
}
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("current release");
assert_eq!(detail.latest_version, current);
assert!(!detail.has_update);
assert!(detail.update_supported);
assert_eq!(
detail.notes.as_deref(),
Some("## 当前版本\n\n- 修复更新状态")
);
assert_eq!(detail.release_url.as_deref(), Some(release_url.as_str()));
let cached = service.update_detail(false).await.expect("cached release");
assert!(cached.cached);
assert_eq!(cached.notes, detail.notes);
assert_eq!(cached.release_url, detail.release_url);
assert!(!service.version().await.expect("version").has_update);
let error = service
.perform_update(Some(current.to_owned()))
.await
.expect_err("current release must not be installed again");
assert_eq!(error.kind(), SystemOperationErrorKind::Conflict);
assert!(!fixture.state().exists());
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"old-binary"
);
}
}
#[tokio::test]
async fn update_detail_should_prefer_available_update_notes_over_current_release() {
for current_first in [true, false] {
let server = MockServer::start().await;
let fixture = Fixture::new();
let mut releases = vec![
serde_json::json!({
"tag_name": "v1.0.0", "prerelease": false, "body": "current notes"
}),
serde_json::json!({
"tag_name": "v1.9.9", "prerelease": false, "body": "update notes",
"html_url": "https://github.com/owner/repository/releases/tag/v1.9.9"
}),
];
if !current_first {
releases.reverse();
}
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(releases))
.mount(&server)
.await;
let detail = fixture
.service(&server)
.update_detail(true)
.await
.expect("detail");
assert!(detail.has_update);
assert_eq!(detail.latest_version, TARGET_VERSION);
assert_eq!(detail.notes.as_deref(), Some("update notes"));
assert_eq!(
detail.release_url.as_deref(),
Some("https://github.com/owner/repository/releases/tag/v1.9.9")
);
}
}
#[tokio::test]
async fn update_detail_should_find_current_release_notes_across_pages() {
let server = MockServer::start().await;
let fixture = Fixture::new();
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.and(query_param("page", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(vec![
serde_json::json!({ "tag_name": "v2.0.0", "prerelease": false });
100
]))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.and(query_param("page", "2"))
.respond_with(release_response("1.0.0", Vec::new()))
.expect(1)
.mount(&server)
.await;
let detail = fixture
.service(&server)
.update_detail(true)
.await
.expect("detail");
assert!(!detail.has_update);
assert_eq!(detail.latest_version, "1.0.0");
assert_eq!(detail.notes.as_deref(), Some("notes"));
assert!(detail.release_url.is_some());
}
#[tokio::test]
async fn update_detail_should_not_use_draft_or_mislabeled_current_release_notes() {
for (current, prerelease) in [("1.0.0", false), ("1.0.0-beta.1", true)] {
let server = MockServer::start().await;
let fixture = Fixture::new();
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{ "tag_name": format!("v{current}"), "prerelease": prerelease, "draft": true, "body": "draft notes" },
{ "tag_name": format!("v{current}"), "prerelease": !prerelease, "body": "mislabeled notes" },
{ "tag_name": "v0.9.0", "prerelease": false, "body": "older notes" },
])))
.mount(&server)
.await;
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = current.to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
assert!(!detail.has_update);
assert_eq!(detail.latest_version, current);
assert!(detail.notes.is_none());
assert!(detail.release_url.is_none());
}
}
#[tokio::test]
async fn update_checks_should_ignore_other_cycles_downgrades_and_build_metadata() {
for (current, target) in [
@@ -475,7 +645,6 @@ async fn update_checks_should_ignore_other_cycles_downgrades_and_build_metadata(
("3.12.0-beta.1", "3.12.1-rc.1"),
("3.12.0-rc.1", "3.13.0"),
("3.12.0-beta.2", "3.12.0-beta.1"),
("3.12.0-exp.2", "3.12.0-exp.2"),
("3.12.0", "3.12.0+new-build"),
("3.12.0-beta.1+aaa", "3.12.0-beta.1+zzz"),
("3.12.0", "3.11.0"),
@@ -576,10 +745,10 @@ async fn prerelease_updates_should_install_later_stages_and_stable_release() {
.expect("detail")
.has_update
);
service
.perform_update(Some(target.to_owned()))
.await
.expect("promoted update");
assert_eq!(
complete_update(&service, target).await.operation.status,
SystemOperationStatus::Succeeded
);
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"new-binary"
@@ -694,10 +863,13 @@ async fn update_events_should_preserve_complete_release_stage_sequence() {
.await;
let service = fixture.service(&server);
let events = service.update_events();
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect("update");
assert_eq!(
complete_update(&service, TARGET_VERSION)
.await
.operation
.status,
SystemOperationStatus::Succeeded
);
let steps = events
.map(|event| event.step.expect("release update step"))
.collect::<Vec<_>>()
@@ -726,10 +898,13 @@ async fn update_events_should_report_download_bytes_and_percent() {
.await;
let service = fixture.service(&server);
let events = service.update_events();
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect("update");
assert_eq!(
complete_update(&service, TARGET_VERSION)
.await
.operation
.status,
SystemOperationStatus::Succeeded
);
let progress = events
.filter_map(
|event| async move { event.progress_percent.map(|value| (value, event.message)) },
@@ -755,12 +930,12 @@ async fn update_should_fail_when_release_checksum_is_missing() {
)
.await;
assert!(
fixture
.service(&server)
.perform_update(Some(TARGET_VERSION.to_owned()))
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
.await
.is_err()
.operation
.status,
SystemOperationStatus::Failed,
);
}
@@ -777,12 +952,12 @@ async fn update_should_fail_when_release_checksum_mismatches() {
)
.await;
assert!(
fixture
.service(&server)
.perform_update(Some(TARGET_VERSION.to_owned()))
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
.await
.is_err()
.operation
.status,
SystemOperationStatus::Failed,
);
}
@@ -794,12 +969,12 @@ async fn update_should_reject_insecure_release_archive() {
.mount_custom_release(&server, "http://github.com/archive", None)
.await;
assert!(
fixture
.service(&server)
.perform_update(Some(TARGET_VERSION.to_owned()))
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
.await
.is_err()
.operation
.status,
SystemOperationStatus::Failed,
);
}
@@ -811,12 +986,12 @@ async fn update_should_reject_release_archive_from_untrusted_host() {
.mount_custom_release(&server, "https://evil.example/archive", None)
.await;
assert!(
fixture
.service(&server)
.perform_update(Some(TARGET_VERSION.to_owned()))
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
.await
.is_err()
.operation
.status,
SystemOperationStatus::Failed,
);
}
@@ -844,12 +1019,12 @@ async fn update_should_reject_release_archive_with_unsafe_path() {
)
.await;
assert!(
fixture
.service(&server)
.perform_update(Some(TARGET_VERSION.to_owned()))
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
.await
.is_err()
.operation
.status,
SystemOperationStatus::Failed,
);
}
@@ -876,12 +1051,12 @@ async fn update_should_reject_when_confirmed_target_differs_from_remote_latest()
.mount_release(&server, "1.9.8", ArchiveKind::Safe, ChecksumKind::Valid)
.await;
assert!(
fixture
.service(&server)
.perform_update(Some(TARGET_VERSION.to_owned()))
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
.await
.is_err()
.operation
.status,
SystemOperationStatus::Failed,
);
}
@@ -900,12 +1075,12 @@ async fn update_should_remove_stale_file_lock_and_continue() {
fs::write(fixture.lock(), "stale").expect("lock");
filetime::set_file_mtime(fixture.lock(), FileTime::from_unix_time(1, 0)).expect("old mtime");
assert!(
fixture
.service(&server)
.perform_update(Some(TARGET_VERSION.to_owned()))
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
.await
.is_ok()
.operation
.status,
SystemOperationStatus::Succeeded,
);
}
@@ -935,10 +1110,13 @@ async fn update_and_rollback_should_use_the_default_api_asset_directory() {
host.resolve_and_validate(&fixture.root.path().join("deploy"), &fixture.web())
.expect("inherit API asset directory");
let service = ProcessSystemOperations::new(CancellationToken::new(), host.system_update);
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect("update");
assert_eq!(
complete_update(&service, TARGET_VERSION)
.await
.operation
.status,
SystemOperationStatus::Succeeded
);
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
@@ -981,10 +1159,13 @@ async fn update_should_replace_web_assets_across_filesystems() {
fs::create_dir_all(&web_dist).expect("web dir");
fs::write(web_dist.join("index.html"), "old-web").expect("web");
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect("update");
assert_eq!(
complete_update(&service, TARGET_VERSION)
.await
.operation
.status,
SystemOperationStatus::Succeeded
);
assert_eq!(
fs::read(web_dist.join("index.html")).expect("web"),
@@ -1006,12 +1187,12 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() {
.await;
fs::create_dir(fixture.executable().with_extension("backup")).expect("blocking backup dir");
assert!(
fixture
.service(&server)
.perform_update(Some(TARGET_VERSION.to_owned()))
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
.await
.is_err()
.operation
.status,
SystemOperationStatus::Failed,
);
assert_eq!(
fs::read(fixture.web().join("index.html")).expect("web"),
@@ -1059,6 +1240,262 @@ async fn version_should_return_backend_build_metadata() {
);
}
#[tokio::test]
async fn accepted_update_should_survive_a_lost_http_response() {
let upstream = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&upstream,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
Mock::given(method("GET"))
.and(path("/archive"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(release_archive(ArchiveKind::Safe))
.set_delay(Duration::from_secs(2)),
)
.with_priority(1)
.mount(&upstream)
.await;
let service = Arc::new(fixture.service(&upstream));
let handler_service = Arc::clone(&service);
let accepted = Arc::new(tokio::sync::Notify::new());
let handler_accepted = Arc::clone(&accepted);
let router = axum::Router::new().route(
"/update",
axum::routing::post(move || {
let service = Arc::clone(&handler_service);
let accepted = Arc::clone(&handler_accepted);
async move {
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect("accepted");
accepted.notify_one();
// 模拟受理响应丢失,客户端断开会取消此 HTTP handler。
std::future::pending::<String>().await
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener");
let address = listener.local_addr().expect("address");
let server = tokio::spawn(async move {
axum::serve(listener, router).await.expect("serve");
});
let client = tokio::spawn(async move {
reqwest::Client::new()
.post(format!("http://{address}/update"))
.send()
.await
});
tokio::time::timeout(Duration::from_secs(1), accepted.notified())
.await
.expect("accepted before slow download finishes");
client.abort();
let _ = client.await;
assert_eq!(
service
.update_status()
.await
.expect("running status")
.operation
.status,
SystemOperationStatus::Running
);
assert_eq!(
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect_err("duplicate update")
.kind(),
SystemOperationErrorKind::Conflict
);
assert_eq!(
service
.rollback()
.await
.expect_err("rollback during update")
.kind(),
SystemOperationErrorKind::Conflict
);
let status = wait_for_update(&service).await;
assert_eq!(status.operation.status, SystemOperationStatus::Succeeded);
assert!(status.need_restart);
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"new-binary"
);
assert!(!fixture.lock().exists());
assert_eq!(
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect_err("restart required")
.kind(),
SystemOperationErrorKind::Conflict
);
let mut restarted_config = fixture.config(&format!("{}/repos", upstream.uri()));
restarted_config.version = TARGET_VERSION.to_owned();
let restarted = ProcessSystemOperations::new(CancellationToken::new(), restarted_config);
assert!(
!restarted
.update_status()
.await
.expect("restarted status")
.need_restart
);
server.abort();
}
#[tokio::test]
async fn host_shutdown_should_finish_an_accepted_update_and_release_its_lock() {
let upstream = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&upstream,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let cancellation = CancellationToken::new();
let service = ProcessSystemOperations::new(
cancellation.clone(),
fixture.config(&format!("{}/repos", upstream.uri())),
);
// 在后台任务第一次 poll 之前触发关闭,覆盖受理后的生命周期交接。
service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect("accepted");
cancellation.cancel();
let status = wait_for_update(&service).await;
assert_eq!(status.operation.status, SystemOperationStatus::Failed);
assert!(status.operation.error.expect("reason").contains("中断"));
assert!(!fixture.lock().exists());
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"old-binary"
);
}
#[test]
fn dropped_update_task_should_persist_failure_even_before_its_first_poll() {
let fixture = Fixture::new();
let service = fixture.service_for_url("http://127.0.0.1:1/repos");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
runtime
.block_on(service.perform_update(Some(TARGET_VERSION.to_owned())))
.expect("accepted");
drop(runtime);
let status: serde_json::Value =
serde_json::from_slice(&fs::read(fixture.state()).expect("state")).expect("JSON");
assert_eq!(status["operation"]["status"], "failed");
assert!(status["operation"]["finishedAt"].is_string());
assert!(!fixture.lock().exists());
}
#[tokio::test]
async fn status_should_recover_abandoned_running_state_without_stealing_a_live_lock() {
let fixture = Fixture::new();
fs::write(fixture.state(), r#"{"currentVersion":"1.0.0","operation":{"operationId":"abandoned","kind":"update","status":"running","targetVersion":"1.9.9"}}"#).expect("state");
fs::write(fixture.lock(), "another process").expect("live lock");
let service = fixture.service_for_url("http://127.0.0.1:1/repos");
assert_eq!(
service
.update_status()
.await
.expect("locked status")
.operation
.status,
SystemOperationStatus::Running
);
fs::remove_file(fixture.lock()).expect("release abandoned lock");
let status = service.update_status().await.expect("recovered status");
assert_eq!(status.operation.status, SystemOperationStatus::Failed);
assert!(status.operation.finished_at.is_some());
assert_eq!(status.operation.operation_id.as_deref(), Some("abandoned"));
}
#[tokio::test]
async fn terminal_event_should_only_be_visible_after_status_is_persisted() {
let upstream = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&upstream,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Mismatch,
)
.await;
let service = fixture.service(&upstream);
let mut events = service.update_events();
let SystemOperationAccepted::Update { operation_id, .. } = service
.perform_update(Some(TARGET_VERSION.to_owned()))
.await
.expect("accepted")
else {
panic!("update")
};
tokio::time::timeout(Duration::from_secs(5), async {
while let Some(event) = events.next().await {
assert_eq!(event.operation_id.as_deref(), Some(operation_id.as_str()));
if event.terminal {
let status = service.update_status().await.expect("terminal status");
assert_eq!(status.operation.status, SystemOperationStatus::Failed);
assert!(status.operation.finished_at.is_some());
return;
}
}
panic!("missing terminal event");
})
.await
.expect("terminal event");
}
async fn wait_for_update(service: &ProcessSystemOperations) -> SystemUpdateStatus {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let status = service.update_status().await.expect("update status");
if status.operation.status != SystemOperationStatus::Running {
assert!(status.operation.finished_at.is_some(), "terminal timestamp");
return status;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("update reaches terminal state")
}
async fn complete_update(service: &ProcessSystemOperations, target: &str) -> SystemUpdateStatus {
let SystemOperationAccepted::Update { operation_id, .. } = service
.perform_update(Some(target.to_owned()))
.await
.expect("update accepted")
else {
panic!("expected update operation")
};
let status = wait_for_update(service).await;
assert_eq!(
status.operation.operation_id.as_deref(),
Some(operation_id.as_str())
);
status
}
struct Fixture {
root: tempfile::TempDir,
}
@@ -1118,10 +1555,11 @@ impl Fixture {
}
fn service(&self, server: &MockServer) -> ProcessSystemOperations {
ProcessSystemOperations::new(
CancellationToken::new(),
self.config(&format!("{}/repos", server.uri())),
)
self.service_for_url(&format!("{}/repos", server.uri()))
}
fn service_for_url(&self, api_base: &str) -> ProcessSystemOperations {
ProcessSystemOperations::new(CancellationToken::new(), self.config(api_base))
}
async fn mount_release(
@@ -458,6 +458,7 @@ pub(crate) fn admin_usage_list_record(
provider_account_ref: record.provider_account_ref,
provider_account_name: record.provider_account_name,
provider_account_email: record.provider_account_email,
provider_account_notes: record.provider_account_notes,
provider_account_authentication_kind: record.provider_account_authentication_kind,
upstream_model_id: record.upstream_model_id,
upstream_transport: record.upstream_transport,
@@ -789,6 +790,7 @@ pub(crate) fn usage_list_record_from_row(
provider_account_ref: get(row, "provider_account_ref")?,
provider_account_name: get(row, "provider_account_name")?,
provider_account_email: get(row, "provider_account_email")?,
provider_account_notes: get(row, "provider_account_notes")?,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
@@ -490,6 +490,7 @@ pub struct UsageListRecord {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_notes: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -128,6 +128,7 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
mr.provider_kind, mr.provider_account_ref,
mr.provider_account_name_snapshot as provider_account_name,
mr.provider_account_email_snapshot as provider_account_email,
account.notes as provider_account_notes,
mr.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
mr.upstream_model_id, mr.upstream_transport, mr.upstream_response_model, mr.service_tier,
@@ -142,7 +143,8 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
mr.reasoning_effort, mr.reasoning_preset, mr.subagent_kind, mr.compact,
mr.started_at
from model_requests mr
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref";
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
left join provider_accounts account on account.id = mr.provider_account_ref";
pub(crate) const USAGE_RECORD_DETAIL_SELECT: &str =
"select mr.id, mr.client_api_key_ref, mr.config_revision,
@@ -159,6 +159,124 @@ async fn usage_page_should_always_return_total() {
database.close().await;
}
#[tokio::test]
async fn usage_list_should_resolve_current_notes_by_account_id() {
let Some(database) = TestDatabase::create("usage_account_notes").await else {
return;
};
let now = Utc::now();
seed_observability_facts(&database.pool, now)
.await
.expect("seed observability facts");
sqlx::query(
"insert into provider_accounts (
id, provider_kind, name, email, notes, upstream_user_id,
upstream_account_id, plan_type, authentication_kind,
provider_credentials_json, credential_revision, credential_observed_at,
has_refresh_token, created_at, updated_at
) select 'acct_team', provider_kind, name, email, '团队工作区', upstream_user_id,
'team-workspace', 'team', authentication_kind,
provider_credentials_json, credential_revision, credential_observed_at,
has_refresh_token, created_at, updated_at
from provider_accounts where id = 'acct_observe'",
)
.execute(&database.pool)
.await
.expect("seed same-email team account");
sqlx::query(
"update model_requests
set provider_account_id = 'acct_team', provider_account_ref = 'acct_team',
downstream_committed_at = completed_at
where id = 'req_observe_uncommitted'",
)
.execute(&database.pool)
.await
.expect("assign completed request to team account");
let range =
admin_observability::TimeRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1))
.expect("admin observability range");
let store = admin_observability_store(&database.pool);
for notes in [None, Some("个人主号"), Some("个人备用号"), None] {
sqlx::query("update provider_accounts set notes = $1 where id = 'acct_observe'")
.bind(notes)
.execute(&database.pool)
.await
.expect("update current account notes");
let page = store
.list_usage_records(admin_observability::UsageQuery {
range,
filter: admin_observability::UsageFilter::default(),
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
})
.await
.expect("usage list with current notes");
assert_eq!(page.total, 2);
assert_eq!(page.items.len(), 2);
let personal = page
.items
.iter()
.find(|record| record.id == "req_observe_success")
.expect("personal request");
let team = page
.items
.iter()
.find(|record| record.id == "req_observe_uncommitted")
.expect("team request");
assert_eq!(
personal.provider_account_ref.as_deref(),
Some("acct_observe")
);
assert_eq!(personal.provider_account_email, team.provider_account_email);
assert_eq!(personal.provider_account_notes.as_deref(), notes);
assert_eq!(team.provider_account_notes.as_deref(), Some("团队工作区"));
}
let dashboard = store
.dashboard_summary(range, now)
.await
.expect("dashboard with current account notes");
assert_eq!(
dashboard
.recent_requests
.iter()
.find(|record| record.provider_account_ref.as_deref() == Some("acct_team"))
.expect("team dashboard request")
.provider_account_notes
.as_deref(),
Some("团队工作区")
);
sqlx::query("delete from provider_accounts where id = 'acct_team'")
.execute(&database.pool)
.await
.expect("delete team account");
let page = store
.list_usage_records(admin_observability::UsageQuery {
range,
filter: admin_observability::UsageFilter {
provider_account_ref: Some("acct_team".to_owned()),
..admin_observability::UsageFilter::default()
},
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
})
.await
.expect("deleted account history");
assert_eq!(page.total, 1);
assert_eq!(
page.items[0].provider_account_ref.as_deref(),
Some("acct_team")
);
assert_eq!(
page.items[0].provider_account_email.as_deref(),
Some("account@example.invalid")
);
assert_eq!(page.items[0].provider_account_notes, None);
database.close().await;
}
#[tokio::test]
async fn usage_search_should_match_literal_prefix_instead_of_substring() {
let Some(database) = TestDatabase::create("usage_literal_prefix_search").await else {
@@ -1519,7 +1519,7 @@ async fn invalid_account_notes_roll_back_scheduling_revision_and_audit() {
}
#[tokio::test]
async fn account_recovery_resets_status_facts_without_changing_credentials_or_scheduling() {
async fn account_enable_preserves_facts_and_explicit_recovery_clears_them() {
const GROUP_ID: &str = "grp_00000000000000000000000000000070";
let Some(database) = TestDatabase::create("provider_account_recovery").await else {
return;
@@ -1594,6 +1594,57 @@ async fn account_recovery_resets_status_facts_without_changing_credentials_or_sc
.await
.expect("load account before recovery");
let store = admin_account_store(&database.pool);
let facts_query = "select to_jsonb(a) - 'enabled' - 'updated_at'
from provider_accounts a where id = 'acct_recovery'";
let before_enable: serde_json::Value = sqlx::query_scalar(facts_query)
.fetch_one(&database.pool)
.await
.expect("load disabled account facts");
let enabled = store
.batch_update_accounts(
BatchUpdateAccounts {
account_ids: vec!["acct_recovery".to_owned()],
enabled: Some(true),
concurrency_limit: None,
weight: None,
model_access: None,
group_ids: None,
outbound_proxy: None,
},
&MutationContext {
actor: MutationActor::System,
request_id: "request_account_enable".to_owned(),
},
)
.await
.expect("enable account scheduling");
assert_eq!(enabled.config_revision.get(), 2);
assert!(
repository
.load_provider_account("acct_recovery")
.await
.unwrap()
.unwrap()
.summary
.enabled
);
assert_eq!(
sqlx::query_scalar::<_, serde_json::Value>(facts_query)
.fetch_one(&database.pool)
.await
.expect("load enabled account facts"),
before_enable
);
assert_eq!(
account_group_ids(&database.pool, "acct_recovery").await,
[GROUP_ID]
);
assert_eq!(
audit_count(&database.pool, "request_account_enable").await,
1
);
let result = store
.recover_account(
@@ -1606,7 +1657,7 @@ async fn account_recovery_resets_status_facts_without_changing_credentials_or_sc
.await
.expect("recover account");
assert_eq!(result.config_revision.get(), 2);
assert_eq!(result.config_revision.get(), 3);
let current = sqlx::query_as::<_, RecoveredAccountRow>(
"select enabled, credential_state, quota_access_state, quota_evidence,
last_error_message, provider_quota_json, concurrency_limit, weight,
@@ -1339,12 +1339,8 @@ pub(super) fn map_upstream_failure(
),
error_message: failure.client_message,
cyber_policy_failure,
// 仅上游明确返回的容量/服务不可用计数;本地连接保护也会映射为
// Unavailable,不能用通用 ProviderErrorKind 推断容量证据。
upstream_capacity_failure: matches!(
category,
CodexFailureCategory::CapacityUnavailable | CodexFailureCategory::Unavailable
),
// 普通 5xx 与未知流内错误不足以证明容量拒绝,不能用于冻结账号。
upstream_capacity_failure: capacity_unavailable,
set_cookie_headers: failure.set_cookie_headers,
rate_limit_headers: failure.rate_limit_headers,
observation,
@@ -92,7 +92,7 @@ use crate::transport::{
CodexBackendJsonResponse, CodexBackendStreamingResponse, CodexBackendTransport,
CodexClientError, CodexRateLimitUpdates, CodexRequestContext, CodexResponseMetadata,
CodexResponseMetadataUpdates, CodexTransportMetrics, CodexUpstreamDiagnostics,
CodexWebSocketPool, endpoint_url,
CodexWebSocketPool, endpoint_url, normalize_non_codex_request_body,
};
mod execution;
@@ -549,6 +549,12 @@ impl Provider for CodexProvider {
lease.installation_id(),
account_scope,
);
if matches!(
lease.authentication(),
crate::credential::CodexRuntimeAuthentication::OAuth(_)
) {
normalize_non_codex_request_body(upstream_request.body_mut());
}
// 每次执行从原始请求编码,选定出口后再覆盖,避免换号时携带上次位置。
if let Some(location) = lease
.account()
@@ -453,6 +453,11 @@ fn classify_upstream_failure(
};
}
// 官方 Codex 将流内 invalid_prompt 归为请求错误,不依赖 HTTP 状态码。
if matches!(source, UpstreamFailureSource::SseFailure) && code == "invalid_prompt" {
return CodexFailureCategory::InvalidRequest;
}
if [code.as_str(), message.as_str(), body.as_str()]
.into_iter()
.any(is_model_unsupported)
@@ -1,6 +1,37 @@
//! 下游客户端模型请求正文的 Codex 协议兼容。
use serde_json::{Map, Value};
use serde_json::{Map, Value, json};
/// 非官方客户端及跨客户端历史回填的兼容入口;调用方必须已选定 Codex/OAuth 上游。
/// 只处理已验证被拒绝的字段,不根据客户端品牌推断整份历史是否合法。
pub(crate) fn normalize_non_codex_request_body(body: &mut Map<String, Value>) {
let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) else {
return;
};
for item in input {
let Some(item) = item.as_object_mut() else {
continue;
};
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
continue;
}
// SDK 输出项的 status 不属于 Codex reasoning 输入合同;其他项的 status 可能合法。
item.shift_remove("status");
// Codex 只接受空 content 数组;仅在加密历史仍可回填时去掉冗余明文,
// 没有加密内容的历史不自动丢弃,保留给上游明确拒绝。
if item
.get("encrypted_content")
.and_then(Value::as_str)
.is_some_and(|content| !content.trim().is_empty())
&& item
.get("content")
.and_then(Value::as_array)
.is_some_and(|content| !content.is_empty())
{
item.shift_remove("content");
}
}
}
/// 补齐 Codex 请求缺省字段并适配已确认不兼容的请求形状,不递归清洗业务正文。
///
@@ -10,6 +41,21 @@ pub(in crate::transport) fn normalize_codex_request_body(body: &mut Map<String,
// 官方 Core/Desktop 显式发送 store=false;仅为缺字段的下游请求补齐,保留显式值。
body.entry("store").or_insert(Value::Bool(false));
// 公开 Responses API 允许 `input` 为字符串(等价于一条 user 文本消息),
// 而 Codex 后端只接受条目数组,否则返回 400 "Input must be a list"。
// 这里按官方 ResponseItem::Message 的形状展开;其他非数组类型不猜测语义,交给上游判定。
if let Some(Value::String(text)) = body.get_mut("input") {
let text = std::mem::take(text);
body.insert(
"input".to_owned(),
json!([{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": text}],
}]),
);
}
// Codex 上游拒绝显式 message 的 system role;沿用官方客户端的 developer
// role 承载指令,只转换已确认的消息形状,保留内容与其他字段。
if let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) {
@@ -5,4 +5,5 @@ mod body;
mod headers;
pub(super) use body::normalize_codex_request_body;
pub(crate) use body::normalize_non_codex_request_body;
pub(super) use headers::is_non_codex_request_header;
@@ -19,6 +19,7 @@ mod response_meta;
pub(crate) mod session;
pub mod subscription;
mod time;
pub(crate) use downstream::normalize_non_codex_request_body;
pub(crate) use endpoints::valid_upstream_base_url;
pub mod tls;
pub mod usage;
@@ -267,6 +267,220 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c
const OFFICIAL_FIXTURE: &[u8] =
include_bytes!("../transport/fixtures/official_models_snapshot.json");
#[tokio::test]
async fn replay_compatibility_should_remove_only_reasoning_status_on_both_transports() {
let input = json!([
{"type":"message","role":"user","status":"completed","content":[{"type":"input_text","text":"hello"}]},
{"type":"reasoning","id":"rs_replay","status":"completed","summary":[],"content":[],"encrypted_content":"test-cipher","extension":{"status":"keep","content":[1]}},
{"type":"function_call","call_id":"call_replay","name":"echo","arguments":"{}","status":"completed"},
{"type":"function_call_output","call_id":"call_replay","output":{"status":"keep","content":[1]},"status":"completed"},
{"type":"tool_search_output","call_id":"call_search","status":"completed","tools":[]},
{"type":"reasoning","status":"in_progress","summary":[],"encrypted_content":"second-test-cipher"},
{"type":"future_item","status":"keep","content":[1]},
"opaque-item"
]);
let mut expected = input.clone();
for index in [1, 5] {
expected[index]
.as_object_mut()
.unwrap()
.shift_remove("status");
}
for websocket in [false, true] {
let actual = capture_replay_compatibility_request(input.clone(), false, websocket).await;
// 比较序列化结果,同时保护未修改字段的顺序。
assert_eq!(actual["input"].to_string(), expected.to_string());
}
}
#[tokio::test]
async fn replay_compatibility_should_keep_encrypted_history_when_removing_nonempty_content() {
let input = json!([
{"type":"reasoning","id":"rs_replay","summary":[{"type":"summary_text","text":"keep summary"}],"content":[{"type":"reasoning_text","text":"synthetic replay"}],"encrypted_content":"test-cipher","extension":9007199254740993_u64},
{"type":"compaction","id":"cmp_replay","content":[{"type":"text","text":"keep compaction"}],"encrypted_content":"compaction-test-cipher"},
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"keep answer"}]}
]);
let mut expected = input.clone();
expected[0].as_object_mut().unwrap().shift_remove("content");
for websocket in [false, true] {
let actual = capture_replay_compatibility_request(input.clone(), false, websocket).await;
assert_eq!(actual["input"].to_string(), expected.to_string());
}
}
#[tokio::test]
async fn replay_compatibility_should_preserve_plaintext_only_and_unrecognized_content_shapes() {
let input = json!([
{"type":"reasoning","summary":[],"content":[{"type":"reasoning_text","text":"only history"}]},
{"type":"reasoning","summary":[],"content":[1],"encrypted_content":""},
{"type":"reasoning","summary":[],"content":[1],"encrypted_content":" "},
{"type":"reasoning","summary":[],"content":[1],"encrypted_content":null},
{"type":"reasoning","summary":[],"content":[1],"encrypted_content":42},
{"type":"reasoning","summary":[],"content":{"status":"keep"},"encrypted_content":"test-cipher"},
{"type":"reasoning","summary":[],"content":"keep","encrypted_content":"test-cipher"}
]);
for websocket in [false, true] {
let actual = capture_replay_compatibility_request(input.clone(), false, websocket).await;
assert_eq!(actual["input"].to_string(), input.to_string());
}
}
#[tokio::test]
async fn replay_compatibility_should_leave_normal_official_history_unchanged() {
let input = json!([
{"type":"reasoning","id":"rs_empty","summary":[],"content":[],"encrypted_content":"test-cipher"},
{"type":"reasoning","id":"rs_null","summary":[],"content":null,"encrypted_content":"test-cipher"},
{"type":"reasoning","id":"rs_absent","summary":[],"encrypted_content":"test-cipher"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"do not change status/content"}]},
{"type":"custom_tool_call","call_id":"call_custom","status":"completed","name":"custom","input":"keep"},
{"type":"tool_search_call","call_id":"call_search","status":"completed","arguments":{}},
{"type":"mcp_call","status":"completed","output":"keep"},
{"summary":[],"content":[1],"status":"keep"},
{"type":"future_item","content":[1],"status":"keep"},
null,
"opaque-item"
]);
for websocket in [false, true] {
let actual = capture_replay_compatibility_request(input.clone(), false, websocket).await;
assert_eq!(actual["input"].to_string(), input.to_string());
}
}
#[tokio::test]
async fn replay_compatibility_should_not_apply_codex_rules_to_api_key_accounts() {
let input = json!([{
"type":"reasoning","id":"rs_api","status":"completed","summary":[],
"content":[{"type":"reasoning_text","text":"API-specific history"}],
"encrypted_content":"test-cipher"
}]);
for websocket in [false, true] {
let actual = capture_replay_compatibility_request(input.clone(), true, websocket).await;
assert_eq!(actual["input"].to_string(), input.to_string());
}
}
#[tokio::test]
async fn replay_compatibility_should_preserve_non_array_input_without_guessing() {
for input in [
Value::Null,
json!({"type":"reasoning","status":"keep"}),
json!(7),
] {
let actual = capture_replay_compatibility_request(input.clone(), false, false).await;
assert_eq!(actual["input"], input);
}
}
async fn capture_replay_compatibility_request(
input: Value,
api_key: bool,
websocket: bool,
) -> Value {
let http = MockServer::start().await;
let (base_url, websocket_server) = if websocket {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let task =
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let frame = websocket.next().await.unwrap().unwrap();
let body: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap();
websocket.send(Message::Text(json!({
"type":"response.completed","response":{
"id":"resp_replay","model":"gpt-5.4","status":"completed","output":[],
"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}
}).to_string().into())).await.unwrap();
body
});
(base_url, Some(task))
} else {
Mock::given(method("POST"))
.and(path(if api_key {
"/responses"
} else {
"/codex/responses"
}))
.respond_with(
ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"),
)
.expect(1)
.mount(&http)
.await;
(http.uri(), None)
};
let store = Arc::new(MemoryAccountStore::default());
if api_key {
store
.seed_api_key(
"acct_provider_contract",
base_url.clone(),
if websocket {
provider_openai::credential::ApiKeyTransport::PreferWebsocket
} else {
provider_openai::credential::ApiKeyTransport::Http
},
)
.await;
} else {
create_account(&store, "acct_provider_contract").await;
}
let original = json!({
"model":"gpt-5.4","store":false,"stream":true,"input":input,
"instructions":"Preserve the original instructions.",
"tools":[{"type":"function","name":"echo","parameters":{"type":"object","properties":{"status":{"type":"string"},"content":{"type":"array"}}}}],
"future_field":{"status":"keep","content":[1]},
"client_metadata":{"extension":{"status":"keep","content":[1]}}
});
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", original.as_object().unwrap().clone())
.unwrap()
.with_context(Map::from_iter([(
"use_websocket".to_owned(),
json!(websocket),
)])),
));
let provider = provider_with_base_url(&store, base_url);
let mut stream = provider
.execute(
planned_request("openai", operation),
context("req_replay_compatibility", CancellationToken::new()),
)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.expect("successful upstream completion");
}
let actual = if let Some(server) = websocket_server {
timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap()
} else {
let requests = http.received_requests().await.unwrap();
assert_eq!(
requests.len(),
1,
"compatibility must run before the first send, without retry"
);
captured_request_body(&requests[0])
};
for field in ["model", "store", "instructions", "tools", "future_field"] {
assert_eq!(
actual[field].to_string(),
original[field].to_string(),
"unexpected change in {field}"
);
}
assert_eq!(
actual["client_metadata"]["extension"],
original["client_metadata"]["extension"]
);
actual
}
const CAPTURE_COMPLETED_SSE: &str = concat!(
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_scope_capture\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"
@@ -4985,7 +5199,10 @@ async fn affinity_quota_switch_should_clear_old_turn_state_without_a_provider_st
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("second request")),
(
"input".to_owned(),
json!([{"role": "user", "content": "second request"}]),
),
("session_id".to_owned(), json!(session_id)),
]),
)
@@ -5020,7 +5237,7 @@ async fn affinity_quota_switch_should_clear_old_turn_state_without_a_provider_st
let second_request = &requests[0];
assert_eq!(
captured_request_body(second_request).get("input"),
Some(&json!("second request"))
Some(&json!([{"role": "user", "content": "second request"}]))
);
assert!(captured_header_values(second_request, "x-codex-turn-state").is_empty());
let expected_account_id = format!("chatgpt-{second_account_id}");
@@ -6106,14 +6323,16 @@ fn provider_with_capacity_tracking(
}
#[tokio::test]
async fn capacity_feedback_counts_business_rejections_but_excludes_diagnostic_probes() {
async fn capacity_feedback_only_counts_overload_rejections_and_excludes_diagnostic_probes() {
use gateway_core::provider_ports::ProviderCooldownPort as _;
for websocket in [false, true] {
for (status, code) in [
(429, "slow_down"),
(503, "server_is_overloaded"),
(500, "server_error"),
for (status, code, capacity) in [
(429, "slow_down", true),
(503, "server_is_overloaded", true),
(500, "server_error", false),
(502, "server_error", false),
(503, "service_unavailable_error", false),
] {
for diagnostic in [false, true] {
let store = Arc::new(MemoryAccountStore::default());
@@ -6177,8 +6396,11 @@ async fn capacity_feedback_counts_business_rejections_but_excludes_diagnostic_pr
"unexpected upstream error: {error:?}"
);
let after = cooldowns.capacity_evidence(account.id());
if diagnostic {
assert_eq!(after, before, "probe must preserve capacity count and peak");
if diagnostic || !capacity {
assert_eq!(
after, before,
"only explicit overload from ordinary requests may change capacity evidence: {status}/{code}"
);
} else {
assert_eq!(
after.map(|(count, _)| count),
@@ -6277,23 +6499,66 @@ async fn websocket_usage_limit_rejection_preserves_quota_state_for_account_rotat
}
#[tokio::test]
async fn capacity_stream_rejection_only_retries_before_semantic_output_on_both_transports() {
for (use_websocket, code) in [
(false, "server_is_overloaded"),
(true, "server_is_overloaded"),
(false, "slow_down"),
(true, "slow_down"),
] {
async fn capacity_feedback_in_stream_only_counts_explicit_overload() {
use gateway_core::provider_ports::{ProviderCooldownKind, ProviderCooldownPort as _};
for (use_websocket, (code, message, expected_kind, scored)) in
[false, true].into_iter().flat_map(|websocket| {
[
(
"server_is_overloaded",
"Selected model is at capacity. Please try a different model.",
ProviderErrorKind::UpstreamCapacityUnavailable,
true,
),
(
"slow_down",
"slow_down",
ProviderErrorKind::UpstreamCapacityUnavailable,
true,
),
(
"invalid_prompt",
"Invalid prompt: we've limited access to this content for safety reasons.",
ProviderErrorKind::InvalidRequest,
false,
),
(
"server_error",
"An internal server error occurred.",
ProviderErrorKind::Unavailable,
true,
),
(
"unknown_error",
"Unrecognized upstream failure.",
ProviderErrorKind::Unavailable,
false,
),
]
.map(|case| (websocket, case))
})
{
for semantic_output in [false, true] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let account = store.account("acct_provider_contract").expect("account");
let cooldowns = Arc::new(MemoryCooldownPort::new());
// 距离冻结阈值只差一次,验证非容量错误不会把可调度账号推入冷却。
for _ in 0..11 {
cooldowns
.record_capacity_failure(account.id(), Duration::from_secs(600), 20)
.await
.expect("seed capacity evidence");
}
let before = cooldowns.capacity_evidence(account.id());
let mut events = vec![
json!({"type": "response.created", "response": {"id": "resp_capacity", "model": "gpt-5.4"}}),
];
if semantic_output {
events.push(json!({"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "hello"}));
}
let original = json!({"type": "response.failed", "response": {"id": "resp_capacity", "error": {"code": code, "message": "Selected model is at capacity. Please try a different model."}}});
let original = json!({"type": "response.failed", "response": {"id": "resp_capacity", "error": {"code": code, "message": message}}});
events.push(original.clone());
let (base_url, _http_server, websocket_server) = if use_websocket {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener");
@@ -6336,7 +6601,9 @@ async fn capacity_stream_rejection_only_retries_before_semantic_output_on_both_t
} else {
http_generate_operation()
};
let mut stream = provider_with_base_url(&store, base_url)
let (provider, pool) =
provider_with_capacity_tracking(&store, base_url, Arc::clone(&cooldowns));
let mut stream = provider
.execute(
planned_request("openai", operation),
context("req_capacity_stream", CancellationToken::new()),
@@ -6352,15 +6619,48 @@ async fn capacity_stream_rejection_only_retries_before_semantic_output_on_both_t
}
}
Some(Err(error)) => break error,
None => panic!("expected capacity failure"),
None => panic!("expected upstream failure"),
}
};
assert_eq!(error.replay_is_safe(), !semantic_output);
assert_eq!(error.kind(), ProviderErrorKind::UpstreamCapacityUnavailable);
assert_eq!(error.pre_delivery_retry().is_some(), !semantic_output);
assert!(provider_openai::openai_failure_affects_account_score(
&error
));
let capacity = expected_kind == ProviderErrorKind::UpstreamCapacityUnavailable;
assert_eq!(
error.kind(),
expected_kind,
"unexpected error for {code}, websocket={use_websocket}, semantic_output={semantic_output}: {error:?}"
);
assert_eq!(error.replay_is_safe(), capacity && !semantic_output);
assert_eq!(error.upstream_status(), None);
assert_eq!(
error.pre_delivery_retry().is_some(),
capacity && !semantic_output
);
assert_eq!(
provider_openai::openai_failure_affects_account_score(&error),
scored
);
let cooldown = cooldowns.read(account.id()).await.expect("read cooldown");
if capacity {
assert_eq!(
cooldowns
.capacity_evidence(account.id())
.map(|(count, _)| count),
Some(12)
);
assert_eq!(
cooldown.expect("capacity cooldown").kind(),
ProviderCooldownKind::CapacityFreezeProbe
);
} else {
assert_eq!(
cooldowns.capacity_evidence(account.id()),
before,
"non-capacity error {code} changed capacity evidence"
);
assert!(
cooldown.is_none(),
"non-capacity error {code} froze the account"
);
}
assert_eq!(
serde_json::from_str::<Value>(
error.raw_upstream_error().expect("raw error").as_str()
@@ -6380,6 +6680,7 @@ async fn capacity_stream_rejection_only_retries_before_semantic_output_on_both_t
if let Some(server) = websocket_server {
server.await.expect("server");
}
pool.shutdown().await;
}
}
}
@@ -8772,16 +9073,33 @@ async fn api_key_websocket_uses_api_path_and_bearer_without_oauth_identity() {
fn quota_continuation_operation(use_websocket: bool) -> Operation {
Operation::Generate(
GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", json!({
"model": "gpt-5.4", "input": "continue", "previous_response_id": "resp_previous",
"session_id": "quota-replay", "thread_id": "turn",
}).as_object().unwrap().clone())
ProtocolPayload::json_object(
"openai",
json!({
"model": "gpt-5.4", "input": [{"role":"user","content":"continue"}],
"previous_response_id": "resp_previous",
"session_id": "quota-replay", "thread_id": "turn",
})
.as_object()
.unwrap()
.clone(),
)
.unwrap()
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(use_websocket))])),
.with_context(Map::from_iter([(
"use_websocket".to_owned(),
json!(use_websocket),
)])),
)
.with_provider_session_state(
generate_with_persisted_session_context("acct_provider_contract", "conversation-quota", "quota-replay", "turn")
.provider_session_state("openai").unwrap().clone(),
generate_with_persisted_session_context(
"acct_provider_contract",
"conversation-quota",
"quota-replay",
"turn",
)
.provider_session_state("openai")
.unwrap()
.clone(),
),
)
}
@@ -9092,7 +9410,10 @@ async fn quota_continuation_full_client_replay_selects_another_account() {
let delta: Value =
serde_json::from_str(ws.next().await.unwrap().unwrap().to_text().unwrap()).unwrap();
assert_eq!(delta["previous_response_id"], "resp_previous");
assert_eq!(delta["input"], "continue");
assert_eq!(
delta["input"],
json!([{"role":"user","content":"continue"}])
);
ws.send(Message::Text(json!({"type":"error","status":429,"error":{"type":"usage_limit_reached","code":"usage_limit_reached","message":"You have reached your usage limit."}}).to_string().into())).await.unwrap();
let (socket, _) = listener.accept().await.unwrap();
let mut ws = accept_codex_test_websocket(socket).await;
@@ -43,11 +43,56 @@ fn encoder_should_adapt_pi_responses_parameters_without_losing_codex_fields() {
);
}
#[test]
fn encoder_should_expand_string_input_into_user_message_item() {
// 公开 Responses API 的字符串 input 等价于一条 user 文本消息;
// 目标形状对照 codex-rs/protocol/src/models.rs 的 ResponseItem::Message + ContentItem::InputText。
let encoded = encode_downstream_request(json!({"model": "gpt-test", "input": "hello"}));
assert_eq!(
Value::Object(encoded.body().clone()),
json!({
"model": "gpt-test",
"input": [{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}]
}],
"store": false
})
);
assert_eq!(encoded.input().len(), 1);
}
#[test]
fn encoder_should_keep_non_string_input_shapes_untouched() {
// 数组按原样透传;空串仍展开为空文本消息,语义由上游判定。
// null、对象等既不是官方形状也没有公开 API 的等价定义,不猜测语义。
let items = json!([{"type": "message", "role": "user", "content": [
{"type": "input_text", "text": "hi"},
{"type": "input_image", "image_url": "data:image/png;base64,AA=="}
]}]);
for input in [items, Value::Null, json!({"text": "hello"}), json!(42)] {
let body = json!({"model": "gpt-test", "input": input, "store": false});
let encoded = encode_downstream_request(body.clone());
assert_eq!(Value::Object(encoded.body().clone()), body);
}
let encoded = encode_downstream_request(json!({"model": "gpt-test", "input": ""}));
assert_eq!(
encoded.body()["input"],
json!([{"type": "message", "role": "user", "content": [{"type": "input_text", "text": ""}]}])
);
}
#[test]
fn encoder_should_default_missing_store_without_changing_unknown_or_nested_fields() {
let mut body = json!({
"model": "client-model",
"input": "temperature and max_output_tokens are tool parameter names",
"input": [{"role": "user", "content": [{
"type": "input_text", "text": "temperature and max_output_tokens are tool parameter names"
}]}],
"max_tokens": 256,
"future_options": {"temperature": 0.7},
"client_metadata": {"prompt_cache_retention": "business-value", "store": true},
@@ -80,7 +125,11 @@ fn encoder_should_default_missing_store_without_changing_unknown_or_nested_field
#[test]
fn encoder_should_preserve_explicit_store_values() {
for store in [json!(false), json!(true), Value::Null, json!("false")] {
let body = json!({"model": "gpt-test", "input": "hello", "store": store});
let body = json!({
"model": "gpt-test",
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}],
"store": store
});
let encoded = encode_downstream_request(body.clone());
assert_eq!(Value::Object(encoded.body().clone()), body);
+2
View File
@@ -135,6 +135,8 @@ SSE 注释保活用于防止传输链路空闲断开,不会重置 Codex 等待
在管理端创建客户端密钥,打开「使用密钥」,按操作系统复制 `config.toml` 和 `auth.json`,
或通过 CCSwitch 导入。已有文件先备份,合并后完全退出并重启 Codex。
CCSwitch 导入同时配置当前 Key 的日/周额度查询,当前 Provider 默认每 30 分钟刷新。
查询地址和凭据随导入生成,在 CCSwitch 中修改 Provider 的地址或 Key 后,需重新导入以同步用量查询。
Linux/macOS 默认目录为 `~/.codex/`,Windows 为 `%USERPROFILE%\.codex\`;
设置过 `CODEX_HOME` 时以该目录为准。Provider 设置应写入用户配置,不要只写到项目目录。
+76 -20
View File
@@ -27,7 +27,7 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全
运行设置可以分别配置 `minCodexDesktopVersion` 与 `minCodexCliVersion`。两者只接受 SemVer,`null`
表示不限制。API 在 Client Key 鉴权成功后识别官方 Desktop/CLI 请求头;适用门禁的客户端没有合法版本,或版本
低于对应门槛时,所有 `/v1/*` HTTP 请求和新 WebSocket 握手在访问上游前返回 `426 Upgrade Required`。
低于对应门槛时,除只读 `/v1/usage` 外的 `/v1/*` HTTP 请求和新 WebSocket 握手在访问上游前返回 `426 Upgrade Required`。
未知客户端保持兼容,不应用版本门禁。
Desktop 应用版本优先取 `version` 头,未提供时取 User-Agent 中的 `(Codex Desktop; <版本>)`。
@@ -156,6 +156,7 @@ WebSocket message 和 frame 不设置网关私有长度上限;协议可接受
| `POST` | `/v1/images/edits` | 通过 OpenAI Provider 发起图像编辑;JSON 请求与响应正文原样转发 |
| `GET` | `/v1/models` | 返回当前 Client Key 账号范围内各 Provider 的可用公开模型并集;有两种响应形态,见下 |
| `GET` | `/v1/models/{model_id}` | 返回 OpenAI 兼容的单模型详情 |
| `GET` | `/v1/usage` | 查询当前 Client Key 的日与周额度,仅使用网关已结算的 USD 账本 |
Codex 的 review 等子代理请求仍使用 `/v1/responses`,并通过 `x-openai-subagent` 请求头携带子代理类型;
网关不提供独立的子代理请求路径。
@@ -190,12 +191,19 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和
Responses 上游编码会移除 Codex 不接受的顶层 `temperature`、`max_output_tokens` 和
`prompt_cache_retention`。缺少顶层 `store` 时补齐 `false`,与官方 Codex 客户端一致;
显式提供的值保持原样。`input` 数组中显式指定 `type: "message"` 且 `role: "system"` 的消息,
其角色转换为 Codex 接受的 `developer`;消息内容、顺序、其他字段和顶层 `instructions` 保持不变。
HTTP/SSE 与 WebSocket 共用这条正文兼容规则。
`prompt_cache_key`、`reasoning`、`include` 等 Codex 参数继续保留。过滤只作用于顶层,
显式提供的值保持原样。顶层 `input` 为字符串时按公开 Responses API 的语义展开为一条
`user` 文本消息条目(`{"type": "message", "role": "user", "content": [{"type": "input_text", ...}]}`),
因为 Codex 后端只接受条目数组;数组及其他类型原样透传。`input` 数组中显式指定 `type: "message"`
且 `role: "system"` 的消息,其角色转换为 Codex 接受的 `developer`;消息内容、顺序、其他字段和顶层
`instructions` 保持不变。HTTP/SSE 与 WebSocket 共用这条正文兼容规则。
`prompt_cache_key`、`reasoning`、`include` 等 Codex 参数继续保留。上述参数过滤只作用于顶层,
不删除工具参数 schema、输入内容或 `client_metadata` 内的同名业务字段;其他未知字段继续透传。
Codex/OAuth 上游的历史回填按字段形状兼容,不以 User-Agent 品牌区分:显式 `type: "reasoning"`
的 `input` 项移除顶层 `status`;该项具有非空字符串 `encrypted_content` 时,还会移除非空数组
`content`。其他字段及顺序保持不变,缺少非空加密载荷的明文历史由上游判定。
普通消息、工具项及未知类型不受此规则影响,API Key 上游不应用此规则。
请求头过滤不提供客户端匿名化;系统提示词、工具定义、工具结果、工作目录及其他业务 metadata
保持原有语义,可能包含客户端环境信息。
@@ -294,6 +302,30 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 `
官方 Codex 的 WebSocket 客户端支持这一流程。其他客户端需要自行处理,网关不会跨账号发送原增量输入。
普通限流、容量不足、发送结果不明以及已经交付输出的失败不触发此转换。
### API Key 额度查询
`GET /v1/usage` 使用 `Authorization: Bearer <Client Key>`,不接受会话 Cookie、管理 API Key 或查询参数。
只返回该 Key 的日与周额度,不包含明文 Key、账号资料或其他 Key 的数据。查询不会调用上游、扣费、占用推理并发/RPM,
也不会更新最近使用时间或开启预算窗口;额度耗尽后仍可查询。
成功响应直接返回以下 JSON,不使用管理接口信封,所有响应带 `Cache-Control: no-store`:
```json
{
"unit": "USD",
"daily": { "total": "1", "used": "0.640001", "remaining": "0.359999", "resetsAt": "2026-09-21T16:00:00Z" },
"weekly": { "total": "5", "used": "2.35", "remaining": "2.65", "resetsAt": "2026-09-27T16:00:00Z" }
}
```
金额使用十进制字符串,`total` 为当前周期限额,`used` 为该周期已结算金额,`remaining` 为限额减已用且最低为零。
不限额时 `total`、`remaining` 均为 `null`,仍返回已用金额。`resetsAt` 为 RFC3339 时间,尚未开启或已到期的窗口返回 `null`,
已到期窗口的 `used` 为 `"0"`。日窗口按北京时间零点划分,周窗口沿用首次使用起的七天周期,不固定为周一。
修改限额、管理员重置和费用结算均复用现有 Key 账本,不从请求日志重算余额。
缺失、非法、已禁用或已删除的 Key 返回 OpenAI 风格 `401` 错误;未知查询参数返回 `400 invalid_usage_query`,
读取账本失败返回 `503 usage_unavailable`,不会用零余额掩盖故障。
## 4. 浏览器认证
### 统一登录与会话
@@ -346,13 +378,14 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 `
| `GET` | `/api/key-usage/overview` | `startTime`、`endTime`、`model?` | 用量汇总、趋势、当前额度和北京时间今日健康时间线 |
| `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的成功请求或错误记录 |
| `GET` | `/api/key-usage/config` | 无 | 当前 Key 的客户端配置凭据 |
| `GET` | `/api/key-usage/version` | 无 | “关于”弹窗使用的当前版本号和提交号 |
用量查询的起止时间使用 RFC3339,开始必须早于结束,一次最多 31 天。模型按完整名称匹配;
不接受 Key ID、账号、Provider 等范围参数或其他未知字段。页码默认 1,每页默认 20,允许 1–100 条;
`kind` 为 `success`(默认)或 `error`。分页响应为 `{ items, currentPage, pageSize, total }`。
overview 返回 `asOf`、`startTime`、`endTime`、`key`、`summary`、`trend`、`healthTimeline`。
`key` 仅包含名称、掩码前缀、并发/RPM、日与七日限额、已用 USD 及重置时间;零限额表示不限,
`key` 仅包含名称、掩码前缀、并发/RPM、日与周限额、已用 USD 及重置时间;零限额表示不限,
未启动窗口的重置时间为 null。额度使用现有结算账本,不受日志日期或模型筛选影响。
健康时间线沿用管理端的 96 个北京时间日内桶与可用性语义,不受历史范围和模型筛选影响。
@@ -367,6 +400,8 @@ Token 明细、费用明细、用时/首字与状态。Token 和费用复用现
成功记录的 `status` 为 `success`,不伪造未保存的 HTTP 状态;错误记录为 `error`,只返回客户端状态码,
缺失的 Token/费用明细为 null。不返回账号资料、Key ID、上游模型或请求标识、原始错误正文或诊断内容。
version 返回 `{ version, gitSha }`,不接受查询参数,不包含部署模式、更新状态或内部诊断。
config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 Key,不接受任何查询参数。
使用统计页在打开“密钥配置”弹窗时读取,用于复制 Codex 配置文件或导入 CCSwitch;
明文不进入用量轮询响应或浏览器持久化存储,关闭弹窗后清除页面中的配置状态。
@@ -387,7 +422,7 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前
| `GET` | `/api/admin/accounts/import-tasks/detail` | `taskId` | 任务摘要和逐条结果,不含原始凭据 |
| `POST` | `/api/admin/accounts/import-tasks/stop` | `{ taskId }` | 跳过未开始的条目,已开始的条目继续完成 |
| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 |
| `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 管理员显式清除该账号的本地错误/额度/cooldown 事实并重新启用,不访问上游 |
| `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 停用账号只启用调度;已启用账号强制清除本地错误/额度/cooldown 事实,不访问上游 |
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 更新指定 OpenAI 账号的 OAuth token 或 API Key 上游设置 |
| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, notes?, modelAccess?, outboundProxyId?, outboundProxyUrl? }` | 一次更新账号备注、调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 |
| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled?, concurrencyLimit?, weight?, groupIds?, modelAccess?, outboundProxyId?, outboundProxyUrl? }` | 一次事务更新所选账号;仅修改提供的字段,至少提供一项修改 |
@@ -709,9 +744,10 @@ OAuth start 使用:
在该窗口的 `resetAt + 2 分钟` 后主动复核。后台每 30 秒检查触发条件;同一重置边界复核后仍未更新时
回到 30 分钟重试,避免旧 reset 持续触发请求。各窗口独立确认恢复,时间到期本身不会直接解除账号
耗尽或将展示用量归零。
- `POST /accounts/recover` 是管理员对本地事实的强制恢复:它清除 Redis cooldown 和已保存的额度/错误,
把账号重新启用并恢复为可调度 credential;它不验证上游账号是否已经恢复,下一次真实请求仍可重新写入
失败事实。
- `POST /accounts/recover` 对停用账号只将 `enabled` 改为 `true`,保留已有额度快照、凭据、错误和 Redis
cooldown;启用后仍按这些事实投影状态,不会把已有错误或耗尽改成正常。对已启用账号则执行强制恢复:
清除 Redis cooldown 和已保存的额度/错误,恢复为可调度 credential。两条路径均不访问上游;强制恢复
不验证上游账号是否已经恢复,下一次真实请求仍可重新写入失败事实。
- 成功额度观测会 revision-fenced 写入 quota;明确 `Allowed` 投影为 `normal`,明确耗尽投影为
`quota_exhausted`。额度观测不会清除凭据过期、无效或封禁事实;这些事实统一投影为 `error`,并由
`errorReason` 区分。额度接口的 401/403 也不足以判定 refresh token 永久失效,credential 终态只由
@@ -735,12 +771,13 @@ OAuth start 使用:
- `targetDays`、`extrapolated`:对应周期存在真实账号级窗口时采用实际时长;缺少对应窗口时,使用
可统计的周/月窗口按 7/30 天折算,明确标记 `extrapolated: true`。不把短期限流或模型专属桶当作账号容量。
- `estimatedTokens` / `estimatedUsd` 及对应 `*Display`:完整目标周期的近似容量,
公式为 `样本用量 × 100 / sampledPercent × 目标窗口秒数 / 源窗口秒数`。
- `estimatedTokens` / `estimatedUsd` 及对应 `*Display`:本周期已记录用量加预计剩余量,
公式为 `(本周期已记录用量 + 样本用量 × (100 - usedPercent) / sampledPercent) × 目标窗口秒数 / 源窗口秒数`。
真实周期由上游额度重置边界定义,不按自然周/月累计;只有折算结果才乘以目标与源窗口的时长比。
- `remainingTokens` / `remainingUsd` 及对应 `*Display`:**额度快照时源窗口**的剩余估算,
公式为 `样本用量 × (100 - usedPercent) / sampledPercent`;不随目标周期折算,不代表当前可消费余额。
- `source`:源窗口名称 `label`、已用比例 `usedPercent` / `usedPercentDisplay`、
额度观测时间 `observedAt` / `observedAtDisplay`、用于过期检查的 `resetAt`,以及选中采样区间
额度观测时间 `observedAt` / `observedAtDisplay`、用于过期检查的 `resetAt`,以及本周期累计
已记录的 `tokensDisplay` / `usdDisplay`。`source: null` 表示没有可选的源窗口。
`observedAt` 表示额度观测时间,与本次查询的 `generatedAt` 不同。
- `unavailableReason`:不能估算时的说明,正常为 `null`。有效进度少于 5 个百分点不预测;
@@ -754,7 +791,9 @@ OAuth start 使用:
完成时间的累计用量建立基线,每累计至少 5 个百分点形成一段,使用最近 3 个完整段及未满一段的尾部。
上式中的 `sampledPercent` 是内部有效额度进度(百分点),不是时间进度或对外响应字段。
只对合并区间计算比值,不平均逐请求小分母比值;重复读数不增加段数,大于 1 个百分点的回落或累计
计数倒退会中断采样,不能静默跨越。缓存命中属于输入,不重复相加;其他币种或缺少有效金额不能当成零 USD。
计数倒退会中断采样,不能静默跨越。重置时间改变或额度明显回落时,旧段不参与新的累计和预测;
当查询区间内包含重置前记录时,以首个新段观测的累计值为基线重新计数,积累至少 5 个百分点后恢复预测。
该基线之前无法精确归属的用量不补算。缓存命中属于输入,不重复相加;其他币种或缺少有效金额不能当成零 USD。
采样查询 `[max(resetAt - windowSeconds, accountAddedAt), observedAt)` 内开始的请求,
仅把 `completedAt <= observedAt` 的完整交付用量计入当前分子;历史分子按各点的完成时间累计,
@@ -1151,8 +1190,9 @@ User-Agent 使用 `grok-shell/<版本> (<系统>; <架构>)`,其中 `arm64`
### 账号自动冻结
账号自动冻结(`accountAutoFreezeEnabled`)默认关闭。启用后,在统计窗口内按尝试累计容量类上游错误(`server_is_overloaded`
等与 5xx 不可用),达到阈值后把该账号冻结为带恢复倒计时的 `rate_limited` 状态。`accountAutoFreezeThreshold`
账号自动冻结(`accountAutoFreezeEnabled`)默认关闭。启用后,在统计窗口内按尝试累计明确的上游容量拒绝
(`server_is_overloaded`、`slow_down` 或结构化错误中的明确过载提示),普通 5xx、`invalid_prompt` 和未识别的上游错误不计入。
达到阈值后把该账号冻结为带恢复倒计时的 `rate_limited` 状态。`accountAutoFreezeThreshold`
取值 2~1,000(默认 12,按普通请求的 attempt 计数,含请求内同账号重试,不含诊断探测与本地连接保护错误);`accountAutoFreezeWindowSeconds`
取值 60~3,600(默认 600,随每次失败滑动顺延);`accountAutoFreezeDurationSeconds` 取值 300~604,800
(默认 7,200,即 2 小时,探测失败后按该时长顺延)。`accountAutoFreezeProbeEnabled` 开启时恢复 worker
@@ -1285,6 +1325,9 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt
request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可取 `model`、`account`、
`apiKey`、`provider`、`transport`、`failureClass`、`status`。
管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 为账号当前备注,按内部账号 ID 关联。
备注不写入请求历史快照;无备注或账号已删除时返回 `null`,修改备注不改变历史请求的账号归属。
管理端请求列表与详情分别保留 `requestedModel`(客户端请求)、`upstreamModel`(网关发送)与
`upstreamResponseModel`(上游返回)。返回模型缺失时为 `null`,不使用请求或映射模型补齐。
OpenAI 优先采用服务端 `openai-model` / `x-openai-model` 报告(流内报告可覆盖初始响应头),
@@ -1368,7 +1411,7 @@ Key 已删除或未关联时为 `null`,不影响记录返回,不包含密钥
| `GET` | `/api/admin/system/version` | 无 | 当前构建、部署模式和可用更新 |
| `GET` | `/api/admin/system/update/detail` | `refresh=true|false` | 读取或强制刷新 Release 详情 |
| `GET` | `/api/admin/system/update/events` | 无 | SSE 更新事件流 |
| `POST` | `/api/admin/system/update` | 可选 `{ targetVersion }` | 开始在线更新 |
| `POST` | `/api/admin/system/update` | `{ targetVersion }` | 受理后台在线更新,返回 `202` |
| `GET` | `/api/admin/system/update/status` | 无 | 查询当前更新或回滚状态 |
| `POST` | `/api/admin/system/rollback` | 无 | 回滚到保留的上一版本 |
| `POST` | `/api/admin/system/restart` | 无 | 请求进程重启 |
@@ -1377,8 +1420,21 @@ Key 已删除或未关联时为 `null`,不影响记录返回,不包含密钥
`updateChannel` 由当前版本推导,取值为 `stable`、`alpha`、`beta`、`rc`、`exp`,无法识别时为 `unknown`。
检查与执行使用同一规则,禁止的通道转换、跨实验线、跨大版本、降级或同版本重装均以 `40901` 拒绝。
`hasUpdate=true` 仅表示当前构建支持在线更新,且存在允许的更高版本;`latestVersion`、`releaseUrl` 和
`notes` 对应这个候选。没有候选或当前构建不支持时,`hasUpdate=false`、`latestVersion` 为当前版本,
`releaseUrl` 和 `notes` 为空;不支持原因通过 `updateSupported=false`、`unsupportedReason` 返回。
`notes` 对应这个候选。没有可升级候选时,`hasUpdate=false`、`latestVersion` 为当前版本,
`releaseUrl` 和 `notes` 保留当前版本的已发布 Release 信息;找不到匹配当前版本的 Release 时为空。
当前构建不支持在线更新时不查询 Release,`hasUpdate=false`、`latestVersion` 为当前版本,
`releaseUrl` 和 `notes` 为空,不支持原因通过 `updateSupported=false`、`unsupportedReason` 返回。
强制检查失败时通过 `warning` 返回错误,`hasUpdate=false`,不以旧缓存或“没有更新”掩盖失败。
普通查询可复用 20 分钟内的结果。下载时仍会校验目标资产、校验和及归档,在线更新成功后按返回值重启。
普通查询可复用 20 分钟内的结果。下载时仍会校验目标资产、校验和及归档。
更新 POST 在本地校验目标版本并持久化任务后返回 `202`,数据包含 `operationId`、`targetVersion`、
`deploymentMode` 和 `message`,只表示已受理。Release 查询、远端目标复核、下载、校验及文件替换在后台
执行,结果通过 `/update/status` 的 `operation` 查询:`status` 为 `idle`、`running`、`succeeded` 或 `failed`,
终态包含 `finishedAt`,失败原因在 `error` 中。SSE 的 `operationId` 用于关联进度;终态事件发出前状态已落盘。
连接中断不取消已受理任务;响应丢失时先查询状态,不自动重复提交。打开更新页面时也会恢复最近一次任务。
状态响应的 `currentVersion` 表示已安装文件的版本,运行中的版本仍以 `/version` 为准。
`needRestart=true` 表示成功安装的版本尚未在当前进程生效,此时应调用重启接口,不能重复发起更新。
Host 关闭或任务取消会记录失败终态;状态查询会收敛无执行锁的遗留 `running`。
异常退出留下的锁仍遵循 30 分钟过期规则,未过期前不会抢占其他进程的操作。
实例升级和仓库发版见 [部署文档](../deploy/README.md#镜像升级与源码构建)。
+16 -10
View File
@@ -10,7 +10,7 @@ Codex Proxy RS 是单进程、单副本运行的多 Provider AI 网关,同时
- 面向客户端的 OpenAI Responses、Images、standalone Search 和模型目录协议;
- 面向管理员的 `/api/admin/*` 控制面和 Vue 管理端;
- 面向 Key 持有者的 `/api/key-usage/*` 用量与客户端配置接口和独立 `/key-usage` 页面;
- 面向 Key 持有者的 `/api/key-usage/*` 用量与客户端配置接口、独立 `/key-usage` 页面,以及 Bearer 鉴权的 `/v1/usage` 额度查询;
- OpenAI 与 xAI 两个编译期 Provider;
- PostgreSQL 持久化、Redis 协调状态以及 S3/R2 数据库备份。
@@ -207,7 +207,7 @@ OpenAI 的 OAuth 与 API Key 共用现有账号和事务。API Key 的 Base URL
通过通用画像输出客户端目录,OAuth 原生对象保留。通用账号层按 Provider 提交的 credential state 调度,不以是否存在
上游用户 ID 推断可用性;OAuth 未完成身份投影时由 Provider 保持 `unknown`。状态恢复和未补齐身份的凭据轮换保留 `unknown`。
API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS pool 与 continuation 按凭据版本隔离。
OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证与传输配置。
OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证、传输配置及明确的上游请求合同适配。
OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配置的模型权限仍由 Core 与选号链路执行。
- OpenAI 是透明边界。Responses 请求保留未知字段和字段顺序;SSE、WebSocket、Images 与 standalone
@@ -216,10 +216,11 @@ OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配
- Responses 的业务扩展头保留原始多值字节。API 负责剥离鉴权、账号身份和 HTTP 传输字段,
并提取会话语义;`gateway-protocol` 共享 HTTP 传输与网关链路字段分类。客户端兼容规则集中在
`providers/openai/src/transport/downstream/`:`headers.rs` 管理下游环境头和已提取语义的头部别名,
`body.rs` 管理已知顶层参数的过滤和缺省值补齐;兼容基准为 Codex Core/Desktop 请求协议,
`body.rs` 管理已知顶层参数的过滤、缺省值补齐和已确认不兼容的 `input` 形状适配;兼容基准为 Codex Core/Desktop 请求协议,
不持有账号身份保护或会话规范化逻辑。
Provider 在 `transport/request.rs` 解码不透明头时组合兼容、身份与 HTTP 规则,
同时调用正文兼容规则;HTTP/SSE 与 WebSocket 共用此边界。
同时调用不依赖账号的正文兼容规则。`body.rs` 中非官方客户端及跨客户端历史回填兼容的入口,
由 Provider 在选定 Codex/OAuth 账号后、HTTP/WS 分流前调用;API Key 上游跳过该入口。
`transport/headers.rs` 负责上游身份保护和官方头组装。
会话别名只规范化请求头,不清除正文身份字段;未知业务扩展与响应诊断头不受影响,字段见
[Responses 合同](api.md#3-openai-数据面与模型目录)。提示词、工具及业务正文不做客户端品牌清洗。
@@ -336,8 +337,9 @@ AuthService 每次恢复 Key 会话时重新检查 Key 是否存在且启用;K
管理员会话保存由已加盐密码哈希派生的指纹,每次恢复时与 PostgreSQL 当前密码核对;普通设置变更不影响该绑定。
改密在 AuthService 验证当前密码和新密码策略,Store 以旧哈希条件更新密码并在同一 PostgreSQL 事务记录审计。
事务提交后旧管理员会话的指纹失配,不依赖 Redis 批量删除完成撤销;原始密码及密码哈希不进入 Redis。
KeyUsageService 从 AuthService 的服务端身份确定唯一查询范围,复用 ClientKeyStore 的额度账本投影和
ObservabilityStore 的范围查询;API 只输出单页所需的字段白名单,不复用管理员的宽响应。
KeyUsageService 从 AuthService 的服务端身份或 Core 的 ClientKeyVerifier 只读校验确定唯一查询范围,复用 ClientKeyStore 的额度账本投影和
ObservabilityStore 的范围查询;Bearer 查询仅提供当前额度,不执行推理准入或开启窗口。
API 只输出各入口所需的字段白名单,不复用管理员的宽响应。
客户端配置通过 ClientKeyStore 显式读取当前会话绑定 Key 的明文,不进入用量响应。
前端 `/key-usage` 独立于管理布局,不挂载管理员菜单或请求管理接口;配置弹窗和 Codex / CCSwitch
配置生成逻辑与管理端共用,明文仅在打开弹窗时获取,关闭后清除,不持久化到浏览器。
@@ -513,7 +515,8 @@ OpenAI 订阅周期属于按需个人信息,不是额度事实。Admin 账号
账号容量预测属于 Admin 的只读派生规则,不参与 quota 权威状态、调度或金额结算。Store 通过专用采样端口
在同一 SQL 快照内返回截至观测时间的累计数值及有界历史 Provider 文档;文档仅由具体 Provider 复用协议
解析器解释,Admin 按中立的额度事实选择近期进度段。该采样不改变全站完整交付用量口径,不创建第二份
解析器解释,Admin 按中立的额度事实选择近期进度段预测剩余量,再加本周期已记录用量形成周期总量。
周期以额度重置为边界,重置后累计与样本重新开始。该采样不改变全站完整交付用量口径,不创建第二份
持久化额度状态。部分 Token/美元费用缺失仅提示精度限制,继续按已记录数值估算;对应数值完全不可用时
才不返回该项预测,不按请求数量补齐未知消耗。历史请求完成时间只是额度时间的近似,不承诺严格扣额归因
或预测准确率。公开字段与采样门槛见 [周/月额度预测](api.md#周月额度预测)。
@@ -555,9 +558,10 @@ Worker 由各 Bundle 贡献、由 Host 统一监督:
以及账号冻结恢复 worker(容量熔断的自适应并发下调与到期探测解冻);
- Provider:credential refresh、quota/catalog 健康和官方版本/etag 检查。
账号容量熔断默认关闭。启用后,仅普通请求收到的容量类上游错误(`server_is_overloaded` 等与
5xx 不可用)按滑动窗口计数,并把当时观测到的在途并发并入峰值证据;本地连接保护与诊断探测
不参与容量计数或峰值采样。这些错误不证明凭据或配额失效,不进入账号失败状态。达到阈值后写入
账号容量熔断默认关闭。启用后,仅普通请求收到的明确容量拒绝(`server_is_overloaded`、`slow_down`
或结构化错误中的明确过载提示)按滑动窗口计数,并把当时观测到的在途并发并入峰值证据;
普通 5xx、未识别的上游错误、本地连接保护与诊断探测不参与容量计数或峰值采样。
这些错误不证明凭据或配额失效,不进入账号失败状态。达到阈值后写入
账号级 Redis 容量冷却,调度立即跳过该账号;管理端沿用限流状态,通过原因区分容量冻结与上游限流。
冻结与计数由 Redis 保存,不改变 PostgreSQL 账号状态。需要探测的冻结在到期后仍阻止调度,恢复
worker 复用连接测试探针执行真实上游调用,并按冻结代次处理结果:成功清除,失败按配置时长顺延,
@@ -600,6 +604,8 @@ HTTP Client 构造失败也不会阻断网关启动。外部解析在已认证
备份设置等明确的敏感 Admin 合同返回。
- OAuth pending flow 使用有期限、带 owner 的一次性 claim;事务成功后才消费,失败释放 claim。
- 在线更新校验 Release host、大小、SHA-256 和归档路径,并只允许同一大版本内更新。
Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。
状态文件是更新结果的权威来源,SSE 终态在状态落盘后发送;Host 关闭与任务析构都必须收敛状态。
PostgreSQL 备份恢复属于人工维护操作。当前没有部署级维护模式开关,需要先停止应用,
离线处理快照中的非终态任务、计划游标和到期清理条件,再重新验证对象存储并恢复计划。
+22
View File
@@ -36,6 +36,28 @@
Surface 和自身 Container 三态做对比度保护,不使用固定色相的文字锚点覆盖自定义 Seed;交互仍尊重
`prefers-reduced-motion`。
## 界面文案与信息层级
管理端以查看状态和完成操作为主,延续紧凑的信息布局、清楚的主次关系,以及表面色差、间距和轻阴影构成的层级
- **文案不使用句号和分号**:自行编写的标题、标签、按钮、表单帮助、空状态、提示和错误文案不使用 `。`、句末 `.`、`;` 或 `;`,优先写成短语或简短分句
需要表达多个要点时,按内容关系拆成字段、短行或列表,不把长段落机械替换成逗号串
这项约定针对界面文案,URL、版本号、小数、代码、原始日志和用户输入保留原样
- **说明只补必要信息**:标题和操作已能表达的内容不再用副标题重复,帮助文字贴近对应控件,扩展说明按需放入帮助入口或折叠区域
影响操作选择的限制、错误原因和不可逆后果仍在操作处清楚展示,简化时保留含义
- **避免大字报**:不堆叠超大标题、口号、整屏介绍、重复副标题或大段说明卡片,页面尽快进入数据、筛选和操作
页面标题与关键指标沿用已有组件的强调方式,辅助说明使用正文或次级文字层级,不通过放大字号、加粗整段或增加大块留白来突出普通说明
- **按任务组织空间**:优先使用紧凑的工具栏、表格、表单和必要的指标卡,卡片承担独立的信息或操作职责,不为每段说明单独套卡
密度以可读、可点击为边界,保留窄屏换行和必要的控件间距
开发时按任务选择参考入口,复用其职责和信息层级,具体布局仍以当前任务为准:
| 场景 | 参考入口 | 关注点 |
| --- | --- | --- |
| 页面头部 | [BasePageHeader](../frontend/src/components/base/BasePageHeader.vue)、[系统概览](../frontend/src/views/dashboard/components/DashboardContent.vue) | 简短标题、必要的统计范围或状态、就近操作 |
| 内容与指标 | [BaseCard](../frontend/src/components/base/BaseCard.vue)、[用量概览卡片](../frontend/src/views/usage/components/UsageSummaryCards.vue) | 可选说明、紧凑摘要、数值与辅助信息的主次 |
| 表单帮助 | [API Key 账号字段](../frontend/src/views/accounts/components/AccountApiKeyFields.vue) | 直接命名字段,在对应位置提供短提示和示例 |
## 架构概览
```mermaid
+13
View File
@@ -8,6 +8,11 @@ export interface KeyUsageConfig {
plaintextKey: string
}
export interface KeyUsageVersion {
version: string
gitSha: string
}
export interface KeyUsageMetrics {
requests: number
inputTokens: number
@@ -91,6 +96,14 @@ export function getKeyUsageOverview(params: KeyUsageQuery, options: RequestOptio
})
}
export function getKeyUsageVersion(options: RequestOptions = {}) {
return request<KeyUsageVersion>({
url: '/api/key-usage/version',
method: 'GET',
...options,
})
}
export function getKeyUsageConfig(options: RequestOptions = {}) {
return request<KeyUsageConfig>({
url: '/api/key-usage/config',
+26 -2
View File
@@ -34,10 +34,25 @@ export interface SystemUpdateAccepted {
operationId: string
deploymentMode: string
message: string
needRestart: boolean
targetVersion: string
}
export interface SystemUpdateStatus {
previousVersion: string | null
currentVersion: string | null
needRestart: boolean
operation: {
operationId: string | null
kind: 'update' | 'rollback' | 'restart' | null
status: 'idle' | 'running' | 'succeeded' | 'failed'
targetVersion: string | null
message: string | null
error: string | null
startedAt: string | null
finishedAt: string | null
}
}
export interface SystemRestartAccepted {
message: string
operationId: string
@@ -67,11 +82,12 @@ export function getSystemUpdateDetail(data: SystemUpdateDetailQuery) {
})
}
export function performSystemUpdate(data: SystemUpdateTarget) {
export function performSystemUpdate(data: SystemUpdateTarget, options: RequestOptions = {}) {
return request<SystemUpdateAccepted>({
url: '/api/admin/system/update',
method: 'POST',
data,
...options,
})
}
@@ -82,3 +98,11 @@ export function restartSystem(options: RequestOptions = {}) {
...options,
})
}
export function getSystemUpdateStatus(options: RequestOptions = {}) {
return request<SystemUpdateStatus>({
url: '/api/admin/system/update/status',
method: 'GET',
...options,
})
}
+1
View File
@@ -82,6 +82,7 @@ export interface UsageListRecord {
accountId: string | null
accountEmail: string | null
accountName: string | null
accountNotes: string | null
route: string
model: string | null
requestedModel: string | null
@@ -1,16 +1,16 @@
<script setup lang="ts">
import { Github } from '@boxicons/vue'
import { ExternalLink } from '@lucide/vue'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
const props = defineProps<{
version: { version: string, gitSha: string } | null
}>()
const open = defineModel<boolean>({ default: false })
const { version } = storeToRefs(useSystemUpdateStore())
const author = 'Zyy'
const githubUrl = 'https://github.com/zyycn/codex-proxy-rs'
@@ -25,11 +25,11 @@ function normalizeBuildValue(value: string | undefined) {
}
const versionDisplay = computed(() => {
const versionText = normalizeBuildValue(version.value?.version).replace(/^v/i, '')
const versionText = normalizeBuildValue(props.version?.version).replace(/^v/i, '')
return versionText ? `v${versionText}` : ''
})
const gitShaDisplay = computed(() => {
const gitSha = normalizeBuildValue(version.value?.gitSha)
const gitSha = normalizeBuildValue(props.version?.gitSha)
return gitSha ? gitSha.slice(0, 8) : ''
})
@@ -93,7 +93,7 @@ async function handleKeydown(event: KeyboardEvent, index: number) {
<template>
<div
class="relative inline-grid items-center rounded-cp bg-cp-fill-tertiary p-0.75"
class="relative inline-grid items-center rounded-cp bg-[var(--cp-input-bg)] p-0.75"
:class="rootClasses[size]"
:style="gridStyle"
role="radiogroup"
@@ -126,6 +126,19 @@ onMounted(load)
<template>
<div class="grid min-w-0 gap-4">
<div v-if="allowInherit" class="flex flex-wrap items-center justify-between gap-3">
<BaseSegmented
v-model="profileSource"
label="客户端身份来源"
class="shrink-0"
:options="[
{ label: '全局配置', value: 'global' },
{ label: '独立配置', value: 'independent' },
]"
:disabled="disabled || loading || !!loadError"
/>
<slot name="source-extra" />
</div>
<div v-if="loadError" role="alert" class="flex items-center justify-between gap-3 text-cp text-cp-error">
<span>预设加载失败:{{ loadError }}</span>
<BaseButton size="sm" @click="load">
@@ -136,17 +149,6 @@ onMounted(load)
正在加载客户端预设…
</p>
<template v-else>
<BaseSegmented
v-if="allowInherit"
v-model="profileSource"
label="客户端身份来源"
class="justify-self-start"
:options="[
{ label: '全局配置', value: 'global' },
{ label: '独立配置', value: 'independent' },
]"
:disabled="disabled"
/>
<template v-if="model">
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="客户端预设">
@@ -99,20 +99,22 @@ onMounted(() => props.allowInherit && void load())
<template>
<div class="grid min-w-0 gap-4">
<div v-if="allowInherit" class="flex flex-wrap items-center justify-between gap-3">
<BaseSegmented
v-model="source"
label="xAI 客户端身份来源"
class="shrink-0"
:options="[{ label: '全局配置', value: 'global' }, { label: '独立配置', value: 'independent' }]"
:disabled="disabled || !globalConfiguration"
/>
<slot name="source-extra" />
</div>
<div v-if="loadError" role="alert" class="flex items-center justify-between gap-3 text-cp text-cp-error">
<span>全局配置加载失败:{{ loadError }}</span>
<BaseButton size="sm" @click="load">
重试
</BaseButton>
</div>
<BaseSegmented
v-if="allowInherit"
v-model="source"
label="xAI 客户端身份来源"
class="justify-self-start"
:options="[{ label: '全局配置', value: 'global' }, { label: '独立配置', value: 'independent' }]"
:disabled="disabled || !globalConfiguration"
/>
<template v-if="model">
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="客户端类型">
@@ -39,7 +39,6 @@ const {
updateError,
updateSuccess,
needRestart,
loadedOnce,
updateLogs,
updateStreaming,
updateStreamError,
@@ -144,8 +143,8 @@ async function handleUpdateRequest() {
async function runConfirmedUpdate(targetVersion: string) {
try {
const result = await updateNow(targetVersion)
if (result?.needRestart) {
toast.success('更新完成,请重启服务')
if (result) {
toast.success('更新已开始')
}
}
catch {}
@@ -171,7 +170,7 @@ async function handleRestart() {
}
watch(open, (visible) => {
if (visible && !loadedOnce.value) {
if (visible) {
void loadSystem(false).catch(() => undefined)
}
})
@@ -195,7 +194,7 @@ watch(
description="检查版本、查看发布说明并执行在线更新"
tone="success"
size="lg"
:dismissible="!updating && !restarting"
:dismissible="!restarting"
>
<template #icon>
<ArrowUpCircle class="size-4.5 text-cp-success" />
+3 -3
View File
@@ -3,11 +3,11 @@ import { storeToRefs } from 'pinia'
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import AppAboutModal from '@/components/AppAboutModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
import { useUiStore } from '@/stores/modules/ui'
import AppAboutModal from './components/AppAboutModal.vue'
import AppSidebar from './components/AppSidebar.vue'
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
@@ -15,7 +15,7 @@ import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
const uiStore = useUiStore()
const systemUpdateStore = useSystemUpdateStore()
const { sidebarCollapsed } = storeToRefs(uiStore)
const { loadedOnce } = storeToRefs(systemUpdateStore)
const { loadedOnce, version } = storeToRefs(systemUpdateStore)
const { toggleSidebar } = uiStore
const route = useRoute()
const pageScrollbarRef = ref<InstanceType<typeof BaseScrollbar> | null>(null)
@@ -110,7 +110,7 @@ watch(
</Transition>
</Teleport>
<AppAboutModal v-model="aboutOpen" />
<AppAboutModal v-model="aboutOpen" :version="version" />
<SystemUpdateModal v-model="systemUpdateOpen" />
</div>
</template>
+135 -30
View File
@@ -1,10 +1,12 @@
import { until, useEventSource } from '@vueuse/core'
import type { SystemUpdateStatus } from '@/api'
import { until, useEventSource, useTimeoutPoll } from '@vueuse/core'
import { delay } from 'es-toolkit'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import {
getSystemUpdateDetail,
getSystemUpdateStatus,
getSystemVersion,
performSystemUpdate,
restartSystem,
@@ -18,9 +20,12 @@ const updateEventReadyTimeoutMs = 3_000
const restartReadyTimeoutMs = 60_000
const restartProbeTimeoutMs = 2_000
const restartReadyPollIntervalMs = 500
const updateStatusPollIntervalMs = 1_000
const updateStatusTimeoutMs = 5_000
interface SystemUpdateEvent {
id: string
operationId?: string | null
level: string
message: string
at: string
@@ -58,6 +63,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
const updateStreaming = shallowRef(false)
const updateStreamError = shallowRef('')
const restartTargetVersion = shallowRef('')
const updateStatus = shallowRef<SystemUpdateStatus | null>(null)
const {
data: updateEventMessage,
status: updateEventStatus,
@@ -76,6 +82,12 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
let loadVersionPromise: ReturnType<typeof getSystemVersion> | undefined
let loadSystemPromise: Promise<void> | undefined
let statusRequest: Promise<void> | undefined
let statusGeneration = 0
let submitting = false
let activeOperationId: string | null = null
let unconfirmedPreviousId: string | null | undefined
const statusPoll = useTimeoutPoll(refreshUpdateStatus, updateStatusPollIntervalMs, { immediate: false })
const phaseKind = computed(() => phase.value.kind)
const loading = computed(() => phaseKind.value === 'loading')
@@ -88,6 +100,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
() =>
hasUpdate.value
&& Boolean(updateInfo.value?.updateSupported)
&& !needRestart.value
&& !UPDATE_BUSY_PHASES.has(phaseKind.value),
)
@@ -128,9 +141,11 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
return
try {
const event = JSON.parse(message.raw) as SystemUpdateEvent
if (activeOperationId && event.operationId !== activeOperationId)
return
appendUpdateLog(event)
if (event.terminal)
disconnectUpdateEvents()
if (event.terminal && !submitting)
void refreshUpdateStatus()
}
catch {
updateStreamError.value = '更新日志解析失败'
@@ -167,6 +182,80 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
closeUpdateEventSource()
}
function applyUpdateStatus(status: SystemUpdateStatus) {
const operation = status.operation
updateStatus.value = status
if (unconfirmedPreviousId !== undefined && operation.operationId === unconfirmedPreviousId) {
unconfirmedPreviousId = undefined
updateError.value = '尚未确认更新任务,请检查状态后重试'
setPhase({ kind: 'ready' })
statusPoll.pause()
disconnectUpdateEvents()
return
}
unconfirmedPreviousId = undefined
activeOperationId = operation.operationId
updateError.value = ''
updateSuccess.value = operation.status === 'succeeded' && status.needRestart
needRestart.value = status.needRestart
restartTargetVersion.value = status.needRestart ? normalizeSystemVersion(status.currentVersion) : ''
if (operation.status === 'running') {
setPhase({ kind: 'updating' })
if (!statusPoll.isActive.value)
statusPoll.resume()
void connectUpdateEvents()
return
}
statusPoll.pause()
disconnectUpdateEvents()
if (operation.status === 'failed') {
updateError.value = operation.error || operation.message || '更新失败'
setPhase({ kind: 'failed' })
return
}
if (status.needRestart && updateInfo.value) {
updateInfo.value = { ...updateInfo.value, hasUpdate: false }
}
setPhase(status.needRestart ? { kind: 'restart_required' } : { kind: 'ready' })
}
async function refreshUpdateStatus() {
if (submitting || restarting.value)
return
if (statusRequest)
return statusRequest
const generation = statusGeneration
statusRequest = (async () => {
try {
const status = await getSystemUpdateStatus({ silent: true, timeout: updateStatusTimeoutMs })
if (generation !== statusGeneration)
return
updateStreamError.value = ''
applyUpdateStatus(status)
}
catch (error: unknown) {
if (generation !== statusGeneration)
return
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
statusPoll.pause()
disconnectUpdateEvents()
updateError.value = errorMessage(error)
setPhase({ kind: 'failed' })
return
}
// 传输失败不能证明任务失败,保留忙碌态,恢复连接后重新核对持久化结果。
updateStreamError.value = '暂时无法确认更新状态,正在重试'
setPhase({ kind: 'updating' })
if (!statusPoll.isActive.value)
statusPoll.resume()
}
finally {
statusRequest = undefined
}
})()
return statusRequest
}
async function loadSystem(refresh = false) {
if (loadSystemPromise)
return loadSystemPromise
@@ -175,10 +264,17 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
setPhase({ kind: 'loading' })
}
loadSystemPromise = (async () => {
updateInfo.value = await getSystemUpdateDetail({ refresh })
if (!version.value)
version.value = await getSystemVersion()
loadedOnce.value = true
await Promise.all([
refreshUpdateStatus(),
(async () => {
updateInfo.value = await getSystemUpdateDetail({ refresh })
if (!version.value)
version.value = await getSystemVersion()
loadedOnce.value = true
})(),
])
if (needRestart.value && updateInfo.value)
updateInfo.value = { ...updateInfo.value, hasUpdate: false }
})()
try {
@@ -207,7 +303,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
}
async function checkUpdates(refresh = true) {
if (checking.value)
if (checking.value || updating.value || restarting.value)
return updateInfo.value
setPhase({ kind: 'checking' })
@@ -217,6 +313,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
if (!version.value)
version.value = await getSystemVersion()
loadedOnce.value = true
await refreshUpdateStatus()
return updateInfo.value
}
finally {
@@ -226,40 +323,46 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
}
async function updateNow(targetVersion: string) {
const currentInfo = updateInfo.value
const confirmedTargetVersion = targetVersion.trim()
if (!canUpdate.value || !currentInfo || updating.value || !confirmedTargetVersion)
const confirmedTargetVersion = normalizeSystemVersion(targetVersion)
if (!canUpdate.value || !confirmedTargetVersion)
return null
statusGeneration += 1
submitting = true
statusPoll.pause()
activeOperationId = null
unconfirmedPreviousId = undefined
const previousId = updateStatus.value?.operation.operationId ?? null
resetUpdateResult()
clearUpdateLogs()
await connectUpdateEvents(true)
setPhase({ kind: 'updating' })
updateError.value = ''
updateSuccess.value = false
try {
const result = await performSystemUpdate({ targetVersion: confirmedTargetVersion })
updateSuccess.value = true
needRestart.value = result.needRestart
restartTargetVersion.value = result.needRestart ? normalizeSystemVersion(result.targetVersion) : ''
updateInfo.value = {
...currentInfo,
latestVersion: result.targetVersion,
hasUpdate: false,
}
setPhase(result.needRestart ? { kind: 'restart_required' } : { kind: 'ready' })
await connectUpdateEvents(true)
const result = await performSystemUpdate({ targetVersion: confirmedTargetVersion }, { silent: true })
activeOperationId = result.operationId
updateLogs.value = updateLogs.value.filter(event => event.operationId === result.operationId)
return result
}
catch (error: unknown) {
if (error instanceof ApiError && (error.status === 0 || error.status >= 500 || error.status === 408)) {
unconfirmedPreviousId = previousId
updateStreamError.value = '正在确认更新是否已开始'
return null
}
updateError.value = errorMessage(error)
appendUpdateLog({
id: `update-client-error-${Date.now()}`,
level: 'error',
message: updateError.value,
at: new Date().toISOString(),
})
setPhase({ kind: 'failed' })
disconnectUpdateEvents()
throw error
}
finally {
submitting = false
if (updating.value) {
statusPoll.resume()
// 提交前的查询可能仍在返回,先丢弃旧代次结果,再读取本次任务。
await statusRequest
await refreshUpdateStatus()
}
}
}
async function waitForServiceAndReload() {
@@ -299,6 +402,8 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
throw error
}
statusGeneration += 1
statusPoll.pause()
setPhase({ kind: 'restarting' })
updateError.value = ''
disconnectUpdateEvents()
+36 -1
View File
@@ -11,6 +11,39 @@ export interface CodexCcSwitchImportInput {
websocketEnabled?: boolean
}
function buildUsageScript(apiKey: string, baseUrl: string) {
// CC Switch 的占位符直接替换 JS 源码,不能安全承载含引号的自定义 Key。
// 按本次导入值生成字符串字面量,并转义左花括号,避免值中的占位符被二次替换。
const url = JSON.stringify(`${baseUrl}/usage`).replaceAll('{', '\\u007b')
const authorization = JSON.stringify(`Bearer ${apiKey}`).replaceAll('{', '\\u007b')
// 不限额时省略 total / remaining,避免把无限额度显示成余额为零。
return `({
request: {
url: ${url},
method: "GET",
headers: { Authorization: ${authorization} }
},
extractor: function(response) {
return [["daily", "日额度"], ["weekly", "周额度"]].map(function(entry) {
var budget = response[entry[0]];
var result = {
planName: entry[1],
isValid: true,
used: Number(budget.used),
unit: response.unit
};
if (budget.total === null) {
result.extra = "不限额";
} else {
result.total = Number(budget.total);
result.remaining = Number(budget.remaining);
}
return result;
});
}
})`
}
export function buildCodexCcSwitchImportDeeplink(input: CodexCcSwitchImportInput): string {
const configFiles = buildCodexConfigFiles({
apiKey: input.apiKey,
@@ -32,7 +65,9 @@ export function buildCodexCcSwitchImportDeeplink(input: CodexCcSwitchImportInput
['apiKey', input.apiKey],
['configFormat', 'json'],
['config', config],
['usageEnabled', 'false'],
['usageEnabled', 'true'],
['usageScript', encodeBase64(buildUsageScript(input.apiKey, configFiles.baseUrl))],
['usageAutoInterval', '30'],
]
return `ccswitch://v1/import?${new URLSearchParams(entries).toString()}`
@@ -25,7 +25,7 @@ const metrics = computed(() => [
<section v-if="source" aria-label="容量预测" class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-cp-xs text-cp-text-secondary">{{ source.label }}已用</span>
<span class="text-cp-xs text-cp-text-secondary">本周期已用</span>
<span class="inline-flex items-center gap-1 rounded-cp-sm bg-cp-info-container px-2 py-1 text-cp-xs font-emphasis text-cp-info-on-container">
<Info class="size-3" aria-hidden="true" />
{{ forecast.lowSample ? '初步估算 · 仅供参考' : '估算值 · 仅供参考' }}
@@ -52,8 +52,8 @@ const metrics = computed(() => [
<div class="grid gap-2">
<div class="flex justify-between text-cp-xs text-cp-text-secondary">
<span>已记录用量</span>
<span>完整{{ forecast.period === 'weekly' ? '周' : '月' }}预测</span>
<span>本周期已记录</span>
<span>{{ forecast.extrapolated ? `${forecast.targetDays}天折算容量` : '本周期预计总量' }}</span>
</div>
<div class="grid gap-2">
<div v-for="metric in metrics" :key="metric.label" class="rounded-cp bg-cp-bg-container px-3 py-3">
@@ -28,10 +28,13 @@ const { report, loading, refreshing, error, load, refresh } = useAccountQuotaFor
account => emit('accountUpdated', account),
)
const { now, pause, resume } = useNow({ interval: 30_000, controls: true })
const options = [
{ label: '7日额度', value: 'weekly' },
const options = computed(() => report.value?.forecasts.map(item => ({
label: item.extrapolated ? `${item.targetDays}天折算` : item.source?.label ?? (item.period === 'weekly' ? '周额度' : '月额度'),
value: item.period,
})) ?? [
{ label: '周额度', value: 'weekly' },
{ label: '月额度', value: 'monthly' },
]
])
const forecast = computed(() => report.value?.forecasts.find(item => item.period === period.value))
const unavailableReason = computed(() => {
if (forecast.value?.source && new Date(forecast.value.source.resetAt) <= now.value)
@@ -64,7 +67,7 @@ function handleExplanationKeydown(event: KeyboardEvent) {
<BaseModal
v-model="open"
title="额度预测"
description="按当前用量结构,估算完整周期的容量"
description="按本周期估算,重置后重新累计"
size="md"
tone="info"
>
@@ -72,9 +75,9 @@ function handleExplanationKeydown(event: KeyboardEvent) {
<ChartNoAxesCombined class="size-5 text-cp-primary-text" :stroke-width="1.75" />
</template>
<div class="grid gap-4">
<div class="grid grid-cols-1 gap-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<AccountIdentityCell :account="account" show-plan title-mode="email">
<AccountIdentityCell :account="account" show-plan title-mode="email" class="max-w-full">
<template #meta>
<ProviderIconGroup :provider="account.provider" size="sm" />
</template>
@@ -122,7 +125,10 @@ function handleExplanationKeydown(event: KeyboardEvent) {
仅供参考
</h4>
<p class="m-0">
按已记录数据估算,缺失的用量或费用可能使结果偏低,结果会随使用的模型和方式变化,并非官方承诺额度
本周期预计总量为已记录用量加预计剩余,剩余量按本周期内近期用量估算
</p>
<p class="m-0">
缺失的用量或费用可能使结果偏低,结果会随使用的模型和方式变化,并非官方承诺额度
</p>
<p v-if="forecast?.lowSample" class="m-0">
目前数据还较少,结果可能有较大波动
@@ -11,7 +11,9 @@ defineProps<{
rows: DashboardSnapshot['usageRecords']
}>()
const dashboardUsageRecordColumns = usageRecordColumns.filter(column => column.key !== 'actions')
const dashboardUsageRecordColumns = usageRecordColumns.filter(
column => column.key !== 'actions' && column.key !== 'clientApiKeyName',
)
</script>
<template>
@@ -8,7 +8,7 @@ import { keyUsageTime, money } from '../utils/format'
const props = defineProps<{ budget: KeyUsageBudget }>()
const windows = computed(() => [
{ label: '今日额度', limit: props.budget.dailyLimitUsd, used: props.budget.dailyUsedUsd, reset: props.budget.dailyResetsAt },
{ label: '7日额度', limit: props.budget.weeklyLimitUsd, used: props.budget.weeklyUsedUsd, reset: props.budget.weeklyResetsAt },
{ label: '周额度', limit: props.budget.weeklyLimitUsd, used: props.budget.weeklyUsedUsd, reset: props.budget.weeklyResetsAt },
].map(window => ({
...window,
limited: Number(window.limit) > 0,
@@ -47,10 +47,10 @@ const windows = computed(() => [
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex items-center justify-between gap-2 rounded-cp bg-cp-fill-quaternary p-3 text-cp-xs text-cp-text-secondary">
<span class="flex items-center gap-1.5"><Network class="size-3.5" />并发上限</span><strong class="font-mono">{{ budget.maxConcurrency || '∞' }}</strong>
<span class="flex items-center gap-1.5 leading-none"><Network class="size-3.5 shrink-0 -translate-y-px" />并发上限</span><strong class="font-mono">{{ budget.maxConcurrency || '∞' }}</strong>
</div>
<div class="flex items-center justify-between gap-2 rounded-cp bg-cp-fill-quaternary p-3 text-cp-xs text-cp-text-secondary">
<span class="flex items-center gap-1.5"><Gauge class="size-3.5" />每分钟请求</span><strong class="font-mono">{{ budget.requestsPerMinute || '∞' }}</strong>
<span class="flex items-center gap-1.5 leading-none"><Gauge class="size-3.5 shrink-0 -translate-y-px" />每分钟请求</span><strong class="font-mono">{{ budget.requestsPerMinute || '∞' }}</strong>
</div>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { KeyRound, LogOut, Moon, RefreshCw, Sun, Terminal } from '@lucide/vue'
import { Info, KeyRound, LogOut, Moon, RefreshCw, Sun, Terminal } from '@lucide/vue'
import { shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
@@ -10,7 +10,7 @@ import { useAuthStore } from '@/stores/modules/auth'
import { useThemeStore } from '@/stores/modules/theme'
defineProps<{ name?: string, prefix?: string, refreshing: boolean, configuring: boolean }>()
defineEmits<{ refresh: [], configure: [] }>()
defineEmits<{ refresh: [], configure: [], openAbout: [] }>()
const period = defineModel<string>('period', { required: true })
const refreshInterval = defineModel<string>('refreshInterval', { required: true })
const auth = useAuthStore()
@@ -45,13 +45,16 @@ async function logout() {
</template>
<template #actions>
<div class="flex max-w-[calc(100vw-32px)] flex-wrap items-center justify-end gap-2">
<BaseIconButton label="关于" variant="filled" @click="$emit('openAbout')">
<Info class="size-4" />
</BaseIconButton>
<BaseButton variant="secondary" :loading="configuring" @click="$emit('configure')">
<Terminal class="size-4" />
密钥配置
</BaseButton>
<BaseSelect v-model="period" aria-label="统计时间范围" class="w-29" :options="[{ label: '今天', value: 'today' }, { label: '近 7 天', value: '7d' }, { label: '近 30 天', value: '30d' }]" />
<BaseSelect v-model="refreshInterval" aria-label="自动刷新频率" class="w-30" :options="[{ label: '30 秒刷新', value: '30' }, { label: '60 秒刷新', value: '60' }, { label: '暂停刷新', value: '0' }]" />
<BaseIconButton label="刷新用量" variant="secondary" :loading="refreshing" @click="$emit('refresh')">
<BaseIconButton label="刷新用量" variant="filled" :loading="refreshing" @click="$emit('refresh')">
<RefreshCw class="size-4" />
</BaseIconButton>
<BaseIconButton label="切换主题" @click="theme.toggleTheme($event)">
@@ -56,7 +56,7 @@ const metrics = [
<BaseCard title="额度概览" aria-busy="true">
<div class="flex flex-1 flex-col justify-between gap-6" aria-hidden="true">
<div class="grid flex-1 gap-6 sm:grid-cols-2">
<div v-for="label in ['今日额度', '7日额度']" :key="label" class="flex min-w-0 flex-col justify-between gap-4">
<div v-for="label in ['今日额度', '周额度']" :key="label" class="flex min-w-0 flex-col justify-between gap-4">
<div>
<div class="text-cp-sm text-cp-text-secondary">
{{ label }}
@@ -3,6 +3,7 @@ import type { EChartsOption, LineSeriesOption } from 'echarts'
import type { KeyUsageTrendPoint } from '@/api/modules/key-usage'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { chartTooltipStyle } from '@/components/charts/tooltip'
import { useChartPalette } from '@/composables/useChartPalette'
@@ -67,8 +68,11 @@ const option = computed<EChartsOption>(() => {
<template>
<BaseCard title="使用趋势" description="用量随时间的变化">
<BaseChart v-if="points.some(point => point.requests > 0)" :option="option" :height="285" />
<div v-else class="grid h-71 place-items-center text-cp-sm text-cp-text-tertiary">
所选时间内暂无请求
</div>
<BaseEmpty
v-else
title="所选时间内暂无请求"
surface="none"
class="h-71 place-content-center"
/>
</BaseCard>
</template>
+26 -1
View File
@@ -1,6 +1,10 @@
<script setup lang="ts">
import type { KeyUsageVersion } from '@/api/modules/key-usage'
import { Search } from '@lucide/vue'
import { shallowRef } from 'vue'
import { getKeyUsageVersion } from '@/api/modules/key-usage'
import ApiKeyConfigModal from '@/components/ApiKeyConfigModal.vue'
import AppAboutModal from '@/components/AppAboutModal.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import RequestHealthTimelineCard from '@/views/dashboard/components/RequestHealthTimelineCard.vue'
@@ -17,6 +21,26 @@ import { keyUsageTime } from './utils/format'
const { period, model, kind, refreshInterval, overview, overviewLoading, overviewError, refreshing, recordsStale, records, refresh, changePageSize, changePage } = useKeyUsage()
const { items, currentPage, pageSize, total, loading: recordsLoading, error: recordsError } = records
const { showConfig, configKey, configuring, apiBaseUrl, openConfig, copyConfig } = useKeyConfig()
const aboutOpen = shallowRef(false)
const version = shallowRef<KeyUsageVersion | null>(null)
const versionLoading = shallowRef(false)
async function openAbout() {
aboutOpen.value = true
if (version.value || versionLoading.value)
return
versionLoading.value = true
try {
version.value = await getKeyUsageVersion({ silent: true })
}
catch {
// 版本不可用不影响查看项目信息,下次打开时重试。
}
finally {
versionLoading.value = false
}
}
</script>
<template>
@@ -24,7 +48,7 @@ const { showConfig, configKey, configuring, apiBaseUrl, openConfig, copyConfig }
<BaseScrollbar>
<div class="mx-auto flex min-h-full w-full max-w-480 flex-col gap-5 p-4 min-[961px]:p-6">
<div class="flex flex-col gap-2">
<KeyUsageHeader v-model:period="period" v-model:refresh-interval="refreshInterval" :name="overview?.key.name" :prefix="overview?.key.prefix" :refreshing="refreshing || overviewLoading" :configuring="configuring" @refresh="refresh" @configure="openConfig" />
<KeyUsageHeader v-model:period="period" v-model:refresh-interval="refreshInterval" :name="overview?.key.name" :prefix="overview?.key.prefix" :refreshing="refreshing || overviewLoading" :configuring="configuring" @refresh="refresh" @configure="openConfig" @open-about="openAbout" />
<div class="flex flex-wrap items-center justify-between gap-3">
<span v-if="overview" class="text-cp-sm text-cp-text-tertiary">更新于 {{ keyUsageTime(overview.asOf).slice(11) }}</span>
<BaseInput v-model="model" class="ml-auto w-60 max-w-full" placeholder="输入完整模型名称" aria-label="筛选统计和日志的模型" :maxlength="128">
@@ -50,5 +74,6 @@ const { showConfig, configKey, configuring, apiBaseUrl, openConfig, copyConfig }
</div>
</BaseScrollbar>
<ApiKeyConfigModal v-model="showConfig" title="密钥配置" :api-key="configKey" :api-base-url="apiBaseUrl" @copy="copyConfig" />
<AppAboutModal v-model="aboutOpen" :version="version" />
</main>
</template>
@@ -7,7 +7,7 @@ import { formatDateTime } from '@/utils/date'
const props = defineProps<{ apiKey: ApiKey }>()
const windows = computed(() => [
{ label: '日', heading: '日用量', used: props.apiKey.dailyUsedUsd, limit: props.apiKey.dailyLimitUsd, reset: props.apiKey.dailyResetsAt },
{ label: '周', heading: '7日用量', used: props.apiKey.weeklyUsedUsd, limit: props.apiKey.weeklyLimitUsd, reset: props.apiKey.weeklyResetsAt },
{ label: '周', heading: '周用量', used: props.apiKey.weeklyUsedUsd, limit: props.apiKey.weeklyLimitUsd, reset: props.apiKey.weeklyResetsAt },
])
function amount(value: string) {
// 列表最多显示两位小数,不补末尾零;明细保留原始金额的全部精度。
@@ -17,12 +17,12 @@ const { loading, run } = useAsyncAction()
const periods = [
{ label: '全部', value: 'all' },
{ label: '日额度', value: 'daily' },
{ label: '7日额度', value: 'weekly' },
{ label: '周额度', value: 'weekly' },
]
const windows = computed(() => props.apiKey
? [
{ value: 'daily', label: '日额度', used: props.apiKey.dailyUsedUsd },
{ value: 'weekly', label: '7日额度', used: props.apiKey.weeklyUsedUsd },
{ value: 'weekly', label: '周额度', used: props.apiKey.weeklyUsedUsd },
].map(window => ({ ...window, selected: period.value === 'all' || period.value === window.value }))
: [])
@@ -1,8 +1,9 @@
<script setup lang="ts">
import type { ApiKeyFormValue } from '../composables/useApiKeyMutations'
import type { AccountGroup } from '@/api'
import { Openai, Xai } from '@boxicons/vue'
import { Copy, DollarSign, KeyRound, Upload } from '@lucide/vue'
import { computed } from 'vue'
import { computed, shallowRef } from 'vue'
import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
import BaseButton from '@/components/base/BaseButton.vue'
@@ -11,8 +12,10 @@ import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import ClientProfileEditor from '@/components/client-profile/ClientProfileEditor.vue'
import XaiClientProfileEditor from '@/components/client-profile/XaiClientProfileEditor.vue'
import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers'
const props = defineProps<{
groups: AccountGroup[]
@@ -30,6 +33,11 @@ const open = defineModel<boolean>({ default: false })
const createdOpen = defineModel<boolean>('createdOpen', { default: false })
const form = defineModel<ApiKeyFormValue>('form', { required: true })
const title = computed(() => props.editing ? '编辑密钥' : '创建 API Key')
const profileProvider = shallowRef('openai')
const profileProviderOptions = [
{ label: PROVIDER_DISPLAY_NAMES.openai, value: 'openai', icon: Openai },
{ label: PROVIDER_DISPLAY_NAMES.xai, value: 'xai', icon: Xai },
]
</script>
<template>
@@ -88,11 +96,45 @@ const title = computed(() => props.editing ? '编辑密钥' : '创建 API Key')
/>
</BaseFormItem>
<BaseFormItem label="OpenAI 上游身份">
<ClientProfileEditor v-if="open" v-model="form.openaiClientProfileOverride" allow-inherit :disabled="saving" />
</BaseFormItem>
<BaseFormItem label="xAI 上游身份">
<XaiClientProfileEditor v-if="open" v-model="form.xaiClientProfileOverride" allow-inherit :disabled="saving" />
<BaseFormItem label="上游身份">
<ClientProfileEditor
v-if="open"
v-show="profileProvider === 'openai'"
v-model="form.openaiClientProfileOverride"
allow-inherit
:active="profileProvider === 'openai'"
:disabled="saving"
>
<template #source-extra>
<BaseSegmented
v-model="profileProvider"
label="上游身份平台"
class="w-20 shrink-0"
display="icon"
:options="profileProviderOptions"
:disabled="saving"
/>
</template>
</ClientProfileEditor>
<XaiClientProfileEditor
v-if="open"
v-show="profileProvider === 'xai'"
v-model="form.xaiClientProfileOverride"
allow-inherit
:active="profileProvider === 'xai'"
:disabled="saving"
>
<template #source-extra>
<BaseSegmented
v-model="profileProvider"
label="上游身份平台"
class="w-20 shrink-0"
display="icon"
:options="profileProviderOptions"
:disabled="saving"
/>
</template>
</XaiClientProfileEditor>
</BaseFormItem>
<div class="grid gap-6 sm:grid-cols-2">
@@ -111,13 +153,13 @@ const title = computed(() => props.editing ? '编辑密钥' : '创建 API Key')
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem label="7日限额">
<BaseFormItem label="周限额">
<BaseInput
v-model="form.weeklyLimitUsd"
type="number"
min="0"
step="any"
aria-label="7日限额(美元)"
aria-label="周限额(美元)"
placeholder="不限制"
:disabled="saving"
>
@@ -141,7 +141,7 @@ export function useApiKeyMutations(options: {
}
function validateForm() {
for (const [label, value] of [['日限额', form.value.dailyLimitUsd], ['7日限额', form.value.weeklyLimitUsd]]) {
for (const [label, value] of [['日限额', form.value.dailyLimitUsd], ['周限额', form.value.weeklyLimitUsd]]) {
if (value.trim() && !/^\d{1,10}(?:\.\d{1,10})?$/.test(value.trim())) {
toast.warning(`${label}必须是非负金额,最多 10 位小数`)
return false
@@ -117,6 +117,7 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageListRecord
accountId: `account_${options.id}`,
accountEmail: options.accountEmail,
accountName: options.accountEmail.split('@')[0] ?? null,
accountNotes: null,
route: stream ? '/v1/responses' : '/v1/chat/completions',
model: options.model,
requestedModel: options.model,
@@ -62,16 +62,12 @@ function itemValueClass(tone?: string, accent?: boolean) {
{{ usageBillingText(record) }}
</span>
<span
v-if="billing?.longContextBillingApplied"
class="inline-flex shrink-0 items-center rounded-cp-sm bg-cp-orange-container px-1 py-0.5 text-cp-xs leading-none font-heavy whitespace-nowrap text-cp-orange-on-container"
title="已应用长上下文计费;各项单价见费用明细"
aria-label="已应用长上下文计费"
<UsageDetailPopover
v-if="billing"
:title="billing.longContextBillingApplied ? '长上下文计费明细' : '计费明细'"
:trigger-label="billing.longContextBillingApplied ? '查看长上下文计费明细' : '查看费用明细'"
:tone="billing.longContextBillingApplied ? 'warning' : 'primary'"
>
长上下文
</span>
<UsageDetailPopover v-if="billing" title="计费明细" trigger-label="查看费用明细">
<div class="grid gap-1.5 text-cp-text-secondary">
<div v-for="item in amountItems" :key="item.label" class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-4">
<span class="whitespace-nowrap">{{ item.label }}</span>
@@ -7,6 +7,7 @@ import BasePopover from '@/components/base/BasePopover.vue'
defineProps<{
title: string
triggerLabel: string
tone?: 'primary' | 'warning'
}>()
</script>
@@ -18,7 +19,10 @@ defineProps<{
<template #trigger>
<button
type="button"
class="inline-flex size-4 items-center justify-center rounded-full bg-cp-primary-container text-cp-primary-on-container outline-none hover:bg-cp-primary-container-hover focus-visible:ring-2 focus-visible:ring-cp-control-outline"
class="inline-flex size-4 items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:class="tone === 'warning'
? 'bg-cp-warning-container text-cp-warning-on-container hover:bg-cp-warning-container-hover'
: 'bg-cp-primary-container text-cp-primary-on-container hover:bg-cp-primary-container-hover'"
:aria-label="triggerLabel"
>
<Info class="size-3" />
@@ -63,6 +63,13 @@ withDefaults(
>
{{ usageAccountText(row) }}
</span>
<span
v-if="row.accountNotes?.trim()"
class="mt-1 block max-w-full truncate text-cp-xs font-emphasis text-cp-text-quaternary"
:title="row.accountNotes"
>
{{ row.accountNotes }}
</span>
</template>
<template #clientIp="{ row }">
+2 -2
View File
@@ -6,7 +6,7 @@ import { formatProvider } from './utils/format'
type OpsErrorRow = OpsError
export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
{ key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: '2xl' },
{ key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: 'xl' },
{
key: 'accountEmail',
hideable: false,
@@ -36,7 +36,7 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
])
export const opsErrorColumns = defineTableColumns<OpsErrorRow>([
{ key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: '2xl' },
{ key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: 'xl' },
{ key: 'accountId', label: '账号', kind: 'identity', size: '3xl', emptyText: '未知账号', hideable: false },
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'sm' },
{ key: 'message', label: '错误', kind: 'custom', size: '4xl', hideable: false },
+11 -15
View File
@@ -1,27 +1,23 @@
# v3.12.0
# v3.13.0
## 新增功能
- OpenAI 与 xAI 客户端身份统一在「系统设置 → 上游配置」管理。xAI 支持跟随最新 Grok CLI 版本或固定版本、预览请求身份,并可为 API 密钥单独设置覆盖
- 密钥使用统计页新增「密钥配置」,持有者登录后可复制当前密钥的 Codex 配置,选择是否启用 WebSocket,或唤起 CCSwitch 导入;管理员密钥页复用同一配置弹窗
- 表格列支持拖动排序和方向键调整,列顺序与显隐偏好保存在当前浏览器,可一键恢复默认设置
- 管理端用量及错误记录显示关联密钥的当前名称;费用明细新增「长上下文」标识,按请求实际采用的价格区间记录,不按当前价格倒推历史标识
- CCSwitch 导入会同时配置当前客户端密钥的日/周额度查询,展示总额、已用和剩余金额并默认每 30 分钟刷新;新增只读的 `GET /v1/usage` 接口,查询不会调用上游、占用推理并发或产生使用记录
- 密钥使用统计页新增「关于」入口,客户端密钥持有者可查看当前版本号和提交号
- 管理端使用统计和 Dashboard 最近请求会在账号邮箱下显示账号的当前备注;同邮箱账号仍按内部账号 ID 区分,已删除账号的历史记录不会误关联备注
## 问题修复
- OpenAI 可选账号全部额度耗尽时,HTTP 与 WebSocket 返回 `429 / usage_limit_reached`,让 Codex 客户端停止本轮自动重试;其他可用 Provider 和可安全恢复的续写仍按原有规则尝试恢复
- 修复 OpenAI 额度窗口到期后仍显示旧用量的问题:有用量的正常账号也会在重置时间两分钟后主动复核,已耗尽账号继续周期复核以发现上游提前重置;仅在取得上游结果后更新额度状态
- Responses 输入中显式指定 `type: "message"`、`role: "system"` 的消息会转换为 Codex 接受的 `developer`,保留消息内容、顺序和其他字段,HTTP/SSE 与 WebSocket 使用一致规则
- 修复在线更新的发行通道筛选:正式版只接收同一大版本内更新的正式版,alpha、beta、rc 仅在同一目标版本内向前晋级,exp 仅接收同一轮实验的后续编号;不支持更新的构建不再显示可用更新,检查失败单独提示
- 更新 OpenAI TLS 依赖和握手基线,与 Codex Desktop 26.915 所用版本对齐
- 在线更新改为后台任务,长时间下载不再受 HTTP 请求生命周期影响;页面刷新或响应丢失后可恢复查看运行状态,更新完成待重启时会阻止重复执行。没有可用更新时继续显示当前版本的发布说明和链接
- OpenAI 账号容量冷却只统计明确的过载拒绝,普通 `5xx`、未识别上游错误和 `invalid_prompt` 不再误触发冻结;流式 `invalid_prompt` 按请求错误处理
- 账号额度预测保留当前周期已记录的用量,只估算剩余容量;额度重置后重新建立采样,避免把前一周期用量混入新周期。重新启用已停用账号时保留已有额度、凭据错误和冷却状态
- 修复第三方客户端或跨客户端继续会话时,OpenAI OAuth 上游拒绝 reasoning 历史的问题:HTTP 与 WebSocket 会移除不受支持的 `status`,并在已有加密历史时去除冗余明文内容,其他输入保持不变
## 体验优化
- 改善浅色主题中表格、输入框与按钮的层次和对比度,统一悬停及焦点反馈,优化表格行过渡和列拖动高亮
- 调整密钥额度、账号用量、使用统计与备注的布局和文字层级,精简周额度标签
- 长上下文计费详情改为主题一致的黄色提示图标,并补充悬停说明和无障碍标签
- 密钥额度文案统一使用「周」,CCSwitch 中不限额密钥明确显示「不限额」
## 升级说明
- 从 v3.11.0 升级不新增数据库迁移,已发布的 `0001`~`0016` SQL 及冻结清单保持不变
- 如需保留 OpenAI 数据驻留约束,请在启动新版本前将 `openai.wire_profile.residency` 移到 `openai.residency`。旧 `openai.wire_profile` 和 `xai.wire_profile` 不再读取;数据库中已有的 OpenAI 身份保留,原先在 YAML 中定制的 xAI 身份需在管理端重新设置,未设置时使用内置 Linux x86_64 身份并跟随最新版本
- 旧环境变量 `CPR_UPDATE_CHANNEL` 不再参与版本选择,可移除;升级后的发行通道由当前版本号决定。实验版切换正式版仍需使用独立数据库迁移业务数据,不能直接复用实验数据库
- 从 v3.12.1 升级不新增数据库迁移,也无需调整配置
+1 -1
View File
@@ -1 +1 @@
version: "3.12.0"
version: "3.13.0"