Compare commits

...
7 Commits
Author SHA1 Message Date
ZyyandCodex 3bebccac5d chore: bump version to 3.6.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-13 03:50:04 +08:00
ZyyandCodex 98287195ca docs(release): prepare v3.6.0 notes
Co-authored-by: Codex <noreply@openai.com>
2026-09-13 03:49:59 +08:00
ZyyandCodex fd1b159f71 docs: clarify document responsibilities and simplify setup
Keep documentation focused on current contracts, responsibilities, and required operations. Define when PRs need documentation updates and consolidate setup commands with explicit ownership and permissions.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 03:43:15 +08:00
Yanshan ChenandCodex 7b03fdc8ab feat(keys): support custom keys and search by name
Support optional custom client keys across creation, storage, and authentication. Search keys and request records by name, remove credential and prefix searches, and reject duplicate names during create and update using the existing control-plane transaction lock.

Simplify the request diagnostics panel and use the shared empty state.

Validated with 1,029 backend tests using dedicated PostgreSQL and Redis, strict workspace Clippy, frontend lint/typecheck/build, and browser checks for key creation, name validation, search, diagnostics refresh/export, and mobile layouts.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 03:21:42 +08:00
Yanshan ChenandCodex 851a50962d fix(admin): support HTTP login and clipboard without configuration
Support same-origin HTTP admin sessions without an additional configuration setting. Derive Cookie security from a validated browser Origin, retain Secure for HTTPS and unknown origins, and enable VueUse's existing clipboard fallback.

Validated with 312 API tests, 36 gateway tests, strict workspace Clippy, frontend lint/typecheck/build, and browser login/reload/logout over HTTP and HTTPS-to-HTTP proxying.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 02:41:05 +08:00
ZyyandCodex b6e17db0c6 fix(openai): preserve native Codex model catalogs
Forward complete upstream model objects and client versions without rebuilding metadata. Keep account-scoped catalogs in a bounded, coalesced cache with explicit invalidation and preserve model aliases without mixing fields.

Cover service tiers, native metadata, account isolation, cache behavior, and failure handling with regression tests; document the catalog contract.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 02:02:17 +08:00
ZyyandCodex b98c2f01a5 fix(accounts): fix quota forecasts and simplify capacity view
Exclude confirmed OpenAI prewarm requests from completed usage while
preserving audit records and unknown inference pricing.

