Compare commits

...
26 Commits
Author SHA1 Message Date
ZyyandCodex 21a436b76a chore: bump version to 3.7.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 17:33:59 +08:00
ZyyandCodex 849aa73a28 docs(release): prepare v3.7.0 notes
Summarize account personal information, reset credit fixes, OpenAI retry and transport changes, and service tier accounting. Document the admin API replacement and upgrade scope.

Co-authored-by: Codex <noreply@openai.com>
2026-09-14 17:33:52 +08:00
ZyyandCodex fb89bda177 docs: clarify documentation scope and required development skills
Centralize documentation update rules in CONTRIBUTING and reference them from the development and PR skills. Require frontend-design for frontend development and rust-best-practices for Rust development.

Co-authored-by: Codex <noreply@openai.com>
2026-09-14 17:28:07 +08:00
ZyyandCodex 9e6c8c9734 fix(openai): retry capacity rejections before rotating accounts
Retry explicit model capacity rejections on the same account up to three
times with bounded backoff, then reuse ordinary account rotation. Honor
the existing replay, delivery, cancellation and total request budgets.

Keep capacity rejections out of account health scores and provider
circuits, preserve original client errors, and extend the WebSocket
rejection boundary introduced by #89. Cover HTTP and WebSocket behavior
and document the policy.

