Compare commits

...
38 Commits
Author SHA1 Message Date
ZyyandCodex c8fdc2b5cd chore(release): prepare v3.7.2
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:55:09 +08:00
2han9wen71an dfabf0f9dd fix(openai): recover quota after same-window usage drops (#95)
OpenAI 周窗口的 used_percent 可以在同一窗口内回落(滚动计量),而
reset_at 固定到窗口边界才变化。恢复规则要求「reset 前进 + 用量低于
10%」,两个条件在滑动周窗口上都无法成立:耗尽账号被钉在旧 reset
日期(最长一周)无法参与调度,尽管 /usage 已显式报告 allowed=true /
limit_reached=false 且 5h 窗口全新。

新增第二条解除路径:同一窗口(reset 未滚动)内连续两次观测都未触顶
即解除。候选记录持久化在 _quota_recovery,跨 worker 周期与重启累积;
连续两次的要求保持对「耗尽后立刻拉到的旧快照」竞态的防护(单次旧
快照撑不过两次观测)。原有「reset 前进 + 用量<10%」快路径不变。
2026-09-15 15:38:46 +08:00
ZyyandCodex 817b0f2d6d chore(release): commit version and notes together
Allow staged or unstaged release notes and commit their final contents with the version update before tagging. Continue rejecting unrelated tracked changes and require notes to remain uncommitted.

Update the release skill, skip duplicate push CI for release metadata alone, and check the publish script syntax in CI. Validated publication behavior in temporary Git repositories, Bash syntax, and Actionlint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:14:18 +08:00
ZyyandCodex a975868046 fix(release): publish version-matched deployment assets
Publish the configuration template and Compose file with release checksums, pin the packaged Compose image to the release version, and include the template in platform archives.

Download deployment assets from one release tag, link versioned deployment documentation, and translate the release installation notes into Chinese.

Validated Actionlint, stable and prerelease packaging for all platforms, SHA-256 checksums, and resolved Docker Compose image versions.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:02:49 +08:00
ZyyandCodex cc2dfab19a fix(openai): preserve client location when unset
Accept omitted and null request locations without rewriting web search location or environment dates and timezones. Keep complete configured overrides validated and document the passthrough behavior.

Validated 626 OpenAI tests, 38 gateway tests including architecture checks, Rustfmt, and strict workspace Clippy.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:02:36 +08:00
ZyyandCodex 040a1e82a6 chore: bump version to 3.7.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:32:29 +08:00
ZyyandCodex 4f39bfc5a8 docs(release): prepare v3.7.1 notes
Document configurable request location, WebSocket SSE error delivery, relaxed Compose resource limits, and the required image/config version alignment.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:32:20 +08:00
ZyyandCodex 3e3616863d fix(deploy): remove fixed container resource limits
Allow the default Compose deployment to run on hosts with fewer than two CPUs and leave CPU and memory policy to the deployment environment.

Validated the rendered Docker Compose configuration.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:01:21 +08:00
1bbae67040 fix(gateway-api): translate upstream WebSocket error events into SSE response.failed (#90)
* fix(gateway-api): translate upstream WebSocket error events into SSE response.failed

WS 上游协议用 `error` 事件报告流中途失败(如 OpenAI 的 server_is_overloaded),
SSE 协议的对应形态是 `response.failed`。SSE 交付边界此前把 `error` 事件按
未知事件原样透传,而 codex 等 SSE 客户端会静默忽略未知事件类型:客户端只看到
无原因的流 EOF("stream closed before response.completed"),丢失了上游的
完整错误负载(message/code/type),也无法据此分类重试。

现在 SSE 编码器在交付边界把 `error` wire 事件翻译成 `response.failed`:
错误负载(type/code/message/param)原样保留,response id 沿用已交付的
response.created。WS 客户端交付不变(WS 协议原生支持 error 帧)。
原有的会话级失败兜底(finish_with_gateway_error 合成 response.failed)不变。

* fix(gateway-api): preserve upstream response failures

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex fc07ac0803 fix(ci): scope rustls security waiver
Temporarily waive RUSTSEC-2026-0285 until 2026-09-21 UTC to preserve the pinned Codex TLS fingerprint. Keep all other audit failures blocking.

Validated cargo audit and waiver expiry behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex c7823a7821 feat(openai): make request location configurable
Group country, region, city, and timezone under openai.wire_profile.location while retaining the existing defaults. Share the configured location across web search and environment context, preserve epoch timestamps and release updates, and add validation, regression tests, and deployment documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex 61d1bcb405 refactor(api): standardize request parameters and trim unused options
Use data for POST bodies and params: data for GET queries. Remove request options that have no callers while retaining cancellation, timeout, and silent polling support. Update affected backup and client-download call sites and document the convention.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:25 +08:00
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
162 changed files with 6989 additions and 1582 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;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
+4 -3
View File
@@ -53,8 +53,9 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
## 执行正式发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
当前脚本要求先提交 `release/notes.md`,再由脚本更新 `release/version.yaml` 并创建版本提交,
不要提前把版本文件改成目标版本,否则脚本会判定该版本已设置。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): prepare vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权正式发布后,将 `release_version` 设为已确定的版本,执行:
@@ -63,7 +64,7 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
release/publish "$release_version"
```
该命令会创建版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
## 恢复与验收
+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:
+4 -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
@@ -205,3 +205,6 @@ jobs:
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
-color
.github/workflows/*.yml
- name: Check release script syntax
run: bash -n release/publish
+1
View File
@@ -6,6 +6,7 @@ on:
- main
paths-ignore:
- 'release/version.yaml'
- 'release/notes.md'
pull_request:
branches:
- main
+31 -14
View File
@@ -315,6 +315,7 @@ jobs:
shell: bash
env:
VERSION: ${{ needs.release-info.outputs.version }}
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
run: |
set -euo pipefail
ruby <<'RUBY' > release-platforms.json
@@ -349,7 +350,18 @@ jobs:
shutil.rmtree(release_dir, ignore_errors=True)
release_dir.mkdir(parents=True)
archives = []
config_template = pathlib.Path("deploy/config.example.yaml")
shutil.copy2(config_template, release_dir / config_template.name)
compose = pathlib.Path("deploy/compose.yaml").read_text()
image = f"{os.environ['REGISTRY']}/{os.environ['IMAGE_NAME']}"
default_image = "${CPR_IMAGE:-" + image + ":latest}"
if compose.count(default_image) != 1:
raise SystemExit("Expected exactly one default application image in deploy/compose.yaml")
# 发布附件固定镜像版本,防止后续 pull 与随包配置模板脱节。
versioned_image = "${CPR_IMAGE:-" + f"{image}:{os.environ['DOCKER_VERSION']}" + "}"
compose = compose.replace(default_image, versioned_image)
(release_dir / "compose.yaml").write_text(compose)
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
for platform in platforms:
platform_name = f"{platform['os']}-{platform['arch']}"
@@ -367,6 +379,9 @@ jobs:
shutil.copy2(binary_src, binary_dest)
binary_dest.chmod(0o755)
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
deploy_dir = package_root / "deploy"
deploy_dir.mkdir()
shutil.copy2(config_template, deploy_dir / config_template.name)
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
archive_path = release_dir / archive_name
@@ -375,12 +390,12 @@ jobs:
archive.add(package_root, arcname=APP_NAME)
else:
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
archives.append(archive_path)
assets.append(archive_path)
checksum_path = release_dir / "checksums.txt"
with checksum_path.open("w", encoding="utf-8") as file:
for archive in archives:
file.write(f"{sha256(archive)} {archive.name}\n")
for asset in assets:
file.write(f"{sha256(asset)} {asset.name}\n")
PY
- name: Generate release asset provenance
@@ -655,25 +670,27 @@ jobs:
env:
INSTALLATION_NOTES: |
> [!WARNING]
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
> **跨大版本升级:** 不支持跨大版本在线升级。请使用全新的 `.runtime/` 数据目录重新部署,再重新导入或重新授权服务商账号,并重新导入客户端密钥。详见[升级说明](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md#镜像升级与源码构建)。
## 📥 Installation
## 📥 安装
**Docker:**
**Docker 部署:**
```bash
# GitHub Container Registry
# 从 GitHub 容器镜像仓库拉取
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
```
**Manual download:**
Download the appropriate archive for your platform from the assets below.
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
Release archives are covered by GitHub artifact attestations. Docker images are published with SBOM/provenance attestations and keyless cosign signatures.
**手动下载:**
请从下方附件中下载适合当前平台的归档包。
## 📚 Documentation
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
- [GitHub Repository](https://github.com/${{ github.repository }})
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
## 📚 文档
- [GitHub 仓库](https://github.com/${{ github.repository }})
- [部署指南](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md)
run: |
python3 <<'PY'
import os
+11 -2
View File
@@ -40,7 +40,16 @@ jobs:
- name: Run cargo audit
working-directory: backend
run: cargo audit
shell: bash
run: |
# 临时接受此中危风险以保持 Codex 0.154.0 的 TLS 指纹;直接升级会新增 ML-DSA 签名算法。
# 仅豁免本条公告,2026-09-21 UTC 起恢复阻断;兼容升级验证完成后应提前删除豁免。
# https://github.com/rustls/rustls/security/advisories/GHSA-2mjx-qc3c-rqvc
audit_advisory_args=()
if [[ "$(date -u +%F)" < "2026-09-21" ]]; then
audit_advisory_args+=(--ignore RUSTSEC-2026-0285)
fi
cargo audit "${audit_advisory_args[@]}"
frontend-security:
runs-on: ubuntu-latest
@@ -52,7 +61,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)。
+15 -16
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)
@@ -21,30 +21,31 @@
## 快速开始
使用 Docker Compose 部署发布镜像 `ghcr.io/zyycn/codex-proxy-rs:latest`,同时启动 PostgreSQL 和 Redis。
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
### 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 \
# 只解析一次最新正式版本,确保两个文件来自同一 Release。
CPR_RELEASE_URL="$(curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/zyycn/codex-proxy-rs/releases/latest)"
CPR_RELEASE_TAG="${CPR_RELEASE_URL##*/}"
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/compose.yaml" \
-o deploy/compose.yaml
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/config.example.yaml \
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/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
```
也可将 `CPR_RELEASE_TAG` 设置为指定的发布标签。Release 附带的 `compose.yaml` 默认使用该版本镜像,
配置模板和部署文件均包含在 `checksums.txt` 中;不要混用 `main` 分支模板与已发布镜像。
分别生成数据库和 Redis 密码:
```bash
@@ -60,8 +61,6 @@ openssl rand -hex 24
| `store.redis.password` | 第二个生成的 48 位十六进制密码 |
| `admin.default_password` | 管理员初始密码,至少 12 位,不能包含 `$` |
不需要创建 `.env`。管理员初始密码只在首次创建账号时使用。
### 2. 启动服务
```bash
@@ -79,7 +78,7 @@ curl -i http://127.0.0.1:8080/healthz
### 3. 添加账号与客户端密钥
1. 在「账号」中添加 OpenAI 或 xAI 账号,完成授权或导入。
1. 在「账号」中添加账号,完成授权或导入。
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
3. 打开密钥的「使用密钥」,复制客户端配置。
@@ -93,7 +92,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 | 管理端创建的客户端密钥 |
可用模型以该密钥查询到的模型列表为准:
+215 -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",
@@ -709,6 +708,7 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
"serde",
]
[[package]]
@@ -740,7 +740,7 @@ dependencies = [
"provider-xai",
"serde",
"serde_json",
"syn 3.0.4",
"syn 3.0.5",
"tempfile",
"thiserror",
"tokio",
@@ -749,9 +749,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 +808,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 +1202,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 +1301,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 +1408,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -1879,6 +1904,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 +1937,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2236,6 +2279,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 +2351,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 +2718,7 @@ dependencies = [
"base64 0.23.1",
"bytes",
"chrono",
"chrono-tz",
"cookie",
"flate2",
"futures",
@@ -2615,6 +2730,9 @@ dependencies = [
"hyper",
"hyper-rustls",
"hyper-util",
"native-tls",
"openssl",
"openssl-sys",
"reqwest",
"roxmltree",
"rustls",
@@ -2741,7 +2859,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2840,9 +2958,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 +3037,7 @@ dependencies = [
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
@@ -2927,9 +3046,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 +3063,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -3011,7 +3134,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3032,21 +3155,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 +3253,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 +3273,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 +3341,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -3663,9 +3799,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 +3828,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 +3870,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3742,7 +3899,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -3811,9 +3968,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 +3993,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 +4351,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 +4586,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"
+33
View File
@@ -310,6 +310,39 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replace("desktop_build: '8109'", "desktop_build: 'build'"));
}
#[test]
fn config_loader_should_accept_empty_and_custom_request_locations() {
let original = valid_config();
let omitted = original
.lines()
.filter(|line| !line.trim_start().starts_with("location:"))
.collect::<Vec<_>>()
.join("\n");
assert_ne!(original, omitted);
parse_config(&omitted).expect("location passthrough when omitted");
let location_line = original
.lines()
.find(|line| line.trim_start().starts_with("location:"))
.expect("example location");
for empty in [" location:", " location: null", " location: ~"] {
parse_config(&original.replace(location_line, empty)).expect("empty YAML location");
}
let custom = original.replace(
"location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
"location: { country: 'NZ', region: 'Auckland', city: 'Auckland', timezone: 'Pacific/Auckland' }",
);
assert_ne!(original, custom);
parse_config(&custom).expect("custom location from YAML");
}
#[test]
fn config_loader_should_reject_invalid_request_location_timezones() {
let original = valid_config();
let invalid = original.replace("timezone: 'America/New_York'", "timezone: 'Not/A_Timezone'");
assert_ne!(original, invalid);
assert_rejected(invalid);
}
fn assert_rejected(config: String) {
assert!(parse_config(&config).is_err());
}
@@ -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>,
@@ -2,7 +2,9 @@
use bytes::Bytes;
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent};
use gateway_protocol::openai::sse::encode_sse_event_with_metadata;
use gateway_protocol::openai::sse::{
encode_sse_event_with_metadata, response_failed_sse_data_from_error_event,
};
use serde_json::Value;
use super::error::ResponseEncodeError;
@@ -17,6 +19,7 @@ const OPENAI_PROTOCOL: &str = "openai";
#[derive(Debug, Default)]
pub struct OpenAiResponsesEncoder {
response_id: Option<String>,
response_snapshot: Option<Value>,
wire_terminal: Option<Value>,
wire_failure: bool,
}
@@ -27,6 +30,7 @@ impl OpenAiResponsesEncoder {
pub const fn new() -> Self {
Self {
response_id: None,
response_snapshot: None,
wire_terminal: None,
wire_failure: false,
}
@@ -39,6 +43,22 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因。
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`。
if wire.event_type() == Some("error")
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
)
{
return vec![Bytes::from(encode_sse_event_with_metadata(
"response.failed",
&data.to_string(),
wire.sse_id(),
wire.sse_retry(),
))];
}
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
return vec![raw_sse_frame.clone()];
}
@@ -110,6 +130,16 @@ impl OpenAiResponsesEncoder {
let effective_type = wire
.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str));
if matches!(
effective_type,
Some("response.created" | "response.in_progress" | "response.queued")
) && let Some(response) = wire
.data()
.get("response")
.filter(|value| value.is_object())
{
self.response_snapshot = Some(response.clone());
}
if matches!(
effective_type,
Some("response.completed" | "response.incomplete")
@@ -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())
@@ -34,6 +34,7 @@ use gateway_core::event::{
use gateway_core::operation::{Operation, OperationKind};
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::{OpaqueUpstreamValue, UpstreamSendState};
use gateway_protocol::openai::sse::{encode_sse_event, parse_sse_events};
use serde_json::{Value, json};
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
@@ -1572,6 +1573,113 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
);
}
#[tokio::test]
async fn streaming_upstream_error_event_should_be_translated_to_response_failed_for_sse() {
let trace = Arc::new(Trace::default());
let error_data = json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
let error_wire = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some("error".to_owned()),
error_data,
projected_error_frame,
None,
None,
)
.expect("valid upstream error wire"),
);
let started_wire = ProviderEvent::canonical_with_wire(
vec![started()],
ProtocolWireEvent::json(
"openai",
Some("response.created".to_owned()),
json!({
"type": "response.created",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "in_progress",
"future_response_field": {"keep": true}
}
}),
)
.expect("valid upstream started wire"),
);
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(CoordinatedEvent::single(
started_wire,
CommitRequirement::CommitBeforeDelivery,
)),
NextStep::Event(CoordinatedEvent::single(
error_wire,
CommitRequirement::AlreadyCommitted,
)),
NextStep::Error(EngineError::Provider(ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
))),
],
);
let response = stream_execution_response(Box::new(session), None).await;
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read SSE body");
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
let events = parse_sse_events(&body).expect("translated SSE should parse");
let failed = events
.iter()
.find(|event| event.event.as_deref() == Some("response.failed"))
.expect("translated response.failed event");
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
assert_eq!(
(
failed,
body.matches("event: response.failed").count(),
body.contains("event: error\n"),
body.ends_with("data: [DONE]\n\n"),
),
(
json!({
"type": "response.failed",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"future_response_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
}),
1,
false,
true,
)
);
}
#[tokio::test]
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
let trace = Arc::new(Trace::default());
@@ -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());
@@ -2,7 +2,7 @@
use std::fmt;
use serde_json::json;
use serde_json::{Value, json};
use thiserror::Error;
/// 单条 SSE 事件。
@@ -277,12 +277,7 @@ pub fn response_failed_sse_data_with_id(
"code": code,
"message": message,
});
let response_id = response_id
.filter(|value| !value.trim().is_empty())
.map_or_else(
|| format!("resp_proxy_{}", uuid::Uuid::new_v4().simple()),
ToString::to_string,
);
let response_id = response_id_or_generated(response_id);
json!({
"type": "response.failed",
"response": {
@@ -294,6 +289,50 @@ pub fn response_failed_sse_data_with_id(
})
}
/// 将携带嵌套错误的上游事件投影为 OpenAI Responses `response.failed` 数据。
///
/// 该投影供客户端 SSE 兼容转换使用。原始错误对象和事件级元数据保持不透明;
/// 已有 response 快照只更新失败终态字段。
pub fn response_failed_sse_data_from_error_event(
response: Option<&Value>,
response_id: Option<&str>,
error_event: &Value,
) -> Option<Value> {
let mut event = error_event.as_object()?.clone();
let error = event.remove("error").filter(Value::is_object)?;
let mut response = response
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let has_response_id = response
.get("id")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
if !has_response_id {
response.insert(
"id".to_owned(),
Value::String(response_id_or_generated(response_id)),
);
}
response.insert("status".to_owned(), Value::String("failed".to_owned()));
response.insert("error".to_owned(), error);
event.insert(
"type".to_owned(),
Value::String("response.failed".to_owned()),
);
event.insert("response".to_owned(), Value::Object(response));
Some(Value::Object(event))
}
fn response_id_or_generated(response_id: Option<&str>) -> String {
response_id
.filter(|value| !value.trim().is_empty())
.map_or_else(
|| format!("resp_proxy_{}", uuid::Uuid::new_v4().simple()),
ToString::to_string,
)
}
/// 返回下一个完整 SSE 帧结束位置(含分隔符)。
pub fn sse_frame_end(bytes: &[u8]) -> Option<usize> {
sse_frame_separator_bytes(bytes).map(|(position, separator_len)| position + separator_len)
@@ -1,8 +1,10 @@
use gateway_protocol::openai::sse::{
DONE_SSE_FRAME, MAX_SSE_EVENT_BUFFER_BYTES, SseError, SseEvent, SseEventDecoder,
encode_sse_event, encode_sse_event_with_metadata, parse_sse_events, response_failed_sse_event,
encode_sse_event, encode_sse_event_with_metadata, parse_sse_events,
response_failed_sse_data_from_error_event, response_failed_sse_event,
response_failed_sse_event_with_id, sse_frame_end, sse_frame_is_done,
};
use serde_json::json;
fn sse_body_has_done(body: &str) -> bool {
body.trim_end_matches(['\r', '\n'])
@@ -386,6 +388,57 @@ fn response_failed_event_should_preserve_existing_response_id() {
assert_eq!(data["response"]["id"], "resp_existing");
}
#[test]
fn response_failed_projection_should_preserve_response_error_and_event_metadata() {
let response = json!({
"id": "resp_existing",
"model": "gpt-test",
"status": "in_progress",
"future_response_field": {"keep": true}
});
let error_event = json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "overloaded",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected = response_failed_sse_data_from_error_event(
Some(&response),
Some("ignored_fallback"),
&error_event,
)
.expect("wrapped error event should project");
assert_eq!(
projected,
json!({
"type": "response.failed",
"response": {
"id": "resp_existing",
"model": "gpt-test",
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "overloaded",
"param": null,
"future_error_field": {"keep": true}
},
"future_response_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
})
);
}
#[test]
fn done_frame_detection_should_require_exact_single_data_field() {
assert_eq!(
@@ -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 = { version = "0.10", features = ["serde"] }
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,
+31 -1
View File
@@ -8,7 +8,9 @@ use serde::Deserialize;
use url::{Host, Url};
use crate::credential::CodexQuotaRefreshPolicy;
use crate::transport::profile::{CodexResidency, CodexWireProfile, CodexWireProfileState};
use crate::transport::profile::{
CodexRequestLocation, CodexResidency, CodexWireProfile, CodexWireProfileState,
};
use crate::transport::session::{CodexSessionIdentity, CodexSessionIdentityError};
use crate::transport::websocket::CodexWebSocketPoolConfig;
use crate::{
@@ -290,6 +292,9 @@ pub struct CodexWireProfileConfig {
pub terminal: String,
#[serde(default)]
pub residency: Option<CodexResidency>,
/// 为空时保留客户端的地区、环境日期和时区。
#[serde(default)]
pub location: Option<CodexRequestLocation>,
pub verified_at: DateTime<Utc>,
}
@@ -305,6 +310,7 @@ impl Default for CodexWireProfileConfig {
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
residency: None,
location: None,
// 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。
verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084),
}
@@ -336,6 +342,29 @@ impl CodexWireProfileConfig {
return Err(OpenAiConfigError::InvalidField(field));
}
}
if let Some(location) = &self.location {
for (field, value) in [
(
"openai.wire_profile.location.region",
location.region.as_str(),
),
("openai.wire_profile.location.city", location.city.as_str()),
] {
if value.trim().is_empty() {
return Err(OpenAiConfigError::InvalidField(field));
}
}
if location.country.len() != 2
|| !location
.country
.bytes()
.all(|byte| byte.is_ascii_uppercase())
{
return Err(OpenAiConfigError::InvalidField(
"openai.wire_profile.location.country",
));
}
}
if semver::Version::parse(&self.codex_version).is_err() {
return Err(OpenAiConfigError::InvalidField(
"openai.wire_profile.codex_version",
@@ -367,6 +396,7 @@ impl From<CodexWireProfileConfig> for CodexWireProfile {
arch: value.arch,
terminal: value.terminal,
residency: value.residency,
location: value.location,
verified_at: value.verified_at,
}
}
@@ -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,
@@ -18,6 +18,10 @@ pub(super) struct QuotaRecovery {
// 绑定本次耗尽事实,避免真实推理成功后再次耗尽时复用上次的恢复进度。
exhausted_at_micros: i64,
pending: BTreeMap<String, Option<DateTime<Utc>>>,
// 已连续观测到"未触顶"的待解除窗口;第二次确认后解除。旧文档没有该
// 字段时反序列化为空集,从当前耗尽事实重新积累证据。
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
candidates: BTreeMap<String, ()>,
}
impl QuotaRecovery {
@@ -59,23 +63,44 @@ impl QuotaRecovery {
Self {
exhausted_at_micros: exhaustion_time(state),
pending,
candidates: BTreeMap::new(),
}
}
fn observe(&mut self, refreshed: &CodexAccountQuotaSnapshot) -> bool {
let had_baseline = !self.pending.is_empty();
let mut candidates = BTreeMap::new();
for window in refreshed.windows().iter().filter(|w| w.is_account_wide()) {
if let Some(previous_reset) = self.pending.get_mut(window.key()) {
if window_reset_recovered(window, *previous_reset) {
self.pending.remove(window.key());
} else if previous_reset.is_none() {
*previous_reset = window.reset_at();
let key = window.key();
if let Some(previous_reset) = self.pending.get(key).copied() {
if window_reset_recovered(window, previous_reset) {
self.pending.remove(key);
continue;
}
if previous_reset.is_none() {
// 首次观测到该窗口只锚定 reset 基线,不构成解除证据。
self.pending.insert(key.to_owned(), window.reset_at());
continue;
}
// 同一窗口内用量回落(reset 不滚动)时"reset 前进"永远不会
// 成立,滑动窗口的正常用量也长期高于低用量阈值;以上游连续
// 两次观测都报告未触顶为解除证据。单次观测无法排除耗尽后
// 立刻拉到的旧快照(reset 未滚动、用量尚未触顶)。
let rolled_forward = window
.reset_at()
.is_some_and(|current| Some(current) > previous_reset);
if !rolled_forward && !window.limit_reached() {
if self.candidates.contains_key(key) {
self.pending.remove(key);
} else {
candidates.insert(key.to_owned(), ());
}
}
} else if window.limit_reached() {
self.pending
.insert(window.key().to_owned(), window.reset_at());
self.pending.insert(key.to_owned(), window.reset_at());
}
}
self.candidates = candidates;
had_baseline && self.pending.is_empty()
}
}
@@ -85,15 +110,24 @@ fn exhaustion_time(state: QuotaState) -> i64 {
}
fn window_reset_recovered(window: &CodexQuotaWindow, previous: Option<DateTime<Utc>>) -> bool {
previous
// reset 必须前进:防止耗尽后立刻拉到的旧快照(reset 未滚动)误解锁。
if !previous
.zip(window.reset_at())
.is_some_and(|(previous, current)| current > previous)
&& window
.used_percent()
.is_some_and(|used| used < RESET_RECOVERY_MAX_USED_PERCENT)
{
return false;
}
// reset 前进说明已进入新窗口,新窗口用量应接近零;高用量说明 reset 基线
// 不可信(例如滑动窗口),保持锁定,等待"同窗口用量回落"的连续观测解除。
window
.used_percent()
.is_some_and(|used| used < RESET_RECOVERY_MAX_USED_PERCENT)
}
/// 统一手动刷新和 worker 的恢复规则;上游 allowed 不参与已耗尽窗口的解除判断。
/// 统一手动刷新和 worker 的恢复规则。已耗尽窗口的解除有两条证据路径:
/// reset 前进且用量低于低用量阈值(窗口已滚动),或同一窗口内连续两次观测
/// 都未触顶(滑动窗口的用量回落,reset 不滚动)。后者防止账号被钉在旧
/// reset 日期上;连续两次的要求排除耗尽后立刻拉到的旧快照。
pub(super) fn reconcile_refresh(
current: QuotaState,
refreshed: &mut CodexAccountQuotaSnapshot,
@@ -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,
}
@@ -72,7 +72,7 @@ use crate::transport::diagnostics::{
CodexFailureCategory, CodexUpstreamFailure, CodexUpstreamSendPhase,
};
use crate::transport::profile::{
APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexWireProfileState,
APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexRequestLocation, CodexWireProfileState,
};
use crate::transport::protocol::responses::{
CodexResponsesRequest, PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
@@ -141,6 +141,7 @@ pub struct CodexProvider {
quota: Arc<CodexCredentialQuotaService>,
account_feedback: Arc<AccountFeedbackStats>,
client: CodexBackendClient,
location: Option<CodexRequestLocation>,
responses_url: Url,
image_generations_url: Url,
image_edits_url: Url,
@@ -173,6 +174,7 @@ impl CodexProvider {
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let search_url = Url::parse(&endpoint_url(&base_url, CODEX_ALPHA_SEARCH_PATH))
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let location = profile.snapshot().location;
let client =
CodexBackendClient::new(http, base_url, profile).with_websocket_pool(websocket_pool);
Ok(Self {
@@ -181,6 +183,7 @@ impl CodexProvider {
quota,
account_feedback,
client,
location,
responses_url,
image_generations_url,
image_edits_url,
@@ -253,7 +256,8 @@ impl Provider for CodexProvider {
..Default::default()
};
};
let Ok(encoded) = encode_generate_request(request, "observability") else {
let Ok(encoded) = encode_generate_request(request, "observability", self.location.as_ref())
else {
return ProviderRequestObservation::default();
};
let semantics = encoded.semantics();
@@ -291,6 +295,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,
@@ -335,8 +358,9 @@ impl Provider for CodexProvider {
};
let previous_session = decode_openai_session_state(generate);
let continuation_requested = generate.native_continuation_requested();
let mut upstream_request = encode_generate_request(generate, upstream_model.as_str())
.map_err(map_request_error)?;
let mut upstream_request =
encode_generate_request(generate, upstream_model.as_str(), self.location.as_ref())
.map_err(map_request_error)?;
if let Some(conversation_id) = previous_session
.as_ref()
.and_then(|state| state.conversation_id.as_ref())
@@ -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;
@@ -39,6 +39,27 @@ pub enum CodexResidency {
Us,
}
/// 环境上下文与 Web Search 共用的请求地区画像。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CodexRequestLocation {
pub country: String,
pub region: String,
pub city: String,
pub timezone: chrono_tz::Tz,
}
impl Default for CodexRequestLocation {
fn default() -> Self {
Self {
country: "US".to_owned(),
region: "Ohio".to_owned(),
city: "Piketon".to_owned(),
timezone: chrono_tz::America::New_York,
}
}
}
/// Codex Desktop 上游请求身份。
///
/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。运行时只会使用
@@ -63,6 +84,8 @@ pub struct CodexWireProfile {
pub terminal: String,
/// 未配置时不发送 residency 头;不随制品版本更新而改变。
pub residency: Option<CodexResidency>,
/// 可选的地区覆盖;为空时透传,不随制品版本更新而改变。
pub location: Option<CodexRequestLocation>,
/// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验。
pub verified_at: DateTime<Utc>,
}
@@ -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,15 +3,18 @@
use std::io;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use chrono::{DateTime, Utc};
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 serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
use crate::transport::profile::CodexRequestLocation;
use crate::transport::protocol::responses::{
CodexResponsesRequest, X_CODEX_TURN_STATE_CLIENT_METADATA_KEY,
};
@@ -20,6 +23,7 @@ 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";
const UNSUPPORTED_CODEX_RESPONSES_FIELDS: &[&str] = &["max_output_tokens", "temperature"];
const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[
@@ -93,13 +97,14 @@ pub enum CodexRequestEncodeError {
pub fn encode_generate_request(
request: &GenerateRequest,
upstream_model: &str,
location: Option<&CodexRequestLocation>,
) -> Result<CodexResponsesRequest, CodexRequestEncodeError> {
let payload = request.protocol_payload();
if payload.protocol() != "openai" {
return Err(CodexRequestEncodeError::InvalidProtocolPayload);
}
let mut body = payload.body().clone();
adapt_codex_responses_body(&mut body, upstream_model);
adapt_codex_responses_body(&mut body, upstream_model, location);
let mut encoded = CodexResponsesRequest::from_body(body);
encoded.explicit_prompt_cache_key = encoded.prompt_cache_key().is_some();
@@ -108,11 +113,141 @@ pub fn encode_generate_request(
Ok(encoded)
}
fn adapt_codex_responses_body(body: &mut Map<String, Value>, upstream_model: &str) {
fn adapt_codex_responses_body(
body: &mut Map<String, Value>,
upstream_model: &str,
location: Option<&CodexRequestLocation>,
) {
body.insert("model".to_owned(), Value::String(upstream_model.to_owned()));
for field in UNSUPPORTED_CODEX_RESPONSES_FIELDS {
body.remove(*field);
}
if let Some(location) = location {
align_structured_location_fields(body, Utc::now(), location);
}
}
fn align_structured_location_fields(
body: &mut Map<String, Value>,
now: DateTime<Utc>,
location: &CodexRequestLocation,
) {
// 只改写带环境标记的日期和时区;epoch 时间戳保持绝对时间原值。
let current_date = now
.with_timezone(&location.timezone)
.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, location.timezone.name());
}
}
if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) {
for tool in tools {
align_web_search_location(tool, location);
}
}
}
fn align_environment_context(item: &mut Value, current_date: &str, timezone: &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, timezone) {
*text = aligned;
}
}
}
fn aligned_environment_context(text: &str, current_date: &str, timezone: &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>{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, location: &CodexRequestLocation) {
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(),
json!({
"type": "approximate",
"country": location.country,
"region": location.region,
"city": location.city,
"timezone": location.timezone.name(),
}),
);
}
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",
@@ -660,7 +660,7 @@ async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export
let Operation::Generate(request) = operation else {
panic!("connection test must be a generate operation");
};
let encoded = provider_openai::encode_generate_request(&request, "gpt-5.4")
let encoded = provider_openai::encode_generate_request(&request, "gpt-5.4", None)
.expect("official OpenAI request");
assert_eq!(
encoded.body().get("model").and_then(Value::as_str),
@@ -1347,6 +1347,7 @@ fn valid_config() -> TestOpenAiConfig {
arch: "arm64".to_owned(),
terminal: "xterm-256color".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 19, 0, 0, 0)
.single()
@@ -3,7 +3,74 @@ use std::path::Path;
use chrono::{TimeZone as _, Utc};
use provider_openai::config::{
CodexWireProfileConfig, DEFAULT_STREAM_MAX_RETRIES, MAX_STREAM_MAX_RETRIES, OpenAiConfig,
OpenAiConfigError,
};
use provider_openai::transport::profile::CodexRequestLocation;
use serde_json::json;
#[test]
fn openai_location_should_default_to_passthrough() {
let profile = OpenAiConfig::default().wire_profile_state().snapshot();
assert_eq!(profile.location, None);
}
#[test]
fn openai_config_should_preserve_a_custom_location_in_the_runtime_profile() {
let location: CodexRequestLocation = serde_json::from_value(json!({
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
}))
.expect("location configuration");
let mut config = valid_config();
config.wire_profile.location = Some(location.clone());
config
.resolve_and_validate(Path::new("/srv/gateway"))
.expect("valid location");
assert_eq!(
config.wire_profile_state().snapshot().location,
Some(location)
);
}
#[test]
fn openai_config_should_reject_invalid_location_fields() {
for (field, value) in [
("country", "USA"),
("country", "us"),
("country", ""),
("region", " "),
("city", ""),
] {
let mut config = valid_config();
let location = config
.wire_profile
.location
.insert(CodexRequestLocation::default());
let (target, expected) = match field {
"country" => (
&mut location.country,
"openai.wire_profile.location.country",
),
"region" => (&mut location.region, "openai.wire_profile.location.region"),
_ => (&mut location.city, "openai.wire_profile.location.city"),
};
*target = value.to_owned();
assert_eq!(
config.resolve_and_validate(Path::new("/srv/gateway")),
Err(OpenAiConfigError::InvalidField(expected))
);
}
}
#[test]
fn location_config_should_reject_unknown_timezones_and_incomplete_groups() {
for value in [
json!({ "country": "US", "region": "Ohio", "city": "Piketon", "timezone": "Not/A_Timezone" }),
json!({ "timezone": "Pacific/Auckland" }),
json!({ "country": "US", "region": "Ohio", "city": "Piketon", "timezone": "America/New_York", "extra": true }),
] {
assert!(serde_json::from_value::<CodexRequestLocation>(value).is_err());
}
}
#[test]
fn openai_config_builds_the_audited_wire_profile() {
@@ -154,6 +221,7 @@ fn valid_config() -> OpenAiConfig {
arch: "arm64".to_owned(),
terminal: "xterm-256color".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 19, 0, 0, 0)
.single()
@@ -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};
@@ -61,6 +65,7 @@ fn wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "catalog-contract".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -113,6 +118,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 +480,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());
@@ -161,6 +161,7 @@ fn selector_with_runtime(
arch: "x86_64".to_owned(),
terminal: "selector-contract".to_owned(),
residency: None,
location: Default::default(),
verified_at: chrono::Utc::now(),
});
let http = reqwest::Client::builder().build().expect("HTTP client");
@@ -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};
@@ -22,6 +22,7 @@ fn wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "profile-statistics-contract".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 8, 28, 0, 0, 0)
.single()
@@ -59,6 +60,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 +233,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;
@@ -37,6 +38,7 @@ fn wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "quota-contract".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -561,6 +561,102 @@ async fn an_unknown_slot_establishes_a_baseline_before_it_can_recover() {
}
}
#[tokio::test]
async fn weekly_usage_falling_back_within_the_same_window_recovers_after_two_observations() {
// 滑动周窗口的用量可以在同一窗口内回落:reset 不滚动、用量从 100% 降到
// 正常水平。此时"reset 前进 + 低用量"永远不会成立,解除依赖连续两次
// 观测都未触顶;单次观测无法排除耗尽后立刻拉到的旧快照,保持锁定。
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_weekly_fallout").await;
let account = store.account("acct_weekly_fallout").expect("account");
let server = MockServer::start().await;
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
let mut old = usage((100, SHORT_RESET), (100, WEEK_RESET));
old["rate_limit"]["allowed"] = json!(false);
old["rate_limit"]["limit_reached"] = json!(true);
mount_usage(&server, old).await;
service
.refresh_account(account.id())
.await
.expect("seed exhausted quota");
// 5h 窗口滚动前进,周窗口 reset 不变但用量回落到正常水平:只标记候选。
let mut first_observation = usage((0, SHORT_RESET + 18_000), (18, WEEK_RESET));
first_observation["rate_limit"]["allowed"] = json!(true);
mount_usage(&server, first_observation).await;
let first = service
.refresh_account(account.id())
.await
.expect("first fallout observation");
assert!(first.quota().is_exhausted());
// 第二次连续观测仍未触顶:解除周窗口。
let mut second_observation = usage((9, SHORT_RESET + 36_000), (18, WEEK_RESET));
second_observation["rate_limit"]["allowed"] = json!(true);
mount_usage(&server, second_observation).await;
let second = service
.refresh_account(account.id())
.await
.expect("second fallout observation");
assert_eq!(second.quota().access(), QuotaAccessState::Allowed);
}
#[tokio::test]
async fn a_single_same_window_fallout_observation_cannot_break_the_reset_baseline() {
// 连续性中断(观测到重新触顶)后必须重新积累两次未触顶证据。
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_weekly_fallout_gap").await;
let account = store.account("acct_weekly_fallout_gap").expect("account");
let server = MockServer::start().await;
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
let mut old = usage((100, SHORT_RESET), (100, WEEK_RESET));
old["rate_limit"]["allowed"] = json!(false);
mount_usage(&server, old).await;
service
.refresh_account(account.id())
.await
.expect("seed exhausted quota");
mount_usage(&server, usage((0, SHORT_RESET + 18_000), (18, WEEK_RESET))).await;
assert!(
service
.refresh_account(account.id())
.await
.expect("first candidate")
.quota()
.is_exhausted()
);
// 观测到重新触顶,候选证据清零。
mount_usage(&server, usage((0, SHORT_RESET + 36_000), (100, WEEK_RESET))).await;
assert!(
service
.refresh_account(account.id())
.await
.expect("reached again")
.quota()
.is_exhausted()
);
mount_usage(&server, usage((0, SHORT_RESET + 54_000), (18, WEEK_RESET))).await;
assert!(
service
.refresh_account(account.id())
.await
.expect("candidate restarts")
.quota()
.is_exhausted()
);
mount_usage(&server, usage((0, SHORT_RESET + 72_000), (18, WEEK_RESET))).await;
assert_eq!(
service
.refresh_account(account.id())
.await
.expect("second consecutive observation")
.quota()
.access(),
QuotaAccessState::Allowed
);
}
#[tokio::test]
async fn monthly_and_arbitrary_periods_use_the_same_recovery_rule() {
for seconds in [18_000, 86_400, 604_800, 864_000, 2_592_000, 7_776_000] {
@@ -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;
@@ -138,6 +138,9 @@ fn wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "provider-contract".to_owned(),
residency: None,
location: serde_json::from_value(json!({
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
})).expect("configured request location"),
verified_at: Utc::now(),
})
}
@@ -465,6 +468,9 @@ 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",
"acct_ws_midstream_overload",
]
.into_iter()
.map(|id| {
@@ -662,6 +668,47 @@ async fn capture_scoped_http_request(
requests.pop().expect("single scoped request")
}
#[tokio::test]
async fn provider_should_send_the_configured_location_to_the_upstream() {
let body = json!({
"model": "gpt-5.4",
"input": [{
"role": "user",
"content": [{"type": "input_text", "text": "<environment_context><timezone>UTC</timezone></environment_context>"}],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": ["environments.environment_context"],
"create_time": 1789293131.822
}
}],
"tools": [{"type": "web_search"}]
}).as_object().expect("request object").clone();
let captured = capture_scoped_http_request(
"req_configured_location",
"acct_provider_contract",
"acct_provider_contract",
body,
Map::new(),
)
.await;
let body = captured_request_body(&captured);
assert_eq!(
body.pointer("/tools/0/user_location"),
Some(&json!({
"type": "approximate", "country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
}))
);
assert_eq!(
body.pointer("/input/0/content/0/text"),
Some(&json!(
"<environment_context><timezone>Pacific/Auckland</timezone></environment_context>"
))
);
assert_eq!(
body.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"),
Some(&json!(1789293131.822))
);
}
async fn capture_turn_state_request(
request_id: &str,
previous_turn_id: Option<&str>,
@@ -2327,6 +2374,114 @@ async fn abrupt_websocket_disconnect_preserves_diagnosis_and_ambiguous_send_stat
);
}
#[tokio::test]
async fn websocket_midstream_error_frame_surfaces_upstream_message_after_delivery() {
const ACCOUNT_ID: &str = "acct_ws_midstream_overload";
const RESPONSE_ID: &str = "resp_midstream_overload";
const OVERLOAD_MESSAGE: &str = "Our servers are currently overloaded. Please try again later.";
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, ACCOUNT_ID).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 (stream, _) = listener.accept().await.expect("accept WebSocket");
let mut websocket = accept_codex_test_websocket(stream).await;
let _request = websocket
.next()
.await
.expect("WebSocket request")
.expect("valid WebSocket request");
// 真实生产观察:OpenAI 在流已开始后发送带原话的 `error` 帧再断连。
websocket
.send(Message::Text(
json!({
"type": "response.created",
"response": {"id": RESPONSE_ID, "model": "gpt-5.4", "status": "in_progress"}
})
.to_string()
.into(),
))
.await
.expect("send response.created");
websocket
.send(Message::Text(
json!({
"type": "response.in_progress",
"response": {"id": RESPONSE_ID, "model": "gpt-5.4", "status": "in_progress"}
})
.to_string()
.into(),
))
.await
.expect("send response.in_progress");
tokio::time::sleep(Duration::from_millis(1_600)).await;
websocket
.send(Message::Text(
json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": OVERLOAD_MESSAGE,
"param": null
},
"sequence_number": 2
})
.to_string()
.into(),
))
.await
.expect("send error frame");
// 不发送任何终止事件,直接断开,模拟上游发完错误帧后的真实行为。
});
let provider = provider_with_base_url(&store, base_url);
let mut stream = provider
.execute(
planned_request("openai", generate_operation()),
context("req_ws_midstream_overload", CancellationToken::new()),
)
.await
.expect("prepare midstream-failure stream");
let mut failure = loop {
match stream.next().await {
Some(Ok(_)) => {}
Some(Err(error)) => break error,
None => panic!("midstream error frame must surface a typed failure"),
}
};
server.abort();
assert_eq!(
failure.kind(),
ProviderErrorKind::UpstreamCapacityUnavailable
);
assert_eq!(failure.send_state(), UpstreamSendState::Sent);
// 交给 Core 的失败必须保留上游原话:客户端只能靠它知道失败原因。
let visible = failure
.client_visible_upstream_error()
.expect("overload frame must carry a client-visible upstream error");
assert_eq!(visible.code(), Some("server_is_overloaded"));
assert_eq!(visible.message(), OVERLOAD_MESSAGE);
// 原始错误帧必须随失败一起交给交付边界(SSE 侧据此翻译成 response.failed)。
let atomic = failure.take_atomic_client_events();
let error_frame = atomic
.iter()
.find_map(|event| {
let wire = event.wire_event()?;
(wire.event_type() == Some("error")).then(|| wire.data().clone())
})
.expect("atomic batch must carry the upstream error frame");
assert_eq!(
error_frame
.get("error")
.and_then(|error| error.get("message"))
.and_then(Value::as_str),
Some(OVERLOAD_MESSAGE)
);
}
#[tokio::test]
async fn websocket_idle_timeout_diagnosis_survives_ambiguous_send_wrapping() {
const ACCOUNT_ID: &str = "acct_abrupt_disconnect";
@@ -3124,6 +3279,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 +5052,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 +5450,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 +6681,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 +6875,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,
@@ -306,6 +419,7 @@ fn profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "xterm".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -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",

Some files were not shown because too many files have changed in this diff Show More