Keep only the quota capacity card, trim unused Admin/API diagnostics,
and use the actual quota observation time without changing forecast gates.
Reduce warning noise and align capacity headings with the cards.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 00:45:26 +08:00
76 changed files with 2573 additions and 784 deletions
+19
View File
@@ -37,6 +37,21 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
区分已被主分支覆盖的实现、仍有价值的修复或回归测试,以及与目标无关的改动;不能仅凭标题或代码相似认定重复。
说明原问题发生版本和当前剩余差异,创建时据此收敛范围,审查时报告保留或拆分建议;这不授权自动关闭或修改他人的 PR。
## 文档职责与更新条件
本仓库的常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
- 按 [开发指南的文档入口](../dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
不按每次 PR 追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue。版本变更与升级影响按发版任务维护
`release/notes.md`,不为普通 PR 自动追加发布说明。
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
## 创建 PR
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
@@ -46,6 +61,8 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
实现应职责清晰、表达直接、复用合理;提交前主动清理本次变更引入的重复逻辑、冗余状态、死代码、
多余抽象及无依据的兼容分支。功能可用和检查通过不能替代可维护性自查。
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
@@ -78,6 +95,8 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
+2 -2
View File
@@ -81,7 +81,7 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。行为或合同变更同步到架构文档指定的文档位置,发布说明按维护者的发布安排更新。
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。改动使现有说明失真或缺少必要信息时,按文档职责修订对应内容;变更经过留在 PR,发布说明按维护者的发布安排更新。
## 界面验证
@@ -113,7 +113,7 @@ pnpm --dir frontend build
git diff --check
```
`build` 已包含类型检查;按当前架构约定,不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security-scan.yml)。
+6 -13
View File
@@ -5,7 +5,7 @@
# Codex Proxy RS
面向 Codex 的自托管多账号 AI 网关。
面向 Codex 的自托管多账号 AI 网关
[![CI](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/zyycn/codex-proxy-rs?display_name=tag&sort=semver&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/releases)
@@ -28,21 +28,16 @@
### 1. 下载部署文件并配置
```bash
mkdir -p codex-proxy-rs/deploy
cd codex-proxy-rs
mkdir -p codex-proxy-rs/deploy && cd codex-proxy-rs
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/compose.yaml \
-o deploy/compose.yaml
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/config.example.yaml \
-o deploy/config.example.yaml
mkdir -p .runtime/data .runtime/logs
install -d -m 0750 .runtime/postgres .runtime/redis
cp deploy/config.example.yaml deploy/config.yaml
sudo chown "$(id -u):10001" deploy/config.yaml
chmod 0640 deploy/config.yaml
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
chmod 0770 .runtime/data .runtime/logs
sudo install -d -m 0770 -o "$(id -u)" -g 10001 .runtime/data .runtime/logs
sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
```
分别生成数据库和 Redis 密码:
@@ -60,8 +55,6 @@ openssl rand -hex 24
| `store.redis.password` | 第二个生成的 48 位十六进制密码 |
| `admin.default_password` | 管理员初始密码,至少 12 位,不能包含 `$` |
不需要创建 `.env`。管理员初始密码只在首次创建账号时使用。
### 2. 启动服务
```bash
@@ -79,7 +72,7 @@ curl -i http://127.0.0.1:8080/healthz
### 3. 添加账号与客户端密钥
1. 在「账号」中添加 OpenAI 或 xAI 账号,完成授权或导入。
1. 在「账号」中添加账号,完成授权或导入。
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
3. 打开密钥的「使用密钥」,复制客户端配置。
@@ -93,7 +86,7 @@ curl -i http://127.0.0.1:8080/healthz
| 配置 | 值 |
| --- | --- |
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
| API Key | 管理端创建的 `sk_...` 客户端密钥 |
| API Key | 管理端创建的客户端密钥 |
可用模型以该密钥查询到的模型列表为准:
@@ -6,7 +6,7 @@ use chrono::{DateTime, Utc};
use gateway_core::{
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
policy::{ClientApiKeyId, RateLimits},
policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits},
routing::{AccountGroupId, ProviderKind},
};
@@ -144,6 +144,7 @@ impl fmt::Debug for ClientKeySecret {
/// API 提交的 Client Key 创建命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub custom_key: Option<PlaintextClientApiKey>,
pub name: String,
pub label: Option<String>,
pub group_ids: Vec<AccountGroupId>,
@@ -26,7 +26,6 @@ pub struct AccountQuotaForecast {
pub low_sample: bool,
pub incomplete_cost: bool,
pub incomplete_tokens: bool,
pub method: QuotaForecastMethod,
pub estimated_tokens: Option<u64>,
pub estimated_usd: Option<f64>,
/// 剩余估算始终属于源窗口,不随目标周期折算。
@@ -36,30 +35,12 @@ pub struct AccountQuotaForecast {
#[derive(Debug, Clone, PartialEq)]
pub struct QuotaForecastSource {
pub key: String,
pub label: String,
pub window_seconds: u64,
pub used_percent: Option<f64>,
pub observed_at: Option<DateTime<Utc>>,
pub start_at: Option<DateTime<Utc>>,
pub reset_at: DateTime<Utc>,
pub request_count: u64,
pub tokens: Option<u64>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub known_cost_count: u64,
pub partial_cost_count: u64,
pub unavailable_cost_count: u64,
pub usd: Option<f64>,
pub sample_start_at: Option<DateTime<Utc>>,
pub baseline_percent: f64,
pub sampled_percent: Option<f64>,
pub block_count: usize,
pub observation_count: usize,
pub missing_token_count: u64,
pub excluded_request_count: u64,
pub pending_request_count: u64,
}
/// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算。
@@ -88,7 +69,6 @@ pub fn account_quota_forecasts(
low_sample: false,
incomplete_cost: false,
incomplete_tokens: false,
method: QuotaForecastMethod::Cumulative,
estimated_tokens: None,
estimated_usd: None,
remaining_tokens: None,
@@ -138,30 +118,12 @@ impl AccountQuotaForecast {
.and_then(Duration::try_seconds)
.and_then(|duration| reset_at.checked_sub_signed(duration));
self.source = Some(QuotaForecastSource {
key: window.key.clone(),
label: window.label.clone(),
window_seconds: seconds,
used_percent: percent,
observed_at,
start_at: start,
reset_at,
request_count: usage.map_or(0, |usage| usage.request_count),
tokens: usage.map(|usage| usage.tokens),
input_tokens: usage.map(|usage| usage.input_tokens),
output_tokens: usage.map(|usage| usage.output_tokens),
cached_tokens: usage.map(|usage| usage.cached_tokens),
known_cost_count: usage.map_or(0, |usage| usage.known_cost_count),
partial_cost_count: 0,
unavailable_cost_count: usage.map_or(0, |usage| usage.unavailable_cost_count),
usd,
sample_start_at: sample.map(|sample| sample.start_at),
baseline_percent: sample.map_or(0.0, |sample| sample.baseline_percent),
sampled_percent: sample.map(|sample| sample.sampled_percent),
block_count: sample.map_or(0, |sample| sample.block_count),
observation_count: sample.map_or(0, |sample| sample.observation_count),
missing_token_count: usage.map_or(0, |usage| usage.missing_token_count),
excluded_request_count: usage.map_or(0, |usage| usage.excluded_request_count),
pending_request_count: sample.map_or(0, |sample| sample.pending_request_count),
});
self.incomplete_cost = usage.is_none_or(|usage| {
usage.unavailable_cost_count > 0
@@ -169,7 +131,7 @@ impl AccountQuotaForecast {
|| usage.known_cost_count != usage.request_count
});
self.incomplete_tokens = usage.is_some_and(|usage| usage.missing_token_count > 0);
self.method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method);
let method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method);
let Some(start) = start.filter(|start| *start <= now && now < reset_at) else {
self.unavailable_reason = Some("额度窗口已过期或边界无效,请刷新账号额度后重试。");
return;
@@ -183,7 +145,7 @@ impl AccountQuotaForecast {
Some("额度观测出现回落或累计记录不连续,正在重新积累配对样本。");
return;
}
if account_added_at > start && self.method == QuotaForecastMethod::Cumulative {
if account_added_at > start && method == QuotaForecastMethod::Cumulative {
self.unavailable_reason = Some(
"本周期开始时的记录不完整,正在积累至少 5 个百分点的配对观测,无需等待下次重置。",
);
@@ -210,7 +172,7 @@ impl AccountQuotaForecast {
return;
};
self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT
|| (self.method == QuotaForecastMethod::Incremental && sample.block_count < 2);
|| (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;
@@ -89,16 +89,6 @@ pub enum QuotaForecastMethod {
Incremental,
}
impl QuotaForecastMethod {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Cumulative => "cumulative",
Self::Incremental => "incremental",
}
}
}
#[derive(Debug, Clone)]
pub struct QuotaForecastSample {
pub key: String,
@@ -44,6 +44,7 @@ pub enum AdminStoreErrorKind {
Invalid,
NotFound,
StaleRevision,
DuplicateName,
Conflict,
Unavailable,
}
@@ -18,7 +18,7 @@ use crate::{
DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey,
},
},
ports::store::ClientKeyStore,
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
};
use super::{map_store_error, publish_committed};
@@ -87,9 +87,13 @@ impl ClientKeyService for DefaultClientKeyService {
) -> Result<CreatedClientKey, AdminError> {
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes));
let plaintext = if let Some(key) = command.custom_key {
key.expose_for_auth().to_owned()
} else {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
};
let (config_revision, record) = self
.store
.create_client_key(
@@ -105,7 +109,7 @@ impl ClientKeyService for DefaultClientKeyService {
context,
)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
.map_err(map_client_key_write_error)?;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
Ok(CreatedClientKey {
config_revision,
@@ -123,7 +127,7 @@ impl ClientKeyService for DefaultClientKeyService {
.store
.update_client_key(command, context)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
.map_err(map_client_key_write_error)?;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
Ok(ClientKeyMutation {
config_revision,
@@ -171,6 +175,14 @@ impl ClientKeyService for DefaultClientKeyService {
}
}
fn map_client_key_write_error(error: AdminStoreError) -> AdminError {
match error.kind() {
AdminStoreErrorKind::DuplicateName => AdminError::conflict("名称已存在"),
AdminStoreErrorKind::Conflict => AdminError::conflict("API Key 已存在,请使用其他密钥"),
_ => map_store_error(error, "client API key"),
}
}
fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
let Some(cursor) = &query.cursor else {
return Ok(());
@@ -42,9 +42,9 @@ fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError
let kind = match error.kind() {
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
AdminStoreErrorKind::StaleRevision | AdminStoreErrorKind::Conflict => {
AdminErrorKind::Conflict
}
AdminStoreErrorKind::StaleRevision
| AdminStoreErrorKind::DuplicateName
| AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict,
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
};
tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed");
@@ -129,8 +129,8 @@ fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() {
#[test]
fn corresponding_actual_window_takes_priority_and_retains_its_duration() {
let [week, month] = forecast(&quota(vec![window("month", 31), window("week", 7)]));
assert_eq!(week.source.unwrap().key, "week");
assert_eq!(month.source.unwrap().key, "month");
assert_eq!(week.source.unwrap().label, "week");
assert_eq!(month.source.unwrap().label, "month");
assert!(!month.extrapolated);
assert_eq!(month.target_seconds, 31 * 86_400);
assert_eq!(month.estimated_tokens, Some(5_000));
@@ -241,7 +241,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
assert_eq!(week.estimated_tokens, Some(10_000));
assert_eq!(week.remaining_tokens, Some(8_000));
assert_eq!(month.remaining_tokens, Some(8_000));
assert_eq!(week.method, QuotaForecastMethod::Incremental);
assert!(week.unavailable_reason.is_none());
assert!(!week.low_sample);
}
@@ -255,7 +255,7 @@ fn missing_tokens_suppress_token_estimates_without_discarding_complete_costs() {
assert_eq!(week.estimated_tokens, None);
assert_eq!(week.remaining_tokens, None);
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.source.unwrap().missing_token_count, 1);
assert_eq!(week.source.unwrap().tokens, Some(1_000));
}
#[test]
@@ -1825,9 +1825,7 @@ async fn quota_forecast_does_not_query_usage_without_a_current_snapshot() {
#[tokio::test]
async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_changed_bucket() {
use gateway_admin::model::quota_forecast_sampling::{
QuotaForecastHistoryPoint, QuotaForecastMethod,
};
use gateway_admin::model::quota_forecast_sampling::QuotaForecastHistoryPoint;
let now = Utc::now();
let observed = now - TimeDelta::minutes(1);
let reset = now + TimeDelta::days(1);
@@ -1884,7 +1882,7 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
assert_eq!(result.forecasts[0].method, QuotaForecastMethod::Incremental);
assert!(result.forecasts[0].unavailable_reason.is_none());
assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000));
assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000));
assert_eq!(store.quota_window_queries()[0].range.start, added);
@@ -1,22 +1,31 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use gateway_core::policy::ClientApiKeyId;
use chrono::Utc;
use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits};
use gateway_admin::{
model::{
AdminErrorKind, MutationContext, Revision,
AdminErrorKind, MutationActor, MutationContext, Revision,
client_keys::{
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPage,
ClientKeyPageSize, ClientKeyRecord, ClientKeySecret, ClientKeySort, ClientKeySortField,
DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
CreateClientKey, DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection,
UpdateClientKey,
},
},
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ClientKeyStore},
};
struct UnusedClientKeyStore;
#[derive(Default)]
struct TestClientKeyStore {
plaintexts: Mutex<Vec<String>>,
create_error: Option<AdminStoreErrorKind>,
update_error: Option<AdminStoreErrorKind>,
}
#[async_trait]
impl ClientKeyStore for UnusedClientKeyStore {
impl ClientKeyStore for TestClientKeyStore {
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
assert_eq!(query.page_size.get(), u16::MAX);
Ok(ClientKeyPage {
@@ -36,10 +45,32 @@ impl ClientKeyStore for UnusedClientKeyStore {
async fn create_client_key(
&self,
_: NewClientKey,
key: NewClientKey,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
Err(unused())
if let Some(kind) = self.create_error {
return Err(AdminStoreError::new(
kind,
"client key",
"test create failure",
));
}
self.plaintexts.lock().unwrap().push(key.plaintext);
let record = ClientKeyRecord {
id: key.id,
name: key.name,
label: key.label,
groups: Vec::new(),
provider_kinds: Vec::new(),
prefix: String::new(),
enabled: true,
limits: key.limits,
budget: Default::default(),
last_used_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
Ok((Revision::new(2).unwrap(), record))
}
async fn update_client_key(
@@ -47,6 +78,13 @@ impl ClientKeyStore for UnusedClientKeyStore {
_: UpdateClientKey,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
if let Some(kind) = self.update_error {
return Err(AdminStoreError::new(
kind,
"client key",
"test update failure",
));
}
Err(unused())
}
@@ -70,7 +108,7 @@ impl ClientKeyStore for UnusedClientKeyStore {
#[tokio::test]
async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
let services = super::AdminHarness::new()
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
.client_keys(Arc::new(TestClientKeyStore::default()))
.build()
.await;
let sort = ClientKeySort {
@@ -98,7 +136,7 @@ async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
#[tokio::test]
async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
let services = super::AdminHarness::new()
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
.client_keys(Arc::new(TestClientKeyStore::default()))
.build()
.await;
let page = services
@@ -118,6 +156,105 @@ async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
assert_eq!(page.total, 0);
}
#[tokio::test]
async fn create_preserves_migrated_keys_and_keeps_default_generation() {
let store = Arc::new(TestClientKeyStore::default());
let services = super::AdminHarness::new()
.client_keys(store.clone())
.build()
.await;
for value in [Some("q".to_owned()), Some("legacy+/=:!".repeat(1024)), None] {
let created = services
.client_keys()
.create(&mutation_context(), create_command(value.as_deref()))
.await
.unwrap();
let plaintext = created.secret.expose_for_response();
assert_eq!(store.plaintexts.lock().unwrap().last().unwrap(), plaintext);
if let Some(value) = value {
assert_eq!(plaintext, value);
} else {
assert!(plaintext.starts_with("sk_"));
assert_eq!(plaintext.len(), 46);
}
}
}
#[tokio::test]
async fn duplicate_keys_return_actionable_conflicts_without_disclosing_the_key() {
let services = super::AdminHarness::new()
.client_keys(Arc::new(TestClientKeyStore {
create_error: Some(AdminStoreErrorKind::Conflict),
..Default::default()
}))
.build()
.await;
let key = "legacy-duplicate-must-stay-private";
let error = services
.client_keys()
.create(&mutation_context(), create_command(Some(key)))
.await
.unwrap_err();
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert_eq!(error.message(), "API Key 已存在,请使用其他密钥");
assert!(!format!("{error:?}").contains(key));
}
#[tokio::test]
async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_update() {
let services = super::AdminHarness::new()
.client_keys(Arc::new(TestClientKeyStore {
create_error: Some(AdminStoreErrorKind::DuplicateName),
update_error: Some(AdminStoreErrorKind::DuplicateName),
..Default::default()
}))
.build()
.await;
let created = services
.client_keys()
.create(&mutation_context(), create_command(None))
.await
.unwrap_err();
let updated = services
.client_keys()
.update(
&mutation_context(),
UpdateClientKey {
id: ClientApiKeyId::new("key_existing").unwrap(),
name: "Migration".to_owned(),
label: None,
group_ids: Vec::new(),
limits: RateLimits::unlimited(),
daily_limit_usd: None,
weekly_limit_usd: None,
},
)
.await
.unwrap_err();
for error in [created, updated] {
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert_eq!(error.message(), "名称已存在");
}
}
fn create_command(key: Option<&str>) -> CreateClientKey {
CreateClientKey {
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
name: "Migration".to_owned(),
label: None,
group_ids: Vec::new(),
limits: RateLimits::unlimited(),
budget: Default::default(),
}
}
fn mutation_context() -> MutationContext {
MutationContext {
actor: MutationActor::System,
request_id: "custom-key-test".to_owned(),
}
}
fn unused() -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
@@ -2,7 +2,6 @@
use super::*;
use gateway_admin::model::quota_forecast::{AccountQuotaForecast, AccountQuotaForecastReport};
use gateway_admin::model::quota_forecast_sampling::QuotaForecastMethod;
pub(super) fn account_page_data(
result: AccountDirectoryPage,
@@ -129,7 +128,6 @@ impl From<AccountQuotaForecastReport> for AccountQuotaForecastData {
Self {
account_id: report.account_id,
generated_at: china_rfc3339(&report.generated_at),
generated_at_display: china_datetime(&report.generated_at),
forecasts: report
.forecasts
.into_iter()
@@ -148,9 +146,7 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
target_days: forecast.target_seconds as f64 / 86_400.0,
extrapolated: forecast.extrapolated,
source: forecast.source.map(|source| QuotaForecastSourceView {
key: source.key,
label: source.label,
window_days: source.window_seconds as f64 / 86_400.0,
used_percent: source.used_percent,
used_percent_display: source
.used_percent
@@ -159,48 +155,14 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
observed_at_display: source
.observed_at
.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
start_at: source.start_at.map(|value| china_rfc3339(&value)),
start_at_display: source
.start_at
.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
reset_at: china_rfc3339(&source.reset_at),
reset_at_display: china_datetime(&source.reset_at),
request_count_display: format_number(source.request_count),
tokens_display: display_optional_tokens(source.tokens),
input_tokens_display: display_optional_tokens(source.input_tokens),
output_tokens_display: display_optional_tokens(source.output_tokens),
cached_tokens_display: display_optional_tokens(source.cached_tokens),
known_cost_count: source.known_cost_count,
known_cost_count_display: format_number(source.known_cost_count),
partial_cost_count: source.partial_cost_count,
partial_cost_count_display: format_number(source.partial_cost_count),
unavailable_cost_count: source.unavailable_cost_count,
unavailable_cost_count_display: format_number(source.unavailable_cost_count),
usd_display: forecast_usd_display(source.usd),
sample_start_at: source.sample_start_at.map(|value| china_rfc3339(&value)),
sample_start_at_display: source
.sample_start_at
.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
baseline_percent: source.baseline_percent,
sampled_percent: source.sampled_percent,
sampled_percent_display: source
.sampled_percent
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1} 个百分点")),
block_count: source.block_count,
observation_count: source.observation_count,
missing_token_count: source.missing_token_count,
excluded_request_count: source.excluded_request_count,
pending_request_count: source.pending_request_count,
}),
unavailable_reason: forecast.unavailable_reason,
low_sample: forecast.low_sample,
incomplete_cost: forecast.incomplete_cost,
incomplete_tokens: forecast.incomplete_tokens,
method: forecast.method.as_str(),
method_display: match forecast.method {
QuotaForecastMethod::Cumulative => "窗口累计估算",
QuotaForecastMethod::Incremental => "近期分段估算",
},
estimated_tokens: forecast.estimated_tokens,
estimated_tokens_display: display_optional_tokens(forecast.estimated_tokens),
estimated_usd: forecast.estimated_usd,
@@ -278,7 +278,6 @@ pub struct AccountView {
pub struct AccountQuotaForecastData {
pub account_id: String,
pub generated_at: String,
pub generated_at_display: String,
pub forecasts: Vec<AccountQuotaForecastView>,
}
@@ -293,8 +292,6 @@ pub struct AccountQuotaForecastView {
pub low_sample: bool,
pub incomplete_cost: bool,
pub incomplete_tokens: bool,
pub method: &'static str,
pub method_display: &'static str,
pub estimated_tokens: Option<u64>,
pub estimated_tokens_display: String,
pub estimated_usd: Option<f64>,
@@ -308,39 +305,14 @@ pub struct AccountQuotaForecastView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QuotaForecastSourceView {
pub key: String,
pub label: String,
pub window_days: f64,
pub used_percent: Option<f64>,
pub used_percent_display: String,
pub observed_at: Option<String>,
pub observed_at_display: String,
pub start_at: Option<String>,
pub start_at_display: String,
pub reset_at: String,
pub reset_at_display: String,
pub request_count_display: String,
pub tokens_display: String,
pub input_tokens_display: String,
pub output_tokens_display: String,
pub cached_tokens_display: String,
pub known_cost_count: u64,
pub known_cost_count_display: String,
pub partial_cost_count: u64,
pub partial_cost_count_display: String,
pub unavailable_cost_count: u64,
pub unavailable_cost_count_display: String,
pub usd_display: String,
pub sample_start_at: Option<String>,
pub sample_start_at_display: String,
pub baseline_percent: f64,
pub sampled_percent: Option<f64>,
pub sampled_percent_display: String,
pub block_count: usize,
pub observation_count: usize,
pub missing_token_count: u64,
pub excluded_request_count: u64,
pub pending_request_count: u64,
}
#[derive(Debug, Clone, Serialize)]
+33 -5
View File
@@ -5,7 +5,11 @@ use std::fmt;
use axum::{
Router,
extract::{FromRequestParts, State},
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, request::Parts},
http::{
HeaderMap, HeaderValue, StatusCode,
header::{ORIGIN, SET_COOKIE},
request::Parts,
},
response::{IntoResponse, Response},
routing::{get, post},
};
@@ -15,18 +19,37 @@ use gateway_admin::{
};
use serde::{Deserialize, Serialize};
use tower_http::request_id::RequestId;
use url::Url;
use super::{AdminEnvelope, AdminError, AdminJson, AdminResponse};
const REQUEST_ID_HEADER: &str = "x-request-id";
const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session";
const ADMIN_SESSION_COOKIE_ATTRS: &str = "Path=/; Secure; HttpOnly; SameSite=Lax";
/// 所有管理 HTTP 模块从 state 消费同一个认证用例端口。
pub trait AdminSessionState {
fn admin_services(&self) -> &AdminServices;
}
fn admin_session_cookie_attrs(headers: &HeaderMap) -> &'static str {
// 同源管理端的 POST 由浏览器携带 Origin,HTTPS 反代回源 HTTP 不改变它。
// 只对明确的 HTTP 来源放宽;缺失、opaque 或非法来源继续使用 Secure。
let http_origin = headers.get_all(ORIGIN).iter().count() == 1
&& headers
.get(ORIGIN)
.and_then(|value| value.to_str().ok())
.is_some_and(|origin| {
Url::parse(origin).is_ok_and(|url| {
url.scheme() == "http" && url.origin().ascii_serialization() == origin
})
});
if http_origin {
"Path=/; HttpOnly; SameSite=Lax"
} else {
"Path=/; Secure; HttpOnly; SameSite=Lax"
}
}
/// 已通过管理员会话或部署级管理 API Key 鉴权的请求。
pub struct AdminAuth {
context: AdminRequestContext,
@@ -203,6 +226,7 @@ where
async fn login<S>(
State(state): State<S>,
headers: HeaderMap,
AdminJson(payload): AdminJson<AdminLoginRequest>,
) -> Result<Response, AdminError>
where
@@ -221,8 +245,9 @@ where
)
.into_response();
let cookie = format!(
"{ADMIN_SESSION_COOKIE}={}; {ADMIN_SESSION_COOKIE_ATTRS}",
session.session_id
"{ADMIN_SESSION_COOKIE}={}; {}",
session.session_id,
admin_session_cookie_attrs(&headers)
);
response.headers_mut().insert(
SET_COOKIE,
@@ -260,7 +285,10 @@ where
let mut response =
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(AdminLogoutData::new()))
.into_response();
let cookie = format!("{ADMIN_SESSION_COOKIE}=; {ADMIN_SESSION_COOKIE_ATTRS}; Max-Age=0");
let cookie = format!(
"{ADMIN_SESSION_COOKIE}=; {}; Max-Age=0",
admin_session_cookie_attrs(&headers)
);
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
@@ -13,7 +13,7 @@ use gateway_admin::model::client_keys::{
use gateway_core::{
engine::budget::ClientBudgetLimits,
metering::Decimal,
policy::{ClientApiKeyId, RateLimits},
policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits},
routing::AccountGroupId,
};
use serde::{Deserialize, Serialize};
@@ -70,9 +70,7 @@ impl ListClientKeysQuery {
}
let search = self.search.map(|search| search.trim().to_owned());
if search.as_deref().is_some_and(|search| {
search.len() > MAX_SEARCH_BYTES
|| search.chars().any(char::is_control)
|| contains_client_key_material(search)
search.len() > MAX_SEARCH_BYTES || search.chars().any(char::is_control)
}) {
return Err(WireValidationError::new("search"));
}
@@ -143,9 +141,10 @@ impl ClientKeySort {
}
/// 创建 Client Key 请求。
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[derive(Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateClientKeyRequest {
custom_key: Option<String>,
name: String,
label: Option<String>,
group_ids: Vec<String>,
@@ -155,6 +154,16 @@ pub struct CreateClientKeyRequest {
weekly_limit_usd: Option<String>,
}
impl fmt::Debug for CreateClientKeyRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CreateClientKeyRequest")
.field("name", &self.name)
.field("custom_key", &"[REDACTED]")
.finish_non_exhaustive()
}
}
impl CreateClientKeyRequest {
/// 校验 wire 边界并直接构造管理用例命令。
pub fn into_command(self) -> Result<CreateClientKey, WireValidationError> {
@@ -164,6 +173,12 @@ impl CreateClientKeyRequest {
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(CreateClientKey {
custom_key: self
.custom_key
.filter(|key| !key.is_empty())
.map(PlaintextClientApiKey::new)
.transpose()
.map_err(|_| WireValidationError::new("customKey"))?,
name: self.name,
label: self.label,
group_ids,
@@ -661,15 +676,6 @@ fn validate_optional_text(
Ok(())
}
fn contains_client_key_material(value: &str) -> bool {
value.as_bytes().windows(46).any(|window| {
&window[..3] == b"sk_"
&& window[3..]
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
})
}
/// 构造固定 GET/POST 且 ID 仅位于 query/body 的 Client API Key 路由。
pub fn router<S>() -> Router<S>
where
@@ -86,7 +86,7 @@ impl ClientApiKeyAuthError {
///
/// # Errors
///
/// Header 缺失、Bearer 语法错误或不是网关 Client Key 前缀时返回稳定错误。
/// Header 缺失、Bearer 语法错误或 Key 不能作为 HTTP Bearer 值时返回稳定错误。
pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAuthError> {
let raw = headers
.get(AUTHORIZATION)
@@ -100,7 +100,7 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu
if token.is_empty() {
return Err(ClientApiKeyAuthError::MalformedAuthorization);
}
if !token.starts_with("sk_") {
if gateway_core::policy::PlaintextClientApiKey::validate(token).is_err() {
return Err(ClientApiKeyAuthError::InvalidKeyFormat);
}
Ok(token)
@@ -6,7 +6,7 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use gateway_core::routing::{PublicModelId, PublicModelProfile};
use gateway_core::routing::{PublicModelDescriptor, PublicModelId, PublicModelProfile};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -14,7 +14,7 @@ use crate::ApiState;
use super::{
auth::{authenticate_client, client_access_error_response},
error::model_not_found_response,
error::{model_not_found_response, openai_error_response},
};
const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000;
@@ -37,19 +37,53 @@ pub(crate) async fn models(
Err(error) => return client_access_error_response(error),
};
if query
if let Some(version) = query
.client_version
.as_deref()
.is_some_and(|version| !version.trim().is_empty())
.filter(|version| !version.trim().is_empty())
{
let models = service
.public_model_profiles(&client)
if version.len() > 64
|| !version
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b".-+".contains(&byte))
{
return openai_error_response(
StatusCode::BAD_REQUEST,
"Invalid client_version",
"invalid_request_error",
"invalid_client_version",
)
.into_response();
}
let catalog = match service.client_model_catalog(&client, version).await {
Ok(catalog) => catalog,
Err(_) => return catalog_unavailable_response(),
};
let models = catalog
.iter()
.enumerate()
.map(|(index, profile)| codex_model_json(profile, index))
.collect::<Vec<_>>();
.map(|(index, entry)| match entry {
PublicModelDescriptor::Adapted(profile) => Ok(codex_model_json(profile, index)),
PublicModelDescriptor::Native { model, payload } => {
if payload.protocol() != "codex" {
return Err(());
}
let mut value: Value =
serde_json::from_slice(payload.body()).map_err(|_| ())?;
let object = value.as_object_mut().ok_or(())?;
// 别名只改变请求标识,能力、提示词、顺序等仍由上游目录决定。
object.insert("slug".to_owned(), Value::String(model.as_str().to_owned()));
Ok(value)
}
})
.collect::<Result<Vec<_>, ()>>();
let Ok(models) = models else {
return catalog_unavailable_response();
};
return (
StatusCode::OK,
// 目录经过账号选择/别名/多 Provider 聚合,不能借用上游 ETag 表示改写后的正文。
[("cache-control", "private, no-store")],
Json(json!({
"models": models,
})),
@@ -72,6 +106,16 @@ pub(crate) async fn models(
.into_response()
}
fn catalog_unavailable_response() -> Response {
openai_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Model catalog is temporarily unavailable",
"server_error",
"model_catalog_unavailable",
)
.into_response()
}
/// `GET /v1/models/{model_id}`。
pub(crate) async fn model_detail(
State(state): State<ApiState>,
@@ -10,7 +10,7 @@ use gateway_core::engine::execution::{
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
use gateway_core::routing::{PublicModelId, PublicModelProfile};
use gateway_core::routing::{ProviderCatalogUnavailable, PublicModelDescriptor, PublicModelId};
use uuid::Uuid;
use super::auth::ClientApiKeyAuthError;
@@ -52,11 +52,14 @@ impl OpenAiService {
.collect()
}
pub(crate) fn public_model_profiles(
pub(crate) async fn client_model_catalog(
&self,
client: &AuthenticatedClient,
) -> Vec<PublicModelProfile> {
self.execution.public_model_profiles(client)
client_version: &str,
) -> Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable> {
self.execution
.client_model_catalog(client, "codex", client_version)
.await
}
pub(crate) fn contains_public_model(
@@ -1,48 +1,28 @@
use gateway_admin::model::{
provider_credentials::AccountUsagePeriod,
quota_forecast::{AccountQuotaForecast, AccountQuotaForecastReport, QuotaForecastSource},
quota_forecast_sampling::QuotaForecastMethod,
};
use gateway_api::admin::accounts::AccountQuotaForecastData;
#[test]
fn quota_forecast_projection_preserves_null_zero_and_sample_metadata() {
fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
let now = "2026-09-12T00:00:00Z".parse().unwrap();
let forecast = AccountQuotaForecast {
period: AccountUsagePeriod::Weekly,
target_seconds: 7 * 86_400,
extrapolated: false,
source: Some(QuotaForecastSource {
key: "week".to_owned(),
label: "周额度".to_owned(),
window_seconds: 7 * 86_400,
used_percent: Some(100.0),
observed_at: Some(now),
start_at: Some(now),
reset_at: now,
request_count: 1_024,
tokens: Some(1_000_000),
input_tokens: Some(900_000),
output_tokens: Some(100_000),
cached_tokens: Some(800_000),
known_cost_count: 1_000,
partial_cost_count: 24,
unavailable_cost_count: 0,
usd: Some(0.1234),
sample_start_at: Some(now),
baseline_percent: 85.0,
sampled_percent: Some(15.0),
block_count: 3,
observation_count: 96,
missing_token_count: 0,
excluded_request_count: 4,
pending_request_count: 2,
}),
unavailable_reason: None,
low_sample: false,
incomplete_cost: true,
incomplete_tokens: false,
method: QuotaForecastMethod::Incremental,
estimated_tokens: Some(1_000_000),
estimated_usd: None,
remaining_tokens: Some(0),
@@ -66,13 +46,22 @@ fn quota_forecast_projection_preserves_null_zero_and_sample_metadata() {
assert!(week["estimatedUsd"].is_null());
assert_eq!(week["estimatedUsdDisplay"], "—");
assert_eq!(week["remainingTokensDisplay"], "0");
assert_eq!(week["source"]["requestCountDisplay"], "1,024");
assert_eq!(week["source"]["usdDisplay"], "$0.1234");
assert_eq!(week["source"]["partialCostCount"], 24);
assert_eq!(week["method"], "incremental");
assert_eq!(week["source"]["sampledPercentDisplay"], "15.0 个百分点");
assert_eq!(week["source"]["pendingRequestCount"], 2);
assert!(week["source"].get("providerData").is_none());
assert_eq!(
week["source"],
serde_json::json!({
"label": "周额度",
"usedPercent": 100.0,
"usedPercentDisplay": "100.0%",
"observedAt": "2026-09-12T08:00:00+08:00",
"observedAtDisplay": "2026-09-12 08:00:00",
"resetAt": "2026-09-12T08:00:00+08:00",
"tokensDisplay": "1M",
"usdDisplay": "$0.1234"
})
);
assert!(week.get("method").is_none());
assert!(week.get("methodDisplay").is_none());
assert!(value.get("generatedAtDisplay").is_none());
assert_eq!(value["forecasts"][1]["period"], "monthly");
assert_eq!(value["forecasts"][1]["targetDays"], 30.0);
assert_eq!(value["forecasts"][1]["extrapolated"], true);
@@ -10,6 +10,71 @@ use serde_json::json;
use super::{AdminTestFixture, AdminTestState};
#[test]
fn custom_client_keys_preserve_migrated_values_and_redact_debug() {
for value in [
"q".to_owned(),
"sk-old!@/key+=value".to_owned(),
"x".repeat(8192),
] {
let request: CreateClientKeyRequest = serde_json::from_value(json!({
"name": "migration", "customKey": value, "groupIds": [],
"maxConcurrency": 2, "requestsPerMinute": 0
}))
.unwrap();
let debug = format!("{request:?}");
assert!(debug.contains("[REDACTED]"));
let command = request.into_command().unwrap();
assert_eq!(command.custom_key.unwrap().expose_for_auth(), value);
}
let secret = "legacy-key-should-not-leak";
let request: CreateClientKeyRequest = serde_json::from_value(json!({
"name": "migration", "customKey": secret, "groupIds": [],
"maxConcurrency": 0, "requestsPerMinute": 0
}))
.unwrap();
assert!(!format!("{request:?}").contains(secret));
assert!(!format!("{:?}", request.into_command().unwrap()).contains(secret));
}
#[test]
fn custom_client_key_is_optional_and_only_rejects_untransportable_values() {
let payload = json!({"name": "migration", "groupIds": [], "maxConcurrency": 0,
"requestsPerMinute": 0});
for value in [None, Some(json!(null)), Some(json!(""))] {
let mut payload = payload.clone();
if let Some(value) = value {
payload["customKey"] = value;
}
let command = serde_json::from_value::<CreateClientKeyRequest>(payload)
.unwrap()
.into_command()
.unwrap();
assert!(command.custom_key.is_none());
}
for value in [
" key",
"key ",
"key with spaces",
"key\n",
"key\tvalue",
"密钥",
] {
let mut payload = payload.clone();
payload["customKey"] = json!(value);
let error = serde_json::from_value::<CreateClientKeyRequest>(payload)
.unwrap()
.into_command()
.unwrap_err();
assert_eq!(error.field(), "customKey");
assert!(!format!("{error:?}").contains(value));
}
let mut update = payload;
update["id"] = json!("key_existing");
update["customKey"] = json!("replacement-credential");
assert!(serde_json::from_value::<UpdateClientKeyRequest>(update).is_err());
}
#[test]
fn budget_inputs_preserve_decimal_precision_and_omitted_updates() {
let payload = json!({"name": "budget", "groupIds": [], "maxConcurrency": 3,
@@ -63,10 +128,6 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
"search": "a".repeat(257)
}))
.expect("deserialize oversized search");
let secret_search = serde_json::from_value::<ListClientKeysQuery>(json!({
"search": format!("sk_{}", "a".repeat(43))
}))
.expect("deserialize secret search");
let invalid_sort = serde_json::from_value::<ListClientKeysQuery>(json!({
"sortBy": "plaintextKey",
"sortDirection": "asc"
@@ -92,13 +153,6 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
.field(),
"search"
);
assert_eq!(
secret_search
.into_command()
.expect_err("reject full key in search")
.field(),
"search"
);
assert_eq!(
invalid_sort
.into_command()
@@ -140,6 +194,16 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
);
}
#[test]
fn client_key_name_search_does_not_interpret_names_as_credentials() {
let name = format!("sk_{}", "a".repeat(43));
let command = serde_json::from_value::<ListClientKeysQuery>(json!({ "search": name }))
.unwrap()
.into_command()
.unwrap();
assert_eq!(command.search.as_deref(), Some(name.as_str()));
}
#[test]
fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
let valid = serde_json::from_value::<CreateClientKeyRequest>(json!({
@@ -76,10 +76,28 @@ fn bearer_client_api_key_should_reject_empty_bearer_token() {
}
#[test]
fn bearer_client_api_key_should_reject_non_gateway_key_prefix() {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer xai-secret"));
fn bearer_client_api_key_should_accept_migrated_formats_without_rewriting() {
for key in [
"q".to_owned(),
"sk-old/key+value=:!@".to_owned(),
"x".repeat(8192),
] {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {key}")).unwrap(),
);
assert_eq!(bearer_client_api_key(&headers), Ok(key.as_str()));
}
}
#[test]
fn bearer_client_api_key_should_reject_whitespace_inside_tokens() {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer key with spaces"),
);
assert_eq!(
bearer_client_api_key(&headers),
Err(ClientApiKeyAuthError::InvalidKeyFormat)
+144 -12
View File
@@ -1,4 +1,4 @@
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use axum::{
body::{Body, to_bytes},
@@ -11,7 +11,8 @@ use gateway_core::engine::execution::{
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::routing::{
ModelPresentation, ModelServiceTier, PublicModelId, PublicModelProfile,
ModelPresentation, ModelServiceTier, ProviderCatalogUnavailable, PublicModelDescriptor,
PublicModelId, PublicModelProfile,
};
use tower::ServiceExt;
@@ -20,6 +21,8 @@ use super::{api_router, authenticated_client, authenticated_client_with_min_vers
pub(super) struct ModelsExecution {
client: AuthenticatedClient,
presentation: Option<ModelPresentation>,
native: Option<Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>>,
requested_versions: Mutex<Vec<String>>,
}
impl ModelsExecution {
@@ -27,6 +30,8 @@ impl ModelsExecution {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
presentation: None,
native: None,
requested_versions: Mutex::default(),
})
}
@@ -61,6 +66,8 @@ impl ModelsExecution {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
presentation: Some(presentation),
native: None,
requested_versions: Mutex::default(),
})
}
@@ -68,6 +75,8 @@ impl ModelsExecution {
Arc::new(Self {
client: authenticated_client_with_min_versions("sk_models_test", None, Some("0.40.0")),
presentation: None,
native: None,
requested_versions: Mutex::default(),
})
}
@@ -79,11 +88,48 @@ impl ModelsExecution {
None,
),
presentation: None,
native: None,
requested_versions: Mutex::default(),
})
}
fn profiles(&self) -> Vec<PublicModelProfile> {
self.presentation
.as_ref()
.map(|presentation| {
PublicModelProfile::new(
PublicModelId::new("grok-4.5").expect("model"),
presentation.clone(),
)
})
.into_iter()
.collect()
}
}
impl ExecutionService for ModelsExecution {
fn client_model_catalog<'a>(
&'a self,
_client: &'a AuthenticatedClient,
protocol: &'a str,
version: &'a str,
) -> BoxFuture<'a, Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>> {
Box::pin(async move {
assert_eq!(protocol, "codex");
self.requested_versions
.lock()
.expect("versions")
.push(version.to_owned());
self.native.clone().unwrap_or_else(|| {
Ok(self
.profiles()
.into_iter()
.map(PublicModelDescriptor::Adapted)
.collect())
})
})
}
fn authenticate(
&self,
plaintext: &str,
@@ -102,16 +148,6 @@ impl ExecutionService for ModelsExecution {
.collect()
}
fn public_model_profiles(&self, _: &AuthenticatedClient) -> Vec<PublicModelProfile> {
let Some(presentation) = &self.presentation else {
return Vec::new();
};
vec![PublicModelProfile::new(
PublicModelId::new("grok-4.5").expect("model"),
presentation.clone(),
)]
}
fn contains_public_model(&self, _: &AuthenticatedClient, model: &PublicModelId) -> bool {
matches!(model.as_str(), "model-a" | "model-b")
}
@@ -145,6 +181,102 @@ fn authorized_request(path: &str) -> Request<Body> {
.expect("build models request")
}
#[tokio::test]
async fn native_catalog_preserves_complete_objects_and_only_rewrites_alias_slug() {
let original = serde_json::json!({
"slug": "gpt-native", "display_name": "Official name", "priority": 47,
"base_instructions": "Official instructions\nDo not replace.",
"model_messages": {"token_budget": "upstream template", "future_section": [null, {"a": true}]},
"service_tiers": [{"id": "priority", "name": "Fast", "description": "Official speed tier"}],
"tool_mode": "code_mode_only", "use_responses_lite": true,
"future_field": {"nested": [1, null, "value"]}, "explicit_null": null
});
let entry = |model: &str| PublicModelDescriptor::Native {
model: PublicModelId::new(model).expect("model"),
payload: gateway_core::operation::RawJsonPayload::new(
"codex",
serde_json::to_vec(&original).expect("JSON").into(),
)
.expect("payload"),
};
let execution = Arc::new(ModelsExecution {
client: authenticated_client("sk_models_test"),
presentation: None,
native: Some(Ok(vec![entry("gpt-native"), entry("my-alias")])),
requested_versions: Mutex::default(),
});
let response = api_router(execution.clone())
.await
.oneshot(authorized_request(
"/v1/models?client_version=0.154.0-alpha.3",
))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["cache-control"], "private, no-store");
assert!(response.headers().get("etag").is_none());
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
assert_eq!(value["models"][0], original);
let mut alias = original;
alias["slug"] = serde_json::json!("my-alias");
assert_eq!(value["models"][1], alias);
assert_eq!(
*execution.requested_versions.lock().expect("versions"),
["0.154.0-alpha.3"]
);
}
#[tokio::test]
async fn native_catalog_failure_does_not_publish_a_synthetic_or_empty_success() {
let execution = Arc::new(ModelsExecution {
client: authenticated_client("sk_models_test"),
presentation: None,
native: Some(Err(ProviderCatalogUnavailable)),
requested_versions: Mutex::default(),
});
let response = api_router(execution)
.await
.oneshot(authorized_request("/v1/models?client_version=0.154.0"))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
assert_eq!(value["error"]["code"], "model_catalog_unavailable");
assert!(value.get("models").is_none());
}
#[tokio::test]
async fn catalog_rejects_unbounded_or_control_character_versions_before_fetching() {
for version in [
"x".repeat(65),
"0.154.0%0A".to_owned(),
"0.154.0%20".to_owned(),
] {
let execution = ModelsExecution::new();
let response = api_router(execution.clone())
.await
.oneshot(authorized_request(&format!(
"/v1/models?client_version={version}"
)))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(
execution
.requested_versions
.lock()
.expect("versions")
.is_empty()
);
}
}
#[tokio::test]
async fn models_should_encode_the_service_visible_catalog() {
let response = api_router(ModelsExecution::new())
@@ -9,6 +9,106 @@ use super::models::ModelsExecution;
const REMOVED_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
#[tokio::test]
async fn browser_origin_controls_http_admin_sessions_without_configuration() {
use axum::body::to_bytes;
use serde_json::{Value, json};
for (origins, secure) in [
(vec!["http://admin.example.test"], false),
(vec!["http://192.0.2.1:8080"], false),
(vec!["http://[::1]:8080"], false),
(vec!["https://admin.example.test"], true),
(vec![], true),
(vec!["null"], true),
(vec!["invalid-origin"], true),
(vec!["http://admin.example.test/path"], true),
(vec!["http://admin.example.test?https"], true),
(vec!["http://user@admin.example.test"], true),
(
vec!["http://admin.example.test", "https://admin.example.test"],
true,
),
] {
let app = api_router_with_origins(ModelsExecution::new(), Vec::new()).await;
let request = |path: &str| {
let mut builder = Request::post(path)
.header("content-type", "application/json")
.header("x-forwarded-proto", "http")
.header("forwarded", "proto=http");
for origin in &origins {
builder = builder.header("origin", *origin);
}
builder
};
let response = app
.clone()
.oneshot(
request("/api/admin/auth/login")
.body(Body::from(
json!({"username": "admin_1", "password": "strong-admin-password"})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let cookie = response.headers()["set-cookie"].to_str().unwrap();
let session = cookie.split(';').next().unwrap().to_owned();
let attrs: Vec<_> = cookie.split(';').map(str::trim).collect();
assert_eq!(attrs.contains(&"Secure"), secure);
for attribute in ["Path=/", "HttpOnly", "SameSite=Lax"] {
assert!(attrs.contains(&attribute));
}
let response = app
.clone()
.oneshot(
Request::get("/api/admin/auth/status")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body: Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap();
assert_eq!(body["data"]["authenticated"], true);
let response = app
.clone()
.oneshot(
request("/api/admin/auth/logout")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let cookie = response.headers()["set-cookie"].to_str().unwrap();
assert!(cookie.starts_with("cpr_admin_session=;"));
let attrs: Vec<_> = cookie.split(';').map(str::trim).collect();
assert_eq!(attrs.contains(&"Secure"), secure);
for attribute in ["Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0"] {
assert!(attrs.contains(&attribute));
}
let response = app
.oneshot(
Request::get("/api/admin/auth/status")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body: Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap();
assert_eq!(body["data"]["authenticated"], false);
}
}
#[tokio::test]
async fn removed_responses_review_route_should_not_reach_the_responses_handler() {
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
@@ -37,7 +37,8 @@ use crate::lifecycle::CancellationToken;
use crate::operation::{Operation, ProviderSessionState};
use crate::policy::{ClientApiKeyId, ClientPolicy};
use crate::routing::{
PublicModelId, PublicModelProfile, RoutingContext, RuntimeSnapshot, UpstreamModelId,
ProviderCatalogUnavailable, PublicModelDescriptor, PublicModelId, RoutingContext,
RuntimeSnapshot, UpstreamModelId,
};
use crate::runtime::RuntimeSnapshotHandle;
@@ -187,8 +188,13 @@ pub trait ExecutionService: Send + Sync {
plaintext: &str,
) -> Result<AuthenticatedClient, ClientAuthenticationError>;
fn public_models(&self, client: &AuthenticatedClient) -> Vec<PublicModelId>;
fn public_model_profiles(&self, _client: &AuthenticatedClient) -> Vec<PublicModelProfile> {
Vec::new()
fn client_model_catalog<'a>(
&'a self,
_client: &'a AuthenticatedClient,
_protocol: &'a str,
_client_version: &'a str,
) -> BoxFuture<'a, Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>> {
Box::pin(async { Err(ProviderCatalogUnavailable) })
}
fn contains_public_model(&self, client: &AuthenticatedClient, model: &PublicModelId) -> bool;
fn start(
@@ -920,10 +926,77 @@ impl ExecutionService for DefaultExecutionService {
.public_models_for_scope(client.policy.account_scope())
}
fn public_model_profiles(&self, client: &AuthenticatedClient) -> Vec<PublicModelProfile> {
client
.snapshot
.public_model_profiles_for_scope(client.policy.account_scope())
fn client_model_catalog<'a>(
&'a self,
client: &'a AuthenticatedClient,
protocol: &'a str,
client_version: &'a str,
) -> BoxFuture<'a, Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>> {
Box::pin(async move {
let scope = client.policy.account_scope();
let mut result = Vec::new();
let mut seen = BTreeSet::new();
for kind in scope.provider_kinds() {
let provider = self.providers.get(kind).ok_or(ProviderCatalogUnavailable)?;
let Some(models) = provider
.query_client_model_catalog(scope, protocol, client_version)
.await?
else {
for profile in client.snapshot.public_model_profiles_for_provider(kind) {
if seen.insert(profile.model().clone()) {
result.push(PublicModelDescriptor::Adapted(profile));
}
}
continue;
};
// 保留上游顺序;映射只选择完整的目标对象,不能跨账号/模型混拼字段。
let by_id = models
.iter()
.map(|entry| (entry.model.as_str(), entry))
.collect::<std::collections::BTreeMap<_, _>>();
for entry in &models {
let target = client.snapshot.mapped_model(entry.model.as_str());
let Some(source) = by_id.get(target.as_str()) else {
continue;
};
let model = PublicModelId::new(entry.model.as_str().to_owned())
.map_err(|_| ProviderCatalogUnavailable)?;
// 原生目录可能比路由快照更新,不能向客户端公布当前已知不可路由的模型。
if !client
.snapshot
.contains_public_model_for_provider(&model, kind)
{
continue;
}
if seen.insert(model.clone()) {
result.push(PublicModelDescriptor::Native {
model,
payload: source.payload.clone(),
});
}
}
for model in client.snapshot.public_models_for_provider(kind) {
let target = client.snapshot.mapped_model(model.as_str());
if target == model.as_str() || seen.contains(&model) {
continue;
}
if !client
.snapshot
.contains_public_model_for_provider(&model, kind)
{
continue;
}
if let Some(source) = by_id.get(target.as_str()) {
seen.insert(model.clone());
result.push(PublicModelDescriptor::Native {
model,
payload: source.payload.clone(),
});
}
}
}
Ok(result)
})
}
fn contains_public_model(&self, client: &AuthenticatedClient, model: &PublicModelId) -> bool {
@@ -503,6 +503,20 @@ pub trait Provider: Send + Sync {
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError>;
/// 读取当前客户端协议的原生目录,必须限定到认证时冻结的账号范围。
/// `None` 表示不提供该协议的原生目录;读取失败不能伪装成不支持。
async fn query_client_model_catalog(
&self,
_scope: &crate::account::scope::FrozenAccountScope,
_protocol: &str,
_client_version: &str,
) -> Result<
Option<Vec<crate::routing::ProviderModelDescriptor>>,
crate::routing::ProviderCatalogUnavailable,
> {
Ok(None)
}
/// 选择一个未被排除的资源并返回 cold [`ProviderStream`]。
///
/// 返回成功、返回错误或准备 future 被取消前,均不得发送本次请求的上游握手或业务
@@ -225,10 +225,10 @@ impl fmt::Debug for ProtocolPayload {
}
}
/// 客户端协议交给 Provider 的未经重编码 JSON 正文。
/// 协议 adapter 与 Provider 之间不透明传递的 JSON 正文。
///
/// 协议 adapter 只确定路由端点;Core 保留原始字节与非 wire 上下文,
/// 不验证、解析或改写正文。
/// Core 保留字节与非 wire 上下文,不验证、解析或改写正文;
/// 请求载荷与原生模型目录均由协议 adapter 和对应 Provider 共同拥有语义。
#[derive(Clone, PartialEq, Eq)]
pub struct RawJsonPayload {
protocol: String,
@@ -237,7 +237,7 @@ pub struct RawJsonPayload {
}
impl RawJsonPayload {
/// 创建未经重编码的协议 JSON 正文。
/// 创建不透明的协议 JSON 正文。
///
/// # Errors
///
@@ -260,7 +260,7 @@ impl RawJsonPayload {
&self.protocol
}
/// 返回 adapter 收到的原始 JSON 字节。
/// 返回协议正文的 JSON 字节。
#[must_use]
pub const fn body(&self) -> &Bytes {
&self.body
+17 -2
View File
@@ -53,13 +53,28 @@ impl PlaintextClientApiKey {
///
/// # Errors
///
/// Key 为空、过长或包含控制字符时返回错误。
/// Key 为空或无法作为 HTTP Bearer 值发送时返回错误。
pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
let value = value.into();
validate_text(&value, 512, false, None)?;
Self::validate(&value)?;
Ok(Self(value))
}
/// 迁入的 Key 不限定前缀或长度;保持原值,仅校验 HTTP 可传输的非空可见 ASCII。
///
/// # Errors
///
/// Key 为空或包含空白、控制字符、非 ASCII 字符时返回错误。
pub fn validate(value: &str) -> Result<(), IdentifierError> {
if value.is_empty() {
return Err(IdentifierError::Empty);
}
if !value.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(IdentifierError::InvalidFormat);
}
Ok(())
}
/// 仅借给同步认证器做常量时间比较。
#[must_use]
pub fn expose_for_auth(&self) -> &str {
@@ -1,12 +1,32 @@
//! 快照编译需要的 Provider 目录事实,不暴露执行或账号选择能力。
//! 快照编译与客户端读取需要的 Provider 目录合同,不暴露执行或账号选择能力。
use std::collections::BTreeMap;
use futures::future::BoxFuture;
use crate::identity::ProviderKind;
use crate::operation::RawJsonPayload;
use super::{ModelCapabilities, ModelPresentation, UpstreamModelId};
use super::{
ModelCapabilities, ModelPresentation, PublicModelId, PublicModelProfile, UpstreamModelId,
};
/// Provider 原生客户端目录条目;Core 只解释模型标识,不解释协议正文。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelDescriptor {
pub model: UpstreamModelId,
pub payload: RawJsonPayload,
}
/// 对外目录保留原生正文;只有没有原生目录的 Provider 才使用通用画像适配。
#[derive(Debug, Clone)]
pub enum PublicModelDescriptor {
Native {
model: PublicModelId,
payload: RawJsonPayload,
},
Adapted(PublicModelProfile),
}
/// Provider 实时目录编译后的单模型能力。
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -10,7 +10,7 @@ pub use crate::account::scope::{
pub use crate::identity::ProviderKind;
pub use catalog::{
ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable,
ProviderModelCapabilities,
ProviderModelCapabilities, ProviderModelDescriptor, PublicModelDescriptor,
};
pub use snapshot::RuntimeSnapshot;
@@ -996,6 +996,108 @@ fn probe_observation_store_failure_preserves_the_provider_error() {
struct FailingProvider;
struct NativeCatalogProvider {
fail: bool,
}
#[async_trait]
impl Provider for NativeCatalogProvider {
fn name(&self) -> &'static str {
"openai"
}
fn catalog_generation(&self) -> ProviderCatalogGeneration {
ProviderCatalogGeneration::default()
}
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
Ok(Vec::new())
}
async fn query_client_model_catalog(
&self,
scope: &FrozenAccountScope,
protocol: &str,
version: &str,
) -> Result<
Option<Vec<gateway_core::routing::ProviderModelDescriptor>>,
gateway_core::routing::ProviderCatalogUnavailable,
> {
assert!(scope.allows(&ProviderAccountId::new("acct_start").expect("account")));
assert!(!scope.allows(&ProviderAccountId::new("acct_other").expect("account")));
assert_eq!((protocol, version), ("codex", "0.154.0"));
if self.fail {
return Err(gateway_core::routing::ProviderCatalogUnavailable);
}
Ok(Some(
["gpt-new", "gpt-start"]
.into_iter()
.map(|id| gateway_core::routing::ProviderModelDescriptor {
model: UpstreamModelId::new(id).expect("id"),
payload: RawJsonPayload::new(
"codex",
serde_json::to_vec(&json!({"slug":id,"future":null}))
.expect("JSON")
.into(),
)
.expect("payload"),
})
.collect(),
))
}
async fn execute(
&self,
_: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
panic!("catalog must not use metered execution")
}
}
#[test]
fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models() {
for fail in [false, true] {
let snapshot = start_snapshot().with_model_mappings(BTreeMap::from([
("alias".to_owned(), "gpt-start".to_owned()),
("missing-alias".to_owned(), "unavailable-model".to_owned()),
]));
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::new([Arc::new(NativeCatalogProvider { fail }) as Arc<dyn Provider>])
.expect("registry"),
Arc::new(UnusedAdmissions),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
);
let client = service.authenticate("sk_start_test").expect("authenticate");
let result = block_on(service.client_model_catalog(&client, "codex", "0.154.0"));
if fail {
assert!(
result.is_err(),
"native failure cannot use the global synthesized catalog"
);
continue;
}
let models = result.expect("models");
let pairs = models
.iter()
.map(|entry| {
let gateway_core::routing::PublicModelDescriptor::Native { model, payload } = entry
else {
panic!("native required")
};
(model.as_str(), payload.body())
})
.collect::<Vec<_>>();
assert_eq!(
pairs.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
["gpt-start", "alias"]
);
assert_eq!(pairs[0].1, pairs[1].1, "Core preserves alias payload bytes");
}
}
#[async_trait]
impl Provider for FailingProvider {
fn name(&self) -> &'static str {
@@ -57,6 +57,29 @@ fn plaintext_client_key_debug_should_be_redacted() {
assert_eq!(key.expose_for_auth(), "sk_must_not_appear");
}
#[test]
fn migrated_client_keys_keep_their_exact_value_without_prefix_or_length_limits() {
for value in [
"q".to_owned(),
"legacy-!@#$%^&*()[]{}:;,.?/+=~|\\".to_owned(),
"x".repeat(8192),
] {
assert_eq!(plaintext(&value).expose_for_auth(), value);
}
for value in [
"",
" ",
" key",
"key ",
"key\n",
"key\tvalue",
"key\u{7f}",
"密钥",
] {
assert!(PlaintextClientApiKey::new(value).is_err());
}
}
#[test]
fn client_version_should_require_strict_semver() {
assert!(CodexClientVersion::parse("0.152.0").is_ok());
@@ -47,7 +47,6 @@ use crate::{
use super::{ControlPlaneRepository, PgControlPlaneRepository};
const ENTITY: &str = "client API key";
const KEY_LENGTH: usize = 46;
const CLIENT_API_KEY_LAST_USED_FLUSH_DELAY: Duration = Duration::from_secs(1);
const CLIENT_API_KEY_LAST_USED_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
@@ -355,7 +354,8 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository {
query.validate()?;
let total = count_client_api_keys(&self.pool, query.search.as_deref()).await?;
let mut statement = QueryBuilder::<Postgres>::new(
"select k.id, k.name, k.label, left(k.key, 10) as prefix, k.enabled,
"select k.id, k.name, k.label,
left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled,
k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at,
k.updated_at, '[]'::jsonb as groups, '{}'::text[] as provider_kinds
from client_api_keys k
@@ -414,7 +414,8 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository {
async fn get_client_api_key(&self, id: &str) -> StoreResult<Option<ClientApiKeyRecord>> {
require_nonempty(ENTITY, "id", id)?;
let record = sqlx::query(
"select k.id, k.name, k.label, left(k.key, 10) as prefix, k.enabled,
"select k.id, k.name, k.label,
left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled,
k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at,
k.updated_at, coalesce(groups.groups, '[]'::jsonb) as groups,
case
@@ -977,6 +978,7 @@ pub(crate) async fn insert_client_api_key_in_transaction(
key: &NewClientApiKey,
) -> StoreResult<()> {
key.validate()?;
ensure_client_key_name_available(transaction, &key.id, key.name.trim()).await?;
sqlx::query(
"insert into client_api_keys (
id, name, label, key, enabled, max_concurrency, requests_per_minute,
@@ -984,7 +986,7 @@ pub(crate) async fn insert_client_api_key_in_transaction(
) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric)",
)
.bind(&key.id)
.bind(&key.name)
.bind(key.name.trim())
.bind(&key.label)
.bind(&key.key)
.bind(to_i64(key.max_concurrency)?)
@@ -993,7 +995,20 @@ pub(crate) async fn insert_client_api_key_in_transaction(
.bind(key.budget.weekly_usd.canonical())
.execute(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("insert client API key in transaction"))?;
.map_err(|error| {
if error.as_database_error().is_some_and(|database| {
database.is_unique_violation()
&& database.constraint() == Some("client_api_keys_key_sha256_idx")
}) {
StoreError::Conflict {
entity: ENTITY,
id: key.id.clone(),
kind: crate::ConflictKind::InvalidTransition,
}
} else {
postgres_unavailable("insert client API key in transaction")
}
})?;
replace_client_api_key_groups_in_transaction(transaction, &key.id, &key.group_ids).await?;
Ok(())
}
@@ -1003,6 +1018,7 @@ pub(crate) async fn update_client_api_key_in_transaction(
key: &UpdateClientApiKeyDetails,
) -> StoreResult<()> {
key.validate()?;
ensure_client_key_name_available(transaction, &key.id, key.name.trim()).await?;
let result = sqlx::query(
"update client_api_keys
set name = $2, label = $3, max_concurrency = $4,
@@ -1012,7 +1028,7 @@ pub(crate) async fn update_client_api_key_in_transaction(
where id = $1",
)
.bind(&key.id)
.bind(&key.name)
.bind(key.name.trim())
.bind(&key.label)
.bind(to_i64(key.max_concurrency)?)
.bind(to_i64(key.requests_per_minute)?)
@@ -1025,6 +1041,32 @@ pub(crate) async fn update_client_api_key_in_transaction(
replace_client_api_key_groups_in_transaction(transaction, &key.id, &key.group_ids).await
}
async fn ensure_client_key_name_available(
transaction: &mut Transaction<'_, Postgres>,
id: &str,
name: &str,
) -> StoreResult<()> {
// 调用方已通过递增配置版本持有控制面行锁,查重与写入在同一事务内串行执行。
// 不回填历史重名数据;创建和保存时统一校验,更新排除当前记录。
let duplicate: bool = sqlx::query_scalar(
"select exists(select 1 from client_api_keys
where lower(btrim(name)) = lower($1) and id <> $2)",
)
.bind(name)
.bind(id)
.fetch_one(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("check client API key name"))?;
if duplicate {
return Err(StoreError::Conflict {
entity: ENTITY,
id: id.to_owned(),
kind: crate::ConflictKind::DuplicateName,
});
}
Ok(())
}
pub(crate) async fn set_client_api_key_enabled_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
id: &str,
@@ -1119,18 +1161,8 @@ fn validate_group_ids(group_ids: &[String]) -> StoreResult<()> {
}
fn validate_key(key: &str) -> StoreResult<()> {
let valid = key.len() == KEY_LENGTH
&& key.starts_with("sk_")
&& key[3..]
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
if valid {
Ok(())
} else {
Err(invalid(
"key must be sk_ followed by 43 URL-safe characters",
))
}
PlaintextClientApiKey::validate(key)
.map_err(|_| invalid("key must contain nonempty visible ASCII characters"))
}
fn client_secret_from_row(
@@ -1204,9 +1236,6 @@ fn push_client_key_search(statement: &mut QueryBuilder<Postgres>, search: Option
statement.push_bind(prefix.clone());
statement.push(" escape '\\'");
statement.push(" or lower(coalesce(label, '')) like ");
statement.push_bind(prefix.clone());
statement.push(" escape '\\'");
statement.push(" or lower(left(key, 10)) like ");
statement.push_bind(prefix);
statement.push(" escape '\\'");
statement.push(")");
@@ -254,5 +254,6 @@ fn push_prefix_search(statement: &mut QueryBuilder<Postgres>, columns: &[&str],
statement.push_bind(pattern.clone());
statement.push(" escape '\\'");
}
push_client_key_name_search(statement, "mr.client_api_key_ref", pattern);
statement.push(")");
}
@@ -94,22 +94,25 @@ pub(crate) fn push_usage_filter(
query.push(format!(" escape '\\' or {alias}.{column} like "));
query.push_bind(pattern.clone());
}
if value.starts_with("sk_") {
query.push(format!(
" escape '\\' or exists (
select 1 from client_api_keys searched_client_key
where searched_client_key.id = {alias}.client_api_key_ref
and searched_client_key.key like "
));
query.push_bind(pattern);
query.push(" escape '\\')");
} else {
query.push(" escape '\\'");
}
query.push(" escape '\\'");
push_client_key_name_search(query, &format!("{alias}.client_api_key_ref"), pattern);
query.push(")");
}
}
pub(crate) fn push_client_key_name_search(
query: &mut QueryBuilder<Postgres>,
key_ref: &str,
pattern: String,
) {
query.push(format!(
" or exists (select 1 from client_api_keys searched_client_key
where searched_client_key.id = {key_ref} and searched_client_key.name ilike "
));
query.push_bind(pattern);
query.push(" escape '\\')");
}
pub(crate) fn literal_prefix_pattern(value: &str) -> String {
format!(
"{}%",
@@ -11,11 +11,15 @@ use sqlx::{Postgres, QueryBuilder};
/// 因此不属于用量记录。图片端点由请求意图证明其用量语义,不依赖文本模型字段。
/// 客户端 WebSocket 的每个 `response.create` 没有独立 HTTP 状态码;成功终态与下游
/// 提交边界已足以证明交付,连接握手的 101 不能冒充单次请求状态。
/// OpenAI Provider 将 `generate=false` 归类为 `prewarm`:它只准备连接与上下文,
/// 即使响应带有模型和输入 Token,也不是推理用量;原始审计和费用事实仍保留。
pub(crate) fn completed_usage_fact_predicate(alias: &str) -> String {
let evidence = usage_evidence_predicate(alias);
format!(
"{alias}.outcome = 'succeeded'
and {alias}.downstream_committed_at is not null
and ({alias}.provider_kind is distinct from 'openai'
or {alias}.request_kind is distinct from 'prewarm')
and (({alias}.client_transport = 'websocket' and {alias}.client_status_code is null)
or {alias}.client_status_code between 200 and 399)
and ({evidence})"
@@ -13,6 +13,7 @@ pub enum StoreBackend {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictKind {
StaleRevision,
DuplicateName,
AlreadyFinalized,
DownstreamAlreadyCommitted,
RequestNotRunning,
@@ -105,6 +106,10 @@ pub(crate) fn admin_store_error(resource: &'static str, error: StoreError) -> Ad
kind: ConflictKind::StaleRevision,
..
} => AdminStoreErrorKind::StaleRevision,
StoreError::Conflict {
kind: ConflictKind::DuplicateName,
..
} => AdminStoreErrorKind::DuplicateName,
StoreError::Conflict { .. } => AdminStoreErrorKind::Conflict,
StoreError::InvalidData { .. } => AdminStoreErrorKind::Invalid,
StoreError::Unavailable { .. } => AdminStoreErrorKind::Unavailable,
@@ -21,8 +21,122 @@ use gateway_store::postgres::{
use super::TestDatabase;
#[tokio::test]
async fn migrated_keys_persist_exactly_and_keep_short_keys_masked() {
use gateway_admin::model::{MutationActor, MutationContext, client_keys::NewClientKey};
use gateway_core::policy::RateLimits;
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
let Some(database) = TestDatabase::create("migrated_keys").await else {
return;
};
let store = PgAdminClientKeyStore::new(database.pool.clone());
let context = MutationContext {
actor: MutationActor::System,
request_id: "migration-test".to_owned(),
};
let long_key: String = (0..160)
.map(|_| uuid::Uuid::new_v4().simple().to_string())
.collect();
let values = [
"q".to_owned(),
"abc".to_owned(),
"sk-legacy/key+value=:!@\\".to_owned(),
long_key,
];
for (index, key) in values.iter().enumerate() {
let id = ClientApiKeyId::new(format!("key_migrated_{index}")).unwrap();
let (_, record) = store
.create_client_key(
NewClientKey {
id: id.clone(),
name: format!("Migrated {index}"),
label: None,
group_ids: Vec::new(),
limits: RateLimits {
max_concurrency: 3,
requests_per_minute: 25,
},
budget: Default::default(),
plaintext: key.clone(),
},
&context,
)
.await
.unwrap();
assert_eq!(record.prefix.len(), 10.min(key.len() / 2));
assert_ne!(record.prefix, *key);
let revealed = store.reveal_client_key(&id).await.unwrap().unwrap();
assert_eq!(revealed.expose_for_response(), key);
}
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
.await
.unwrap();
assert_eq!(snapshot.client_api_keys.len(), values.len());
for key in &values {
let record = snapshot
.client_api_keys
.iter()
.find(|item| item.plaintext_key.expose_for_auth() == key)
.unwrap();
assert_eq!(record.limits.max_concurrency, 3);
}
database.close().await;
}
#[tokio::test]
async fn duplicate_migrated_keys_conflict_atomically_without_extra_audits() {
use gateway_admin::{
model::{MutationActor, MutationContext, client_keys::NewClientKey},
ports::store::AdminStoreErrorKind,
};
use gateway_core::policy::RateLimits;
let Some(database) = TestDatabase::create("duplicate_keys").await else {
return;
};
let store = PgAdminClientKeyStore::new(database.pool.clone());
let context = MutationContext {
actor: MutationActor::System,
request_id: "duplicate-test".to_owned(),
};
let key = "legacy-key-case-sensitive!";
let command = |id: &str| NewClientKey {
id: ClientApiKeyId::new(id).unwrap(),
name: id.to_owned(),
label: None,
group_ids: Vec::new(),
limits: RateLimits::unlimited(),
budget: Default::default(),
plaintext: key.to_owned(),
};
let (first, second) = tokio::join!(
store.create_client_key(command("key_first"), &context),
store.create_client_key(command("key_second"), &context)
);
assert_ne!(first.is_ok(), second.is_ok());
let error = first.err().or_else(|| second.err()).unwrap();
assert_eq!(error.kind(), AdminStoreErrorKind::Conflict);
assert!(!format!("{error:?}").contains(key));
let counts: (i64, i64) = sqlx::query_as(
"select (select count(*) from client_api_keys), (select count(*) from admin_audit_events)",
)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(counts, (1, 1));
let mut different_case = command("key_case_sensitive");
different_case.plaintext = key.to_uppercase();
store
.create_client_key(different_case, &context)
.await
.unwrap();
database.close().await;
}
#[test]
fn client_key_requires_the_frozen_plaintext_format() {
fn generated_client_key_format_remains_valid() {
let key = NewClientApiKey {
budget: Default::default(),
id: "key-1".to_owned(),
@@ -36,6 +150,142 @@ fn client_key_requires_the_frozen_plaintext_format() {
assert!(key.validate().is_ok());
}
#[tokio::test]
async fn client_key_names_are_checked_atomically_on_create_and_rename() {
use gateway_admin::{
model::{
MutationActor, MutationContext,
client_keys::{NewClientKey, UpdateClientKey},
},
ports::store::AdminStoreErrorKind,
};
use gateway_core::policy::RateLimits;
let Some(database) = TestDatabase::create("duplicate_key_names").await else {
return;
};
let store = PgAdminClientKeyStore::new(database.pool.clone());
let context = MutationContext {
actor: MutationActor::System,
request_id: "duplicate-name-test".to_owned(),
};
let create = |id: &str, name: &str| NewClientKey {
id: ClientApiKeyId::new(id).unwrap(),
name: name.to_owned(),
label: None,
group_ids: Vec::new(),
limits: RateLimits::unlimited(),
budget: Default::default(),
plaintext: format!("synthetic-credential-{id}"),
};
let update = |id: ClientApiKeyId, name: &str| UpdateClientKey {
id,
name: name.to_owned(),
label: None,
group_ids: Vec::new(),
limits: RateLimits::unlimited(),
daily_limit_usd: None,
weekly_limit_usd: None,
};
let (first, second) = tokio::join!(
store.create_client_key(create("key_name_first", "Production"), &context),
store.create_client_key(create("key_name_second", " production "), &context),
);
let (winner, conflict) = match (first, second) {
(Ok((_, record)), Err(error)) | (Err(error), Ok((_, record))) => (record, error),
results => panic!("expected one committed create: {results:?}"),
};
assert_eq!(conflict.kind(), AdminStoreErrorKind::DuplicateName);
assert_eq!(winner.name.trim(), winner.name);
let counts: (i64, i64) = sqlx::query_as(
"select (select count(*) from client_api_keys), (select count(*) from admin_audit_events)",
)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(counts, (1, 1));
let (revision, _) = store
.update_client_key(update(winner.id.clone(), "PRODUCTION"), &context)
.await
.unwrap();
let (_, other) = store
.create_client_key(create("key_name_other", "Staging"), &context)
.await
.unwrap();
let error = store
.update_client_key(update(other.id.clone(), " production "), &context)
.await
.unwrap_err();
assert_eq!(error.kind(), AdminStoreErrorKind::DuplicateName);
let unchanged = store.reveal_client_key(&other.id).await.unwrap().unwrap();
assert_eq!(unchanged.record.name, "Staging");
let persisted_revision: i64 =
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(persisted_revision as u64, revision.get() + 1);
let (first, second) = tokio::join!(
store.update_client_key(update(winner.id, "Shared"), &context),
store.update_client_key(update(other.id, "SHARED"), &context),
);
assert_ne!(first.is_ok(), second.is_ok());
assert_eq!(
first.err().or_else(|| second.err()).unwrap().kind(),
AdminStoreErrorKind::DuplicateName
);
database.close().await;
}
#[tokio::test]
async fn client_key_search_matches_names_and_labels_but_never_credential_values() {
let Some(database) = TestDatabase::create("key_name_search").await else {
return;
};
let generated = format!("sk_{}", "K".repeat(43));
for (id, name, key) in [
("key_generated", "Production", generated.as_str()),
("key_custom", "生产_%专用", "legacy-token+/123"),
] {
sqlx::query(
"insert into client_api_keys (id, name, label, key, created_at, updated_at)
values ($1, $2, 'backend label', $3, now(), now())",
)
.bind(id)
.bind(name)
.bind(key)
.execute(&database.pool)
.await
.unwrap();
}
let repository = PgClientApiKeyRepository::new(database.pool.clone());
for (search, expected) in [
("PROD", 1),
("生产_%", 1),
("生产X", 0),
("backend", 2),
(generated.as_str(), 0),
(&generated[..10], 0),
("legacy-token+/123", 0),
("legacy-", 0),
] {
let page = repository
.list_client_api_keys(ClientApiKeyListQuery {
cursor: None,
page_size: 10,
search: Some(search.to_owned()),
sort: ClientApiKeySort::default(),
})
.await
.unwrap();
assert_eq!(page.total, expected, "search: {search}");
assert_eq!(page.items.len() as u64, expected);
}
database.close().await;
}
#[tokio::test]
async fn client_key_list_uses_safe_keyset_search_and_filtered_total() {
let Some(database) = TestDatabase::create("client_key_page").await else {
@@ -331,7 +331,7 @@ async fn assert_usage_search_ids(
}
#[tokio::test]
async fn usage_search_should_match_client_api_key_prefix() {
async fn usage_and_error_search_match_key_names_instead_of_credentials() {
let Some(database) = TestDatabase::create("usage_client_api_key_search").await else {
return;
};
@@ -342,7 +342,7 @@ async fn usage_search_should_match_client_api_key_prefix() {
let plaintext_key = format!("sk_{}", "K".repeat(43));
sqlx::query(
"insert into client_api_keys (id, name, key, enabled, created_at, updated_at)
values ('key_observe', 'usage search', $1, true, $2, $2)",
values ('key_observe', 'Production_%专用', $1, true, $2, $2)",
)
.bind(&plaintext_key)
.bind(now)
@@ -352,22 +352,53 @@ async fn usage_search_should_match_client_api_key_prefix() {
let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1))
.expect("observability range");
for search in [plaintext_key.as_str(), &plaintext_key[..10]] {
let page = observability_repository(&database.pool)
.list_usage_records(UsageRecordQuery {
range,
filter: UsageRecordFilter {
search: Some(search.to_owned()),
..UsageRecordFilter::default()
},
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
let repository = observability_repository(&database.pool);
for key in [plaintext_key.as_str(), "legacy-key+/123"] {
sqlx::query("update client_api_keys set key = $1 where id = 'key_observe'")
.bind(key)
.execute(&database.pool)
.await
.expect("search usage by client API key");
.unwrap();
for (search, expected) in [
("production", 1),
("PRODUCTION_%", 1),
("ProductionX", 0),
(key, 0),
(&key[..10], 0),
] {
let page = repository
.list_usage_records(UsageRecordQuery {
range,
filter: UsageRecordFilter {
search: Some(search.to_owned()),
..UsageRecordFilter::default()
},
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
.await
.expect("search usage by client API key name");
assert_eq!(page.total, 1);
assert_eq!(page.items[0].id, "req_observe_success");
assert_eq!(page.total, expected, "search: {search}");
assert_eq!(page.items.len() as u64, expected);
if expected == 1 {
assert_eq!(page.items[0].id, "req_observe_success");
}
let errors = repository
.list_ops_errors(OpsErrorQuery {
range,
filter: OpsErrorFilter {
search: Some(search.to_owned()),
..Default::default()
},
current_page: 1,
page_size: ObservabilityPageSize::new(10).unwrap(),
})
.await
.unwrap();
assert_eq!(errors.total, expected * 2, "error search: {search}");
assert_eq!(errors.items.len() as u64, expected * 2);
}
}
database.close().await;
}
@@ -1,5 +1,165 @@
use super::*;
use gateway_admin::model::quota_forecast_sampling::MAX_FORECAST_HISTORY_POINTS;
use gateway_store::postgres::{
ObservabilityPageSize, ObservabilityRange, ObservabilityRepository, ProviderAccountUsageQuery,
UsageRecordFilter, UsageRecordQuery,
};
#[tokio::test]
async fn quota_forecast_excludes_openai_prewarm_but_preserves_inference_and_audit() {
let Some(database) = TestDatabase::create("forecast_prewarm").await else {
return;
};
PgProviderAccountRepository::new(database.pool.clone())
.insert_provider_account(account("acct_prewarm", "user-prewarm"))
.await
.unwrap();
let start = Utc::now() - TimeDelta::minutes(30);
let end = start + TimeDelta::minutes(20);
for (id, minute, tokens) in [("normal", 1, 100), ("review", 2, 200), ("prewarm", 3, 400)] {
// 普通成功推理也可以只有输入 Token,不能用 output_tokens = 0 判定预热。
seed_model_request(
&database.pool,
ModelRequestSeed {
request_id: id,
account_id: "acct_prewarm",
provider_kind: "openai",
model: "gpt-test",
total_tokens: tokens,
cost_amount: "1",
started_at: start + TimeDelta::minutes(minute),
},
)
.await
.unwrap();
}
sqlx::query("update model_requests set request_kind = 'review' where id = 'review'")
.execute(&database.pool)
.await
.unwrap();
sqlx::query(
"update model_requests set request_kind = 'prewarm', client_transport = 'websocket',
client_status_code = null, upstream_transport = 'websocket', service_tier = 'auto',
cost_source = 'unavailable', cost_amount = null, cost_currency = null,
provider_observation_json = '{\"upstreamServiceTier\":\"auto\"}'::jsonb
where id = 'prewarm'",
)
.execute(&database.pool)
.await
.unwrap();
let store = admin_account_store(&database.pool);
let query = AccountUsageWindowQuery {
account_id: "acct_prewarm".to_owned(),
key: "week".to_owned(),
range: TimeRange { start, end },
};
let history = store.load_quota_forecast_history(&query).await.unwrap();
assert_eq!(history.usage.request_count, 2);
assert_eq!(history.usage.tokens, 300);
assert_eq!(history.usage.missing_token_count, 0);
assert_eq!(history.usage.known_cost_count, 2);
assert_eq!(history.usage.unavailable_cost_count, 0);
assert_eq!(history.usage.usd, 2.0);
assert_eq!(history.usage.excluded_request_count, 1);
assert_eq!(history.pending_request_count, 0);
// 预热响应携带的额度观测仍可配对,但它不增加累计推理用量。
assert_eq!(history.points.len(), 1);
assert_eq!(history.points[0].usage, history.usage);
let repository = super::super::observability_repository(&database.pool);
let range = ObservabilityRange::new(start, end).unwrap();
let account_usage = repository
.provider_account_usage(
ProviderAccountUsageQuery::for_accounts(range, vec!["acct_prewarm".to_owned()])
.unwrap(),
)
.await
.unwrap();
assert_eq!(account_usage[0].request_count, 2);
assert_eq!(account_usage[0].cost_coverage.unavailable_count, 0);
let records = repository
.list_usage_records(UsageRecordQuery {
range,
filter: UsageRecordFilter::default(),
current_page: 1,
page_size: ObservabilityPageSize::new(10).unwrap(),
})
.await
.unwrap();
assert_eq!(records.total, 2);
assert!(records.items.iter().all(|record| record.id != "prewarm"));
let detail = repository.usage_record_detail("prewarm").await.unwrap();
assert_eq!(detail.request.request_kind.as_deref(), Some("prewarm"));
assert_eq!(detail.request.service_tier.as_deref(), Some("auto"));
let dashboard = repository.dashboard_summary(range, end).await.unwrap();
assert_eq!(dashboard.totals.request_count, 3);
assert_eq!(dashboard.totals.total_tokens, 300);
// 真正推理的未知费用仍必须阻止费用外推,不能靠放宽覆盖门槛掩盖缺失。
sqlx::query(
"update model_requests set cost_source = 'unavailable', cost_amount = null,
cost_currency = null, service_tier = 'auto' where id = 'review'",
)
.execute(&database.pool)
.await
.unwrap();
let history = store.load_quota_forecast_history(&query).await.unwrap();
assert_eq!(history.usage.request_count, 2);
assert_eq!(history.usage.known_cost_count, 1);
assert_eq!(history.usage.unavailable_cost_count, 1);
assert_eq!(history.usage.usd, 1.0);
assert_eq!(history.usage.excluded_request_count, 1);
database.close().await;
}
#[tokio::test]
async fn quota_forecast_does_not_apply_openai_prewarm_semantics_to_other_providers() {
let Some(database) = TestDatabase::create("forecast_xai_kind").await else {
return;
};
let mut candidate = account("acct_xai_kind", "user-xai-kind");
candidate.provider_kind = "xai".to_owned();
PgProviderAccountRepository::new(database.pool.clone())
.insert_provider_account(candidate)
.await
.unwrap();
let start = Utc::now() - TimeDelta::minutes(30);
seed_model_request(
&database.pool,
ModelRequestSeed {
request_id: "xai_kind",
account_id: "acct_xai_kind",
provider_kind: "xai",
model: "grok-test",
total_tokens: 100,
cost_amount: "1",
started_at: start,
},
)
.await
.unwrap();
sqlx::query("update model_requests set request_kind = 'prewarm' where id = 'xai_kind'")
.execute(&database.pool)
.await
.unwrap();
let history = admin_account_store(&database.pool)
.load_quota_forecast_history(&AccountUsageWindowQuery {
account_id: "acct_xai_kind".to_owned(),
key: "week".to_owned(),
range: TimeRange {
start,
end: start + TimeDelta::minutes(10),
},
})
.await
.unwrap();
assert_eq!(history.usage.request_count, 1);
assert_eq!(history.usage.tokens, 100);
assert_eq!(history.usage.known_cost_count, 1);
assert_eq!(history.usage.usd, 1.0);
assert_eq!(history.usage.excluded_request_count, 0);
database.close().await;
}
#[tokio::test]
async fn quota_forecast_pairs_completed_usage_and_preserves_missing_pending_and_excluded_counts() {
@@ -7,25 +7,32 @@ use std::time::{Duration, SystemTime};
use chrono::{DateTime, Utc};
use gateway_core::account::{
CredentialState, OpaqueProviderData, ProviderAccount, ProviderAccountId,
CredentialRevision, CredentialState, OpaqueProviderData, ProviderAccount, ProviderAccountId,
};
use gateway_core::provider_ports::{
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCatalogScope,
};
use gateway_core::routing::{ProviderCatalogGeneration, ProviderKind, UpstreamModelId};
use gateway_core::routing::{
FrozenAccountScope, ProviderCatalogGeneration, ProviderKind, ProviderModelDescriptor,
UpstreamModelId,
};
use secrecy::ExposeSecret;
use thiserror::Error;
use tokio::sync::Notify;
use tokio::sync::{Notify, OnceCell};
use tokio::time::Instant;
use uuid::Uuid;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use crate::transport::profile::CodexWireProfileState;
use crate::transport::profile::{CodexWireProfile, CodexWireProfileState};
use crate::transport::{CodexBackendClient, CodexCatalogModel, CodexRequestContext};
const MAX_RESPONSE_ETAG_BYTES: usize = 256;
const PLAN_CATALOG_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
const MAX_CATALOG_FETCH_ATTEMPTS: usize = 3;
const MAX_PLAN_CATALOG_MODELS: usize = 2_048;
const MAX_CLIENT_CATALOGS: usize = 32;
const CLIENT_CATALOG_TIMEOUT: Duration = Duration::from_secs(15);
const CLIENT_CATALOG_ERROR_TTL: Duration = Duration::from_secs(5);
/// OpenAI 以套餐划分的模型目录作用域。
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
@@ -128,7 +135,7 @@ impl fmt::Debug for CodexCredentialCatalogSnapshot {
}
}
#[derive(Debug, Error)]
#[derive(Debug, Clone, Error)]
pub enum CodexCredentialCatalogError {
#[error("Codex model catalog has no eligible account")]
NoEligibleCredential,
@@ -158,6 +165,44 @@ struct CatalogCacheState {
snapshot: Option<CodexCredentialCatalogSnapshot>,
}
#[derive(PartialEq, Eq)]
struct ClientCatalogKey {
account_id: ProviderAccountId,
revision: CredentialRevision,
plan: Option<String>,
upstream_account_id: Option<String>,
client_version: String,
profile: CodexWireProfile,
}
type ClientCatalogResult = Result<Vec<ProviderModelDescriptor>, CodexCredentialCatalogError>;
struct CachedClientCatalog {
// 负缓存从请求结束计时,不能让一次慢失败耗尽后续请求的退避窗口。
completed_at: Instant,
result: ClientCatalogResult,
}
struct ClientCatalogEntry {
key: ClientCatalogKey,
created_at: Instant,
value: Arc<OnceCell<CachedClientCatalog>>,
}
impl ClientCatalogEntry {
fn is_fresh(&self) -> bool {
let Some(value) = self.value.get() else {
return self.created_at.elapsed() < PLAN_CATALOG_CACHE_TTL;
};
let ttl = if value.result.is_err() {
CLIENT_CATALOG_ERROR_TTL
} else {
PLAN_CATALOG_CACHE_TTL
};
value.completed_at.elapsed() < ttl
}
}
struct FetchedAccountModels {
models: Vec<CodexCatalogModel>,
etag: Option<String>,
@@ -182,6 +227,7 @@ pub struct CodexCredentialCatalogService {
base_url: String,
plan_catalog_cache: Arc<dyn ProviderCatalogCachePort>,
cache: Arc<RwLock<CatalogCacheState>>,
client_catalogs: Arc<Mutex<Vec<ClientCatalogEntry>>>,
etags: Arc<Mutex<CatalogEtagState>>,
etag_notification: Arc<Notify>,
}
@@ -201,11 +247,102 @@ impl CodexCredentialCatalogService {
base_url,
plan_catalog_cache,
cache: Arc::new(RwLock::new(CatalogCacheState::default())),
client_catalogs: Arc::new(Mutex::new(Vec::new())),
etags: Arc::new(Mutex::new(CatalogEtagState::default())),
etag_notification: Arc::new(Notify::new()),
}
}
/// 原生客户端目录使用 Key 范围内排序稳定的首个成功账号,不复用套餐级画像。
/// 同一账号/凭据版本/客户端版本的并发读取合并;原生正文仅保留在有界进程缓存。
pub async fn client_model_catalog(
&self,
scope: &FrozenAccountScope,
client_version: &str,
) -> ClientCatalogResult {
let now = SystemTime::now();
let mut accounts = self.repository.list_for_provider().await?;
accounts
.retain(|account| scope.allows(account.id()) && eligible_catalog_account(account, now));
accounts.sort_by(|left, right| left.id().cmp(right.id()));
let mut last_error = CodexCredentialCatalogError::NoEligibleCredential;
for account in accounts.iter().take(MAX_CATALOG_FETCH_ATTEMPTS) {
let profile = self.profile.snapshot();
let key = ClientCatalogKey {
account_id: account.id().clone(),
revision: account.revision(),
plan: account.plan_type().map(str::to_owned),
upstream_account_id: account.upstream_account_id().map(str::to_owned),
client_version: client_version.to_owned(),
profile: profile.clone(),
};
let value = {
let mut cache = self
.client_catalogs
.lock()
.map_err(|_| CodexCredentialCatalogError::Cache)?;
cache.retain(ClientCatalogEntry::is_fresh);
if let Some(entry) = cache.iter().find(|entry| entry.key == key) {
Arc::clone(&entry.value)
} else {
// 不驱逐正在读取的目录,避免并发请求绕过缓存容量限制制造额外出站。
if cache.len() >= MAX_CLIENT_CATALOGS {
let Some(index) = cache.iter().position(|entry| entry.value.initialized())
else {
return Err(CodexCredentialCatalogError::Cache);
};
cache.remove(index);
}
let value = Arc::new(OnceCell::new());
cache.push(ClientCatalogEntry {
key,
created_at: Instant::now(),
value: Arc::clone(&value),
});
value
}
};
let result = value
.get_or_init(|| async {
// 冻结画像,确保实际请求与缓存身份一致;不传递客户端 Bearer 给上游。
let client = CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
CodexWireProfileState::new(profile),
);
let result = tokio::time::timeout(
CLIENT_CATALOG_TIMEOUT,
self.fetch_account_models(&client, account, Some(client_version)),
)
.await
.map_err(|_| CodexCredentialCatalogError::Upstream {
detail: "client model catalog timed out".to_owned(),
})
.and_then(|result| result)
.map(|fetched| {
fetched
.models
.into_iter()
.map(|model| ProviderModelDescriptor {
model: model.request_model().clone(),
payload: model.document().clone(),
})
.collect()
});
CachedClientCatalog {
completed_at: Instant::now(),
result,
}
})
.await;
match &result.result {
Ok(models) => return Ok(models.clone()),
Err(error) => last_error = error.clone(),
}
}
Err(last_error)
}
#[must_use]
pub fn catalog_generation(&self) -> ProviderCatalogGeneration {
let cache = self
@@ -377,7 +514,7 @@ impl CodexCredentialCatalogService {
) -> Result<FetchedAccountModels, CodexCredentialCatalogError> {
let mut last_error = None;
for account in candidates.into_iter().take(MAX_CATALOG_FETCH_ATTEMPTS) {
match self.fetch_account_models(client, &account).await {
match self.fetch_account_models(client, &account, None).await {
Ok(fetched) => return Ok(fetched),
Err(error) => last_error = Some(error),
}
@@ -423,6 +560,7 @@ impl CodexCredentialCatalogService {
&self,
client: &CodexBackendClient,
account: &ProviderAccount,
client_version: Option<&str>,
) -> Result<FetchedAccountModels, CodexCredentialCatalogError> {
let credential = self
.repository
@@ -439,12 +577,15 @@ impl CodexCredentialCatalogService {
.map_err(|error| CodexCredentialCatalogError::Upstream {
detail: error.to_string(),
})?
.fetch_models_with_context(CodexRequestContext::auxiliary(
authorization.expose_secret(),
account.upstream_account_id(),
&request_id,
None,
))
.fetch_models_with_context(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
account.upstream_account_id(),
&request_id,
None,
),
client_version,
)
.await;
let snapshot = result.map_err(|error| CodexCredentialCatalogError::Upstream {
detail: error.to_string(),
@@ -461,9 +602,19 @@ impl CodexCredentialCatalogService {
.write()
.map_err(|_| CodexCredentialCatalogError::Cache)?;
if cache.snapshot.take().is_some() {
cache.revision = cache.revision.saturating_add(1);
cache.generation = cache.generation.saturating_add(1);
}
cache.revision = cache.revision.saturating_add(1);
drop(cache);
self.invalidate_client_catalogs()?;
Ok(())
}
fn invalidate_client_catalogs(&self) -> Result<(), CodexCredentialCatalogError> {
self.client_catalogs
.lock()
.map_err(|_| CodexCredentialCatalogError::Cache)?
.clear();
Ok(())
}
@@ -486,6 +637,7 @@ impl CodexCredentialCatalogService {
}
};
if changed {
self.invalidate_client_catalogs()?;
self.etag_notification.notify_one();
}
Ok(changed)
@@ -613,6 +765,9 @@ impl CodexCredentialCatalogService {
cache.generation = cache.generation.saturating_add(1);
}
drop(cache);
if changed {
self.invalidate_client_catalogs()?;
}
self.record_applied_catalog_etags(fetched.etags)?;
Ok(snapshot)
}
@@ -291,6 +291,25 @@ impl Provider for CodexProvider {
.collect())
}
async fn query_client_model_catalog(
&self,
scope: &gateway_core::account::scope::FrozenAccountScope,
protocol: &str,
client_version: &str,
) -> Result<
Option<Vec<gateway_core::routing::ProviderModelDescriptor>>,
gateway_core::routing::ProviderCatalogUnavailable,
> {
if protocol != "codex" {
return Ok(None);
}
self.catalog
.client_model_catalog(scope, client_version)
.await
.map(Some)
.map_err(|_| gateway_core::routing::ProviderCatalogUnavailable)
}
async fn execute(
&self,
request: ProviderRequest,
@@ -615,6 +615,7 @@ pub(super) fn codex_model_presentation(model: &CodexCatalogModel) -> ModelPresen
capabilities.image_detail_original() == CodexCatalogCapabilityEvidence::DeclaredNative,
)
.with_verbosity(capabilities.verbosity() == CodexCatalogCapabilityEvidence::DeclaredNative)
.with_service_tiers(model.metadata().service_tiers().to_vec())
.with_hidden(hidden)
}
@@ -3,7 +3,9 @@
use std::collections::BTreeSet;
use std::num::NonZeroU64;
use gateway_core::routing::UpstreamModelId;
use bytes::Bytes;
use gateway_core::operation::RawJsonPayload;
use gateway_core::routing::{ModelServiceTier, UpstreamModelId};
use reqwest::header::{ETAG, HeaderMap};
use serde::Deserialize;
@@ -13,6 +15,7 @@ pub const MAX_CODEX_MODEL_CATALOG_BYTES: usize = 1024 * 1024;
const MAX_CATALOG_MODELS: usize = 2_048;
const MAX_DISPLAY_NAME_BYTES: usize = 256;
const MAX_DESCRIPTION_BYTES: usize = 4 * 1024;
const MAX_SERVICE_TIER_ID_BYTES: usize = 64;
const MAX_ETAG_BYTES: usize = 256;
/// 上游目录对一项能力给出的明确证据。
@@ -133,6 +136,7 @@ pub struct CodexCatalogMetadata {
description: Option<String>,
priority: Option<i32>,
visibility: Option<CodexCatalogVisibility>,
service_tiers: Vec<ModelServiceTier>,
}
impl CodexCatalogMetadata {
@@ -153,6 +157,12 @@ impl CodexCatalogMetadata {
pub const fn visibility(&self) -> Option<CodexCatalogVisibility> {
self.visibility
}
/// 返回上游明确声明的服务档位;缺失时为空,不根据模型名补齐。
#[must_use]
pub fn service_tiers(&self) -> &[ModelServiceTier] {
&self.service_tiers
}
}
/// Codex 官方 picker 可见性。
@@ -173,6 +183,7 @@ pub enum CodexCatalogVisibility {
/// 一个已完整校验的 Codex 真实模型。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexCatalogModel {
document: RawJsonPayload,
request_model: UpstreamModelId,
display_name: String,
capabilities: CodexCatalogCapabilities,
@@ -181,6 +192,12 @@ pub struct CodexCatalogModel {
}
impl CodexCatalogModel {
/// 完整原生对象只用于客户端目录,不写入控制面画像或日志。
#[must_use]
pub const fn document(&self) -> &RawJsonPayload {
&self.document
}
/// 返回实际写入上游请求的模型 slug。
#[must_use]
pub const fn request_model(&self) -> &UpstreamModelId {
@@ -270,7 +287,7 @@ pub enum CodexModelCatalogError {
#[derive(Debug, Deserialize)]
struct CodexModelsWire {
models: Vec<CodexModelWire>,
models: Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
@@ -289,6 +306,15 @@ struct CodexModelWire {
context_window: Option<i64>,
max_context_window: Option<i64>,
input_modalities: Option<Vec<CodexInputModalityWire>>,
#[serde(default)]
service_tiers: Vec<CodexServiceTierWire>,
}
#[derive(Debug, Deserialize)]
struct CodexServiceTierWire {
id: String,
name: String,
description: String,
}
#[derive(Debug, Deserialize)]
@@ -309,7 +335,7 @@ enum CodexInputModalityWire {
///
/// # Errors
///
/// 任一条目、重复 slug 或 ETag 不合法时整轮失败;上游新增的未知字段会忽略。
/// 任一条目、重复 slug 或 ETag 不合法时整轮失败;未知字段保留在原生正文中。
pub fn parse_codex_model_catalog(
body: &[u8],
etag: Option<&str>,
@@ -330,7 +356,16 @@ pub fn parse_codex_model_catalog(
let mut seen = BTreeSet::new();
let mut models = Vec::with_capacity(wire.models.len());
for model in wire.models {
let model = normalize_model(model)?;
let wire =
CodexModelWire::deserialize(&model).map_err(|_| CodexModelCatalogError::InvalidWire)?;
let document = RawJsonPayload::new(
"codex",
Bytes::from(
serde_json::to_vec(&model).map_err(|_| CodexModelCatalogError::InvalidWire)?,
),
)
.map_err(|_| CodexModelCatalogError::InvalidWire)?;
let model = normalize_model(wire, document)?;
if !seen.insert(model.request_model.as_str().to_owned()) {
return Err(CodexModelCatalogError::DuplicateModelSlug);
}
@@ -351,7 +386,10 @@ pub(super) fn catalog_etag(headers: &HeaderMap) -> Result<Option<String>, CodexM
.transpose()
}
fn normalize_model(wire: CodexModelWire) -> Result<CodexCatalogModel, CodexModelCatalogError> {
fn normalize_model(
wire: CodexModelWire,
document: RawJsonPayload,
) -> Result<CodexCatalogModel, CodexModelCatalogError> {
if !valid_model_slug(&wire.slug) {
return Err(CodexModelCatalogError::InvalidModelSlug);
}
@@ -361,6 +399,17 @@ fn normalize_model(wire: CodexModelWire) -> Result<CodexCatalogModel, CodexModel
if let Some(description) = wire.description.as_deref() {
validate_public_text(description, MAX_DESCRIPTION_BYTES, true)?;
}
// name 供客户端生成命令,id 才是请求选档值;不能从命令名推导或改写 id。
let service_tiers = wire
.service_tiers
.into_iter()
.map(|tier| {
validate_public_text(&tier.id, MAX_SERVICE_TIER_ID_BYTES, false)?;
validate_public_text(&tier.name, MAX_DISPLAY_NAME_BYTES, false)?;
validate_public_text(&tier.description, MAX_DESCRIPTION_BYTES, true)?;
Ok(ModelServiceTier::new(tier.id, tier.name, tier.description))
})
.collect::<Result<Vec<_>, CodexModelCatalogError>>()?;
let context_window_tokens = optional_positive(wire.context_window)?;
let max_context_window_tokens = optional_positive(wire.max_context_window)?;
@@ -382,6 +431,7 @@ fn normalize_model(wire: CodexModelWire) -> Result<CodexCatalogModel, CodexModel
}
Ok(CodexCatalogModel {
document,
request_model,
display_name: wire.display_name,
capabilities: CodexCatalogCapabilities {
@@ -405,6 +455,7 @@ fn normalize_model(wire: CodexModelWire) -> Result<CodexCatalogModel, CodexModel
description: wire.description,
priority: wire.priority,
visibility: wire.visibility,
service_tiers,
},
})
}
@@ -495,10 +495,11 @@ impl CodexBackendClient {
Some(key)
}
/// 获取后端模型目录条目。
/// 客户端目录按调用方版本协商;后台目录仍使用经过核验的服务端画像版本。
pub async fn fetch_models_with_context(
&self,
context: CodexRequestContext<'_>,
client_version: Option<&str>,
) -> CodexClientResult<CodexModelCatalogSnapshot> {
let endpoint = endpoint_url(&self.base_url, "codex/models");
let profile = self.profile.snapshot();
@@ -506,7 +507,10 @@ impl CodexBackendClient {
let response = self
.client
.get(endpoint)
.query(&[("client_version", profile.codex_version.as_str())])
.query(&[(
"client_version",
client_version.unwrap_or(profile.codex_version.as_str()),
)])
.headers(headers)
.send()
.await?;
@@ -644,10 +644,18 @@ impl CodexResponsesRequest {
/// 提取 Codex 请求类型、子代理类型、推理预设与压缩语义。
pub fn semantics(&self) -> CodexRequestSemantics {
codex_responses_request_semantics_with_turn_metadata(
let mut semantics = codex_responses_request_semantics_with_turn_metadata(
self.body(),
self.turn_metadata.as_deref(),
)
);
// 官方 generate=false 只准备连接与上下文,不是模型推理。
// 预热分类参与用量筛选,不能仅凭客户端的 request_kind 提示排除真实推理。
if !self.generate() {
semantics.request_kind = Some("prewarm".to_owned());
} else if semantics.request_kind.as_deref() == Some("prewarm") {
semantics.request_kind = None;
}
semantics
}
/// 返回请求语义与 child transport 隔离所用的子代理区分值。
@@ -5,12 +5,16 @@ use chrono::{TimeZone as _, Utc};
use futures::executor::block_on;
use gateway_core::account::{ProviderAccount, ProviderAccountId};
use gateway_core::provider_ports::ProviderCatalogCachePort;
use gateway_core::routing::{
ClientRoutingScope, FrozenAccountScope, ModelServiceTier, ProviderKind, RuntimeAccount,
RuntimeAccountDirectory,
};
use provider_openai::OFFICIAL_CODEX_BASE_URL;
use provider_openai::credential::{
CodexCredentialCatalogError, CodexCredentialCatalogService, ImportCodexOAuthCredential,
};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use wiremock::matchers::{method, path};
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use crate::support::{MemoryAccountStore, catalog_cache, profile, secret};
@@ -113,6 +117,289 @@ async fn seed_account_with_plan(
store.account(account_id).expect("seeded account")
}
fn client_scope(accounts: &[ProviderAccount]) -> FrozenAccountScope {
FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(
accounts
.iter()
.map(|account| {
(
account.id().clone(),
RuntimeAccount::new(
ProviderKind::new("openai").expect("provider"),
Default::default(),
),
)
})
.collect(),
)),
ClientRoutingScope::all_accounts(),
)
}
#[tokio::test]
async fn client_catalog_is_account_and_version_scoped_and_keeps_original_objects() {
let store = Arc::new(MemoryAccountStore::default());
let first = seed_account(&store, "acct_client_a").await;
let second = seed_account(&store, "acct_client_b").await;
let server = MockServer::start().await;
for (account, version, text) in [
("acct_client_a", "0.154.0", "a-original"),
("acct_client_b", "0.154.0", "b-original"),
("acct_client_a", "0.155.0", "a-new-client"),
] {
Mock::given(method("GET")).and(path("/codex/models"))
.and(header("authorization", format!("Bearer access-{account}")))
.and(header("chatgpt-account-id", format!("chatgpt-{account}")))
.and(query_param("client_version", version))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"models":[{
"slug":"gpt-native", "display_name":"Native", "base_instructions":text,
"future_field":{"null":null,"list":[1,true]}, "model_messages":{"new_template":text}
}]}))).expect(1).mount(&server).await;
}
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
for (accounts, version, text) in [
(vec![second.clone(), first.clone()], "0.154.0", "a-original"),
(vec![second.clone()], "0.154.0", "b-original"),
(vec![first.clone()], "0.155.0", "a-new-client"),
(vec![first], "0.154.0", "a-original"),
] {
let models = service
.client_model_catalog(&client_scope(&accounts), version)
.await
.expect("catalog");
let document: serde_json::Value =
serde_json::from_slice(models[0].payload.body()).expect("document");
assert_eq!(
document,
serde_json::json!({
"slug":"gpt-native", "display_name":"Native", "base_instructions":text,
"future_field":{"null":null,"list":[1,true]}, "model_messages":{"new_template":text}
})
);
}
assert!(
service
.client_model_catalog(&client_scope(&[]), "0.154.0")
.await
.is_err()
);
server.verify().await;
}
#[tokio::test]
async fn client_catalog_coalesces_reads_and_invalidates_on_etag_expiry_and_explicit_reset() {
let store = Arc::new(MemoryAccountStore::default());
let account = seed_account(&store, "acct_client_cache").await;
let scope = client_scope(&[account]);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(OFFICIAL_FIXTURE, "application/json")
.set_delay(std::time::Duration::from_millis(20)),
)
.expect(4)
.mount(&server)
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let (first, second) = tokio::join!(
service.client_model_catalog(&scope, "0.154.0"),
service.client_model_catalog(&scope, "0.154.0")
);
assert_eq!(first.expect("first"), second.expect("second"));
service
.invalidate()
.expect("reset without a routing snapshot");
service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("after reset");
assert!(service.observe_response_etag("catalog-new").expect("ETag"));
service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("after ETag");
assert!(
!service
.observe_response_etag("catalog-new")
.expect("same ETag")
);
service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("still cached");
tokio::time::pause();
tokio::time::advance(std::time::Duration::from_secs(301)).await;
tokio::time::resume();
service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("after TTL");
server.verify().await;
}
#[tokio::test]
async fn client_catalog_cache_does_not_survive_credential_rotation() {
let store = Arc::new(MemoryAccountStore::default());
let account = seed_account(&store, "acct_client_rotate").await;
let scope = client_scope(std::slice::from_ref(&account));
let server = MockServer::start().await;
for (token, text) in [
("access-acct_client_rotate", "old"),
("rotated-access", "new"),
] {
Mock::given(method("GET"))
.and(path("/codex/models"))
.and(header("authorization", format!("Bearer {token}")))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"models":[{
"slug":"gpt-native", "display_name":"Native", "base_instructions":text
}]})),
)
.expect(1)
.mount(&server)
.await;
}
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let first = service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("old");
store
.repository()
.rotate_refreshed_oauth_secret(&account, secret("rotated-access"), None, None)
.await
.expect("rotate");
let second = service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("new");
assert_ne!(first, second);
server.verify().await;
}
#[tokio::test]
async fn failed_client_catalog_never_falls_back_to_an_out_of_scope_account_or_plan_cache() {
let store = Arc::new(MemoryAccountStore::default());
let allowed = seed_account(&store, "acct_client_allowed").await;
seed_account(&store, "acct_client_outside").await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.and(header("authorization", "Bearer access-acct_client_allowed"))
.respond_with(ResponseTemplate::new(503))
.expect(1)
.mount(&server)
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let scope = client_scope(&[allowed]);
for _ in 0..2 {
assert!(
service
.client_model_catalog(&scope, "0.154.0")
.await
.is_err()
);
}
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
server.verify().await;
}
#[tokio::test]
async fn raw_instructions_only_change_advances_catalog_generation() {
let store = Arc::new(MemoryAccountStore::default());
seed_account(&store, "acct_raw_generation").await;
let server = MockServer::start().await;
Mock::given(method("GET")).and(path("/codex/models"))
.respond_with(SequencedCatalogResponder {
calls: Arc::new(AtomicUsize::new(0)),
bodies: [
br#"{"models":[{"slug":"gpt-native","display_name":"Native","model_messages":{"future":"old"}}]}"#,
br#"{"models":[{"slug":"gpt-native","display_name":"Native","model_messages":{"future":"new"}}]}"#,
],
}).expect(2).mount(&server).await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
service.synchronize().await.expect("initial");
let generation = service.catalog_generation();
service.refresh_catalogs().await.expect("refresh");
assert!(service.catalog_generation() > generation);
server.verify().await;
}
#[tokio::test]
async fn client_catalog_evicts_old_entries_after_32_distinct_versions() {
let store = Arc::new(MemoryAccountStore::default());
let account = seed_account(&store, "acct_client_bounded").await;
let scope = client_scope(&[account]);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_raw(OFFICIAL_FIXTURE, "application/json"))
.expect(34)
.mount(&server)
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
for patch in 0..33 {
service
.client_model_catalog(&scope, &format!("0.154.{patch}"))
.await
.expect("catalog");
}
service
.client_model_catalog(&scope, "0.154.32")
.await
.expect("newest is cached");
service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("oldest was evicted");
server.verify().await;
}
#[tokio::test]
async fn client_catalog_retries_after_short_failure_cache_expires() {
let store = Arc::new(MemoryAccountStore::default());
let account = seed_account(&store, "acct_client_retry").await;
let scope = client_scope(&[account]);
let server = MockServer::start().await;
let failure = Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(ResponseTemplate::new(503))
.expect(1)
.mount_as_scoped(&server)
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
assert!(
service
.client_model_catalog(&scope, "0.154.0")
.await
.is_err()
);
drop(failure);
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_raw(OFFICIAL_FIXTURE, "application/json"))
.expect(1)
.mount(&server)
.await;
assert!(
service
.client_model_catalog(&scope, "0.154.0")
.await
.is_err()
);
tokio::time::pause();
tokio::time::advance(std::time::Duration::from_secs(6)).await;
tokio::time::resume();
service
.client_model_catalog(&scope, "0.154.0")
.await
.expect("recovered after failure TTL");
server.verify().await;
}
#[test]
fn catalog_starts_without_a_snapshot_or_generation() {
let store = Arc::new(MemoryAccountStore::default());
@@ -192,6 +479,52 @@ async fn plan_catalog_cache_is_shared_and_manual_refresh_replaces_it() {
server.verify().await;
}
#[tokio::test]
async fn service_tier_changes_should_replace_cached_metadata_and_advance_generation() {
let store = Arc::new(MemoryAccountStore::default());
seed_account(&store, "acct_service_tiers").await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(SequencedCatalogResponder {
calls: Arc::new(AtomicUsize::new(0)),
bodies: [
br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4","service_tiers":[{"id":"priority","name":"Fast","description":"Priority processing."}]}]}"#,
br#"{"models":[{"slug":"gpt-5.4","display_name":"GPT-5.4","service_tiers":[]}]}"#,
],
})
.expect(2)
.mount(&server)
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let initial = service.synchronize().await.expect("initial catalog");
let generation = service.catalog_generation().get();
let cached = service.synchronize().await.expect("cached catalog");
assert_eq!(cached.models(), initial.models());
assert_eq!(
cached.models()[0].metadata().service_tiers(),
[ModelServiceTier::new(
"priority",
"Fast",
"Priority processing."
)]
);
assert_eq!(service.catalog_generation().get(), generation);
let refreshed = service.refresh_catalogs().await.expect("updated catalog");
assert!(refreshed.models()[0].metadata().service_tiers().is_empty());
assert_eq!(service.catalog_generation().get(), generation + 1);
assert_eq!(
service
.cached()
.expect("cache read")
.expect("cached snapshot")
.models(),
refreshed.models()
);
}
#[tokio::test]
async fn plan_catalog_refresh_stops_after_three_failed_accounts() {
let store = Arc::new(MemoryAccountStore::default());
@@ -33,9 +33,9 @@ use gateway_core::operation::{
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
ProviderModel, PublicModelId, RoutingContext, RuntimeAccount, RuntimeAccountDirectory,
RuntimeSnapshot, UpstreamModelId,
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ModelServiceTier,
ProviderKind, ProviderModel, PublicModelId, RoutingContext, RuntimeAccount,
RuntimeAccountDirectory, RuntimeSnapshot, UpstreamModelId,
};
use gateway_core::upstream::UpstreamSendState;
use provider_openai::config::DEFAULT_STREAM_MAX_RETRIES;
@@ -6113,6 +6113,56 @@ fn request_observation_reads_openai_metadata_without_changing_the_operation() {
assert!(matches!(operation, Operation::Generate(_)));
}
#[test]
fn request_observation_classifies_prewarm_from_generate_without_rewriting_the_payload() {
let store = Arc::new(MemoryAccountStore::default());
let provider = provider(&store);
let client_key_id = ClientApiKeyId::new("key_prewarm_observation").expect("client key");
for (generate, request_kind, expected_kind) in [
(Some(json!(false)), None, Some("prewarm")),
(Some(json!(false)), Some("prewarm"), Some("prewarm")),
(Some(json!(false)), Some("review"), Some("prewarm")),
(Some(json!(true)), Some("prewarm"), None),
(None, Some("prewarm"), None),
(Some(json!(null)), Some("prewarm"), None),
(Some(json!("false")), Some("prewarm"), None),
(None, Some("review"), Some("review")),
(None, None, None),
] {
let mut body = Map::from_iter([
("model".to_owned(), json!("gpt-test")),
("input".to_owned(), json!("hello")),
("store".to_owned(), json!(false)),
]);
if let Some(generate) = generate {
body.insert("generate".to_owned(), generate);
}
let payload = ProtocolPayload::json_object("openai", body.clone())
.expect("OpenAI payload")
.with_context(Map::from_iter([(
"turn_metadata".to_owned(),
Value::String(
json!({"request_kind": request_kind, "subagent_kind": "worker"}).to_string(),
),
)]));
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload));
let observation = provider.request_observation(&operation, &client_key_id);
assert_eq!(
observation.request_kind.as_deref(),
expected_kind,
"generate={:?}, request_kind={request_kind:?}",
body.get("generate"),
);
assert_eq!(observation.subagent_kind.as_deref(), Some("worker"));
let Operation::Generate(generation) = operation else {
unreachable!();
};
assert_eq!(generation.protocol_payload().body(), &body);
}
}
#[test]
fn endpoint_observation_should_read_models_without_rewriting_or_requiring_a_catalog() {
let store = Arc::new(MemoryAccountStore::default());
@@ -6257,6 +6307,41 @@ async fn provider_compiles_catalog_presentation_for_codex_models() {
assert!(presentation.image_detail_original());
assert!(presentation.verbosity());
assert!(!presentation.hidden());
assert_eq!(
presentation.service_tiers(),
[ModelServiceTier::new(
"priority",
"Fast",
"Priority processing."
)]
);
let scope = FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([(
ProviderAccountId::new("acct_presentation").expect("account"),
RuntimeAccount::new(
ProviderKind::new("openai").expect("provider"),
BTreeSet::new(),
),
)]))),
ClientRoutingScope::all_accounts(),
);
let native = provider
.query_client_model_catalog(&scope, "codex", "0.154.0")
.await
.expect("native catalog")
.expect("supported");
let original: Value = serde_json::from_slice(OFFICIAL_FIXTURE).expect("fixture");
let document: Value = serde_json::from_slice(native[0].payload.body()).expect("document");
assert_eq!(document, original["models"][0]);
assert!(
server
.received_requests()
.await
.expect("requests")
.iter()
.any(|request| request.url.query() == Some("client_version=0.154.0"))
);
}
#[tokio::test]
@@ -1,15 +1,43 @@
use chrono::{TimeZone, Utc};
use gateway_core::routing::ModelServiceTier;
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use provider_openai::transport::{
CodexBackendClient, CodexCatalogCapabilityEvidence, CodexCatalogVisibility, CodexClientError,
CodexModelCatalogError, CodexRequestContext, MAX_CODEX_MODEL_CATALOG_BYTES,
build_reqwest_client, parse_codex_model_catalog,
};
use serde_json::{Value, json};
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
const OFFICIAL_FIXTURE: &[u8] = include_bytes!("fixtures/official_models_snapshot.json");
#[test]
fn native_document_keeps_unknown_nested_fields_nulls_and_model_instructions() {
let original = json!({
"slug": "gpt-native", "display_name": "Native",
"base_instructions": "original model instructions\nwith line breaks",
"model_messages": {"future_instructions": {"template": "do not replace"}},
"future_field": [null, {"new": true}], "explicit_null": null,
"service_tiers": [{"id":"priority", "name":"Fast", "description":"1.5x speed", "future":true}]
});
let body = serde_json::to_vec(&json!({"models":[original]})).expect("body");
let snapshot = parse_codex_model_catalog(&body, None).expect("catalog");
let model = &snapshot.models()[0];
let preserved: Value = serde_json::from_slice(model.document().body()).expect("document");
assert_eq!(preserved, original);
assert!(!format!("{model:?}").contains("original model instructions"));
assert!(!format!("{model:?}").contains("future_instructions"));
let mut changed = original;
changed["model_messages"]["future_instructions"]["template"] = json!("new content");
let changed = parse_codex_model_catalog(
&serde_json::to_vec(&json!({"models":[changed]})).expect("body"),
None,
)
.expect("catalog");
assert_ne!(snapshot.models(), changed.models());
}
#[test]
fn official_fixture_should_produce_safe_full_snapshot() {
let snapshot = parse_codex_model_catalog(OFFICIAL_FIXTURE, Some("W/\"codex-v1\""))
@@ -65,7 +93,7 @@ fn official_fixture_should_produce_safe_full_snapshot() {
}
#[test]
fn non_whitelisted_wire_fields_should_not_survive_normalization() {
fn raw_instructions_and_unknown_fields_should_not_appear_in_snapshot_debug() {
let snapshot =
parse_codex_model_catalog(OFFICIAL_FIXTURE, None).expect("official fixture should parse");
let debug = format!("{snapshot:?}");
@@ -77,6 +105,91 @@ fn non_whitelisted_wire_fields_should_not_survive_normalization() {
);
}
#[test]
fn service_tiers_should_preserve_declared_ids_names_and_descriptions() {
let body = json!({"models": [{
"slug": "gpt-future",
"display_name": "Future",
"service_tiers": [
{"id": "priority", "name": "Fast", "description": "Priority processing."},
{"id": "deferred", "name": "Economy", "description": ""}
]
}]});
let snapshot = parse_codex_model_catalog(&serde_json::to_vec(&body).expect("JSON"), None)
.expect("declared service tiers");
assert_eq!(
snapshot.models()[0].metadata().service_tiers(),
[
ModelServiceTier::new("priority", "Fast", "Priority processing."),
ModelServiceTier::new("deferred", "Economy", ""),
]
);
}
#[test]
fn missing_or_empty_service_tiers_should_not_infer_fast_from_legacy_metadata() {
for service_tiers in [None, Some(json!([]))] {
let mut model = json!({
"slug": "gpt-5.4",
"display_name": "GPT-5.4",
"additional_speed_tiers": ["fast"]
});
if let Some(service_tiers) = service_tiers {
model["service_tiers"] = service_tiers;
}
let body = json!({"models": [model]});
let snapshot = parse_codex_model_catalog(&serde_json::to_vec(&body).expect("JSON"), None)
.expect("optional service tiers");
assert!(snapshot.models()[0].metadata().service_tiers().is_empty());
}
}
#[test]
fn invalid_service_tier_metadata_should_fail_the_entire_snapshot() {
for (field, value) in [
("id", String::new()),
("id", "p".repeat(65)),
("name", " ".to_owned()),
("name", "f".repeat(257)),
("name", "Fast\u{1b}".to_owned()),
("description", "d".repeat(4097)),
("description", "unsafe\u{0}".to_owned()),
] {
let mut tier = json!({"id": "priority", "name": "Fast", "description": "Priority."});
tier[field] = Value::String(value);
let body = json!({"models": [{
"slug": "gpt-5.4", "display_name": "GPT-5.4", "service_tiers": [tier]
}]});
assert_eq!(
parse_codex_model_catalog(&serde_json::to_vec(&body).expect("JSON"), None),
Err(CodexModelCatalogError::InvalidMetadata),
"invalid {field}"
);
}
}
#[test]
fn malformed_service_tiers_should_fail_the_official_wire_contract() {
for service_tiers in [
Value::Null,
json!("fast"),
json!([{"id": "priority", "name": "Fast"}]),
json!([{"id": 1, "name": "Fast", "description": "Priority."}]),
] {
let body = json!({"models": [{
"slug": "gpt-5.4", "display_name": "GPT-5.4", "service_tiers": service_tiers
}]});
assert_eq!(
parse_codex_model_catalog(&serde_json::to_vec(&body).expect("JSON"), None),
Err(CodexModelCatalogError::InvalidWire)
);
}
}
#[test]
fn invalid_etag_should_fail_the_entire_snapshot() {
let result = parse_codex_model_catalog(OFFICIAL_FIXTURE, Some("raw-unquoted-etag"));
@@ -241,7 +354,7 @@ async fn fetch_should_send_official_catalog_headers_and_query() {
profile(),
);
let snapshot = client
.fetch_models_with_context(context())
.fetch_models_with_context(context(), None)
.await
.expect("fetch strict snapshot");
@@ -285,7 +398,7 @@ async fn fetch_should_reject_streamed_body_over_hard_limit() {
profile(),
);
let result = client.fetch_models_with_context(context()).await;
let result = client.fetch_models_with_context(context(), None).await;
assert!(matches!(
result,
@@ -13,6 +13,13 @@
"supports_image_detail_original": true,
"supports_search_tool": true,
"support_verbosity": true,
"service_tiers": [
{
"id": "priority",
"name": "Fast",
"description": "Priority processing."
}
],
"supported_reasoning_levels": [
{
"effort": "low",
@@ -32,7 +32,7 @@ async fn each_core_endpoint_uses_the_current_bundled_release() {
});
let context = request_context("audit", Some("audit-account"));
client
.fetch_models_with_context(context)
.fetch_models_with_context(context, None)
.await
.expect_err("fixture rejection");
client
+1
View File
@@ -3,3 +3,4 @@ b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.s
46f493ff410cf77550114542e0418475876ee2b2c975436290f0f72440e098c4 0003_account_outbound_proxy.sql
5e4bfd0b47a4de5e99b7ae1d89e0294de61856a40fba3480572db765147c3b3a 0004_remove_client_charge_reconciliation.sql
2741fabb6ec23e7a19962e364ffdc054f81584c868e24453e33644b96ccca19b 0005_managed_outbound_proxies.sql
393968715af911bb09f6738472678fbedcdc0a0967c39711610f52b9a2994c3f 0006_custom_client_keys.sql
@@ -0,0 +1,17 @@
-- 保留旧 Key,允许迁入不同前缀、长度和标点;约束与 HTTP Bearer 可见字符一致。
alter table client_api_keys
drop constraint client_api_keys_key_ck,
add constraint client_api_keys_key_ck check (
key <> '' and key collate "C" !~ '[^!-~]'
);
-- 可见 ASCII 在 UTF-8 中编码固定,因此这里可声明 immutable。
-- 对完整值建固定大小的摘要索引,避免长 Key 超过 PostgreSQL B-tree 索引项上限。
create function client_api_key_sha256(value text) returns bytea
language sql immutable strict parallel safe
return sha256(convert_to(value, 'UTF8'));
create unique index client_api_keys_key_sha256_idx
on client_api_keys (client_api_key_sha256(key));
alter table client_api_keys drop constraint client_api_keys_key_key;
+17 -23
View File
@@ -17,11 +17,9 @@
按快速开始下载部署文件后,从安装目录执行:
```bash
mkdir -p .runtime/data .runtime/logs
install -d -m 0750 .runtime/postgres .runtime/redis
cp deploy/config.example.yaml deploy/config.yaml
sudo chown "$(id -u):10001" deploy/config.yaml
chmod 0640 deploy/config.yaml
sudo install -d -m 0770 -o "$(id -u)" -g 10001 .runtime/data .runtime/logs
sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
```
为 PostgreSQL 与 Redis 分别生成一个密码:
@@ -41,15 +39,9 @@ openssl rand -hex 24
PostgreSQL 与 Redis 密码必须是 48 位十六进制字符。Compose 通过 `config.yaml` 的凭据桥接区
引用同一密码;三个值都不需要额外导出为环境变量,数据库和 Redis 密码也不能嵌入连接 URL。
Linux 上应用容器以 `10001:10001` 运行,需要允许该组写入应用数据和日志目录:
```bash
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
chmod 0770 .runtime/data .runtime/logs
```
`config.yaml` 通过 Compose `configs` 只读挂载。普通 Compose 对本地文件保留宿主机的
UID/GID 和 mode,因此配置由当前用户持有,并只向容器组 `10001` 开放读取权限。
Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和日志目录设为 `0770`,
配置设为 `0640`,均由当前用户持有、容器组 `10001` 访问。
`config.yaml` 通过 Compose `configs` 只读挂载,普通 Compose 保留宿主机文件的 UID/GID 和 mode。
模板中的 `openai` / `xai` 只保留请求画像启动基线。OpenAI 的上游地址、WebSocket 池、额度刷新
与 OAuth 设置,以及 xAI 的 OAuth、额度和模型目录策略,均由各自 Provider 使用代码内默认值管理;
@@ -78,12 +70,17 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet`。
## 公网访问
Compose 默认只绑定 `127.0.0.1`。从其他设备访问时,在应用前配置 HTTPS 反向代理,
Compose 默认只绑定 `127.0.0.1`。从其他设备访问时,在应用前配置反向代理,
不要把 PostgreSQL 或 Redis 暴露到公网。
同源管理端支持 HTTP 和 HTTPS 登录。反向代理应原样保留浏览器的 `Origin`,
不要清除它或改写 Cookie 的 `Secure` 属性;HTTPS 反代可以使用 HTTP 回源。
HTTP 传输不加密,公网部署仍建议使用 HTTPS。
会话 Cookie 合同见 [管理接口鉴权](../docs/api.md#管理接口)。
反向代理需要保留 `Authorization`,支持 `/v1/responses` 的 WebSocket Upgrade,
并关闭 SSE 响应缓冲。读取超时应覆盖长时间生成任务。
客户端使用 `https://你的域名/v1`,不要使用前端开发服务的 `5173/dev/v1`。
客户端使用部署地址下的 `/v1`,协议与部署一致,不要使用前端开发服务的 `5173/dev/v1`。
当前应用只支持单副本,不能通过复制容器扩容。
流式响应在首个上游事件提交后,每 15 秒无输出会发送一次 SSE 注释保活,
@@ -156,7 +153,7 @@ goals = true
### 生图和 WebSocket
新模板已启用原生生图。Codex 可在任务需要图片时调用 `image_gen.imagegen`,
模板启用原生生图。Codex 可在任务需要图片时调用 `image_gen.imagegen`,
再通过代理的 Images 接口生成或编辑图片;也可以在需求中明确要求生成并使用图片。
需要支持该能力的客户端、支持图片输入的对话模型,以及有生图权限和额度的 OpenAI 账号。
代理不会增加上游权限,xAI 账号不能承接这些 Images 请求。
@@ -173,7 +170,7 @@ goals = true
仅含代理密钥的 `auth.json` 配合 `requires_openai_auth = true` 仍可用于已有请求,
但 API Key 登录本身不会启用原生生图。需要生图时换用上述 Provider 配置。
以已核验的 Codex 0.153.4 为准,管理端已移除以下旧字段:
使用 Codex 0.153.4 时,旧配置中的以下字段需删除或替换:
| 字段 | 处理 |
| --- | --- |
@@ -289,8 +286,7 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr
2. 收集响应中的 `x-gateway-request-id`、`x-request-id` / `x-oai-request-id`;配置了
`api.request_id_header` 时也记录该入口头。WebSocket 合成错误的关联头位于本条错误的 `headers`。
在管理端错误列表按 ID 和时间搜索;检查平台条件并主动刷新,翻页不会推进查询时间。
已建立模型执行的 HTTP 响应在没有上游 ID 时以模型执行 ID 回传 `x-request-id`,
不需要新增入口 ID 数据列。旧响应或执行前拒绝若只有入口 ID,改用入口日志和时间定位。
只有入口 ID 时,改用入口日志和时间定位。
3. 打开错误详情,核对上游/客户端状态、发送状态、attempt、失败阶段及后续恢复关联。
下载默认诊断包作为反馈材料,先看 `availability`、`attemptsComplete` 和淘汰计数;
该包不含原始错误正文或完整 trace data。分享前仍应检查关联 ID 等内部信息。
@@ -385,10 +381,8 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
### 备份内容与限制
- 运行时镜像的 `pg_dump`/`pg_restore` 在构建期从与 compose 服务端同一固定
`postgres:18-bookworm` 镜像(`Dockerfile` 的 `POSTGRES_IMAGE`)COPY 到
`/usr/lib/postgresql/18/bin` 并加入容器 PATH,版本与服务端严格一致;运行时只从
Debian 官方源补 `libpq5` 依赖,不引入第三方 APT 源,也不依赖运行时动态安装。
- 官方运行镜像内置与 Compose 数据库服务版本一致的 `pg_dump` / `pg_restore`,无需额外安装。
自行替换 PostgreSQL 版本时,应同步核对备份工具版本。
- 备份暂存目录为 `host.runtime_data_dir/backup-staging`;Compose 默认对应
`/app/.runtime/data/backup-staging`,由 `.runtime/data` 卷持久化,权限 `0700`(仅 `cpr`
用户可读写)。部署卷至少预留一个最大数据库归档的空间。
+1 -3
View File
@@ -1,9 +1,7 @@
# Codex Proxy RS 应用配置模板。
#
# 使用:
# cp deploy/config.example.yaml deploy/config.yaml
# sudo chown "$(id -u):10001" deploy/config.yaml
# chmod 640 deploy/config.yaml
# sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
#
# PostgreSQL 与 Redis 密码分别执行 `openssl rand -hex 24` 生成;管理员初始密码单独设置。
+116 -84
View File
@@ -14,6 +14,9 @@
Authorization: Bearer sk_...
```
自动生成的 Key 保持 `sk_` 格式;迁入的自定义 Key 使用保存时的原值,不限制前缀或固定长度。
无论格式如何,只有已保存且启用的 Client Key 能通过鉴权。
Codex 原生生图配置还会携带 `X-OpenAI-Actor-Authorization: proxy-managed`。
它仅用于客户端识别服务端托管认证,不能代替 Client Key。网关和 OpenAI Provider 都会过滤该请求头,
上游账号身份只由服务端选中的账号提供;不要把真实账号 token 放进该标记。
@@ -51,6 +54,11 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全
- 浏览器登录后得到的 `cpr_admin_session` Cookie;
- `x-api-key: <admin-api-key>`。
同源管理端的登录和退出根据浏览器 `Origin` 自动设置会话 Cookie:HTTP 来源省略 `Secure`,
HTTPS 来源以及缺失、`null` 或非法来源保留 `Secure`。`HttpOnly`、`SameSite=Lax` 始终保留。
不根据 `X-Forwarded-Proto` 等转发头降级;HTTPS 反代使用 HTTP 回源不影响 Cookie。
部署要求见 [公网访问](../deploy/README.md#公网访问)。
请求无需自带 `x-request-id`;缺失时服务端自动生成 UUID 并在响应头回传同一 request ID。
`api.request_id_header` 可改变注入与回传的 header 名,管理端鉴权不依赖该名字。
管理端响应统一带 `Cache-Control: no-store`。
@@ -79,8 +87,7 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全
}
```
管理端本地产生的 `message` 是可安全展示的中文文案;Store、Serde、Provider 内部 `Display` 和原始上游
body 不进入这个通用信封。稳定业务码如下:
管理端本地产生的 `message` 是可安全展示的中文文案,不包含内部异常或原始上游正文。稳定业务码如下:
| HTTP | `code` | 含义 |
| ---: | ---: | --- |
@@ -99,20 +106,21 @@ body 不进入这个通用信封。稳定业务码如下:
未知 `/api/admin/*` 路径使用 `40401`,不会落入 SPA;已存在路径使用错误 method 时返回 `405`、
`40001`,并保留标准 `Allow` header。request ID 继续通过配置的响应 header 返回。
Provider 管理适配使用静态 `public_message` 提供可操作的具体原因,Admin 用例完成安全消息选择后,
API 对 `50201`、`50202` 和 `50301` 也保留该消息,不再用固定错误覆盖;缺少安全消息时仍回退到通用提示。
认证错误和未知内部错误继续使用固定文案,不公开 Provider 内部 message、原始响应或凭据。
`50201`、`50202` 和 `50301` 的 `message` 提供可安全展示的具体原因;缺少安全消息时使用通用提示。
认证错误和未知内部错误使用固定文案,不公开 Provider 内部 message、原始响应或凭据。
手动刷新令牌时,容量/账号租约占用和账号快照冲突仍为 `40901`,但分别提示等待或刷新账号列表。
OpenAI 已收到的刷新失败响应不再统一归为资源冲突:原先落入宽泛 Transport 分类的明确拒绝使用
`50201`,按已解析的错误码区分令牌过期、已使用、已撤销和 `invalid_grant`;刷新接口返回
`token_expired` 时提示“刷新令牌不可用,请重新授权”,不据此断言具体失效原因。无法确认刷新结果时使用
`50202`,提示先核对账号状态、不要立即重复刷新。缺少刷新令牌及原有明确凭据无效分支仍为 `40001`。
上游 `401` 不代表管理员会话失效,也不会触发管理端重新登录。上述变化只修正管理错误的分类和展示,
不改变后台自动刷新、401 恢复退避或账号终态策略;客户端不得仅因状态码从 `409` 改为 `502` 自动重发刷新。
xAI 手动刷新返回无效的新凭据时,从 `40001` 改为 `50202`,因为上游可能已经轮换了旧 RT;
未能完成刷新、但没有明确凭据永久失效证据的 `Rejected` 从 `40001` 改为 `50201`,不再一概提示凭据无效。
Codex PAT 验证服务不可用和身份响应无效分别通过 `50301`、`50201` 保留原有具体提示。
手动刷新令牌时,按错误类型处理:
| `code` | 场景 | 调用方处理 |
| --- | --- | --- |
| `40901` | 刷新容量、账号租约占用或账号快照冲突 | 按提示等待,或重新查询账号后操作 |
| `40001` | 缺少或无效的凭据、已确认刷新令牌失效或账号停用 | 检查凭据或重新授权 |
| `50201` | 明确的上游失败,且未归类为凭据永久失效 | 根据安全消息处理上游拒绝、限流或服务异常 |
| `50202` | 无法确认令牌轮换结果,包括 xAI 返回无效的新凭据 | 先核对账号状态,不要自动重发可能已轮换的一次性令牌 |
| `50301` | 刷新服务或依赖暂不可用 | 检查出站连接与依赖服务后重试 |
OpenAI 上游 `401` 按 `50201` 返回,不代表管理员会话失效,也不要求管理端重新登录。
Codex PAT 验证服务不可用和身份响应无效分别返回 `50301`、`50201` 及对应的安全提示。
### 管理写入一致性
@@ -160,7 +168,6 @@ Responses 不透传下游的逐跳头、反代元数据(如 `cf-*`、`x-forwar
Responses WebSocket 仅接受文本 `response.create`,同一连接串行执行。当前响应期间收到的后续业务帧
留在有界接收队列中,待当前响应完成终结和写出后再逐条校验、准入与执行,不因请求提前到达而断开。
这对齐 Codex 客户端 `stream_request` 持锁至本轮结束的串行行为,不表示支持额外控制消息类型。
接收队列容量为 32 个事件,超载仍关闭连接;Ping/Pong、客户端关闭和服务关闭不等待队列中的请求执行。
客户端使用 HTTP/SSE 时,OpenAI Provider 仍可能选择上游 WebSocket。
@@ -183,8 +190,29 @@ message/type/code;没有结构化错误时使用稳定安全文案,不把原
`GET /v1/models` 默认返回 OpenAI 兼容列表 `{"object": "list", "data": [...]}`;请求携带非空
`client_version` query 参数(Codex 客户端)时改为返回 Codex 专用目录合同 `{"models": [...]}`。
OpenAI Provider 按客户端传入的 `client_version` 请求上游目录,完整保留每个模型 JSON 对象,包括
`base_instructions`、`model_messages`、`service_tiers`、工具与能力字段,以及未知嵌套字段、显式 `null`
和字段缺失的区别。
模型别名仅替换 `slug`,不替换上游展示名、提示词、能力或 `priority`;保持原生模型顺序,新增别名附在后面。
目录仍按当前路由快照的模型存在性过滤,避免公布已知无法路由的模型;新模型需待后台目录对账后进入列表。
xAI 没有 Codex 原生目录,继续使用明确的通用画像适配。
目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 在其中按账号 ID 排序,使用首个成功读取的
合格账号,最多尝试三个账号;同名模型不跨账号或套餐混拼字段。因此单账号、无别名时可保持该账号的
模型对象一致,多账号/多 Provider 聚合不代表“与某个官方账号的整个目录完全一致”,也不会固定后续推理账号。
读取失败返回 `503 model_catalog_unavailable`,不以简化模板或空成功响应覆盖客户端缓存。
原生目录可能返回缓存结果,成功缓存有效期为 5 分钟。每次查询都检查账号资格和 Client Key 范围。
成功目录响应带 `Cache-Control: private, no-store`,不借用上游 ETag 标识经过选择/别名/聚合后的正文。
`service_tiers` 的 `name` 用于生成 `/fast` 等命令,`id` 是请求使用的 `service_tier` 值,二者不能互换。
上游未声明或明确返回空数组时不补档位,也不根据模型名或旧 `additional_speed_tiers` 字段推断 Fast。
该合同对齐
[官方 Codex 0.154.0 的模型元数据](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/openai_models.rs)
与其动态服务档位命令;客户端需重新加载模型目录才能使用更新后的档位信息。
Codex 专用目录中的 `context_window` 与 `max_context_window` 分别表示默认上下文窗口和客户端本地
覆盖的上限。OpenAI Provider 分别传递上游目录中的对应字段,缺失时保留 `null`;网关不通过部署配置
覆盖的上限。OpenAI Provider 原样保留上游对应字段,缺失与 `null` 不互相转换;网关不通过部署配置
覆盖这些值。Codex 客户端配置 `model_context_window` 后,按该值与非空 `max_context_window` 的较小值
使用窗口;上限为空时保留客户端本地值。xAI 目录只声明一个窗口,其 Provider 继续以该值作为客户端覆盖上限。
@@ -262,7 +290,6 @@ OpenAI 的 `self_serve_business_prolite` 等 Team 套餐显示为 `Business`;
### 独立代理管理 / Managed Proxies
所有端点要求管理员身份。所有响应只返回去掉认证信息的 `endpoint`,不会返回完整 URL。
All endpoints require admin authentication and redact proxy credentials from responses.
| 方法 / Method | 路径 / Path | 请求 / Request | 结果 / Result |
| --- | --- | --- | --- |
@@ -282,16 +309,13 @@ All endpoints require admin authentication and redact proxy credentials from res
代理列表只返回关联账号数量。关联账号按需查询,每项包含 `id`、`name`、`email`、`provider`、`enabled`、
`authenticationKind`、`planType`、`planTypeDisplay` 和 `groups: [{ id, name, color, enabled }]`,
不返回账号凭据。默认每页 20 条,按名称、ID 稳定排序;搜索不区分大小写,匹配名称或邮箱的字面子串。
不存在的代理返回 404,未绑定账号或没有匹配结果时返回空页。数量与当前页来自同一个数据库只读快照。
不存在的代理返回 404,未绑定账号或没有匹配结果时返回空页。数量与当前页对应同一查询快照。
移除关联账号只清除指定账号的代理绑定与连接地址,使其改为直连,保留凭据、调度参数与分组。
若账号已不再绑定请求中的代理,则返回 409;成功后在同一事务中更新配置版本与审计,并发布运行时快照。
若账号已不再绑定请求中的代理,则返回 409。
更新省略 `proxyUrl` 保留认证;连接配置改变时清除测试结果并更新所有绑定账号。
Omit `proxyUrl` to preserve credentials. Connection changes invalidate the previous test and update all bound accounts.
Tests persist only when the requested revision still matches. Connectivity failures use HTTP 200 with
`lastTest.success=false`; stale revisions, duplicate URLs and deleting an in-use proxy return 409.
The test concurrency limit returns 429.
测试结果只在请求中的版本仍匹配时保存。
测试固定经代理访问 `https://api.ipify.org?format=json`,超时 15 秒,每进程最多同时测试 4 条。
探测器复用 OpenAI 的证书信任配置:优先读取非空的 `CODEX_CA_CERTIFICATE`,
@@ -302,12 +326,6 @@ The test concurrency limit returns 429.
避免已轮换的凭据因代理状态变化而丢失。完成导入或请求取消后自动释放保护。
OAuth 等待回调期间不持有保护;提交仍拒绝已删除、连接配置改变或测试失败的代理。
Tests reach `https://api.ipify.org?format=json` through the configured proxy, with a 15-second timeout
and four concurrent tests per process. Provider access still requires the account connection test.
Imports accept a top-level `outboundProxyId` as the default exit before token exchange; explicit per-account
settings in the document take precedence. Credential imports reserve their selected proxy until commit;
concurrent proxy mutations return 409. OAuth commits still reject a deleted, changed or failed proxy.
### 账号连接测试 SSE
`GET /api/admin/accounts/connection-test` 固定探测请求指定的账号,不参与普通账号轮换。成功流沿用
@@ -335,6 +353,8 @@ concurrent proxy mutations return 409. OAuth commits still reject a deleted, cha
- `error`、`providerErrorCode`、`providerErrorType`、`upstreamStatus`、`upstreamContentType` 和
`upstreamBody` 是实际捕获的原始诊断字段;缺失时为 `null`,不会由本地猜测或翻译。
### 账号导入与 OAuth
导入的 `data` 必须是 JSON object,Admin API 请求上限为 64 MiB;Provider 可以收紧限制,
当前 xAI 导入上限为 16 MiB。内部 schema 由目标 Provider 独占解释:
@@ -350,8 +370,7 @@ concurrent proxy mutations return 409. OAuth commits still reject a deleted, cha
- xAI API Key 不是受支持的账号 credential;
- 导入不会只凭文件外形写入账号;目标 Provider 使用认证材料完成必要的 token exchange 或已认证账号资料补全。
管理端的 OpenAI `AT` / `RT` 标签是同一导入 API 的输入便利层:每行一个 token,最多 200 行,提交前
转换为对应的 `accounts` JSON。Admin API 本身不接收纯文本 token 列表。例如:
批量导入 AT / RT 使用 `accounts` JSON 数组,最多 200 项,不接受纯文本 token 列表。例如:
```json
{
@@ -373,8 +392,6 @@ RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token
与凭据在同一事务内提交;分组不存在时整次回滚。省略 `settings` 时新账号使用默认设置并保持未分组,
已有账号保留原有分组、权重与并发设置。重新授权不接受 `settings`,普通 credential refresh/rotation 也保留账号设置。
管理端先配置账号设置,再选择 OAuth、AT/RT 或账号文件完成导入。返回设置保留输入;更改出站配置会使
旧 OAuth 链接失效。文件中显式的出站配置优先于表单代理,未指定时使用表单代理。
账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。
OpenAI 的 CPR 导出保持 OAuth 账号的既有 token 与过期时间字段。
@@ -446,25 +463,20 @@ OAuth start 使用:
OAuth refresh 的明确永久错误写入。
- 正常 Responses 请求会解析上游响应的 rate-limit headers,合并进同一 quota 快照并同步状态。Free、
K12 等套餐共用该状态机;套餐只参与账号展示和按套餐隔离的模型目录 cache,不存在 K12 专属额度路径。
- 账号展开区的 Token 结构、模型排行和列表 Token 汇总优先使用账号级周额度窗口,无可统计的周窗口时
- 账号详情的 Token 统计、模型排行和列表 Token 汇总优先使用账号级周额度窗口,无可统计的周窗口时
使用月额度窗口;`usage.windowLabelDisplay` 随选中的窗口返回“周额度窗口”或“月额度窗口”。查询边界
严格为 `[resetAt - windowSeconds, resetAt)`,不是自然周/月或最近 7/30 天;额度刷新若返回了更早的
重置时间,会按新边界重新聚合。没有边界完整、可归属到账号的周/月窗口时显示无数据,标签为
“周/月额度窗口”,不回退到 5 小时、日窗口或历史累计。各额度条与 Dashboard 的百分比选择不受影响。
“周/月额度窗口”,不回退到 5 小时、日窗口或历史累计。
金额原值保持完整精度,USD 展示值
小于 1 美元时最多保留四位小数,其余保留两位。
- 账号页没有定时静默轮询。手工额度刷新只替换响应中的账号行并同步状态汇总,不触发整页 loading;若
新状态不符合当前筛选,该行从当前页移除。请求驱动或后台任务产生的状态变化,需要下一次显式查询账号
列表后才会显示。
### 周/月额度预测
`GET /api/admin/accounts/quota-forecast?accountId=...` 使用现有管理员鉴权,返回独立的预测结果,不向
账号列表或详情附加预测字段。管理端从“模型使用排行”后的图标打开弹窗时查询;切换周/月仅切换本次结果,
不轮询。关闭弹窗取消未完成的查询,重新打开重新采样。“刷新额度”仍调用现有
`POST /api/admin/accounts/quota/refresh`,成功后替换账号行并重新查询预测。
`GET /api/admin/accounts/quota-forecast?accountId=...` 使用管理员鉴权,每次查询返回独立的预测结果。
该接口不刷新上游额度;需要重新观测时,先调用 `POST /api/admin/accounts/quota/refresh`,再查询预测。
响应 `data` 包含 `accountId`、`generatedAt`、`generatedAtDisplay` 和 `forecasts`(`weekly`、`monthly`):
响应 `data` 包含 `accountId`、`generatedAt` 和 `forecasts`(`weekly`、`monthly`):
- `targetDays`、`extrapolated`:对应周期存在真实账号级窗口时采用实际时长;缺少对应窗口时,使用
可统计的周/月窗口按 7/30 天折算,明确标记 `extrapolated: true`。不把短期限流或模型专属桶当作账号容量。
@@ -472,27 +484,23 @@ OAuth start 使用:
公式为 `样本用量 × 100 / sampledPercent × 目标窗口秒数 / 源窗口秒数`。
- `remainingTokens` / `remainingUsd` 及对应 `*Display`:**额度快照时源窗口**的剩余估算,
公式为 `样本用量 × (100 - usedPercent) / sampledPercent`;不随目标周期折算,不代表当前可消费余额。
- `method` / `methodDisplay`:`incremental` 为近期分段估算,`cumulative` 为窗口累计估算。
同一额度段内,以历史额度和截至相应完成时间的累计用量建立基线,每累计至少 5 个百分点形成一段,
使用最近 3 个完整段及未满一段的尾部。只对合并区间计算比值,不平均逐请求小分母比值。
重复读数不增加段数;大于 1 个百分点的回落或累计计数倒退会中断采样,不能静默跨越。
- `source`:源窗口标识、实际天数、原始已用比例、窗口开始 / 额度快照 / 重置时间,
**选中采样区间**的请求数、总 Token、输入 / 输出 / 缓存命中构成、USD 费用与费用记录数。
`sampleStartAt` / `sampleStartAtDisplay` 为实际采样开始,`baselinePercent` 为基线已用比例,
`sampledPercent` / `sampledPercentDisplay` 为有效额度进度(百分点,而非时间进度)。
`blockCount` 是采用的完整进度段数,`observationCount` 是可供配对的历史点数,二者均不是独立统计样本数。
`missingTokenCount` 为不能由 total 或完整 input/output 确定 Token 的请求数;
`excludedRequestCount` 为选中区间内未进入成功用量口径的已结束请求数;
`pendingRequestCount` 为查询范围内在额度快照时尚未结束的请求数。
缓存命中属于输入,不重复相加。费用覆盖针对有效 USD 记录;其他币种或缺少有效金额均不能当成零 USD。
- `source`:源窗口名称 `label`、已用比例 `usedPercent` / `usedPercentDisplay`、
额度观测时间 `observedAt` / `observedAtDisplay`、用于过期检查的 `resetAt`,以及选中采样区间
已记录的 `tokensDisplay` / `usdDisplay`。`source: null` 表示没有可选的源窗口。
`observedAt` 表示额度观测时间,与本次查询的 `generatedAt` 不同。
- `unavailableReason`:不能估算时的说明,正常为 `null`。有效进度少于 5 个百分点不预测;
`lowSample` 表示有效进度不足 10 个百分点,或增量采样少于 2 个完整段,仅是质量提示,不承诺精度。
`incompleteCost` 仅抑制费用预测;`incompleteTokens` 仅抑制 Token 预测。未知值不按确定的零消耗外推。
内部采样优先采用近期分段,条件不足时才使用窗口累计估算。同一额度段内,以历史额度和截至相应
完成时间的累计用量建立基线,每累计至少 5 个百分点形成一段,使用最近 3 个完整段及未满一段的尾部。
上式中的 `sampledPercent` 是内部有效额度进度(百分点),不是时间进度或对外响应字段。
只对合并区间计算比值,不平均逐请求小分母比值;重复读数不增加段数,大于 1 个百分点的回落或累计
计数倒退会中断采样,不能静默跨越。缓存命中属于输入,不重复相加;其他币种或缺少有效金额不能当成零 USD。
采样查询 `[max(resetAt - windowSeconds, accountAddedAt), observedAt)` 内开始的请求,
仅把 `completedAt <= observedAt` 的完整交付用量计入当前分子;历史分子按各点的完成时间累计,
同完成时间使用同一累计值。每个源窗口从同一数据库语句取得累计数值和最多 128 个按时间分桶的历史文档,
不会因历史点抽样而丢弃其间的成功 Token,也不重新聚合模型排行;接口不返回原始 Provider 文档。
同完成时间使用同一累计值。历史点抽样不缩减区间内的累计用量,接口不返回原始 Provider 文档。
OpenAI 复用已有限流协议解析器匹配额度桶、槽位、时长和明确的套餐。仅允许最多 2 秒的重置时间抖动,
超出或套餐改变则截断历史基线;不将宽时间容差当作上游窗口身份。历史文档缺少独立额度观测时间,
@@ -515,14 +523,12 @@ OpenAI 复用已有限流协议解析器匹配额度桶、槽位、时长和明
以及插件与 Skill 调用排行。
官方未返回的字段保持 `null`,不使用本地数据补齐;`hasStatsError: true` 表示账号资料可用,但官方统计
部分不可用。access token 已过期或官方返回 401 时,接口要求先刷新 credential 或重新授权。原账号级
`GET /api/admin/accounts/usage-statistics` usage/billing 报表接口及其查询链路已移除。
部分不可用。access token 已过期或官方返回 401 时,接口要求先刷新 credential 或重新授权。
### OpenAI 主动额度重置卡
`GET /api/admin/accounts/reset-credits?accountId=...` 每次都查询 OpenAI 上游;后端不把卡片列表写入
PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用,并在当前浏览器会话内缓存最近一次成功
结果,用于账号行上的 `xN` 提示。
PostgreSQL 或 Redis。
查询响应:
@@ -588,19 +594,46 @@ PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用
| `POST` | `/api/admin/client-keys/delete` | `{ id }` | 删除 |
创建字段为 `name`、可选 `label`、`groupIds`、`maxConcurrency`、`requestsPerMinute`、可选
`dailyLimitUsd` 和 `weeklyLimitUsd`,更新请求再增加
`id`。`groupIds` 必须显式提交:空数组派生 `routingScope: "all"`,非空数组派生
`dailyLimitUsd`、`weeklyLimitUsd` 和 `customKey`。更新请求携带 `id`,不接受 `customKey`。
`groupIds` 必须显式提交:空数组派生 `routingScope: "all"`,非空数组派生
`routingScope: "groups"`。响应同时返回分组引用 `groups`,以及从当前有效账号池派生、仅供展示的
`providerKinds`;Client Key 不再保存 `providerKind`。创建和 reveal 响应会返回完整明文 Key,调用方
`providerKinds`。创建和 reveal 响应会返回完整明文 Key,调用方
必须立即安全保存。
密钥列表的 `search` 仅匹配名称和标签,不匹配密钥值或可见前缀;搜索不区分大小写,使用字面量前缀匹配。
创建和更新时去除名称首尾空白,并按忽略大小写、首尾空格的名称查重,重复返回 `409`。
更新排除当前记录,并发写入同样执行查重;失败不会留下审计或配置版本变更。
历史重名数据不自动改名,已有凭据继续有效;再次保存时需使用未被其他密钥占用的名称。
`customKey` 仅用于创建:省略、`null` 或空字符串时继续自动生成;非空时按原值保存,不追加前缀、
不截断、不修剪空白,也不要求固定长度。Key 须为 HTTP Bearer 可传输的非空可见 ASCII 字符,
支持标点,空格、控制字符和非 ASCII 文本会返回 `400`。重复 Key 返回 `409`,包括并发创建时。
应用不另设 Key 长度上限;创建请求和 `Authorization` 头仍受 Web 服务器及反向代理的通用大小限制。
更新接口不接受 `customKey`,防止修改策略时意外替换正在使用的凭据。
列表仅展示最多前 10 个字符,且至少隐藏一半字符;单字符 Key 的可见前缀为空。
完整值仍只通过创建和显式 reveal 返回,不进入普通 Debug 或审计。
自定义 Key 创建示例:
```json
{
"name": "迁入的客户端",
"customKey": "legacy-platform-key/example+=",
"groupIds": [],
"maxConcurrency": 2,
"requestsPerMinute": 0
}
```
自定义 Key 的分组权限、日/周限额和并发规则按本平台配置执行,不导入其他平台的历史用量。
金额字段为非负十进制字符串,最多 10 位整数与 10 位小数,`"0"` 表示不限额。
创建时省略金额字段默认为零;更新时省略或 `null` 保留当前值,修改限额不会清空已用金额。
`maxConcurrency` 和 `requestsPerMinute` 是非负整数,零表示不限。
列表增加 `dailyLimitUsd`、`weeklyLimitUsd`、`dailyUsedUsd`、`weeklyUsedUsd`(均为字符串)、
列表返回 `dailyLimitUsd`、`weeklyLimitUsd`、`dailyUsedUsd`、`weeklyUsedUsd`(均为字符串)、
`dailyResetsAt`、`weeklyResetsAt`(RFC3339 或 `null`)。
管理端日/周金额显示两位小数,悬停可查看原始值;记账和限额比较保留完整精度。
记账和限额比较保留完整精度。
日窗口按北京时间零点重置;周窗口从首次准入当天零点起持续七天,到期后在下一次使用时重新开启。
费用按请求完成时间归属窗口。并发按同一 Key 的执行中请求累计,包含 SSE 与每个 WebSocket
`response.create`;空闲连接不占名额,内部重试不重复占用。
@@ -616,10 +649,6 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly
自动结算按网关请求 ID 幂等执行。账本独立于使用统计日志,记录保留至删除 Key,
不受 `usageRetentionDays` 影响。
English: Daily and weekly budgets use automatically recorded costs. Missing usage or interrupted requests
do not block a Key, and no manual reconciliation is required. New requests receive `429` once recorded
costs reach the daily or weekly limit; requests already admitted can finish above that threshold.
## 8. 运行设置
| 方法 | 路由 | 说明 |
@@ -681,7 +710,7 @@ Desktop 三段 SemVer 门禁,也不会自动回写最低版本设置。门禁
## 9. 备份
全部备份端点位于 `/api/admin/settings/backups/*`,内部由独立 BackupService 承担,不并入设置用例。响应继续使用 `AdminEnvelope`,wire 字段 camelCase,`Cache-Control: no-store`。
全部备份端点位于 `/api/admin/settings/backups/*`,使用管理接口响应信封,字段为 camelCase,响应带 `Cache-Control: no-store`。
| 方法 | 路由 | 请求 | 说明 |
| --- | --- | --- | --- |
@@ -694,7 +723,7 @@ Desktop 三段 SemVer 门禁,也不会自动回写最低版本设置。门禁
| `POST` | `/api/admin/settings/backups/download-url` | `{ backupId }` | 创建 5 分钟有效预签名下载地址(仅 completed) |
| `POST` | `/api/admin/settings/backups/delete` | `{ backupId }` | 请求删除(进入 `deleting`,由 Worker 收敛硬删除) |
读取设置响应(Secret 以明文返回,由前端掩码显示):
读取设置响应(Secret 以明文返回):
```text
storageRevision, endpoint, region, bucket, accessKeyId, secretAccessKey, prefix,
@@ -773,25 +802,28 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt
request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可取 `model`、`account`、
`apiKey`、`provider`、`transport`、`failureClass`、`status`。
请求记录列表的 `search` 使用区分大小写的字面量前缀匹配,支持请求 ID、Client Key ID / Key 前缀、
账号 ID、账号邮箱与名称、请求 / 上游模型 ID、上游请求 ID。账号邮箱与名称按请求记录的历史快照检索,
请求记录列表的 `search` 使用字面量前缀匹配,支持请求 ID、Client Key ID / 名称、
账号 ID、账号邮箱与名称、请求 / 上游模型 ID、上游请求 ID。密钥名称不区分大小写,其他字段区分大小写。
密钥名称按当前密钥记录检索,改名后使用新名称,删除后仍可按 Client Key ID 查询历史记录。
账号邮箱与名称按请求记录的历史快照检索,
不随当前账号修改或删除而改变;`%`、`_` 和 `\` 均按普通字符处理,不作为搜索通配符。
请求 ID 和上游 ID 继续通过既有字段查询;不增加入口 ID 字段,也不扫描 trace 建立查询映射。
旧响应中只有入口 ID 时仍需结合时间与入口日志定位,不能回填不存在的关联。
管理端搜索完整 `sk_` Client Key 时仅提交其可见前缀,不将完整密钥放入 URL。
错误列表的主动刷新、搜索和平台/时间条件变化会取得新的结束时间;翻页沿用该次查询快照。
请求记录按模型执行 ID 或上游 ID 查询;只有入口 ID 时需结合时间与入口日志定位。
请求记录和错误列表支持按密钥名称搜索,不支持密钥值或可见前缀搜索。
已进入模型执行会话、但在首次合法 ProviderStream 建立前失败的请求也进入现有错误及详情查询,
已进入模型执行会话、但尚未登记执行尝试的失败请求也进入错误及详情查询,
包含无可用账号、准备失败、启动/准备超时与取消。此时 attempt 数为零,未确认的 Provider、账号及
上游传输为空;可用模型执行 ID 在“全部平台”下查询,不从路由候选推断实际调用平台。
已有合法 stream 后的失败仍保留真实 attempt,即使尚未收到首事件。
已登记的尝试即使尚未收到首个上游事件,也会保留真实 attempt。
鉴权、解析、路由和准入等入口拒绝不属于该范围;请求观测仍是可能延迟或丢弃的异步投影。
汇总与洞察中的请求数与 outcome 分布覆盖筛选范围内全部请求;token、缓存、延迟与成本聚合仅统计
已完整交付客户端的成功响应。
已完整交付客户端的成功推理响应。OpenAI 的 `generate: false` 连接与上下文准备记录归类为
`requestKind: "prewarm"`,不进入用量列表、账号用量或额度预测的 Token / 费用覆盖统计,但仍可按
请求 ID 读取审计详情,响应中的额度观测仍可用于预测配对。此分类以实际 `generate` 字段为准,
不能仅凭客户端的同名 metadata 或输出 Token 为零排除普通推理;其他 Provider 不套用该规则。
详情接口按 `id` 可读取成功、失败或未完成请求。新增 `trace`(历史未采集记录为 `null`)和
详情接口按 `id` 可读取成功、失败或未完成请求,返回 `trace`(未采集记录为 `null`)和
`relatedRequests[]`(`requestId / relation / outcome / completedAt`);`relation` 为 `recovered_by` 或
`recovers`。`trace` 是执行终态时的有界脱敏时间线,包含 request、attempt 和 exchange 关联、阶段、
事件摘要及淘汰计数;普通用量列表不携带此字段。
+32 -37
View File
@@ -99,7 +99,7 @@ flowchart LR
### 3.2 `gateway-core` 内部 owner
`gateway-core` 内共享事实按语义 owner 划分,不再借用 `engine` 作为通用命名空间:
`gateway-core` 内共享事实按语义 owner 划分:
| 模块 | 唯一责任 |
| --- | --- |
@@ -113,7 +113,7 @@ flowchart LR
| `routing` | 冻结路由事实、请求计划、Provider 只读目录合同以及运行时快照的表示与编译 |
| `runtime` | 当前快照的发布、读取、revision 订阅与周期对账任务 |
`event` 通过 `validation` / `upstream` 使用基础值,不再依赖承载原始事件的执行错误;账号值对象和
`event` 通过 `validation` / `upstream` 使用基础值,不依赖承载原始事件的执行错误;账号值对象和
选择策略通过 `identity` / `account::scope` 使用身份与范围,不依赖路由计划。`routing` 和 `error` 的
既有公开类型路径保留 re-export,定义与 Core 内部使用均归属上述 owner。架构测试约束这些叶子依赖。
@@ -121,6 +121,13 @@ Provider 模型能力、目录代次与 `ProviderCatalogPort` 由 `routing::cata
该只读合同,不反向依赖执行注册表;现有 `ProviderRegistry` 直接实现目录端口,仍只维护一份 Provider
注册集合。已知空目录与查询失败的未知目录保持不同语义,目录替身无需实现请求执行。
客户端原生模型目录同样由 Provider 拥有,通过 `ExecutionService` 和现有 Registry 的只读调用传递。
`routing::catalog` 的原生条目只包含模型 ID 与不透明协议正文,Core 负责冻结账号范围和整对象模型映射,
不解析上游字段;API 负责协议输出,不直接依赖具体 Provider。原生目录失败不会退回通用画像,只有不提供
该协议原生目录的 Provider(如 xAI 的 Codex 适配)才走画像转换。目录读取不创建计量请求或推理 attempt。
OpenAI 的账号/凭据 revision/客户端版本隔离、并发合并、TTL、容量与失效均归属已有 credential catalog
service;客户端原生对象只进有界进程缓存,不扩展套餐 Redis ID cache 或持久化模型字段副本。
`engine::observation` 统一维护单次响应的用量、费用、时间和响应 ID,并负责重试前清理;协调器继续
独占发送、提交、重试和终结顺序。Provider 上报费用优先于本地估算,丢弃的 attempt 不得污染最终计量。
Client Key 费用账本独立累计各次 attempt 的实际费用,不能因请求重试而清空已产生的费用或未知计费状态。
@@ -151,7 +158,7 @@ sequenceDiagram
请求开始时冻结 `RuntimeSnapshot`、Client Key 的账号范围、模型映射、Codex 客户端最低版本、Provider
候选顺序和调度策略。
运行中的请求不再拼接新旧配置,也不在热路径查询分组关系。
运行中的请求始终使用该快照,不拼接不同版本的配置,也不在热路径查询分组关系。
Client Key 鉴权完成后,API adapter 从有界请求头识别 Codex Desktop/CLI,Core 使用同一请求冻结的
`RuntimeSnapshot` 比较对应最低版本。Desktop 优先于其 User-Agent 内嵌的 CLI/Core 标记;未知客户端不
@@ -187,6 +194,10 @@ Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 ca
- 请求画像以配置为启动基线。OpenAI Desktop 与 xAI CLI 的官方版本检查只更新各自负责的运行时画像,
不回写 `config.yaml`。
xAI Provider 负责 Codex custom 工具与 Grok function 工具的双向转换,保持工具类型、item ID 与
`call_id` 配对;超限或转换失败终止流。默认 `store: false` 的续接由现有会话 owner 重放完整历史;
原生续接按上游约束处理 `instructions` 与 `previous_response_id`,不把协议差异交给 Core。
### 账号出站代理
出站代理属于账号配置,由各 Provider 在推理、OAuth 服务端交换/刷新、额度、目录和资料等请求中统一使用。
@@ -203,25 +214,19 @@ client,OIDC 的 JWKS 缓存与单飞归属对应出口状态。自动刷新提
连接配置改变时在同一事务内同步关联账号并清除旧测试结果,已绑定的代理不可删除。
关联账号支持单独移除:在原子更新中校验当前代理 ID,只清除账号的代理 ID 和 URL,
保留账号其他设置;提交配置版本与审计后复用快照发布流程,使后续请求使用直连。
代理目录只携带关联数量;账号明细通过独立分页读端口按需查询,复用代理 ID 索引,
代理目录只携带关联数量;账号明细通过独立分页读端口按需查询,
在同一个只读快照中统计并读取有界页面,不在目录中聚合完整账号数组。
文件及 AT/RT 导入在上游凭据交换前取得 PostgreSQL 会话级共享咨询锁,直到凭据提交后释放。
代理修改、删除和测试结果写入通过同一资源的事务级独占锁检查,导入期间直接返回冲突。
锁连接不占用提交所需的连接池槽位,也不持有空闲事务;每进程最多增加四个导入保护连接。
导入保护连接数量有界,不占用提交所需的连接池槽位,也不持有空闲事务。
请求取消或进程退出时关闭会话并释放锁。探测器由组合根注入 OpenAI 的 HTTP 客户端构建函数,
复用其自定义 CA 加载与证书校验规则,Host 不依赖具体 Provider 包。
迁移 `0005_managed_outbound_proxies.sql` 将已有 URL 按完整连接信息合并为共享代理,保留认证及账号绑定。
导入器在认证交换前解析并校验账号出口。sub2api 的代理引用按完整 URL 登记为共享代理并绑定账号;
其 SOCKS5 配置按远端 DNS 语义转换为 SOCKS5H。账号导入只迁移账号及出口,不迁移下游 Key、余额或历史用量。
代理认证信息仅通过敏感账号导出返回,列表、详情、Debug 和普通审计不得暴露;数据库及备份按凭据保护。
输入字段、协议支持与导入校验见 [账号 API](api.md#5-账号)。
Managed proxies store names, credentials and connectivity results independently. Accounts bind by ID and retain
the resolved URL for provider transports. Connection edits update all bound accounts atomically and invalidate
old tests; in-use proxies cannot be deleted. Migration 0005 preserves existing credentials and account bindings.
Proxy changes advance the runtime configuration revision without invalidating in-flight token refreshes.
### Codex 原生生图与认证
管理端导出的 Codex 配置使用代理 Bearer 密钥,并声明服务端托管账号认证。
@@ -230,8 +235,7 @@ Proxy changes advance the runtime configuration revision without invalidating in
`X-OpenAI-Actor-Authorization` 是客户端能力标记,不是账号凭据。API 解码和 OpenAI Provider
都过滤该 header,网关继续校验 Client Key,上游认证由服务端账号产生。
仅含代理密钥的本地 `auth.json` 可以与新配置共存。配置示例维护在
[部署文档](../deploy/README.md#客户端配置)。
客户端配置示例维护在 [部署文档](../deploy/README.md#客户端配置)。
客户端到代理与代理到上游的传输选择相互独立。客户端的 `supports_websockets = false`
不禁止 Provider 使用上游 WebSocket。响应终态前的 Close 1000 仍视为失败,
@@ -261,7 +265,7 @@ Provider 管理适配在仍持有结构化失败事实时选择静态 `public_me
Vue 普通管理请求的错误提示由 `api/request.ts` 响应拦截器统一负责:优先展示安全信封的 `message`,
缺失或空白时才使用请求层的 HTTP、网络或超时兜底;成功业务码为 `200`,HTTP 成功但业务码失败也会拒绝。
规范化异常保留 `status`、`code`、`requestId` 与 `kind`。页面和 `useAsyncAction` 不再重复弹出接口错误;
规范化异常保留 `status`、`code`、`requestId` 与 `kind`。页面和 `useAsyncAction` 不重复弹出接口错误;
查询可以保留失败状态与重试入口,本地校验、文件操作、SSE 诊断和成功响应中的业务结果仍归各自 owner。
API 模块通过统一的 `RequestOptions` 传递 `signal`、`timeout` 和 `silent`。取消或已被新查询取代的请求
@@ -373,10 +377,6 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
- xAI 使用 OAuth session;API Key 不是受支持的账号 credential。刷新额度时同步查询官方实时订阅,
只把套餐事实写入现有 quota JSON。明确无付费订阅的个人账号显示 Free;查询失败、缺失字段或
团队身份不推断为 Free,订阅查询失败不影响额度观测。
Codex custom 工具统一以 function 的 `input` 字符串包装,保留说明与 grammar;响应恢复
原工具类型及对应 item ID,`call_id` 始终保持配对。转换仅处理协议字段,超限或转换失败终止流。
默认 `store: false` 的续接使用现有会话 owner 重放完整历史;上一轮要求上游存储且指令未变时,
原生续接移除本轮 `instructions`,避免 Grok 拒绝与 `previous_response_id` 同传。
- 账号导入和 OAuth complete(包括重新授权)在 credential 提交、Provider 事实失效及快照发布后,
由 Admin 共用流程后台读取一次额度;不等待观测完成才返回管理请求,失败记录告警但不回滚账号事务。
手工和后台 credential refresh 仍不隐式刷新 quota。
@@ -399,13 +399,11 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
可恢复观测并累计指标,但不允许改变客户端响应。Usage 详情中的 attempt 因此是 best-effort,并通过
`attemptsComplete: false` 明示不完整性。
模型执行沿用 `model_requests.id`,上游身份沿用 `upstream_request_id`,不新增入口 ID 列或查询索引。
API 的 `x-gateway-request-id` 标识模型执行;HTTP `x-request-id` 优先保留上游值,只有上游别名
`x-oai-request-id` 时复用其值,两者均缺失时回传模型执行 ID。该规则属于客户端协议,不依赖所选
Provider 或客户端名称。入口 middleware 的 ID 仍可用于入口日志,但不作为模型请求的主键或检索映射。
模型执行以 `model_requests.id` 标识,上游身份由 `upstream_request_id` 保存。
API 输出模型执行与上游请求的关联信息,具体响应头规则见 [数据面接口](api.md#3-openai-数据面与模型目录)。
入口 middleware 的 ID 用于入口日志,不作为模型请求的主键或检索映射。
失败终态及中间失败的上游请求 ID 优先取对应 `ProviderError`,缺失时再取该 attempt 的响应
observation、调用 metadata;最终失败的关联头不与 opening 身份混合。HTTP 错误仍按既有合同
返回已采集的 turn state 等允许的会话头,不为修复 ID 关联丢弃续接状态。
observation、调用 metadata;最终失败的关联头不与 opening 身份混合。
正常路径的请求行仍在首次合法 ProviderStream 建立后随 attempt 合并创建。已进入 Core 执行会话、
但在首次合法冷流建立前发生的无可用账号、准备失败、超时或取消,复用同一请求表补写失败终态与
@@ -418,8 +416,11 @@ observation、调用 metadata;最终失败的关联头不与 opening 身份混
鉴权、解析、路由或准入等尚未进入执行会话的入口拒绝不纳入此请求记录边界;异步投影也仍可能延迟或
丢弃。排障需结合入口日志,不能将“无记录”解释成没有发生错误。
只有完整交付客户端的成功响应进入 Token、延迟和成本聚合。实际 `service_tier` 只接受上游响应事件确认,
不能用请求期望值替代。
只有完整交付客户端的成功推理响应进入 Token、延迟和成本聚合。OpenAI Provider 根据请求的
`generate: false` 将连接与上下文准备归类为 `prewarm`,不信任客户端单独声明的同名 metadata。
Store 的共享用量口径排除这些预热记录,账号用量与额度预测复用同一规则;原始请求审计、响应额度
观测和费用事实仍保留,不将未知费用改写为零,也不影响 Client Key 结算账本。
实际 `service_tier` 只接受上游响应事件确认,不能用请求期望值替代。
Worker 由各 Bundle 贡献、由 Host 统一监督:
@@ -450,12 +451,9 @@ HTTP Client 构造失败也不会阻断网关启动。外部解析在已认证
与普通文件日志开关分别控制,不输出到普通日志或 stdout。`.runtime/logs` 同样属于敏感数据。
- `host.logging.request_dump` 默认关闭;开启后独立请求转储包含原始请求头和正文,
可能包含密钥及用户内容,只能在明确的排障范围内使用,不能作为普通日志公开。
普通及 OAuth 文件按 `file.retention_days`(默认 7)保留;报文按独立的
`request_dump_retention_days`(默认 1)保留。保留当天及前 N 个完整 UTC 日期的所有分片,
仅整组清理更早日期;近期被写入的旧日期组延后清理。不存在文件数量淘汰。
`file.max_file_size_mb` 只控制轮转;已关闭分片压缩后才删除原文件,启动和轮转时清理过期日期。
文件队列满时背压,正常关闭时排空并同步。`file_logging` 健康探针持续报告本次进程的写入缺口,
不将压缩/清理失败伪装成保留完成。容量规划须覆盖完整时间窗口,不能靠提前删窗口内分片解决磁盘不足。
- Host 拥有文件日志写入、轮转、保留和关闭流程,以时间窗口完整性为约束,队列满时背压,正常关闭时排空并同步。
写入缺口必须通过健康状态持续报告,压缩或清理失败不能伪装成保留完成。
配置、保留窗口和容量规划见 [日志与持久化](../deploy/README.md#持久化与备份)。
- Provider 将安全诊断的阶段、原因码与消息独立于错误大类和发送状态传入 Core;
`attempt.failed` 保存这些字段,最终错误记录优先持久化诊断消息。重试包装不能覆盖底层诊断,
也不能因为错误更详细而改变已有重试安全边界。
@@ -483,14 +481,11 @@ PostgreSQL 备份恢复属于人工维护操作。当前没有部署级维护模
cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml -- --check
RUST_MIN_STACK=16777216 cargo +1.97.0 clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml --test main --locked
pnpm --dir frontend format:check
pnpm --dir frontend build
docker compose -f deploy/compose.yaml config --quiet
```
线程栈设置与当前 CI 一致。PostgreSQL/Redis 集成测试需按
[迁移文档](../backend/migrations/README.md#本地测试库) 配置专用测试库;未设置环境变量时,本地相关测试会跳过。
前端以 lint、类型检查和构建验证,`build` 已包含类型检查,不维护独立前端测试代码。
其他检查与界面验证按 [贡献与审查](../CONTRIBUTING.md#验证) 执行。
行为、配置或边界变化必须同步其唯一文档 owner:用户入口写入根 README,HTTP 合同写入 `docs/api.md`,
改动使现有说明失真或缺少必要信息时,修订所属文档:用户入口写入根 README,HTTP 合同写入 `docs/api.md`,
部署操作写入 `deploy/README.md`,架构不变量保留在本文。
+7 -15
View File
@@ -76,7 +76,7 @@ flowchart LR
`theme/` 根目录只保留公开入口 `index.ts` 和唯一类型文件 `types.ts`;内部实现按 `core/`、`derive/`、`runtime/` 分层,不增加嵌套 barrel。
纯派生模块不访问 DOM,`theme/runtime/browser.ts` 不包含派生规则,Theme Store 不复制算法。
普通 Map 字段按 camelCase → kebab-case 统一生成 `--cp-*`;Semantic 与 Preset Color 仅维护各自的短角色表。
`ThemeTokenName` 在 `types.ts` 中由 Map 契约推导,新增字段不再要求同步手写大段联合类型和对象映射。
`ThemeTokenName` 在 `types.ts` 中由 Map 契约推导,无需手写重复的联合类型和对象映射。
## Token 模型
@@ -265,9 +265,8 @@ createApp
</style>
```
根元素只保留 `data-theme` 与 `data-theme-color` 状态,不写整套内联 Token。运行时样式表比逐项调用
`style.setProperty()` 更集中、可检查,也避免在 DOM 属性中形成超长变量串。全局弹窗通过 Teleport 挂到 `body`
后仍继承根变量。
根元素只保留 `data-theme` 与 `data-theme-color` 状态,Token 统一写入运行时样式表。
全局弹窗通过 Teleport 挂到 `body` 后仍继承根变量。
> [!NOTE]
> Theme Editor 预览是例外:草稿 Token 以内联变量写在影子环境的局部根节点上,仅影响预览,不污染已保存主题。
@@ -393,17 +392,8 @@ utility / arbitrary variant;Vue Transition、跨浏览器 Range、动态富文
## 验证
前端改动至少执行:
```bash
cd frontend
pnpm run lint
pnpm run typecheck
pnpm run build
git diff --check
```
视觉验收覆盖:
基础检查与受影响页面的验收按 [贡献与审查](../CONTRIBUTING.md#界面验证) 执行。
修改主题派生、主题编辑器或共用组件的视觉行为时,按影响范围补充以下检查:
- 四个预置、自定义 HEX、浅色、深色和跟随系统模式。
- 页面、容器、浮层、输入框、表格、分页、主按钮、选中态、焦点和品牌图标。
@@ -411,6 +401,8 @@ git diff --check
- 首页画板缩放清晰度、组件概览滚动、ECharts 网格线和 Skeleton 动效。
- 键盘操作、颜色之外的选中反馈和 `prefers-reduced-motion`。
局部页面改动只验证受影响的页面与状态,无需执行整套主题编辑器验收。
## 上游参考
- [Ant Design 色彩规范](https://github.com/ant-design/ant-design/blob/621b63dff5641cd96afa5bec26ca18a389961db3/docs/spec/colors.zh-CN.md)
-28
View File
@@ -149,46 +149,19 @@ export interface AccountQuotaForecast {
targetDays: number
extrapolated: boolean
source: {
key: string
label: string
windowDays: number
usedPercent: number | null
usedPercentDisplay: string
observedAt: string | null
observedAtDisplay: string
startAt: string | null
startAtDisplay: string
resetAt: string
resetAtDisplay: string
requestCountDisplay: string
tokensDisplay: string
inputTokensDisplay: string
outputTokensDisplay: string
cachedTokensDisplay: string
knownCostCount: number
knownCostCountDisplay: string
partialCostCount: number
partialCostCountDisplay: string
unavailableCostCount: number
unavailableCostCountDisplay: string
usdDisplay: string
sampleStartAt: string | null
sampleStartAtDisplay: string
baselinePercent: number
sampledPercent: number | null
sampledPercentDisplay: string
blockCount: number
observationCount: number
missingTokenCount: number
excludedRequestCount: number
pendingRequestCount: number
} | null
unavailableReason: string | null
lowSample: boolean
incompleteCost: boolean
incompleteTokens: boolean
method: 'cumulative' | 'incremental'
methodDisplay: string
estimatedTokens: number | null
estimatedTokensDisplay: string
estimatedUsd: number | null
@@ -202,7 +175,6 @@ export interface AccountQuotaForecast {
export interface AccountQuotaForecastResponse {
accountId: string
generatedAt: string
generatedAtDisplay: string
forecasts: AccountQuotaForecast[]
}
+5 -1
View File
@@ -70,6 +70,10 @@ interface ApiKeyUpdateParam extends ApiKeyWriteParam {
id: string
}
interface ApiKeyCreateParam extends ApiKeyWriteParam {
customKey?: string
}
interface ApiKeyIdParam {
id: string
}
@@ -83,7 +87,7 @@ export function getApiKeys(data: ApiKeyListParams, options: RequestOptions = {})
})
}
export function createApiKey(data: ApiKeyWriteParam, options: RequestOptions = {}) {
export function createApiKey(data: ApiKeyCreateParam, options: RequestOptions = {}) {
return request<ApiKeyCreateResponse>({
url: '/api/admin/client-keys/create',
method: 'POST',
+2 -1
View File
@@ -13,7 +13,8 @@ interface CopyTextOptions {
// 剪贴板复制 + toast 反馈;空值与错误文案语义由调用方按站点配置。
export function useCopyText() {
const { copy } = useClipboard()
// 普通 HTTP 页面没有 Clipboard API,启用 VueUse 内置的兼容复制路径。
const { copy } = useClipboard({ legacy: true })
return async function copyText(value: string, options: CopyTextOptions) {
if (!value) {
@@ -5,6 +5,15 @@ import { computed } from 'vue'
const props = defineProps<{ forecast: AccountQuotaForecast }>()
const source = computed(() => props.forecast.source)
const incompleteNotice = computed(() => {
if (props.forecast.incompleteTokens && props.forecast.incompleteCost)
return '用量和费用数据不完整,暂时无法预测。'
if (props.forecast.incompleteTokens)
return '用量数据不完整,暂时无法预测 Token 容量。'
if (props.forecast.incompleteCost)
return '费用数据不完整,暂时无法估算金额。'
return null
})
const metrics = computed(() => [
{
label: 'Token 容量',
@@ -22,29 +31,25 @@ const metrics = computed(() => [
</script>
<template>
<section v-if="source" aria-label="容量预测" class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 md:justify-between [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<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="inline-flex items-center gap-1 rounded-cp-sm px-2 py-1 text-cp-xs font-emphasis"
:class="forecast.lowSample ? 'bg-cp-warning-container text-cp-warning-on-container' : 'bg-cp-info-container text-cp-info-on-container'"
>
<TriangleAlert v-if="forecast.lowSample" class="size-3" />
<Info v-else class="size-3" />
{{ forecast.lowSample ? '低样本 · 误差较大' : '估算值 · 仅供参考' }}
<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 ? '初步估算 · 仅供参考' : '估算值 · 仅供参考' }}
</span>
</div>
<div class="mt-2 flex items-end justify-between gap-4">
<strong class="font-mono text-3xl font-heavy tabular-nums text-cp-text [html[data-theme=light]_&]:font-emphasis">{{ source.usedPercentDisplay }}</strong>
<span class="pb-1 text-cp-xs text-cp-text-secondary">完整源窗口 <span class="font-mono">100%</span></span>
<span class="pb-1 text-cp-xs text-cp-text-secondary">本周期 <span class="font-mono">100%</span></span>
</div>
<!-- 实心为已观测比例,虚线为待推算容量,不伪造历史趋势或置信区间。 -->
<div
class="relative mt-3 h-3 overflow-hidden rounded-full bg-cp-bg-container"
role="img"
:aria-label="`源窗口已用 ${source.usedPercentDisplay},其余为推算区域`"
:aria-label="`本周期已用 ${source.usedPercentDisplay},其余为推算区域`"
>
<span class="absolute inset-x-0 top-1/2 border-t-2 border-dashed border-cp-primary/50" />
<span class="absolute inset-y-0 left-0 rounded-full bg-cp-primary" :style="{ width: `${source.usedPercent ?? 0}%` }" />
@@ -55,8 +60,8 @@ const metrics = computed(() => [
</div>
<div class="grid gap-2">
<div class="flex justify-between px-3 text-cp-xs text-cp-text-secondary">
<span>采样区间已记录</span>
<div class="flex justify-between text-cp-xs text-cp-text-secondary">
<span>已记录用量</span>
<span>完整{{ forecast.period === 'weekly' ? '周' : '月' }}预测</span>
</div>
<div class="grid gap-2">
@@ -77,21 +82,16 @@ const metrics = computed(() => [
<div class="grid gap-3">
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2 text-cp-xs">
<span class="text-cp-text-secondary">快照时源窗口剩余</span>
<span class="text-cp-text-secondary">更新时的剩余额度</span>
<span class="font-mono font-emphasis text-cp-text [html[data-theme=light]_&]:font-medium">
{{ forecast.remainingTokensDisplay }} Tokens
<span class="mx-1 text-cp-text-tertiary">/</span>
{{ forecast.remainingUsdDisplay }}
</span>
</div>
<p v-if="forecast.lowSample" class="m-0 text-cp-xs leading-relaxed text-cp-warning-text">
有效额度进度或分段样本较少,估算容易波动。
</p>
<p v-if="forecast.incompleteCost" class="m-0 text-cp-xs leading-relaxed text-cp-warning-text">
美元费用记录不完整,暂不预测等价费用。
</p>
<p v-if="forecast.incompleteTokens" class="m-0 text-cp-xs leading-relaxed text-cp-warning-text">
Token 记录不完整,暂不预测 Token 容量;未知用量不按零消耗估算。
<p v-if="incompleteNotice" class="m-0 flex items-start gap-1.5 text-cp-xs leading-relaxed text-cp-warning-text">
<TriangleAlert class="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
{{ incompleteNotice }}
</p>
</div>
</section>
@@ -1,97 +0,0 @@
<script setup lang="ts">
import type { AccountQuotaForecast } from '@/api'
import { computed } from 'vue'
const props = defineProps<{ source: NonNullable<AccountQuotaForecast['source']>, methodDisplay: string }>()
const costCoverage = computed(() => {
const total = props.source.knownCostCount + props.source.partialCostCount + props.source.unavailableCostCount
return total > 0 ? `${(props.source.knownCostCount / total * 100).toFixed(1)}%` : '—'
})
const tokenRows = computed(() => [
{
label: '输入 Token',
value: props.source.inputTokensDisplay,
tone: 'bg-cp-green-container text-cp-green-on-container [html[data-theme=light]_&]:text-cp-green-text',
markerTone: 'bg-cp-green-solid',
},
{
label: '输出 Token',
value: props.source.outputTokensDisplay,
tone: 'bg-cp-orange-container text-cp-orange-on-container [html[data-theme=light]_&]:text-cp-orange-text',
markerTone: 'bg-cp-orange-solid',
},
{
label: '缓存命中',
value: props.source.cachedTokensDisplay,
tone: 'bg-cp-cyan-container text-cp-cyan-on-container [html[data-theme=light]_&]:text-cp-cyan-text',
markerTone: 'bg-cp-cyan-solid',
},
])
const timeRows = computed(() => [
{ label: '窗口开始', value: props.source.startAtDisplay },
{ label: '采样开始', value: props.source.sampleStartAtDisplay },
{ label: '采样截止 / 快照', value: props.source.observedAtDisplay },
{ label: '额度重置', value: props.source.resetAtDisplay },
])
</script>
<template>
<section aria-label="采样依据" class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 md:justify-between [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div class="grid gap-3">
<div class="grid gap-1.5">
<div class="flex items-baseline justify-between gap-3 text-cp-xs">
<span class="text-cp-text-secondary">采样请求</span>
<strong class="font-mono text-cp-sm font-heavy text-cp-text [html[data-theme=light]_&]:font-emphasis">{{ source.requestCountDisplay }}</strong>
</div>
<div class="flex flex-wrap items-baseline justify-between gap-2 text-cp-xs">
<span class="text-cp-text-secondary">{{ methodDisplay }}</span>
<span class="font-mono text-cp-text">{{ source.sampledPercentDisplay }}</span>
</div>
</div>
<dl class="m-0 grid gap-2">
<div
v-for="row in tokenRows"
:key="row.label"
class="flex items-center justify-between gap-3 rounded-cp px-3 py-2.5 text-cp-xs [html[data-theme=light]_&]:bg-cp-bg-container"
:class="row.tone"
>
<dt class="flex items-center gap-2 [html[data-theme=light]_&]:text-cp-text-secondary">
<span class="hidden size-1.5 shrink-0 rounded-full [html[data-theme=light]_&]:block" :class="row.markerTone" aria-hidden="true" />
{{ row.label }}
</dt>
<dd class="m-0 font-mono font-emphasis tabular-nums">
{{ row.value }}
</dd>
</div>
</dl>
</div>
<div class="grid gap-2">
<div class="flex justify-between gap-3 text-cp-xs">
<span class="text-cp-text-secondary">美元费用覆盖</span>
<strong class="font-mono font-emphasis text-cp-text">{{ costCoverage }}</strong>
</div>
<div class="h-1 overflow-hidden rounded-full bg-cp-fill-secondary" role="img" :aria-label="`费用记录覆盖 ${costCoverage}`">
<div class="h-full rounded-full bg-cp-success" :style="{ width: costCoverage === '—' ? '0%' : costCoverage }" />
</div>
<p class="m-0 text-cp-xs text-cp-text-tertiary">
完整 {{ source.knownCostCountDisplay }} 次 · 部分 {{ source.partialCostCountDisplay }} 次 · 缺失 {{ source.unavailableCostCountDisplay }} 次
</p>
<p v-if="source.blockCount > 0" class="m-0 text-cp-xs text-cp-text-tertiary">
{{ source.blockCount }} 个有效进度段 · 历史读数不等于独立样本
</p>
<p v-if="source.missingTokenCount > 0 || source.excludedRequestCount > 0 || source.pendingRequestCount > 0" class="m-0 text-cp-xs leading-relaxed text-cp-warning-text">
Token 缺失 {{ source.missingTokenCount }} 次 · 未纳入 {{ source.excludedRequestCount }} 次 · 快照时未完成 {{ source.pendingRequestCount }} 次
</p>
</div>
<dl class="m-0 grid gap-3 text-cp-xs">
<div v-for="row in timeRows" :key="row.label" class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<dt class="text-cp-text-secondary">
{{ row.label }}
</dt>
<dd class="m-0 font-mono text-cp-text">
{{ row.value }}
</dd>
</div>
</dl>
</section>
</template>
@@ -5,7 +5,7 @@ import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
<template>
<div role="status" aria-busy="true">
<span class="sr-only">正在加载额度预测</span>
<div class="grid items-start gap-4 md:grid-cols-[1.15fr_1fr] md:items-stretch" aria-hidden="true">
<div aria-hidden="true">
<div class="flex min-h-96 flex-col justify-between 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 items-center justify-between gap-3">
@@ -20,7 +20,7 @@ import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
</div>
<div class="grid gap-2">
<div class="mb-1 flex justify-between gap-3 px-3">
<div class="mb-1 flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-20" />
</div>
@@ -39,42 +39,6 @@ import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
<BaseSkeleton shape="text" class="w-32" />
</div>
</div>
<div class="flex min-h-96 flex-col justify-between gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div class="grid gap-3">
<div class="grid gap-1.5">
<div v-for="row in 2" :key="row" class="flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" :class="row === 1 ? 'w-8' : 'w-24'" />
</div>
</div>
<div class="grid gap-2">
<div v-for="row in 3" :key="row" class="flex h-9.5 items-center justify-between gap-3 rounded-cp bg-cp-bg-container px-3">
<div class="flex items-center gap-2">
<BaseSkeleton shape="circle" class="size-1.5" />
<BaseSkeleton shape="text" class="w-16" />
</div>
<BaseSkeleton shape="text" class="w-12" />
</div>
</div>
</div>
<div class="grid gap-2">
<div class="flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-24" />
<BaseSkeleton shape="text" class="w-12" />
</div>
<BaseSkeleton class="h-1 w-full rounded-full" />
<BaseSkeleton shape="text" class="w-3/4" />
</div>
<div class="grid gap-3">
<div v-for="row in 4" :key="row" class="flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-40 max-w-3/5" />
</div>
</div>
</div>
</div>
</div>
</template>
@@ -13,7 +13,6 @@ import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { useAccountQuotaForecast } from '../../composables/useAccountQuotaForecast'
import AccountIdentityCell from '../AccountIdentityCell.vue'
import ForecastCapacity from './ForecastCapacity.vue'
import ForecastSample from './ForecastSample.vue'
import ForecastSkeleton from './Skeleton.vue'
const props = defineProps<{ account: AccountRow }>()
@@ -65,7 +64,7 @@ function handleExplanationKeydown(event: KeyboardEvent) {
v-model="open"
title="额度预测"
description="按当前用量结构,估算完整周期的容量"
size="xl"
size="md"
tone="info"
>
<template #icon>
@@ -93,13 +92,12 @@ function handleExplanationKeydown(event: KeyboardEvent) {
</template>
</BaseEmpty>
</div>
<div v-else-if="forecast" class="grid items-start gap-4 md:items-stretch" :class="forecast.source ? 'md:grid-cols-[1.15fr_1fr]' : ''">
<template v-else-if="forecast">
<div v-if="unavailableReason" class="grid rounded-cp-card bg-cp-fill-tertiary/70 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<BaseEmpty title="暂时无法预测" :description="unavailableReason" :icon="ChartNoAxesCombined" surface="none" class="min-h-72 content-center" />
</div>
<ForecastCapacity v-else :forecast="forecast" />
<ForecastSample v-if="forecast.source" :source="forecast.source" :method-display="forecast.methodDisplay" />
</div>
</template>
</div>
</div>
@@ -121,40 +119,25 @@ function handleExplanationKeydown(event: KeyboardEvent) {
</BaseButton>
</template>
<section :id="explanationId" role="tooltip" class="grid w-96 max-w-[calc(100vw-2rem)] gap-3 p-4 text-cp-xs leading-relaxed text-cp-text-secondary">
<div class="grid gap-1">
<h4 class="m-0 font-heavy text-cp-text">
计算方式
</h4>
<p class="m-0">
样本用量 ÷(有效额度进度 / 100)<span v-if="forecast?.extrapolated"> × {{ forecast.targetDays }}/{{ forecast.source?.windowDays }}</span>。
</p>
<p class="m-0">
优先使用最近 3 个完整进度段及尾部,每段至少 5 个百分点;不足时仅在本周期记录可用的情况下使用累计估算。
</p>
<p class="m-0">
历史额度以请求完成时间近似对齐,不能证明扣额同步。覆盖率表示记录完整度,不是预测准确率。
</p>
</div>
<div v-if="forecast?.extrapolated" class="grid gap-1">
<h4 class="m-0 font-heavy text-cp-text">
跨周期折算
</h4>
<p class="m-0">
不代表自然月额度或可一次用完的余额;剩余估算仍属于源窗口。
</p>
</div>
<div class="grid gap-1">
<h4 class="m-0 font-heavy text-cp-text">
仅供参考
</h4>
<p class="m-0">
非官方承诺额度。站外使用、日志清理、观测延迟及模型组合变化均会影响结果;等价费用不是订阅价格或账户余额。
</p>
</div>
<h4 class="m-0 font-heavy text-cp-text">
仅供参考
</h4>
<p class="m-0">
根据已记录用量估算,结果会随使用的模型和方式变化,并非官方承诺额度。
</p>
<p v-if="forecast?.lowSample" class="m-0">
目前数据还较少,结果可能有较大波动。
</p>
<p v-if="forecast?.extrapolated" class="m-0">
本页预测为折算值,剩余量仍按{{ forecast.source?.label ?? '当前周期' }}计算。
</p>
<p class="m-0">
剩余量以数据更新时间为准;等价费用不是实际账单或账户余额。
</p>
</section>
</BasePopover>
<span v-if="report" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
查询于 {{ report.generatedAtDisplay }}
<span v-if="forecast?.source?.observedAt" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
更新于 {{ forecast.source.observedAtDisplay }}
</span>
</div>
<BaseButton variant="secondary" @click="open = false">
@@ -61,6 +61,21 @@ const title = computed(() => props.editing ? '编辑 API Key' : '创建 API Key'
/>
</BaseFormItem>
<BaseFormItem
v-if="!editing"
label="自定义 Key(可选)"
>
<BaseInput
v-model="form.customKey"
type="password"
autocomplete="new-password"
:spellcheck="false"
aria-label="自定义 Key(可选)"
placeholder="留空自动生成"
:disabled="saving"
/>
</BaseFormItem>
<BaseFormItem label="分组">
<AccountGroupCheckboxGrid
v-model="form.groupIds"
@@ -24,7 +24,7 @@ const search = defineModel<string>('search', { required: true })
aria-label="API Key 筛选与操作"
>
<div class="min-w-0 flex-1 md:w-96 md:flex-none">
<BaseInput v-model="search" placeholder="搜索名称、标签或 ID" class="w-full">
<BaseInput v-model="search" placeholder="搜索名称或标签" aria-label="搜索 API Key 名称或标签" class="w-full">
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
@@ -17,6 +17,7 @@ import { useIdSet } from '@/composables/useIdSet'
type ApiKeyRow = Awaited<ReturnType<typeof getApiKeys>>['items'][number]
export interface ApiKeyFormValue {
customKey: string
name: string
label: string
groupIds: string[]
@@ -61,6 +62,7 @@ export function useApiKeyMutations(options: {
function openEdit(key: ApiKeyRow) {
editingKey.value = key
form.value = {
customKey: '',
name: key.name,
label: key.label ?? '',
groupIds: key.groups.map(group => group.id),
@@ -107,7 +109,10 @@ export function useApiKeyMutations(options: {
await updateApiKey({ id: current.id, ...payload })
}
else {
const result = await createApiKey(payload)
const result = await createApiKey({
...payload,
customKey: form.value.customKey || undefined,
})
createdKey.value = result.plaintextKey
createdKeyName.value = payload.name
}
@@ -139,6 +144,10 @@ export function useApiKeyMutations(options: {
toast.warning('请输入 API Key 名称')
return false
}
if (!editingKey.value && form.value.customKey && !/^[\x21-\x7E]+$/.test(form.value.customKey)) {
toast.warning('自定义 Key 只能包含 HTTP 可传输的可见字符,不能包含空格或换行')
return false
}
for (const [label, value] of [
['最大并发', form.value.maxConcurrency],
['每分钟请求数', form.value.requestsPerMinute],
@@ -285,6 +294,7 @@ export function useApiKeyMutations(options: {
function emptyForm(): ApiKeyFormValue {
return {
customKey: '',
name: '',
label: '',
groupIds: [],
@@ -91,8 +91,8 @@ function upstreamSendStateText(value: string | null | undefined) {
<div class="min-w-0 flex-1">
<BaseInput
v-model="searchQuery"
placeholder="请求 ID、Key ID / 可见前缀或账号"
aria-label="搜索错误:请求 ID、Key ID / 可见前缀或账号"
placeholder="请求 ID、密钥名称或账号"
aria-label="搜索错误:请求 ID、密钥名称或账号"
class="min-w-0 w-full lg:max-w-96"
>
<template #prefix>
@@ -3,6 +3,7 @@ import type { OpsError, OpsErrorMetadata } from '@/api'
import { Download, RefreshCw } from '@lucide/vue'
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useDownload } from '@/composables/useDownload'
@@ -54,46 +55,34 @@ function download() {
<template #icon>
<Download :size="14" />
</template>
导出安全诊断包
导出诊断包
</BaseButton>
</div>
</div>
<p class="mt-1 mb-3 break-all font-mono text-cp-xs leading-relaxed text-cp-text-secondary">
{{ selectedId }}
</p>
<p class="mb-3 text-cp-xs leading-relaxed text-cp-text-secondary">
诊断包 v2 仅含关联字段、错误分类、尝试与时间线阶段;不含错误原文、正文、用户内容或事件 data。版本与环境需另附,分享前请审阅关联 ID。
</p>
<p v-if="!loading && !selectedError" class="text-cp-xs leading-relaxed text-cp-text-secondary">
当前请求未提供错误详情摘要,导出不会沿用其他请求的错误;可结合尝试记录排查。
</p>
<p v-if="loading" role="status" class="text-cp-sm text-cp-text-secondary">
正在加载诊断记录…
</p>
<p v-else-if="error" role="alert" class="text-cp-sm text-cp-error-text">
{{ error }}
<span v-if="selectedError">仍可导出本次错误的分类摘要和关联字段,缺失诊断会在包内标明。</span>
</p>
<template v-else-if="detail">
<p v-if="!detail.attemptsComplete" role="status" class="text-cp-xs leading-relaxed text-cp-warning-text">
尝试记录可能不完整,诊断包会保留此缺口,不能仅按列表条数判断实际尝试次数。
</p>
<div v-if="detail.relatedRequests?.length" class="mb-3 flex flex-wrap gap-2">
<BaseButton v-for="related in detail.relatedRequests" :key="related.requestId" variant="soft" size="sm" class="max-w-full" @click="selectedId = related.requestId">
{{ related.relation === 'recovered_by' ? '查看恢复请求' : '查看先前失败' }} · {{ related.requestId }}
</BaseButton>
</div>
<RequestTransportFailure :events="trace?.events ?? []" :metadata="selectedId === requestId ? metadata : undefined" />
<p v-if="!trace" class="text-cp-sm text-cp-text-secondary">
这条记录没有保存诊断时间线。旧记录无法补回当时未采集的事件。
</p>
<template v-else>
<BaseEmpty v-if="!events.length" title="暂无诊断时间线" size="sm" surface="none" />
<template v-else-if="trace">
<div class="mb-3 grid gap-1 text-cp-xs leading-relaxed">
<p class="m-0 text-cp-text-secondary">
已观测 {{ trace.totalEvents }} 个阶段或事件,展示 {{ events.length }} 条记录。连续增量合并计数;历史事件内容可能未充分脱敏,不会自动导出。
已观测 {{ trace.totalEvents }} 个阶段或事件,展示 {{ events.length }} 条记录
</p>
<p v-if="trace.droppedEvents" role="status" class="m-0 text-cp-warning-text">
达到保存上限,{{ trace.droppedEvents }} 个事件已被淘汰;保留请求开头与最近事件。
已达保存上限,{{ trace.droppedEvents }} 个事件未保留
</p>
</div>
<BaseScrollbar max-height="32rem">
@@ -114,9 +103,6 @@ function download() {
</li>
</ol>
</BaseScrollbar>
<p class="mt-3 mb-0 text-cp-xs leading-relaxed text-cp-text-secondary">
界面中的诊断事实不等于默认导出内容。原始错误或完整报文如确需反馈,请先单独审阅脱敏;可用请求 ID 在服务器日志中检索。
</p>
</template>
</template>
</section>
@@ -19,7 +19,7 @@ const search = defineModel<string>('search', { required: true })
<template>
<div class="flex w-full items-center gap-3" role="group" aria-label="使用记录筛选与操作">
<div class="min-w-0 flex-1 sm:w-96 sm:flex-none">
<BaseInput v-model="search" placeholder="请求、Key ID / 可见前缀、账号或模型" aria-label="搜索使用记录:请求、Key ID / 可见前缀、账号或模型" class="w-full">
<BaseInput v-model="search" placeholder="请求、密钥名称、账号或模型" aria-label="搜索使用记录:请求、密钥名称、账号或模型" class="w-full">
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
@@ -6,7 +6,6 @@ import { computed, onScopeDispose, shallowRef, watch } from 'vue'
import { getOpsErrors } from '@/api'
import { useStablePagedQuery } from '@/composables/useStablePagedQuery'
import { withMinimumDuration } from '@/utils/async'
import { usageSearchParam } from '../utils/search'
interface UseOpsErrorsTableOptions {
timeRangeParams: Readonly<Ref<UsageTimeRangeParams>>
@@ -18,7 +17,7 @@ interface UseOpsErrorsTableOptions {
export function useOpsErrorsTable(options: UseOpsErrorsTableOptions) {
const refreshing = shallowRef(false)
const searchQuery = shallowRef('')
const search = computed(() => usageSearchParam(searchQuery.value))
const search = computed(() => searchQuery.value.trim() || undefined)
let disposed = false
// 时间、平台和搜索共同构成分页快照,避免翻页混入另一组筛选结果。
let tableParams = snapshot()
@@ -11,7 +11,6 @@ import {
getUsageRecordSummary,
} from '@/api'
import { withMinimumDuration } from '@/utils/async'
import { usageSearchParam } from '../utils/search'
interface UseUsageRecordsTableOptions {
timeRangeParams: Readonly<Ref<UsageTimeRangeParams>>
@@ -36,7 +35,7 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
const pageSize = shallowRef(10)
const totalRecords = shallowRef(0)
const searchQuery = shallowRef('')
const search = computed(() => usageSearchParam(searchQuery.value))
const search = computed(() => searchQuery.value.trim() || undefined)
const providerQuery = shallowRef('')
let tableParams = snapshot()
const refreshingList = shallowRef(false)
-10
View File
@@ -1,10 +0,0 @@
const CLIENT_API_KEY_VISIBLE_PREFIX_LENGTH = 10
export function usageSearchParam(value: string) {
const search = value.trim()
if (!search)
return undefined
if (search.startsWith('sk_'))
return search.slice(0, CLIENT_API_KEY_VISIBLE_PREFIX_LENGTH)
return search
}
+13 -16
View File
@@ -1,28 +1,25 @@
# v3.5.0
# v3.6.0
## 新增功能
- 账号用量面板新增周/月额度容量预测,可查看预计 Token、等价 USD 费用、源窗口剩余估算及采样依据;支持周/月切换和刷新额度后重新采样。
- 预测优先使用近期额度进度分段,明确提示样本不足、记录缺失和周期折算;Token 与费用分别判断完整性,缺失数据不按零消耗外推。
- 创建客户端密钥时支持填写自定义 Key,便于沿用旧平台凭据;留空仍自动生成。自定义值支持不同前缀、长度和标点,沿用本平台的分组权限、限额与并发规则。
- 密钥列表、请求记录和错误列表支持按密钥名称搜索;创建和修改名称时校验重名,忽略大小写和首尾空白。
## 问题修复
- 修复 Codex 模型目录将默认上下文窗口误作最大窗口的问题,分别保留上游 `context_window` 与 `max_context_window`,缺失值不再自行补齐。
- 完善 HTTP 与 WebSocket 错误的上游状态、结构化原因及请求 ID 关联,避免失败信息被通用文案覆盖或关联到错误的请求。
- 补记已进入模型执行、但尚未建立上游流时的无可用账号、准备失败、超时和取消,便于通过网关请求 ID 排查;没有实际尝试时不虚构账号或上游调用。
- 修复请求记录无法按账号邮箱与名称搜索的问题,按请求发生时的历史快照进行字面量前缀匹配;完整 Client Key 搜索仅提交可见前缀。
- 保留令牌刷新等管理操作中可安全展示的具体原因,区分账号繁忙、上游明确拒绝和刷新结果未知,避免将上游认证失败误作管理员会话失效。
- 修复运行时快照并发刷新可能被旧结果覆盖、取消后的执行清理未能持续完成,以及账号展开行重建后丢失额度重置未决状态的问题。
- 修复 Codex 模型目录丢失原生元数据的问题,完整保留上游提示词、工具能力、上下文窗口及 `service_tiers`,使支持的客户端可按上游声明显示 `/fast` 等服务档位。目录遵守密钥的账号范围,模型别名保留目标模型的完整元数据。
- 修复同源 HTTP 管理端无法保持登录及复制失败的问题;会话 Cookie 随浏览器来源区分 HTTP 与 HTTPS,HTTPS 反向代理使用 HTTP 回源时仍保留安全属性。
- 将 OpenAI 的 `generate: false` 预热请求排除出推理用量和额度预测统计,保留审计记录与额度观测,避免预热记录干扰预测;预测面板使用实际额度观测时间。
## 体验优化
- 管理端统一展示接口安全错误消息,减少重复弹窗;取消请求与静默后台请求不再产生普通错误提示,批量操作仍保留汇总反馈。
- 用量与错误查询统一刷新、筛选和分页快照行为;诊断反馈包改为仅导出允许的关联、分类、阶段与计时信息,并加强未知 JSON 内容的诊断脱敏。
- 账号导入和 OAuth 完成(含重新授权)在提交后后台读取一次额度,不再等待该次观测才返回成功;观测失败不回滚账号。
- 精简额度预测面板,聚焦容量与剩余估算,减少采样明细和重复提示。
- 精简请求诊断面板,缺少时间线时使用空状态展示,保留诊断包导出。
- 简化部署初始化命令,合并目录创建、配置复制和权限设置。
## 升级说明
- 从 v3.4.1 升级无需新增数据库迁移或配置调整。
- 额度预测依赖本地用量与上游额度观测,仅供参考,不参与调度或账单结算;剩余估算对应额度快照时的源窗口,不是实时余额,等价 USD 也不是官方订阅价格。
- 部分令牌刷新失败的管理接口业务码调整为 `50201` 或 `50202`(HTTP 502)。自建管理客户端应保留具体错误消息;结果未知时先核对账号状态,不要自动重放可能已经轮换的一次性刷新令牌。
- 诊断反馈包使用 `schemaVersion: 2`,不自动包含原始错误正文、请求响应头或完整 trace data;旧诊断记录不清理或回填,分享历史材料前仍需审阅脱敏。
- 从 v3.5.0 升级时,服务启动自动执行 `0006_custom_client_keys.sql`,保留已有密钥;无需新增配置项。历史重名密钥仍可使用,再次保存时需调整为未被占用的名称。
- 密钥相关搜索不再匹配 Key 值或可见前缀,请改用密钥名称;请求记录仍支持按 Client Key ID 查询。
- 额度预测管理接口移除了 `generatedAtDisplay`、`method` / `methodDisplay` 及部分采样诊断字段。自建管理客户端需按 [当前 API 合同](https://github.com/zyycn/codex-proxy-rs/blob/v3.6.0/docs/api.md#周月额度预测) 调整。
- Codex 客户端需重新加载模型目录,才能取得更新后的服务档位与能力信息。
+1 -1
View File
@@ -1 +1 @@
version: "3.5.0"
version: "3.6.0"