mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-08 00:05:59 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bebccac5d | ||
|
|
98287195ca | ||
|
|
fd1b159f71 | ||
|
|
7b03fdc8ab | ||
|
|
851a50962d | ||
|
|
b6e17db0c6 | ||
|
|
b98c2f01a5 |
@@ -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
@@ -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)。
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
# Codex Proxy RS
|
||||
|
||||
面向 Codex 的自托管多账号 AI 网关。
|
||||
面向 Codex 的自托管多账号 AI 网关
|
||||
|
||||
[](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
|
||||
[](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("a(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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+21
-21
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
version: "3.5.0"
|
||||
version: "3.6.0"
|
||||
|
||||
Reference in New Issue
Block a user