Co-authored-by: Codex <noreply@openai.com>
2026-09-14 17:19:38 +08:00
2han9wen71an 3f69bc861c fix(openai): rotate accounts instead of same-socket retry on WebSocket account-level rejections (#89)
WebSocket 升级或交换被上游账号级拒绝(额度耗尽、限流、封禁等)时,恢复策略
仍会把错误交给同账号传输重试,且退避直接采用上游 retry-after(额度窗口通常
是小时级)。请求在 deadline 内无法恢复,错误原样返回客户端,Core 也失去了
换号机会——而同错误在 HTTP 路径上因回放安全会立即换号重试。

现在回放安全且带账号级失败记录的 WebSocket 拒绝不进入传输恢复:保留账号失败
记录(健康度与额度标记立即生效),把换号决策交还 Core,与 HTTP 路径语义一致。
会话 WebSocket 预算与禁用标记也不被账号级拒绝消耗。
2026-09-14 17:15:47 +08:00
ZyyandCodex cea4e1d8e9 fix(openai): use outbound service tier for usage and billing
Keep response-reported tiers as diagnostic metadata without overriding outbound-tier usage or cost estimates. Cover HTTP and WebSocket behavior and document the updated accounting policy.

Co-authored-by: Codex <noreply@openai.com>
2026-09-14 16:41:02 +08:00
539764f7eb feat(accounts): aggregate personal information on demand (#83)
Load profile statistics and OpenAI subscription information concurrently through one on-demand admin endpoint. Keep subscription queries out of quota refreshes, preserve partial results and account identity checks, and show subscriptions in the approved personal-information dialog layout.

Update the current API and architecture contracts while leaving README unchanged.

Co-authored-by: Yanshan Chen <16632048+chenyanshan@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 15:48:12 +08:00
c9be17778d fix(accounts): consume reset credits without card details (#81)
Allow count-only reset inventories to request upstream card selection without a credit ID. Preserve confirmation and idempotent retry guards, and place the fallback action in the shared modal footer with balanced inventory spacing.

Co-authored-by: Yanshan Chen <16632048+chenyanshan@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 15:47:32 +08:00
d8bc91efce fix(openai): align structured location metadata (#88)
Rewrite only Codex-generated environment context and web search location fields to Piketon, Ohio. Preserve user-authored text and absolute timestamps.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 14:14:08 +08:00
ZyyandZyy e8e2f18927 fix(openai): align WebSocket wire fingerprint (#87)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-09-14 12:52:06 +08:00
0977de3713 chore(deps): align Codex transport dependencies (#86)
Align HTTP and WebSocket TLS dependencies with the locally installed official Codex 0.154.0 client, preserve the hyper-util SOCKS IPv6 fix, and lock captured ClientHello behavior in tests.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 12:02:44 +08:00
dependabot[bot] eaa37d9faa chore(deps): bump the minor-and-patch group in /backend with 4 updates (#85)
Bumps the minor-and-patch group in /backend with 4 updates: [syn](https://github.com/dtolnay/syn), [uuid](https://github.com/uuid-rs/uuid), [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust) and [redis](https://github.com/redis-rs/redis-rs).


Updates `syn` from 3.0.4 to 3.0.5
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/3.0.4...3.0.5)

Updates `uuid` from 1.26.0 to 1.26.1
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.26.0...v1.26.1)

Updates `aws-sdk-s3` from 1.144.0 to 1.146.0
- [Release notes](https://github.com/awslabs/aws-sdk-rust/releases)
- [Commits](https://github.com/awslabs/aws-sdk-rust/commits)

Updates `redis` from 1.6.0 to 1.7.0
- [Release notes](https://github.com/redis-rs/redis-rs/releases)
- [Commits](https://github.com/redis-rs/redis-rs/compare/redis-1.6.0...redis-1.7.0)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 3.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: uuid
  dependency-version: 1.26.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.146.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: redis
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 11:48:32 +08:00
4e588ffa1d chore(deps): ignore Codex-aligned dependencies (#84)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 11:01:34 +08:00
dependabot[bot] b9e919f510 chore(deps): bump rust from 82150a5 to 9a73a50 in /deploy (#80)
Bumps rust from `82150a5` to `9a73a50`.

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.98-bookworm
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:58:58 +08:00
dependabot[bot] 32f9f3675d chore(deps): bump the minor-and-patch group in /frontend with 5 updates (#79)
Bumps the minor-and-patch group in /frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.41.0` | `1.45.0` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.14` | `3.4.15` |
| [marked](https://github.com/markedjs/marked) | `18.0.11` | `18.0.13` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.4.1` | `26.5.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.2` | `8.3.0` |


Updates `@lucide/vue` from 1.41.0 to 1.45.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.45.0/packages/vue)

Updates `dompurify` from 3.4.14 to 3.4.15
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.14...3.4.15)

Updates `marked` from 18.0.11 to 18.0.13
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.11...v18.0.13)

Updates `@types/node` from 26.4.1 to 26.5.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `vite` from 8.2.2 to 8.3.0
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.3.0/packages/vite)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:58:33 +08:00
dependabot[bot] 221d557d5e chore(deps): bump pnpm/action-setup in the minor-and-patch group (#76)
Bumps the minor-and-patch group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `pnpm/action-setup` from 6.0.10 to 6.1.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0977fd99725f1db4007ccb2928dbb4e90d06cc86...ea17c68df8912ef543352723c149a84f56e3d413)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:57:06 +08:00
ZyyandCodex 4105c1ece6 chore: bump version to 3.6.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-13 04:30:39 +08:00
ZyyandCodex 4d59687cbc docs(release): prepare v3.6.1 notes
Co-authored-by: Codex <noreply@openai.com>
2026-09-13 04:30:35 +08:00
ZyyandCodex 8621e24e7c fix: tolerate partial quota usage and remove retired model pricing
Keep quota capacity and remaining estimates based on recorded usage when some token or cost facts are missing. Preserve unknown values and explain potential underestimation without blocking usable forecasts.

Remove OpenAI pricing rules for shutdown models and the GPT-4-0314 alias while preserving historical costs and active regular prices. Document actual tier fallback and model-specific pricing behavior.

Validated with backend test targets, strict workspace Clippy, Rustfmt, frontend lint/typecheck/build, and mocked browser checks for partial, missing, complete, weekly/monthly, and mobile dark forecast states.

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 00:45:26 +08:00
135 changed files with 6019 additions and 1407 deletions
+6 -3
View File
@@ -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`。
+9
View File
@@ -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
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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)。
+6 -13
View File
@@ -5,7 +5,7 @@
# Codex Proxy RS
面向 Codex 的自托管多账号 AI 网关。
面向 Codex 的自托管多账号 AI 网关
[![CI](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/zyycn/codex-proxy-rs?display_name=tag&sort=semver&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/releases)
@@ -28,21 +28,16 @@
### 1. 下载部署文件并配置
```bash
mkdir -p codex-proxy-rs/deploy
cd codex-proxy-rs
mkdir -p codex-proxy-rs/deploy && cd codex-proxy-rs
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/compose.yaml \
-o deploy/compose.yaml
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/config.example.yaml \
-o deploy/config.example.yaml
mkdir -p .runtime/data .runtime/logs
install -d -m 0750 .runtime/postgres .runtime/redis
cp deploy/config.example.yaml deploy/config.yaml
sudo chown "$(id -u):10001" deploy/config.yaml
chmod 0640 deploy/config.yaml
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
chmod 0770 .runtime/data .runtime/logs
sudo install -d -m 0770 -o "$(id -u)" -g 10001 .runtime/data .runtime/logs
sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
```
分别生成数据库和 Redis 密码:
@@ -60,8 +55,6 @@ openssl rand -hex 24
| `store.redis.password` | 第二个生成的 48 位十六进制密码 |
| `admin.default_password` | 管理员初始密码,至少 12 位,不能包含 `$` |
不需要创建 `.env`。管理员初始密码只在首次创建账号时使用。
### 2. 启动服务
```bash
@@ -79,7 +72,7 @@ curl -i http://127.0.0.1:8080/healthz
### 3. 添加账号与客户端密钥
1. 在「账号」中添加 OpenAI 或 xAI 账号,完成授权或导入。
1. 在「账号」中添加账号,完成授权或导入。
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
3. 打开密钥的「使用密钥」,复制客户端配置。
@@ -93,7 +86,7 @@ curl -i http://127.0.0.1:8080/healthz
| 配置 | 值 |
| --- | --- |
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
| API Key | 管理端创建的 `sk_...` 客户端密钥 |
| API Key | 管理端创建的客户端密钥 |
可用模型以该密钥查询到的模型列表为准:
+214 -37
View File
@@ -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(&quota(vec![window("month", 31), window("week", 7)]));
assert_eq!(week.source.unwrap().key, "week");
assert_eq!(month.source.unwrap().key, "month");
assert_eq!(week.source.unwrap().label, "week");
assert_eq!(month.source.unwrap().label, "month");
assert!(!month.extrapolated);
assert_eq!(month.target_seconds, 31 * 86_400);
assert_eq!(month.estimated_tokens, Some(5_000));
@@ -241,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(&quota(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(&quota(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")]
+33 -5
View File
@@ -5,7 +5,11 @@ use std::fmt;
use axum::{
Router,
extract::{FromRequestParts, State},
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, request::Parts},
http::{
HeaderMap, HeaderValue, StatusCode,
header::{ORIGIN, SET_COOKIE},
request::Parts,
},
response::{IntoResponse, Response},
routing::{get, post},
};
@@ -15,18 +19,37 @@ use gateway_admin::{
};
use serde::{Deserialize, Serialize};
use tower_http::request_id::RequestId;
use url::Url;
use super::{AdminEnvelope, AdminError, AdminJson, AdminResponse};
const REQUEST_ID_HEADER: &str = "x-request-id";
const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session";
const ADMIN_SESSION_COOKIE_ATTRS: &str = "Path=/; Secure; HttpOnly; SameSite=Lax";
/// 所有管理 HTTP 模块从 state 消费同一个认证用例端口。
pub trait AdminSessionState {
fn admin_services(&self) -> &AdminServices;
}
fn admin_session_cookie_attrs(headers: &HeaderMap) -> &'static str {
// 同源管理端的 POST 由浏览器携带 Origin,HTTPS 反代回源 HTTP 不改变它。
// 只对明确的 HTTP 来源放宽;缺失、opaque 或非法来源继续使用 Secure。
let http_origin = headers.get_all(ORIGIN).iter().count() == 1
&& headers
.get(ORIGIN)
.and_then(|value| value.to_str().ok())
.is_some_and(|origin| {
Url::parse(origin).is_ok_and(|url| {
url.scheme() == "http" && url.origin().ascii_serialization() == origin
})
});
if http_origin {
"Path=/; HttpOnly; SameSite=Lax"
} else {
"Path=/; Secure; HttpOnly; SameSite=Lax"
}
}
/// 已通过管理员会话或部署级管理 API Key 鉴权的请求。
pub struct AdminAuth {
context: AdminRequestContext,
@@ -203,6 +226,7 @@ where
async fn login<S>(
State(state): State<S>,
headers: HeaderMap,
AdminJson(payload): AdminJson<AdminLoginRequest>,
) -> Result<Response, AdminError>
where
@@ -221,8 +245,9 @@ where
)
.into_response();
let cookie = format!(
"{ADMIN_SESSION_COOKIE}={}; {ADMIN_SESSION_COOKIE_ATTRS}",
session.session_id
"{ADMIN_SESSION_COOKIE}={}; {}",
session.session_id,
admin_session_cookie_attrs(&headers)
);
response.headers_mut().insert(
SET_COOKIE,
@@ -260,7 +285,10 @@ where
let mut response =
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(AdminLogoutData::new()))
.into_response();
let cookie = format!("{ADMIN_SESSION_COOKIE}=; {ADMIN_SESSION_COOKIE_ATTRS}; Max-Age=0");
let cookie = format!(
"{ADMIN_SESSION_COOKIE}=; {}; Max-Age=0",
admin_session_cookie_attrs(&headers)
);
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
@@ -13,7 +13,7 @@ use gateway_admin::model::client_keys::{
use gateway_core::{
engine::budget::ClientBudgetLimits,
metering::Decimal,
policy::{ClientApiKeyId, RateLimits},
policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits},
routing::AccountGroupId,
};
use serde::{Deserialize, Serialize};
@@ -70,9 +70,7 @@ impl ListClientKeysQuery {
}
let search = self.search.map(|search| search.trim().to_owned());
if search.as_deref().is_some_and(|search| {
search.len() > MAX_SEARCH_BYTES
|| search.chars().any(char::is_control)
|| contains_client_key_material(search)
search.len() > MAX_SEARCH_BYTES || search.chars().any(char::is_control)
}) {
return Err(WireValidationError::new("search"));
}
@@ -143,9 +141,10 @@ impl ClientKeySort {
}
/// 创建 Client Key 请求。
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[derive(Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateClientKeyRequest {
custom_key: Option<String>,
name: String,
label: Option<String>,
group_ids: Vec<String>,
@@ -155,6 +154,16 @@ pub struct CreateClientKeyRequest {
weekly_limit_usd: Option<String>,
}
impl fmt::Debug for CreateClientKeyRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CreateClientKeyRequest")
.field("name", &self.name)
.field("custom_key", &"[REDACTED]")
.finish_non_exhaustive()
}
}
impl CreateClientKeyRequest {
/// 校验 wire 边界并直接构造管理用例命令。
pub fn into_command(self) -> Result<CreateClientKey, WireValidationError> {
@@ -164,6 +173,12 @@ impl CreateClientKeyRequest {
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(CreateClientKey {
custom_key: self
.custom_key
.filter(|key| !key.is_empty())
.map(PlaintextClientApiKey::new)
.transpose()
.map_err(|_| WireValidationError::new("customKey"))?,
name: self.name,
label: self.label,
group_ids,
@@ -661,15 +676,6 @@ fn validate_optional_text(
Ok(())
}
fn contains_client_key_material(value: &str) -> bool {
value.as_bytes().windows(46).any(|window| {
&window[..3] == b"sk_"
&& window[3..]
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
})
}
/// 构造固定 GET/POST 且 ID 仅位于 query/body 的 Client API Key 路由。
pub fn router<S>() -> Router<S>
where
@@ -86,7 +86,7 @@ impl ClientApiKeyAuthError {
///
/// # Errors
///
/// Header 缺失、Bearer 语法错误或不是网关 Client Key 前缀时返回稳定错误。
/// Header 缺失、Bearer 语法错误或 Key 不能作为 HTTP Bearer 值时返回稳定错误。
pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAuthError> {
let raw = headers
.get(AUTHORIZATION)
@@ -100,7 +100,7 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu
if token.is_empty() {
return Err(ClientApiKeyAuthError::MalformedAuthorization);
}
if !token.starts_with("sk_") {
if gateway_core::policy::PlaintextClientApiKey::validate(token).is_err() {
return Err(ClientApiKeyAuthError::InvalidKeyFormat);
}
Ok(token)
@@ -6,7 +6,7 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use gateway_core::routing::{PublicModelId, PublicModelProfile};
use gateway_core::routing::{PublicModelDescriptor, PublicModelId, PublicModelProfile};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -14,7 +14,7 @@ use crate::ApiState;
use super::{
auth::{authenticate_client, client_access_error_response},
error::model_not_found_response,
error::{model_not_found_response, openai_error_response},
};
const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000;
@@ -37,19 +37,53 @@ pub(crate) async fn models(
Err(error) => return client_access_error_response(error),
};
if query
if let Some(version) = query
.client_version
.as_deref()
.is_some_and(|version| !version.trim().is_empty())
.filter(|version| !version.trim().is_empty())
{
let models = service
.public_model_profiles(&client)
if version.len() > 64
|| !version
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b".-+".contains(&byte))
{
return openai_error_response(
StatusCode::BAD_REQUEST,
"Invalid client_version",
"invalid_request_error",
"invalid_client_version",
)
.into_response();
}
let catalog = match service.client_model_catalog(&client, version).await {
Ok(catalog) => catalog,
Err(_) => return catalog_unavailable_response(),
};
let models = catalog
.iter()
.enumerate()
.map(|(index, profile)| codex_model_json(profile, index))
.collect::<Vec<_>>();
.map(|(index, entry)| match entry {
PublicModelDescriptor::Adapted(profile) => Ok(codex_model_json(profile, index)),
PublicModelDescriptor::Native { model, payload } => {
if payload.protocol() != "codex" {
return Err(());
}
let mut value: Value =
serde_json::from_slice(payload.body()).map_err(|_| ())?;
let object = value.as_object_mut().ok_or(())?;
// 别名只改变请求标识,能力、提示词、顺序等仍由上游目录决定。
object.insert("slug".to_owned(), Value::String(model.as_str().to_owned()));
Ok(value)
}
})
.collect::<Result<Vec<_>, ()>>();
let Ok(models) = models else {
return catalog_unavailable_response();
};
return (
StatusCode::OK,
// 目录经过账号选择/别名/多 Provider 聚合,不能借用上游 ETag 表示改写后的正文。
[("cache-control", "private, no-store")],
Json(json!({
"models": models,
})),
@@ -72,6 +106,16 @@ pub(crate) async fn models(
.into_response()
}
fn catalog_unavailable_response() -> Response {
openai_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Model catalog is temporarily unavailable",
"server_error",
"model_catalog_unavailable",
)
.into_response()
}
/// `GET /v1/models/{model_id}`。
pub(crate) async fn model_detail(
State(state): State<ApiState>,
@@ -10,7 +10,7 @@ use gateway_core::engine::execution::{
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
use gateway_core::routing::{PublicModelId, PublicModelProfile};
use gateway_core::routing::{ProviderCatalogUnavailable, PublicModelDescriptor, PublicModelId};
use uuid::Uuid;
use super::auth::ClientApiKeyAuthError;
@@ -52,11 +52,14 @@ impl OpenAiService {
.collect()
}
pub(crate) fn public_model_profiles(
pub(crate) async fn client_model_catalog(
&self,
client: &AuthenticatedClient,
) -> Vec<PublicModelProfile> {
self.execution.public_model_profiles(client)
client_version: &str,
) -> Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable> {
self.execution
.client_model_catalog(client, "codex", client_version)
.await
}
pub(crate) fn contains_public_model(
@@ -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)
+144 -12
View File
@@ -1,4 +1,4 @@
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use axum::{
body::{Body, to_bytes},
@@ -11,7 +11,8 @@ use gateway_core::engine::execution::{
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::routing::{
ModelPresentation, ModelServiceTier, PublicModelId, PublicModelProfile,
ModelPresentation, ModelServiceTier, ProviderCatalogUnavailable, PublicModelDescriptor,
PublicModelId, PublicModelProfile,
};
use tower::ServiceExt;
@@ -20,6 +21,8 @@ use super::{api_router, authenticated_client, authenticated_client_with_min_vers
pub(super) struct ModelsExecution {
client: AuthenticatedClient,
presentation: Option<ModelPresentation>,
native: Option<Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>>,
requested_versions: Mutex<Vec<String>>,
}
impl ModelsExecution {
@@ -27,6 +30,8 @@ impl ModelsExecution {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
presentation: None,
native: None,
requested_versions: Mutex::default(),
})
}
@@ -61,6 +66,8 @@ impl ModelsExecution {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
presentation: Some(presentation),
native: None,
requested_versions: Mutex::default(),
})
}
@@ -68,6 +75,8 @@ impl ModelsExecution {
Arc::new(Self {
client: authenticated_client_with_min_versions("sk_models_test", None, Some("0.40.0")),
presentation: None,
native: None,
requested_versions: Mutex::default(),
})
}
@@ -79,11 +88,48 @@ impl ModelsExecution {
None,
),
presentation: None,
native: None,
requested_versions: Mutex::default(),
})
}
fn profiles(&self) -> Vec<PublicModelProfile> {
self.presentation
.as_ref()
.map(|presentation| {
PublicModelProfile::new(
PublicModelId::new("grok-4.5").expect("model"),
presentation.clone(),
)
})
.into_iter()
.collect()
}
}
impl ExecutionService for ModelsExecution {
fn client_model_catalog<'a>(
&'a self,
_client: &'a AuthenticatedClient,
protocol: &'a str,
version: &'a str,
) -> BoxFuture<'a, Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>> {
Box::pin(async move {
assert_eq!(protocol, "codex");
self.requested_versions
.lock()
.expect("versions")
.push(version.to_owned());
self.native.clone().unwrap_or_else(|| {
Ok(self
.profiles()
.into_iter()
.map(PublicModelDescriptor::Adapted)
.collect())
})
})
}
fn authenticate(
&self,
plaintext: &str,
@@ -102,16 +148,6 @@ impl ExecutionService for ModelsExecution {
.collect()
}
fn public_model_profiles(&self, _: &AuthenticatedClient) -> Vec<PublicModelProfile> {
let Some(presentation) = &self.presentation else {
return Vec::new();
};
vec![PublicModelProfile::new(
PublicModelId::new("grok-4.5").expect("model"),
presentation.clone(),
)]
}
fn contains_public_model(&self, _: &AuthenticatedClient, model: &PublicModelId) -> bool {
matches!(model.as_str(), "model-a" | "model-b")
}
@@ -145,6 +181,102 @@ fn authorized_request(path: &str) -> Request<Body> {
.expect("build models request")
}
#[tokio::test]
async fn native_catalog_preserves_complete_objects_and_only_rewrites_alias_slug() {
let original = serde_json::json!({
"slug": "gpt-native", "display_name": "Official name", "priority": 47,
"base_instructions": "Official instructions\nDo not replace.",
"model_messages": {"token_budget": "upstream template", "future_section": [null, {"a": true}]},
"service_tiers": [{"id": "priority", "name": "Fast", "description": "Official speed tier"}],
"tool_mode": "code_mode_only", "use_responses_lite": true,
"future_field": {"nested": [1, null, "value"]}, "explicit_null": null
});
let entry = |model: &str| PublicModelDescriptor::Native {
model: PublicModelId::new(model).expect("model"),
payload: gateway_core::operation::RawJsonPayload::new(
"codex",
serde_json::to_vec(&original).expect("JSON").into(),
)
.expect("payload"),
};
let execution = Arc::new(ModelsExecution {
client: authenticated_client("sk_models_test"),
presentation: None,
native: Some(Ok(vec![entry("gpt-native"), entry("my-alias")])),
requested_versions: Mutex::default(),
});
let response = api_router(execution.clone())
.await
.oneshot(authorized_request(
"/v1/models?client_version=0.154.0-alpha.3",
))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["cache-control"], "private, no-store");
assert!(response.headers().get("etag").is_none());
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
assert_eq!(value["models"][0], original);
let mut alias = original;
alias["slug"] = serde_json::json!("my-alias");
assert_eq!(value["models"][1], alias);
assert_eq!(
*execution.requested_versions.lock().expect("versions"),
["0.154.0-alpha.3"]
);
}
#[tokio::test]
async fn native_catalog_failure_does_not_publish_a_synthetic_or_empty_success() {
let execution = Arc::new(ModelsExecution {
client: authenticated_client("sk_models_test"),
presentation: None,
native: Some(Err(ProviderCatalogUnavailable)),
requested_versions: Mutex::default(),
});
let response = api_router(execution)
.await
.oneshot(authorized_request("/v1/models?client_version=0.154.0"))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
assert_eq!(value["error"]["code"], "model_catalog_unavailable");
assert!(value.get("models").is_none());
}
#[tokio::test]
async fn catalog_rejects_unbounded_or_control_character_versions_before_fetching() {
for version in [
"x".repeat(65),
"0.154.0%0A".to_owned(),
"0.154.0%20".to_owned(),
] {
let execution = ModelsExecution::new();
let response = api_router(execution.clone())
.await
.oneshot(authorized_request(&format!(
"/v1/models?client_version={version}"
)))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(
execution
.requested_versions
.lock()
.expect("versions")
.is_empty()
);
}
}
#[tokio::test]
async fn models_should_encode_the_service_visible_catalog() {
let response = api_router(ModelsExecution::new())
@@ -9,6 +9,106 @@ use super::models::ModelsExecution;
const REMOVED_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
#[tokio::test]
async fn browser_origin_controls_http_admin_sessions_without_configuration() {
use axum::body::to_bytes;
use serde_json::{Value, json};
for (origins, secure) in [
(vec!["http://admin.example.test"], false),
(vec!["http://192.0.2.1:8080"], false),
(vec!["http://[::1]:8080"], false),
(vec!["https://admin.example.test"], true),
(vec![], true),
(vec!["null"], true),
(vec!["invalid-origin"], true),
(vec!["http://admin.example.test/path"], true),
(vec!["http://admin.example.test?https"], true),
(vec!["http://user@admin.example.test"], true),
(
vec!["http://admin.example.test", "https://admin.example.test"],
true,
),
] {
let app = api_router_with_origins(ModelsExecution::new(), Vec::new()).await;
let request = |path: &str| {
let mut builder = Request::post(path)
.header("content-type", "application/json")
.header("x-forwarded-proto", "http")
.header("forwarded", "proto=http");
for origin in &origins {
builder = builder.header("origin", *origin);
}
builder
};
let response = app
.clone()
.oneshot(
request("/api/admin/auth/login")
.body(Body::from(
json!({"username": "admin_1", "password": "strong-admin-password"})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let cookie = response.headers()["set-cookie"].to_str().unwrap();
let session = cookie.split(';').next().unwrap().to_owned();
let attrs: Vec<_> = cookie.split(';').map(str::trim).collect();
assert_eq!(attrs.contains(&"Secure"), secure);
for attribute in ["Path=/", "HttpOnly", "SameSite=Lax"] {
assert!(attrs.contains(&attribute));
}
let response = app
.clone()
.oneshot(
Request::get("/api/admin/auth/status")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body: Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap();
assert_eq!(body["data"]["authenticated"], true);
let response = app
.clone()
.oneshot(
request("/api/admin/auth/logout")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let cookie = response.headers()["set-cookie"].to_str().unwrap();
assert!(cookie.starts_with("cpr_admin_session=;"));
let attrs: Vec<_> = cookie.split(';').map(str::trim).collect();
assert_eq!(attrs.contains(&"Secure"), secure);
for attribute in ["Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0"] {
assert!(attrs.contains(&attribute));
}
let response = app
.oneshot(
Request::get("/api/admin/auth/status")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body: Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap();
assert_eq!(body["data"]["authenticated"], false);
}
}
#[tokio::test]
async fn removed_responses_review_route_should_not_reach_the_responses_handler() {
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
@@ -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 -16
View File
@@ -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",
+3 -3
View File
@@ -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
+17 -2
View File
@@ -53,13 +53,28 @@ impl PlaintextClientApiKey {
///
/// # Errors
///
/// Key 为空、过长或包含控制字符时返回错误。
/// Key 为空或无法作为 HTTP Bearer 值发送时返回错误。
pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
let value = value.into();
validate_text(&value, 512, false, None)?;
Self::validate(&value)?;
Ok(Self(value))
}
/// 迁入的 Key 不限定前缀或长度;保持原值,仅校验 HTTP 可传输的非空可见 ASCII。
///
/// # Errors
///
/// Key 为空或包含空白、控制字符、非 ASCII 字符时返回错误。
pub fn validate(value: &str) -> Result<(), IdentifierError> {
if value.is_empty() {
return Err(IdentifierError::Empty);
}
if !value.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(IdentifierError::InvalidFormat);
}
Ok(())
}
/// 仅借给同步认证器做常量时间比较。
#[must_use]
pub fn expose_for_auth(&self) -> &str {
@@ -1,12 +1,32 @@
//! 快照编译需要的 Provider 目录事实,不暴露执行或账号选择能力。
//! 快照编译与客户端读取需要的 Provider 目录合同,不暴露执行或账号选择能力。
use std::collections::BTreeMap;
use futures::future::BoxFuture;
use crate::identity::ProviderKind;
use crate::operation::RawJsonPayload;
use super::{ModelCapabilities, ModelPresentation, UpstreamModelId};
use super::{
ModelCapabilities, ModelPresentation, PublicModelId, PublicModelProfile, UpstreamModelId,
};
/// Provider 原生客户端目录条目;Core 只解释模型标识,不解释协议正文。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelDescriptor {
pub model: UpstreamModelId,
pub payload: RawJsonPayload,
}
/// 对外目录保留原生正文;只有没有原生目录的 Provider 才使用通用画像适配。
#[derive(Debug, Clone)]
pub enum PublicModelDescriptor {
Native {
model: PublicModelId,
payload: RawJsonPayload,
},
Adapted(PublicModelProfile),
}
/// Provider 实时目录编译后的单模型能力。
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -10,7 +10,7 @@ pub use crate::account::scope::{
pub use crate::identity::ProviderKind;
pub use catalog::{
ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable,
ProviderModelCapabilities,
ProviderModelCapabilities, ProviderModelDescriptor, PublicModelDescriptor,
};
pub use snapshot::RuntimeSnapshot;
@@ -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() {
+14 -6
View File
@@ -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"] }
+22 -1
View File
@@ -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, &current_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 {
+1
View File
@@ -3,3 +3,4 @@ b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.s
46f493ff410cf77550114542e0418475876ee2b2c975436290f0f72440e098c4 0003_account_outbound_proxy.sql
5e4bfd0b47a4de5e99b7ae1d89e0294de61856a40fba3480572db765147c3b3a 0004_remove_client_charge_reconciliation.sql
2741fabb6ec23e7a19962e364ffdc054f81584c868e24453e33644b96ccca19b 0005_managed_outbound_proxies.sql
393968715af911bb09f6738472678fbedcdc0a0967c39711610f52b9a2994c3f 0006_custom_client_keys.sql
@@ -0,0 +1,17 @@
-- 保留旧 Key,允许迁入不同前缀、长度和标点;约束与 HTTP Bearer 可见字符一致。
alter table client_api_keys
drop constraint client_api_keys_key_ck,
add constraint client_api_keys_key_ck check (
key <> '' and key collate "C" !~ '[^!-~]'
);
-- 可见 ASCII 在 UTF-8 中编码固定,因此这里可声明 immutable。
-- 对完整值建固定大小的摘要索引,避免长 Key 超过 PostgreSQL B-tree 索引项上限。
create function client_api_key_sha256(value text) returns bytea
language sql immutable strict parallel safe
return sha256(convert_to(value, 'UTF8'));
create unique index client_api_keys_key_sha256_idx
on client_api_keys (client_api_key_sha256(key));
alter table client_api_keys drop constraint client_api_keys_key_key;
+1 -1
View File
@@ -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
View File
@@ -17,11 +17,9 @@
按快速开始下载部署文件后,从安装目录执行:
```bash
mkdir -p .runtime/data .runtime/logs
install -d -m 0750 .runtime/postgres .runtime/redis
cp deploy/config.example.yaml deploy/config.yaml
sudo chown "$(id -u):10001" deploy/config.yaml
chmod 0640 deploy/config.yaml
sudo install -d -m 0770 -o "$(id -u)" -g 10001 .runtime/data .runtime/logs
sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
```
为 PostgreSQL 与 Redis 分别生成一个密码:
@@ -41,15 +39,9 @@ openssl rand -hex 24
PostgreSQL 与 Redis 密码必须是 48 位十六进制字符。Compose 通过 `config.yaml` 的凭据桥接区
引用同一密码;三个值都不需要额外导出为环境变量,数据库和 Redis 密码也不能嵌入连接 URL。
Linux 上应用容器以 `10001:10001` 运行,需要允许该组写入应用数据和日志目录:
```bash
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
chmod 0770 .runtime/data .runtime/logs
```
`config.yaml` 通过 Compose `configs` 只读挂载。普通 Compose 对本地文件保留宿主机的
UID/GID 和 mode,因此配置由当前用户持有,并只向容器组 `10001` 开放读取权限。
Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和日志目录设为 `0770`,
配置设为 `0640`,均由当前用户持有、容器组 `10001` 访问。
`config.yaml` 通过 Compose `configs` 只读挂载,普通 Compose 保留宿主机文件的 UID/GID 和 mode。
模板中的 `openai` / `xai` 只保留请求画像启动基线。OpenAI 的上游地址、WebSocket 池、额度刷新
与 OAuth 设置,以及 xAI 的 OAuth、额度和模型目录策略,均由各自 Provider 使用代码内默认值管理;
@@ -78,12 +70,17 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet`。
## 公网访问
Compose 默认只绑定 `127.0.0.1`。从其他设备访问时,在应用前配置 HTTPS 反向代理,
Compose 默认只绑定 `127.0.0.1`。从其他设备访问时,在应用前配置反向代理,
不要把 PostgreSQL 或 Redis 暴露到公网。
同源管理端支持 HTTP 和 HTTPS 登录。反向代理应原样保留浏览器的 `Origin`,
不要清除它或改写 Cookie 的 `Secure` 属性;HTTPS 反代可以使用 HTTP 回源。
HTTP 传输不加密,公网部署仍建议使用 HTTPS。
会话 Cookie 合同见 [管理接口鉴权](../docs/api.md#管理接口)。
反向代理需要保留 `Authorization`,支持 `/v1/responses` 的 WebSocket Upgrade,
并关闭 SSE 响应缓冲。读取超时应覆盖长时间生成任务。
客户端使用 `https://你的域名/v1`,不要使用前端开发服务的 `5173/dev/v1`。
客户端使用部署地址下的 `/v1`,协议与部署一致,不要使用前端开发服务的 `5173/dev/v1`。
当前应用只支持单副本,不能通过复制容器扩容。
流式响应在首个上游事件提交后,每 15 秒无输出会发送一次 SSE 注释保活,
@@ -156,7 +153,7 @@ goals = true
### 生图和 WebSocket
新模板已启用原生生图。Codex 可在任务需要图片时调用 `image_gen.imagegen`,
模板启用原生生图。Codex 可在任务需要图片时调用 `image_gen.imagegen`,
再通过代理的 Images 接口生成或编辑图片;也可以在需求中明确要求生成并使用图片。
需要支持该能力的客户端、支持图片输入的对话模型,以及有生图权限和额度的 OpenAI 账号。
代理不会增加上游权限,xAI 账号不能承接这些 Images 请求。
@@ -173,7 +170,7 @@ goals = true
仅含代理密钥的 `auth.json` 配合 `requires_openai_auth = true` 仍可用于已有请求,
但 API Key 登录本身不会启用原生生图。需要生图时换用上述 Provider 配置。
以已核验的 Codex 0.153.4 为准,管理端已移除以下旧字段:
使用 Codex 0.153.4 时,旧配置中的以下字段需删除或替换:
| 字段 | 处理 |
| --- | --- |
@@ -289,8 +286,7 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr
2. 收集响应中的 `x-gateway-request-id`、`x-request-id` / `x-oai-request-id`;配置了
`api.request_id_header` 时也记录该入口头。WebSocket 合成错误的关联头位于本条错误的 `headers`。
在管理端错误列表按 ID 和时间搜索;检查平台条件并主动刷新,翻页不会推进查询时间。
已建立模型执行的 HTTP 响应在没有上游 ID 时以模型执行 ID 回传 `x-request-id`,
不需要新增入口 ID 数据列。旧响应或执行前拒绝若只有入口 ID,改用入口日志和时间定位。
只有入口 ID 时,改用入口日志和时间定位。
3. 打开错误详情,核对上游/客户端状态、发送状态、attempt、失败阶段及后续恢复关联。
下载默认诊断包作为反馈材料,先看 `availability`、`attemptsComplete` 和淘汰计数;
该包不含原始错误正文或完整 trace data。分享前仍应检查关联 ID 等内部信息。
@@ -385,10 +381,8 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
### 备份内容与限制
- 运行时镜像的 `pg_dump`/`pg_restore` 在构建期从与 compose 服务端同一固定
`postgres:18-bookworm` 镜像(`Dockerfile` 的 `POSTGRES_IMAGE`)COPY 到
`/usr/lib/postgresql/18/bin` 并加入容器 PATH,版本与服务端严格一致;运行时只从
Debian 官方源补 `libpq5` 依赖,不引入第三方 APT 源,也不依赖运行时动态安装。
- 官方运行镜像内置与 Compose 数据库服务版本一致的 `pg_dump` / `pg_restore`,无需额外安装。
自行替换 PostgreSQL 版本时,应同步核对备份工具版本。
- 备份暂存目录为 `host.runtime_data_dir/backup-staging`;Compose 默认对应
`/app/.runtime/data/backup-staging`,由 `.runtime/data` 卷持久化,权限 `0700`(仅 `cpr`
用户可读写)。部署卷至少预留一个最大数据库归档的空间。
+1 -3
View File
@@ -1,9 +1,7 @@
# Codex Proxy RS 应用配置模板。
#
# 使用:
# cp deploy/config.example.yaml deploy/config.yaml
# sudo chown "$(id -u):10001" deploy/config.yaml
# chmod 640 deploy/config.yaml
# sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
#
# PostgreSQL 与 Redis 密码分别执行 `openssl rand -hex 24` 生成;管理员初始密码单独设置。
+181 -93
View File
@@ -14,6 +14,9 @@
Authorization: Bearer sk_...
```
自动生成的 Key 保持 `sk_` 格式;迁入的自定义 Key 使用保存时的原值,不限制前缀或固定长度。
无论格式如何,只有已保存且启用的 Client Key 能通过鉴权。
Codex 原生生图配置还会携带 `X-OpenAI-Actor-Authorization: proxy-managed`。
它仅用于客户端识别服务端托管认证,不能代替 Client Key。网关和 OpenAI Provider 都会过滤该请求头,
上游账号身份只由服务端选中的账号提供;不要把真实账号 token 放进该标记。
@@ -51,6 +54,11 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全
- 浏览器登录后得到的 `cpr_admin_session` Cookie;
- `x-api-key: <admin-api-key>`。
同源管理端的登录和退出根据浏览器 `Origin` 自动设置会话 Cookie:HTTP 来源省略 `Secure`,
HTTPS 来源以及缺失、`null` 或非法来源保留 `Secure`。`HttpOnly`、`SameSite=Lax` 始终保留。
不根据 `X-Forwarded-Proto` 等转发头降级;HTTPS 反代使用 HTTP 回源不影响 Cookie。
部署要求见 [公网访问](../deploy/README.md#公网访问)。
请求无需自带 `x-request-id`;缺失时服务端自动生成 UUID 并在响应头回传同一 request ID。
`api.request_id_header` 可改变注入与回传的 header 名,管理端鉴权不依赖该名字。
管理端响应统一带 `Cache-Control: no-store`。
@@ -79,8 +87,7 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全
}
```
管理端本地产生的 `message` 是可安全展示的中文文案;Store、Serde、Provider 内部 `Display` 和原始上游
body 不进入这个通用信封。稳定业务码如下:
管理端本地产生的 `message` 是可安全展示的中文文案,不包含内部异常或原始上游正文。稳定业务码如下:
| HTTP | `code` | 含义 |
| ---: | ---: | --- |
@@ -99,20 +106,21 @@ body 不进入这个通用信封。稳定业务码如下:
未知 `/api/admin/*` 路径使用 `40401`,不会落入 SPA;已存在路径使用错误 method 时返回 `405`、
`40001`,并保留标准 `Allow` header。request ID 继续通过配置的响应 header 返回。
Provider 管理适配使用静态 `public_message` 提供可操作的具体原因,Admin 用例完成安全消息选择后,
API 对 `50201`、`50202` 和 `50301` 也保留该消息,不再用固定错误覆盖;缺少安全消息时仍回退到通用提示。
认证错误和未知内部错误继续使用固定文案,不公开 Provider 内部 message、原始响应或凭据。
`50201`、`50202` 和 `50301` 的 `message` 提供可安全展示的具体原因;缺少安全消息时使用通用提示。
认证错误和未知内部错误使用固定文案,不公开 Provider 内部 message、原始响应或凭据。
手动刷新令牌时,容量/账号租约占用和账号快照冲突仍为 `40901`,但分别提示等待或刷新账号列表。
OpenAI 已收到的刷新失败响应不再统一归为资源冲突:原先落入宽泛 Transport 分类的明确拒绝使用
`50201`,按已解析的错误码区分令牌过期、已使用、已撤销和 `invalid_grant`;刷新接口返回
`token_expired` 时提示“刷新令牌不可用,请重新授权”,不据此断言具体失效原因。无法确认刷新结果时使用
`50202`,提示先核对账号状态、不要立即重复刷新。缺少刷新令牌及原有明确凭据无效分支仍为 `40001`。
上游 `401` 不代表管理员会话失效,也不会触发管理端重新登录。上述变化只修正管理错误的分类和展示,
不改变后台自动刷新、401 恢复退避或账号终态策略;客户端不得仅因状态码从 `409` 改为 `502` 自动重发刷新。
xAI 手动刷新返回无效的新凭据时,从 `40001` 改为 `50202`,因为上游可能已经轮换了旧 RT;
未能完成刷新、但没有明确凭据永久失效证据的 `Rejected` 从 `40001` 改为 `50201`,不再一概提示凭据无效。
Codex PAT 验证服务不可用和身份响应无效分别通过 `50301`、`50201` 保留原有具体提示。
手动刷新令牌时,按错误类型处理:
| `code` | 场景 | 调用方处理 |
| --- | --- | --- |
| `40901` | 刷新容量、账号租约占用或账号快照冲突 | 按提示等待,或重新查询账号后操作 |
| `40001` | 缺少或无效的凭据、已确认刷新令牌失效或账号停用 | 检查凭据或重新授权 |
| `50201` | 明确的上游失败,且未归类为凭据永久失效 | 根据安全消息处理上游拒绝、限流或服务异常 |
| `50202` | 无法确认令牌轮换结果,包括 xAI 返回无效的新凭据 | 先核对账号状态,不要自动重发可能已轮换的一次性令牌 |
| `50301` | 刷新服务或依赖暂不可用 | 检查出站连接与依赖服务后重试 |
OpenAI 上游 `401` 按 `50201` 返回,不代表管理员会话失效,也不要求管理端重新登录。
Codex PAT 验证服务不可用和身份响应无效分别返回 `50301`、`50201` 及对应的安全提示。
### 管理写入一致性
@@ -160,7 +168,6 @@ Responses 不透传下游的逐跳头、反代元数据(如 `cf-*`、`x-forwar
Responses WebSocket 仅接受文本 `response.create`,同一连接串行执行。当前响应期间收到的后续业务帧
留在有界接收队列中,待当前响应完成终结和写出后再逐条校验、准入与执行,不因请求提前到达而断开。
这对齐 Codex 客户端 `stream_request` 持锁至本轮结束的串行行为,不表示支持额外控制消息类型。
接收队列容量为 32 个事件,超载仍关闭连接;Ping/Pong、客户端关闭和服务关闭不等待队列中的请求执行。
客户端使用 HTTP/SSE 时,OpenAI Provider 仍可能选择上游 WebSocket。
@@ -183,8 +190,29 @@ message/type/code;没有结构化错误时使用稳定安全文案,不把原
`GET /v1/models` 默认返回 OpenAI 兼容列表 `{"object": "list", "data": [...]}`;请求携带非空
`client_version` query 参数(Codex 客户端)时改为返回 Codex 专用目录合同 `{"models": [...]}`。
OpenAI Provider 按客户端传入的 `client_version` 请求上游目录,完整保留每个模型 JSON 对象,包括
`base_instructions`、`model_messages`、`service_tiers`、工具与能力字段,以及未知嵌套字段、显式 `null`
和字段缺失的区别。
模型别名仅替换 `slug`,不替换上游展示名、提示词、能力或 `priority`;保持原生模型顺序,新增别名附在后面。
目录仍按当前路由快照的模型存在性过滤,避免公布已知无法路由的模型;新模型需待后台目录对账后进入列表。
xAI 没有 Codex 原生目录,继续使用明确的通用画像适配。
目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 在其中按账号 ID 排序,使用首个成功读取的
合格账号,最多尝试三个账号;同名模型不跨账号或套餐混拼字段。因此单账号、无别名时可保持该账号的
模型对象一致,多账号/多 Provider 聚合不代表“与某个官方账号的整个目录完全一致”,也不会固定后续推理账号。
读取失败返回 `503 model_catalog_unavailable`,不以简化模板或空成功响应覆盖客户端缓存。
原生目录可能返回缓存结果,成功缓存有效期为 5 分钟。每次查询都检查账号资格和 Client Key 范围。
成功目录响应带 `Cache-Control: private, no-store`,不借用上游 ETag 标识经过选择/别名/聚合后的正文。
`service_tiers` 的 `name` 用于生成 `/fast` 等命令,`id` 是请求使用的 `service_tier` 值,二者不能互换。
上游未声明或明确返回空数组时不补档位,也不根据模型名或旧 `additional_speed_tiers` 字段推断 Fast。
该合同对齐
[官方 Codex 0.154.0 的模型元数据](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/openai_models.rs)
与其动态服务档位命令;客户端需重新加载模型目录才能使用更新后的档位信息。
Codex 专用目录中的 `context_window` 与 `max_context_window` 分别表示默认上下文窗口和客户端本地
覆盖的上限。OpenAI Provider 分别传递上游目录中的对应字段,缺失时保留 `null`;网关不通过部署配置
覆盖的上限。OpenAI Provider 原样保留上游对应字段,缺失与 `null` 不互相转换;网关不通过部署配置
覆盖这些值。Codex 客户端配置 `model_context_window` 后,按该值与非空 `max_context_window` 的较小值
使用窗口;上限为空时保留客户端本地值。xAI 目录只声明一个窗口,其 Provider 继续以该值作为客户端覆盖上限。
@@ -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