mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a436b76a | ||
|
|
849aa73a28 | ||
|
|
fb89bda177 | ||
|
|
9e6c8c9734 | ||
|
|
3f69bc861c | ||
|
|
cea4e1d8e9 | ||
|
|
539764f7eb | ||
|
|
c9be17778d | ||
|
|
d8bc91efce | ||
|
|
e8e2f18927 | ||
|
|
0977de3713 | ||
|
|
eaa37d9faa | ||
|
|
4e588ffa1d | ||
|
|
b9e919f510 | ||
|
|
32f9f3675d | ||
|
|
221d557d5e | ||
|
|
4105c1ece6 | ||
|
|
4d59687cbc | ||
|
|
8621e24e7c | ||
|
|
3bebccac5d | ||
|
|
98287195ca | ||
|
|
fd1b159f71 | ||
|
|
7b03fdc8ab | ||
|
|
851a50962d | ||
|
|
b6e17db0c6 | ||
|
|
b98c2f01a5 |
@@ -10,12 +10,14 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 任务 | 文档入口 |
|
||||
| 任务 | 技能与文档入口 |
|
||||
| --- | --- |
|
||||
| 用户能力、快速开始 | [README](../../../README.md) |
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 管理端组件、主题 Token、视觉验证 | [管理端主题](../../../docs/theme.md) |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 加载并使用 `$frontend-design`,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
|
||||
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
|
||||
@@ -29,7 +31,8 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
|
||||
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
|
||||
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
|
||||
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;同步受影响的说明。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
|
||||
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
|
||||
5. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
|
||||
|
||||
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
|
||||
|
||||
@@ -37,6 +37,11 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
|
||||
区分已被主分支覆盖的实现、仍有价值的修复或回归测试,以及与目标无关的改动;不能仅凭标题或代码相似认定重复。
|
||||
说明原问题发生版本和当前剩余差异,创建时据此收敛范围,审查时报告保留或拆分建议;这不授权自动关闭或修改他人的 PR。
|
||||
|
||||
## 文档职责与更新条件
|
||||
|
||||
按 [贡献与审查的文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否需要更新文档。
|
||||
常规文档维护当前有效说明,变更经过留在 PR / Issue;审查时核对文档改动的必要性、职责与重复内容,不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
## 创建 PR
|
||||
|
||||
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
|
||||
@@ -46,6 +51,8 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
|
||||
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
|
||||
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
|
||||
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
|
||||
实现应职责清晰、表达直接、复用合理;提交前主动清理本次变更引入的重复逻辑、冗余状态、死代码、
|
||||
多余抽象及无依据的兼容分支。功能可用和检查通过不能替代可维护性自查。
|
||||
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
|
||||
更新现有标题或正文须在本次整理/更新请求范围内。
|
||||
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
|
||||
@@ -78,6 +85,8 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
|
||||
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
|
||||
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
|
||||
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
|
||||
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
|
||||
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
|
||||
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
|
||||
|
||||
+17
-1
@@ -21,11 +21,27 @@ updates:
|
||||
day: monday
|
||||
time: '09:10'
|
||||
timezone: Asia/Shanghai
|
||||
# 固定官方 Codex Desktop 的 TLS 指纹;升级前必须抓取握手并比对扩展顺序。
|
||||
# 按官方 Codex 锁文件、协议指纹或已审计 fork 固定;完成对应核对与实测后再手动升级。
|
||||
ignore:
|
||||
- dependency-name: aws-lc-rs
|
||||
- dependency-name: hyper
|
||||
- dependency-name: hyper-rustls
|
||||
- dependency-name: hyper-tls
|
||||
- dependency-name: hyper-util
|
||||
- dependency-name: native-tls
|
||||
- dependency-name: openssl
|
||||
- dependency-name: openssl-src
|
||||
- dependency-name: openssl-sys
|
||||
- dependency-name: reqwest
|
||||
- dependency-name: rustls
|
||||
- dependency-name: rustls-native-certs
|
||||
- dependency-name: rustls-pki-types
|
||||
- dependency-name: tokio
|
||||
- dependency-name: tokio-native-tls
|
||||
- dependency-name: tokio-rustls
|
||||
- dependency-name: tokio-tungstenite
|
||||
- dependency-name: tungstenite
|
||||
- dependency-name: zstd
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
|
||||
+20
-3
@@ -76,15 +76,32 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
|
||||
|
||||
## 项目约定
|
||||
|
||||
- Rust 模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
|
||||
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
|
||||
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式。
|
||||
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
|
||||
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
|
||||
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。行为或合同变更同步到架构文档指定的文档位置,发布说明按维护者的发布安排更新。
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。
|
||||
|
||||
## 文档职责与更新条件
|
||||
|
||||
常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
|
||||
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
|
||||
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
- 按 [开发指南的文档入口](.agents/skills/dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
|
||||
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
|
||||
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
|
||||
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
|
||||
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue 或任务报告。版本变更与升级影响按维护者的发版安排维护
|
||||
`release/notes.md`,不为普通开发任务或 PR 自动追加发布说明。
|
||||
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
|
||||
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
|
||||
|
||||
## 界面验证
|
||||
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
|
||||
|
||||
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
|
||||
@@ -113,7 +130,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 | 管理端创建的客户端密钥 |
|
||||
|
||||
可用模型以该密钥查询到的模型列表为准:
|
||||
|
||||
|
||||
Generated
+214
-37
@@ -116,7 +116,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -154,9 +154,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.1"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
@@ -165,22 +165,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.45.0"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-runtime"
|
||||
version = "1.9.1"
|
||||
version = "1.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb"
|
||||
checksum = "ef47857a1d4488b528f4a5d5715fa7c3300820897824152234d3fa22b1426657"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
@@ -206,9 +205,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.144.0"
|
||||
version = "1.146.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30dc8bf6baaf7d46336a0ca2c69f223d9b90d7a801fb3e28f7ea17b00dc6b1de"
|
||||
checksum = "37b5ffaae346b9bd486ebdc3eb7053ec8ab8a1ad0f19a332eefeff036ed559e6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -479,9 +478,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-types"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94"
|
||||
checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-async",
|
||||
@@ -740,7 +739,7 @@ dependencies = [
|
||||
"provider-xai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -749,9 +748,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -808,6 +807,16 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -1192,7 +1201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1291,6 +1300,21 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1383,7 +1407,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1879,6 +1903,22 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1896,9 +1936,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2236,6 +2278,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe 0.1.6",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
@@ -2291,12 +2350,66 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
@@ -2604,6 +2717,7 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cookie",
|
||||
"flate2",
|
||||
"futures",
|
||||
@@ -2615,6 +2729,9 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"reqwest",
|
||||
"roxmltree",
|
||||
"rustls",
|
||||
@@ -2741,7 +2858,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2840,9 +2957,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f"
|
||||
checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arcstr",
|
||||
@@ -2919,6 +3036,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
@@ -2927,9 +3045,12 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -2941,6 +3062,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
@@ -3011,7 +3133,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3032,21 +3154,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.2.1",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
@@ -3130,6 +3252,19 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -3137,7 +3272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -3205,7 +3340,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3663,9 +3798,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3692,6 +3827,27 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
@@ -3713,7 +3869,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3742,7 +3898,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3811,9 +3967,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -3836,6 +3992,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -4184,9 +4350,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.0"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -4419,6 +4585,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -757,6 +757,24 @@ pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 按需查询的订阅周期,不参与额度或账号可用性判断。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderSubscription {
|
||||
pub starts_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub will_renew: Option<bool>,
|
||||
pub billing_period: Option<String>,
|
||||
pub billing_currency: Option<String>,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountPersonalInfo {
|
||||
pub profile: Result<ProviderProfileStatistics, AdminError>,
|
||||
pub subscription: Option<ProviderSubscription>,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的累计摘要。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderProfileStatisticsSummary {
|
||||
|
||||
@@ -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,23 +172,20 @@ 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;
|
||||
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0 && !self.incomplete_tokens);
|
||||
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0);
|
||||
self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor));
|
||||
self.estimated_usd = usd
|
||||
.filter(|_| !self.incomplete_cost)
|
||||
.and_then(|value| estimate(value, factor));
|
||||
self.estimated_usd = usd.and_then(|value| estimate(value, factor));
|
||||
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
|
||||
self.remaining_tokens = tokens.and_then(|value| estimate_tokens(value, remaining_factor));
|
||||
self.remaining_usd = usd
|
||||
.filter(|_| !self.incomplete_cost)
|
||||
.and_then(|value| estimate(value, remaining_factor));
|
||||
self.remaining_usd = usd.and_then(|value| estimate(value, remaining_factor));
|
||||
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
|
||||
{
|
||||
Some("本周期缺少可用的 Token 或完整费用记录,暂时无法预测额度。")
|
||||
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::model::provider_credentials::{
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderResetCreditResult, ProviderResetCredits, explicit_plan_type,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
|
||||
};
|
||||
use crate::model::{
|
||||
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
|
||||
@@ -185,6 +185,14 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
}
|
||||
|
||||
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
|
||||
/// 只由个人信息显式查询,不在额度刷新或后台任务中预取。
|
||||
async fn subscription(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
|
||||
@@ -44,6 +44,7 @@ pub enum AdminStoreErrorKind {
|
||||
Invalid,
|
||||
NotFound,
|
||||
StaleRevision,
|
||||
DuplicateName,
|
||||
Conflict,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ use crate::{
|
||||
},
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, ConsumeProviderResetCredit, PrepareCredentialRefresh,
|
||||
ProviderModels, ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
@@ -90,11 +90,11 @@ pub trait AccountsService: Send + Sync {
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<AccountQuotaForecastReport, AdminError>;
|
||||
|
||||
async fn profile_statistics(
|
||||
async fn personal_info(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持账号统计"))
|
||||
) -> Result<AccountPersonalInfo, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持个人信息"))
|
||||
}
|
||||
|
||||
async fn profile_avatar(
|
||||
@@ -680,15 +680,36 @@ impl AccountsService for DefaultAccountsService {
|
||||
})
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
async fn personal_info(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
provider
|
||||
.profile_statistics(account_id)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider profile statistics"))
|
||||
) -> Result<AccountPersonalInfo, AdminError> {
|
||||
let (initial, provider) = self.provider_for_account(account_id).await?;
|
||||
// 两项读取相互独立;不因其中一项失败而取消另一项,也不触发凭据或额度刷新。
|
||||
let (profile, subscription) = futures::join!(
|
||||
provider.profile_statistics(account_id),
|
||||
provider.subscription(account_id),
|
||||
);
|
||||
let profile =
|
||||
profile.map_err(|error| map_provider_error(error, "provider profile statistics"));
|
||||
let subscription = subscription
|
||||
.map_err(|error| map_provider_error(error, "provider subscription"))
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// 汇聚等待期间发生重新授权、换绑或删除时,不返回混合身份的数据。
|
||||
let current = self.load_account(account_id).await?;
|
||||
if current.account.provider_kind != initial.account.provider_kind
|
||||
|| current.account.credential_revision != initial.account.credential_revision
|
||||
|| current.account.upstream_user_id != initial.account.upstream_user_id
|
||||
|| current.account.upstream_account_id != initial.account.upstream_account_id
|
||||
{
|
||||
return Err(AdminError::conflict("账号身份已变化,请刷新信息后重试"));
|
||||
}
|
||||
Ok(AccountPersonalInfo {
|
||||
profile,
|
||||
subscription,
|
||||
})
|
||||
}
|
||||
|
||||
async fn profile_avatar(
|
||||
|
||||
@@ -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,21 +241,22 @@ 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tokens_suppress_token_estimates_without_discarding_complete_costs() {
|
||||
fn missing_tokens_keep_estimates_from_recorded_usage() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.usage.missing_token_count = 1;
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.incomplete_tokens);
|
||||
assert_eq!(week.estimated_tokens, None);
|
||||
assert_eq!(week.remaining_tokens, None);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
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]
|
||||
@@ -269,7 +270,7 @@ fn discontinuous_samples_never_fall_back_to_cumulative_predictions() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_or_unknown_costs_suppress_only_money_estimates() {
|
||||
fn partial_costs_keep_estimates_from_known_amounts() {
|
||||
for coverage in [
|
||||
CostCoverage {
|
||||
calculated_count: 9,
|
||||
@@ -281,19 +282,77 @@ fn partial_or_unknown_costs_suppress_only_money_estimates() {
|
||||
unavailable_count: 1,
|
||||
..Default::default()
|
||||
},
|
||||
CostCoverage::default(),
|
||||
] {
|
||||
let mut sample = window("week", 7);
|
||||
sample.local_usage.as_mut().unwrap().cost_coverage = coverage;
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert!(week.estimated_usd.is_none());
|
||||
assert!(week.remaining_usd.is_none());
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
|
||||
sample.usage.request_count += 2;
|
||||
sample.usage.missing_token_count = 2;
|
||||
sample.usage.unavailable_cost_count = 2;
|
||||
sample.usage.excluded_request_count = 1;
|
||||
sample.pending_request_count = 1;
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.incomplete_tokens);
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
|
||||
let mut sample = window("week", 7);
|
||||
let usage = sample.local_usage.as_mut().unwrap();
|
||||
usage.cost_coverage = CostCoverage {
|
||||
unavailable_count: usage.request_count,
|
||||
..Default::default()
|
||||
};
|
||||
usage.costs.clear();
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.usage = QuotaForecastUsage {
|
||||
request_count: 2,
|
||||
missing_token_count: 2,
|
||||
unavailable_cost_count: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.unavailable_reason.is_some());
|
||||
assert_eq!(week.estimated_tokens, None);
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_tokens, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_known_cost_is_valid_but_missing_usd_is_not_invented() {
|
||||
let mut sample = window("week", 7);
|
||||
|
||||
@@ -38,8 +38,10 @@ use gateway_admin::{
|
||||
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileActivityInsights, ProviderProfileStatistics,
|
||||
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderSubscription,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
quota_forecast_sampling::{QuotaForecastHistory, QuotaForecastUsage},
|
||||
@@ -77,6 +79,9 @@ pub(super) struct FakeProviderAdmin {
|
||||
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
|
||||
current_credential_revision: Mutex<Revision>,
|
||||
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
|
||||
profile_result: Mutex<Result<ProviderProfileStatistics, ProviderAdminErrorKind>>,
|
||||
subscription_result: Mutex<Result<Option<ProviderSubscription>, ProviderAdminErrorKind>>,
|
||||
personal_info_barrier: Mutex<Option<Arc<tokio::sync::Barrier>>>,
|
||||
}
|
||||
|
||||
impl FakeProviderAdmin {
|
||||
@@ -97,6 +102,9 @@ impl FakeProviderAdmin {
|
||||
quota_refresh_account: Mutex::new(None),
|
||||
current_credential_revision: Mutex::new(revision(1)),
|
||||
reset_credit_commands: Mutex::new(Vec::new()),
|
||||
profile_result: Mutex::new(Ok(empty_profile_statistics())),
|
||||
subscription_result: Mutex::new(Ok(None)),
|
||||
personal_info_barrier: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,6 +112,14 @@ impl FakeProviderAdmin {
|
||||
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
|
||||
}
|
||||
|
||||
async fn wait_personal_info_queries(&self) {
|
||||
let barrier = self.personal_info_barrier.lock().unwrap().clone();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.wait().await;
|
||||
barrier.wait().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_next_with_message(&self, kind: ProviderAdminErrorKind, message: &str) {
|
||||
*self.failure.lock().expect("provider failure") =
|
||||
Some(ProviderAdminError::new(kind).with_message(message));
|
||||
@@ -237,6 +253,32 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
|
||||
self.record("provider.profile_statistics");
|
||||
self.wait_personal_info_queries().await;
|
||||
self.profile_result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.map_err(ProviderAdminError::new)
|
||||
}
|
||||
|
||||
async fn subscription(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
|
||||
self.record("provider.subscription");
|
||||
self.wait_personal_info_queries().await;
|
||||
self.subscription_result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.map_err(ProviderAdminError::new)
|
||||
}
|
||||
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
format!("{} display: {plan_type}", self.kind)
|
||||
}
|
||||
@@ -1825,9 +1867,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 +1924,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);
|
||||
@@ -2431,6 +2471,167 @@ fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResul
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_profile_statistics() -> ProviderProfileStatistics {
|
||||
ProviderProfileStatistics {
|
||||
display_name: Some("Preview user".to_owned()),
|
||||
username: None,
|
||||
image_url: None,
|
||||
has_stats_error: false,
|
||||
summary: ProviderProfileStatisticsSummary {
|
||||
total_text_tokens: Some(123),
|
||||
peak_tokens: None,
|
||||
longest_task_duration_ms: None,
|
||||
current_streak_days: None,
|
||||
longest_streak_days: None,
|
||||
},
|
||||
daily_usage: None,
|
||||
activity_insights: ProviderProfileActivityInsights {
|
||||
fast_mode_percent: None,
|
||||
reasoning_effort: None,
|
||||
reasoning_effort_percent: None,
|
||||
skills_explored: None,
|
||||
total_skills_used: None,
|
||||
total_threads: None,
|
||||
invocations: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn personal_info_subscription() -> ProviderSubscription {
|
||||
ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: Utc::now(),
|
||||
will_renew: Some(true),
|
||||
billing_period: Some("monthly".to_owned()),
|
||||
billing_currency: Some("USD".to_owned()),
|
||||
observed_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_query_both_parts_concurrently_once_on_every_request() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let subscription = personal_info_subscription();
|
||||
*provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone()));
|
||||
*provider.personal_info_barrier.lock().unwrap() = Some(Arc::new(tokio::sync::Barrier::new(2)));
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = accounts_service(provider, store).await;
|
||||
let account_id = ProviderAccountId::new("acct_test").unwrap();
|
||||
|
||||
for _ in 0..2 {
|
||||
let info = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
services.accounts().personal_info(&account_id),
|
||||
)
|
||||
.await
|
||||
.expect("两项查询应并发执行")
|
||||
.unwrap();
|
||||
assert_eq!(info.profile.unwrap(), empty_profile_statistics());
|
||||
assert_eq!(info.subscription, Some(subscription.clone()));
|
||||
}
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.load_account",
|
||||
"provider.profile_statistics",
|
||||
"provider.subscription",
|
||||
"store.load_account",
|
||||
"store.load_account",
|
||||
"provider.profile_statistics",
|
||||
"provider.subscription",
|
||||
"store.load_account",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_keep_profile_when_subscription_is_missing_or_fails() {
|
||||
for subscription in [Ok(None), Err(ProviderAdminErrorKind::Unavailable)] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
*provider.subscription_result.lock().unwrap() = subscription;
|
||||
let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await;
|
||||
let info = services
|
||||
.accounts()
|
||||
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(info.profile.unwrap(), empty_profile_statistics());
|
||||
assert_eq!(info.subscription, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_keep_subscription_when_profile_fails() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
*provider.profile_result.lock().unwrap() = Err(ProviderAdminErrorKind::BadGateway);
|
||||
let subscription = personal_info_subscription();
|
||||
*provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone()));
|
||||
let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await;
|
||||
let info = services
|
||||
.accounts()
|
||||
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
info.profile.unwrap_err().kind(),
|
||||
gateway_admin::model::AdminErrorKind::BadGateway
|
||||
);
|
||||
assert_eq!(info.subscription, Some(subscription));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_reject_missing_account_before_querying_provider() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
store.set_accounts(Vec::new());
|
||||
let services = accounts_service(provider, store).await;
|
||||
let error = services
|
||||
.accounts()
|
||||
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::NotFound);
|
||||
assert_eq!(recorded(&events), ["store.load_account"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_discard_results_when_account_changes_during_query() {
|
||||
for change in 0..5 {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(3));
|
||||
*provider.personal_info_barrier.lock().unwrap() = Some(barrier.clone());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
let services = accounts_service(provider, store.clone()).await;
|
||||
let account_id = ProviderAccountId::new("acct_test").unwrap();
|
||||
let mutate = async {
|
||||
barrier.wait().await;
|
||||
let mut accounts = store.accounts.lock().unwrap().clone();
|
||||
match change {
|
||||
0 => accounts[0].credential_revision = revision(99),
|
||||
1 => accounts[0].upstream_account_id = Some("changed-account".to_owned()),
|
||||
2 => accounts[0].upstream_user_id = Some("changed-user".to_owned()),
|
||||
3 => accounts[0].provider_kind = ProviderKind::new("xai").unwrap(),
|
||||
_ => accounts.clear(),
|
||||
}
|
||||
store.set_accounts(accounts);
|
||||
barrier.wait().await;
|
||||
};
|
||||
let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
tokio::join!(services.accounts().personal_info(&account_id), mutate)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = if change == 4 {
|
||||
gateway_admin::model::AdminErrorKind::NotFound
|
||||
} else {
|
||||
gateway_admin::model::AdminErrorKind::Conflict
|
||||
};
|
||||
assert_eq!(result.unwrap_err().kind(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
async fn accounts_service(
|
||||
provider: Arc<FakeProviderAdmin>,
|
||||
store: Arc<FakeAccountStore>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,12 +23,12 @@ where
|
||||
)
|
||||
.route("/api/admin/accounts/quota", get(account_quota::<S>))
|
||||
.route(
|
||||
"/api/admin/accounts/quota-forecast",
|
||||
get(account_quota_forecast::<S>),
|
||||
"/api/admin/accounts/personal-info",
|
||||
get(account_personal_info::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/profile-statistics",
|
||||
get(account_profile_statistics::<S>),
|
||||
"/api/admin/accounts/quota-forecast",
|
||||
get(account_quota_forecast::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/profile-avatar",
|
||||
@@ -389,7 +389,7 @@ where
|
||||
))
|
||||
}
|
||||
|
||||
async fn account_profile_statistics<S>(
|
||||
async fn account_personal_info<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
@@ -401,12 +401,12 @@ where
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.profile_statistics(&account_id)
|
||||
.personal_info(&account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountProfileStatisticsData::from(result)),
|
||||
AdminEnvelope::ok(AccountPersonalInfoData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -25,14 +25,15 @@ use gateway_admin::model::{
|
||||
AccountWeight, AccountsUpdateResult, BatchUpdateAccounts, SortDirection, UpdateAccount,
|
||||
},
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
|
||||
ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
|
||||
ProviderDocument, ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar,
|
||||
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
|
||||
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaWindow, ProviderResetCredit,
|
||||
ProviderResetCreditResult, ProviderResetCredits, RotateCredential, StartAuthorization,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, RotateCredential,
|
||||
StartAuthorization,
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
|
||||
@@ -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)]
|
||||
@@ -681,6 +653,54 @@ pub struct AccountQuotaData {
|
||||
pub account: AccountView,
|
||||
}
|
||||
|
||||
/// 个人资料、累计统计与订阅的统一响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountPersonalInfoData {
|
||||
pub profile: Option<AccountProfileStatisticsData>,
|
||||
pub profile_error: Option<String>,
|
||||
pub subscription: Option<AccountSubscriptionData>,
|
||||
}
|
||||
|
||||
impl From<AccountPersonalInfo> for AccountPersonalInfoData {
|
||||
fn from(info: AccountPersonalInfo) -> Self {
|
||||
let (profile, profile_error) = match info.profile {
|
||||
Ok(profile) => (Some(AccountProfileStatisticsData::from(profile)), None),
|
||||
Err(error) => (None, Some(error.message().to_owned())),
|
||||
};
|
||||
Self {
|
||||
profile,
|
||||
profile_error,
|
||||
subscription: info.subscription.map(AccountSubscriptionData::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按需读取的订阅安全字段,不暴露原始上游响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountSubscriptionData {
|
||||
pub starts_at: Option<String>,
|
||||
pub expires_at: String,
|
||||
pub will_renew: Option<bool>,
|
||||
pub billing_period: Option<String>,
|
||||
pub billing_currency: Option<String>,
|
||||
pub observed_at: String,
|
||||
}
|
||||
|
||||
impl From<ProviderSubscription> for AccountSubscriptionData {
|
||||
fn from(subscription: ProviderSubscription) -> Self {
|
||||
Self {
|
||||
starts_at: subscription.starts_at.map(|value| value.to_rfc3339()),
|
||||
expires_at: subscription.expires_at.to_rfc3339(),
|
||||
will_renew: subscription.will_renew,
|
||||
billing_period: subscription.billing_period,
|
||||
billing_currency: subscription.billing_currency,
|
||||
observed_at: subscription.observed_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料统计响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -7,6 +7,42 @@ use tower::ServiceExt as _;
|
||||
|
||||
use super::super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_requires_admin_and_a_valid_account_query() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for (query, authenticated, expected) in [
|
||||
("?accountId=acct_test", false, StatusCode::UNAUTHORIZED),
|
||||
("", true, StatusCode::BAD_REQUEST),
|
||||
("?accountId=bad", true, StatusCode::BAD_REQUEST),
|
||||
(
|
||||
"?accountId=acct_test&refresh=true",
|
||||
true,
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
// 此夹具未提供账号 Store,合法查询应透传服务不可用,而非绕过查询。
|
||||
(
|
||||
"?accountId=acct_test",
|
||||
true,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
),
|
||||
] {
|
||||
let mut request = Request::builder()
|
||||
.uri(format!("/api/admin/accounts/personal-info{query}"))
|
||||
.header("x-request-id", "req_personal_info");
|
||||
if authenticated {
|
||||
request = request.header(header::COOKIE, "cpr_admin_session=valid-session");
|
||||
}
|
||||
let response = admin::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), expected, "{query}");
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_forecast_requires_admin_and_valid_account_query() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
|
||||
@@ -1,6 +1,77 @@
|
||||
mod handlers;
|
||||
mod presenter;
|
||||
|
||||
mod personal_info {
|
||||
use chrono::{TimeZone as _, Utc};
|
||||
use gateway_admin::model::{
|
||||
AdminError,
|
||||
provider_credentials::{AccountPersonalInfo, ProviderSubscription},
|
||||
};
|
||||
use gateway_api::admin::accounts::{AccountPersonalInfoData, AccountSubscriptionData};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn subscription_response_exposes_only_display_fields_and_preserves_unknowns() {
|
||||
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
|
||||
let response = AccountSubscriptionData::from(ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: observed,
|
||||
will_renew: None,
|
||||
billing_period: None,
|
||||
billing_currency: Some("USD".to_owned()),
|
||||
observed_at: observed,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).unwrap(),
|
||||
json!({
|
||||
"startsAt": null, "expiresAt": "2026-09-14T00:00:00+00:00",
|
||||
"willRenew": null, "billingPeriod": null, "billingCurrency": "USD",
|
||||
"observedAt": "2026-09-14T00:00:00+00:00"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn personal_info_response_preserves_subscription_when_profile_fails() {
|
||||
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
|
||||
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
|
||||
profile: Err(AdminError::bad_gateway("上游服务请求失败")),
|
||||
subscription: Some(ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: observed,
|
||||
will_renew: None,
|
||||
billing_period: None,
|
||||
billing_currency: None,
|
||||
observed_at: observed,
|
||||
}),
|
||||
});
|
||||
let value = serde_json::to_value(response).unwrap();
|
||||
assert!(value["profile"].is_null());
|
||||
assert_eq!(value["profileError"], "上游服务请求失败");
|
||||
assert_eq!(
|
||||
value["subscription"]["expiresAt"],
|
||||
"2026-09-14T00:00:00+00:00"
|
||||
);
|
||||
assert_eq!(value.as_object().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn personal_info_response_preserves_unknown_subscription_and_profile_error() {
|
||||
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
|
||||
profile: Err(AdminError::unavailable("Provider 服务暂不可用")),
|
||||
subscription: None,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).unwrap(),
|
||||
json!({
|
||||
"profile": null,
|
||||
"profileError": "Provider 服务暂不可用",
|
||||
"subscription": null,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod query {
|
||||
use gateway_admin::model::accounts::{AccountSortField, AccountStatus, SortDirection};
|
||||
use gateway_api::admin::accounts::ListQuery;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::diagnostics::TraceContext;
|
||||
use serde_json::json;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::future::Future;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
@@ -192,7 +192,8 @@ where
|
||||
excluded_accounts: BTreeSet::new(),
|
||||
credential_recovery_attempted_accounts: BTreeSet::new(),
|
||||
recovery_account: None,
|
||||
transport_recovery: None,
|
||||
pending_retry: None,
|
||||
transient_retry_counts: BTreeMap::new(),
|
||||
current: None,
|
||||
send_state_watermark: UpstreamSendState::NotSent,
|
||||
downstream_committed_at: None,
|
||||
@@ -224,6 +225,7 @@ struct CurrentAttempt {
|
||||
stream: ProviderStream,
|
||||
metadata: ProviderCallMetadata,
|
||||
trigger: AttemptTrigger,
|
||||
transport: AttemptTransport,
|
||||
index: NonZeroU32,
|
||||
started_at: SystemTime,
|
||||
send_observed: bool,
|
||||
@@ -253,10 +255,11 @@ struct FailureFinalization {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PendingTransportRecovery {
|
||||
struct PendingAttemptRetry {
|
||||
account: crate::account::ProviderAccountId,
|
||||
transport: AttemptTransport,
|
||||
delay: Duration,
|
||||
transport_recovery: bool,
|
||||
}
|
||||
|
||||
/// API 可逐事件消费的 Core 执行会话。
|
||||
@@ -294,9 +297,10 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
/// attempt 建立时即被消费,后续可重试错误仍可换号消耗剩余重试预算。
|
||||
/// 与 `required_account`(外部指定、贯穿整个请求)语义不同,不可合并。
|
||||
recovery_account: Option<crate::account::ProviderAccountId>,
|
||||
/// 上游传输在提交边界前失败后的一次性同账号重试或备用传输钉选。
|
||||
/// 与凭据恢复分开保存,避免把 transport recovery 误记为 OAuth 恢复。
|
||||
transport_recovery: Option<PendingTransportRecovery>,
|
||||
/// 提交前同账号退避/传输恢复共用的等待与钉选状态;业务重试消耗路由预算。
|
||||
pending_retry: Option<PendingAttemptRetry>,
|
||||
/// 请求内按账号累计的瞬时拒绝重试次数,不能跨请求污染账号健康状态。
|
||||
transient_retry_counts: BTreeMap<crate::account::ProviderAccountId, u32>,
|
||||
current: Option<CurrentAttempt>,
|
||||
/// 请求级发送状态水位;跨 attempt 单调不降,终态写回不得低于此档。
|
||||
send_state_watermark: UpstreamSendState,
|
||||
@@ -693,11 +697,14 @@ where
|
||||
}
|
||||
|
||||
async fn prepare_attempt(&mut self) -> Result<Option<PullOutcome>, EngineError> {
|
||||
let transport_recovery = self.transport_recovery.take();
|
||||
if transport_recovery.is_none() && self.routing_attempts >= self.plan.max_attempts().get() {
|
||||
let pending_retry = self.pending_retry.take();
|
||||
let is_transport_recovery = pending_retry
|
||||
.as_ref()
|
||||
.is_some_and(|retry| retry.transport_recovery);
|
||||
if !is_transport_recovery && self.routing_attempts >= self.plan.max_attempts().get() {
|
||||
return Err(EngineError::EmptyRoutingPlan);
|
||||
}
|
||||
if let Some(recovery) = transport_recovery.as_ref()
|
||||
if let Some(recovery) = pending_retry.as_ref()
|
||||
&& !recovery.delay.is_zero()
|
||||
{
|
||||
match poll_retry_delay(recovery.delay, self.cancellation.clone(), self.deadline).await {
|
||||
@@ -738,8 +745,7 @@ where
|
||||
.ok_or(EngineError::EmptyRoutingPlan)?;
|
||||
// 请求局部恢复钉选在此被一次性消费,只绑定本次 replay attempt;
|
||||
// 外部 required_account 每次 attempt 都重新生效。
|
||||
let is_transport_recovery = transport_recovery.is_some();
|
||||
let (pinned_account, attempt_transport) = if let Some(recovery) = transport_recovery {
|
||||
let (pinned_account, attempt_transport) = if let Some(recovery) = pending_retry {
|
||||
(Some(recovery.account), recovery.transport)
|
||||
} else {
|
||||
match &self.account_selection {
|
||||
@@ -1018,6 +1024,7 @@ where
|
||||
stream,
|
||||
metadata,
|
||||
trigger,
|
||||
transport: attempt_transport,
|
||||
index: next_attempt,
|
||||
started_at: SystemTime::now(),
|
||||
send_observed: false,
|
||||
@@ -1190,6 +1197,27 @@ where
|
||||
&& attempt_send_state != UpstreamSendState::Ambiguous
|
||||
&& provider_proved_replay_safe
|
||||
&& self.routing_attempts < self.plan.max_attempts().get();
|
||||
let transient_retry = match error.pre_delivery_retry() {
|
||||
Some(crate::error::PreDeliveryRetry::SameAccountTransientRetry {
|
||||
max_retries,
|
||||
initial_delay,
|
||||
max_delay,
|
||||
}) if ordinary_retry => {
|
||||
let retries = self
|
||||
.transient_retry_counts
|
||||
.entry(current.metadata.provider_account_id().clone())
|
||||
.or_default();
|
||||
if *retries < max_retries.get() {
|
||||
let multiplier = 1_u32.checked_shl(*retries).unwrap_or(u32::MAX);
|
||||
let delay = initial_delay.saturating_mul(multiplier).min(max_delay);
|
||||
*retries = retries.saturating_add(1);
|
||||
Some(delay)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let same_account_retry = error.retries_same_account()
|
||||
&& provider_proved_replay_safe
|
||||
&& self.downstream_committed_at.is_none()
|
||||
@@ -1209,7 +1237,8 @@ where
|
||||
"retryable": retryable, "continuationRetry": continuation_retry,
|
||||
"sameAccountRetry": same_account_retry, "accountRotationRetry": account_rotation_retry,
|
||||
"ordinaryRetry": ordinary_retry, "transportRecovery": transport_recovery.is_some(),
|
||||
"delayMs": transport_recovery.map(|(_, delay)| duration_ms(delay)),
|
||||
"transientRetry": transient_retry.is_some(),
|
||||
"delayMs": transient_retry.or(transport_recovery.map(|(_, delay)| delay)).map(duration_ms),
|
||||
"downstreamCommitted": self.downstream_committed_at.is_some(),
|
||||
"sendState": format!("{attempt_send_state:?}"),
|
||||
}));
|
||||
@@ -1229,11 +1258,19 @@ where
|
||||
// 只钉住紧随其后的 replay attempt;replay 再遇可重试错误时,
|
||||
// ordinary/continuation 重试门不受影响,仍可换号。
|
||||
self.recovery_account = Some(account);
|
||||
} else if let Some(delay) = transient_retry {
|
||||
self.pending_retry = Some(PendingAttemptRetry {
|
||||
account: current.metadata.provider_account_id().clone(),
|
||||
transport: current.transport,
|
||||
delay,
|
||||
transport_recovery: false,
|
||||
});
|
||||
} else if let Some((transport, delay)) = transport_recovery {
|
||||
self.transport_recovery = Some(PendingTransportRecovery {
|
||||
self.pending_retry = Some(PendingAttemptRetry {
|
||||
account: current.metadata.provider_account_id().clone(),
|
||||
transport,
|
||||
delay,
|
||||
transport_recovery: true,
|
||||
});
|
||||
} else if !continuation_retry {
|
||||
self.excluded_accounts
|
||||
|
||||
@@ -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 被取消前,均不得发送本次请求的上游握手或业务
|
||||
|
||||
@@ -46,6 +46,8 @@ pub enum ProviderErrorKind {
|
||||
Protocol,
|
||||
/// Provider 暂不可用。
|
||||
Unavailable,
|
||||
/// 上游明确拒绝当前请求的模型容量;不证明整个 Provider 的基础设施故障。
|
||||
UpstreamCapacityUnavailable,
|
||||
/// 请求被取消。
|
||||
Cancelled,
|
||||
/// 进程终止后由恢复流程收敛。
|
||||
@@ -71,6 +73,7 @@ impl ProviderErrorKind {
|
||||
Self::Transport => "transport",
|
||||
Self::Protocol => "protocol",
|
||||
Self::Unavailable => "unavailable",
|
||||
Self::UpstreamCapacityUnavailable => "upstream_capacity_unavailable",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::ProcessTerminated => "process_terminated",
|
||||
}
|
||||
@@ -105,6 +108,15 @@ pub enum ContinuationRecoveryDisposition {
|
||||
pub enum PreDeliveryRetry {
|
||||
/// 排除本次账号,按普通调度策略重新选号。
|
||||
AccountRotation,
|
||||
/// 上游明确拒绝后的同账号退避;耗尽后仍由普通安全重放规则决定是否换号。
|
||||
SameAccountTransientRetry {
|
||||
/// 每个账号在本请求中的重试上限,同时受总路由预算约束。
|
||||
max_retries: NonZeroU32,
|
||||
/// 指数退避的起始间隔。
|
||||
initial_delay: Duration,
|
||||
/// 单次等待上限,避免上游提示把请求拖入长时间等待。
|
||||
max_delay: Duration,
|
||||
},
|
||||
/// 固定本次账号,并按 Provider 给出的序号重试当前传输。
|
||||
SameAccountTransportRetry {
|
||||
/// Provider-owned 传输重试序号,从 1 开始。
|
||||
@@ -409,7 +421,7 @@ pub struct ProviderError {
|
||||
continuation_recovery_disposition: Option<ContinuationRecoveryDisposition>,
|
||||
failure_observation: Option<Box<ProviderErrorFailureObservation>>,
|
||||
replay_safe: bool,
|
||||
pre_delivery_retry: Option<PreDeliveryRetry>,
|
||||
pre_delivery_retry: Option<Box<PreDeliveryRetry>>,
|
||||
credential_recovery_required: bool,
|
||||
retry_same_account: bool,
|
||||
sensitive_context_redacted: bool,
|
||||
@@ -557,8 +569,8 @@ impl ProviderError {
|
||||
/// Provider 选择了“客户端无感恢复优先”的传输策略。Core 在 continuation、
|
||||
/// 指定账号或下游已经进入提交状态时必须忽略它。
|
||||
#[must_use]
|
||||
pub const fn with_pre_delivery_retry(mut self) -> Self {
|
||||
self.pre_delivery_retry = Some(PreDeliveryRetry::AccountRotation);
|
||||
pub fn with_pre_delivery_retry(mut self) -> Self {
|
||||
self.pre_delivery_retry = Some(Box::new(PreDeliveryRetry::AccountRotation));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -567,14 +579,14 @@ impl ProviderError {
|
||||
/// 与普通换号恢复相同,Core 仍会拒绝 continuation、指定账号、预算耗尽或
|
||||
/// 下游已提交后的隐藏重放。
|
||||
#[must_use]
|
||||
pub const fn with_pre_delivery_transport_fallback(mut self) -> Self {
|
||||
pub fn with_pre_delivery_transport_fallback(mut self) -> Self {
|
||||
self.set_pre_delivery_transport_fallback();
|
||||
self
|
||||
}
|
||||
|
||||
/// 原地设置同账号备用传输恢复,保留当前错误携带的原客户端响应。
|
||||
pub const fn set_pre_delivery_transport_fallback(&mut self) {
|
||||
self.pre_delivery_retry = Some(PreDeliveryRetry::SameAccountTransportFallback);
|
||||
pub fn set_pre_delivery_transport_fallback(&mut self) {
|
||||
self.pre_delivery_retry = Some(Box::new(PreDeliveryRetry::SameAccountTransportFallback));
|
||||
}
|
||||
|
||||
/// 允许 Core 在首个客户端事件前固定原账号并重试当前 Provider 传输。
|
||||
@@ -582,7 +594,7 @@ impl ProviderError {
|
||||
/// 重试预算和退避策略由 Provider 所属协议定义;Core 只负责同账号钉选、
|
||||
/// deadline/取消边界以及下游 commit barrier。
|
||||
#[must_use]
|
||||
pub const fn with_pre_delivery_transport_retry(
|
||||
pub fn with_pre_delivery_transport_retry(
|
||||
mut self,
|
||||
retry_index: NonZeroU32,
|
||||
delay: Duration,
|
||||
@@ -592,13 +604,11 @@ impl ProviderError {
|
||||
}
|
||||
|
||||
/// 原地设置同账号当前传输重试,保留当前错误携带的原客户端响应。
|
||||
pub const fn set_pre_delivery_transport_retry(
|
||||
&mut self,
|
||||
retry_index: NonZeroU32,
|
||||
delay: Duration,
|
||||
) {
|
||||
self.pre_delivery_retry =
|
||||
Some(PreDeliveryRetry::SameAccountTransportRetry { retry_index, delay });
|
||||
pub fn set_pre_delivery_transport_retry(&mut self, retry_index: NonZeroU32, delay: Duration) {
|
||||
self.pre_delivery_retry = Some(Box::new(PreDeliveryRetry::SameAccountTransportRetry {
|
||||
retry_index,
|
||||
delay,
|
||||
}));
|
||||
}
|
||||
|
||||
/// 要求 Core 在账号凭据已恢复后仅对原账号重放一次。
|
||||
@@ -608,6 +618,22 @@ impl ProviderError {
|
||||
self
|
||||
}
|
||||
|
||||
/// 请求有界同账号退避;该意图本身不提供安全重放证据。
|
||||
#[must_use]
|
||||
pub fn with_transient_retry(
|
||||
mut self,
|
||||
max_retries: NonZeroU32,
|
||||
initial_delay: Duration,
|
||||
max_delay: Duration,
|
||||
) -> Self {
|
||||
self.pre_delivery_retry = Some(Box::new(PreDeliveryRetry::SameAccountTransientRetry {
|
||||
max_retries,
|
||||
initial_delay,
|
||||
max_delay,
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
/// 标记上游明确拒绝应先触发 OAuth 恢复,而不是直接废弃账号。
|
||||
#[must_use]
|
||||
pub const fn with_credential_recovery(mut self) -> Self {
|
||||
@@ -748,7 +774,10 @@ impl ProviderError {
|
||||
/// 返回下游提交前由 Provider 选择的隐藏恢复方式。
|
||||
#[must_use]
|
||||
pub const fn pre_delivery_retry(&self) -> Option<PreDeliveryRetry> {
|
||||
self.pre_delivery_retry
|
||||
match self.pre_delivery_retry.as_ref() {
|
||||
Some(retry) => Some(**retry),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回 Provider 是否已完成凭据恢复并要求原账号重放。
|
||||
@@ -822,7 +851,7 @@ impl Clone for ProviderError {
|
||||
continuation_recovery_disposition: self.continuation_recovery_disposition,
|
||||
failure_observation: self.failure_observation.clone(),
|
||||
replay_safe: self.replay_safe,
|
||||
pre_delivery_retry: self.pre_delivery_retry,
|
||||
pre_delivery_retry: self.pre_delivery_retry.clone(),
|
||||
credential_recovery_required: self.credential_recovery_required,
|
||||
retry_same_account: self.retry_same_account,
|
||||
sensitive_context_redacted: self.sensitive_context_redacted,
|
||||
@@ -1064,6 +1093,7 @@ impl GatewayError {
|
||||
ProviderErrorKind::Transport
|
||||
| ProviderErrorKind::Protocol
|
||||
| ProviderErrorKind::Unavailable
|
||||
| ProviderErrorKind::UpstreamCapacityUnavailable
|
||||
| ProviderErrorKind::ProcessTerminated => Self::new(
|
||||
GatewayErrorKind::UpstreamUnavailable,
|
||||
"upstream service is unavailable",
|
||||
|
||||
@@ -521,7 +521,7 @@ impl fmt::Debug for ProviderResponseHeader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Core 消费的实际上游响应事实。
|
||||
/// Core 消费的 Provider 执行观测,不会原样进入客户端响应。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResponseObservation {
|
||||
transport: UpstreamTransport,
|
||||
@@ -575,7 +575,7 @@ impl ProviderResponseObservation {
|
||||
self
|
||||
}
|
||||
|
||||
/// 附加 Provider 在响应中确认的服务档位。
|
||||
/// 附加 Provider 确定的统计档位,Core 不重新解释其来源。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -590,7 +590,7 @@ impl ProviderResponseObservation {
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// 附加合法服务档位;不适合持久化的上游观测值会被忽略。
|
||||
/// 附加 Provider 确定的统计档位;不适合持久化的值会被忽略。
|
||||
#[must_use]
|
||||
pub fn with_service_tier_if_valid(mut self, service_tier: impl Into<String>) -> Self {
|
||||
let service_tier = service_tier.into();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -3318,6 +3318,284 @@ fn provider_owned_transport_retries_keep_the_same_account_until_fallback() {
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
}
|
||||
|
||||
fn transient_rejection() -> ProviderError {
|
||||
ProviderError::new(
|
||||
ProviderErrorKind::UpstreamCapacityUnavailable,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_replay_safe()
|
||||
.with_transient_retry(
|
||||
NonZeroU32::new(3).expect("retry budget"),
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_retries_back_off_on_the_same_account_then_rotate_with_a_fresh_budget() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let mut scripts = (0..4)
|
||||
.map(|_| Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(transient_rejection())],
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
scripts.push(Script::Stream {
|
||||
account_id: "acct_second",
|
||||
items: vec![Err(transient_rejection())],
|
||||
});
|
||||
scripts.push(Script::Stream {
|
||||
account_id: "acct_second",
|
||||
items: complete_stream(None),
|
||||
});
|
||||
let (coordinator, store, provider) = coordinator(scripts);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
block_on(session.collect_uncommitted()).expect("second account succeeds");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit");
|
||||
let first = ProviderAccountId::new("acct_first").expect("account");
|
||||
let second = ProviderAccountId::new("acct_second").expect("account");
|
||||
let contexts = provider.contexts.lock().expect("contexts");
|
||||
assert_eq!(contexts.len(), 6);
|
||||
for context in &contexts[1..4] {
|
||||
assert_eq!(context.required_account(), Some(&first));
|
||||
assert!(!context.excluded_accounts().contains(&first));
|
||||
}
|
||||
assert_eq!(contexts[4].required_account(), None);
|
||||
assert!(contexts[4].excluded_accounts().contains(&first));
|
||||
assert_eq!(contexts[5].required_account(), Some(&second));
|
||||
assert!(!contexts[5].excluded_accounts().contains(&second));
|
||||
assert!(
|
||||
contexts
|
||||
.iter()
|
||||
.all(|context| context.transport() == AttemptTransport::Default)
|
||||
);
|
||||
assert_eq!(
|
||||
store.state.lock().expect("store").finalizations[0].attempt_count,
|
||||
6
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_retries_consume_the_total_routing_budget() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let max_attempts = route_plan.max_attempts();
|
||||
let scripts = (0..max_attempts.get())
|
||||
.map(|_| Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(transient_rejection().with_transient_retry(
|
||||
max_attempts,
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
))],
|
||||
})
|
||||
.collect();
|
||||
let (coordinator, store, provider) = coordinator(scripts);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
assert!(matches!(
|
||||
block_on(session.collect_uncommitted()),
|
||||
Err(EngineError::Provider(_))
|
||||
));
|
||||
assert_eq!(
|
||||
provider.contexts.lock().expect("contexts").len(),
|
||||
max_attempts.get() as usize
|
||||
);
|
||||
assert_eq!(
|
||||
store.state.lock().expect("store").finalizations[0].attempt_count,
|
||||
max_attempts.get()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_retry_intent_does_not_supply_replay_proof_or_override_ambiguous_send() {
|
||||
for (send_state, prove_safe) in [
|
||||
(UpstreamSendState::Sent, false),
|
||||
(UpstreamSendState::Ambiguous, true),
|
||||
] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let mut error = ProviderError::new(ProviderErrorKind::Unavailable, send_state)
|
||||
.with_transient_retry(NonZeroU32::MIN, Duration::ZERO, Duration::ZERO);
|
||||
if prove_safe {
|
||||
error = error.with_replay_safe();
|
||||
}
|
||||
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(error)],
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
assert!(matches!(
|
||||
block_on(session.collect_uncommitted()),
|
||||
Err(EngineError::Provider(_))
|
||||
));
|
||||
assert_eq!(provider.contexts.lock().expect("contexts").len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_backoff_obeys_request_deadline() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(transient_rejection().with_transient_retry(
|
||||
NonZeroU32::MIN,
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(60),
|
||||
))],
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_millis(100)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
assert!(matches!(
|
||||
block_on(session.collect_uncommitted()),
|
||||
Err(EngineError::Deadline)
|
||||
));
|
||||
assert_eq!(provider.contexts.lock().expect("contexts").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_retry_keeps_http_fallback_for_the_pinned_account() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, _, provider) = coordinator(vec![
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::Transport,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_pre_delivery_transport_fallback())],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(transient_rejection())],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
block_on(session.collect_uncommitted()).expect("fallback succeeds after backoff");
|
||||
let contexts = provider.contexts.lock().expect("contexts");
|
||||
assert_eq!(contexts.len(), 3);
|
||||
assert_eq!(contexts[2].transport(), AttemptTransport::Fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_retry_is_suppressed_for_required_accounts_and_committed_output() {
|
||||
for committed in [false, true] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let mut items = Vec::new();
|
||||
if committed {
|
||||
items.push(Ok(GatewayEvent::Started(ResponseMeta::new(
|
||||
"response-visible",
|
||||
"gpt-5",
|
||||
))));
|
||||
}
|
||||
items.push(Err(transient_rejection()));
|
||||
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items,
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
(!committed).then(|| ProviderAccountId::new("acct_first").expect("account")),
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
if committed {
|
||||
block_on(session.next_event())
|
||||
.expect("first event")
|
||||
.expect("visible event");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit output");
|
||||
}
|
||||
assert!(matches!(
|
||||
block_on(session.next_event()),
|
||||
Err(EngineError::Provider(_))
|
||||
));
|
||||
assert_eq!(provider.contexts.lock().expect("contexts").len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_backoff_can_be_cancelled_before_the_next_attempt() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(transient_rejection().with_transient_retry(
|
||||
NonZeroU32::MIN,
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(60),
|
||||
))],
|
||||
}]);
|
||||
let cancellation = CancellationToken::new();
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
cancellation.clone(),
|
||||
))
|
||||
.expect("start execution");
|
||||
let cancel = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
cancellation.cancel();
|
||||
});
|
||||
assert!(matches!(
|
||||
block_on(session.collect_uncommitted()),
|
||||
Err(EngineError::Cancelled)
|
||||
));
|
||||
cancel.join().expect("cancel task");
|
||||
assert_eq!(provider.contexts.lock().expect("contexts").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_capacity_exhaustion_returns_the_last_retryable_upstream_failure() {
|
||||
let operation = generate_operation();
|
||||
|
||||
@@ -863,6 +863,9 @@ fn only_provider_attributable_failures_should_affect_circuit() {
|
||||
assert!(!provider_failure_affects_circuit(
|
||||
ProviderErrorKind::ContinuationRecoveryRequired
|
||||
));
|
||||
assert!(!provider_failure_affects_circuit(
|
||||
ProviderErrorKind::UpstreamCapacityUnavailable
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -996,6 +999,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() {
|
||||
|
||||
@@ -13,6 +13,7 @@ async-trait = "0.1.89"
|
||||
base64 = "0.23.0"
|
||||
bytes = "1.11.1"
|
||||
chrono = { version = "0.4.45", features = ["clock", "serde"] }
|
||||
chrono-tz = "0.10"
|
||||
cookie = "0.18.1"
|
||||
futures = "0.3.32"
|
||||
flate2 = "1.1.9"
|
||||
@@ -22,27 +23,30 @@ gateway-protocol = { path = "../../gateway-protocol" }
|
||||
getrandom = "0.4.3"
|
||||
hex = "0.4.3"
|
||||
# TLS 依赖按官方 Core 锁文件固定,包含用于约束传递依赖的直接声明。
|
||||
# hyper 受 workspace 的 SOCKS IPv6 修复补丁约束,保留补丁要求的兼容版本。
|
||||
# 升级前须重复采样 ClientHello,核对算法、扩展集合及排序约束、key_share 与 ALPN。
|
||||
aws-lc-rs = "=1.18.1"
|
||||
aws-lc-rs = "=1.16.2"
|
||||
hyper = { version = "=1.11.1", default-features = false }
|
||||
hyper-rustls = "=0.27.7"
|
||||
hyper-util = { version = "0.1.20", features = ["client-legacy", "http1", "tokio"] }
|
||||
# 与官方 Codex Desktop 一致:reqwest 不启用压缩特性,上游请求不带 accept-encoding。
|
||||
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "http2", "socks"] }
|
||||
# 与官方 Codex Desktop 一致:HTTP 默认走 native-tls,rustls 保留给自定义 CA 回退;
|
||||
# vendored OpenSSL 固定 Linux 发布版的 TLS 画像,且不启用压缩特性。
|
||||
native-tls = "=0.2.14"
|
||||
reqwest = { version = "=0.12.28", features = ["json", "stream", "cookies", "rustls-tls-native-roots", "socks"] }
|
||||
roxmltree = "0.21.1"
|
||||
semver = "1.0.27"
|
||||
# 与官方 rustls 路径一致:aws-lc 支持 P-521,优先提供 X25519MLKEM768。
|
||||
# reqwest 的 roots feature 仍会编译 ring;实际 provider 由 TLS 初始化显式选择。
|
||||
rustls = { version = "=0.23.36", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std", "tls12"] }
|
||||
rustls-native-certs = "=0.8.4"
|
||||
rustls-pki-types = "=1.15.1"
|
||||
rustls-native-certs = "=0.8.3"
|
||||
rustls-pki-types = "=1.14.0"
|
||||
secrecy = "0.10.3"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
sha2 = "0.11.0"
|
||||
subtle = "2.6.1"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "=1.53.1", default-features = false, features = ["fs", "macros", "rt", "sync", "time"] }
|
||||
tokio = { version = "=1.52.3", default-features = false, features = ["fs", "macros", "rt", "sync", "time"] }
|
||||
tokio-rustls = { version = "=0.26.4", default-features = false }
|
||||
tokio-util = { version = "0.7.18", features = ["rt"] }
|
||||
tower-service = "0.3.3"
|
||||
@@ -56,6 +60,10 @@ uuid = { version = "1.23.3", features = ["v4", "v7"] }
|
||||
# 并携带 Content-Encoding: zstd;保持同版本同级别以对齐上游指纹。
|
||||
zstd = "=0.13.3"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
openssl = "=0.10.75"
|
||||
openssl-sys = { version = "=0.9.111", features = ["vendored"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-thread", "test-util"] }
|
||||
|
||||
@@ -24,7 +24,7 @@ use gateway_admin::model::provider_credentials::{
|
||||
ProviderProfileAvatar, ProviderProfileAvatarStreamError, ProviderProfileDailyUsage,
|
||||
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
|
||||
ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole,
|
||||
ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
|
||||
ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription,
|
||||
QuotaLocalUsageAttribution,
|
||||
};
|
||||
use gateway_admin::model::quota_forecast_sampling::QuotaForecastObservation;
|
||||
@@ -513,6 +513,27 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
})
|
||||
}
|
||||
|
||||
async fn subscription(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
|
||||
self.account(account_id).await?;
|
||||
self.profile_statistics
|
||||
.subscription(account_id)
|
||||
.await
|
||||
.map(|subscription| {
|
||||
subscription.map(|subscription| ProviderSubscription {
|
||||
starts_at: subscription.starts_at,
|
||||
expires_at: subscription.expires_at,
|
||||
will_renew: subscription.will_renew,
|
||||
billing_period: subscription.billing_period,
|
||||
billing_currency: subscription.billing_currency,
|
||||
observed_at: subscription.observed_at,
|
||||
})
|
||||
})
|
||||
.map_err(map_profile_statistics_error)
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::transport::profile::CodexWireProfileState;
|
||||
use crate::transport::subscription::CodexSubscription;
|
||||
use crate::transport::{
|
||||
CodexBackendClient, CodexClientError, CodexProfileAvatar, CodexProfileAvatarFetchError,
|
||||
CodexProfileStatistics, CodexRequestContext, fetch_profile_avatar,
|
||||
@@ -129,6 +130,53 @@ impl CodexCredentialProfileService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 订阅只按需读取;不缓存、不刷新凭据,也不更新额度状态。
|
||||
pub async fn subscription(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<Option<CodexSubscription>, CodexProfileStatisticsError> {
|
||||
let (authorization, upstream_account_id, account) =
|
||||
self.account_authentication(account_id).await?;
|
||||
let Some(upstream_account_id) = upstream_account_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
let request_id = format!("subscription_{}", Uuid::now_v7().simple());
|
||||
let subscription = CodexBackendClient::new(
|
||||
self.http.clone(),
|
||||
self.base_url.clone(),
|
||||
self.profile.clone(),
|
||||
)
|
||||
.for_account(&account)
|
||||
.map_err(map_client_error)?
|
||||
.fetch_subscription(
|
||||
CodexRequestContext::auxiliary(
|
||||
authorization.expose_secret(),
|
||||
Some(&upstream_account_id),
|
||||
&request_id,
|
||||
None,
|
||||
),
|
||||
&upstream_account_id,
|
||||
)
|
||||
.await;
|
||||
// 请求期间重新授权、换绑或删除账号时,丢弃旧身份的结果。
|
||||
let current = self
|
||||
.repository
|
||||
.store()
|
||||
.get_account(account_id)
|
||||
.await
|
||||
.map_err(|error| CodexProfileStatisticsError::Store {
|
||||
detail: error.to_string(),
|
||||
})?;
|
||||
if !current.is_some_and(|current| {
|
||||
current.provider() == account.provider()
|
||||
&& current.revision() == account.revision()
|
||||
&& current.upstream_account_id() == Some(upstream_account_id.as_str())
|
||||
}) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(subscription)
|
||||
}
|
||||
|
||||
pub async fn profile_statistics(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
|
||||
@@ -320,7 +320,6 @@ impl OpenAiTokenClient {
|
||||
return Ok(self.clone());
|
||||
};
|
||||
let builder = Client::builder()
|
||||
.use_rustls_tls()
|
||||
.no_proxy()
|
||||
.proxy(reqwest::Proxy::all(proxy.expose_url()).map_err(|_| TokenClientBuildError)?)
|
||||
.redirect(Policy::none())
|
||||
@@ -418,7 +417,6 @@ pub fn openai_token_client(
|
||||
) -> Result<OpenAiTokenClient, TokenClientBuildError> {
|
||||
ensure_rustls_provider();
|
||||
let builder = Client::builder()
|
||||
.use_rustls_tls()
|
||||
.no_proxy()
|
||||
.redirect(Policy::none())
|
||||
.connect_timeout(TOKEN_CONNECT_TIMEOUT)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
//! OpenAI 上游失败分类、恢复决策与稳定错误投影。
|
||||
|
||||
use super::*;
|
||||
use crate::transport::diagnostics::is_capacity_error;
|
||||
|
||||
/// OpenAI 失败对 Smart 账号分数的结构化 reason 闭集。
|
||||
///
|
||||
/// 已归一的上游 code 优先;code 缺失时才读取结构化客户端错误的 code/type。
|
||||
/// 新增错误、裸 HTTP 状态和内部错误 kind 默认都不会进入该闭集。
|
||||
const OPENAI_ACCOUNT_SCORE_FAILURE_REASONS: &[&str] = &[
|
||||
"server_is_overloaded",
|
||||
"slow_down",
|
||||
"rate_limit_exceeded",
|
||||
"rate_limit_error",
|
||||
"server_error",
|
||||
@@ -33,6 +32,17 @@ fn openai_account_score_failure_reason(error: &ProviderError) -> Option<&str> {
|
||||
/// 返回该 OpenAI 失败是否属于 Smart 账号计分闭集。
|
||||
#[doc(hidden)]
|
||||
pub fn openai_failure_affects_account_score(error: &ProviderError) -> bool {
|
||||
let client_error = error.client_visible_upstream_error();
|
||||
if is_capacity_error(
|
||||
error
|
||||
.upstream_code()
|
||||
.map(OpaqueUpstreamValue::as_str)
|
||||
.or_else(|| client_error.and_then(ClientVisibleUpstreamError::code)),
|
||||
client_error.and_then(ClientVisibleUpstreamError::error_type),
|
||||
client_error.map(ClientVisibleUpstreamError::message),
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
openai_account_score_failure_reason(error).is_some_and(is_openai_account_score_failure_reason)
|
||||
}
|
||||
|
||||
@@ -506,6 +516,30 @@ pub(super) fn apply_websocket_recovery_policy(
|
||||
failure: &mut MappedProviderFailure,
|
||||
context: WebSocketRecoveryContext<'_>,
|
||||
) {
|
||||
// 明确账号拒绝走已有换号路径,容量拒绝走请求内退避;两者都不消耗 WS 传输预算。
|
||||
if failure.error.replay_is_safe()
|
||||
&& (failure.account_failure.is_some()
|
||||
|| matches!(
|
||||
failure.error.pre_delivery_retry(),
|
||||
Some(gateway_core::error::PreDeliveryRetry::SameAccountTransientRetry { .. })
|
||||
))
|
||||
{
|
||||
tracing::warn!(
|
||||
request_id = context.request_id,
|
||||
attempt_index = context.attempt_index,
|
||||
account_id = context.account_id,
|
||||
websocket_failure_kind = failure.error.kind().as_str(),
|
||||
websocket_failure_code = failure
|
||||
.error
|
||||
.upstream_code()
|
||||
.map_or("", OpaqueUpstreamValue::as_str),
|
||||
upstream_status_code = failure.error.upstream_status().unwrap_or_default(),
|
||||
upstream_status_code_present = failure.error.upstream_status().is_some(),
|
||||
transport_requirement = context.requirement.as_str(),
|
||||
"OpenAI upstream WebSocket returned a business rejection; deferring to request retry"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let session_budget_exhausted = context.session_affinity_key.is_some_and(|key| {
|
||||
context
|
||||
.session_transport_recovery
|
||||
@@ -1134,6 +1168,7 @@ pub(super) fn map_upstream_failure(
|
||||
replay_boundary: ReplayBoundary,
|
||||
) -> MappedProviderFailure {
|
||||
let category = failure.category();
|
||||
let capacity_unavailable = category == CodexFailureCategory::CapacityUnavailable;
|
||||
let cyber_policy_failure = failure
|
||||
.status
|
||||
.is_some_and(|status| status.is_client_error())
|
||||
@@ -1193,6 +1228,17 @@ pub(super) fn map_upstream_failure(
|
||||
if let Some(retry_after) = failure.retry_after_seconds.map(Duration::from_secs) {
|
||||
error = error.with_retry_after(retry_after);
|
||||
}
|
||||
if capacity_unavailable && error.replay_is_safe() {
|
||||
let max_delay = Duration::from_secs(8);
|
||||
error = error.with_transient_retry(
|
||||
NonZeroU32::new(3).unwrap_or(NonZeroU32::MIN),
|
||||
failure
|
||||
.retry_after_seconds
|
||||
.map_or(Duration::from_millis(500), Duration::from_secs)
|
||||
.min(max_delay),
|
||||
max_delay,
|
||||
);
|
||||
}
|
||||
if let Some(code) = failure.persistable_code() {
|
||||
error = error.with_upstream_code(OpaqueUpstreamValue::new(code.to_owned()));
|
||||
}
|
||||
@@ -1260,6 +1306,7 @@ pub(super) const fn provider_error_kind(category: CodexFailureCategory) -> Provi
|
||||
CodexFailureCategory::CloudflareChallenge
|
||||
| CodexFailureCategory::CloudflarePathBlocked
|
||||
| CodexFailureCategory::Unavailable => ProviderErrorKind::Unavailable,
|
||||
CodexFailureCategory::CapacityUnavailable => ProviderErrorKind::UpstreamCapacityUnavailable,
|
||||
CodexFailureCategory::InvalidRequest => ProviderErrorKind::InvalidRequest,
|
||||
CodexFailureCategory::PermissionDenied => ProviderErrorKind::PermissionDenied,
|
||||
CodexFailureCategory::Timeout => ProviderErrorKind::Timeout,
|
||||
@@ -1311,6 +1358,7 @@ pub(super) fn account_failure(
|
||||
| CodexFailureCategory::InvalidRequest
|
||||
| CodexFailureCategory::PermissionDenied
|
||||
| CodexFailureCategory::Timeout
|
||||
| CodexFailureCategory::CapacityUnavailable
|
||||
| CodexFailureCategory::Unavailable
|
||||
| CodexFailureCategory::Transport => None,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -117,8 +117,9 @@ impl OpenAiResponseObservationState {
|
||||
if let Some(metadata) = self.provider_metadata() {
|
||||
observation = observation.with_provider_metadata(metadata);
|
||||
}
|
||||
if let Some(service_tier) = self.effective_service_tier() {
|
||||
observation = observation.with_service_tier_if_valid(service_tier.to_owned());
|
||||
// 统计与计费采用最终发送档位;响应回显只作为独立诊断事实保留。
|
||||
if let Some(service_tier) = &self.requested_service_tier {
|
||||
observation = observation.with_service_tier_if_valid(service_tier.clone());
|
||||
}
|
||||
Some(observation)
|
||||
}
|
||||
@@ -161,12 +162,6 @@ impl OpenAiResponseObservationState {
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn effective_service_tier(&self) -> Option<&str> {
|
||||
self.upstream_service_tier
|
||||
.as_deref()
|
||||
.or(self.requested_service_tier.as_deref())
|
||||
}
|
||||
|
||||
pub(super) fn merge_rate_limit_headers(&mut self, updates: &[(String, String)]) -> bool {
|
||||
let mut changed = false;
|
||||
for (name, value) in selected_observation_headers(updates) {
|
||||
@@ -615,6 +610,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ impl CodexCanonicalDecoder {
|
||||
self
|
||||
}
|
||||
|
||||
/// 响应未报告实际档位时,才使用请求档位估算费用。
|
||||
/// 使用最终发送给上游的请求档位估算费用,不由响应回显覆盖。
|
||||
#[must_use]
|
||||
pub fn with_requested_service_tier(mut self, service_tier: Option<&str>) -> Self {
|
||||
self.requested_service_tier = normalize_service_tier(service_tier);
|
||||
@@ -217,7 +217,7 @@ impl CodexCanonicalDecoder {
|
||||
std::mem::take(&mut self.timing_signals)
|
||||
}
|
||||
|
||||
/// 返回本 attempt 已从上游响应生命周期帧观察到的实际服务档位。
|
||||
/// 返回本 attempt 的上游响应回显档位,仅供诊断,不作为本地计费依据。
|
||||
#[must_use]
|
||||
pub fn response_service_tier(&self) -> Option<&str> {
|
||||
self.response_service_tier.as_deref()
|
||||
@@ -861,10 +861,8 @@ impl CodexCanonicalDecoder {
|
||||
.filter(|model| !model.is_empty())
|
||||
.unwrap_or(&self.fallback_model)
|
||||
.to_owned();
|
||||
let service_tier = self
|
||||
.response_service_tier
|
||||
.as_deref()
|
||||
.or(self.requested_service_tier.as_deref());
|
||||
// 与用量统计统一按最终发送档位估算,响应回显不改变本地计价口径。
|
||||
let service_tier = self.requested_service_tier.as_deref();
|
||||
let tool_calls = billable_tool_calls(response);
|
||||
if let Some(breakdown) = usage
|
||||
.filter(|usage| billable_usage_is_complete(response, *usage))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ pub fn build_account_http_client(
|
||||
}
|
||||
|
||||
let mut builder = Client::builder()
|
||||
.use_rustls_tls()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.pool_max_idle_per_host(4)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -102,6 +102,8 @@ pub enum CodexFailureCategory {
|
||||
InvalidRequest,
|
||||
PermissionDenied,
|
||||
Timeout,
|
||||
/// 模型容量暂时不足,只影响当前请求,不证明账号健康异常。
|
||||
CapacityUnavailable,
|
||||
Unavailable,
|
||||
Transport,
|
||||
}
|
||||
@@ -243,6 +245,7 @@ impl CodexUpstreamFailure {
|
||||
| CodexFailureCategory::QuotaExhausted
|
||||
| CodexFailureCategory::CloudflareChallenge
|
||||
| CodexFailureCategory::CloudflarePathBlocked
|
||||
| CodexFailureCategory::CapacityUnavailable
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -428,6 +431,15 @@ fn classify_upstream_failure(
|
||||
let message = fields.message.to_ascii_lowercase();
|
||||
let body = body.to_ascii_lowercase();
|
||||
|
||||
// 容量拒绝可能带 400/429/503;仅用结构化错误字段识别,不能扫描任意正文。
|
||||
if is_capacity_error(
|
||||
fields.code.as_deref(),
|
||||
fields.error_type.as_deref(),
|
||||
fields.client_message.as_deref(),
|
||||
) {
|
||||
return CodexFailureCategory::CapacityUnavailable;
|
||||
}
|
||||
|
||||
// 与官方 Codex HTTP/WS 路径一致:429 只有结构化
|
||||
// `error.type=usage_limit_reached` 才能确认额度窗口耗尽;其余 429 都是临时限流。
|
||||
// SSE `response.failed` 的结构化字段形态不同,保留其 code/type 语义单独分类。
|
||||
@@ -523,9 +535,6 @@ fn classify_upstream_failure(
|
||||
if status == Some(StatusCode::TOO_MANY_REQUESTS) {
|
||||
return CodexFailureCategory::RateLimited;
|
||||
}
|
||||
if is_upstream_overload(&code) || is_upstream_overload(&message) {
|
||||
return CodexFailureCategory::Unavailable;
|
||||
}
|
||||
match status.map(|status| status.as_u16()) {
|
||||
Some(status) => match status {
|
||||
400 | 404 | 409 | 422 => CodexFailureCategory::InvalidRequest,
|
||||
@@ -662,6 +671,40 @@ fn is_cloudflare_challenge(value: &str) -> bool {
|
||||
|| value.contains("just a moment")
|
||||
}
|
||||
|
||||
fn is_upstream_overload(value: &str) -> bool {
|
||||
matches!(value, "server_is_overloaded" | "slow_down") || value.contains("server_overloaded")
|
||||
pub(crate) fn is_capacity_error(
|
||||
code: Option<&str>,
|
||||
error_type: Option<&str>,
|
||||
message: Option<&str>,
|
||||
) -> bool {
|
||||
let code = normalized(code);
|
||||
let error_type = normalized(error_type);
|
||||
// 明确额度窗口/账号错误保留原分类,不能被描述中的容量词覆盖。
|
||||
if !matches!(
|
||||
error_type.as_str(),
|
||||
"" | "server_error"
|
||||
| "service_unavailable_error"
|
||||
| "invalid_request_error"
|
||||
| "rate_limit_error"
|
||||
| "server_is_overloaded"
|
||||
| "slow_down"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if matches!(code.as_str(), "server_is_overloaded" | "slow_down") {
|
||||
return true;
|
||||
}
|
||||
if !matches!(
|
||||
code.as_str(),
|
||||
"" | "server_error" | "service_unavailable_error"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if matches!(error_type.as_str(), "server_is_overloaded" | "slow_down") {
|
||||
return true;
|
||||
}
|
||||
let message = normalized(message);
|
||||
matches!(message.as_str(), "server_is_overloaded" | "slow_down")
|
||||
|| message.contains("selected model is at capacity")
|
||||
|| message.contains("server is overloaded")
|
||||
|| message.contains("server_overloaded")
|
||||
}
|
||||
|
||||
@@ -254,5 +254,32 @@ fn header_pairs(headers: &HeaderMap) -> Vec<(String, String)> {
|
||||
}
|
||||
|
||||
pub(super) fn websocket_header_pairs(headers: &HeaderMap) -> Vec<(String, String)> {
|
||||
header_pairs(headers)
|
||||
let mut pairs = header_pairs(headers);
|
||||
// WebSocket opening 的 HeaderMap 先由业务头构造,再被 tungstenite 插入协议头;
|
||||
// 这里复现官方 HeaderMap 交给 tungstenite 时的迭代顺序,最终序列化后的
|
||||
// 线级顺序由 fingerprint 测试锁定。未知扩展头仍保持相对顺序。
|
||||
pairs.sort_by_key(|(name, _)| websocket_header_order(name));
|
||||
pairs
|
||||
}
|
||||
|
||||
fn websocket_header_order(name: &str) -> usize {
|
||||
const OFFICIAL_INSERTION_ORDER: &[&str] = &[
|
||||
"version",
|
||||
"x-codex-beta-features",
|
||||
"x-client-request-id",
|
||||
"session-id",
|
||||
"thread-id",
|
||||
"x-codex-window-id",
|
||||
"x-codex-turn-metadata",
|
||||
"x-codex-routing-hint",
|
||||
"openai-beta",
|
||||
"originator",
|
||||
"user-agent",
|
||||
"authorization",
|
||||
"chatgpt-account-id",
|
||||
];
|
||||
OFFICIAL_INSERTION_ORDER
|
||||
.iter()
|
||||
.position(|candidate| name.eq_ignore_ascii_case(candidate))
|
||||
.unwrap_or(OFFICIAL_INSERTION_ORDER.len())
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod request;
|
||||
pub mod reset_credits;
|
||||
mod response_meta;
|
||||
pub(crate) mod session;
|
||||
pub mod subscription;
|
||||
mod time;
|
||||
pub mod tls;
|
||||
pub mod usage;
|
||||
|
||||
@@ -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 隔离所用的子代理区分值。
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
use std::io;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono_tz::America::New_York;
|
||||
use gateway_core::operation::GenerateRequest;
|
||||
use gateway_protocol::openai::{
|
||||
WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY, is_transport_managed_request_header,
|
||||
};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use roxmltree::Document;
|
||||
use serde::Serialize as _;
|
||||
use serde_json::{Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -20,6 +23,12 @@ const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
|
||||
const TURN_ID_CLIENT_METADATA_KEY: &str = "turn_id";
|
||||
const THREAD_SPAWN_SUBAGENT_KIND: &str = "thread_spawn";
|
||||
const THREAD_SPAWN_CONVERSATION_PREFIX: &str = "thread-spawn:";
|
||||
const ENVIRONMENT_CONTEXT_CONTENT_KIND: &str = "environments.environment_context";
|
||||
// 请求中的地区画像以 PORTS 所在的 Piketon 为基准;epoch 时间戳仍保持绝对时间原值。
|
||||
const TARGET_COUNTRY: &str = "US";
|
||||
const TARGET_REGION: &str = "Ohio";
|
||||
const TARGET_CITY: &str = "Piketon";
|
||||
const TARGET_TIMEZONE: &str = "America/New_York";
|
||||
const UNSUPPORTED_CODEX_RESPONSES_FIELDS: &[&str] = &["max_output_tokens", "temperature"];
|
||||
|
||||
const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[
|
||||
@@ -113,6 +122,128 @@ fn adapt_codex_responses_body(body: &mut Map<String, Value>, upstream_model: &st
|
||||
for field in UNSUPPORTED_CODEX_RESPONSES_FIELDS {
|
||||
body.remove(*field);
|
||||
}
|
||||
align_structured_location_fields(body, Utc::now());
|
||||
}
|
||||
|
||||
fn align_structured_location_fields(body: &mut Map<String, Value>, now: DateTime<Utc>) {
|
||||
let current_date = now.with_timezone(&New_York).format("%Y-%m-%d").to_string();
|
||||
if let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) {
|
||||
for item in input {
|
||||
align_environment_context(item, ¤t_date);
|
||||
}
|
||||
}
|
||||
if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) {
|
||||
for tool in tools {
|
||||
align_web_search_location(tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn align_environment_context(item: &mut Value, current_date: &str) {
|
||||
let Some(item) = item.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
if item.get("role").and_then(Value::as_str) != Some("user") {
|
||||
return;
|
||||
}
|
||||
let content_kinds = item
|
||||
.get("internal_chat_message_metadata_passthrough")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get("content_item_kinds"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|kinds| {
|
||||
kinds
|
||||
.iter()
|
||||
.map(|kind| kind.as_str().map(str::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let Some(content) = item.get_mut("content").and_then(Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
for (index, part) in content.iter_mut().enumerate() {
|
||||
if content_kinds
|
||||
.as_ref()
|
||||
.and_then(|kinds| kinds.get(index))
|
||||
.and_then(Option::as_deref)
|
||||
!= Some(ENVIRONMENT_CONTEXT_CONTENT_KIND)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(part) = part.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
if part.get("type").and_then(Value::as_str) != Some("input_text") {
|
||||
continue;
|
||||
}
|
||||
let Some(Value::String(text)) = part.get_mut("text") else {
|
||||
continue;
|
||||
};
|
||||
if let Some(aligned) = aligned_environment_context(text, current_date) {
|
||||
*text = aligned;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn aligned_environment_context(text: &str, current_date: &str) -> Option<String> {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.starts_with("<environment_context>") || !trimmed.ends_with("</environment_context>")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let document = Document::parse(text).ok()?;
|
||||
let root = document.root_element();
|
||||
if !root.has_tag_name("environment_context") {
|
||||
return None;
|
||||
}
|
||||
let mut replacements = root
|
||||
.children()
|
||||
.filter(|node| node.is_element())
|
||||
.filter_map(|node| {
|
||||
let replacement = match node.tag_name().name() {
|
||||
"current_date" => format!("<current_date>{current_date}</current_date>"),
|
||||
"timezone" => format!("<timezone>{TARGET_TIMEZONE}</timezone>"),
|
||||
_ => return None,
|
||||
};
|
||||
Some((node.range(), replacement))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if replacements.is_empty() {
|
||||
return None;
|
||||
}
|
||||
replacements.sort_unstable_by_key(|(range, _)| std::cmp::Reverse(range.start));
|
||||
let mut aligned = text.to_owned();
|
||||
for (range, replacement) in replacements {
|
||||
aligned.replace_range(range, &replacement);
|
||||
}
|
||||
Some(aligned)
|
||||
}
|
||||
|
||||
fn align_web_search_location(tool: &mut Value) {
|
||||
let Some(tool) = tool.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(tool_type) = tool.get("type").and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
if tool_type != "web_search" && !tool_type.starts_with("web_search_") {
|
||||
return;
|
||||
}
|
||||
tool.insert(
|
||||
"user_location".to_owned(),
|
||||
Value::Object(Map::from_iter([
|
||||
("type".to_owned(), Value::String("approximate".to_owned())),
|
||||
(
|
||||
"country".to_owned(),
|
||||
Value::String(TARGET_COUNTRY.to_owned()),
|
||||
),
|
||||
("region".to_owned(), Value::String(TARGET_REGION.to_owned())),
|
||||
("city".to_owned(), Value::String(TARGET_CITY.to_owned())),
|
||||
(
|
||||
"timezone".to_owned(),
|
||||
Value::String(TARGET_TIMEZONE.to_owned()),
|
||||
),
|
||||
])),
|
||||
);
|
||||
}
|
||||
|
||||
fn extract_request_context(request: &mut CodexResponsesRequest) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! 当前绑定账号的订阅周期安全投影,与令牌有效期和额度状态无关。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::{CodexBackendClient, CodexRequestContext, client::read_capped_response_body};
|
||||
|
||||
const MAX_SUBSCRIPTION_BODY_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexSubscription {
|
||||
pub starts_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub will_renew: Option<bool>,
|
||||
pub billing_period: Option<String>,
|
||||
pub billing_currency: Option<String>,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl CodexBackendClient {
|
||||
/// 可选展示查询失败只返回未知,不改变凭据或额度状态,也不重试。
|
||||
pub async fn fetch_subscription(
|
||||
&self,
|
||||
context: CodexRequestContext<'_>,
|
||||
account_id: &str,
|
||||
) -> Option<CodexSubscription> {
|
||||
if account_id.is_empty() || account_id.len() > 512 {
|
||||
return None;
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let mut url = reqwest::Url::parse(&self.base_url).ok()?;
|
||||
let base_path = url.path().trim_end_matches('/');
|
||||
let subscription_path = if base_path.ends_with("/backend-api") {
|
||||
format!("{base_path}/subscriptions")
|
||||
} else {
|
||||
"/backend-api/subscriptions".to_owned()
|
||||
};
|
||||
url.set_path(&subscription_path);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
url.query_pairs_mut().append_pair("account_id", account_id);
|
||||
let origin = url.origin().ascii_serialization();
|
||||
let headers = self.account_request_headers(context).ok()?;
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(headers)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::ORIGIN, &origin)
|
||||
.header(reqwest::header::REFERER, format!("{origin}/"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body = read_capped_response_body(response, MAX_SUBSCRIPTION_BODY_BYTES)
|
||||
.await
|
||||
.ok()?;
|
||||
if body.limit_exceeded() {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(&body.into_string()).ok()?;
|
||||
let expires_at =
|
||||
DateTime::parse_from_rfc3339(value.get("active_until")?.as_str()?.trim())
|
||||
.ok()?
|
||||
.with_timezone(&Utc);
|
||||
Some(CodexSubscription {
|
||||
starts_at: value
|
||||
.get("active_start")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value.trim()).ok())
|
||||
.map(|value| value.with_timezone(&Utc)),
|
||||
expires_at,
|
||||
will_renew: value.get("will_renew").and_then(serde_json::Value::as_bool),
|
||||
billing_period: optional_short_text(&value, "billing_period"),
|
||||
billing_currency: optional_short_text(&value, "billing_currency"),
|
||||
observed_at: Utc::now(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_short_text(value: &serde_json::Value, key: &str) -> Option<String> {
|
||||
value
|
||||
.get(key)?
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| {
|
||||
!value.is_empty() && value.len() <= 64 && !value.chars().any(char::is_control)
|
||||
})
|
||||
.map(str::to_owned)
|
||||
}
|
||||
@@ -222,6 +222,8 @@ struct PricingRule {
|
||||
// 价格来源:https://developers.openai.com/api/docs/pricing,核验日期 2026-09-09。
|
||||
// 使用常规价,不采用 Sol 的临时 $4/$20 优惠;缓存、Flex、Fast 和长上下文
|
||||
// 档位也统一按常规价计算。
|
||||
// 已按 https://developers.openai.com/api/docs/deprecations 核验至 2026-09-13,
|
||||
// 移除已关闭的型号;仅宣布弃用但尚未到关闭日期的型号继续保留。
|
||||
const PRICING_RULES: &[PricingRule] = &[
|
||||
// Astra:https://developers.openai.com/api/docs/models/gpt-6-astra
|
||||
// 已于 2026-09-09 对照官方价目表核验。
|
||||
@@ -428,14 +430,6 @@ const PRICING_RULES: &[PricingRule] = &[
|
||||
},
|
||||
// 各变体独立采用已公开的价格与档位,不能继承父型号的所有档位。
|
||||
// Cyber 长上下文的官方来源存在差异,该区间暂不估价。
|
||||
PricingRule {
|
||||
model: "gpt-5.1-codex-mini",
|
||||
pricing: ModelPricing::new(2_500, 20_000, 250),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.3-chat-latest",
|
||||
pricing: ModelPricing::new(17_500, 140_000, 1_750),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.6-cyber",
|
||||
pricing: ModelPricing::new(125_000, 750_000, 12_500)
|
||||
@@ -450,34 +444,6 @@ const PRICING_RULES: &[PricingRule] = &[
|
||||
model: "chat-latest",
|
||||
pricing: ModelPricing::new(50_000, 300_000, 5_000),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5-codex",
|
||||
pricing: ModelPricing::new(12_500, 100_000, 1_250),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.1-codex",
|
||||
pricing: ModelPricing::new(12_500, 100_000, 1_250),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.1-codex-max",
|
||||
pricing: ModelPricing::new(12_500, 100_000, 1_250),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5-chat-latest",
|
||||
pricing: ModelPricing::new(12_500, 100_000, 1_250),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.1-chat-latest",
|
||||
pricing: ModelPricing::new(12_500, 100_000, 1_250),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.2-codex",
|
||||
pricing: ModelPricing::new(17_500, 140_000, 1_750),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.2-chat-latest",
|
||||
pricing: ModelPricing::new(17_500, 140_000, 1_750),
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -784,7 +750,6 @@ fn normalize_model_name(model: &str) -> String {
|
||||
fn pricing_model_name(model: &str) -> &str {
|
||||
match model {
|
||||
"gpt-3.5-turbo-0125" => "gpt-3.5-turbo",
|
||||
"gpt-4-0314" => "gpt-4",
|
||||
"gpt-4-0613" => "gpt-4",
|
||||
"gpt-4-turbo-2024-04-09" => "gpt-4-turbo",
|
||||
"gpt-4.1-2025-04-14" => "gpt-4.1",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -6,7 +6,7 @@ use gateway_core::account::ProviderAccountId;
|
||||
use provider_openai::credential::{CodexCredentialProfileService, ImportCodexOAuthCredential};
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::matchers::{header, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use crate::support::{MemoryAccountStore, profile, secret};
|
||||
@@ -59,6 +59,11 @@ async fn service(
|
||||
#[tokio::test]
|
||||
async fn profile_statistics_uses_one_profile_request_and_preserves_official_fields() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/backend-api/subscriptions"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/profiles/me"))
|
||||
.and(header(
|
||||
@@ -227,3 +232,66 @@ async fn profile_avatar_reuses_cached_source_with_the_accounts_rotated_token() {
|
||||
"chatgpt-acct_profile_avatar"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_is_read_only_and_makes_one_bound_account_request_per_query() {
|
||||
let server = MockServer::start().await;
|
||||
let (store, service) = service("acct_subscription", &server).await;
|
||||
let account = store.account("acct_subscription").expect("account");
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/subscriptions"))
|
||||
.and(query_param("account_id", "chatgpt-acct_subscription"))
|
||||
.and(header("chatgpt-account-id", "chatgpt-acct_subscription"))
|
||||
.and(header("authorization", "Bearer token-acct_subscription"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"active_until": "2026-10-01T00:00:00Z", "will_renew": true
|
||||
})))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
for _ in 0..2 {
|
||||
let subscription = service.subscription(account.id()).await.unwrap().unwrap();
|
||||
assert_eq!(subscription.will_renew, Some(true));
|
||||
}
|
||||
let after = store.account("acct_subscription").expect("account");
|
||||
assert_eq!(after.revision(), account.revision());
|
||||
assert_eq!(after.quota(), account.quota());
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_discards_response_when_credential_rotates_during_query() {
|
||||
let server = MockServer::start().await;
|
||||
let (store, service) = service("acct_subscription_rotation", &server).await;
|
||||
let account = store
|
||||
.account("acct_subscription_rotation")
|
||||
.expect("account");
|
||||
let requested = Arc::new(tokio::sync::Notify::new());
|
||||
let notify = Arc::clone(&requested);
|
||||
Mock::given(path("/backend-api/subscriptions"))
|
||||
.respond_with(move |_: &wiremock::Request| {
|
||||
notify.notify_one();
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(json!({"active_until": "2026-10-01T00:00:00Z"}))
|
||||
.set_delay(std::time::Duration::from_millis(100))
|
||||
})
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (result, ()) = tokio::join!(service.subscription(account.id()), async {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(3), requested.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.repository()
|
||||
.rotate_refreshed_oauth_secret(
|
||||
&account,
|
||||
secret("rotated-subscription-token"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ mod refresh_timing;
|
||||
mod scheduling;
|
||||
mod slots;
|
||||
mod snapshot;
|
||||
mod subscription;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! 订阅展示不得随手工或后台额度检查预取。
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_refresh_and_background_synchronize_do_not_query_subscription() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_quota_without_subscription").await;
|
||||
let account = store.account("acct_quota_without_subscription").unwrap();
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/backend-api/subscriptions"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"primary_window": { "used_percent": 20, "limit_window_seconds": 18000 }
|
||||
}
|
||||
})))
|
||||
.expect(2..)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
service.synchronize().await.unwrap();
|
||||
service.refresh_account(account.id()).await.unwrap();
|
||||
assert!(
|
||||
server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|request| request.url.path() == "/api/codex/usage")
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -465,6 +465,8 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
|
||||
"acct_websocket_busy_replay",
|
||||
"acct_websocket_metadata_close",
|
||||
"acct_websocket_turn_state",
|
||||
"acct_ws_quota_a",
|
||||
"acct_ws_quota_b",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
@@ -3124,6 +3126,60 @@ async fn downstream_websocket_new_chain_should_override_session_and_attempt_http
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_opening_account_rejection_keeps_replay_safe_without_transport_retry() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_ws_quota_a").await;
|
||||
create_account(&store, "acct_ws_quota_b").await;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut opening, _) = listener.accept().await.unwrap();
|
||||
let request = capture_http_request(&mut opening).await;
|
||||
assert!(String::from_utf8_lossy(&request).starts_with("GET /codex/responses"));
|
||||
// 额度耗尽拒绝通常携带小时级的 retry-after;同账号重试注定再次命中。
|
||||
let body = r#"{"error":{"message":"You have reached your usage limit.","type":"rate_limit_error"}}"#;
|
||||
opening
|
||||
.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 429 Too Many Requests\r\nretry-after: 129600\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let provider = provider_with_base_url(&store, base_url);
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
planned_request("openai", generate_operation()),
|
||||
context("req_ws_quota_rotation", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare quota-rejected stream");
|
||||
let error = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("quota rejection must surface as a terminal error"),
|
||||
}
|
||||
};
|
||||
server.await.expect("upstream server");
|
||||
|
||||
assert_eq!(error.kind(), ProviderErrorKind::RateLimited);
|
||||
assert_eq!(error.upstream_status(), Some(429));
|
||||
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
|
||||
assert!(
|
||||
error.replay_is_safe(),
|
||||
"before-payload rejection is replay safe"
|
||||
);
|
||||
// 回放安全的账号级拒绝必须把换号决策留给 Core,不得钉死同账号传输重试
|
||||
// (旧行为会携带小时级 retry-after 的同账号重试标记,请求必然超时)。
|
||||
assert_eq!(error.pre_delivery_retry(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_turn_state_metadata_is_exposed_through_response_observation() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -4843,10 +4899,157 @@ async fn completed_response_persists_session_affinity_before_stream_consumer_sto
|
||||
drop(stream);
|
||||
|
||||
assert_eq!(affinity.binding_count(), 1);
|
||||
assert_eq!(observed_service_tier.as_deref(), Some("default"));
|
||||
assert_eq!(observed_service_tier.as_deref(), Some("priority"));
|
||||
assert_eq!(upstream_service_tier.as_deref(), Some("default"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_should_observe_and_bill_the_outbound_service_tier_on_both_transports() {
|
||||
for use_websocket in [false, true] {
|
||||
for (requested, reported, expected_cost) in [
|
||||
(Some("priority"), Some("default"), 6_875_000),
|
||||
(Some("priority"), None, 6_875_000),
|
||||
(Some("default"), Some("priority"), 3_437_500),
|
||||
(None, Some("priority"), 3_437_500),
|
||||
] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let mut created =
|
||||
json!({"type":"response.created","response":{"id":"resp_tier","model":"gpt-5.4"}});
|
||||
let mut completed = json!({
|
||||
"type":"response.completed",
|
||||
"response":{
|
||||
"id":"resp_tier","model":"gpt-5.4","status":"completed","output":[],
|
||||
"usage":{"input_tokens":100,"output_tokens":10,"input_tokens_details":{"cached_tokens":25,"cache_write_tokens":0},"total_tokens":110}
|
||||
}
|
||||
});
|
||||
if let Some(reported) = reported {
|
||||
created["response"]["service_tier"] = json!("auto");
|
||||
completed["response"]["service_tier"] = json!(reported);
|
||||
}
|
||||
let response_frames = format!(
|
||||
"event: response.created\ndata: {created}\n\nevent: response.completed\ndata: {completed}\n\n"
|
||||
);
|
||||
let (base_url, http_server, websocket_server) = if use_websocket {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener");
|
||||
let base_url = format!("http://{}", listener.local_addr().expect("address"));
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.expect("accept WebSocket");
|
||||
let mut websocket = accept_codex_test_websocket(socket).await;
|
||||
let request = websocket.next().await.expect("request").expect("frame");
|
||||
let request: Value = serde_json::from_str(request.to_text().expect("text"))
|
||||
.expect("request JSON");
|
||||
for event in [created, completed] {
|
||||
websocket
|
||||
.send(Message::Text(event.to_string().into()))
|
||||
.await
|
||||
.expect("response");
|
||||
}
|
||||
request
|
||||
});
|
||||
(base_url, None, Some(server))
|
||||
} else {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_raw(response_frames.clone(), "text/event-stream"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
(server.uri(), Some(server), None)
|
||||
};
|
||||
let mut body = Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), json!("hello")),
|
||||
]);
|
||||
if let Some(requested) = requested {
|
||||
body.insert("service_tier".to_owned(), json!(requested));
|
||||
}
|
||||
let payload = ProtocolPayload::json_object("openai", body)
|
||||
.expect("payload")
|
||||
.with_context(Map::from_iter([(
|
||||
"use_websocket".to_owned(),
|
||||
json!(use_websocket),
|
||||
)]));
|
||||
let mut stream = provider_with_base_url(&store, base_url)
|
||||
.execute(
|
||||
planned_request(
|
||||
"openai",
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
|
||||
),
|
||||
context("req_service_tier", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("provider stream");
|
||||
let mut observations = Vec::new();
|
||||
let mut costs = Vec::new();
|
||||
let mut raw_response = Vec::new();
|
||||
while let Some(event) = stream.next().await {
|
||||
let event = event.expect("provider event");
|
||||
if let Some(observation) = event.response_observation() {
|
||||
observations.push(observation.clone());
|
||||
}
|
||||
for fact in event.canonical_facts() {
|
||||
if let GatewayEvent::CalculatedCost(cost) = fact {
|
||||
costs.push(cost.total().amount().scaled());
|
||||
}
|
||||
}
|
||||
if let Some(frame) = event.wire_event().and_then(|wire| wire.raw_sse_frame()) {
|
||||
raw_response.extend_from_slice(frame);
|
||||
}
|
||||
}
|
||||
let outbound = if let Some(server) = http_server {
|
||||
server.verify().await;
|
||||
let requests = server.received_requests().await.expect("upstream requests");
|
||||
captured_request_body(&requests[0])
|
||||
} else {
|
||||
websocket_server
|
||||
.expect("WebSocket server")
|
||||
.await
|
||||
.expect("server task")
|
||||
};
|
||||
assert_eq!(
|
||||
outbound.get("service_tier").and_then(Value::as_str),
|
||||
requested
|
||||
);
|
||||
assert!(!observations.is_empty());
|
||||
for observation in &observations {
|
||||
assert_eq!(
|
||||
observation.service_tier(),
|
||||
requested,
|
||||
"WebSocket={use_websocket}"
|
||||
);
|
||||
}
|
||||
let metadata: Value = serde_json::from_str(
|
||||
observations
|
||||
.last()
|
||||
.expect("final observation")
|
||||
.provider_metadata()
|
||||
.expect("metadata")
|
||||
.as_json(),
|
||||
)
|
||||
.expect("metadata JSON");
|
||||
assert_eq!(
|
||||
metadata.get("requestedServiceTier").and_then(Value::as_str),
|
||||
requested
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.get("upstreamServiceTier").and_then(Value::as_str),
|
||||
reported
|
||||
);
|
||||
assert_eq!(
|
||||
costs,
|
||||
vec![expected_cost],
|
||||
"WebSocket={use_websocket}, requested={requested:?}, reported={reported:?}"
|
||||
);
|
||||
assert_eq!(raw_response, response_frames.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_failed_before_semantic_output_is_atomic_and_persists_quota_lock() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -5094,6 +5297,218 @@ async fn official_usage_limit_failure_persists_fact_without_fabricating_usage()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_http_and_websocket_opening_rejections_use_bounded_business_retry() {
|
||||
for use_websocket in [false, true] {
|
||||
for status in [400, 429, 503] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_provider_contract";
|
||||
create_account(&store, account_id).await;
|
||||
let server = MockServer::start().await;
|
||||
let body = json!({"error": {
|
||||
"code": if status == 503 { Some("server_is_overloaded") } else { None },
|
||||
"type": "server_error",
|
||||
"message": "Selected model is at capacity. Please try a different model.",
|
||||
"extension": {"preserved": true}
|
||||
}});
|
||||
Mock::given(method(if use_websocket { "GET" } else { "POST" }))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(status)
|
||||
.insert_header("retry-after", "129600")
|
||||
.set_body_json(body.clone()),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let operation = if use_websocket {
|
||||
generate_operation()
|
||||
} else {
|
||||
http_generate_operation()
|
||||
};
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", operation),
|
||||
context("req_capacity", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare stream");
|
||||
let error = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("expected capacity rejection"),
|
||||
}
|
||||
};
|
||||
assert_eq!(error.kind(), ProviderErrorKind::UpstreamCapacityUnavailable);
|
||||
assert_eq!(error.upstream_status(), Some(status));
|
||||
assert!(error.replay_is_safe());
|
||||
assert!(
|
||||
matches!(error.pre_delivery_retry(), Some(PreDeliveryRetry::SameAccountTransientRetry {
|
||||
max_retries, initial_delay, max_delay,
|
||||
}) if max_retries.get() == 3 && initial_delay == Duration::from_secs(8) && max_delay == Duration::from_secs(8))
|
||||
);
|
||||
assert!(!provider_openai::openai_failure_affects_account_score(
|
||||
&error
|
||||
));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(
|
||||
error.raw_upstream_error().expect("original error").as_str()
|
||||
)
|
||||
.expect("JSON"),
|
||||
body
|
||||
);
|
||||
let response = error
|
||||
.client_visible_upstream_response()
|
||||
.expect("client response");
|
||||
assert_eq!(response.status(), status);
|
||||
assert_eq!(response.body().as_ref(), body.to_string().as_bytes());
|
||||
let account = store.account(account_id).expect("account");
|
||||
assert_eq!(account.quota().access(), QuotaAccessState::Unknown);
|
||||
assert_eq!(account.credential_state(), CredentialState::Ready);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_usage_limit_rejection_preserves_quota_state_for_account_rotation() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_provider_contract";
|
||||
create_account(&store, account_id).await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "129600")
|
||||
.set_body_json(json!({"error": {"type": "usage_limit_reached", "message": "limit reached", "resets_at": 1_900_000_000}})))
|
||||
.expect(1).mount(&server).await;
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", generate_operation()),
|
||||
context("req_ws_quota", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare stream");
|
||||
let error = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("expected quota rejection"),
|
||||
}
|
||||
};
|
||||
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
|
||||
assert!(error.replay_is_safe());
|
||||
assert_eq!(error.pre_delivery_retry(), None);
|
||||
assert_eq!(error.retry_after(), Some(Duration::from_secs(129_600)));
|
||||
assert_eq!(error.kind(), ProviderErrorKind::QuotaExhausted);
|
||||
assert_eq!(
|
||||
store.account(account_id).expect("account").quota().access(),
|
||||
QuotaAccessState::Exhausted
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_stream_rejection_only_retries_before_semantic_output_on_both_transports() {
|
||||
for use_websocket in [false, true] {
|
||||
for semantic_output in [false, true] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let mut events = vec![
|
||||
json!({"type": "response.created", "response": {"id": "resp_capacity", "model": "gpt-5.4"}}),
|
||||
];
|
||||
if semantic_output {
|
||||
events.push(json!({"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "hello"}));
|
||||
}
|
||||
let original = json!({"type": "response.failed", "response": {"id": "resp_capacity", "error": {"code": "server_is_overloaded", "message": "Selected model is at capacity. Please try a different model."}}});
|
||||
events.push(original.clone());
|
||||
let (base_url, _http_server, websocket_server) = if use_websocket {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener");
|
||||
let base_url = format!("http://{}", listener.local_addr().expect("address"));
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.expect("accept WebSocket");
|
||||
let mut websocket = accept_codex_test_websocket(socket).await;
|
||||
websocket.next().await.expect("request").expect("frame");
|
||||
for event in events {
|
||||
websocket
|
||||
.send(Message::Text(event.to_string().into()))
|
||||
.await
|
||||
.expect("response");
|
||||
}
|
||||
});
|
||||
(base_url, None, Some(server))
|
||||
} else {
|
||||
let server = MockServer::start().await;
|
||||
let frames = events
|
||||
.iter()
|
||||
.map(|event| {
|
||||
format!(
|
||||
"event: {}\ndata: {event}\n\n",
|
||||
event["type"].as_str().expect("type")
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_raw(frames, "text/event-stream"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
(server.uri(), Some(server), None)
|
||||
};
|
||||
let operation = if use_websocket {
|
||||
generate_operation()
|
||||
} else {
|
||||
http_generate_operation()
|
||||
};
|
||||
let mut stream = provider_with_base_url(&store, base_url)
|
||||
.execute(
|
||||
planned_request("openai", operation),
|
||||
context("req_capacity_stream", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare stream");
|
||||
let mut client_events = Vec::new();
|
||||
let mut error = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(event)) => {
|
||||
if event.has_client_event() {
|
||||
client_events.push(event);
|
||||
}
|
||||
}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("expected capacity failure"),
|
||||
}
|
||||
};
|
||||
assert_eq!(error.replay_is_safe(), !semantic_output);
|
||||
assert_eq!(error.kind(), ProviderErrorKind::UpstreamCapacityUnavailable);
|
||||
assert_eq!(error.pre_delivery_retry().is_some(), !semantic_output);
|
||||
assert!(!provider_openai::openai_failure_affects_account_score(
|
||||
&error
|
||||
));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(
|
||||
error.raw_upstream_error().expect("raw error").as_str()
|
||||
)
|
||||
.expect("JSON"),
|
||||
original
|
||||
);
|
||||
if !semantic_output {
|
||||
assert!(client_events.is_empty());
|
||||
}
|
||||
client_events.extend(error.take_atomic_client_events());
|
||||
let wire = client_events
|
||||
.last()
|
||||
.and_then(|event| event.wire_event())
|
||||
.expect("failure wire");
|
||||
assert_eq!(wire.data(), &original);
|
||||
if let Some(server) = websocket_server {
|
||||
server.await.expect("server");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_request_should_hold_created_until_later_failure_can_rotate() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -6113,6 +6528,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 +6722,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]
|
||||
|
||||
@@ -45,17 +45,21 @@ fn deliver_error_with_openai_feedback(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_score_failure_filter_should_include_server_overload_as_a_regular_reason() {
|
||||
let error = sent_error(ProviderErrorKind::Unavailable, Some("server_is_overloaded"));
|
||||
|
||||
assert!(openai_failure_affects_account_score(&error));
|
||||
fn account_score_failure_filter_should_exclude_request_scoped_capacity_errors() {
|
||||
for code in ["server_is_overloaded", "slow_down", "server_error"] {
|
||||
let error = sent_error(ProviderErrorKind::Unavailable, Some(code))
|
||||
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
"Selected model is at capacity. Please try a different model.",
|
||||
Some("server_error".to_owned()),
|
||||
Some("server_error".to_owned()),
|
||||
));
|
||||
assert!(!openai_failure_affects_account_score(&error));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_score_failure_filter_should_accept_the_closed_reason_list() {
|
||||
for code in [
|
||||
"server_is_overloaded",
|
||||
"slow_down",
|
||||
"rate_limit_exceeded",
|
||||
"rate_limit_error",
|
||||
"server_error",
|
||||
@@ -181,7 +185,7 @@ fn openai_feedback_should_not_amplify_repeated_client_configuration_errors() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_feedback_should_score_server_overload_as_one_regular_failure() {
|
||||
fn openai_feedback_should_not_penalize_an_account_for_server_overload() {
|
||||
let feedback = Arc::new(AccountFeedbackStats::default());
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let account = ProviderAccountId::new("acct_overloaded").expect("account");
|
||||
@@ -193,8 +197,5 @@ fn openai_feedback_should_score_server_overload_as_one_regular_failure() {
|
||||
sent_error(ProviderErrorKind::Unavailable, Some("server_is_overloaded")),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
feedback.scheduling_signals(&provider, &account).0,
|
||||
Some(2_000)
|
||||
);
|
||||
assert_eq!(feedback.scheduling_signals(&provider, &account).0, None);
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ fn decoder_should_emit_calculated_cost_for_complete_known_model_usage() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_prefer_response_service_tier_over_requested_tier_for_billing() {
|
||||
fn decoder_should_bill_requested_service_tier_despite_default_response() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_fast_cost\",\"model\":\"gpt-5.4\",\"service_tier\":\"default\"}}\n\n",
|
||||
@@ -236,12 +236,12 @@ fn decoder_should_prefer_response_service_tier_over_requested_tier_for_billing()
|
||||
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
|
||||
event,
|
||||
GatewayEvent::CalculatedCost(cost)
|
||||
if cost.total().amount().scaled() == 3_437_500
|
||||
if cost.total().amount().scaled() == 6_875_000
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_use_response_service_tier_when_request_omits_it() {
|
||||
fn decoder_should_bill_standard_when_request_omits_service_tier() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_response_tier_cost\",\"model\":\"gpt-5.4\",\"service_tier\":\"priority\"}}\n\n",
|
||||
@@ -257,7 +257,7 @@ fn decoder_should_use_response_service_tier_when_request_omits_it() {
|
||||
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
|
||||
event,
|
||||
GatewayEvent::CalculatedCost(cost)
|
||||
if cost.total().amount().scaled() == 6_875_000
|
||||
if cost.total().amount().scaled() == 3_437_500
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ fn decoder_should_fail_closed_for_fixed_block_web_search_content() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_decoder_should_prefer_response_service_tier_over_requested_tier_for_billing() {
|
||||
fn websocket_decoder_should_bill_requested_service_tier_despite_default_response() {
|
||||
let created = websocket_event_to_sse_frame(
|
||||
r#"{"type":"response.created","response":{"id":"resp_ws_fast_cost","model":"gpt-5.4","service_tier":"default"}}"#,
|
||||
)
|
||||
@@ -366,7 +366,7 @@ fn websocket_decoder_should_prefer_response_service_tier_over_requested_tier_for
|
||||
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
|
||||
event,
|
||||
GatewayEvent::CalculatedCost(cost)
|
||||
if cost.total().amount().scaled() == 3_437_500
|
||||
if cost.total().amount().scaled() == 6_875_000
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -719,6 +719,36 @@ fn decoder_should_classify_official_server_overloaded_failure() {
|
||||
assert_failed_event("server_is_overloaded", "server-secret-marker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_failure_preserves_original_wire_and_diagnostics() {
|
||||
for path in ["error", "response.failed"] {
|
||||
let data = if path == "error" {
|
||||
r#"{"type":"error","error":{"code":"slow_down","message":"busy","extra":123456789012345678901234567890},"extension":true}"#
|
||||
} else {
|
||||
r#"{"type":"response.failed","response":{"id":"resp_capacity","error":{"code":"server_is_overloaded","message":"busy","extra":123456789012345678901234567890}},"extension":true}"#
|
||||
};
|
||||
let raw = format!("event: {path}\r\nid: upstream-id\r\nretry: 123\r\ndata: {data}\r\n\r\n");
|
||||
let failure = CodexCanonicalDecoder::new("fallback")
|
||||
.with_raw_sse_passthrough()
|
||||
.push(raw.as_bytes())
|
||||
.expect_err("capacity failure");
|
||||
let wire = failure.events()[0].wire_event().expect("client wire");
|
||||
let expected: serde_json::Value = serde_json::from_str(data).expect("original JSON");
|
||||
assert_eq!(wire.data(), &expected);
|
||||
assert_eq!(wire.sse_id(), Some("upstream-id"));
|
||||
assert_eq!(wire.sse_retry(), Some(123));
|
||||
assert_eq!(
|
||||
wire.raw_sse_frame().map(AsRef::as_ref),
|
||||
Some(raw.as_bytes())
|
||||
);
|
||||
let CodexCanonicalError::Upstream(upstream) = failure.error() else {
|
||||
panic!("typed failure")
|
||||
};
|
||||
assert_eq!(upstream.raw_body(), data);
|
||||
assert_ne!(upstream.upstream_code.as_deref(), Some("server_error"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_classify_official_cyber_policy_as_an_invalid_request() {
|
||||
assert_failed_event("cyber_policy", "policy-secret-marker");
|
||||
@@ -975,14 +1005,19 @@ fn pricing_response_cost(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_should_follow_actual_tier_and_only_fall_back_when_it_is_absent() {
|
||||
fn billing_should_follow_requested_tier_independently_of_response_tier() {
|
||||
for (requested, actual, expected) in [
|
||||
(Some("fast"), Some("default"), Some(1_000_000_000)),
|
||||
(Some("auto"), Some("fast"), Some(2_000_000_000)),
|
||||
(Some("default"), Some("flex"), Some(500_000_000)),
|
||||
(Some("priority"), Some("default"), Some(2_000_000_000)),
|
||||
(Some("fast"), Some("default"), Some(2_000_000_000)),
|
||||
(Some("auto"), Some("fast"), None),
|
||||
(Some("default"), Some("flex"), Some(1_000_000_000)),
|
||||
(Some("flex"), Some("priority"), Some(500_000_000)),
|
||||
(Some("fast"), None, Some(2_000_000_000)),
|
||||
(Some("auto"), None, None),
|
||||
(Some("default"), Some("future"), None),
|
||||
(Some("default"), Some("future"), Some(1_000_000_000)),
|
||||
(Some("future"), Some("default"), None),
|
||||
(None, Some("priority"), Some(1_000_000_000)),
|
||||
(None, None, Some(1_000_000_000)),
|
||||
] {
|
||||
assert_eq!(
|
||||
pricing_response_cost(requested, actual, json!([]), &[]),
|
||||
@@ -1002,8 +1037,8 @@ fn billing_should_add_file_and_web_search_fees_without_tier_markup() {
|
||||
for (tier, expected) in [("default", 1_150_000_000), ("fast", 2_150_000_000)] {
|
||||
assert_eq!(
|
||||
pricing_response_cost(
|
||||
None,
|
||||
Some(tier),
|
||||
Some("default"),
|
||||
output.clone(),
|
||||
&[json!({"type": "web_search"})]
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,6 +20,69 @@ fn classify(status: u16, body: &str) -> CodexFailureCategory {
|
||||
.category()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_rejections_are_request_scoped_across_http_statuses() {
|
||||
for status in [400, 429, 503] {
|
||||
for body in [
|
||||
r#"{"error":{"code":"server_is_overloaded","message":"busy"}}"#,
|
||||
r#"{"error":{"code":"slow_down","type":"server_error"}}"#,
|
||||
r#"{"error":{"type":"server_is_overloaded"}}"#,
|
||||
r#"{"error":{"message":"slow_down"}}"#,
|
||||
r#"{"error":{"type":"invalid_request_error","message":"Selected model is at capacity. Please try a different model."}}"#,
|
||||
] {
|
||||
assert_eq!(
|
||||
classify(status, body),
|
||||
CodexFailureCategory::CapacityUnavailable
|
||||
);
|
||||
for (phase, safe) in [
|
||||
(CodexUpstreamSendPhase::BeforePayload, true),
|
||||
(CodexUpstreamSendPhase::AfterPayload, true),
|
||||
(CodexUpstreamSendPhase::Ambiguous, false),
|
||||
] {
|
||||
let failure = CodexUpstreamFailure::from_response(
|
||||
reqwest::StatusCode::from_u16(status).expect("status"),
|
||||
body,
|
||||
Some(129_600),
|
||||
&CodexUpstreamDiagnostics::default(),
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
phase,
|
||||
);
|
||||
assert_eq!(failure.replay_is_safe(), safe);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_words_do_not_override_quota_policy_or_arbitrary_body_fields() {
|
||||
for (status, body, expected) in [
|
||||
(
|
||||
429,
|
||||
r#"{"error":{"type":"usage_limit_reached","message":"Selected model is at capacity"}}"#,
|
||||
CodexFailureCategory::UsageLimitExhausted,
|
||||
),
|
||||
(
|
||||
400,
|
||||
r#"{"error":{"code":"cyber_policy","message":"Selected model is at capacity"}}"#,
|
||||
CodexFailureCategory::InvalidRequest,
|
||||
),
|
||||
(
|
||||
400,
|
||||
r#"{"error":{"message":"bad input"},"input":"Selected model is at capacity"}"#,
|
||||
CodexFailureCategory::InvalidRequest,
|
||||
),
|
||||
(
|
||||
429,
|
||||
r#"{"error":{"code":"rate_limit_exceeded","message":"retry later"}}"#,
|
||||
CodexFailureCategory::RateLimited,
|
||||
),
|
||||
] {
|
||||
assert_eq!(classify(status, body), expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn trace_header<'a>(diagnostics: &'a CodexUpstreamDiagnostics, name: &str) -> Option<&'a str> {
|
||||
diagnostics
|
||||
.trace_headers
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -60,6 +60,7 @@ mod profile_contract;
|
||||
mod protocol;
|
||||
mod request;
|
||||
mod reset_credits;
|
||||
mod subscription;
|
||||
mod tls;
|
||||
mod usage;
|
||||
mod websocket;
|
||||
|
||||
@@ -32,7 +32,7 @@ async fn each_core_endpoint_uses_the_current_bundled_release() {
|
||||
});
|
||||
let context = request_context("audit", Some("audit-account"));
|
||||
client
|
||||
.fetch_models_with_context(context)
|
||||
.fetch_models_with_context(context, None)
|
||||
.await
|
||||
.expect_err("fixture rejection");
|
||||
client
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use chrono::{Datelike as _, Utc};
|
||||
use chrono_tz::America::New_York;
|
||||
use gateway_core::operation::{GenerateRequest, ProtocolPayload};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -117,6 +119,120 @@ fn encoder_should_remove_unsupported_fields_from_upstream_body() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() {
|
||||
let normal_chat = "<environment_context>\n <current_date>2026-09-13</current_date>\n \
|
||||
<timezone>Asia/Shanghai</timezone>\n</environment_context>";
|
||||
let body = json!({
|
||||
"model": "client-model",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "<environment_context>\n <cwd>/Users/mike/Personal/workspace/PORTAL2</cwd>\n <shell>zsh</shell>\n <current_date>2026-09-13</current_date>\n <timezone>Asia/Shanghai</timezone>\n <filesystem><file_system type=\"unrestricted\" /></filesystem>\n</environment_context>"
|
||||
}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"create_time": 1789293131.822,
|
||||
"content_item_kinds": ["environments.environment_context"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": normal_chat}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["user.text"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": normal_chat}]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{"type": "web_search"},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "remember_timezone",
|
||||
"description": "Keep Asia/Shanghai unchanged"
|
||||
}
|
||||
],
|
||||
"client_metadata": {
|
||||
"x-codex-turn-metadata": "{\"turn_started_at_unix_ms\":1789293131822}",
|
||||
"x-codex-ws-stream-request-start-ms": "1789293132000"
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("request object")
|
||||
.clone();
|
||||
let before = Utc::now().with_timezone(&New_York);
|
||||
|
||||
let encoded = encode_generate_request(&request(body), "gpt-test").expect("encode");
|
||||
|
||||
let after = Utc::now().with_timezone(&New_York);
|
||||
let encoded = Value::Object(encoded.body().clone());
|
||||
let environment = encoded
|
||||
.pointer("/input/0/content/0/text")
|
||||
.and_then(Value::as_str)
|
||||
.expect("environment context text");
|
||||
let expected_dates = [
|
||||
format!(
|
||||
"{:04}-{:02}-{:02}",
|
||||
before.year(),
|
||||
before.month(),
|
||||
before.day()
|
||||
),
|
||||
format!(
|
||||
"{:04}-{:02}-{:02}",
|
||||
after.year(),
|
||||
after.month(),
|
||||
after.day()
|
||||
),
|
||||
];
|
||||
assert!(
|
||||
expected_dates
|
||||
.iter()
|
||||
.any(|date| environment.contains(&format!("<current_date>{date}</current_date>")))
|
||||
);
|
||||
assert!(environment.contains("<timezone>America/New_York</timezone>"));
|
||||
assert_eq!(
|
||||
encoded.pointer("/tools/0/user_location"),
|
||||
Some(&json!({
|
||||
"type": "approximate",
|
||||
"country": "US",
|
||||
"region": "Ohio",
|
||||
"city": "Piketon",
|
||||
"timezone": "America/New_York"
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
encoded.pointer("/input/1/content/0/text"),
|
||||
Some(&json!(normal_chat))
|
||||
);
|
||||
assert_eq!(
|
||||
encoded.pointer("/input/2/content/0/text"),
|
||||
Some(&json!(normal_chat))
|
||||
);
|
||||
assert_eq!(
|
||||
encoded.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"),
|
||||
Some(&json!(1789293131.822))
|
||||
);
|
||||
assert_eq!(
|
||||
encoded.pointer("/tools/1/description"),
|
||||
Some(&json!("Keep Asia/Shanghai unchanged"))
|
||||
);
|
||||
assert_eq!(
|
||||
encoded.pointer("/client_metadata"),
|
||||
Some(&json!({
|
||||
"x-codex-turn-metadata": "{\"turn_started_at_unix_ms\":1789293131822}",
|
||||
"x-codex-ws-stream-request-start-ms": "1789293132000"
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_preserve_client_store_intent() {
|
||||
let request = request(Map::from_iter([
|
||||
|
||||
@@ -146,6 +146,50 @@ async fn consume_should_send_exact_credit_and_redeem_request_id_without_retry()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_only_credits_should_allow_upstream_selected_consumption() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/rate-limit-reset-credits"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"available_count": 1,
|
||||
"credits": []
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/rate-limit-reset-credits/consume"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "code": "reset" })))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let client = client(&server.uri());
|
||||
let credits = client
|
||||
.list_rate_limit_reset_credits(context())
|
||||
.await
|
||||
.expect("count-only reset credits");
|
||||
assert_eq!(credits.available_count, 1);
|
||||
assert!(credits.credits.is_empty());
|
||||
|
||||
let redeem_request_id =
|
||||
Uuid::parse_str("8fbf302d-11df-4bd5-82e4-08e4b3df7874").expect("redeem request ID");
|
||||
let result = client
|
||||
.consume_rate_limit_reset_credit(context(), None, redeem_request_id)
|
||||
.await
|
||||
.expect("upstream-selected reset-credit consume");
|
||||
assert_eq!(result.code, "reset");
|
||||
let requests = server.received_requests().await.expect("received requests");
|
||||
let request = requests
|
||||
.iter()
|
||||
.find(|request| request.method == Method::POST)
|
||||
.expect("consume request");
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&request.body).expect("consume JSON"),
|
||||
json!({ "redeem_request_id": "8fbf302d-11df-4bd5-82e4-08e4b3df7874" })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_should_preserve_raw_upstream_error_and_never_retry() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
use super::{CodexBackendClient, CodexRequestContext, test_wire_profile};
|
||||
use serde_json::json;
|
||||
use wiremock::{
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
matchers::{header, method, path, query_param},
|
||||
};
|
||||
|
||||
fn client(base: &str) -> CodexBackendClient {
|
||||
CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
base,
|
||||
test_wire_profile(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_uses_exact_bound_account_and_projects_only_safe_facts() {
|
||||
let server = MockServer::start().await;
|
||||
let account = "personal/account + A";
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/subscriptions"))
|
||||
.and(query_param("account_id", account))
|
||||
.and(header("chatgpt-account-id", account))
|
||||
.and(header("authorization", "Bearer fixture"))
|
||||
.and(header("accept", "application/json"))
|
||||
.and(header("origin", server.uri()))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id":"subscription-not-account", "plan_type":"enterprise", "secret":"ignored",
|
||||
"active_start":"2019-12-02T08:00:00+08:00",
|
||||
"active_until":" 2020-01-02T08:00:00+08:00 ", "will_renew":true,
|
||||
"billing_period":"monthly", "billing_currency":"USD"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let result = client(&format!("{}/backend-api", server.uri()))
|
||||
.fetch_subscription(
|
||||
CodexRequestContext::auxiliary(
|
||||
"Bearer fixture",
|
||||
Some(account),
|
||||
"subscription_test",
|
||||
None,
|
||||
),
|
||||
account,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.starts_at.unwrap().to_rfc3339(),
|
||||
"2019-12-02T00:00:00+00:00"
|
||||
);
|
||||
assert_eq!(result.billing_period.as_deref(), Some("monthly"));
|
||||
assert_eq!(result.billing_currency.as_deref(), Some("USD"));
|
||||
assert_eq!(result.expires_at.to_rfc3339(), "2020-01-02T00:00:00+00:00");
|
||||
assert_eq!(result.will_renew, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_failures_are_unknown_without_retry() {
|
||||
for response in [
|
||||
ResponseTemplate::new(204),
|
||||
ResponseTemplate::new(401),
|
||||
ResponseTemplate::new(403),
|
||||
ResponseTemplate::new(404),
|
||||
ResponseTemplate::new(429),
|
||||
ResponseTemplate::new(500),
|
||||
ResponseTemplate::new(200).set_body_string("not json"),
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(json!({"active_until":"invalid","will_renew":false})),
|
||||
ResponseTemplate::new(200).set_body_json(json!({"will_renew":true})),
|
||||
ResponseTemplate::new(200).set_body_string("x".repeat(65537)),
|
||||
ResponseTemplate::new(200).set_delay(std::time::Duration::from_secs(6)),
|
||||
] {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(response)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
assert!(
|
||||
client(&server.uri())
|
||||
.fetch_subscription(
|
||||
CodexRequestContext::auxiliary("Bearer fixture", Some("account"), "req", None),
|
||||
"account"
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_unknown_renewal_is_not_false_and_empty_account_does_not_request() {
|
||||
let server = MockServer::start().await;
|
||||
for renewal in [json!(null), json!("false"), json!(false)] {
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(
|
||||
json!({"active_until":"2026-10-01T00:00:00Z","will_renew":renewal}),
|
||||
),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let result = client(&server.uri())
|
||||
.fetch_subscription(
|
||||
CodexRequestContext::auxiliary("Bearer fixture", Some("account"), "req", None),
|
||||
"account",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.will_renew, renewal.as_bool());
|
||||
}
|
||||
server.reset().await;
|
||||
assert!(
|
||||
client(&server.uri())
|
||||
.fetch_subscription(
|
||||
CodexRequestContext::auxiliary("Bearer fixture", None, "req", None),
|
||||
""
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
assert!(server.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_preserves_configured_backend_path_prefix() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/proxy/backend-api/subscriptions"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(json!({"active_until":"2026-10-01T00:00:00Z"})),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
assert!(
|
||||
client(&format!("{}/proxy/backend-api/", server.uri()))
|
||||
.fetch_subscription(
|
||||
CodexRequestContext::auxiliary("Bearer fixture", Some("account"), "req", None),
|
||||
"account"
|
||||
)
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ use bytes::{Buf as _, Bytes};
|
||||
|
||||
use super::*;
|
||||
|
||||
// Captured from Desktop 26.901.51231 / Core 0.153.4 on 2026-09-06.
|
||||
// Compare wire semantics: rustls randomizes extension order and ephemeral key bytes.
|
||||
// 2026-09-14 从 Desktop 26.908.40834 / Core 0.154.0 抓取。
|
||||
// HTTP/native-tls 的扩展顺序固定;WebSocket/rustls 会随机化扩展顺序和临时密钥。
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct ClientHello {
|
||||
cipher_suites: Vec<u16>,
|
||||
@@ -16,28 +16,66 @@ struct ClientHello {
|
||||
alpn: Vec<String>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_client_hello_should_match_official_rustls_transport() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!(
|
||||
"https://localhost:{}/",
|
||||
listener.local_addr().unwrap().port()
|
||||
);
|
||||
let client = provider_openai::transport::build_reqwest_client().unwrap();
|
||||
let (hello, response) = timeout(Duration::from_secs(10), async {
|
||||
tokio::join!(read_client_hello(listener), client.get(url).send())
|
||||
})
|
||||
.await
|
||||
.expect("HTTP ClientHello within timeout");
|
||||
assert!(
|
||||
response.is_err(),
|
||||
"capture endpoint rejects TLS after ClientHello"
|
||||
);
|
||||
#[test]
|
||||
fn http_client_hello_should_match_official_native_tls_transport() {
|
||||
const CASE_ENV: &str = "CODEX_PROXY_TEST_NATIVE_TLS_CLIENT_HELLO";
|
||||
const CASE_COMPLETED: &str = "native-tls-client-hello-case-completed";
|
||||
|
||||
let mut expected = official_websocket_hello();
|
||||
expected.extensions.insert(5, 16);
|
||||
expected.alpn = vec!["h2".to_owned(), "http/1.1".to_owned()];
|
||||
assert_eq!(hello, expected);
|
||||
if std::env::var_os(CASE_ENV).is_some() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let hello = runtime.block_on(async {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!(
|
||||
"https://localhost:{}/",
|
||||
listener.local_addr().unwrap().port()
|
||||
);
|
||||
let client = provider_openai::transport::build_reqwest_client().unwrap();
|
||||
let (hello, response) = timeout(Duration::from_secs(10), async {
|
||||
tokio::join!(read_client_hello(listener), client.get(url).send())
|
||||
})
|
||||
.await
|
||||
.expect("HTTP ClientHello within timeout");
|
||||
assert!(
|
||||
response.is_err(),
|
||||
"capture endpoint rejects TLS after ClientHello"
|
||||
);
|
||||
hello
|
||||
});
|
||||
assert_eq!(hello, official_http_hello());
|
||||
println!("\n{CASE_COMPLETED}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cargo 运行测试时可能注入 SSL_CERT_FILE,生产代码会把它视作自定义 CA 并切到 rustls;
|
||||
// 因此在清理相关环境变量的子进程里验证默认路径。
|
||||
let current_exe = std::env::current_exe().expect("current test binary path");
|
||||
let output = Command::new(current_exe)
|
||||
.arg("--exact")
|
||||
.arg("transport::tls::http_client_hello_should_match_official_native_tls_transport")
|
||||
.arg("--nocapture")
|
||||
.env(CASE_ENV, "1")
|
||||
.env_remove(provider_openai::transport::tls::CODEX_CA_CERT_ENV)
|
||||
.env_remove(provider_openai::transport::tls::SSL_CERT_FILE_ENV)
|
||||
.output()
|
||||
.expect("run isolated native TLS ClientHello case");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"isolated native TLS ClientHello case failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert_eq!(
|
||||
stdout
|
||||
.lines()
|
||||
.filter(|line| *line == CASE_COMPLETED)
|
||||
.count(),
|
||||
1,
|
||||
"isolated native TLS ClientHello case did not complete exactly once\nstdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -60,7 +98,27 @@ async fn websocket_client_hello_should_match_official_rustls_transport() {
|
||||
response.is_err(),
|
||||
"capture endpoint rejects TLS after ClientHello"
|
||||
);
|
||||
assert_eq!(hello, official_websocket_hello());
|
||||
let mut normalized = hello;
|
||||
normalized.extensions.sort_unstable();
|
||||
assert_eq!(normalized, official_websocket_hello());
|
||||
}
|
||||
|
||||
fn official_http_hello() -> ClientHello {
|
||||
ClientHello {
|
||||
cipher_suites: vec![
|
||||
4866, 4867, 4865, 49196, 49200, 159, 52393, 52392, 52394, 49195, 49199, 158, 49188,
|
||||
49192, 107, 49187, 49191, 103, 49162, 49172, 57, 49161, 49171, 51, 157, 156, 61, 60,
|
||||
53, 47,
|
||||
],
|
||||
extensions: vec![65281, 0, 11, 10, 35, 22, 23, 13, 43, 45, 51],
|
||||
groups: vec![4588, 29, 23, 30, 24, 25, 256, 257],
|
||||
signature_algorithms: vec![
|
||||
2309, 2310, 2308, 1027, 1283, 1539, 2055, 2056, 2074, 2075, 2076, 2057, 2058, 2059,
|
||||
2052, 2053, 2054, 1025, 1281, 1537, 771, 769, 770, 1026, 1282, 1538,
|
||||
],
|
||||
key_shares: vec![(4588, 1216), (29, 32)],
|
||||
alpn: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn official_websocket_hello() -> ClientHello {
|
||||
@@ -96,9 +154,11 @@ async fn read_client_hello(listener: TcpListener) -> ClientHello {
|
||||
let compression_len = usize::from(hello.get_u8());
|
||||
hello.advance(compression_len);
|
||||
let mut extensions = take_vector(&mut hello);
|
||||
let mut extension_order = Vec::new();
|
||||
let mut values = BTreeMap::new();
|
||||
while extensions.has_remaining() {
|
||||
let kind = extensions.get_u16();
|
||||
extension_order.push(kind);
|
||||
assert!(values.insert(kind, take_vector(&mut extensions)).is_none());
|
||||
}
|
||||
|
||||
@@ -124,7 +184,7 @@ async fn read_client_hello(listener: TcpListener) -> ClientHello {
|
||||
}
|
||||
ClientHello {
|
||||
cipher_suites,
|
||||
extensions: values.into_keys().collect(),
|
||||
extensions: extension_order,
|
||||
groups,
|
||||
signature_algorithms,
|
||||
key_shares,
|
||||
|
||||
@@ -243,15 +243,11 @@ async fn exact_limit_success_body_should_be_accepted() {
|
||||
fn billing_should_use_explicit_variant_prices_and_verified_snapshots() {
|
||||
// 每个用例包含一个普通输入 token 和一个输出 token,金额单位为 USD ticks。
|
||||
for (model, expected) in [
|
||||
("gpt-5.1-codex-mini", 22_500),
|
||||
("gpt-5.3-chat-latest", 157_500),
|
||||
("gpt-5.6-cyber", 875_000),
|
||||
("gpt-5.5-cyber", 875_000),
|
||||
("gpt-daybreak-red-latest", 875_000),
|
||||
("gpt-daybreak-blue-latest", 350_000),
|
||||
("chat-latest", 350_000),
|
||||
("gpt-5.1-codex-max", 112_500),
|
||||
("gpt-5.2-codex", 157_500),
|
||||
("gpt-5.4-2026-03-05", 175_000),
|
||||
(" OpenAI/GPT-4o-2024-08-06 ", 125_000),
|
||||
("gpt-4o-2024-05-13", 200_000),
|
||||
@@ -268,6 +264,53 @@ fn billing_should_use_explicit_variant_prices_and_verified_snapshots() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_should_not_price_shutdown_models_or_restore_them_through_aliases() {
|
||||
// 官方关闭日期已过;同族仍在服务的基础型号不能为这些旧型号兜底。
|
||||
for model in [
|
||||
"gpt-4-0314",
|
||||
" OpenAI/GPT-4-0314 ",
|
||||
"gpt-5-codex",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5-chat-latest",
|
||||
"gpt-5.1-chat-latest",
|
||||
"gpt-5.2-chat-latest",
|
||||
"gpt-5.3-chat-latest",
|
||||
] {
|
||||
for tier in [None, Some("flex"), Some("fast")] {
|
||||
assert!(
|
||||
openai_billing_breakdown(model, billing_usage(100, 10, 20, 0), tier).is_none(),
|
||||
"{model} {tier:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_should_keep_deprecated_models_before_their_shutdown_dates() {
|
||||
// 截至 2026-09-13,这些型号尚未到官方关闭日期,仍按已公布单价计费。
|
||||
for model in [
|
||||
"gpt-3.5-turbo-instruct",
|
||||
"gpt-3.5-turbo-1106",
|
||||
"gpt-4-0613",
|
||||
"gpt-4-turbo-2024-04-09",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4.1-nano",
|
||||
"o1",
|
||||
"o1-pro",
|
||||
"o3-mini",
|
||||
"o4-mini",
|
||||
] {
|
||||
assert!(
|
||||
openai_billing_breakdown(model, billing_usage(100, 10, 20, 0), None).is_some(),
|
||||
"{model}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_should_not_inherit_prices_for_unknown_models_or_tiers() {
|
||||
for model in [
|
||||
@@ -291,7 +334,7 @@ fn billing_should_not_inherit_prices_for_unknown_models_or_tiers() {
|
||||
("gpt-5.6-cyber", 272_001, None),
|
||||
("gpt-5.5-cyber", 272_001, None),
|
||||
("gpt-5.6-cyber", 1, Some("fast")),
|
||||
("gpt-5.1-codex-mini", 1, Some("flex")),
|
||||
("gpt-5.3-codex", 1, Some("flex")),
|
||||
("gpt-6-astra", 1, Some("auto")),
|
||||
("gpt-6-astra", 1, Some("ultrafast")),
|
||||
] {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use flate2::{Decompress, FlushDecompress};
|
||||
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ClientFrame {
|
||||
first_byte: u8,
|
||||
masked: bool,
|
||||
mask_len: usize,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_websocket_wire_fingerprint_should_remain_aligned() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind fingerprint server");
|
||||
let address = listener.local_addr().expect("fingerprint server address");
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept fingerprint client");
|
||||
let opening = read_http_request(&mut stream).await;
|
||||
let websocket_key = read_header_value(&opening, "sec-websocket-key")
|
||||
.expect("WebSocket key")
|
||||
.to_owned();
|
||||
let response = format!(
|
||||
concat!(
|
||||
"HTTP/1.1 101 Switching Protocols\r\n",
|
||||
"Connection: Upgrade\r\n",
|
||||
"Upgrade: websocket\r\n",
|
||||
"Sec-WebSocket-Accept: {}\r\n",
|
||||
"Sec-WebSocket-Extensions: permessage-deflate\r\n",
|
||||
"\r\n"
|
||||
),
|
||||
derive_accept_key(websocket_key.as_bytes())
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.expect("write upgrade response");
|
||||
|
||||
let frame = read_client_frame(&mut stream).await;
|
||||
write_server_text_frame(
|
||||
&mut stream,
|
||||
&completed_websocket_response("resp_wire_fingerprint", 1, 1),
|
||||
)
|
||||
.await;
|
||||
(opening, websocket_key, frame)
|
||||
});
|
||||
|
||||
let mut request = CodexResponsesRequest::from_body(
|
||||
json!({
|
||||
"model": "gpt-5.5",
|
||||
"instructions": "be brief",
|
||||
"input": [],
|
||||
"tools": [],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": true,
|
||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"prompt_cache_key": "conversation-fingerprint",
|
||||
"generate": false,
|
||||
"client_metadata": {"request_kind": "prewarm"}
|
||||
})
|
||||
.as_object()
|
||||
.expect("request object")
|
||||
.clone(),
|
||||
);
|
||||
request.use_websocket = true;
|
||||
request.local_conversation_id = Some("conversation-fingerprint".to_owned());
|
||||
request.turn_metadata = Some(r#"{"kind":"fingerprint"}"#.to_owned());
|
||||
request.beta_features = Some("feature-a".to_owned());
|
||||
request.version = Some("26.908.40834".to_owned());
|
||||
request.codex_window_id = Some("window-fingerprint".to_owned());
|
||||
let client = CodexBackendClient::new(
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("HTTP client"),
|
||||
format!("http://{address}/backend-api"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
|
||||
|
||||
let result = client
|
||||
.create_response(
|
||||
&request,
|
||||
CodexRequestContext {
|
||||
trace: None,
|
||||
turn_metadata: request.turn_metadata.as_deref(),
|
||||
beta_features: request.beta_features.as_deref(),
|
||||
version: request.version.as_deref(),
|
||||
codex_window_id: request.codex_window_id.as_deref(),
|
||||
session_id: Some("session-fingerprint"),
|
||||
thread_id: Some("thread-fingerprint"),
|
||||
client_request_id: Some("request-fingerprint"),
|
||||
..request_context("req_wire_fingerprint", Some("account-fingerprint"))
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
result.unwrap_or_else(|error| panic!("fingerprint WebSocket response: {error}"));
|
||||
let (opening, websocket_key, frame) = server.await.expect("fingerprint server task");
|
||||
assert_eq!(
|
||||
opening.lines().next(),
|
||||
Some("GET /backend-api/codex/responses HTTP/1.1")
|
||||
);
|
||||
// 官方 Codex Core 0.154.0(Desktop 26.908.40834)的原始 opening 抓包顺序。
|
||||
assert_eq!(
|
||||
read_header_names(&opening),
|
||||
vec![
|
||||
"host",
|
||||
"connection",
|
||||
"upgrade",
|
||||
"sec-websocket-version",
|
||||
"sec-websocket-key",
|
||||
"chatgpt-account-id",
|
||||
"authorization",
|
||||
"user-agent",
|
||||
"originator",
|
||||
"openai-beta",
|
||||
"version",
|
||||
"x-codex-beta-features",
|
||||
"x-client-request-id",
|
||||
"session-id",
|
||||
"thread-id",
|
||||
"x-codex-window-id",
|
||||
"x-codex-turn-metadata",
|
||||
"x-codex-routing-hint",
|
||||
"sec-websocket-extensions",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
read_header_value(&opening, "sec-websocket-extensions"),
|
||||
Some("permessage-deflate; client_max_window_bits")
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
read_header_value(&opening, "chatgpt-account-id"),
|
||||
read_header_value(&opening, "authorization"),
|
||||
read_header_value(&opening, "originator"),
|
||||
read_header_value(&opening, "version"),
|
||||
read_header_value(&opening, "openai-beta"),
|
||||
read_header_value(&opening, "x-client-request-id"),
|
||||
read_header_value(&opening, "x-codex-routing-hint"),
|
||||
),
|
||||
(
|
||||
Some("account-fingerprint"),
|
||||
Some("Bearer access-token"),
|
||||
Some("codex_cli_rs"),
|
||||
Some("1.2.3"),
|
||||
Some("responses_websockets=2026-02-06"),
|
||||
Some("request-fingerprint"),
|
||||
Some("model=gpt-5.5"),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
STANDARD
|
||||
.decode(websocket_key)
|
||||
.expect("base64 WebSocket key")
|
||||
.len(),
|
||||
16
|
||||
);
|
||||
|
||||
assert_eq!(frame.first_byte, 0b1100_0001, "FIN + RSV1 + text opcode");
|
||||
assert!(frame.masked, "official client frames are masked");
|
||||
assert_eq!(frame.mask_len, 4);
|
||||
let payload = decompress_permessage_deflate(&frame.payload);
|
||||
let payload: Value = serde_json::from_slice(&payload).expect("response.create JSON");
|
||||
assert_eq!(payload["type"], "response.create");
|
||||
assert_eq!(payload["generate"], false);
|
||||
assert_eq!(
|
||||
payload
|
||||
.as_object()
|
||||
.expect("response.create object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"type",
|
||||
"model",
|
||||
"instructions",
|
||||
"input",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"parallel_tool_calls",
|
||||
"reasoning",
|
||||
"store",
|
||||
"stream",
|
||||
"include",
|
||||
"prompt_cache_key",
|
||||
"generate",
|
||||
"client_metadata",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async fn read_client_frame(stream: &mut TcpStream) -> ClientFrame {
|
||||
let first_byte = stream.read_u8().await.expect("frame first byte");
|
||||
let second_byte = stream.read_u8().await.expect("frame second byte");
|
||||
let payload_len = match second_byte & 0x7f {
|
||||
len @ 0..=125 => u64::from(len),
|
||||
126 => u64::from(stream.read_u16().await.expect("16-bit frame length")),
|
||||
127 => stream.read_u64().await.expect("64-bit frame length"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let masked = second_byte & 0x80 != 0;
|
||||
let mut mask = [0_u8; 4];
|
||||
if masked {
|
||||
stream.read_exact(&mut mask).await.expect("frame mask");
|
||||
}
|
||||
let mut payload = vec![0_u8; usize::try_from(payload_len).expect("frame length fits usize")];
|
||||
stream
|
||||
.read_exact(&mut payload)
|
||||
.await
|
||||
.expect("frame payload");
|
||||
if masked {
|
||||
for (index, byte) in payload.iter_mut().enumerate() {
|
||||
*byte ^= mask[index % mask.len()];
|
||||
}
|
||||
}
|
||||
ClientFrame {
|
||||
first_byte,
|
||||
masked,
|
||||
mask_len: if masked { mask.len() } else { 0 },
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
fn decompress_permessage_deflate(payload: &[u8]) -> Vec<u8> {
|
||||
let mut compressed = Vec::with_capacity(payload.len() + 4);
|
||||
compressed.extend_from_slice(payload);
|
||||
compressed.extend_from_slice(&[0x00, 0x00, 0xff, 0xff]);
|
||||
let mut output = vec![0_u8; 64 * 1024];
|
||||
let mut decompressor = Decompress::new(false);
|
||||
decompressor
|
||||
.decompress(&compressed, &mut output, FlushDecompress::Sync)
|
||||
.expect("decompress response.create frame");
|
||||
output.truncate(
|
||||
usize::try_from(decompressor.total_out()).expect("decompressed length fits usize"),
|
||||
);
|
||||
output
|
||||
}
|
||||
|
||||
async fn write_server_text_frame(stream: &mut TcpStream, payload: &str) {
|
||||
let payload = payload.as_bytes();
|
||||
let mut frame = Vec::with_capacity(payload.len() + 10);
|
||||
frame.push(0x81);
|
||||
match payload.len() {
|
||||
len @ 0..=125 => frame.push(u8::try_from(len).expect("short frame length")),
|
||||
len @ 126..=65_535 => {
|
||||
frame.push(126);
|
||||
frame.extend_from_slice(
|
||||
&u16::try_from(len)
|
||||
.expect("16-bit frame length")
|
||||
.to_be_bytes(),
|
||||
);
|
||||
}
|
||||
len => {
|
||||
frame.push(127);
|
||||
frame.extend_from_slice(
|
||||
&u64::try_from(len)
|
||||
.expect("64-bit frame length")
|
||||
.to_be_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
frame.extend_from_slice(payload);
|
||||
stream
|
||||
.write_all(&frame)
|
||||
.await
|
||||
.expect("write completed frame");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use provider_openai::transport::{
|
||||
};
|
||||
|
||||
mod diagnostics;
|
||||
mod fingerprint;
|
||||
mod metadata;
|
||||
|
||||
fn rate_limit_event(used_percent: u64) -> String {
|
||||
|
||||
@@ -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;
|
||||
+1
-1
@@ -13,7 +13,7 @@ RUN npm install --global pnpm@11.7.0 \
|
||||
COPY frontend ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM rust:1.98-bookworm@sha256:82150a52ec202c1b14d7817e14516c392bb7f5cfebd88f1ed531cb37ebd39922 AS backend-builder
|
||||
FROM rust:1.98-bookworm@sha256:9a73a5088750b4c95158ab26629c854c3d6fc4b173cb7bc8079ad252d8ed7bfa AS backend-builder
|
||||
|
||||
WORKDIR /app/backend
|
||||
ARG TARGETARCH
|
||||
|
||||
+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` 生成;管理员初始密码单独设置。
|
||||
|
||||
|
||||
+181
-93
@@ -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 继续以该值作为客户端覆盖上限。
|
||||
|
||||
@@ -200,6 +228,14 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
|
||||
上游结构化错误的 message/code/type 会透传给客户端,其中内嵌的账号指纹 UUID 已脱敏。模型映射是
|
||||
全局精确映射,未命中时模型名原样交给候选 Provider;分组只限定账号集合,不参与模型改名。
|
||||
|
||||
OpenAI 明确返回 `server_is_overloaded`、`slow_down` 或模型容量不足错误时,代理在允许安全重放且
|
||||
尚未交付输出的前提下,先做最多 3 次同账号指数退避,再通过现有调度换号。默认间隔从 500ms 开始,
|
||||
上游 `Retry-After` 参与退避计算,单次等待不超过 8 秒;重试同时受请求总尝试次数和截止时间约束。
|
||||
容量不足不扣 Smart 账号健康分,不触发 Provider 全局熔断,也不作为账号额度耗尽写入冷却状态。
|
||||
最终交付的上游错误仍按上述透明边界保留原始状态码、错误码和正文。
|
||||
明确额度耗尽继续走现有账号隔离与安全换号流程,
|
||||
包括 WebSocket 握手返回的 429;不会因其长 `Retry-After` 而转入同账号传输恢复等待。
|
||||
|
||||
## 4. 管理员认证
|
||||
|
||||
| 方法 | 路由 | 请求 | 说明 |
|
||||
@@ -228,7 +264,7 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
|
||||
| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 |
|
||||
| `GET` | `/api/admin/accounts/quota-forecast` | `accountId` | 按需读取周/月容量预测、源窗口剩余估算与采样依据,不刷新上游额度 |
|
||||
| `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 访问 Provider 并刷新额度,同时同步额度所属状态 |
|
||||
| `GET` | `/api/admin/accounts/profile-statistics` | `accountId` | 实时查询 OpenAI/Codex 官方个人资料中的累计活动与使用洞察 |
|
||||
| `GET` | `/api/admin/accounts/personal-info` | `accountId` | 按需汇聚 OpenAI/Codex 官方个人资料、累计活动与订阅信息,不更新额度或 credential |
|
||||
| `GET` | `/api/admin/accounts/reset-credits` | `accountId` | 查询 OpenAI 上游主动额度重置卡,不读取本地库存 |
|
||||
| `POST` | `/api/admin/accounts/reset-credits` | `{ accountId, creditId?, redeemRequestId }` | 使用 UUIDv4 幂等键消费一张 OpenAI 上游重置卡 |
|
||||
| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 |
|
||||
@@ -262,7 +298,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 +317,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 +334,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 +361,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 +378,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 +400,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 +471,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 +492,26 @@ 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 预测。未知值不按确定的零消耗外推。
|
||||
`incompleteCost` / `incompleteTokens` 仅提示费用 / Token 记录存在缺口,不阻断已有数据的预测。
|
||||
Token 使用已记录的正数用量,费用使用已知的有效 USD 金额;对应数据完全缺失时该项为 `null`,
|
||||
两项均不可估算时才返回无可用数据的说明。明确记录的零 USD 仍可估算为零,未知费用不能当成零。
|
||||
不按请求数量补齐未知消耗;漏记、失败或缺少计量可能使估算偏低,不代表完整账单或真实剩余额度。
|
||||
|
||||
内部采样优先采用近期分段,条件不足时才使用窗口累计估算。同一额度段内,以历史额度和截至相应
|
||||
完成时间的累计用量建立基线,每累计至少 5 个百分点形成一段,使用最近 3 个完整段及未满一段的尾部。
|
||||
上式中的 `sampledPercent` 是内部有效额度进度(百分点),不是时间进度或对外响应字段。
|
||||
只对合并区间计算比值,不平均逐请求小分母比值;重复读数不增加段数,大于 1 个百分点的回落或累计
|
||||
计数倒退会中断采样,不能静默跨越。缓存命中属于输入,不重复相加;其他币种或缺少有效金额不能当成零 USD。
|
||||
|
||||
采样查询 `[max(resetAt - windowSeconds, accountAddedAt), observedAt)` 内开始的请求,
|
||||
仅把 `completedAt <= observedAt` 的完整交付用量计入当前分子;历史分子按各点的完成时间累计,
|
||||
同完成时间使用同一累计值。每个源窗口从同一数据库语句取得累计数值和最多 128 个按时间分桶的历史文档,
|
||||
不会因历史点抽样而丢弃其间的成功 Token,也不重新聚合模型排行;接口不返回原始 Provider 文档。
|
||||
同完成时间使用同一累计值。历史点抽样不缩减区间内的累计用量,接口不返回原始 Provider 文档。
|
||||
|
||||
OpenAI 复用已有限流协议解析器匹配额度桶、槽位、时长和明确的套餐。仅允许最多 2 秒的重置时间抖动,
|
||||
超出或套餐改变则截断历史基线;不将宽时间容差当作上游窗口身份。历史文档缺少独立额度观测时间,
|
||||
@@ -503,10 +522,26 @@ OpenAI 复用已有限流协议解析器匹配额度桶、槽位、时长和明
|
||||
上游消耗和模型组合变化仍可能造成误差;等价 USD 费用不是官方订阅价格或固定额度承诺,
|
||||
30 天折算也不是自然月额度。记录覆盖率不等于预测准确率,不输出未经校准的置信区间。
|
||||
|
||||
### OpenAI 官方个人资料统计
|
||||
### OpenAI 个人信息
|
||||
|
||||
`GET /api/admin/accounts/profile-statistics?accountId=...` 仅支持 OpenAI/Codex OAuth 账号。每次查询直接
|
||||
访问官方个人资料端点,不读取本地 usage/billing 记录,也不缓存或估算统计结果。响应 `data` 包含:
|
||||
`GET /api/admin/accounts/personal-info?accountId=...` 需要管理员会话,当前由 OpenAI/Codex OAuth
|
||||
账号提供。后端并发读取资料统计与订阅,一次返回;每次请求均重新查询,不自动重试或
|
||||
刷新 credential,不读取本地 usage/billing 记录,也不缓存或估算统计结果。
|
||||
|
||||
响应 `data` 包含:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `profile` | object 或 null | 官方个人资料、累计统计与活动洞察;查询失败为 null |
|
||||
| `profileError` | string 或 null | 资料查询失败时的安全错误提示;成功为 null |
|
||||
| `subscription` | object 或 null | 当前绑定账号的订阅周期;无可用周期或查询失败为 null |
|
||||
|
||||
两部分的查询结果独立:资料失败时仍返回可用订阅,订阅失败时仍返回资料。账号不存在、查询参数不合法或
|
||||
无管理员会话时,仍返回标准错误;请求期间账号身份或 credential revision 改变时拒绝整份结果。
|
||||
|
||||
#### 官方资料与累计统计
|
||||
|
||||
`profile` 包含:
|
||||
|
||||
- `displayName`、`username`、`imageUrl`:官方账号资料;
|
||||
- `summary`:累计文本 Token、单日峰值 Token、最长任务时长、当前连续天数和最长连续天数;
|
||||
@@ -515,14 +550,32 @@ OpenAI 复用已有限流协议解析器匹配额度桶、槽位、时长和明
|
||||
以及插件与 Skill 调用排行。
|
||||
|
||||
官方未返回的字段保持 `null`,不使用本地数据补齐;`hasStatsError: true` 表示账号资料可用,但官方统计
|
||||
部分不可用。access token 已过期或官方返回 401 时,接口要求先刷新 credential 或重新授权。原账号级
|
||||
`GET /api/admin/accounts/usage-statistics` usage/billing 报表接口及其查询链路已移除。
|
||||
部分不可用。access token 已过期或官方返回 401 时,通过 `profileError` 提示先刷新 credential 或重新授权。
|
||||
|
||||
#### 订阅信息
|
||||
|
||||
后端使用当前凭据、绑定的上游账号 ID 和账号出站代理访问 `/backend-api/subscriptions?account_id=...`,
|
||||
不枚举其他账号,不返回上游订阅 ID 或原始响应。
|
||||
|
||||
`subscription` 为 `null`(未获得可用订阅周期),或包含以下字段:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `startsAt` | RFC 3339 字符串或 null | 上游本期开始时间 |
|
||||
| `expiresAt` | RFC 3339 字符串 | 上游本期结束时间,不代表自动续费账号最终失效 |
|
||||
| `willRenew` | boolean 或 null | 自动续费状态;未知不推断为 false |
|
||||
| `billingPeriod` | string 或 null | 上游计费周期标识 |
|
||||
| `billingCurrency` | string 或 null | 上游计费币种 |
|
||||
| `observedAt` | RFC 3339 字符串 | 本次查询时间 |
|
||||
|
||||
订阅不写入额度快照或数据库,不参与账号状态或调度;单次上游查询最多 5 秒、响应最多 64 KiB,不重试。
|
||||
上游失败或未提供有效周期时返回未知,不据此标记免费、过期或禁用;请求期间账号身份或 credential
|
||||
revision 变化时丢弃结果。
|
||||
|
||||
### OpenAI 主动额度重置卡
|
||||
|
||||
`GET /api/admin/accounts/reset-credits?accountId=...` 每次都查询 OpenAI 上游;后端不把卡片列表写入
|
||||
PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用,并在当前浏览器会话内缓存最近一次成功
|
||||
结果,用于账号行上的 `xN` 提示。
|
||||
PostgreSQL 或 Redis。
|
||||
|
||||
查询响应:
|
||||
|
||||
@@ -588,19 +641,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 +696,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 +757,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 +770,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 +849,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 关联、阶段、
|
||||
事件摘要及淘汰计数;普通用量列表不携带此字段。
|
||||
@@ -813,10 +892,19 @@ Dashboard 的 `accountUsage[]` 由后端提供 `usageWindow`、`metricLabel`、`
|
||||
和本地用量由 Provider/Admin 投影。前端不得从套餐缺失推断免费套餐,也不得从显示时舍入的百分比推断
|
||||
触顶。滚动窗口使用相应时间范围的本地用量,独立于 Dashboard 的今日统计范围。
|
||||
|
||||
OpenAI 的 `serviceTier` 只接受上游响应生命周期事件确认的实际 `response.service_tier`;请求里的
|
||||
期望档位只保留在 request summary,不能冒充响应事实。计费展示把 `priority`/`fast` 映射为 `Fast`,
|
||||
`flex` 映射为 `Flex`,缺失或 `default` 映射为 `Default`;未知非空值原样展示。Fast 优先使用模型的
|
||||
priority 价格,缺少专用价格时回退到标准价格的 `2.00x`;Flex 为 `0.50x`,Default 为 `1.00x`。
|
||||
OpenAI Responses 用量记录的 `serviceTier` 与本地费用估算统一采用 Provider 最终发给上游的请求
|
||||
`service_tier`,不使用响应档位覆盖或回退。例如发送 `priority`、响应回显 `default` 时,仍显示
|
||||
`Fast` 并按 Priority 价格估算。未发送档位时,`serviceTier` 保持缺失,展示与估算按标准档处理。
|
||||
计费展示把 `priority`/`fast` 映射为 `Fast`,`flex` 映射为 `Flex`,缺失或 `default`/`standard`
|
||||
映射为 `Standard`;其他非空值以首字母大写展示。各模型、档位及长上下文区间使用明确登记的价格;缺少对应
|
||||
价格时不估算(包括 `auto` 和未知档位),不以固定倍数兜底。展示的倍率由所选档位与标准档位费用之比
|
||||
计算,托管工具调用费不随 Token 档位倍增。
|
||||
Provider metadata 分别保留 `requestedServiceTier` 与 `upstreamServiceTier` 供诊断;发送给客户端的
|
||||
原始 `response.service_tier` 不变。用量中的 Fast 仅表示发送档位,不能证明上游实际加速,本地费用
|
||||
估算也不能代替官方账单。该口径仅作用于新记录,不回填历史档位或重算已存储费用。
|
||||
|
||||
本地计价规则只保留尚在服务的型号;已过官方关闭日期的型号不再新增本地估价。清理计价规则不删除或
|
||||
重新计算已存储的历史费用;缺少当前计价规则时,历史总额仍保留,但无法再据此补充费用拆分。
|
||||
|
||||
## 11. 版本、更新与重启
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user