mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-11 08:06:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
debba0e13c | ||
|
|
a0836ec3f2 | ||
|
|
dc0cdf2930 | ||
|
|
d76bb57b8c | ||
|
|
6434edd554 | ||
|
|
3a90d8d0b6 | ||
|
|
f3236d0410 | ||
|
|
ccb7357914 | ||
|
|
c0ad2ae656 | ||
|
|
798d4f518e | ||
|
|
6819682176 | ||
|
|
b2d4a7e833 | ||
|
|
01006380ee | ||
|
|
c79e032575 | ||
|
|
96cf46cca8 |
@@ -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. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
|
||||
|
||||
@@ -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) 开源
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 备份恢复属于人工维护操作。当前没有部署级维护模式开关,需要先停止应用,
|
||||
离线处理快照中的非终态任务、计划游标和到期清理条件,再重新验证对象存储并恢复计划。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-6
@@ -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,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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()}`
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
version: "3.12.0"
|
||||
version: "3.13.0"
|
||||
|
||||
Reference in New Issue
Block a user