Compare commits

...
40 Commits
Author SHA1 Message Date
ZyyandCodex ec605b3d1c chore(release): v3.12.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 23:43:27 +08:00
ZyyandCodex 890fb3299b fix(openai): stop client retries for exhausted account pools
Return 429 usage_limit_reached when all eligible accounts have exhausted their quota. Preserve provider fallback, continuation recovery, and other unavailability causes.

Cover HTTP and WebSocket error contracts and account selection boundaries. Verified with 2516 backend integration tests, strict Clippy, and the official Codex CLI using gpt-5.6-luna and a quota-exhausted account.

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 23:28:37 +08:00
helenandCodex a18f0a2e11 fix(openai): normalize system messages for Codex (#172)
Normalize explicit system messages to developer at the shared Codex request boundary while preserving message content and other fields. Cover HTTP and WebSocket request bodies and document the compatibility behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 22:43:49 +08:00
ZyyandCodex a1ba6c0b61 fix(update): enforce release channel upgrade rules
Refs #169

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 22:38:23 +08:00
ZyyandCodex 5c11b62f93 fix(ui): refine theme contrast and table interactions
Separate light control and table fills, unify input hover and focus feedback, and add smooth table row transitions. Keep column drag highlights attached to their keys and reduce account note text size.

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 22:38:23 +08:00
ZyyandCodex 748c77da6a feat(ui): add key configuration and reorderable table columns
Add session-scoped Codex configuration and CCSwitch import for key holders. Persist table-column ordering with drag animations and refine key, quota, and usage layouts.

Validated with 395 gateway-api integration tests, Rustfmt, and strict Clippy for gateway-api and gateway-admin. Frontend validation is left to the maintainer as requested.

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 19:44:14 +08:00
d0180b74b3 fix(openai): refresh expired quota windows after reset grace (#170)
Refresh non-exhausted accounts after the quota reset grace period while preserving observed usage and exhausted-window attribution. Keep periodic checks for exhausted accounts to detect early upstream resets, and cover refresh timing, throttling, and display regressions.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 18:13:33 +08:00
4669a29b46 feat(usage): show key names and long-context billing badges (#171)
Show current client key names in usage and error records, and persist the selected long-context pricing band in billing snapshots.

Display a long-context badge without assuming a fixed multiplier. Keep missing historical flags false while preserving reconstructed billing details, with regression coverage for OpenAI and xAI.

Validated with 36 billing tests, strict Clippy, Rustfmt, frontend lint/typecheck/build, and browser checks for both usage views.

Co-authored-by: 陈燕山 <chenyanshan@yanshan-mac.local>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 18:13:14 +08:00
e63c5fa28d chore(deps): align TLS with Codex Desktop 26.915 (#175)
Update rustls and AWS-LC to the versions bundled with Codex Core 0.155,
refresh the captured ClientHello baseline, and remove the rustls audit waiver.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 17:39:48 +08:00
1acd4a5f59 feat(settings): unify OpenAI and xAI client identities (#174)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 17:39:29 +08:00
ZyyandCodex 6659d0c1f9 fix(ui): shorten weekly budget label
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 14:22:52 +08:00
ZyyandCodex ab828650e6 chore(release): v3.11.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 06:41:19 +08:00
ZyyandCodex 4c7e4e08f6 feat: refine admin controls and overload protection
Refine settings navigation, selective pricing sync, model deletion, and admin interactions. Consolidate unreleased migrations and keep Fast policy scoped to groups. Increase confirmed capacity-rejection feedback without changing ordinary failure recovery.

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 06:41:03 +08:00
16ae8c7afa feat(auth): add administrator password changes (#166)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 06:07:17 +08:00
956f627f68 feat(keys): add manual budget reset and compact row actions (#165)
Add an admin-only reset for daily, weekly, or all key budget usage with atomic auditing and serialized settlement boundaries.

Keep budget limits, original expiration times, and billing history while moving secondary row actions into the existing popover pattern.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 04:46:22 +08:00
f3076e2338 feat(openai): fallback auxiliary account endpoints to official upstream on 404 (#160)
* feat(openai): fallback auxiliary account endpoints to official upstream on 404

When a custom upstream base URL is configured (such as a Codex Relay for chat and inference), non-inference auxiliary account endpoints (usage, profile statistics, reset credits, subscriptions) may return 404 Not Found if the relay only proxies /backend-api/codex/*. Automatically fallback to the official ChatGPT backend endpoint to retrieve account metadata and quota statistics, while keeping chat and model traffic routed through the configured relay.

* fix(openai): preserve account fallback failures and query budgets

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-19 02:44:52 +08:00
ZyyandCodex 7f320797a0 docs: add public quick preview access
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 01:30:31 +08:00
ZyyandCodex 7bcc207055 fix(openai): stop cancelled artifact reads during shutdown
Return a terminal I/O error for cancelled or expired Desktop artifact reads so standard read helpers cannot retry forever and block runtime shutdown.

Cover cancellation before reading and during an in-flight range request.

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 00:14:25 +08:00
fba8941068 feat(pricing): add configurable model prices and manual catalog sync (#159)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-18 20:22:15 +08:00
ZyyandCodex ede456877b docs(release): define experimental publishing and title numbering
Co-authored-by: Codex <noreply@openai.com>
2026-09-18 16:55:07 +08:00
Zyy e6587b6ca2 feat(openai): add configurable upstream client identities (#156)
Add six Desktop and CLI presets, global defaults and per-key overrides, custom identity parameters, and official release tracking. Keep the resolved identity stable throughout each request and its retries.
2026-09-18 16:39:29 +08:00
ZyyandCodex 4d1ed7917d fix(api): make final overload failures retryable for clients (#152)
Project capacity errors at the HTTP, SSE, and WebSocket delivery boundary while preserving upstream facts and existing gateway recovery policies.

Co-authored-by: Codex <noreply@openai.com>
2026-09-18 16:38:08 +08:00
ZyyandCodex 5fe892de82 docs(community): configure Chinese discussions and feedback routing
Add Chinese discussion forms, community guidance, and README and Issue chooser links.

Co-authored-by: Codex <noreply@openai.com>
2026-09-18 15:55:36 +08:00
ZyyandCodex 3328a4356a chore(release): v3.10.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 20:10:53 +08:00
77b0c29354 feat(proxies): add dual-stack diagnostics without blocking binding (#132)
Probe IPv4 and IPv6 exit endpoints concurrently and accept either successful address family. Persist both exit addresses with migration 0015 while preserving existing migration checksums and the legacy exitIp field.

Remove stale frontend IP preference payloads and display exit addresses without redundant family prefixes. Treat probe results as diagnostics and allow users to select and bind saved proxies regardless of probe status, while preserving missing-proxy checks, configuration consistency, import guards, and no-direct-fallback behavior.

Co-authored-by: zhangwentian <2han9wen71an@gmail.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 19:58:22 +08:00
ZyyandCodex c872bc7881 refactor(settings): refine Fast display and save feedback
Move the revised service-tier description into the card header and simplify the switch label. Keep the undo button filled and place the unsaved indicator before it.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 19:05:54 +08:00
487b2e87e2 feat: add global and group controls to disable Fast (#146)
Freeze global and bound-group Fast restrictions in each request snapshot, then apply the effective policy before OpenAI transport headers, observations, and billing. Preserve omitted update values and existing data with an additive migration.

Keep the current split settings layout, add a service-tier card, and require WebSocket transport from the first turn in the continuation regression test.

Validation: frontend lint, typecheck and build; Rust formatting; migration checksums; 15 focused tests. Live priority/default/priority requests preserved context on one WebSocket, but the upstream returned default for all three, so actual priority-tier execution remains unverified. Cloud CI was still running when the maintainer requested immediate merge.

Co-authored-by: Yanshan Chen <16632048+chenyanshan@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 18:55:31 +08:00
ZyyandCodex eb8a01541a refactor(ui): refine settings and account interactions
Split settings into focused cards while retaining the original tabs and page layout. Simplify save feedback and help controls, preserve settings drafts, and align account dialog and model restriction widths.

Keep select option scrolling tied to keyboard navigation and focus so hovering does not shift the dropdown.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 18:29:47 +08:00
ZyyandCodex 0acf114fa7 ci(release): remove major-version upgrade warning
Remove the same warning from release installation notes and deployment documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 18:25:15 +08:00
2han9wen71anandZyy 5d941008fc fix(providers): let disabled accounts source their own model catalogs (#143)
* fix(providers): let disabled accounts source their own model catalogs

The admin per-account model query (connection test model dropdown)
resolves the account's plan catalog through refresh_account_catalog,
whose candidates come from catalog_candidates_by_scope over the
scheduling account listing. Disabled accounts never appear there, so a
disabled account either failed with NoEligibleCredential (no enabled
account in its plan scope) or silently sourced the catalog from other
accounts instead of itself, and the panel showed no test models.

Keep the pinned account reachable: when the target account is missing
from the eligibility-filtered candidates, append it (for xAI, load its
full credential) and let the preferred-first ordering try it before any
same-scope peers. A fully filtered listing is now treated as empty
candidates instead of an early error so the pinned account can still
answer alone. Client-facing catalog discovery, background sync and
scheduling listings continue to exclude disabled accounts.

* fix(xai): keep catalog credential revision aligned

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-09-17 16:07:58 +08:00
b44a790777 fix(openai): coalesce SOCKS proxy negotiation writes (#142)
* fix(openai): coalesce SOCKS proxy negotiation writes

Buffer proxy handshake writes until the existing protocol flush points to support SOCKS services that reject partial method greetings. Preserve the dependency handshake and return the unbuffered stream before TLS/WebSocket traffic.

Refs #139

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

* test(openai): make SOCKS negotiation coverage deterministic

Replace TCP peek boundary assumptions with an in-memory write transcript and fragmented responses. Cover authenticated and unauthenticated negotiation, retain the existing account proxy integration coverage, and verify the handshake buffer is drained.

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <39424122+zyycn@users.noreply.github.com>
2026-09-17 15:54:09 +08:00
06e94ef69b fix(settings): make Responses decompression limit configurable (#141)
Persist the output limit in runtime settings and use the authenticated snapshot for each compressed Responses request. Expose the setting in MiB and preserve bounded decoding and the independent zstd window limit.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 15:29:14 +08:00
0f2e0b18a9 feat(openai): recover quota-exhausted continuations through client replay (#140)
Project replay-safe quota rejections to the existing client history recovery
contract while preserving upstream failure facts and account isolation.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 15:07:31 +08:00
71e26f44b7 fix(usage): bill sent models and show response model mismatches (#136)
* feat(usage): show upstream response model mismatches

Persist observed response models separately from routing and billing facts.
Surface mismatches with the existing model arrow and native title tooltips.
Keep response observations isolated across retries and WebSocket reuse.

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

* fix(usage): calculate costs from the outbound model

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-17 14:55:54 +08:00
yertandCodex 8232e19bc0 fix(ui): make reauthorization account copyable (#135)
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 14:55:22 +08:00
dcc61915e9 fix(openai): default missing downstream store to false (#134)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 14:54:52 +08:00
47d08560a7 fix(api): allow ChatGPT remote Desktop sessions without app versions (#133)
* fix(api): allow ChatGPT remote Desktop sessions without app versions

Recognize the official Android and iOS remote User-Agent suffixes when
Desktop application version metadata is absent. Preserve explicit version
validation and Client Key authentication, cover the streaming request path,
and document the version enforcement boundary.

Refs #129

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

* fix(api): match ChatGPT remote client name family

Accept nonempty platforms in codex_chatgpt_*_remote while preserving suffix
token validation and explicit Desktop version enforcement. Cover future
platforms and malformed names in parser and streaming route tests.

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-17 14:54:17 +08:00
ZyyandCodex 590c4a163b fix(store): release rejected proxy import locks before returning
Explicitly unlock rejected import reservations before returning their error so immediate proxy operations do not race connection teardown. Cover repeated rejection followed by writes with PostgreSQL integration tests.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 05:36:00 +08:00
ZyyandCodex 9cfcd371d6 chore(release): v3.9.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 05:19:07 +08:00
ZyyandCodex 223021630e fix(openai): unify API key and OAuth passthrough
Share the simulated client profile and existing request/response paths across authentication modes. Forward native capabilities and unknown models upstream while preserving credential isolation and routing permissions.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 05:15:31 +08:00
397 changed files with 19703 additions and 2570 deletions
+32 -7
View File
@@ -1,6 +1,6 @@
---
name: release
description: 准备或执行当前仓库发版,维护中文发布说明。用于“发版”“发个新版本”“准备 vX.Y.Z”“推荐版本号”“整理更新日志/发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing a repository release; not for deploying/restarting an instance, publishing application data, or authoring a release skill.
description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行版和 exp 实验版发版,维护中文发布说明。用于“发版”“发布 beta/rc/实验版”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable, prerelease, or experimental releases; not for deploying/restarting an instance or authoring a release skill.
---
# 发版
@@ -12,12 +12,20 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
| 发布 alpha、beta、rc | 从 `main` 发布,确定目标正式版本和阶段,核对预发行标记与晋级路径 |
| 发布实验版、exp 版本 | 从目标实验分支发布,按下方实验版流程确定版本、文案和渠道隔离 |
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
沿用当前会话明确的版本、范围与授权;用户已要求正式发版且条件满足时直接执行,不重复确认。
沿用当前会话明确的版本、范围与授权;用户已授权发布目标版本且条件满足时直接执行,不重复确认。
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
稳定版以及面向下一正式版本的 alpha、beta、rc 从 `main` 发布,预发行 tag 使用 `vX.Y.Z-<阶段>.N`。
发版前读取[版本命名与升级规则](../../../deploy/README.md#版本命名与升级规则),作为命名、阶段晋级和更新边界的权威约定。
实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`,
展示标题包含分支用途名;先读 [实验版发布](references/experimental.md) 确定标题与编号,再复用本文的准备、执行与验收步骤。
实验功能是否合入 `main` 是单独的维护决策,发布实验版不包含合并功能的授权。
## 读取当前流程
先读 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),再按任务检查:
@@ -33,9 +41,23 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
命令从仓库根目录执行;每次以当前脚本和工作流为准,不另写发布脚本或绕过已有质量检查。
用户明确指定其他仓库时先读取其发布流程,不能直接套用这里的路径和命令。
## 发行通道核对
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
- 正式版只接收同一大版本内更新的正式版。alpha → beta → rc → 正式版只在相同 `X.Y.Z` 内晋级,
可以跳过阶段,不能倒退;转正后不自动进入下一轮预发行。exp 仅接收同基线、同一轮实验的后续编号,
不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
- 验收更新器时,核对检查更新与执行更新使用相同规则。只有当前构建支持更新且存在允许的更高目标时
才能返回 `hasUpdate=true`;被禁止的目标即使版本领先,也不能出现在更新标记、可用版本或发布说明中。
检查失败须单独报告。核对上一发行实际包含的更新能力,不能因新包支持在线更新就宣称旧包也能在线安装它。
## 准备版本与说明
1. 确认发布分支、upstream、目标仓库、工作区和完整待推送范围。本仓库沿用 `main`;
1. 确认发布类型、对应分支、upstream、目标仓库、工作区和完整待推送范围;
未提交或未跟踪内容分别核对,不因脚本只检查已跟踪文件就认定所有内容都已纳入发布。
2. 读取版本文件、已有 tag 和实际发行记录,确认上一发布基线与本次目标提交。
根据基线到目标的提交和最终差异归纳变更,剔除回退、重复和未进入发布范围的内容。
@@ -48,13 +70,13 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
涉及数据库迁移时,对照同一大版本的上一正式 Release 检查已发布迁移是否保持不变,并验证升级路径;
涉及数据库迁移时,对照同一大版本的上一正式 Release 及本轮预发行/实验线的已发布版本检查迁移是否保持不变,并验证升级路径;
跨大版本按项目部署约定判断,不默认支持在线升级。区分未发布迁移的整理、已执行这些迁移的源码部署实例,
以及 CI 实际采用的冻结基线。未发版不代表迁移从未被执行,CI 冻结检查失败也不等于正式版数据库升级失败;
按各自证据报告兼容性与检查结果,不因未发版而自动放宽现有冻结规则。
只修改说明时无需重建前后端;正式发版仍要跟踪发布工作流针对目标提交的检查与产物。
只修改说明时无需重建前后端;实际发版仍要跟踪发布工作流针对目标提交的检查与产物。
## 执行正式发版
## 执行发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
@@ -62,7 +84,7 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权正式发布后,将 `release_version` 设为已确定的版本,执行:
在准备完成且用户已授权发布后,将 `release_version` 设为已确定的版本,执行:
```bash
release/publish "$release_version"
@@ -71,6 +93,9 @@ release/publish "$release_version"
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
## 恢复与验收
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
+2 -2
View File
@@ -1,4 +1,4 @@
interface:
display_name: "release"
short_description: "按仓库流程整理中文发布说明、核对版本并完成发行与验证"
default_prompt: "使用 $release 准备下一个版本的中文发布说明,并核对发版条件。"
short_description: "按仓库流程准备稳定版或实验版的中文说明,完成发行与渠道验证"
default_prompt: "使用 $release 按指定的稳定版或实验版渠道准备中文发布说明,并核对发版条件。"
@@ -0,0 +1,65 @@
# 实验版发布
适用于独立于主版本维护、供用户主动选择体验的实验功能。发布入口仍是仓库的 `release/publish`,
构建复用目标实验分支的 `.github/workflows/release.yml` 与 `release/platforms.yaml`。
## 分支与版本
- 从用户指定或会话已确定的 `experimental/<用途>` 分支发布,核对它的 upstream 和实验功能范围。
使用独立 worktree 时保留其他工作区内容;不把实验代码合入 `main`,也不顺带升级运行实例。
- 版本采用 `vX.Y.Z-exp.N`:`X.Y.Z` 表示本轮采用的正式版基线,`N` 从 1 开始递增。
同一基线的 `exp.N` 只代表同一轮实验,不得用于不同实验分支;开始其他实验时使用不同的实际基线,不能仅接续编号。
后续只有实际同步了新的正式版基线,才调整 `X.Y.Z`;调整后属于另一实验线,发布说明需交代手动迁移方式。
- 核对已有 tag、Release 与目标分支源码,先区分重试和新发行。已发布的旧命名版本保留,
不为统一名称移动 tag 或替换原有产物。
- GitHub Release 展示标题采用 `vX.Y.Z-<用途>.M`,`<用途>` 取实验分支名去掉 `experimental/` 前缀后的部分。
发布前通过 `gh api` 核对相同正式版基线与用途的已有 Release 标题和旧命名 tag,新发行取已有最大编号加 1,首次从 1 开始。
展示编号 `M` 独立于实际 tag 的 `exp.N`,旧命名发行也计入,不能因改用 `exp.N` 而重置;同一发行重试沿用已确定编号。
例如已有 `v3.10.0-codex-anti-degradation.1`,下一次 tag 即使是 `v3.10.0-exp.1`,标题也应为 `v3.10.0-codex-anti-degradation.2`。
- tag 和产物继续使用 `exp.N`:镜像版本去掉 tag 开头的 `v`,安装包沿用项目的版本、系统和架构命名。
发布说明首行仍匹配实际 tag;关联 Issue 和试验目的写在正文中。新标题规则不自动改名已有 Release。
## 先核对发布能力
在目标实验分支读取实际工作流,确认以下行为;新实验分支若缺少支持,先在既有流程中补齐并验证:
- `exp.N` 产生 `experimental` 构建类型,所有平台的二进制与镜像使用同一版本和源码提交。
应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。
发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
正式版和 alpha、beta、rc 均不接收实验版更新;环境变量不能改变发行线。
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
核对发布附件确实包含它,不能只在文案中承诺。
- 实验版正文使用下方完整文案,不再由工作流重复追加安装章节或自动生成混入正式版基线的提交列表。
- Release 的 `name` 使用上述展示标题。工作流若默认以 tag 为标题,发布成功后用 `gh release edit --title` 设置标题,
再通过 `gh api` 回读核对;仅修改 `name`,保留 tag、产物、正文和预发行状态。
## 中文发布说明
在目标分支的 `release/notes.md` 编写,首行为 `# vX.Y.Z-exp.N`。后续内容按实际需要组织:
1. **实验说明**:开头简述正式版基线、验证的问题与适用人群。效果尚未验证时明确说明,
不把假设写成已解决的问题,不承诺实验功能一定进入主版本。
2. **本次变化**:首次发行概括相对正式版基线新增的实验能力,关联 PR;后续发行只列相对上个实验版的变化。
同步主分支修复时说明它对实验用户的影响,不复制整份正式版更新记录。
3. **安装与使用**:给出本版本真实镜像命令、支持平台、部署文件及实验部署文档链接。
交代功能是否默认启用和必要配置;保留工作流实际提供的校验与证明说明。
4. **使用须知**:只写本次适用的已知限制、数据隔离、迁移与回退边界,以及在线更新条件和手动迁移方式。
5. **反馈与致谢**:链接关联 Issue 或已有反馈入口,感谢实际贡献者。没有相应内容的章节可以省略。
用户要求先审核文案时,先展示完整正文;已批准的同一版本文案无需重复确认。
正常新发行把说明纳入版本提交并从 tag 构建,不先创建公开空 Release 再回填附件。
## 执行与验收
按主技能准备说明和其他变更后,在实验分支执行 `release/publish <X.Y.Z-exp.N>`。
入口创建版本提交和 tag,并从该分支 dispatch 发布工作流;不要从 `main` 发布实验代码。
按主技能的「发行通道核对」确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。
检查展示标题的分支用途与历史续号、它所对应的实际 tag,以及目标 tag 的源码提交、完整产物、校验和、多架构平台和 Pre-release 标记。
同基线的 `X.Y.Z-exp.N` 按 SemVer 排序低于正式 `X.Y.Z`;渠道隔离仍以更新器过滤和发布标签规则为准。
失败时按主技能从未完成步骤恢复,不通过换版本号或移动 tag 掩盖失败。
仅维护已有页面文案时不重新构建;报告实际完成状态、发布链接及验证缺口。
@@ -0,0 +1,33 @@
body:
- type: markdown
attributes:
value: |
欢迎提问。提交前可以搜索已有讨论,并查看[部署文档](https://github.com/zyycn/codex-proxy-rs/blob/main/deploy/README.md)。
明确的程序故障请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose);不确定是不是 Bug,也可以先在这里交流。
请移除密钥、令牌、Cookie、代理认证和私人内容,不要上传完整配置或请求转储。解决后请标记帮助到你的答案。
- type: textarea
id: question
attributes:
label: 想解决什么问题
description: 说明希望完成的任务、当前遇到的困难,标题尽量具体。
validations:
required: true
- type: input
id: version
attributes:
label: 版本与部署方式
description: 涉及具体实例时,填写完整版本号或提交号,以及 Docker、二进制或源码部署方式。
- type: textarea
id: attempts
attributes:
label: 已尝试的方法与结果
description: 可以附看过的文档、相关讨论、脱敏错误片段或截图;未尝试可留空。
- type: textarea
id: environment
attributes:
label: 相关环境与补充信息
description: 按问题需要补充操作系统、客户端、反向代理或中转链路,无需提供无关信息。
@@ -0,0 +1,44 @@
body:
- type: markdown
attributes:
value: |
欢迎分享实验版本的使用体验,包括有效、无明显变化或遇到限制的情况。
请先查看对应[预发行页面](https://github.com/zyycn/codex-proxy-rs/releases)的部署与数据隔离说明,并搜索已有反馈。
明确的程序缺陷请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose),在两处互相附链接即可,无需重复粘贴。
请移除密钥、令牌、Cookie、代理认证和私人内容,不要上传完整配置或请求转储。
- type: input
id: version
attributes:
label: 实验版本或提交号
description: 填写实际运行的完整版本号或提交号,不要只写 exp 或 latest。
validations:
required: true
- type: textarea
id: scenario
attributes:
label: 使用场景与环境
description: 说明启用的实验功能、部署方式、客户端及与结果相关的条件,不需要提供账号身份信息。
validations:
required: true
- type: textarea
id: observation
attributes:
label: 观察到的效果或限制
description: 描述实际现象、出现频率和观察时长;请区分观察结果与原因推测。
validations:
required: true
- type: textarea
id: comparison
attributes:
label: 对照与复现信息
description: 如已对比启用前后或不同配置,请说明条件和结果;没有对照也可以反馈,无需额外测试。
- type: textarea
id: references
attributes:
label: 相关链接与补充材料
description: 可附已有 Issue、讨论、脱敏日志片段或截图。
@@ -0,0 +1,29 @@
body:
- type: markdown
attributes:
value: |
这里适合交流尚在探索的想法、方案取舍和使用场景,不要求先给出完整实现。
已有明确场景与预期行为时,可以直接提交[功能建议 Issue](https://github.com/zyycn/codex-proxy-rs/issues/new?template=feature-request.yml),无需先开讨论。
请先搜索相关讨论,每个主题围绕一个问题展开。
- type: textarea
id: use-case
attributes:
label: 使用场景与当前不便
description: 希望完成什么任务,目前的做法有什么不足?
validations:
required: true
- type: textarea
id: idea
attributes:
label: 想法与希望讨论的方向
description: 可以说明期望体验、考虑过的替代方法,以及还不确定的地方。
validations:
required: true
- type: textarea
id: references
attributes:
label: 参考资料
description: 可附相关 Issue、讨论或其他项目的具体行为;示例和截图请先脱敏。
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: 使用问答
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答
about: 安装部署、配置和日常使用求助;明确的 Bug 请选择上方问题反馈模板。
- name: 想法与方案讨论
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论
about: 探索尚未明确的需求与方案;具体功能需求也可以直接提交功能建议。
- name: 实验版体验反馈
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈
about: 分享实验版本的使用效果、适用场景和限制。
+5 -6
View File
@@ -35,6 +35,7 @@ jobs:
docker_platforms: ${{ steps.version.outputs.docker_platforms }}
previous_tag: ${{ steps.version.outputs.previous_tag }}
prerelease: ${{ steps.version.outputs.prerelease }}
build_type: ${{ steps.version.outputs.build_type }}
steps:
- name: Checkout
@@ -57,7 +58,7 @@ jobs:
require "yaml"
APP_NAME = "codex-proxy-rs"
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc|exp)\.[1-9]\d*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/
def fail(message)
warn(message)
@@ -161,6 +162,7 @@ jobs:
puts "docker_platforms=#{docker_platforms.join(",")}"
puts "previous_tag=#{previous_tag}"
puts "prerelease=#{version.split("+", 2).first.include?("-")}"
puts "build_type=#{version.split("+", 2).first.include?("-exp.") ? "experimental" : "release"}"
RUBY
quality:
@@ -221,7 +223,7 @@ jobs:
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
CPR_BUILD_TYPE: release
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
- name: Upload binary artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -274,7 +276,7 @@ jobs:
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
CPR_BUILD_TYPE: release
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
- name: Upload binary artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -669,9 +671,6 @@ jobs:
shell: bash
env:
INSTALLATION_NOTES: |
> [!WARNING]
> **跨大版本升级:** 不支持跨大版本在线升级。请使用全新的 `.runtime/` 数据目录重新部署,再重新导入或重新授权服务商账号,并重新导入客户端密钥。详见[升级说明](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md#镜像升级与源码构建)。
## 📥 安装
**Docker 部署:**
+1 -10
View File
@@ -40,16 +40,7 @@ jobs:
- name: Run cargo audit
working-directory: backend
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[@]}"
run: cargo audit
frontend-security:
runs-on: ubuntu-latest
+10 -1
View File
@@ -4,7 +4,16 @@
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
## 提交 Issue
## 交流与反馈
[讨论区](https://github.com/zyycn/codex-proxy-rs/discussions) 用于使用问答、想法讨论、实验反馈和经验分享。
提问时按需补充版本、部署方式、已尝试的方法及脱敏信息;问题解决后,请在「使用问答」中标记有帮助的答案。
明确的 Bug、具体功能需求或协作规则建议使用 Issue,无需先开讨论。
讨论形成可执行事项后,可以新建 Issue 并互相附链接;已有 Issue 的后续信息继续补充在原帖,避免重复记录。
围绕问题友善交流,欢迎不同使用场景和方案观点。
### 提交 Issue
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏。
+24 -4
View File
@@ -12,13 +12,27 @@
[![GHCR](https://img.shields.io/badge/GHCR-codex--proxy--rs-2496ED?logo=docker&logoColor=white&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square)](LICENSE)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
[快速预览](#快速预览) · [快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
</div>
> [!NOTE]
> 本项目提供 Responses API,不支持 `/v1/chat/completions`。接入前请确认客户端支持 Responses 协议。
## 快速预览
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计。
| 登录信息 | 值 |
| --- | --- |
| 地址 | <https://codex-proxy-rs.ainz.cc> |
| 登录身份 | 管理员 |
| 账号 | `admin@cpr.local` |
| 密码 | `039c18de2aeac46d23ead7766bb07bbbe3747233c66a128e` |
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据。
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息。
## 快速开始
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
@@ -62,6 +76,7 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch。
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)。
@@ -72,9 +87,6 @@ API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage`
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
3. 打开密钥的「使用密钥」,复制客户端配置。
账号自动冻结默认关闭,可在「系统设置 → 运行参数」中启用。启用后,账号频繁命中上游容量错误时
会暂停调度并显示为限流中;若开启恢复前探测,冷却到期后需探测成功才恢复。
## 客户端接入
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
@@ -99,6 +111,7 @@ curl http://127.0.0.1:8080/v1/models \
- [客户端接入与生图](deploy/README.md#客户端配置)
- [部署、备份与恢复](deploy/README.md)
- [API 参考](docs/api.md)
- [模型定价与手动同步](docs/api.md#模型定价)
- [系统架构](docs/architecture.md)
- [管理端主题](docs/theme.md)
- [数据库迁移](backend/migrations/README.md)
@@ -106,6 +119,13 @@ curl http://127.0.0.1:8080/v1/models \
## 社区
欢迎到 [Discussions 讨论区](https://github.com/zyycn/codex-proxy-rs/discussions)交流:
[使用问答](https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答)、
[想法讨论](https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论)、
[实验反馈](https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈)与
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)。
明确的 Bug 或功能需求请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose),无需先发讨论。
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台。
## 许可证
+52 -8
View File
@@ -154,9 +154,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -165,14 +165,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.39.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -2203,6 +2204,17 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "markup5ever"
version = "0.39.0"
@@ -2723,6 +2735,7 @@ dependencies = [
"chrono",
"chrono-tz",
"cookie",
"crc32fast",
"flate2",
"futures",
"gateway-admin",
@@ -2747,6 +2760,7 @@ dependencies = [
"serde_json",
"sha2 0.11.0",
"subtle",
"tar",
"tempfile",
"thiserror",
"tokio",
@@ -2760,6 +2774,8 @@ dependencies = [
"url",
"uuid",
"wiremock",
"xz2",
"zip",
"zstd",
]
@@ -3142,9 +3158,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.36"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
@@ -3180,9 +3196,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
@@ -4281,6 +4297,12 @@ dependencies = [
"thiserror",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -4796,6 +4818,15 @@ version = "0.8.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576"
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yaml-rust2"
version = "0.11.0"
@@ -4924,6 +4955,19 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
]
[[package]]
name = "zlib-rs"
version = "0.6.6"
+3 -6
View File
@@ -17,8 +17,8 @@ pub struct GatewayConfig {
#[serde(default)]
client: gateway_admin::ClientConfig,
api: gateway_api::ApiConfig,
#[serde(default)]
openai: provider_openai::OpenAiConfig,
xai: provider_xai::XaiConfig,
}
impl LoadableConfig for GatewayConfig {
@@ -46,9 +46,6 @@ impl LoadableConfig for GatewayConfig {
self.openai
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
self.xai
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("xai"))?;
Ok(())
}
}
@@ -64,7 +61,6 @@ pub async fn run() -> Result<(), BootstrapError> {
client,
api,
openai,
xai,
} = config;
let host = gateway_host::initialize(host).await?;
@@ -74,7 +70,7 @@ pub async fn run() -> Result<(), BootstrapError> {
let provider_ports = store.provider_ports();
let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?;
host.report_startup_ready("OpenAI Provider");
let mut xai = provider_xai::initialize(xai, provider_ports).await?;
let mut xai = provider_xai::initialize(provider_ports).await?;
host.report_startup_ready("xAI Provider");
let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
let mut core = gateway_core::initialize(store.core_ports(), providers).await?;
@@ -84,6 +80,7 @@ pub async fn run() -> Result<(), BootstrapError> {
client,
store.admin_ports(),
gateway_admin::AdminRuntimePorts {
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
providers: vec![openai.admin_provider(), xai.admin_provider()],
snapshot: core.snapshot_control(),
account_probe: core.account_probe(),
+2 -4
View File
@@ -184,6 +184,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
&[
"client_distribution",
"config",
"pricing",
"proxy_probe",
"serve",
"system_update",
@@ -195,10 +196,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
"crates/providers/openai",
&["config", "credential", "transport"],
),
(
"crates/providers/xai",
&["config", "credential", "transport"],
),
("crates/providers/xai", &["credential", "transport"]),
];
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景。
+25 -30
View File
@@ -291,9 +291,6 @@ fn config_loader_should_ignore_unknown_fields_in_configuration_sections() {
"/client",
"/api",
"/openai",
"/openai/wire_profile",
"/xai",
"/xai/wire_profile",
] {
let mut extended = document.clone();
extended.pointer_mut(path).expect("configuration section")["unused_setting"] =
@@ -364,7 +361,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
);
match case {
"unused" => assert!(
stderr.contains("openai.wire_profile.location") && stderr.contains("已忽略"),
stderr.contains("openai.wire_profile") && stderr.contains("已忽略"),
"{stderr}"
),
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
@@ -380,6 +377,26 @@ fn config_loader_should_reject_missing_explicit_fields() {
assert_rejected(valid_config().replace(" request_id_header: 'x-request-id'\n", ""));
}
#[test]
fn config_loader_should_allow_missing_provider_configuration() {
let mut document = valid_config_document();
document.as_object_mut().unwrap().remove("openai");
document.as_object_mut().unwrap().remove("xai");
parse_config(&document.to_string()).expect("provider defaults require no YAML identity");
}
#[test]
fn config_loader_should_ignore_removed_provider_identity_sections() {
let mut document = valid_config_document();
document["openai"]["wire_profile"] = serde_json::json!({"codex_version":"legacy-invalid"});
document["xai"] = serde_json::json!({"wire_profile": {"client_identifier": "legacy-client"}});
let (config, _directory) =
parse_config(&document.to_string()).expect("removed identities ignored");
let debug = format!("{config:?}");
assert!(!debug.contains("legacy-invalid"));
assert!(!debug.contains("legacy-client"));
}
#[test]
fn config_loader_should_reject_unsupported_schema_version() {
assert_rejected(valid_config().replace("schema_version: 1", "schema_version: 2"));
@@ -444,11 +461,6 @@ fn config_loader_should_reject_missing_client_session_ttl() {
assert_rejected(config.to_string());
}
#[test]
fn config_loader_should_reject_invalid_codex_cli_version() {
assert_rejected(valid_config().replace("codex_version: '0.153.4'", "codex_version: 'latest'"));
}
#[test]
fn config_loader_should_reject_disabled_all_log_outputs() {
assert_rejected(
@@ -464,27 +476,10 @@ fn config_loader_should_reject_zero_server_port() {
}
#[test]
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_ignore_removed_request_location_setting() {
let original = valid_config();
parse_config(&original).expect("location belongs to runtime settings");
for value in [
"null",
"{}",
"{ country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
] {
let legacy = original.replacen(
" residency: us",
&format!(" residency: us\n location: {value}"),
1,
);
assert_ne!(legacy, original);
parse_config(&legacy).expect("removed request location does not prevent startup");
}
fn config_loader_should_validate_openai_residency() {
let mut document = valid_config_document();
document["openai"]["residency"] = serde_json::json!("invalid");
assert_rejected(document.to_string());
}
fn assert_rejected(config: String) {
+1 -1
View File
@@ -25,6 +25,7 @@ rand_core = { version = "0.6.4", features = ["getrandom"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
secrecy = { version = "0.10.3", features = ["serde"] }
sha2 = "0.11"
subtle = "2.6.1"
thiserror = "2.0.18"
tracing = "0.1.44"
@@ -34,7 +35,6 @@ chrono-tz = "0.10"
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
[dev-dependencies]
sha2 = "0.11"
tempfile = "3"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] }
+8 -3
View File
@@ -51,7 +51,7 @@ use use_case::{
const OPENAI_PROVIDER_KIND: &str = "openai";
const XAI_PROVIDER_KIND: &str = "xai";
const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12;
const WEAK_INITIAL_PASSWORDS: &[&str] = &[
const WEAK_ADMIN_PASSWORDS: &[&str] = &[
"",
"admin",
"123456",
@@ -149,7 +149,7 @@ impl AdminConfig {
let password = self.default_password.expose().trim();
if password.len() < MINIMUM_INITIAL_PASSWORD_BYTES
|| password.contains('$')
|| WEAK_INITIAL_PASSWORDS.contains(&password.to_ascii_lowercase().as_str())
|| WEAK_ADMIN_PASSWORDS.contains(&password.to_ascii_lowercase().as_str())
{
return Err(AdminConfigError::WeakInitialPassword);
}
@@ -296,6 +296,7 @@ impl AdminBundle {
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
pub struct AdminRuntimePorts {
pub pricing_source: Arc<dyn ports::pricing::PricingSource>,
pub providers: Vec<Arc<dyn ProviderAdmin>>,
pub snapshot: Arc<dyn SnapshotControl>,
pub account_probe: Arc<dyn AccountProbe>,
@@ -317,6 +318,7 @@ pub async fn initialize(
runtime: AdminRuntimePorts,
) -> Result<AdminBundle, AdminError> {
let AdminRuntimePorts {
pricing_source,
providers,
snapshot,
account_probe: probe,
@@ -406,17 +408,20 @@ pub async fn initialize(
client_keys: Arc::new(DefaultClientKeyService::new(
store.client_keys(),
snapshot.clone(),
registry.clone(),
)),
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
observability: Arc::new(DefaultObservabilityService::new(
store.observability(),
store.accounts(),
store.settings(),
registry,
registry.clone(),
)),
settings: Arc::new(DefaultSettingsService::new(
store.settings(),
snapshot.clone(),
registry,
pricing_source,
)),
system: Arc::new(DefaultSystemService::new(system)),
openai,
@@ -79,6 +79,7 @@ pub struct AccountGroupListQuery {
/// Complete account group summary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupRecord {
pub disable_fast: bool,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
@@ -107,6 +108,7 @@ pub struct AccountGroupPage {
/// Create an account group.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAccountGroup {
pub disable_fast: bool,
pub name: String,
pub description: Option<String>,
pub color: AccountGroupColor,
@@ -115,6 +117,7 @@ pub struct CreateAccountGroup {
/// Store-ready create command with a generated stable ID.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewAccountGroup {
pub disable_fast: bool,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
@@ -124,6 +127,7 @@ pub struct NewAccountGroup {
/// Update an account group's descriptive fields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccountGroup {
pub disable_fast: Option<bool>,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
+19 -2
View File
@@ -109,8 +109,25 @@ impl From<ClientAuthenticationError> for LoginError {
/// 服务端已验证的身份绑定,不保存密码或原始 API Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionSubject {
Admin { admin_user_id: String },
Key { client_key_id: ClientApiKeyId },
Admin {
admin_user_id: String,
credential_fingerprint: String,
},
Key {
client_key_id: ClientApiKeyId,
},
}
/// 仅已登录管理员可以修改自己的密码。
pub struct ChangePassword {
pub current_password: String,
pub new_password: String,
}
impl std::fmt::Debug for ChangePassword {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("ChangePassword([REDACTED])")
}
}
/// 两种登录方式共用的固定有效期会话。
@@ -86,6 +86,8 @@ pub struct ClientKeyListQuery {
/// 不含完整明文 Key 的管理投影。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyRecord {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -109,7 +111,7 @@ pub struct ClientKeyPage {
pub next_cursor: Option<ClientKeyCursor>,
}
/// 仅在创建或显式 reveal 时跨越管理边界的明文 Key。
/// 仅在创建或显式读取密钥配置时返回的明文 Key;Debug 隐去明文。
#[derive(Clone, PartialEq, Eq)]
pub struct ClientKeySecret {
pub record: ClientKeyRecord,
@@ -144,6 +146,8 @@ impl fmt::Debug for ClientKeySecret {
/// API 提交的 Client Key 创建命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub custom_key: Option<PlaintextClientApiKey>,
pub name: String,
pub label: Option<String>,
@@ -155,6 +159,8 @@ pub struct CreateClientKey {
/// 管理用例生成 ID 与明文后的持久化命令。
#[derive(Clone, PartialEq, Eq)]
pub struct NewClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -179,6 +185,8 @@ impl fmt::Debug for NewClientKey {
/// 修改 Client Key 的公开策略字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKey {
pub openai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub xai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -195,6 +203,21 @@ pub struct SetClientKeyEnabled {
pub enabled: bool,
}
/// 管理员选择的金额重置范围。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientKeyBudgetPeriod {
Daily,
Weekly,
All,
}
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResetClientKeyBudget {
pub id: ClientApiKeyId,
pub period: ClientKeyBudgetPeriod,
}
/// 删除 Client Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteClientKey {
@@ -11,6 +11,7 @@ pub mod client_keys;
pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod pricing;
pub mod provider_credentials;
pub mod proxies;
pub mod quota_forecast;
@@ -297,6 +297,7 @@ pub struct ProviderBillingInput {
/// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageCalculatedBillingFact {
pub breakdown: Option<CalculatedBillingBreakdown>,
pub bucket_start: DateTime<Utc>,
pub provider_kind: String,
pub upstream_model_id: String,
@@ -311,6 +312,9 @@ pub struct UsageCalculatedBillingFact {
/// Provider 已确认的逐项费用与单价。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedBillingBreakdown {
pub long_context_billing_applied: bool,
pub image: Option<ImageBillingBreakdown>,
pub custom_multiplier_bps: u32,
pub input_amount: CurrencyCost,
pub output_amount: CurrencyCost,
pub cache_read_amount: CurrencyCost,
@@ -325,6 +329,16 @@ pub struct CalculatedBillingBreakdown {
pub multiplier_percent: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageBillingBreakdown {
pub input_tokens: u64,
pub cached_tokens: u64,
pub input_amount: CurrencyCost,
pub cache_read_amount: CurrencyCost,
pub input_price_per_million: CurrencyCost,
pub cache_read_price_per_million: CurrencyCost,
}
/// 单次请求的费用语义。
///
/// Provider 上报费用或无法恢复逐项价格时保留总额;Provider 验证成功后升级为完整分解。
@@ -533,6 +547,7 @@ pub struct DashboardObservation {
/// 使用记录表格的窄读模型。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageListRecord {
pub client_api_key_name: Option<String>,
pub id: String,
pub endpoint: String,
pub client_transport: String,
@@ -544,6 +559,7 @@ pub struct UsageListRecord {
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
@@ -602,6 +618,7 @@ pub struct UsageRecord {
pub upstream_transport: Option<String>,
pub http_version: Option<String>,
pub websocket_pool: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明。
pub provider_metadata_json: Option<String>,
@@ -760,6 +777,7 @@ pub struct DiagnosticObservation {
/// 统一运维错误记录。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsError {
pub client_api_key_name: Option<String>,
pub source: String,
pub event_id: String,
pub request_id: Option<String>,
@@ -0,0 +1,52 @@
//! 全局模型定价的管理合同。
use gateway_core::metering::{ModelPriceOverride, PricingOverrides};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PricingCatalog {
pub defaults: PricingOverrides,
pub overrides: PricingOverrides,
pub synced: PricingOverrides,
pub synced_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StoredPricing {
pub overrides: PricingOverrides,
pub synced: PricingOverrides,
pub synced_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PricingSyncPreview {
pub prices: PricingOverrides,
pub skipped: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SyncPricing {
pub preview: PricingSyncPreview,
pub models: BTreeMap<String, BTreeSet<String>>,
}
pub type PricingSyncChanges = BTreeMap<String, BTreeMap<String, Option<ModelPriceOverride>>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PricingChange {
Replace(ModelPriceOverride),
Multiplier(u32),
Reset,
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdatePricing {
pub provider: String,
pub models: Vec<String>,
pub change: PricingChange,
}
pub type ProviderPricingCatalog = BTreeMap<String, ModelPriceOverride>;
@@ -60,6 +60,8 @@ pub struct ProxyTestResult {
pub success: bool,
pub latency_ms: u64,
pub exit_ip: Option<std::net::IpAddr>,
pub exit_ipv4: Option<std::net::Ipv4Addr>,
pub exit_ipv6: Option<std::net::Ipv6Addr>,
pub message: String,
}
@@ -17,6 +17,8 @@ pub use gateway_core::account::RotationStrategy;
/// 完整运行设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeSettings {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub config_revision: Revision,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
@@ -28,6 +30,7 @@ pub struct RuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -47,6 +50,8 @@ pub struct RuntimeSettings {
/// 原子替换运行设置的命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplaceRuntimeSettings {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
@@ -57,6 +62,7 @@ pub struct ReplaceRuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -122,3 +128,16 @@ impl fmt::Debug for RegeneratedAdminApiKey {
.finish()
}
}
impl RuntimeSettings {
pub fn client_profile(
&self,
provider: &str,
) -> Option<&gateway_core::account::OpaqueProviderData> {
match provider {
"openai" => self.openai_client_profile.as_ref(),
"xai" => self.xai_client_profile.as_ref(),
_ => None,
}
}
}
@@ -2,6 +2,7 @@
pub mod backup;
pub mod client_distribution;
pub mod pricing;
pub mod provider;
pub mod proxy;
pub mod store;
@@ -0,0 +1,9 @@
//! 手动同步的外部价目来源;网络和外部文档解析由 Host 实现。
use crate::model::{AdminError, pricing::PricingSyncPreview};
use async_trait::async_trait;
#[async_trait]
pub trait PricingSource: Send + Sync {
async fn fetch(&self) -> Result<PricingSyncPreview, AdminError>;
}
@@ -110,8 +110,28 @@ impl ProviderAdminError {
/// 全部由 [`crate::ports::store::AccountStore`] 提交。运行时资源通知只在事务成功后发生。
#[async_trait]
pub trait ProviderAdmin: Send + Sync {
/// 只读内置价目;没有本地计价能力的 Provider 返回空目录。
fn pricing_catalog(&self) -> crate::model::pricing::ProviderPricingCatalog {
Default::default()
}
fn provider_kind(&self) -> &ProviderKind;
/// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段。
fn client_profile_options(
&self,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 校验并投影客户端身份,结果不含认证或账号材料。
fn preview_client_profile(
&self,
_configuration: &gateway_core::account::OpaqueProviderData,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 将原始套餐值投影为展示名称;默认保留未知 Provider 的原始名称。
fn plan_type_display(&self, plan_type: &str) -> String {
plan_type.to_owned()
@@ -139,7 +159,15 @@ pub trait ProviderAdmin: Send + Sync {
/// 返回该 Provider 实际持有的 Dashboard 上游身份画像。
fn dashboard_wire_profile(&self) -> Option<DashboardWireProfile>;
fn configured_wire_profile(
&self,
_configuration: &gateway_core::account::OpaqueProviderData,
) -> Option<DashboardWireProfile> {
self.dashboard_wire_profile()
}
/// 使用 Provider-owned 价格规则恢复持久请求的逐项费用。
/// 未保存的长上下文计费标记不得从当前价格反推。
fn calculated_billing(
&self,
input: &ProviderBillingInput,
@@ -251,6 +279,14 @@ pub struct ProviderAdminRegistry {
}
impl ProviderAdminRegistry {
#[must_use]
pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides {
self.providers
.iter()
.map(|(kind, provider)| (kind.as_str().to_owned(), provider.pricing_catalog()))
.collect()
}
/// 创建无重复 ProviderKind 的注册表。
///
/// # Errors
@@ -313,10 +349,19 @@ impl ProviderAdminRegistry {
}
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
pub fn dashboard_wire_profiles(&self) -> Vec<DashboardWireProfile> {
pub fn dashboard_wire_profiles(
&self,
configurations: &std::collections::BTreeMap<
ProviderKind,
gateway_core::account::OpaqueProviderData,
>,
) -> Vec<DashboardWireProfile> {
self.providers
.values()
.filter_map(|provider| provider.dashboard_wire_profile())
.iter()
.filter_map(|(kind, provider)| match configurations.get(kind) {
Some(configuration) => provider.configured_wire_profile(configuration),
None => provider.dashboard_wire_profile(),
})
.collect()
}
@@ -23,7 +23,7 @@ use crate::model::{
auth::{AdminAuditEvent, AuthSession},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled, UpdateClientKey,
},
observability::{
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
@@ -227,6 +227,15 @@ pub trait AccountRuntimeStore: Send + Sync {
pub trait AuthStore: Send + Sync {
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
/// 密码更新与审计必须在同一事务提交;旧哈希不匹配时不写入。
async fn change_password(
&self,
admin_user_id: &str,
expected_hash: &str,
password_hash: &str,
audit: AdminAuditEvent,
) -> AdminStoreResult<bool>;
async fn create_password_hash_if_absent(
&self,
admin_user_id: &str,
@@ -297,6 +306,13 @@ pub trait ClientKeyStore: Send + Sync {
command: DeleteClientKey,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
/// 仅修改运行时账本并原子记录审计,不推进配置版本。
async fn reset_client_key_budget(
&self,
command: ResetClientKeyBudget,
context: &MutationContext,
) -> AdminStoreResult<()>;
}
/// Provider-neutral account group management transactions.
@@ -401,6 +417,18 @@ pub trait ObservabilityStore: Send + Sync {
/// Runtime settings 与管理员 API Key 写入。
#[async_trait]
pub trait SettingsStore: Send + Sync {
async fn load_pricing(&self) -> AdminStoreResult<crate::model::pricing::StoredPricing>;
async fn sync_pricing(
&self,
changes: crate::model::pricing::PricingSyncChanges,
context: &MutationContext,
) -> AdminStoreResult<crate::model::Revision>;
async fn update_pricing(
&self,
command: crate::model::pricing::UpdatePricing,
context: &MutationContext,
) -> AdminStoreResult<crate::model::Revision>;
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings>;
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool>;
@@ -144,6 +144,7 @@ impl AccountGroupService for DefaultAccountGroupService {
id,
name: command.name,
description: command.description,
disable_fast: command.disable_fast,
color: command.color,
},
context,
@@ -8,6 +8,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{Duration, Utc};
use gateway_core::engine::execution::ClientKeyVerifier;
use rand_core::{OsRng, RngCore as _};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq as _;
use uuid::Uuid;
@@ -15,8 +16,8 @@ use crate::{
model::{
AdminError, AdminErrorKind,
auth::{
AdminAuditEvent, AuditActorKind, AuthSession, LoginCommand, LoginError, LoginResult,
SessionSubject,
AdminAuditEvent, AuditActorKind, AuthSession, ChangePassword, LoginCommand, LoginError,
LoginResult, SessionSubject,
},
},
ports::store::AuthStore,
@@ -27,6 +28,12 @@ use super::map_store_error;
/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定。
#[async_trait]
pub trait AuthService: Send + Sync {
async fn change_password(
&self,
session_id: Option<&str>,
command: ChangePassword,
source_ip: IpAddr,
) -> Result<(), AdminError>;
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError>;
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError>;
async fn resolve_admin_user_id(
@@ -98,17 +105,21 @@ impl DefaultAuthService {
{
return Err(LoginError::InvalidCredentials);
}
let hash = self
let password_hash = self
.store
.load_password_hash(&self.default_admin_user_id)
.await
.map_err(|_| LoginError::Unavailable)?
.ok_or(LoginError::InvalidCredentials)?;
if !verify_admin_password(&password, &hash).map_err(|_| LoginError::Unavailable)? {
if password.len() > 4096
|| !verify_admin_password(&password, &password_hash)
.map_err(|_| LoginError::Unavailable)?
{
return Err(LoginError::InvalidCredentials);
}
Ok(SessionSubject::Admin {
admin_user_id: self.default_admin_user_id.clone(),
credential_fingerprint: password_fingerprint(&password_hash),
})
}
LoginCommand::Key { api_key } => {
@@ -132,6 +143,75 @@ impl DefaultAuthService {
#[async_trait]
impl AuthService for DefaultAuthService {
async fn change_password(
&self,
session_id: Option<&str>,
command: ChangePassword,
source_ip: IpAddr,
) -> Result<(), AdminError> {
let session = self
.session(session_id)
.await?
.ok_or_else(|| AdminError::new(AdminErrorKind::Unauthorized, "请先登录"))?;
let SessionSubject::Admin {
admin_user_id,
credential_fingerprint,
} = session.subject
else {
return Err(AdminError::new(
AdminErrorKind::Forbidden,
"仅管理员可以修改密码",
));
};
if self
.store
.consume_login_attempt(
source_ip,
LOGIN_ATTEMPTS_PER_SOURCE,
LOGIN_ATTEMPTS_GLOBAL,
LOGIN_WINDOW,
)
.await
.map_err(|error| map_store_error(error, "password change limit"))?
.is_some()
{
return Err(AdminError::new(
AdminErrorKind::RateLimited,
"尝试过于频繁,请稍后再试",
));
}
validate_new_password(&command.new_password)?;
let password_hash = self
.store
.load_password_hash(&admin_user_id)
.await
.map_err(|error| map_store_error(error, "administrator"))?
.filter(|hash| password_fingerprint(hash) == credential_fingerprint)
.ok_or_else(|| AdminError::conflict("密码已变更,请重新登录"))?;
if command.current_password.len() > 4096
|| !verify_admin_password(&command.current_password, &password_hash)?
{
return Err(AdminError::invalid("当前密码不正确"));
}
if command.new_password == command.current_password {
return Err(AdminError::invalid("新密码不能与当前密码相同"));
}
let hash = hash_admin_password(&command.new_password)?;
let mut audit = self.auth_audit("admin.password_changed", &admin_user_id);
audit.entity_kind = "admin_user".to_owned();
audit.changed_fields = vec!["password".to_owned()];
if !self
.store
.change_password(&admin_user_id, &password_hash, &hash, audit)
.await
.map_err(|error| map_store_error(error, "administrator password"))?
{
return Err(AdminError::conflict("密码已变更,请重新登录"));
}
// 密码事务提交后旧指纹不再匹配,会话撤销不依赖 Redis 删除成功。
Ok(())
}
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError> {
let hash = hash_admin_password(password)?;
self.store
@@ -156,6 +236,23 @@ impl AuthService for DefaultAuthService {
let _ = self.store.delete_session(session_id).await;
return Ok(None);
}
if let SessionSubject::Admin {
admin_user_id,
credential_fingerprint,
} = &session.subject
{
let password_hash = self
.store
.load_password_hash(admin_user_id)
.await
.map_err(|error| map_store_error(error, "administrator session"))?;
if password_hash
.is_none_or(|hash| password_fingerprint(&hash) != *credential_fingerprint)
{
let _ = self.store.delete_session(session_id).await;
return Ok(None);
}
}
if let SessionSubject::Key { client_key_id } = &session.subject
&& !self
.store
@@ -178,7 +275,7 @@ impl AuthService for DefaultAuthService {
.await?
.map(|session| session.subject)
{
Some(SessionSubject::Admin { admin_user_id }) => Ok(Some(admin_user_id)),
Some(SessionSubject::Admin { admin_user_id, .. }) => Ok(Some(admin_user_id)),
Some(SessionSubject::Key { .. }) => Err(AdminError::new(
AdminErrorKind::Forbidden,
"当前身份无权访问管理接口",
@@ -237,7 +334,7 @@ impl AuthService for DefaultAuthService {
.store_session(&session_id, &session)
.await
.map_err(|_| LoginError::Unavailable)?;
if let SessionSubject::Admin { admin_user_id } = &session.subject
if let SessionSubject::Admin { admin_user_id, .. } = &session.subject
&& self
.store
.append_audit_event(self.auth_audit("admin.login", admin_user_id))
@@ -267,7 +364,7 @@ impl AuthService for DefaultAuthService {
.await
.map_err(|error| map_store_error(error, "authentication session"))?;
if let Some(AuthSession {
subject: SessionSubject::Admin { admin_user_id },
subject: SessionSubject::Admin { admin_user_id, .. },
..
}) = session
{
@@ -288,6 +385,19 @@ fn session_ttl(minutes: u64) -> Duration {
)
}
fn validate_new_password(password: &str) -> Result<(), AdminError> {
if password.trim().chars().count() < 12
|| password.len() > 1024
|| password.chars().any(char::is_control)
|| crate::WEAK_ADMIN_PASSWORDS.contains(&password.trim().to_ascii_lowercase().as_str())
{
return Err(AdminError::invalid(
"新密码至少需要 12 个字符,最多 1024 字节,不能使用常见弱口令或控制字符",
));
}
Ok(())
}
fn hash_admin_password(password: &str) -> Result<String, AdminError> {
Argon2::default()
.hash_password(password.as_bytes())
@@ -314,3 +424,8 @@ fn valid_admin_api_key_shape(value: &str) -> bool {
&& value.starts_with("admin-")
&& value[6..].bytes().all(|byte| byte.is_ascii_hexdigit())
}
// 指纹只绑定已加盐的密码哈希,不把密码或原始哈希复制到 Redis 会话。
fn password_fingerprint(password_hash: &str) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(password_hash.as_bytes()))
}
@@ -15,7 +15,8 @@ use crate::{
client_keys::{
ClientKeyCursorValue, ClientKeyListQuery, ClientKeyMutation, ClientKeyPage,
ClientKeySecret, ClientKeySortField, CreateClientKey, CreatedClientKey,
DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey,
DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
UpdateClientKey,
},
},
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
@@ -48,22 +49,49 @@ pub trait ClientKeyService: Send + Sync {
context: &MutationContext,
command: DeleteClientKey,
) -> Result<ClientKeyMutation, AdminError>;
async fn reset_budget(
&self,
context: &MutationContext,
command: ResetClientKeyBudget,
) -> Result<ClientApiKeyId, AdminError>;
}
pub(crate) struct DefaultClientKeyService {
providers: crate::ports::provider::ProviderAdminRegistry,
store: Arc<dyn ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultClientKeyService {
#[must_use]
pub(crate) fn new(store: Arc<dyn ClientKeyStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
Self { store, snapshot }
pub(crate) fn new(
store: Arc<dyn ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
providers: crate::ports::provider::ProviderAdminRegistry,
) -> Self {
Self {
store,
snapshot,
providers,
}
}
}
#[async_trait]
impl ClientKeyService for DefaultClientKeyService {
async fn reset_budget(
&self,
context: &MutationContext,
command: ResetClientKeyBudget,
) -> Result<ClientApiKeyId, AdminError> {
let id = command.id.clone();
self.store
.reset_client_key_budget(command, context)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
Ok(id)
}
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError> {
validate_cursor(&query)?;
self.store
@@ -85,6 +113,19 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError> {
for (provider, profile) in [
("openai", &command.openai_client_profile_override),
("xai", &command.xai_client_profile_override),
] {
if let Some(profile) = profile {
let kind = gateway_core::routing::ProviderKind::new(provider)
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
self.providers
.require(&kind)
.and_then(|provider| provider.preview_client_profile(profile))
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
}
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
let plaintext = if let Some(key) = command.custom_key {
@@ -98,6 +139,8 @@ impl ClientKeyService for DefaultClientKeyService {
.store
.create_client_key(
NewClientKey {
openai_client_profile_override: command.openai_client_profile_override,
xai_client_profile_override: command.xai_client_profile_override,
id,
name: command.name,
label: command.label,
@@ -122,6 +165,19 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: UpdateClientKey,
) -> Result<ClientKeyMutation, AdminError> {
for (provider, profile) in [
("openai", &command.openai_client_profile_override),
("xai", &command.xai_client_profile_override),
] {
if let Some(Some(profile)) = profile {
let kind = gateway_core::routing::ProviderKind::new(provider)
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
self.providers
.require(&kind)
.and_then(|provider| provider.preview_client_profile(profile))
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
}
let id = command.id.clone();
let (config_revision, record) = self
.store
@@ -10,6 +10,7 @@ use crate::{
model::{
AdminError, AdminErrorKind,
auth::SessionSubject,
client_keys::ClientKeySecret,
key_usage::{
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
KeyUsageRecordsQuery,
@@ -26,6 +27,9 @@ use super::{map_store_error, observability::health_timeline_at};
#[async_trait]
pub trait KeyUsageService: Send + Sync {
async fn config(&self, session_id: Option<&str>)
-> Result<Option<ClientKeySecret>, AdminError>;
async fn overview(
&self,
session_id: Option<&str>,
@@ -85,6 +89,21 @@ fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
#[async_trait]
impl KeyUsageService for DefaultKeyUsageService {
async fn config(
&self,
session_id: Option<&str>,
) -> Result<Option<ClientKeySecret>, AdminError> {
let Some(id) = self.key_id(session_id).await? else {
return Ok(None);
};
// 明文只按服务端会话绑定的 Key 读取,禁用或删除后不再提供配置。
self.keys
.reveal_client_key(&id)
.await
.map(|secret| secret.filter(|secret| secret.record.enabled))
.map_err(|error| map_store_error(error, "key usage config"))
}
async fn overview(
&self,
session_id: Option<&str>,
@@ -191,9 +191,6 @@ async fn pending_authorization(
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
if !record.last_test.is_some_and(|test| test.success) {
return Err(AdminError::conflict("请先测试代理连接"));
}
proxy_id = Some(record.id);
Some(record.proxy)
}
@@ -192,7 +192,20 @@ impl DefaultObservabilityService {
average(first_token_latency_sum_ms, first_token_latency_count);
let trend = trend(TrendKind::Usage, observation.trend.clone())?;
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
let wire_profiles = self.providers.dashboard_wire_profiles();
let mut configurations = std::collections::BTreeMap::new();
if let Some(configuration) = &settings.openai_client_profile {
configurations.insert(
gateway_core::routing::ProviderKind::new("openai").expect("static provider kind"),
configuration.clone(),
);
}
if let Some(configuration) = &settings.xai_client_profile {
configurations.insert(
gateway_core::routing::ProviderKind::new("xai").expect("static provider kind"),
configuration.clone(),
);
}
let wire_profiles = self.providers.dashboard_wire_profiles(&configurations);
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
let total_slots = runtime_slots.as_ref().map_or_else(
|| {
@@ -612,7 +625,7 @@ async fn recover_standard_costs(
mut facts: UsageCalculatedBillingStream<'_>,
) -> Result<UsageCostScenarios, AdminError> {
let mut scenarios = UsageCostScenarios::default();
while let Some(fact) = facts
while let Some(mut fact) = facts
.try_next()
.await
.map_err(|error| map_store_error(error, "usage billing facts"))?
@@ -629,7 +642,14 @@ async fn recover_standard_costs(
cache_write_tokens: fact.cache_write_tokens,
total: fact.total.clone(),
};
let breakdown = match providers.calculated_billing(&provider_kind, &input) {
let breakdown = match fact
.breakdown
.take()
.filter(|b| b.total_amount == input.total)
.map_or_else(
|| providers.calculated_billing(&provider_kind, &input),
|b| Ok(Some(b)),
) {
Ok(breakdown) => breakdown,
Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => continue,
Err(error) => return Err(map_provider_error(error, "usage billing")),
@@ -658,8 +678,15 @@ fn no_cache_cost(
fact: &UsageCalculatedBillingFact,
breakdown: &crate::model::observability::CalculatedBillingBreakdown,
) -> Option<DecimalAmount> {
let input_tokens = fact.input_tokens?;
let cached_tokens = fact.cached_tokens.unwrap_or_default().min(input_tokens);
let image = breakdown.image.as_ref();
let input_tokens = fact
.input_tokens?
.checked_sub(image.map_or(0, |i| i.input_tokens))?;
let cached_tokens = fact
.cached_tokens
.unwrap_or_default()
.checked_sub(image.map_or(0, |i| i.cached_tokens))?
.min(input_tokens);
let cache_write_tokens = fact
.cache_write_tokens
.unwrap_or_default()
@@ -669,11 +696,20 @@ fn no_cache_cost(
.scaled()
.checked_mul(u128::from(cached_tokens.saturating_add(cache_write_tokens)))?
.checked_div(1_000_000)?;
let total = decimal(&breakdown.total_amount.amount)?
let mut total = decimal(&breakdown.total_amount.amount)?
.scaled()
.checked_sub(decimal(&breakdown.cache_read_amount.amount)?.scaled())?
.checked_sub(decimal(&breakdown.cache_write_amount.amount)?.scaled())?
.checked_add(replaced_input_amount)?;
if let Some(image) = image {
let image_no_cache = decimal(&image.input_price_per_million.amount)?
.scaled()
.checked_mul(u128::from(image.cached_tokens))?
.checked_div(1_000_000)?;
total = total
.checked_sub(decimal(&image.cache_read_amount.amount)?.scaled())?
.checked_add(image_no_cache)?;
}
DecimalAmount::from_str(
&gateway_core::metering::Decimal::from_scaled(total)
.ok()?
@@ -23,6 +23,33 @@ use super::{map_store_error, publish_committed};
/// API 消费的 Runtime settings 管理服务。
#[async_trait]
pub trait SettingsService: Send + Sync {
async fn preview_pricing_sync(
&self,
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError>;
async fn sync_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::SyncPricing,
) -> Result<(), AdminError>;
async fn pricing(&self) -> Result<crate::model::pricing::PricingCatalog, AdminError>;
async fn update_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::UpdatePricing,
) -> Result<(), AdminError>;
async fn client_profile_options(
&self,
_provider: &str,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
}
async fn preview_client_profile(
&self,
_provider: &str,
_configuration: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
}
async fn load(&self) -> Result<RuntimeSettings, AdminError>;
async fn replace(
&self,
@@ -41,19 +68,244 @@ pub trait SettingsService: Send + Sync {
}
pub(crate) struct DefaultSettingsService {
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
store: Arc<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultSettingsService {
fn profile_provider(
&self,
provider: &str,
) -> Result<Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
let kind = gateway_core::routing::ProviderKind::new(provider)
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
self.providers
.require(&kind)
.map_err(|error| super::map_provider_error(error, "client profile"))
}
#[must_use]
pub(crate) fn new(store: Arc<dyn SettingsStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
Self { store, snapshot }
pub(crate) fn new(
store: Arc<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
) -> Self {
Self {
providers,
pricing_source,
store,
snapshot,
}
}
}
#[async_trait]
impl SettingsService for DefaultSettingsService {
async fn preview_pricing_sync(
&self,
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError> {
self.pricing_source.fetch().await
}
async fn sync_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::SyncPricing,
) -> Result<(), AdminError> {
let count = command
.models
.values()
.map(std::collections::BTreeSet::len)
.sum::<usize>();
if count == 0
|| count > 10_000
|| command
.models
.values()
.any(std::collections::BTreeSet::is_empty)
{
return Err(AdminError::invalid("请选择 1 至 10000 个模型"));
}
let mut current = self.pricing_source.fetch().await?;
if current != command.preview {
return Err(AdminError::invalid(
"models.dev 价目已变化,请重新预览后确认",
));
}
let stored = self
.store
.load_pricing()
.await
.map_err(|error| map_store_error(error, "model pricing"))?;
let mut changes = crate::model::pricing::PricingSyncChanges::new();
for (provider, models) in command.models {
let selected = changes.entry(provider.clone()).or_default();
for model in models {
let price = current
.prices
.get_mut(&provider)
.and_then(|prices| prices.remove(&model));
if price.is_none()
&& !stored
.synced
.get(&provider)
.is_some_and(|prices| prices.contains_key(&model))
{
return Err(AdminError::invalid("所选模型不在来源价目中,请重新预览"));
}
selected.insert(model, price);
}
}
let revision = self
.store
.sync_pricing(changes, context)
.await
.map_err(|error| map_store_error(error, "model pricing sync"))?;
publish_committed(self.snapshot.as_ref(), revision).await
}
async fn pricing(&self) -> Result<crate::model::pricing::PricingCatalog, AdminError> {
let stored = self
.store
.load_pricing()
.await
.map_err(|error| map_store_error(error, "model pricing"))?;
Ok(crate::model::pricing::PricingCatalog {
defaults: self.providers.pricing_catalog(),
overrides: stored.overrides,
synced: stored.synced,
synced_at: stored.synced_at,
})
}
async fn update_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::UpdatePricing,
) -> Result<(), AdminError> {
use crate::model::pricing::PricingChange;
let catalog = self.pricing().await?;
if !catalog.defaults.contains_key(&command.provider)
|| command.models.is_empty()
|| command.models.len() > 500
|| command.models.iter().any(|model| {
model.is_empty()
|| model.len() > 128
|| model.trim() != model
|| model.chars().any(char::is_whitespace)
|| model.chars().any(char::is_control)
})
{
return Err(AdminError::invalid("Provider、模型 ID 或批量数量不合法"));
}
if command.change == PricingChange::Delete
&& command.models.iter().any(|model| {
catalog
.defaults
.get(&command.provider)
.is_some_and(|models| models.contains_key(model))
})
{
return Err(AdminError::invalid("内置价目不能删除,仅支持人工覆盖"));
}
let defaults = gateway_core::metering::merge_pricing(catalog.defaults, &catalog.synced);
let defaults = defaults.get(&command.provider);
match &command.change {
PricingChange::Replace(pricing) => {
pricing.validate().map_err(AdminError::invalid)?;
if command.provider == "xai"
&& pricing
.bands
.keys()
.any(|band| matches!(band.as_str(), "image" | "flex" | "long_flex"))
{
return Err(AdminError::invalid("xAI 不支持此价格档位"));
}
if !pricing.bands.contains_key("standard")
&& command
.models
.iter()
.any(|model| !defaults.is_some_and(|p| p.contains_key(model)))
{
return Err(AdminError::invalid("未知模型必须提供标准档价格"));
}
}
PricingChange::Multiplier(bps) if *bps > 1_000_000 => {
return Err(AdminError::invalid("倍率必须在 0 至 100 倍之间"));
}
PricingChange::Multiplier(_)
if command.models.iter().any(|model| {
!defaults.is_some_and(|p| p.contains_key(model))
&& !catalog
.overrides
.get(&command.provider)
.and_then(|p| p.get(model))
.is_some_and(|p| p.bands.contains_key("standard"))
}) =>
{
return Err(AdminError::invalid("请先为未知模型配置标准档价格"));
}
_ => {}
}
let revision = self
.store
.update_pricing(command, context)
.await
.map_err(|error| map_store_error(error, "model pricing"))?;
publish_committed(self.snapshot.as_ref(), revision).await
}
async fn client_profile_options(
&self,
provider: &str,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
let mut options = self
.profile_provider(provider)?
.client_profile_options()
.map_err(|error| super::map_provider_error(error, "client profile"))?
.into_inner();
let configuration = self
.load()
.await?
.client_profile(provider)
.cloned()
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
options.insert(
"globalConfiguration".to_owned(),
serde_json::Value::Object(configuration.into_inner()),
);
Ok(gateway_core::account::OpaqueProviderData::new(options))
}
async fn preview_client_profile(
&self,
provider: &str,
configuration: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
let profile_provider = self.profile_provider(provider)?;
let global;
let (configuration, source) = if let Some(configuration) = configuration {
(configuration, "override")
} else {
global = self
.load()
.await?
.client_profile(provider)
.cloned()
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
(&global, "global")
};
let mut preview = profile_provider
.preview_client_profile(configuration)
.map_err(|error| super::map_provider_error(error, "client profile"))?
.into_inner();
preview.insert("source".to_owned(), serde_json::Value::from(source));
Ok(gateway_core::account::OpaqueProviderData::new(preview))
}
async fn load(&self) -> Result<RuntimeSettings, AdminError> {
self.store
.load_runtime_settings()
@@ -67,6 +319,16 @@ impl SettingsService for DefaultSettingsService {
command: ReplaceRuntimeSettings,
) -> Result<RuntimeSettings, AdminError> {
validate_settings(&command)?;
for (provider, profile) in [
("openai", &command.openai_client_profile),
("xai", &command.xai_client_profile),
] {
if let Some(profile) = profile {
self.profile_provider(provider)?
.preview_client_profile(profile)
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
}
let settings = self
.store
.replace_runtime_settings(command, context)
@@ -115,6 +377,8 @@ impl SettingsService for DefaultSettingsService {
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
let valid = command.request_location.validate().is_ok()
&& command.responses_max_decompressed_body_bytes > 0
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
&& command.refresh_margin_seconds > 0
&& command.refresh_concurrency > 0
&& command.max_concurrent_per_account > 0
@@ -618,6 +618,16 @@ impl AuthStore for FakeAuthStore {
async fn load_password_hash(&self, _admin_user_id: &str) -> AdminStoreResult<Option<String>> {
Ok(None)
}
async fn change_password(
&self,
_: &str,
_: &str,
_: &str,
_: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn create_password_hash_if_absent(
&self,
_admin_user_id: &str,
@@ -202,6 +202,7 @@ impl AccountRuntimeStore for FakeRuntimeStore {
fn group_record() -> AccountGroupRecord {
let now = Utc::now();
AccountGroupRecord {
disable_fast: false,
id: group_id(),
name: "Primary".to_owned(),
description: None,
@@ -971,8 +971,27 @@ struct StaticSettingsStore;
#[async_trait]
impl SettingsStore for StaticSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
@@ -984,6 +1003,7 @@ impl SettingsStore for StaticSettingsStore {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -21,12 +21,83 @@ struct MemoryAuthStore {
audits: Mutex<Vec<AdminAuditEvent>>,
}
#[tokio::test]
async fn password_change_obeys_rate_limit_and_rejects_unbound_legacy_sessions() {
use gateway_admin::model::{
AdminErrorKind,
auth::{ChangePassword, SessionSubject},
};
let store = std::sync::Arc::new(MemoryAuthStore::default());
let services = super::AdminHarness::new().auth(store.clone()).build().await;
let auth = services.auth();
let login = auth
.login(
LoginCommand::Admin {
username: None,
password: "strong-test-password".into(),
},
std::net::Ipv4Addr::LOCALHOST.into(),
None,
)
.await
.unwrap();
*store.retry_after.lock().unwrap() = Some(std::time::Duration::from_secs(30));
let error = auth
.change_password(
Some(&login.session_id),
ChangePassword {
current_password: "strong-test-password".into(),
new_password: "new-strong-password".into(),
},
std::net::Ipv4Addr::LOCALHOST.into(),
)
.await
.unwrap_err();
assert_eq!(error.kind(), AdminErrorKind::RateLimited);
assert!(
auth.session(Some(&login.session_id))
.await
.unwrap()
.is_some()
);
store.sessions.lock().unwrap().insert(
"legacy".into(),
AuthSession {
subject: SessionSubject::Admin {
admin_user_id: "admin".into(),
credential_fingerprint: String::new(),
},
expires_at: Utc::now() + TimeDelta::hours(1),
},
);
assert!(auth.session(Some("legacy")).await.unwrap().is_none());
}
#[async_trait]
impl AuthStore for MemoryAuthStore {
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn change_password(
&self,
_: &str,
expected_hash: &str,
password_hash: &str,
audit: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
let mut stored = self.password_hash.lock().unwrap();
let Some(credentials) = stored
.as_mut()
.filter(|value| value.as_str() == expected_hash)
else {
return Ok(false);
};
*credentials = password_hash.to_owned();
self.audits.lock().unwrap().push(audit);
Ok(true)
}
async fn create_password_hash_if_absent(
&self,
_: &str,
@@ -197,6 +268,7 @@ async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logou
"expired".to_owned(),
AuthSession {
subject: SessionSubject::Admin {
credential_fingerprint: String::new(),
admin_user_id: "admin".to_owned(),
},
expires_at: Utc::now() - TimeDelta::seconds(1),
@@ -242,6 +242,16 @@ impl AuthStore for MemoryKeyAuthStore {
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
Ok(None)
}
async fn change_password(
&self,
_: &str,
_: &str,
_: &str,
_: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn create_password_hash_if_absent(&self, _: &str, _: &str) -> AdminStoreResult<bool> {
Ok(true)
}
@@ -22,10 +22,20 @@ struct TestClientKeyStore {
plaintexts: Mutex<Vec<String>>,
create_error: Option<AdminStoreErrorKind>,
update_error: Option<AdminStoreErrorKind>,
resets: Mutex<Vec<gateway_admin::model::client_keys::ResetClientKeyBudget>>,
}
#[async_trait]
impl ClientKeyStore for TestClientKeyStore {
async fn reset_client_key_budget(
&self,
command: gateway_admin::model::client_keys::ResetClientKeyBudget,
_: &MutationContext,
) -> AdminStoreResult<()> {
self.resets.lock().unwrap().push(command);
Ok(())
}
async fn get_client_key(
&self,
_: &ClientApiKeyId,
@@ -64,6 +74,8 @@ impl ClientKeyStore for TestClientKeyStore {
}
self.plaintexts.lock().unwrap().push(key.plaintext);
let record = ClientKeyRecord {
openai_client_profile_override: None,
xai_client_profile_override: None,
id: key.id,
name: key.name,
label: key.label,
@@ -112,6 +124,27 @@ impl ClientKeyStore for TestClientKeyStore {
}
}
#[tokio::test]
async fn reset_budget_forwards_scope_and_returns_only_key_identity() {
use gateway_admin::model::client_keys::{ClientKeyBudgetPeriod, ResetClientKeyBudget};
let store = Arc::new(TestClientKeyStore::default());
let services = super::AdminHarness::new()
.client_keys(store.clone())
.build()
.await;
let command = ResetClientKeyBudget {
id: ClientApiKeyId::new("key_reset").unwrap(),
period: ClientKeyBudgetPeriod::Weekly,
};
let result = services
.client_keys()
.reset_budget(&mutation_context(), command.clone())
.await
.unwrap();
assert_eq!(result, command.id);
assert_eq!(*store.resets.lock().unwrap(), vec![command]);
}
#[tokio::test]
async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
let services = super::AdminHarness::new()
@@ -227,6 +260,8 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
.update(
&mutation_context(),
UpdateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
id: ClientApiKeyId::new("key_existing").unwrap(),
name: "Migration".to_owned(),
label: None,
@@ -246,6 +281,8 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
fn create_command(key: Option<&str>) -> CreateClientKey {
CreateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
name: "Migration".to_owned(),
label: None,
@@ -25,6 +25,8 @@ use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, event
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
@@ -36,6 +38,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -59,6 +62,23 @@ struct FreezeSettingsStore {
#[async_trait]
impl SettingsStore for FreezeSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(self.settings.clone())
}
@@ -243,6 +243,7 @@ impl AdminHarness {
self.backup,
),
gateway_admin::AdminRuntimePorts {
pricing_source: Arc::new(UnavailablePricingSource),
providers: self.providers,
snapshot: Arc::new(NoopSnapshot),
account_probe: self.probe,
@@ -282,6 +283,25 @@ impl AuthStore for BootstrapAuthStore {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn change_password(
&self,
_: &str,
expected_hash: &str,
password_hash: &str,
audit: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
let mut stored = self.password_hash.lock().unwrap();
let Some(credentials) = stored
.as_mut()
.filter(|value| value.as_str() == expected_hash)
else {
return Ok(false);
};
*credentials = password_hash.to_owned();
let _ = audit;
Ok(true)
}
async fn create_password_hash_if_absent(
&self,
_: &str,
@@ -567,6 +587,14 @@ impl AccountRuntimeStore for UnavailableStore {
#[async_trait]
impl ClientKeyStore for UnavailableStore {
async fn reset_client_key_budget(
&self,
_: gateway_admin::model::client_keys::ResetClientKeyBudget,
_: &MutationContext,
) -> AdminStoreResult<()> {
Err(unavailable("client key budget reset"))
}
async fn get_client_key(
&self,
_: &ClientApiKeyId,
@@ -678,6 +706,23 @@ impl ObservabilityStore for UnavailableStore {
#[async_trait]
impl SettingsStore for UnavailableStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unavailable("settings"))
}
@@ -716,6 +761,19 @@ struct UnavailableProvider {
calculated_billing: Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
}
struct UnavailablePricingSource;
#[async_trait]
impl gateway_admin::ports::pricing::PricingSource for UnavailablePricingSource {
async fn fetch(
&self,
) -> Result<gateway_admin::model::pricing::PricingSyncPreview, gateway_admin::model::AdminError>
{
Err(gateway_admin::model::AdminError::internal(
"pricing source unavailable",
))
}
}
impl UnavailableProvider {
fn new(kind: &str) -> Self {
Self {
@@ -863,6 +921,9 @@ pub(super) fn calculated_billing_provider() -> Arc<dyn ProviderAdmin> {
dashboard_profile: None,
calculated_billing: Some(
gateway_admin::model::observability::CalculatedBillingBreakdown {
long_context_billing_applied: false,
custom_multiplier_bps: 10_000,
image: None,
input_amount: amount("0.8"),
output_amount: amount("0.2"),
cache_read_amount: amount("0"),
@@ -372,6 +372,7 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
}],
}]);
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
breakdown: None,
bucket_start: quarter_hour_start(now),
provider_kind: "openai".to_owned(),
upstream_model_id: "gpt-5.5".to_owned(),
@@ -543,6 +544,7 @@ async fn usage_insights_should_reject_partial_costs_when_billing_stream_fails()
let range = observation_range(now);
let store = Arc::new(FixtureObservabilityStore::new(range));
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
breakdown: None,
bucket_start: quarter_hour_start(now),
provider_kind: "openai".to_owned(),
upstream_model_id: "gpt-5.5".to_owned(),
@@ -751,8 +753,27 @@ struct FixtureSettingsStore;
#[async_trait]
impl SettingsStore for FixtureSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(1).expect("revision"),
@@ -764,6 +785,7 @@ impl SettingsStore for FixtureSettingsStore {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -849,6 +871,7 @@ fn total_record(
now: DateTime<Utc>,
) -> UsageListRecord {
UsageListRecord {
client_api_key_name: Some("Production".to_owned()),
id: id.to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
@@ -860,6 +883,7 @@ fn total_record(
provider_account_authentication_kind: None,
upstream_model_id: Some("gpt-5.5".to_owned()),
upstream_transport: None,
upstream_response_model: None,
service_tier: None,
input_tokens: Some(800),
output_tokens: Some(200),
@@ -12,6 +12,7 @@ use gateway_core::account::{OutboundProxy, ProviderAccountId};
pub(super) struct TestProxies {
pub events: Option<super::accounts::EventLog>,
pub accounts: Option<Vec<ProxyAccountRef>>,
pub record: Option<ProxyRecord>,
}
struct ImportGuard(super::accounts::EventLog);
@@ -70,8 +71,11 @@ impl ProxyStore for TestProxies {
page_size: query.page_size.get(),
})
}
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
Err(super::unavailable("proxy"))
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord> {
self.record
.clone()
.filter(|record| record.id == id)
.ok_or_else(|| super::unavailable("proxy"))
}
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
Err(super::unavailable("proxy"))
@@ -105,6 +109,60 @@ impl ProxyProbe for TestProxies {
}
}
#[tokio::test]
async fn authorization_uses_selected_proxy_regardless_of_probe_status() {
use super::accounts::{FakeAccountStore, FakeProviderAdmin, context, events, recorded};
use gateway_admin::model::provider_credentials::StartAuthorization;
use std::sync::Arc;
for kind in ["openai", "xai"] {
for probe_success in [None, Some(false), Some(true)] {
let events = events();
let now = chrono::Utc::now();
let services = super::AdminHarness::new()
.provider(FakeProviderAdmin::new(kind, events.clone()))
.accounts(FakeAccountStore::new(kind, events.clone()))
.proxies(Arc::new(TestProxies {
record: Some(ProxyRecord {
location: None,
id: "proxy_oauth".to_owned(),
name: "授权出口".to_owned(),
proxy: OutboundProxy::parse("http://127.0.0.1:8080").unwrap(),
revision: Revision::new(1).unwrap(),
account_count: 0,
last_test_at: probe_success.map(|_| now),
last_test: probe_success.map(|success| ProxyTestResult {
success,
latency_ms: 10,
exit_ip: None,
exit_ipv4: None,
exit_ipv6: None,
message: "出口探测结果".to_owned(),
}),
created_at: now,
updated_at: now,
}),
..Default::default()
}))
.build()
.await;
let command = StartAuthorization {
outbound_proxy: Some(AccountProxySelection::Saved("proxy_oauth".to_owned())),
context: context("oauth-proxy-status"),
name: "授权账号".to_owned(),
reauthorization: None,
};
let result = if kind == "openai" {
services.openai().start_authorization(command).await
} else {
services.xai().start_authorization(command).await
};
assert!(result.is_ok(), "{kind}, {probe_success:?}: {result:?}");
assert_eq!(recorded(&events), ["provider.start_authorization"]);
}
}
}
#[tokio::test]
async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
use super::accounts::{FakeProviderAdmin, events};
@@ -13,8 +13,39 @@ use gateway_admin::{
struct UnusedSettingsStore;
#[tokio::test]
async fn client_profile_preview_rejects_unknown_provider_before_loading_settings() {
let services = super::AdminHarness::new()
.settings(std::sync::Arc::new(UnusedSettingsStore))
.build()
.await;
let error = services
.settings()
.preview_client_profile("unknown", None)
.await
.expect_err("unknown provider cannot use global settings");
assert_eq!(error.kind(), AdminErrorKind::Invalid);
}
#[async_trait]
impl SettingsStore for UnusedSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unused())
}
@@ -61,6 +92,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
request_id: "request-settings".to_owned(),
},
ReplaceRuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
model_mappings: Default::default(),
@@ -71,6 +104,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -72,6 +72,8 @@ impl ListAccountGroupsQuery {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateAccountGroupRequest {
#[serde(default)]
disable_fast: bool,
name: String,
description: Option<String>,
color: String,
@@ -80,6 +82,7 @@ struct CreateAccountGroupRequest {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct UpdateAccountGroupRequest {
disable_fast: Option<bool>,
id: String,
name: String,
description: Option<String>,
@@ -95,6 +98,7 @@ struct AccountGroupIdRequest {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountGroupView {
disable_fast: bool,
id: String,
name: String,
description: Option<String>,
@@ -138,6 +142,7 @@ impl From<AccountGroupRecord> for AccountGroupView {
id: record.id.to_string(),
name: record.name,
description: record.description,
disable_fast: record.disable_fast,
color: record.color.as_str().to_owned(),
enabled: record.enabled,
member_count: record.member_count,
@@ -273,6 +278,7 @@ where
CreateAccountGroup {
name: request.name,
description: request.description,
disable_fast: request.disable_fast,
color: group_color(&request.color)?,
},
)
@@ -300,6 +306,7 @@ where
id: group_id(request.id)?,
name: request.name,
description: request.description,
disable_fast: request.disable_fast,
color: group_color(&request.color)?,
},
)
@@ -7,10 +7,11 @@ use std::fmt;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use gateway_admin::model::client_keys::{
ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue, ClientKeyListQuery,
ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret,
ClientKeySort as DomainSort, ClientKeySortField as DomainSortField, CreateClientKey,
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
ClientKeyBudgetPeriod, ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue,
ClientKeyListQuery, ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord,
ClientKeySecret, ClientKeySort as DomainSort, ClientKeySortField as DomainSortField,
CreateClientKey, CreatedClientKey, DeleteClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
SortDirection, UpdateClientKey,
};
use gateway_core::{
engine::budget::ClientBudgetLimits,
@@ -146,6 +147,8 @@ impl ClientKeySort {
#[derive(Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateClientKeyRequest {
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
xai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
custom_key: Option<String>,
name: String,
label: Option<String>,
@@ -175,6 +178,12 @@ impl CreateClientKeyRequest {
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(CreateClientKey {
openai_client_profile_override: self
.openai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::new),
xai_client_profile_override: self
.xai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::new),
custom_key: self
.custom_key
.filter(|key| !key.is_empty())
@@ -201,6 +210,10 @@ impl CreateClientKeyRequest {
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateClientKeyRequest {
#[serde(default, deserialize_with = "deserialize_profile_override")]
openai_client_profile_override: Option<Option<serde_json::Map<String, serde_json::Value>>>,
#[serde(default, deserialize_with = "deserialize_profile_override")]
xai_client_profile_override: Option<Option<serde_json::Map<String, serde_json::Value>>>,
id: String,
name: String,
label: Option<String>,
@@ -221,6 +234,12 @@ impl UpdateClientKeyRequest {
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(UpdateClientKey {
openai_client_profile_override: self
.openai_client_profile_override
.map(|value| value.map(gateway_core::account::OpaqueProviderData::new)),
xai_client_profile_override: self
.xai_client_profile_override
.map(|value| value.map(gateway_core::account::OpaqueProviderData::new)),
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
name: self.name,
label: self.label,
@@ -235,6 +254,36 @@ impl UpdateClientKeyRequest {
}
}
/// 重置指定周期已用金额的请求。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ResetClientKeyBudgetRequest {
id: String,
period: BudgetResetPeriod,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
enum BudgetResetPeriod {
Daily,
Weekly,
All,
}
impl ResetClientKeyBudgetRequest {
pub fn into_command(self) -> Result<ResetClientKeyBudget, WireValidationError> {
validate_required_text(&self.id, "id")?;
Ok(ResetClientKeyBudget {
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
period: match self.period {
BudgetResetPeriod::Daily => ClientKeyBudgetPeriod::Daily,
BudgetResetPeriod::Weekly => ClientKeyBudgetPeriod::Weekly,
BudgetResetPeriod::All => ClientKeyBudgetPeriod::All,
},
})
}
}
/// 只携带 ID 的 Client Key mutation 请求。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -276,6 +325,8 @@ impl ClientKeyMutationRequest {
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientKeyView {
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
xai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
id: String,
name: String,
label: Option<String>,
@@ -314,6 +365,12 @@ impl From<ClientKeyRecord> for ClientKeyView {
"groups"
};
Self {
openai_client_profile_override: record
.openai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::into_inner),
xai_client_profile_override: record
.xai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::into_inner),
id: record.id.to_string(),
name: record.name,
label: record.label,
@@ -690,6 +747,10 @@ where
post(create_client_key::<S>),
)
.route("/api/admin/client-keys/reveal", get(reveal_client_key::<S>))
.route(
"/api/admin/client-keys/reset-budget",
post(reset_client_key_budget::<S>),
)
.route(
"/api/admin/client-keys/update",
post(update_client_key::<S>),
@@ -791,6 +852,29 @@ where
)
}
async fn reset_client_key_budget<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(payload): AdminJson<ResetClientKeyBudgetRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let id = state
.admin_services()
.client_keys()
.reset_budget(
&auth.context().mutation_context(),
payload.into_command().map_err(map_wire_error)?,
)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(MutatedClientKeyData::new(id.as_str().to_owned())),
))
}
async fn disable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
@@ -874,3 +958,10 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error)
}
// 省略表示不修改,null 表示恢复跟随通用设置。
fn deserialize_profile_override<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Option<serde_json::Map<String, serde_json::Value>>>, D::Error> {
Option::<serde_json::Map<String, serde_json::Value>>::deserialize(deserializer).map(Some)
}
@@ -214,6 +214,8 @@ fn capitalize_first(value: &str) -> String {
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
match billing? {
domain::UsageBilling::Total { source, total } => Some(BillingView {
long_context_billing_applied: false,
image: None,
input_amount_display: "—".to_owned(),
output_amount_display: "—".to_owned(),
cache_read_amount_display: "—".to_owned(),
@@ -232,6 +234,18 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<Bil
multiplier_display: "—".to_owned(),
}),
domain::UsageBilling::Calculated(value) => Some(BillingView {
long_context_billing_applied: value.long_context_billing_applied,
image: value
.image
.as_ref()
.map(|image| super::wire::ImageBillingView {
input_amount_display: format_money(&image.input_amount),
cache_read_amount_display: format_money(&image.cache_read_amount),
input_price_display: format_token_price(&image.input_price_per_million),
cache_read_price_display: format_token_price(
&image.cache_read_price_per_million,
),
}),
input_amount_display: format_money(&value.input_amount),
output_amount_display: format_money(&value.output_amount),
cache_read_amount_display: format_money(&value.cache_read_amount),
@@ -243,7 +257,15 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<Bil
cache_read_price_display: format_token_price(&value.cache_read_price_per_million),
cache_write_price_display: format_token_price(&value.cache_write_price_per_million),
service_tier_display: format_service_tier(value.service_tier.as_deref()),
multiplier_display: format!("{:.2}x", f64::from(value.multiplier_percent) / 100.0),
multiplier_display: if value.custom_multiplier_bps == 10_000 {
format!("{:.2}x", f64::from(value.multiplier_percent) / 100.0)
} else {
format!(
"档位 {:.2}x · 自定义 {:.4}x",
f64::from(value.multiplier_percent) / 100.0,
f64::from(value.custom_multiplier_bps) / 10_000.0
)
},
}),
}
}
@@ -257,6 +279,7 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi
.clone()
.or_else(|| record.requested_model_id.clone());
UsageListRecordView {
client_api_key_name: record.client_api_key_name,
id: record.id,
provider: record.provider_kind,
authentication_kind: record.provider_account_authentication_kind,
@@ -267,6 +290,7 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi
model,
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
client_transport: record.client_transport,
upstream_transport: record.upstream_transport,
@@ -360,6 +384,7 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
model,
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
status_code,
client_transport: record.client_transport,
@@ -542,6 +567,7 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
.clone()
.or_else(|| error.requested_model_id.clone());
OpsErrorView {
client_api_key_name: error.client_api_key_name,
id: error.event_id,
request_id: error.request_id,
client_api_key_id: error.client_api_key_ref,
@@ -56,6 +56,9 @@ pub struct CostCoverageView {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BillingView {
pub long_context_billing_applied: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<ImageBillingView>,
pub input_amount_display: String,
pub output_amount_display: String,
pub cache_read_amount_display: String,
@@ -70,10 +73,20 @@ pub struct BillingView {
pub multiplier_display: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageBillingView {
pub input_amount_display: String,
pub cache_read_amount_display: String,
pub input_price_display: String,
pub cache_read_price_display: String,
}
/// 使用记录表格的窄展示。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageListRecordView {
pub client_api_key_name: Option<String>,
pub id: String,
pub provider: Option<String>,
pub authentication_kind: Option<String>,
@@ -84,6 +97,7 @@ pub struct UsageListRecordView {
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub client_transport: String,
pub upstream_transport: Option<String>,
@@ -122,6 +136,7 @@ pub struct UsageRecordView {
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub status_code: Option<i64>,
pub client_transport: String,
@@ -760,6 +775,7 @@ pub struct DiagnosticsView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpsErrorView {
pub client_api_key_name: Option<String>,
pub id: String,
pub request_id: Option<String>,
pub client_api_key_id: Option<String>,
@@ -92,6 +92,8 @@ struct ProxyTestView {
success: bool,
latency_ms: u64,
exit_ip: Option<String>,
exit_ipv4: Option<String>,
exit_ipv6: Option<String>,
message: String,
}
@@ -101,6 +103,8 @@ impl From<ProxyTestResult> for ProxyTestView {
success: result.success,
latency_ms: result.latency_ms,
exit_ip: result.exit_ip.map(|ip| ip.to_string()),
exit_ipv4: result.exit_ipv4.map(|ip| ip.to_string()),
exit_ipv6: result.exit_ipv6.map(|ip| ip.to_string()),
message: result.message,
}
}
@@ -6,7 +6,7 @@ use std::{collections::BTreeMap, fmt};
use axum::{
Router,
extract::State,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
@@ -34,6 +34,8 @@ pub type ModelMappings = BTreeMap<String, String>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
@@ -44,6 +46,7 @@ pub struct RuntimeSettingsView {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -64,6 +67,10 @@ pub struct RuntimeSettingsView {
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateRuntimeSettingsRequest {
#[serde(default, deserialize_with = "deserialize_profile_update")]
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default, deserialize_with = "deserialize_profile_update")]
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
@@ -74,6 +81,7 @@ pub struct UpdateRuntimeSettingsRequest {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -104,6 +112,13 @@ impl UpdateRuntimeSettingsRequest {
return Err(WireValidationError::new(field));
}
}
if self.responses_max_decompressed_body_bytes == 0
|| isize::try_from(self.responses_max_decompressed_body_bytes).is_err()
{
return Err(WireValidationError::new(
"responsesMaxDecompressedBodyBytes",
));
}
if !(1..=120).contains(&self.concurrency_wait_timeout_seconds) {
return Err(WireValidationError::new("concurrencyWaitTimeoutSeconds"));
}
@@ -166,6 +181,12 @@ impl UpdateRuntimeSettingsRequest {
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
self.validate()?;
Ok(ReplaceRuntimeSettings {
openai_client_profile: self
.openai_client_profile
.map(gateway_core::account::OpaqueProviderData::new),
xai_client_profile: self
.xai_client_profile
.map(gateway_core::account::OpaqueProviderData::new),
request_location_enabled: self.request_location_enabled,
request_location: self
.request_location
@@ -181,6 +202,7 @@ impl UpdateRuntimeSettingsRequest {
max_waiting_per_key: self.max_waiting_per_key,
max_waiting_per_account: self.max_waiting_per_account,
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
min_codex_desktop_version: self.min_codex_desktop_version,
@@ -206,6 +228,12 @@ impl UpdateRuntimeSettingsRequest {
impl From<RuntimeSettings> for RuntimeSettingsView {
fn from(settings: RuntimeSettings) -> Self {
Self {
openai_client_profile: settings
.openai_client_profile
.map(gateway_core::account::OpaqueProviderData::into_inner),
xai_client_profile: settings
.xai_client_profile
.map(gateway_core::account::OpaqueProviderData::into_inner),
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
model_mappings: wire_model_mappings(settings.model_mappings),
@@ -216,6 +244,7 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
rotation_strategy: settings.rotation_strategy.as_str().to_owned(),
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
@@ -329,7 +358,25 @@ where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/settings/pricing", get(pricing::<S>))
.route(
"/api/admin/settings/pricing/update",
post(update_pricing::<S>),
)
.route(
"/api/admin/settings/pricing/sync/preview",
post(preview_pricing_sync::<S>),
)
.route("/api/admin/settings/pricing/sync", post(sync_pricing::<S>))
.route("/api/admin/settings", get(settings::<S>))
.route(
"/api/admin/settings/client-profiles/{provider}",
get(client_profile_options::<S>),
)
.route(
"/api/admin/settings/client-profiles/{provider}/preview",
post(preview_client_profile::<S>),
)
.route("/api/admin/settings/update", post(update_settings::<S>))
.route(
"/api/admin/settings/client-downloads/codex-desktop/windows",
@@ -368,6 +415,125 @@ where
)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PricingUpdateRequest {
provider: String,
models: Vec<String>,
change: PricingChangeRequest,
}
#[derive(Deserialize)]
#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)]
enum PricingChangeRequest {
Replace {
pricing: gateway_core::metering::ModelPriceOverride,
},
Multiplier {
#[serde(rename = "multiplierBps")]
multiplier_bps: u32,
},
Reset {},
Delete {},
}
async fn pricing<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let pricing = state
.admin_services()
.settings()
.pricing()
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({
"defaults": pricing.defaults, "overrides": pricing.overrides,
"synced": pricing.synced, "syncedAt": pricing.synced_at,
})),
))
}
async fn preview_pricing_sync<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let preview = state
.admin_services()
.settings()
.preview_pricing_sync()
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(preview),
))
}
async fn sync_pricing<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(command): AdminJson<gateway_admin::model::pricing::SyncPricing>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
state
.admin_services()
.settings()
.sync_pricing(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"saved": true})),
))
}
async fn update_pricing<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<PricingUpdateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
use gateway_admin::model::pricing::{PricingChange, UpdatePricing};
let change = match request.change {
PricingChangeRequest::Replace { pricing } => PricingChange::Replace(pricing),
PricingChangeRequest::Multiplier { multiplier_bps } => {
PricingChange::Multiplier(multiplier_bps)
}
PricingChangeRequest::Reset {} => PricingChange::Reset,
PricingChangeRequest::Delete {} => PricingChange::Delete,
};
state
.admin_services()
.settings()
.update_pricing(
&auth.context().mutation_context(),
UpdatePricing {
provider: request.provider,
models: request.models,
change,
},
)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"saved": true})),
))
}
async fn settings<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -564,3 +730,60 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error)
}
// 字段省略时保留已有配置;显式 null 不能清空唯一的通用默认。
fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<serde_json::Map<String, serde_json::Value>>, D::Error> {
serde_json::Map::<String, serde_json::Value>::deserialize(deserializer).map(Some)
}
async fn client_profile_options<S>(
_auth: AdminAuth,
Path(provider): Path<String>,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
.settings()
.client_profile_options(&provider)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result.into_inner()),
))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ClientProfilePreviewRequest {
configuration: Option<serde_json::Map<String, serde_json::Value>>,
}
async fn preview_client_profile<S>(
_auth: AdminAuth,
Path(provider): Path<String>,
State(state): State<S>,
AdminJson(request): AdminJson<ClientProfilePreviewRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let configuration = request
.configuration
.map(gateway_core::account::OpaqueProviderData::new);
let result = state
.admin_services()
.settings()
.preview_client_profile(&provider, configuration.as_ref())
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result.into_inner()),
))
}
+56 -3
View File
@@ -12,7 +12,7 @@ use axum::{
};
use gateway_admin::{
AdminServices,
model::auth::{AuthSession, LoginCommand, LoginError, SessionSubject},
model::auth::{AuthSession, ChangePassword, LoginCommand, LoginError, SessionSubject},
};
use serde::{Deserialize, Serialize};
@@ -39,6 +39,19 @@ pub enum LoginRequest {
},
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ChangePasswordRequest {
current_password: String,
new_password: String,
}
impl fmt::Debug for ChangePasswordRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ChangePasswordRequest([REDACTED])")
}
}
impl fmt::Debug for LoginRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -102,6 +115,7 @@ where
.route("/api/auth/login", post(login::<S>))
.route("/api/auth/status", get(session_status::<S>))
.route("/api/auth/logout", post(logout::<S>))
.route("/api/auth/password", post(change_password::<S>))
.route("/api/auth", any(not_found))
.route("/api/auth/{*path}", any(not_found))
.method_not_allowed_fallback(method_not_allowed)
@@ -189,17 +203,56 @@ where
.await
.map_err(map_admin_service_error)?;
}
let mut response = AdminResponse::new(
let response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(LogoutData {
message: "Logged out successfully",
}),
)
.into_response();
clear_session_cookie(response, &headers)
}
async fn change_password<S>(
State(state): State<S>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
AdminJson(payload): AdminJson<ChangePasswordRequest>,
) -> Result<Response, AdminError>
where
S: SessionState + Send + Sync,
{
state
.admin_services()
.auth()
.change_password(
session_cookie::value(&headers).as_deref(),
ChangePassword {
current_password: payload.current_password,
new_password: payload.new_password,
},
peer.ip(),
)
.await
.map_err(map_admin_service_error)?;
let response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(LogoutData {
message: "密码已修改,请重新登录",
}),
)
.into_response();
clear_session_cookie(response, &headers)
}
fn clear_session_cookie(
mut response: Response,
headers: &HeaderMap,
) -> Result<Response, AdminError> {
let cookie = format!(
"{}=; {}; Max-Age=0",
session_cookie::NAME,
session_cookie::attributes(&headers)
session_cookie::attributes(headers)
);
response.headers_mut().insert(
header::SET_COOKIE,
@@ -23,6 +23,7 @@ where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/key-usage/config", get(config::<S>))
.route("/api/key-usage/overview", get(overview::<S>))
.route("/api/key-usage/records", get(records::<S>))
.route("/api/key-usage", any(not_found))
@@ -31,6 +32,27 @@ where
.layer(middleware::map_response(no_store))
}
async fn config<S>(
State(state): State<S>,
headers: HeaderMap,
AdminQuery(_): AdminQuery<query::ConfigQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let secret = state
.admin_services()
.key_usage()
.config(session_cookie::value(&headers).as_deref())
.await
.map_err(map_admin_service_error)?
.ok_or_else(AdminError::session_required)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(presenter::config(secret)),
))
}
async fn overview<S>(
State(state): State<S>,
headers: HeaderMap,
@@ -1,7 +1,8 @@
//! 只投影当前 Key 可见的用量字段,不序列化账号、凭据、上游标识或诊断正文。
//! 用量响应不含凭据;配置响应仅在显式读取时返回当前 Key 的名称与明文。
use chrono::{DateTime, Utc};
use gateway_admin::model::{
client_keys::ClientKeySecret,
key_usage::{KeyUsageOverview, KeyUsageRecords},
observability::{
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
@@ -14,6 +15,21 @@ use crate::admin::observability::{
health_timeline_view, usage_list_token_details,
};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ConfigView {
name: String,
plaintext_key: String,
}
pub(super) fn config(secret: ClientKeySecret) -> ConfigView {
let plaintext_key = secret.expose_for_response().to_owned();
ConfigView {
name: secret.record.name,
plaintext_key,
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct OverviewView {
@@ -11,6 +11,10 @@ use serde::Deserialize;
use crate::admin::AdminError;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ConfigQuery {}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct OverviewQuery {
+40 -7
View File
@@ -143,7 +143,7 @@ pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response
}
}
/// Desktop 优先于其内嵌的 CLI/Core 标记;未知客户端保持兼容并返回 `None`。
/// Desktop 优先于内嵌 CLI/Core;未知客户端及未提供应用版本的手机远程客户端返回 `None`。
#[must_use]
pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClient> {
const MAXIMUM_HEADER_LENGTH: usize = 4096;
@@ -154,11 +154,21 @@ pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClien
|| user_agent.is_some_and(|value| contains_ascii_case_insensitive(value, "Codex Desktop"));
if is_desktop {
let explicit_version = bounded_ascii_header(headers, "version", MAXIMUM_HEADER_LENGTH);
let user_agent_version = user_agent.and_then(desktop_version_from_user_agent);
// 手机远程初始化会覆盖进程级 UA 后缀,保留的 Desktop/Core 前缀不代表应用版本。
// 仅缺失版本信息时免于门禁;已提供的版本即使非法,也继续按 Desktop 校验。
if !headers.contains_key("version")
&& user_agent_version.is_none()
&& headers
.get("user-agent")
.is_some_and(|value| value.as_bytes().len() <= MAXIMUM_HEADER_LENGTH)
&& user_agent.is_some_and(has_chatgpt_remote_user_agent_suffix)
{
return None;
}
let version = match explicit_version {
Some(value) => CodexClientVersion::parse(value).ok(),
None => user_agent
.and_then(desktop_version_from_user_agent)
.and_then(|value| CodexClientVersion::parse(value).ok()),
None => user_agent_version.and_then(|value| CodexClientVersion::parse(value).ok()),
};
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Desktop,
@@ -206,11 +216,34 @@ fn bounded_ascii_header<'a>(
fn desktop_version_from_user_agent(user_agent: &str) -> Option<&str> {
let marker = "Codex Desktop;";
let start = find_ascii_case_insensitive(user_agent, marker)? + marker.len();
let version = user_agent[start..]
// 空候选也代表已提供应用版本,不能被手机远程的缺失版本规则放行。
user_agent[start..]
.trim_start()
.split([')', ' ', ';', ','])
.next()?;
(!version.is_empty()).then_some(version)
.next()
}
fn has_chatgpt_remote_user_agent_suffix(user_agent: &str) -> bool {
let Some((_, suffix)) = user_agent
.strip_suffix(')')
.and_then(|value| value.rsplit_once(" ("))
else {
return false;
};
let Some((name, version)) = suffix.split_once("; ") else {
return false;
};
let Some(platform) = name
.strip_prefix("codex_chatgpt_")
.and_then(|value| value.strip_suffix("_remote"))
else {
return false;
};
[platform, version].into_iter().all(|value| {
!value.is_empty()
&& !value.contains(['(', ')', ';'])
&& !value.bytes().any(|byte| byte.is_ascii_whitespace())
})
}
fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
+74 -14
View File
@@ -8,7 +8,7 @@ use axum::{
};
use gateway_core::{
engine::EngineError,
error::{GatewayError, GatewayErrorKind},
error::{GatewayError, GatewayErrorKind, ProviderErrorKind},
policy::{ClientVersionRejection, CodexClientKind},
};
use serde_json::{Value, json};
@@ -22,16 +22,54 @@ pub fn openai_error_response(
error_type: &str,
code: &str,
) -> (StatusCode, Json<Value>) {
(
status,
Json(json!({
"error": {
"message": message,
"type": error_type,
"code": code
}
})),
)
let body = json!({
"error": {"message": message, "type": error_type, "code": code}
});
match capacity_error_for_client(&body) {
Some(body) => (StatusCode::SERVICE_UNAVAILABLE, Json(body)),
None => (status, Json(body)),
}
}
/// Codex 将这两个容量码视为不可重试;仅在交付边界转换,内部仍保留真实上游事实。
pub(super) fn client_error_code(code: &str) -> &str {
match code {
"server_is_overloaded" | "slow_down" => "server_error",
_ => code,
}
}
/// 为 HTTP 错误或流内失败保留原有字段,仅投影客户端需要的重试信号。
pub(super) fn capacity_error_for_client(data: &Value) -> Option<Value> {
const CODE_PATHS: [&str; 2] = ["/error/code", "/response/error/code"];
let capacity_code = |value: &Value| {
value
.as_str()
.is_some_and(|code| client_error_code(code) != code)
};
if !CODE_PATHS
.iter()
.any(|path| data.pointer(path).is_some_and(capacity_code))
{
return None;
}
let mut projected = data.clone();
for path in CODE_PATHS {
if let Some(code) = projected.pointer_mut(path)
&& capacity_code(code)
{
*code = Value::String("server_error".to_owned());
}
}
// WS 包装错误按 HTTP 状态分类;保留 400/429 会使客户端终止重试。
for field in ["status", "status_code"] {
if let Some(status) = projected.get_mut(field)
&& status.is_number()
{
*status = json!(503);
}
}
Some(projected)
}
/// 严格协议 decoder/encoder 返回的安全错误 body。
@@ -166,14 +204,33 @@ pub fn gateway_error_response(error: &GatewayError) -> Response {
response
}
/// 在下游尚未提交时优先交付 Provider 的原始 HTTP 失败响应。
/// 在下游尚未提交时交付 Provider 的 HTTP 失败响应,并应用客户端容量恢复合同。
pub fn engine_error_response(error: &EngineError) -> Response {
let capacity_unavailable = matches!(error, EngineError::Provider(error)
if error.kind() == ProviderErrorKind::UpstreamCapacityUnavailable);
if let EngineError::Provider(error) = error
&& let Some(upstream) = error.client_visible_upstream_response()
&& let Ok(status) = StatusCode::from_u16(upstream.status())
{
let mut response = Response::new(Body::from(upstream.body().clone()));
*response.status_mut() = status;
let projected = serde_json::from_slice::<Value>(upstream.body())
.ok()
.and_then(|body| capacity_error_for_client(&body));
let mut response = match projected {
Some(body) => {
let mut response = Response::new(Body::from(body.to_string()));
*response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
response
}
None => {
let mut response = Response::new(Body::from(upstream.body().clone()));
*response.status_mut() = if capacity_unavailable {
StatusCode::SERVICE_UNAVAILABLE
} else {
status
};
response
}
};
if let Some(content_type) = upstream.content_type()
&& let Ok(content_type) = HeaderValue::from_bytes(content_type)
{
@@ -191,6 +248,9 @@ pub fn engine_error_response(error: &EngineError) -> Response {
return response;
}
let mut response = gateway_error_response(&gateway_error_from_engine(error));
if capacity_unavailable {
*response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
}
// 正文未完整取得时不能透传残缺响应,但已确认的上游关联 ID 仍应交付。
if let EngineError::Provider(error) = error
&& let Some(request_id) = error.upstream_request_id()
@@ -42,7 +42,10 @@ pub enum RequestDecodeError {
MalformedJson,
/// 请求体解压后超过允许上限。
#[error("decompressed request body is too large")]
DecompressedBodyTooLarge,
DecompressedBodyTooLarge {
/// 当前请求快照中的解压输出上限。
limit_bytes: usize,
},
/// 请求使用了网关不支持的 `Content-Encoding`。
#[error("request content-encoding `{encoding}` is not supported")]
UnsupportedContentEncoding {
@@ -108,9 +111,11 @@ impl RequestDecodeError {
"Request body must be valid JSON.".to_owned(),
None,
),
Self::DecompressedBodyTooLarge => (
Self::DecompressedBodyTooLarge { limit_bytes } => (
"request_too_large",
"Decompressed request body exceeds the allowed size.".to_owned(),
format!(
"Decompressed request body exceeds the allowed size ({limit_bytes} bytes)."
),
None,
),
Self::UnsupportedContentEncoding { encoding } => (
@@ -52,7 +52,11 @@ pub(crate) async fn responses(
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
let decoded = match decode_request_with_headers(&body, &headers) {
let decoded = match decode_request_with_headers(
&body,
&headers,
client.snapshot().responses_max_decompressed_body_bytes(),
) {
Ok(decoded) => decoded,
Err(error) => {
return protocol_error_response(StatusCode::BAD_REQUEST, error.protocol_body());
@@ -631,7 +635,9 @@ impl ResponsesStreamState {
.push_back(Bytes::from(response_failed_sse_event_with_id(
self.encoder.response_id(),
error.client_error_type().unwrap_or(default_type),
error.client_error_code().unwrap_or(default_code),
super::super::error::client_error_code(
error.client_error_code().unwrap_or(default_code),
),
error.client_message(),
)));
self.pending
@@ -327,13 +327,14 @@ impl fmt::Debug for DecodedResponsesRequest {
pub fn decode_request_with_headers(
body: &[u8],
headers: &HeaderMap,
max_decompressed_bytes: usize,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
let body = decompress_request_body(body, headers)?;
let body = decompress_request_body(body, headers, max_decompressed_bytes)?;
decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))
}
/// 下游请求体解压后的最大字节数;防御解压炸弹。
const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024;
/// zstd 回溯窗口独立于输出上限,调整设置不能放大解码器内部窗口分配。
const MAX_ZSTD_WINDOW_LOG: u32 = 26;
/// 按 `Content-Encoding` 解压下游请求体。
///
@@ -342,6 +343,7 @@ const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024;
fn decompress_request_body<'a>(
body: &'a [u8],
headers: &HeaderMap,
max_decompressed_bytes: usize,
) -> Result<Cow<'a, [u8]>, RequestDecodeError> {
let encoding = headers
.get_all(axum::http::header::CONTENT_ENCODING)
@@ -357,20 +359,20 @@ fn decompress_request_body<'a>(
"" | "identity" => Ok(Cow::Borrowed(body)),
"gzip" => {
let decoder = flate2::read::MultiGzDecoder::new(body);
read_bounded(decoder).map(Cow::Owned)
read_bounded(decoder, max_decompressed_bytes).map(Cow::Owned)
}
"deflate" => {
let decoder = flate2::read::ZlibDecoder::new(body);
read_bounded(decoder).map(Cow::Owned)
read_bounded(decoder, max_decompressed_bytes).map(Cow::Owned)
}
"zstd" => {
let mut decoder = zstd::stream::read::Decoder::with_buffer(body)
.map_err(|_| RequestDecodeError::MalformedJson)?;
// 输出有界之外,也限制压缩帧声明的回溯窗口,防止解码器内部过量分配。
decoder
.window_log_max(MAX_DECOMPRESSED_REQUEST_BYTES.ilog2())
.window_log_max(MAX_ZSTD_WINDOW_LOG)
.map_err(|_| RequestDecodeError::MalformedJson)?;
read_bounded(decoder).map(Cow::Owned)
read_bounded(decoder, max_decompressed_bytes).map(Cow::Owned)
}
other => Err(RequestDecodeError::UnsupportedContentEncoding {
encoding: other.to_owned(),
@@ -378,12 +380,15 @@ fn decompress_request_body<'a>(
}
}
fn read_bounded<R: std::io::Read>(mut reader: R) -> Result<Vec<u8>, RequestDecodeError> {
fn read_bounded<R: std::io::Read>(
mut reader: R,
max_decompressed_bytes: usize,
) -> Result<Vec<u8>, RequestDecodeError> {
let mut decoded = Vec::new();
let mut chunk = [0; 8192];
loop {
let remaining = MAX_DECOMPRESSED_REQUEST_BYTES - decoded.len();
let read_limit = chunk.len().min(remaining + 1);
let remaining = max_decompressed_bytes - decoded.len();
let read_limit = chunk.len().min(remaining.saturating_add(1));
let read = reader
.read(&mut chunk[..read_limit])
.map_err(|_| RequestDecodeError::MalformedJson)?;
@@ -391,15 +396,19 @@ fn read_bounded<R: std::io::Read>(mut reader: R) -> Result<Vec<u8>, RequestDecod
return Ok(decoded);
}
if read > remaining {
return Err(RequestDecodeError::DecompressedBodyTooLarge);
return Err(RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: max_decompressed_bytes,
});
}
// 默认 Vec 扩容和 read_to_end 的 EOF 探测可能突破输出上限;
// 容量增长也受相同边界约束,越界探测只使用栈上分块缓冲区。
let required = decoded.len() + read;
if required > decoded.capacity() {
let capacity = (decoded.capacity() * 2)
let capacity = decoded
.capacity()
.saturating_mul(2)
.max(required)
.min(MAX_DECOMPRESSED_REQUEST_BYTES);
.min(max_decompressed_bytes);
decoded.reserve_exact(capacity - decoded.len());
}
decoded.extend_from_slice(&chunk[..read]);
@@ -8,6 +8,7 @@ use gateway_protocol::openai::sse::{
use serde_json::Value;
use super::error::ResponseEncodeError;
use crate::openai::error::capacity_error_for_client;
const OPENAI_PROTOCOL: &str = "openai";
@@ -15,7 +16,7 @@ const OPENAI_PROTOCOL: &str = "openai";
///
/// OpenAI Provider 与 xAI adapter 都必须交付 OpenAI wire。wire 是客户端交付
/// 与终态判断的事实来源;canonical facts 只在 wire 暂未携带 ID 时提供旁路观测,
/// 不得反过来拒绝或改写上游事件。
/// 不得反过来拒绝或改写上游事件;客户端错误兼容投影只发生在编码出口。
#[derive(Debug, Default)]
pub struct OpenAiResponsesEncoder {
response_id: Option<String>,
@@ -43,13 +44,15 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
let projected = client_failure_payload(wire);
let data = projected.as_ref().unwrap_or_else(|| wire.data());
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因。
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`。
if wire.event_type() == Some("error")
if effective_event_type(wire) == Some("error")
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
data,
)
{
return vec![Bytes::from(encode_sse_event_with_metadata(
@@ -59,12 +62,14 @@ impl OpenAiResponsesEncoder {
wire.sse_retry(),
))];
}
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
if projected.is_none()
&& let Some(raw_sse_frame) = wire.raw_sse_frame()
{
return vec![raw_sse_frame.clone()];
}
vec![Bytes::from(encode_sse_event_with_metadata(
wire.event_type().unwrap_or_default(),
&wire.data().to_string(),
effective_event_type(wire).unwrap_or_default(),
&data.to_string(),
wire.sse_id(),
wire.sse_retry(),
))]
@@ -77,22 +82,24 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
let projected = client_failure_payload(wire);
let data = projected.as_ref().unwrap_or_else(|| wire.data());
// WS 客户端只对它无法消费的裸 `error` 帧做投影:codex 的 WS 端点
// 会静默忽略缺少 status 且不含内置可重试错误码的 `error` 帧,客户
// 端只能空等到 idle 超时。投影成 `response.failed`(消息 JSON 自带
// type 字段),codex 将其映射为可重试错误并立即重试。可消费形状
// (带非 2xx status 或特殊错误码)原样透传,保持业务语义。
if wire.event_type() == Some("error")
&& !ws_client_consumable_error(wire.data())
// type 字段),让 codex 按错误码处理。可消费形状(带非 2xx status
// 或特殊错误码)保留 envelope,容量码已由共享客户端投影处理。
if effective_event_type(wire) == Some("error")
&& !ws_client_consumable_error(data)
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
data,
)
{
return vec![data.to_string()];
}
vec![wire.data().to_string()]
vec![data.to_string()]
}
/// 返回是否已经看到客户端可见的 wire 终态。
@@ -142,9 +149,7 @@ impl OpenAiResponsesEncoder {
{
self.response_id = Some(response_id.to_owned());
}
let effective_type = wire
.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str));
let effective_type = effective_event_type(wire);
if matches!(
effective_type,
Some("response.created" | "response.in_progress" | "response.queued")
@@ -172,6 +177,22 @@ fn openai_wire(event: &ProviderEvent) -> Option<&ProtocolWireEvent> {
.filter(|wire| wire.protocol() == OPENAI_PROTOCOL)
}
fn effective_event_type(wire: &ProtocolWireEvent) -> Option<&str> {
wire.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str))
}
fn client_failure_payload(wire: &ProtocolWireEvent) -> Option<Value> {
if matches!(
effective_event_type(wire),
Some("error" | "response.failed")
) {
capacity_error_for_client(wire.data())
} else {
None
}
}
/// 判断 WS 上游的 `error` 帧是否已经是客户端可直接消费的形状。
///
/// codex 的 WS 端点只消费两类 `error` 帧:带非 2xx HTTP status 的包装错误,
@@ -2,11 +2,14 @@
use axum::http::{HeaderName, HeaderValue, StatusCode};
use gateway_core::engine::EngineError;
use gateway_core::error::ProviderErrorKind;
use gateway_core::event::ProviderResponseHeader;
use serde_json::{Map, Value, json};
use thiserror::Error;
use crate::openai::error::{gateway_error_contract, gateway_error_from_engine};
use crate::openai::error::{
capacity_error_for_client, gateway_error_contract, gateway_error_from_engine,
};
use super::super::{
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError,
@@ -152,12 +155,18 @@ pub(super) fn initial_engine_error_event(
headers.remove("x-request-id");
headers.remove("x-oai-request-id");
}
let status = upstream
.map(|response| response.status())
.or_else(|| provider.and_then(|error| error.upstream_status()))
.and_then(|value| StatusCode::from_u16(value).ok())
.filter(|value| !value.is_success() && !value.is_informational())
.unwrap_or(default_status);
let status = if provider
.is_some_and(|error| error.kind() == ProviderErrorKind::UpstreamCapacityUnavailable)
{
StatusCode::SERVICE_UNAVAILABLE
} else {
upstream
.map(|response| response.status())
.or_else(|| provider.and_then(|error| error.upstream_status()))
.and_then(|value| StatusCode::from_u16(value).ok())
.filter(|value| !value.is_success() && !value.is_informational())
.unwrap_or(default_status)
};
// 无原响应关联头时使用 Provider 的失败事实;不能让旧会话 ID 遮住当前失败。
if !has_request_id(&headers)
&& let Some(request_id) = provider.and_then(|error| error.upstream_request_id())
@@ -224,7 +233,10 @@ pub(super) fn error_event(
if !headers.is_empty() {
event.insert("headers".to_owned(), Value::Object(headers));
}
Value::Object(event).to_string()
let event = Value::Object(event);
capacity_error_for_client(&event)
.unwrap_or(event)
.to_string()
}
pub(super) fn connection_limit_event() -> String {
@@ -272,3 +272,27 @@ async fn response_json(response: axum::response::Response) -> Value {
.expect("account group response body");
serde_json::from_slice(&body).expect("account group response JSON")
}
#[tokio::test]
async fn disable_fast_group_updates_preserve_omitted_values() {
let fixture = authenticated_fixture().await;
for (value, expected) in [(Some(true), true), (None, true), (Some(false), false)] {
let mut body = json!({"id": PRIMARY_GROUP_ID, "name":"Policy", "description":null,"color":"#F43F5ECC"});
if let Some(value) = value {
body["disableFast"] = json!(value);
}
let response = request(
router(&fixture),
Method::POST,
"/api/admin/account-groups/update",
Some(body),
true,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response_json(response).await["data"]["record"]["disableFast"],
expected
);
}
}
@@ -4,6 +4,21 @@ use serde_json::json;
use super::AdminTestFixture;
#[test]
fn password_change_request_rejects_unknown_fields_and_redacts_both_passwords() {
let request: gateway_api::auth::ChangePasswordRequest = serde_json::from_value(json!({
"currentPassword": "current-password-secret", "newPassword": "new-password-secret"
}))
.unwrap();
assert!(!format!("{request:?}").contains("password-secret"));
assert!(
serde_json::from_value::<gateway_api::auth::ChangePasswordRequest>(json!({
"currentPassword": "old", "newPassword": "new", "username": "another-admin"
}))
.is_err()
);
}
#[test]
fn login_request_should_deny_unknown_fields_and_redact_password_debug() {
let password = "admin-password-must-not-leak";
@@ -10,6 +10,127 @@ use serde_json::json;
use super::{AdminTestFixture, AdminTestState};
#[test]
fn reset_budget_requires_an_explicit_supported_period_and_valid_key() {
use gateway_api::admin::client_keys::ResetClientKeyBudgetRequest;
for period in ["daily", "weekly", "all"] {
let command = serde_json::from_value::<ResetClientKeyBudgetRequest>(
json!({"id":"key_reset", "period":period}),
)
.unwrap()
.into_command()
.unwrap();
assert_eq!(command.id.as_str(), "key_reset");
}
for payload in [
json!({"id":"key_reset"}),
json!({"id":"key_reset", "period":"monthly"}),
json!({"id":"key_reset", "period":"all", "amount":0}),
] {
assert!(serde_json::from_value::<ResetClientKeyBudgetRequest>(payload).is_err());
}
assert!(
serde_json::from_value::<ResetClientKeyBudgetRequest>(json!({"id":" ", "period":"all"}))
.unwrap()
.into_command()
.is_err()
);
}
#[tokio::test]
async fn reset_budget_route_requires_admin_and_maps_missing_keys() {
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use tower::ServiceExt as _;
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (cookie, expected) in [
("", StatusCode::UNAUTHORIZED),
("cpr_session=valid-session", StatusCode::NOT_FOUND),
] {
let response = client_keys::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/client-keys/reset-budget")
.header(header::COOKIE, cookie)
.header(header::CONTENT_TYPE, "application/json")
.header("x-request-id", "req_reset")
.body(Body::from(r#"{"id":"missing","period":"all"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), expected);
}
}
#[tokio::test]
async fn budget_reset_allows_admin_but_rejects_key_sessions_without_changing_usage() {
use crate::support::{RAW_KEY, json_request, key_fixture, response_json};
use axum::http::{Method, StatusCode, header};
use gateway_core::policy::ClientApiKeyId;
use tower::ServiceExt as _;
let fixture = key_fixture().await;
fixture.auth.insert_session("valid-admin");
let mut record = fixture
.services
.client_keys()
.reveal(&ClientApiKeyId::new("key-42").unwrap())
.await
.unwrap()
.record;
record.budget.daily_used_usd = "1.25".parse().unwrap();
record.budget.weekly_used_usd = "4.5".parse().unwrap();
*fixture.client_key.lock().unwrap() = Some(record);
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let login = app
.clone()
.oneshot(json_request(
Method::POST,
"/api/auth/login",
json!({"mode":"key", "apiKey":RAW_KEY}),
))
.await
.unwrap();
assert_eq!(login.status(), StatusCode::OK);
let key_cookie = login.headers()[header::SET_COOKIE]
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_owned();
for (cookie, expected, used) in [
(key_cookie.as_str(), StatusCode::FORBIDDEN, "1.25"),
("cpr_session=valid-admin", StatusCode::OK, "0"),
] {
let mut request = json_request(
Method::POST,
"/api/admin/client-keys/reset-budget",
json!({"id":"key-42", "period":"daily"}),
);
request
.headers_mut()
.insert(header::COOKIE, cookie.parse().unwrap());
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), expected);
if expected == StatusCode::OK {
assert_eq!(
response_json(response).await["data"],
json!({"id":"key-42"})
);
}
let key = fixture.client_key.lock().unwrap().clone().unwrap();
assert_eq!(key.budget.daily_used_usd.canonical(), used);
assert_eq!(key.budget.weekly_used_usd.canonical(), "4.5");
}
}
#[test]
fn custom_client_keys_preserve_migrated_values_and_redact_debug() {
for value in [
@@ -311,6 +432,8 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
.single()
.expect("valid time");
let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord {
openai_client_profile_override: None,
xai_client_profile_override: None,
budget: Default::default(),
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
name: "visible".to_owned(),
@@ -446,3 +569,38 @@ async fn list_route_should_accept_the_full_nonzero_u16_page_size() {
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn profile_override_distinguishes_omission_from_explicit_inheritance() {
let mut payload = json!({"id":"key_profile", "name":"profile", "groupIds":[], "maxConcurrency":0, "requestsPerMinute":0});
let decode = |body| {
serde_json::from_value::<UpdateClientKeyRequest>(body)
.unwrap()
.into_command()
.unwrap()
.openai_client_profile_override
};
assert_eq!(decode(payload.clone()), None);
payload["openaiClientProfileOverride"] = json!(null);
assert_eq!(decode(payload.clone()), Some(None));
payload["openaiClientProfileOverride"] =
json!({"client":"cli", "platform":"linux", "versionMode":"latest"});
assert!(decode(payload).unwrap().is_some());
}
#[test]
fn xai_profile_override_distinguishes_omission_from_explicit_inheritance() {
let mut payload = json!({"id":"key_profile", "name":"profile", "groupIds":[], "maxConcurrency":0, "requestsPerMinute":0});
let decode = |body| {
serde_json::from_value::<UpdateClientKeyRequest>(body)
.unwrap()
.into_command()
.unwrap()
.xai_client_profile_override
};
assert_eq!(decode(payload.clone()), None);
payload["xaiClientProfileOverride"] = json!(null);
assert_eq!(decode(payload.clone()), Some(None));
payload["xaiClientProfileOverride"] = json!({"versionMode":"latest"});
assert!(decode(payload).unwrap().is_some());
}
@@ -176,6 +176,7 @@ impl AdminTestFixture {
ClientConfig::default(),
stores,
gateway_admin::AdminRuntimePorts {
pricing_source: Arc::new(StaticPricingSource),
providers,
snapshot: Arc::new(NoopSnapshot),
account_probe: Arc::new(NoopProbe),
@@ -270,6 +271,20 @@ impl MemoryAuthStore {
session_id.to_owned(),
AuthSession {
subject: gateway_admin::model::auth::SessionSubject::Admin {
credential_fingerprint: {
use base64::Engine as _;
use sha2::Digest as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
sha2::Sha256::digest(
self.password_hash
.lock()
.unwrap()
.as_ref()
.unwrap()
.as_bytes(),
),
)
},
admin_user_id: "admin_1".to_owned(),
},
expires_at: Utc::now() + Duration::hours(1),
@@ -300,6 +315,28 @@ impl AuthStore for MemoryAuthStore {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn change_password(
&self,
_: &str,
expected_hash: &str,
password_hash: &str,
audit: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
if self.fail_audit.load(Ordering::SeqCst) {
return Err(unavailable("audit"));
}
let mut stored = self.password_hash.lock().unwrap();
let Some(credentials) = stored
.as_mut()
.filter(|value| value.as_str() == expected_hash)
else {
return Ok(false);
};
*credentials = password_hash.to_owned();
self.audits.lock().unwrap().push(audit);
Ok(true)
}
async fn create_password_hash_if_absent(
&self,
_: &str,
@@ -368,14 +405,31 @@ impl AuthStore for MemoryAuthStore {
}
pub(super) struct MemorySettingsStore {
pricing: Mutex<gateway_admin::model::pricing::StoredPricing>,
settings: Mutex<RuntimeSettings>,
api_key: Arc<Mutex<Option<AdminApiKey>>>,
}
struct StaticPricingSource;
#[async_trait]
impl gateway_admin::ports::pricing::PricingSource for StaticPricingSource {
async fn fetch(
&self,
) -> Result<gateway_admin::model::pricing::PricingSyncPreview, gateway_admin::model::AdminError>
{
Ok(gateway_admin::model::pricing::PricingSyncPreview {
prices: serde_json::from_value(serde_json::json!({"openai":{"gpt-5.4":{
"multiplierBps":10000,"bands":{"standard":{"input":"2.5","output":"15","cacheRead":"0.25","cacheWrite":"0"}}
}}})).expect("prices"), skipped: vec![],
})
}
}
impl MemorySettingsStore {
fn new(api_key: Arc<Mutex<Option<AdminApiKey>>>) -> Self {
Self {
settings: Mutex::new(test_runtime_settings()),
pricing: Mutex::default(),
api_key,
}
}
@@ -387,6 +441,70 @@ impl MemorySettingsStore {
#[async_trait]
impl SettingsStore for MemorySettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(self.pricing.lock().expect("pricing").clone())
}
async fn sync_pricing(
&self,
changes: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
let mut pricing = self.pricing.lock().expect("pricing");
for (provider, models) in changes {
let stored = pricing.synced.entry(provider).or_default();
for (model, price) in models {
if let Some(price) = price {
stored.insert(model, price);
} else {
stored.remove(&model);
}
}
}
pricing.synced.retain(|_, models| !models.is_empty());
pricing.synced_at = Some(Utc::now());
let mut settings = self.settings.lock().expect("settings");
settings.config_revision = next_revision(settings.config_revision);
Ok(settings.config_revision)
}
async fn update_pricing(
&self,
command: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
use gateway_admin::model::pricing::PricingChange;
let mut pricing = self.pricing.lock().expect("pricing");
let gateway_admin::model::pricing::StoredPricing {
overrides, synced, ..
} = &mut *pricing;
let models = overrides.entry(command.provider.clone()).or_default();
let source = synced.entry(command.provider).or_default();
for model in command.models {
match &command.change {
PricingChange::Reset => {
models.remove(&model);
}
PricingChange::Delete => {
models.remove(&model);
source.remove(&model);
}
PricingChange::Replace(p) => {
models.insert(model, p.clone());
}
PricingChange::Multiplier(bps) => {
models
.entry(model)
.or_insert_with(|| gateway_core::metering::ModelPriceOverride {
multiplier_bps: 10_000,
bands: Default::default(),
})
.multiplier_bps = *bps;
}
}
}
let mut settings = self.settings.lock().expect("settings");
settings.config_revision = next_revision(settings.config_revision);
Ok(settings.config_revision)
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(self.settings.lock().expect("settings").clone())
}
@@ -402,6 +520,8 @@ impl SettingsStore for MemorySettingsStore {
) -> AdminStoreResult<RuntimeSettings> {
let mut settings = self.settings.lock().expect("settings");
let updated = RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: command.request_location_enabled,
request_location: command.request_location,
config_revision: next_revision(settings.config_revision),
@@ -413,6 +533,7 @@ impl SettingsStore for MemorySettingsStore {
max_waiting_per_key: command.max_waiting_per_key,
max_waiting_per_account: command.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: command.responses_max_decompressed_body_bytes,
rotation_strategy: command.rotation_strategy,
min_codex_desktop_version: command.min_codex_desktop_version,
min_codex_cli_version: command.min_codex_cli_version,
@@ -491,6 +612,7 @@ impl MemoryAccountGroupStore {
(
primary_id.clone(),
AccountGroupRecord {
disable_fast: false,
id: primary_id,
name: "Alpha routing".to_owned(),
description: Some("Primary traffic".to_owned()),
@@ -512,6 +634,7 @@ impl MemoryAccountGroupStore {
(
secondary_id.clone(),
AccountGroupRecord {
disable_fast: false,
id: secondary_id,
name: "Beta routing".to_owned(),
description: None,
@@ -627,6 +750,7 @@ impl AccountGroupStore for MemoryAccountGroupStore {
let mut state = self.state.lock().expect("account groups");
let now = Utc::now();
let record = AccountGroupRecord {
disable_fast: command.disable_fast,
id: command.id.clone(),
name: command.name,
description: command.description,
@@ -658,6 +782,9 @@ impl AccountGroupStore for MemoryAccountGroupStore {
record.name = command.name;
record.description = command.description;
record.color = command.color;
if let Some(disable_fast) = command.disable_fast {
record.disable_fast = disable_fast;
}
record.updated_at = Utc::now();
mutation(&mut state, command.id, true)
}
@@ -709,6 +836,34 @@ fn mutation(
#[async_trait]
impl ClientKeyStore for MemoryClientKeyStore {
async fn reset_client_key_budget(
&self,
command: gateway_admin::model::client_keys::ResetClientKeyBudget,
_: &MutationContext,
) -> AdminStoreResult<()> {
use gateway_admin::model::client_keys::ClientKeyBudgetPeriod;
let mut record = self.0.lock().unwrap();
let record = record
.as_mut()
.filter(|record| record.id == command.id)
.ok_or_else(|| {
AdminStoreError::new(AdminStoreErrorKind::NotFound, "client key", "missing key")
})?;
if matches!(
command.period,
ClientKeyBudgetPeriod::Daily | ClientKeyBudgetPeriod::All
) {
record.budget.daily_used_usd = gateway_core::metering::Decimal::ZERO;
}
if matches!(
command.period,
ClientKeyBudgetPeriod::Weekly | ClientKeyBudgetPeriod::All
) {
record.budget.weekly_used_usd = gateway_core::metering::Decimal::ZERO;
}
Ok(())
}
async fn get_client_key(
&self,
id: &ClientApiKeyId,
@@ -734,9 +889,15 @@ impl ClientKeyStore for MemoryClientKeyStore {
&self,
id: &ClientApiKeyId,
) -> AdminStoreResult<Option<ClientKeySecret>> {
if let Some(record) = self.0.lock().expect("client key").clone() {
return Ok((record.id == *id)
.then(|| ClientKeySecret::new(record, format!("sk_{}", "a".repeat(43)))));
}
let now = Utc::now();
Ok(Some(ClientKeySecret::new(
ClientKeyRecord {
openai_client_profile_override: None,
xai_client_profile_override: None,
budget: Default::default(),
id: id.clone(),
name: "revealed".to_owned(),
@@ -1105,6 +1266,15 @@ impl UnusedProvider {
#[async_trait]
impl ProviderAdmin for UnusedProvider {
fn pricing_catalog(&self) -> gateway_admin::model::pricing::ProviderPricingCatalog {
serde_json::from_value(serde_json::json!({
"builtin-model": {
"multiplierBps": 10000,
"bands": {"standard": {"input": "1", "output": "2", "cacheRead": "0", "cacheWrite": "1"}}
}
})).unwrap()
}
fn provider_kind(&self) -> &ProviderKind {
&self.kind
}
@@ -1270,6 +1440,8 @@ fn test_runtime_settings() -> RuntimeSettings {
),
]);
RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
@@ -1281,6 +1453,7 @@ fn test_runtime_settings() -> RuntimeSettings {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -169,6 +169,8 @@ fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() {
#[test]
fn billing_view_should_preserve_the_original_detail_contract() {
let value = serde_json::to_value(BillingView {
long_context_billing_applied: false,
image: None,
input_amount_display: "$0.03".to_owned(),
output_amount_display: "$0.00".to_owned(),
cache_read_amount_display: "$0.14".to_owned(),
@@ -630,6 +632,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
.lock()
.expect("ops errors")
.push(OpsError {
client_api_key_name: Some("Production".to_owned()),
source: "model_request".to_owned(),
event_id: "err_snapshot".to_owned(),
request_id: Some("req_err".to_owned()),
@@ -700,6 +703,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
.await
.expect("ops errors body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("ops errors JSON");
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
assert_eq!(
serde_json::json!({
"provider": value["data"]["items"][0]["provider"],
@@ -840,6 +844,7 @@ fn usage_record_with_account(
upstream_transport: Some("http_sse".to_owned()),
http_version: Some("h2".to_owned()),
websocket_pool: None,
upstream_response_model: None,
service_tier: None,
provider_metadata_json: None,
attempt_count: 1,
@@ -952,6 +957,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
.lock()
.expect("usage records")
.push(UsageListRecord {
client_api_key_name: Some("Production".to_owned()),
id: "request_endpoint".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "websocket".to_owned(),
@@ -963,6 +969,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
provider_account_authentication_kind: Some("oauth".to_owned()),
upstream_model_id: Some("grok-4.5".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
upstream_response_model: Some("grok-4.6-build".to_owned()),
service_tier: Some("default".to_owned()),
input_tokens: Some(1),
output_tokens: Some(1),
@@ -977,6 +984,9 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
cost_currency: None,
billing: Some(UsageBilling::Calculated(Box::new(
CalculatedBillingBreakdown {
long_context_billing_applied: true,
custom_multiplier_bps: 10_000,
image: None,
input_amount: usd("0.03"),
output_amount: usd("0.07"),
cache_read_amount: usd("0.00"),
@@ -1046,6 +1056,15 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
.expect("usage response body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("usage response JSON");
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
assert_eq!(
value["data"]["items"][0]["billing"]["longContextBillingApplied"],
true
);
assert_eq!(
value["data"]["items"][1]["billing"]["longContextBillingApplied"],
false
);
assert_eq!(
value["data"]["items"][0]["billing"]["inputPriceDisplay"],
"$10 / 1M Token"
@@ -1075,6 +1094,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
"imageOutputTokens": value["data"]["items"][0]["tokenDetails"]["imageOutputTokens"],
"requestedModel": value["data"]["items"][0]["requestedModel"],
"upstreamModel": value["data"]["items"][0]["upstreamModel"],
"upstreamResponseModel": value["data"]["items"][0]["upstreamResponseModel"],
"reasoningEffort": value["data"]["items"][0]["reasoningEffort"],
"reasoningPreset": value["data"]["items"][0]["reasoningPreset"],
"subagentKind": value["data"]["items"][0]["subagentKind"],
@@ -1098,6 +1118,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
"imageOutputTokens": 9,
"requestedModel": "grok-4.5",
"upstreamModel": "grok-4.5",
"upstreamResponseModel": "grok-4.6-build",
"reasoningEffort": "max",
"reasoningPreset": "ultra",
"subagentKind": "worker",
@@ -237,6 +237,8 @@ impl ProxyProbe for SuccessfulProbe {
success: true,
latency_ms: 15,
exit_ip: Some("203.0.113.2".parse().unwrap()),
exit_ipv4: Some("203.0.113.2".parse().unwrap()),
exit_ipv6: None,
message: "Connected".to_owned(),
}
}
@@ -390,7 +392,14 @@ async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records
assert_eq!(status, StatusCode::OK);
assert_eq!(
probed["data"],
json!({"success": true, "latencyMs": 15, "exitIp": "203.0.113.2", "message": "Connected"})
json!({
"success": true,
"latencyMs": 15,
"exitIp": "203.0.113.2",
"exitIpv4": "203.0.113.2",
"exitIpv6": null,
"message": "Connected"
})
);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["page"]["total"], 0);
@@ -50,6 +50,7 @@ fn update_body() -> Value {
"maxWaitingPerKey": 0,
"maxWaitingPerAccount": 0,
"concurrencyWaitTimeoutSeconds": 30,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
@@ -101,6 +102,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
use gateway_core::routing::{PublicModelId, UpstreamModelId};
let settings = RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
@@ -121,6 +124,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::RoundRobin,
min_codex_desktop_version: Some("26.825.6671".to_owned()),
min_codex_cli_version: Some("0.40.0".to_owned()),
@@ -144,7 +148,9 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
assert_eq!(
value,
json!({
"requestLocationEnabled": false,
"openaiClientProfile": null,
"xaiClientProfile": null,
"requestLocationEnabled": false,
"requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"},
"modelMappings": {
"gpt-5.4": "gpt-5.5",
@@ -157,6 +163,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"maxWaitingPerKey": 0,
"maxWaitingPerAccount": 0,
"concurrencyWaitTimeoutSeconds": 30,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
@@ -196,6 +203,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
.cloned()
.collect();
let settings = RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
@@ -217,6 +226,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
.expect("fixture rotation strategy"),
min_codex_desktop_version: request.min_codex_desktop_version,
@@ -243,6 +253,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
.cloned()
.collect();
let mut expected_fields = request_fields;
expected_fields.insert("openaiClientProfile".to_owned());
expected_fields.insert("xaiClientProfile".to_owned());
expected_fields.insert("updatedAt".to_owned());
assert_eq!(response_fields, expected_fields);
@@ -575,3 +587,464 @@ async fn request_location_should_reject_invalid_or_missing_fields_without_replac
original
);
}
#[test]
fn decompression_setting_should_reject_invalid_values() {
let field = "responsesMaxDecompressedBodyBytes";
for invalid in [
json!(0),
json!(-1),
json!(1.5),
json!(u64::MAX),
Value::Null,
] {
let mut body = update_body();
body[field] = invalid;
if let Ok(request) = serde_json::from_value::<UpdateRuntimeSettingsRequest>(body) {
assert_eq!(request.validate().unwrap_err().field(), field);
}
}
let mut body = update_body();
body.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
}
#[tokio::test]
async fn settings_reject_removed_global_fast_policy() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
let initial = fixture.settings.settings.lock().unwrap().clone();
for value in [json!(true), json!(false), Value::Null] {
let mut body = update_body();
body["disableFast"] = value;
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(*fixture.settings.settings.lock().unwrap(), initial);
}
let response = app
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response_json(response).await["data"]
.get("disableFast")
.is_none()
);
}
#[test]
fn global_profile_can_be_omitted_but_cannot_be_cleared() {
for field in ["openaiClientProfile", "xaiClientProfile"] {
let mut body = update_body();
body.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_ok());
body[field] = json!(null);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_err());
body[field] = json!({"versionMode":"latest"});
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_ok());
}
}
fn custom_pricing() -> Value {
json!({"multiplierBps":12500,"bands":{"standard":{"input":"3","output":"12","cacheRead":"0","cacheWrite":"0"}}})
}
#[tokio::test]
async fn pricing_routes_keep_manual_overrides_during_sync_and_reset_to_source() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
let response = app.clone().oneshot(request(Method::POST, "/api/admin/settings/pricing/update", Some(json!({
"provider":"openai", "models":["gpt-5.4"], "change":{"action":"replace", "pricing":custom_pricing()}
})))).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let preview = response_json(
app.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
))
.await
.unwrap(),
)
.await;
let approved = preview["data"].clone();
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview": approved, "models": {"openai": ["gpt-5.4"]}})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let result = response_json(
app.clone()
.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
assert_eq!(
result["data"]["overrides"]["openai"]["gpt-5.4"],
custom_pricing()
);
assert_eq!(
result["data"]["synced"]["openai"]["gpt-5.4"]["bands"]["standard"]["input"],
"2.5"
);
assert!(result["data"]["syncedAt"].is_string());
for _ in 0..2 {
let response = app.clone().oneshot(request(Method::POST, "/api/admin/settings/pricing/update", Some(json!({
"provider":"openai", "models":["gpt-5.4"], "change":{"action":"multiplier", "multiplierBps":20000}
})))).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
let result = response_json(
app.clone()
.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
assert_eq!(
result["data"]["overrides"]["openai"]["gpt-5.4"]["multiplierBps"],
20000
);
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(json!({
"provider":"openai", "models":["gpt-5.4"], "change":{"action":"reset"}
})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let result = response_json(
app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
assert!(result["data"]["overrides"]["openai"]["gpt-5.4"].is_null());
assert!(result["data"]["synced"]["openai"]["gpt-5.4"].is_object());
}
#[tokio::test]
async fn pricing_delete_rejects_builtin_models_even_with_overrides_without_partial_writes() {
for overridden in [false, true] {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
{
let mut stored = fixture.settings.pricing.lock().unwrap();
stored.synced = serde_json::from_value(json!({
"openai": {"builtin-model": custom_pricing(), "custom-model": custom_pricing()}
}))
.unwrap();
if overridden {
stored.overrides = stored.synced.clone();
}
}
let before = fixture.settings.pricing.lock().unwrap().clone();
let revision = fixture.settings.settings.lock().unwrap().config_revision;
for models in [
json!(["builtin-model"]),
json!(["custom-model", "builtin-model"]),
] {
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(
json!({"provider":"openai", "models":models, "change":{"action":"delete"}}),
),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(*fixture.settings.pricing.lock().unwrap(), before);
assert_eq!(
fixture.settings.settings.lock().unwrap().config_revision,
revision
);
}
}
}
#[tokio::test]
async fn pricing_delete_removes_selected_nonbuiltin_models_from_both_layers() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
{
let mut stored = fixture.settings.pricing.lock().unwrap();
stored.synced = serde_json::from_value(json!({
"openai": {"shared":custom_pricing(), "synced-only":custom_pricing(), "untouched":custom_pricing()},
"xai": {"shared":custom_pricing()}
})).unwrap();
stored.overrides = serde_json::from_value(json!({
"openai": {"shared":custom_pricing(), "custom-only":custom_pricing(), "untouched":custom_pricing()},
"xai": {"shared":custom_pricing()}
})).unwrap();
}
for _ in 0..2 {
let response = app.clone().oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(json!({
"provider":"openai", "models":["shared", "synced-only", "custom-only"], "change":{"action":"delete"}
})),
)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
let result = response_json(
app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
let expected =
json!({"openai":{"untouched":custom_pricing()},"xai":{"shared":custom_pricing()}});
assert_eq!(result["data"]["overrides"], expected);
assert_eq!(result["data"]["synced"], expected);
assert!(result["data"]["defaults"]["openai"]["builtin-model"].is_object());
}
#[tokio::test]
async fn pricing_rejects_invalid_edits_and_tampered_sync_without_writes() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
for body in [
json!({"provider":"unknown","models":["model"],"change":{"action":"reset"}}),
json!({"provider":"openai","models":[],"change":{"action":"reset"}}),
json!({"provider":"openai","models":["bad model"],"change":{"action":"reset"}}),
json!({"provider":"openai","models":["model"],"change":{"action":"multiplier","multiplierBps":1_000_001}}),
json!({"provider":"openai","models":["model"],"change":{"action":"replace","pricing":{"multiplierBps":10000,"bands":{}}}}),
] {
let scenario = body.to_string();
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{scenario}");
}
// JSON 合同错误沿用 AdminJson 的 422,业务校验错误为 400。
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(json!({
"provider":"openai","models":["model"],"change":{"action":"reset","extra":true}
})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview":{"prices":{},"skipped":[]},"models":{"openai":["gpt-5.4"]}})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let result = response_json(
app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
assert_eq!(result["data"]["overrides"], json!({}));
assert_eq!(result["data"]["synced"], json!({}));
}
#[tokio::test]
async fn pricing_sync_only_updates_selected_provider_models() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let old = custom_pricing();
{
let mut pricing = fixture.settings.pricing.lock().unwrap();
pricing.synced = serde_json::from_value(json!({
"openai": {"gpt-5.4": old, "untouched": old},
"xai": {"gpt-5.4": old}
}))
.unwrap();
pricing.overrides = serde_json::from_value(json!({"openai": {"gpt-5.4": old}})).unwrap();
}
let app = app(fixture.state());
let preview = response_json(
app.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
))
.await
.unwrap(),
)
.await;
let response = app
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview": preview["data"], "models": {"openai": ["gpt-5.4"]}})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let pricing = fixture.settings.pricing.lock().unwrap();
assert_eq!(
serde_json::to_value(&pricing.synced).unwrap(),
json!({
"openai": {"gpt-5.4": preview["data"]["prices"]["openai"]["gpt-5.4"], "untouched": old},
"xai": {"gpt-5.4": old}
})
);
assert_eq!(
serde_json::to_value(&pricing.overrides).unwrap(),
json!({"openai": {"gpt-5.4": old}})
);
}
#[tokio::test]
async fn pricing_sync_removes_only_selected_retired_source_prices() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
{
let mut pricing = fixture.settings.pricing.lock().unwrap();
pricing.synced = serde_json::from_value(json!({
"openai": {"retired": custom_pricing(), "untouched": custom_pricing()}
}))
.unwrap();
}
let app = app(fixture.state());
let preview = response_json(
app.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
))
.await
.unwrap(),
)
.await;
let response = app
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview": preview["data"], "models": {"openai": ["retired"]}})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let pricing = fixture.settings.pricing.lock().unwrap();
assert_eq!(
serde_json::to_value(&pricing.synced).unwrap(),
json!({
"openai": {"untouched": custom_pricing()}
})
);
}
#[tokio::test]
async fn pricing_sync_rejects_empty_unknown_or_oversized_selections_without_writes() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
let preview = response_json(
app.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
))
.await
.unwrap(),
)
.await;
for models in [
json!({}),
json!({"openai": []}),
json!({"unknown": ["gpt-5.4"]}),
json!({"openai": ["gpt-5.4", "missing"]}),
json!({"openai": ["gpt-5.4"], "xai": []}),
json!({"openai": (0..10_001).map(|index| format!("model-{index}")).collect::<Vec<_>>()}),
] {
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview": preview["data"], "models": models})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
let pricing = fixture.settings.pricing.lock().unwrap();
assert_eq!(
*pricing,
gateway_admin::model::pricing::StoredPricing::default()
);
}
#[tokio::test]
async fn pricing_endpoints_require_administrator_authentication() {
let fixture = AdminTestFixture::new().await;
let app = app(fixture.state());
for (method, path, body) in [
(Method::GET, "/api/admin/settings/pricing", None),
(
Method::POST,
"/api/admin/settings/pricing/update",
Some(json!({"provider":"openai","models":["model"],"change":{"action":"reset"}})),
),
(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
),
(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview":{"prices":{},"skipped":[]},"models":{"openai":["gpt-5.4"]}})),
),
] {
assert_eq!(
app.clone()
.oneshot(request(method, path, body))
.await
.unwrap()
.status(),
StatusCode::UNAUTHORIZED
);
}
}
+167
View File
@@ -42,6 +42,173 @@ async fn get(app: &axum::Router, path: &str, cookie: &str) -> axum::response::Re
.expect("GET response")
}
async fn change_password(
app: &axum::Router,
cookie: Option<&str>,
current: &str,
new: &str,
) -> axum::response::Response {
let mut request = json_request(
Method::POST,
"/api/auth/password",
json!({"currentPassword": current, "newPassword": new}),
);
if let Some(cookie) = cookie {
request
.headers_mut()
.insert(header::COOKIE, cookie.parse().unwrap());
}
app.clone().oneshot(request).await.unwrap()
}
#[tokio::test]
async fn password_change_revokes_all_admin_sessions_but_preserves_key_sessions() {
let (app, fixture) = auth_app().await;
let admin_body = json!({"mode": "admin", "password": "strong-admin-password"});
let first = session_cookie(&login(&app, admin_body.clone(), None).await);
let second = session_cookie(&login(&app, admin_body.clone(), None).await);
let key = session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
fixture.set_api_key(&format!("admin-{}", "b".repeat(64)));
assert_eq!(
get(&app, "/api/admin/system/version", &first)
.await
.status(),
StatusCode::OK
);
let response = change_password(
&app,
Some(&first),
"strong-admin-password",
"new-strong-admin-password",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(
response.headers()[header::SET_COOKIE]
.to_str()
.unwrap()
.contains("Max-Age=0")
);
for cookie in [&first, &second] {
assert_eq!(
get(&app, "/api/admin/system/version", cookie)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
assert_eq!(
response_json(get(&app, "/api/auth/status", &key).await).await["data"]["authenticated"],
true
);
assert_eq!(
login(&app, admin_body, None).await.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
login(
&app,
json!({"mode": "admin", "password": "new-strong-admin-password"}),
None
)
.await
.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn password_change_requires_admin_session_and_keeps_session_on_validation_failure() {
let (app, _) = auth_app().await;
assert_eq!(
change_password(&app, None, "strong-admin-password", "new-strong-password")
.await
.status(),
StatusCode::UNAUTHORIZED
);
let key = session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
assert_eq!(
change_password(
&app,
Some(&key),
"strong-admin-password",
"new-strong-password"
)
.await
.status(),
StatusCode::FORBIDDEN
);
let admin = session_cookie(
&login(
&app,
json!({"mode": "admin", "password": "strong-admin-password"}),
None,
)
.await,
);
for (current, new) in [
("wrong-current-password", "new-strong-password"),
("strong-admin-password", "short"),
("strong-admin-password", "strong-admin-password"),
] {
assert_eq!(
change_password(&app, Some(&admin), current, new)
.await
.status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
get(&app, "/api/admin/system/version", &admin)
.await
.status(),
StatusCode::OK
);
}
}
#[tokio::test]
async fn password_change_audit_failure_preserves_password_and_session() {
let (app, fixture) = auth_app().await;
let admin = session_cookie(
&login(
&app,
json!({"mode": "admin", "password": "strong-admin-password"}),
None,
)
.await,
);
fixture.fail_audit(true);
assert_eq!(
change_password(
&app,
Some(&admin),
"strong-admin-password",
"new-strong-password"
)
.await
.status(),
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
get(&app, "/api/admin/system/version", &admin)
.await
.status(),
StatusCode::OK
);
fixture.fail_audit(false);
assert_eq!(
login(
&app,
json!({"mode": "admin", "password": "strong-admin-password"}),
None
)
.await
.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn unified_login_returns_server_identity_and_rotates_the_previous_session() {
let (app, _) = auth_app().await;
@@ -17,6 +17,8 @@ pub(super) async fn fixture() -> AdminTestFixture {
let fixture = key_fixture().await;
let now = Utc::now();
*fixture.client_key.lock().unwrap() = Some(ClientKeyRecord {
openai_client_profile_override: None,
xai_client_profile_override: None,
id: ClientApiKeyId::new("key-42").unwrap(),
name: "Development".to_owned(),
label: Some("private-sentinel".to_owned()),
@@ -89,6 +91,7 @@ pub(super) async fn fixture() -> AdminTestFixture {
fn usage_record() -> UsageListRecord {
UsageListRecord {
client_api_key_name: Some("Production".to_owned()),
id: "req-visible".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
@@ -100,6 +103,7 @@ fn usage_record() -> UsageListRecord {
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
upstream_response_model: Some("private-sentinel".to_owned()),
service_tier: Some("private-sentinel".to_owned()),
input_tokens: Some(1000),
output_tokens: Some(100),
@@ -144,6 +148,7 @@ fn usage_record() -> UsageListRecord {
fn error_record() -> OpsError {
OpsError {
client_api_key_name: Some("Production".to_owned()),
source: "private-sentinel".to_owned(),
event_id: "error-visible".to_owned(),
request_id: Some("private-sentinel".to_owned()),
@@ -40,7 +40,11 @@ async fn login(app: &Router, mode: &str) -> String {
}
async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Response {
let path = format!("/api/key-usage/{resource}?{RANGE}{suffix}");
let path = if resource == "config" {
format!("/api/key-usage/config{suffix}")
} else {
format!("/api/key-usage/{resource}?{RANGE}{suffix}")
};
let response = app
.clone()
.oneshot(cookie_request(Method::GET, &path, cookie))
@@ -50,6 +54,72 @@ async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Respon
response
}
#[tokio::test]
async fn config_reveals_only_the_session_keys_name_and_plaintext() {
let fixture = fixtures::fixture().await;
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
let response = get(&app, "config", "", &cookie).await;
assert_eq!(response.status(), StatusCode::OK);
let data = response_json(response).await["data"].clone();
assert_eq!(
data,
json!({
"name": "Development",
"plaintextKey": format!("sk_{}", "a".repeat(43)),
})
);
let overview = get(&app, "overview", "", &cookie).await;
assert!(
!response_json(overview)
.await
.to_string()
.contains("plaintextKey")
);
}
#[tokio::test]
async fn config_rejects_caller_selected_scope() {
let fixture = fixtures::fixture().await;
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
for query in [
"?keyId=other",
"?id=other",
"?clientApiKeyRef=other",
"?provider=openai",
] {
let response = get(&app, "config", query, &cookie).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(response_json(response).await["data"].is_null());
}
}
#[tokio::test]
async fn config_does_not_reveal_disabled_or_other_keys() {
let fixture = fixtures::fixture().await;
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
{
let mut key = fixture.client_key.lock().unwrap();
key.as_mut().unwrap().enabled = false;
}
assert_eq!(
get(&app, "config", "", &cookie).await.status(),
StatusCode::UNAUTHORIZED
);
{
let mut key = fixture.client_key.lock().unwrap();
let key = key.as_mut().unwrap();
key.enabled = true;
key.id = gateway_core::policy::ClientApiKeyId::new("other-key").unwrap();
}
assert_eq!(
get(&app, "config", "", &cookie).await.status(),
StatusCode::UNAUTHORIZED
);
}
fn assert_fields(value: &Value, expected: &[&str]) {
let mut actual: Vec<_> = value
.as_object()
@@ -218,7 +288,7 @@ async fn missing_admin_and_revoked_sessions_cannot_read_key_usage() {
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let admin = login(&app, "admin").await;
let key = login(&app, "key").await;
for resource in ["overview", "records"] {
for resource in ["overview", "records", "config"] {
assert_eq!(
get(&app, resource, "", "").await.status(),
StatusCode::UNAUTHORIZED
@@ -352,6 +422,11 @@ async fn namespace_errors_remain_json_and_uncacheable() {
"/api/key-usage/overview",
StatusCode::METHOD_NOT_ALLOWED,
),
(
Method::POST,
"/api/key-usage/config",
StatusCode::METHOD_NOT_ALLOWED,
),
] {
let response = app
.clone()
@@ -180,3 +180,121 @@ fn cli_user_agent_should_expose_semver_or_recognized_missing_version() {
.is_none()
);
}
#[test]
fn chatgpt_remote_desktop_without_app_version_should_not_use_a_version_gate() {
for name in [
"codex_chatgpt_android_remote",
"codex_chatgpt_ios_remote",
"codex_chatgpt_future_os_remote",
] {
for remote_version in ["dev", "1.2.3"] {
for (product, originator) in [
("Codex Desktop", None),
("codex_cli_rs", Some("Codex Desktop")),
] {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_str(&format!(
"{product}/0.154.0-alpha.6.2 (Windows 10.0.26200; x86_64) unknown ({name}; {remote_version})"
))
.unwrap(),
);
if let Some(originator) = originator {
headers.insert("originator", HeaderValue::from_static(originator));
}
assert_eq!(identify_codex_client(&headers), None, "{headers:?}");
}
}
}
}
#[test]
fn chatgpt_remote_marker_should_require_a_complete_exact_client_suffix() {
for suffix in [
"(codex_chatgpt_android_remote_extra; dev)",
"(unofficial_codex_chatgpt_ios_remote; dev)",
"(codex_chatgpt__remote; dev)",
"(codex_chatgpt_remote; dev)",
"(codex_chatgpt_future os_remote; dev)",
"(codex_chatgpt_future;os_remote; dev)",
"codex_chatgpt_android_remote; dev",
"(codex_chatgpt_android_remote; dev",
"(codex_chatgpt_android_remote; dev) trailing",
"(codex_chatgpt_android_remote; dev) (another_client; dev)",
"(codex_chatgpt_android_remote; )",
"(another_client; codex_chatgpt_android_remote)",
"(Codex Desktop; dev)",
"(Codex Desktop; ) (codex_chatgpt_android_remote; dev)",
"(Codex Desktop; invalid) (codex_chatgpt_ios_remote; dev)",
"(Codex Desktop)",
] {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_str(&format!("Codex Desktop/0.154.0-alpha.6.2 {suffix}")).unwrap(),
);
let client = identify_codex_client(&headers).expect("Desktop still requires a version");
assert_eq!(client.kind(), CodexClientKind::Desktop, "{suffix}");
assert!(client.version().is_none(), "{suffix}");
}
}
#[test]
fn chatgpt_remote_desktop_should_preserve_explicit_version_validation() {
for (value, expected) in [
(
HeaderValue::from_static("26.908.70816"),
Some("26.908.70816"),
),
(HeaderValue::from_static("26.1.0"), Some("26.1.0")),
(HeaderValue::from_static("dev"), None),
(HeaderValue::from_static(""), None),
(HeaderValue::from_bytes(&[0xff]).unwrap(), None),
] {
let mut headers = HeaderMap::new();
headers.insert("version", value);
headers.insert(
"user-agent",
HeaderValue::from_static(
"Codex Desktop/0.154.0-alpha.6.2 (codex_chatgpt_android_remote; dev)",
),
);
let client = identify_codex_client(&headers).expect("explicit Desktop version");
assert_eq!(client.kind(), CodexClientKind::Desktop);
assert_eq!(
client.version().map(ToString::to_string).as_deref(),
expected
);
}
}
#[test]
fn chatgpt_remote_marker_should_not_override_an_existing_desktop_version_suffix() {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_static(
"Codex Desktop/0.154.0-alpha.6.2 (Codex Desktop; 26.1.0) (codex_chatgpt_ios_remote; dev)",
),
);
let client = identify_codex_client(&headers).expect("Desktop version remains available");
assert_eq!(client.version().unwrap().to_string(), "26.1.0");
}
#[test]
fn chatgpt_remote_suffix_created_by_header_truncation_should_not_skip_the_gate() {
let suffix = " (codex_chatgpt_android_remote; dev)";
let mut user_agent = "Codex Desktop/0.154.0-alpha.6.2 ".to_owned();
user_agent.push_str(&"x".repeat(4096 - user_agent.len() - suffix.len()));
user_agent.push_str(suffix);
user_agent.push_str(" (another_client; dev)");
let mut headers = HeaderMap::new();
headers.insert("user-agent", HeaderValue::from_str(&user_agent).unwrap());
let client =
identify_codex_client(&headers).expect("incomplete headers still require a version");
assert_eq!(client.kind(), CodexClientKind::Desktop);
assert!(client.version().is_none());
}
@@ -7,10 +7,127 @@ use gateway_core::error::{
use gateway_core::upstream::UpstreamSendState;
use gateway_api::openai::error::{
gateway_error_contract, gateway_error_from_engine, gateway_error_response,
openai_error_response,
engine_error_response, gateway_error_contract, gateway_error_from_engine,
gateway_error_response, openai_error_response,
};
#[tokio::test]
async fn capacity_errors_offer_client_retry_without_changing_upstream_facts() {
use bytes::Bytes;
use gateway_core::error::ClientVisibleUpstreamResponse;
use gateway_core::event::ProviderResponseHeader;
use gateway_core::upstream::OpaqueUpstreamValue;
use serde_json::{Value, json};
for code in ["server_is_overloaded", "slow_down"] {
for status in [400, 429, 503] {
for raw_response in [false, true] {
let original = json!({"error": {
"code": code, "type": "service_unavailable_error", "message": "busy",
"param": "model", "future": {"keep": true}
}});
let mut provider = ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_status(status)
.with_upstream_code(OpaqueUpstreamValue::new(code.to_owned()))
.with_client_visible_upstream_error(
ClientVisibleUpstreamError::new(
"busy",
Some(code.to_owned()),
Some("service_unavailable_error".to_owned()),
),
);
if raw_response {
provider = provider.with_client_visible_upstream_response(
ClientVisibleUpstreamResponse::new(
status,
Some(b"application/json".to_vec()),
Bytes::from(original.to_string()),
)
.with_headers(vec![
ProviderResponseHeader::new(
"x-request-id",
Bytes::from_static(b"req_capacity"),
),
ProviderResponseHeader::new("retry-after", Bytes::from_static(b"7")),
]),
);
}
let error = EngineError::Provider(provider);
let response = engine_error_response(&error);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
if raw_response {
assert_eq!(response.headers()["x-request-id"], "req_capacity");
assert_eq!(response.headers()["retry-after"], "7");
}
let body: Value = serde_json::from_slice(
&to_bytes(response.into_body(), 4096)
.await
.expect("response body"),
)
.expect("JSON");
assert_eq!(body["error"]["code"], "server_error");
assert_eq!(body["error"]["message"], "busy");
assert_eq!(body["error"]["type"], "service_unavailable_error");
if raw_response {
let mut expected = original.clone();
expected["error"]["code"] = json!("server_error");
assert_eq!(body, expected);
}
let EngineError::Provider(provider) = error else {
unreachable!()
};
assert_eq!(provider.upstream_status(), Some(status));
assert_eq!(
provider.upstream_code().map(OpaqueUpstreamValue::as_str),
Some(code)
);
assert_eq!(
provider
.client_visible_upstream_error()
.expect("detail")
.code(),
Some(code)
);
if let Some(raw) = provider.client_visible_upstream_response() {
assert_eq!(
serde_json::from_slice::<Value>(raw.body()).expect("original JSON"),
original
);
}
}
}
}
}
#[tokio::test]
async fn classified_capacity_error_without_special_code_returns_retryable_http_status() {
use bytes::Bytes;
use gateway_core::error::ClientVisibleUpstreamResponse;
let body = Bytes::from_static(br#"{"error":{"code":null,"type":"server_error","message":"Selected model is at capacity. Please try a different model."}}"#);
let error = EngineError::Provider(
ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_status(400)
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
400,
Some(b"application/json".to_vec()),
body.clone(),
)),
);
let response = engine_error_response(&error);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
to_bytes(response.into_body(), 4096).await.expect("body"),
body
);
}
#[tokio::test]
async fn key_budget_errors_preserve_limit_code_and_retry_after() {
for code in ["key_daily_budget_exceeded", "key_weekly_budget_exceeded"] {
@@ -329,6 +446,27 @@ fn engine_provider_quota_exhaustion_should_use_the_retryable_capacity_contract()
);
}
#[tokio::test]
async fn locally_exhausted_account_pool_returns_official_usage_limit_contract() {
let error = EngineError::Provider(
ProviderError::new(
ProviderErrorKind::QuotaExhausted,
UpstreamSendState::NotSent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"All eligible accounts have exhausted their quota.",
Some("usage_limit_reached".to_owned()),
Some("usage_limit_reached".to_owned()),
)),
);
let response = engine_error_response(&error);
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap();
assert_eq!(body["error"]["type"], "usage_limit_reached");
assert_eq!(body["error"]["code"], "usage_limit_reached");
}
#[test]
fn engine_provider_timeout_and_cancellation_should_remain_distinct() {
let timeout = EngineError::Provider(ProviderError::new(
@@ -615,3 +753,33 @@ mod model_routing {
);
}
}
#[tokio::test]
async fn quota_recovery_http_response_uses_projection_without_replacing_upstream_facts() {
use bytes::Bytes;
use gateway_core::error::{ClientVisibleUpstreamResponse, OpaqueUpstreamValue};
let body = Bytes::from_static(br#"{"error":{"code":"previous_response_not_found","type":"invalid_request_error","message":"Previous response was not found. Retrying the full request."}}"#);
let provider = ProviderError::new(ProviderErrorKind::QuotaExhausted, UpstreamSendState::Sent)
.with_status(429)
.with_upstream_code(OpaqueUpstreamValue::new("usage_limit_reached"))
.with_retry_after(std::time::Duration::from_secs(129_600))
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
400,
Some(b"application/json".to_vec()),
body.clone(),
));
let error = EngineError::Provider(provider);
let response = gateway_api::openai::error::engine_error_response(&error);
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(response.headers()["content-type"], "application/json");
assert!(!response.headers().contains_key("retry-after"));
assert_eq!(to_bytes(response.into_body(), 4096).await.unwrap(), body);
let EngineError::Provider(provider) = error else {
unreachable!()
};
assert_eq!(provider.upstream_status(), Some(429));
assert_eq!(
provider.upstream_code().unwrap().as_str(),
"usage_limit_reached"
);
}
+11 -1
View File
@@ -135,8 +135,18 @@ pub(super) fn authenticated_client_for_provider(
plaintext: &str,
provider_name: &str,
) -> AuthenticatedClient {
authenticated_client_for_provider_with_limit(plaintext, provider_name, 64 * 1024 * 1024)
}
pub(super) fn authenticated_client_for_provider_with_limit(
plaintext: &str,
provider_name: &str,
bytes: usize,
) -> AuthenticatedClient {
let snapshot = snapshot(plaintext, provider_name)
.with_responses_max_decompressed_body_bytes(std::num::NonZeroUsize::new(bytes).unwrap());
let source = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot(plaintext, provider_name)),
RuntimeSnapshotHandle::new(snapshot),
Arc::new(UnusedExecutionStore),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
@@ -41,7 +41,9 @@ use serde_json::{Value, json};
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
use tower::ServiceExt;
use crate::openai::{api_router, authenticated_client_for_provider};
use crate::openai::{
api_router, authenticated_client_for_provider, authenticated_client_with_min_versions,
};
#[derive(Default)]
struct Trace {
@@ -546,7 +548,7 @@ async fn invalid_compressed_http_requests_should_fail_before_execution() {
super::request::encode_body("zstd", std::io::repeat(0).take(64 * 1024 * 1024 + 1))
.into(),
"request_too_large",
"Decompressed request body exceeds the allowed size.",
"Decompressed request body exceeds the allowed size (67108864 bytes).",
),
] {
let headers = HeaderMap::from_iter([
@@ -1696,7 +1698,7 @@ async fn streaming_upstream_error_event_should_be_translated_to_response_failed_
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"code": "server_error",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
@@ -1713,6 +1715,45 @@ async fn streaming_upstream_error_event_should_be_translated_to_response_failed_
);
}
#[tokio::test]
async fn streaming_capacity_error_after_commit_offers_client_retry() {
for code in ["server_is_overloaded", "slow_down"] {
let trace = Arc::new(Trace::default());
let error = ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"busy",
Some(code.to_owned()),
Some("service_unavailable_error".to_owned()),
));
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)),
NextStep::Error(EngineError::Provider(error)),
],
);
let response = stream_execution_response(Box::new(session), None).await;
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 16 * 1024)
.await
.expect("SSE body");
let text = std::str::from_utf8(&body).expect("UTF-8");
let events = parse_sse_events(text).expect("SSE");
let failed = events
.iter()
.find(|event| event.event.as_deref() == Some("response.failed"))
.expect("failed event");
let failed: Value = serde_json::from_str(&failed.data).expect("JSON");
assert_eq!(failed["response"]["error"]["code"], "server_error");
assert_eq!(failed["response"]["error"]["message"], "busy");
assert_eq!(text.matches("event: response.failed").count(), 1);
assert!(text.ends_with("data: [DONE]\n\n"));
}
}
#[tokio::test]
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
let trace = Arc::new(Trace::default());
@@ -1968,6 +2009,112 @@ struct SessionExecution {
session: Mutex<Option<Box<dyn ExecutionSession>>>,
}
#[tokio::test]
async fn chatgpt_remote_responses_should_only_skip_missing_desktop_versions() {
for name in [
"codex_chatgpt_android_remote",
"codex_chatgpt_ios_remote",
"codex_chatgpt_future_os_remote",
] {
for (version, expected_code) in [
(None, None),
(Some("26.908.70816"), None),
(Some("26.1.0"), Some("client_version_too_old")),
(Some("dev"), Some("client_version_unavailable")),
] {
let trace = Arc::new(Trace::default());
let execution = Arc::new(SessionExecution {
client: authenticated_client_with_min_versions(
"sk_remote_test",
Some("26.908.70816"),
Some("0.154.0"),
),
session: Mutex::new(Some(Box::new(FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(delivery(
started(),
CommitRequirement::CommitBeforeDelivery,
)),
NextStep::Event(delivery(completed(), CommitRequirement::AlreadyCommitted)),
NextStep::FinalizeSuccess,
],
)))),
});
let mut request = Request::post("/v1/responses")
.header(AUTHORIZATION, "Bearer sk_remote_test")
.header("user-agent", format!(
"Codex Desktop/0.154.0-alpha.6.2 (Windows 10.0.26200; x86_64) unknown ({name}; dev)"
));
if let Some(version) = version {
request = request.header("version", version);
}
let response = api_router(execution.clone())
.await
.oneshot(
request
.body(Body::from(
json!({"model": "model-a", "input": "synthetic", "stream": true})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
if let Some(code) = expected_code {
let value: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(status, StatusCode::UPGRADE_REQUIRED, "{name} {version:?}");
assert_eq!(value["error"]["code"], code);
assert!(execution.session.lock().unwrap().is_some());
} else {
assert_eq!(status, StatusCode::OK, "{name} {version:?}");
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains("event: response.completed\n"));
assert!(body.ends_with("data: [DONE]\n\n"));
assert!(trace.snapshot().contains(&"commit"));
}
}
}
}
#[tokio::test]
async fn chatgpt_remote_responses_should_still_require_client_key_authentication() {
let observed = Arc::new(Mutex::new(None));
let execution = Arc::new(ContextCaptureExecution {
observed: Arc::clone(&observed),
client: authenticated_client_with_min_versions(
"sk_context_test",
Some("26.908.70816"),
None,
),
});
let router = api_router(execution).await;
for authorization in [None, Some("Bearer sk_invalid")] {
let mut request = Request::post("/v1/responses").header(
"user-agent",
"Codex Desktop/0.154.0-alpha.6.2 (codex_chatgpt_android_remote; dev)",
);
if let Some(authorization) = authorization {
request = request.header(AUTHORIZATION, authorization);
}
let response = router
.clone()
.oneshot(
request
.body(Body::from(
json!({"model": "model-a", "input": "synthetic"}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(observed.lock().unwrap().is_none());
}
}
impl ExecutionService for SessionExecution {
fn authenticate(&self, _: &str) -> Result<AuthenticatedClient, ClientAuthenticationError> {
Ok(self.client.clone())
@@ -2180,3 +2327,50 @@ async fn local_execution_failure_uses_the_existing_model_id() {
}
}
}
#[tokio::test]
async fn compressed_http_request_above_default_limit_should_reach_execution_after_increase() {
let body = json!({"model": "model-a", "input": "x".repeat(64 * 1024 * 1024)}).to_string();
let compressed = super::request::encode_body("zstd", body.as_bytes());
for limit in [64 * 1024 * 1024, 128 * 1024 * 1024] {
let observed = Arc::new(Mutex::new(None));
let execution = Arc::new(ContextCaptureExecution {
observed: Arc::clone(&observed),
client: crate::openai::authenticated_client_for_provider_with_limit(
"sk_context_test",
"openai",
limit,
),
});
let response = api_router(execution)
.await
.oneshot(
Request::post("/v1/responses")
.header(AUTHORIZATION, "Bearer sk_context_test")
.header(CONTENT_ENCODING, "zstd")
.body(Body::from(compressed.clone()))
.unwrap(),
)
.await
.unwrap();
if limit == 64 * 1024 * 1024 {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(observed.lock().unwrap().is_none());
let error: Value =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert_eq!(error["error"]["code"], "request_too_large");
} else {
// 捕获执行服务刻意返回 500;断言完整输入到达执行层,而不是只验证路由状态码。
let captured = observed
.lock()
.unwrap()
.take()
.expect("request reached execution");
assert_eq!(
captured.input.unwrap().as_str().unwrap().len(),
64 * 1024 * 1024
);
}
}
}
@@ -19,7 +19,7 @@ use gateway_protocol::openai::sse::parse_sse_events;
use serde_json::{Value, json};
fn decode_request(body: &[u8]) -> Result<DecodedResponsesRequest, RequestDecodeError> {
decode_request_with_headers(body, &HeaderMap::new())
decode_request_with_headers(body, &HeaderMap::new(), 64 * 1024 * 1024)
}
fn decode_response_create(
@@ -205,8 +205,9 @@ fn decoder_should_preserve_connection_metadata_outside_the_openai_wire_body() {
}
});
let decoded = decode_request_with_headers(body.to_string().as_bytes(), &headers)
.expect("request should decode");
let decoded =
decode_request_with_headers(body.to_string().as_bytes(), &headers, 64 * 1024 * 1024)
.expect("request should decode");
assert_eq!(Value::Object(openai_wire_body(&decoded).clone()), body);
assert_eq!(
@@ -317,9 +318,12 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
HeaderValue::from_static("client-is-update"),
);
let decoded =
decode_request_with_headers(br#"{"model":"smart-code","input":"hello"}"#, &headers)
.expect("opaque request headers should not affect body decoding");
let decoded = decode_request_with_headers(
br#"{"model":"smart-code","input":"hello"}"#,
&headers,
64 * 1024 * 1024,
)
.expect("opaque request headers should not affect body decoding");
let entries = openai_protocol_context(&decoded)
.get("opaque_request_headers")
.and_then(Value::as_array)
@@ -420,9 +424,12 @@ fn decoder_should_strip_http_transport_but_leave_source_headers_for_provider() {
headers.insert("content-encoding", HeaderValue::from_static("identity"));
headers.insert("x-openai-future-mode", HeaderValue::from_static("keep"));
let decoded =
decode_request_with_headers(br#"{"model":"smart-code","input":"hello"}"#, &headers)
.expect("decode request behind a reverse proxy");
let decoded = decode_request_with_headers(
br#"{"model":"smart-code","input":"hello"}"#,
&headers,
64 * 1024 * 1024,
)
.expect("decode request behind a reverse proxy");
assert_eq!(
openai_protocol_context(&decoded).get("opaque_request_headers"),
@@ -482,7 +489,8 @@ fn downstream_client_headers_should_remain_opaque_without_losing_session_semanti
frame["type"] = json!("response.create");
let opening = OpenAiRequestHeaders::from_headers(&headers);
for decoded in [
decode_request_with_headers(body.to_string().as_bytes(), &headers).unwrap(),
decode_request_with_headers(body.to_string().as_bytes(), &headers, 64 * 1024 * 1024)
.unwrap(),
decode_response_create_with_context(&frame.to_string(), &opening).unwrap(),
decode_response_create_with_context(&frame.to_string(), &opening).unwrap(),
] {
@@ -565,7 +573,7 @@ fn decoder_should_preserve_unknown_arbitrary_precision_numbers() {
let number = "12345678901234567890123456789012345678901234567890";
let body = format!(r#"{{"model":"smart-code","input":"hello","future_number":{number}}}"#);
let decoded = decode_request_with_headers(body.as_bytes(), &HeaderMap::new())
let decoded = decode_request_with_headers(body.as_bytes(), &HeaderMap::new(), 64 * 1024 * 1024)
.expect("opaque numeric field should decode");
let encoded = serde_json::to_string(openai_wire_body(&decoded)).expect("encode request body");
@@ -838,7 +846,7 @@ fn transparent_encoder_should_translate_error_wire_to_response_failed_for_websoc
assert_eq!(payload["type"], "response.failed");
assert_eq!(payload["response"]["id"], response_id);
assert_eq!(payload["response"]["status"], "failed");
assert_eq!(payload["response"]["error"]["code"], "server_is_overloaded");
assert_eq!(payload["response"]["error"]["code"], "server_error");
// 与 SSE 边界投影到同一份 data 负载,两条客户端通道行为一致。
let mut sse_encoder = OpenAiResponsesEncoder::new();
@@ -912,6 +920,108 @@ fn transparent_encoder_should_follow_wire_when_canonical_identity_changes() {
);
}
#[test]
fn capacity_failure_projection_preserves_wire_metadata_and_original_events() {
for code in ["server_is_overloaded", "slow_down"] {
for (event_type, explicit_type) in [
("error", true),
("error", false),
("response.failed", true),
("response.failed", false),
] {
for status in [None, Some(400), Some(429), Some(503)] {
let error = json!({"code": code, "type": "service_unavailable_error", "message": "busy", "future": 42});
let mut original = if event_type == "error" {
json!({"type": event_type, "error": error, "sequence_number": 3})
} else {
json!({"type": event_type, "response": {"id": "resp_capacity", "status": "failed", "error": error}, "sequence_number": 3})
};
if let Some(status) = status {
original["status_code"] = json!(status);
}
let raw = Bytes::from(format!(
"id: capacity\r\nretry: 1700\r\nevent: {event_type}\r\ndata: {original}\r\n\r\n"
));
let event = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
explicit_type.then(|| event_type.to_owned()),
original.clone(),
raw.clone(),
Some("capacity".to_owned()),
Some(1700),
)
.expect("wire"),
);
let mut sse = OpenAiResponsesEncoder::new();
let mut ws = OpenAiResponsesEncoder::new();
let created = openai_wire_event(
Vec::new(),
"response.created",
json!({"type": "response.created", "response": {"id": "resp_capacity", "status": "in_progress"}}),
);
sse.push_sse(&created);
ws.push_websocket(&created);
let frames = sse.push_sse(&event);
let parsed =
parse_sse_events(std::str::from_utf8(&frames[0]).expect("UTF-8")).expect("SSE");
let sse_data: Value = serde_json::from_str(&parsed[0].data).expect("JSON");
assert_eq!(parsed[0].id.as_deref(), Some("capacity"));
assert_eq!(parsed[0].retry, Some(1700));
assert_eq!(parsed[0].event.as_deref(), Some("response.failed"));
assert_eq!(sse_data["response"]["error"]["code"], "server_error");
assert_eq!(sse_data["response"]["error"]["message"], "busy");
assert_eq!(sse_data["response"]["error"]["future"], 42);
let messages = ws.push_websocket(&event);
let ws_data: Value = serde_json::from_str(&messages[0]).expect("JSON");
if event_type == "error" && status.is_some() {
let mut expected = original.clone();
expected["error"]["code"] = json!("server_error");
expected["status_code"] = json!(503);
assert_eq!(ws_data, expected);
} else {
assert_eq!(ws_data, sse_data);
}
assert!(sse.has_wire_failure());
assert!(ws.has_wire_failure());
let wire = event.wire_event().expect("original wire");
assert_eq!(wire.data(), &original);
assert_eq!(wire.raw_sse_frame(), Some(&raw));
}
}
}
}
#[test]
fn capacity_client_projection_does_not_touch_other_codes_or_non_failure_events() {
for (event_type, code) in [
("response.failed", "rate_limit_exceeded"),
("response.failed", "insufficient_quota"),
("response.failed", "previous_response_not_found"),
("response.future", "server_is_overloaded"),
] {
let original =
json!({"type": event_type, "response": {"error": {"code": code, "message": "busy"}}});
let raw = Bytes::from(format!("event: {event_type}\r\ndata: {original}\r\n\r\n"));
let event = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some(event_type.to_owned()),
original.clone(),
raw.clone(),
None,
None,
)
.expect("wire"),
);
assert_eq!(OpenAiResponsesEncoder::new().push_sse(&event), vec![raw]);
assert_eq!(
OpenAiResponsesEncoder::new().push_websocket(&event),
vec![original.to_string()]
);
}
}
#[test]
fn transparent_encoder_should_only_require_wire_terminal_for_buffered_conversion() {
let mut missing_terminal = OpenAiResponsesEncoder::new();
@@ -41,7 +41,9 @@ fn http_decode_should_preserve_compressed_request_fields() {
let expected = serde_json::from_slice::<serde_json::Value>(REQUEST).expect("fixture JSON");
for encoding in ENCODINGS {
let compressed = encode_body(encoding, REQUEST);
let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding);
let decoded =
decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect(encoding);
assert_eq!(
openai_wire_body(&decoded),
expected.as_object().expect("object")
@@ -52,10 +54,10 @@ fn http_decode_should_preserve_compressed_request_fields() {
#[test]
fn http_decode_should_accept_plain_identity_and_case_insensitive_encodings() {
for request_headers in [HeaderMap::new(), headers(""), headers(" identity ")] {
assert!(decode_request_with_headers(REQUEST, &request_headers).is_ok());
assert!(decode_request_with_headers(REQUEST, &request_headers, 64 * 1024 * 1024).is_ok());
}
let compressed = encode_body("gzip", REQUEST);
assert!(decode_request_with_headers(&compressed, &headers(" GZip ")).is_ok());
assert!(decode_request_with_headers(&compressed, &headers(" GZip "), 64 * 1024 * 1024).is_ok());
}
#[test]
@@ -63,7 +65,7 @@ fn http_decode_should_reject_unknown_and_stacked_encodings() {
let mut repeated = headers("identity");
repeated.append(CONTENT_ENCODING, HeaderValue::from_static("gzip"));
for request_headers in [headers("br"), headers("gzip, zstd"), repeated] {
let error = decode_request_with_headers(REQUEST, &request_headers)
let error = decode_request_with_headers(REQUEST, &request_headers, 64 * 1024 * 1024)
.expect_err("unsupported encoding");
assert!(matches!(
error,
@@ -82,7 +84,8 @@ fn http_decode_should_reject_corrupted_and_truncated_compressed_bodies() {
let mut truncated = encode_body(encoding, REQUEST);
truncated.truncate(truncated.len() - 4);
for body in [b"not-a-compressed-stream".as_slice(), truncated.as_slice()] {
let error = decode_request_with_headers(body, &headers(encoding)).expect_err(encoding);
let error = decode_request_with_headers(body, &headers(encoding), 64 * 1024 * 1024)
.expect_err(encoding);
assert_eq!(
error.protocol_body().into_value(),
json!({"error": {
@@ -99,7 +102,9 @@ fn http_decode_should_read_all_gzip_members_and_zstd_frames() {
for encoding in ["gzip", "zstd"] {
let mut compressed = encode_body(encoding, &REQUEST[..12]);
compressed.extend(encode_body(encoding, &REQUEST[12..]));
let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding);
let decoded =
decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect(encoding);
assert_eq!(openai_wire_body(&decoded)["input"], "hello");
}
}
@@ -110,7 +115,8 @@ fn http_decode_should_not_ignore_invalid_trailing_members() {
let mut compressed = encode_body(encoding, REQUEST);
compressed.extend(encode_body(encoding, b"invalid trailing JSON".as_slice()));
assert_eq!(
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding),
decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect_err(encoding),
RequestDecodeError::MalformedJson
);
}
@@ -129,7 +135,8 @@ fn http_decode_should_reject_zstd_frames_with_an_excessive_window() {
REQUEST
);
assert_eq!(
decode_request_with_headers(&compressed, &headers("zstd")).expect_err("oversized window"),
decode_request_with_headers(&compressed, &headers("zstd"), 128 * 1024 * 1024)
.expect_err("oversized window"),
RequestDecodeError::MalformedJson
);
}
@@ -138,7 +145,8 @@ fn http_decode_should_reject_zstd_frames_with_an_excessive_window() {
fn http_decode_should_accept_the_exact_decompressed_limit() {
let padding = repeat(b' ').take(DECOMPRESSED_LIMIT - REQUEST.len() as u64);
let compressed = encode_body("zstd", REQUEST.chain(padding));
let decoded = decode_request_with_headers(&compressed, &headers("zstd")).expect("exact limit");
let decoded = decode_request_with_headers(&compressed, &headers("zstd"), 64 * 1024 * 1024)
.expect("exact limit");
assert_eq!(decoded.metadata().requested_model(), "gpt-test");
}
@@ -146,11 +154,13 @@ fn http_decode_should_accept_the_exact_decompressed_limit() {
fn http_decode_should_reject_bodies_above_the_decompressed_limit() {
for encoding in ENCODINGS {
let compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1));
let error =
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding);
let error = decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect_err(encoding);
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge,
RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: 64 * 1024 * 1024
},
"{encoding}"
);
}
@@ -163,12 +173,54 @@ fn http_decode_should_stop_at_limit_before_reading_invalid_trailing_frames() {
// 这个断言验证越界时已经停止读取,而不只是最终返回了某个超限错误。
let mut compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1));
compressed.extend_from_slice(b"invalid trailing frame");
let error =
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding);
let error = decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect_err(encoding);
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge,
RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: 64 * 1024 * 1024
},
"{encoding}"
);
}
}
#[test]
fn http_decode_should_apply_configured_limits_to_every_encoding_and_frame() {
for encoding in ENCODINGS {
let compressed = encode_body(encoding, REQUEST);
assert!(
decode_request_with_headers(&compressed, &headers(encoding), REQUEST.len()).is_ok()
);
let error = decode_request_with_headers(&compressed, &headers(encoding), REQUEST.len() - 1)
.unwrap_err();
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: REQUEST.len() - 1
}
);
assert!(
error
.protocol_body()
.error
.message
.contains(&(REQUEST.len() - 1).to_string())
);
}
for encoding in ["gzip", "zstd"] {
let split = REQUEST.len() / 2;
let mut compressed = encode_body(encoding, &REQUEST[..split]);
compressed.extend(encode_body(encoding, &REQUEST[split..]));
assert!(
decode_request_with_headers(&compressed, &headers(encoding), REQUEST.len()).is_ok()
);
assert!(matches!(
decode_request_with_headers(&compressed, &headers(encoding), REQUEST.len() - 1),
Err(RequestDecodeError::DecompressedBodyTooLarge { .. })
));
}
for request_headers in [HeaderMap::new(), headers("identity")] {
assert!(decode_request_with_headers(REQUEST, &request_headers, 1).is_ok());
}
}
@@ -200,6 +200,54 @@ async fn non_structured_bodies_use_safe_fallback_without_losing_status_or_ids()
}
}
#[tokio::test]
async fn initial_capacity_failure_offers_client_retry_with_upstream_correlation() {
for code in ["server_is_overloaded", "slow_down"] {
for status in [400, 429, 503] {
let provider = upstream_failure(
status,
b"",
vec![
header("x-request-id", b"req_capacity"),
header("retry-after", b"7"),
],
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"busy",
Some(code.to_owned()),
Some("service_unavailable_error".to_owned()),
));
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
assert_eq!(error["status"], 503);
assert_eq!(error["error"]["code"], "server_error");
assert_eq!(error["error"]["message"], "busy");
assert_eq!(error["headers"]["x-request-id"], "req_capacity");
assert_eq!(error["headers"]["retry-after"], "7");
}
}
}
#[tokio::test]
async fn classified_capacity_error_without_special_code_returns_retryable_websocket_status() {
let provider = ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_status(400)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"Selected model is at capacity. Please try a different model.",
None,
Some("server_error".to_owned()),
));
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
assert_eq!(error["status"], 503);
assert_eq!(error["error"]["code"], "upstream_unavailable");
assert_eq!(
error["error"]["message"],
"Selected model is at capacity. Please try a different model."
);
}
#[tokio::test]
async fn final_failure_headers_take_precedence_over_observed_opening_headers() {
let provider = upstream_failure(403, b"", vec![header("x-oai-request-id", b"req_final")])
@@ -447,3 +495,46 @@ async fn deliverable_business_failures_are_not_rewritten_or_followed_by_a_second
socket.close(None).await.unwrap();
}
}
#[tokio::test]
async fn initial_quota_recovery_delivers_client_projection_instead_of_upstream_status() {
let detail = json!({"message":"Previous response was not found. Retrying the full request.","code":"previous_response_not_found","type":"invalid_request_error"});
let provider = ProviderError::new(ProviderErrorKind::QuotaExhausted, UpstreamSendState::Sent)
.with_status(429)
.with_retry_after(Duration::from_secs(129_600))
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
detail["message"].as_str().unwrap(),
Some("previous_response_not_found".to_owned()),
Some("invalid_request_error".to_owned()),
))
.with_client_visible_upstream_response(
ClientVisibleUpstreamResponse::new(
400,
Some(b"application/json".to_vec()),
Bytes::from(json!({"error":detail}).to_string()),
)
.with_headers(vec![header("x-request-id", b"req-quota-rejected")]),
);
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
assert_eq!(error["status"], 400);
assert_eq!(error["error"], detail);
assert_eq!(error["headers"]["x-request-id"], "req-quota-rejected");
assert!(error["headers"].get("retry-after").is_none());
}
#[tokio::test]
async fn locally_exhausted_account_pool_sends_one_official_usage_limit_error() {
let provider = ProviderError::new(
ProviderErrorKind::QuotaExhausted,
UpstreamSendState::NotSent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"All eligible accounts have exhausted their quota.",
Some("usage_limit_reached".to_owned()),
Some("usage_limit_reached".to_owned()),
));
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
assert_eq!(error["status"], 429);
assert_eq!(error["error"]["type"], "usage_limit_reached");
assert_eq!(error["error"]["code"], "usage_limit_reached");
}
@@ -205,7 +205,7 @@ impl fmt::Debug for PlaintextCredential {
}
/// Provider-owned 的任意 JSON object;公共层只搬运、不读取内部 key。
#[derive(Clone, PartialEq)]
#[derive(Clone, PartialEq, Eq)]
pub struct OpaqueProviderData(Map<String, Value>);
impl OpaqueProviderData {
@@ -247,17 +247,48 @@ impl ClientRoutingScope {
/// 一次认证随 RuntimeSnapshot 冻结的账号目录与 Key 权限。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrozenAccountScope {
disable_fast: bool,
request_profiles: BTreeMap<ProviderKind, super::OpaqueProviderData>,
directory: Arc<RuntimeAccountDirectory>,
client_scope: ClientRoutingScope,
}
impl FrozenAccountScope {
/// 与 Key 授权范围一同冻结;具体字段只由对应 Provider 解释。
#[must_use]
pub fn with_request_profiles(
mut self,
profiles: BTreeMap<ProviderKind, super::OpaqueProviderData>,
) -> Self {
self.request_profiles = profiles;
self
}
#[must_use]
pub fn request_profile(&self, provider: &ProviderKind) -> Option<&super::OpaqueProviderData> {
self.request_profiles.get(provider)
}
/// Key 绑定分组的冻结 Fast 限制,与账号成员资格无关。
#[must_use]
pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self {
self.disable_fast = disable_fast;
self
}
#[must_use]
pub const fn disable_fast(&self) -> bool {
self.disable_fast
}
#[must_use]
pub const fn new(
directory: Arc<RuntimeAccountDirectory>,
client_scope: ClientRoutingScope,
) -> Self {
Self {
disable_fast: false,
request_profiles: BTreeMap::new(),
directory,
client_scope,
}
@@ -112,6 +112,7 @@ pub struct AccountRuntimeSignals {
}
const ACCOUNT_FEEDBACK_EWMA_ALPHA: f64 = 0.2;
const ACCOUNT_CAPACITY_FAILURE_EWMA_ALPHA: f64 = 0.4;
const ACCOUNT_FAILURE_RATE_HALF_LIFE: Duration = Duration::from_secs(15 * 60);
const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits();
@@ -120,6 +121,7 @@ const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits();
pub enum AccountAttemptFeedback {
Succeeded { first_output_ms: Option<u64> },
Failed { first_output_ms: Option<u64> },
CapacityRejected { first_output_ms: Option<u64> },
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -150,13 +152,12 @@ impl DecayingAccountFailureRate {
* 0.5_f64.powf(elapsed.as_secs_f64() / ACCOUNT_FAILURE_RATE_HALF_LIFE.as_secs_f64())
}
fn report_at(&mut self, sample: f64, now: Instant) {
fn report_at(&mut self, sample: f64, alpha: f64, now: Instant) {
let now = self
.updated_at
.map_or(now, |updated_at| updated_at.max(now));
let decayed = self.value_at(now);
self.value =
ACCOUNT_FEEDBACK_EWMA_ALPHA * sample + (1.0 - ACCOUNT_FEEDBACK_EWMA_ALPHA) * decayed;
self.value = alpha * sample + (1.0 - alpha) * decayed;
self.updated_at = Some(now);
}
}
@@ -173,14 +174,21 @@ impl Default for AccountFeedback {
impl AccountFeedback {
fn report_at(&self, feedback: AccountAttemptFeedback, now: Instant) {
let (failure, first_output_ms) = match feedback {
AccountAttemptFeedback::Succeeded { first_output_ms } => (0.0, first_output_ms),
AccountAttemptFeedback::Failed { first_output_ms } => (1.0, first_output_ms),
let (failure, alpha, first_output_ms) = match feedback {
AccountAttemptFeedback::Succeeded { first_output_ms } => {
(0.0, ACCOUNT_FEEDBACK_EWMA_ALPHA, first_output_ms)
}
AccountAttemptFeedback::Failed { first_output_ms } => {
(1.0, ACCOUNT_FEEDBACK_EWMA_ALPHA, first_output_ms)
}
AccountAttemptFeedback::CapacityRejected { first_output_ms } => {
(1.0, ACCOUNT_CAPACITY_FAILURE_EWMA_ALPHA, first_output_ms)
}
};
self.failure_rate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.report_at(failure, now);
.report_at(failure, alpha, now);
if let Some(first_output_ms) = first_output_ms.filter(|value| *value > 0) {
update_feedback_ewma(&self.first_output_ms, first_output_ms as f64);
}
@@ -196,6 +196,7 @@ where
recovery_account: None,
pending_retry: None,
transient_retry_counts: BTreeMap::new(),
request_profiles: BTreeMap::new(),
current: None,
send_state_watermark: UpstreamSendState::NotSent,
downstream_committed_at: None,
@@ -304,6 +305,8 @@ pub struct ResponseExecutionSession<S: ?Sized> {
pending_retry: Option<PendingAttemptRetry>,
/// 请求内按账号累计的瞬时拒绝重试次数,不能跨请求污染账号健康状态。
transient_retry_counts: BTreeMap<crate::account::ProviderAccountId, u32>,
/// 只在首次进入对应 Provider 时解析,避免后台发布更新改变同一请求的重试身份。
request_profiles: BTreeMap<crate::identity::ProviderKind, crate::account::OpaqueProviderData>,
current: Option<CurrentAttempt>,
/// 请求级发送状态水位;跨 attempt 单调不降,终态写回不得低于此档。
send_state_watermark: UpstreamSendState,
@@ -786,8 +789,36 @@ where
self.credential_recovery_attempted_accounts
.contains(account)
}));
let provider = self
.engine
.providers()
.get(candidate.provider())
.cloned()
.ok_or_else(|| EngineError::ProviderNotRegistered {
provider: candidate.provider().as_str().to_owned(),
})?;
if !self.request_profiles.contains_key(candidate.provider())
&& let Some(configuration) = self
.plan
.account_scope()
.request_profile(candidate.provider())
{
match provider.resolve_request_profile(configuration) {
Ok(profile) => {
self.request_profiles
.insert(candidate.provider().clone(), profile);
}
Err(error) => {
self.finish_provider_error(&error).await?;
return Err(provider_engine_error(error));
}
}
}
let context = AttemptContext::new(
RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone())
.with_request_profile(self.request_profiles.get(candidate.provider()).cloned())
.with_disable_fast(self.plan.disable_fast())
.with_pricing(self.plan.pricing())
.with_request_location(self.plan.request_location().cloned())
.with_concurrency_wait_budget(self.concurrency_wait_budget.clone())
.with_timing_started_at(self.observation.timing_started_at)
@@ -806,14 +837,6 @@ where
} else {
AttemptTrigger::AccountRetry
};
let provider = self
.engine
.providers()
.get(candidate.provider())
.cloned()
.ok_or_else(|| EngineError::ProviderNotRegistered {
provider: candidate.provider().as_str().to_owned(),
})?;
let attempt_trace = self.trace.attempt(next_attempt.get());
attempt_trace.record(
"attempt.started",
@@ -858,6 +881,7 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
@@ -874,6 +898,7 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
) && let Some(last_failure) = self.last_retryable_failure.take()
@@ -896,6 +921,7 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
@@ -1424,6 +1450,7 @@ where
error.kind(),
ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::QuotaExhausted
)
{
return false;
@@ -1498,6 +1525,12 @@ where
http_version,
websocket_pool,
service_tier,
upstream_response_model: self
.current
.as_ref()
.and_then(|current| current.response_observation.as_ref())
.and_then(ProviderResponseObservation::upstream_response_model)
.map(str::to_owned),
provider_metadata_json,
diagnostic_trace_json: self.trace.snapshot().map(|value| value.to_string()),
error: None,
@@ -1659,6 +1692,12 @@ where
http_version,
websocket_pool,
service_tier,
upstream_response_model: self
.current
.as_ref()
.and_then(|current| current.response_observation.as_ref())
.and_then(ProviderResponseObservation::upstream_response_model)
.map(str::to_owned),
provider_metadata_json,
diagnostic_trace_json: self.trace.snapshot().map(|value| value.to_string()),
error: Some(finalization.error),
@@ -314,6 +314,9 @@ impl ContinuationAttempt {
/// Provider 每次执行可见的 request-local context。
#[derive(Debug, Clone)]
pub struct RequestAttemptContext {
pricing: Arc<crate::metering::PricingOverrides>,
request_profile: Option<crate::account::OpaqueProviderData>,
disable_fast: bool,
request_location: Option<crate::account::RequestLocation>,
request_id: ModelRequestId,
client_api_key_ref: ClientApiKeyId,
@@ -323,6 +326,27 @@ pub struct RequestAttemptContext {
}
impl RequestAttemptContext {
#[must_use]
pub fn with_pricing(mut self, pricing: Arc<crate::metering::PricingOverrides>) -> Self {
self.pricing = pricing;
self
}
#[must_use]
pub fn with_request_profile(
mut self,
profile: Option<crate::account::OpaqueProviderData>,
) -> Self {
self.request_profile = profile;
self
}
#[must_use]
pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self {
self.disable_fast = disable_fast;
self
}
#[must_use]
pub fn with_request_location(
mut self,
@@ -337,6 +361,9 @@ impl RequestAttemptContext {
Self {
request_id,
client_api_key_ref,
request_profile: None,
pricing: Arc::default(),
disable_fast: false,
request_location: None,
timing_started_at: Instant::now(),
trace: crate::diagnostics::TraceContext::default(),
@@ -399,6 +426,22 @@ pub struct AttemptContext {
}
impl AttemptContext {
#[must_use]
pub fn pricing(&self) -> &crate::metering::PricingOverrides {
&self.request.pricing
}
/// 本次逻辑请求首次解析的 Provider 身份,换号及传输重试保持不变。
#[must_use]
pub const fn request_profile(&self) -> Option<&crate::account::OpaqueProviderData> {
self.request.request_profile.as_ref()
}
#[must_use]
pub const fn disable_fast(&self) -> bool {
self.request.disable_fast
}
#[must_use]
pub const fn request_location(&self) -> Option<&crate::account::RequestLocation> {
self.request.request_location.as_ref()
@@ -659,6 +702,7 @@ pub struct ModelRequestFinalization {
pub http_version: Option<String>,
pub websocket_pool: Option<String>,
pub service_tier: Option<String>,
pub upstream_response_model: Option<String>,
/// Provider 已筛选的专有观测 JSON;Core 不解释字段。
pub provider_metadata_json: Option<String>,
/// 请求全程的有界诊断快照,跨 Provider 与重试保留。
@@ -44,7 +44,7 @@ impl ResponseObservation {
if let GatewayEvent::CalculatedCost(observed) = event
&& self.cost.source() != CostSource::ProviderReported
{
self.cost = observed.into_estimate();
self.cost = observed.clone().into_estimate();
}
if let GatewayEvent::ProviderCost(observed) = event {
self.cost = observed.into_estimate();
@@ -264,13 +264,17 @@ impl ProviderStreamAccountFeedback {
{
return;
}
self.stats.report(
&self.provider_kind,
&self.account_id,
let feedback = if error.kind() == ProviderErrorKind::UpstreamCapacityUnavailable {
AccountAttemptFeedback::CapacityRejected {
first_output_ms: self.first_output_ms,
}
} else {
AccountAttemptFeedback::Failed {
first_output_ms: self.first_output_ms,
},
);
}
};
self.stats
.report(&self.provider_kind, &self.account_id, feedback);
self.reported = true;
}
}
@@ -479,12 +483,25 @@ impl fmt::Debug for ProviderRequest {
/// 新的 attempt 再次调用。
#[async_trait]
pub trait Provider: Send + Sync {
/// 从已冻结的配置解析请求身份;只读取本地发布资料,不执行网络请求。
fn resolve_request_profile(
&self,
configuration: &crate::account::OpaqueProviderData,
) -> Result<crate::account::OpaqueProviderData, ProviderError> {
Ok(configuration.clone())
}
/// 返回编译期注册名称。
fn name(&self) -> &'static str;
/// 返回当前进程已经成功发布的目录代次。
fn catalog_generation(&self) -> ProviderCatalogGeneration;
/// 发现型目录不用于提前拒绝上游可能支持的新模型或别名。
fn model_catalog_is_exhaustive(&self) -> bool {
true
}
/// 解释 Provider 差异化观测字段;不参与路由和传输。
fn request_observation(
&self,
@@ -652,6 +669,12 @@ impl ProviderRegistry {
}
impl ProviderCatalogPort for ProviderRegistry {
fn model_catalog_is_exhaustive(&self, provider: &ProviderKind) -> bool {
self.providers
.get(provider)
.is_none_or(|provider| provider.model_catalog_is_exhaustive())
}
fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
self.providers
.iter()
+5 -2
View File
@@ -338,7 +338,10 @@ impl ProviderConnectionObservation {
}
}
/// 只供当前客户端请求使用的原始上游 HTTP 失败响应。
/// 只供当前客户端请求使用的 HTTP 失败响应。
///
/// 通常保留原始上游响应;Provider 也可为明确的恢复流程准备客户端投影。
/// 投影不能覆盖 [`ProviderError`] 中用于诊断和记账的真实上游事实。
///
/// 该值不属于稳定诊断事实,不能进入日志或持久化。它刻意不实现 [`Clone`];
/// [`ProviderError`] 的普通 clone 会丢弃它,只有最终失败的原对象才能把响应交给
@@ -352,7 +355,7 @@ pub struct ClientVisibleUpstreamResponse {
}
impl ClientVisibleUpstreamResponse {
/// 保存 transport 实际收到的状态码、Content-Type 原值和正文。
/// 保存客户端响应的状态码、Content-Type 和正文。
#[must_use]
pub fn new(status: u16, content_type: Option<Vec<u8>>, body: Bytes) -> Self {
Self {
+17
View File
@@ -530,6 +530,7 @@ pub struct ProviderResponseObservation {
status_code: Option<u16>,
request_id: Option<OpaqueUpstreamValue>,
service_tier: Option<String>,
upstream_response_model: Option<String>,
timings: ProviderResponseTimings,
client_headers: Vec<ProviderResponseHeader>,
provider_metadata: Option<ProviderResponseMetadata>,
@@ -545,6 +546,7 @@ impl ProviderResponseObservation {
status_code: None,
request_id: None,
service_tier: None,
upstream_response_model: None,
timings: ProviderResponseTimings::default(),
client_headers: Vec::new(),
provider_metadata: None,
@@ -600,6 +602,21 @@ impl ProviderResponseObservation {
self
}
/// 记录 Provider 从上游响应明确取得的模型,不改写发送模型或计费合同。
#[must_use]
pub fn with_upstream_response_model_if_valid(mut self, model: &str) -> Self {
let model = model.trim();
if validate_text(model, 256, false, None).is_ok() {
self.upstream_response_model = Some(model.to_owned());
}
self
}
#[must_use]
pub fn upstream_response_model(&self) -> Option<&str> {
self.upstream_response_model.as_deref()
}
#[must_use]
pub const fn with_timings(mut self, timings: ProviderResponseTimings) -> Self {
self.timings = timings;
+141 -6
View File
@@ -5,6 +5,11 @@ use std::str::FromStr;
use crate::validation::MeteringError;
mod pricing;
pub use pricing::{
ModelPriceOverride, PricingOverrides, TokenPrice, TokenPriceOverride, merge_pricing,
};
const DECIMAL_SCALE: u128 = 10_000_000_000;
const MAX_SCALED_DECIMAL: u128 = 99_999_999_999_999_999_999;
@@ -272,12 +277,13 @@ impl CostEstimateStatus {
}
}
/// 单次模型请求的总费用,不保存价格版本或 breakdown。
/// 单次模型请求的总费用及当次确定的本地计算明细。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CostEstimate {
status: CostEstimateStatus,
source: CostSource,
total: Option<Money>,
breakdown: Option<std::sync::Arc<CalculatedCostBreakdown>>,
}
/// Provider 在单次请求终态上报的实际已计费总额。
@@ -287,16 +293,20 @@ pub struct ProviderReportedCost {
}
/// Provider 域依据公开单价和实际用量算出的单次总额。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedCost {
total: Money,
breakdown: Option<std::sync::Arc<CalculatedCostBreakdown>>,
}
/// Provider 受控价格规则计算出的运行时费用明细。
///
/// 该值只用于生成事件和管理端展示,不是持久化模型;数据库仍只保存最终总额、货币和来源。
/// 随本地费用事件保存,以免后续改价改变历史明细。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedCostBreakdown {
// Provider 选中的价格区间事实,与服务档位和自定义倍率独立。
long_context_billing_applied: bool,
image: Option<ImageCostBreakdown>,
input_amount: Money,
output_amount: Money,
cache_read_amount: Money,
@@ -309,6 +319,18 @@ pub struct CalculatedCostBreakdown {
cache_write_price_per_million: Money,
service_tier: Option<String>,
multiplier_percent: u32,
custom_multiplier_bps: u32,
}
/// 图像输入是总输入的子集,单独保留其价格,不能伪装成文本平均单价。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageCostBreakdown {
pub input_tokens: u64,
pub cached_tokens: u64,
pub input_amount: Money,
pub cache_read_amount: Money,
pub input_price_per_million: Money,
pub cache_read_price_per_million: Money,
}
/// 一次请求的费用组成,全部使用同一币种。
@@ -373,7 +395,9 @@ impl CalculatedCostBreakdown {
multiplier_percent: u32,
) -> Self {
Self {
long_context_billing_applied: false,
input_amount: amounts.input,
image: None,
output_amount: amounts.output,
cache_read_amount: amounts.cache_read,
cache_write_amount: amounts.cache_write,
@@ -385,9 +409,32 @@ impl CalculatedCostBreakdown {
cache_write_price_per_million: rates.cache_write,
service_tier,
multiplier_percent,
custom_multiplier_bps: 10_000,
}
}
#[must_use]
pub const fn with_long_context_billing(mut self, applied: bool) -> Self {
self.long_context_billing_applied = applied;
self
}
#[must_use]
pub const fn long_context_billing_applied(&self) -> bool {
self.long_context_billing_applied
}
#[must_use]
pub fn with_image(mut self, image: ImageCostBreakdown) -> Self {
self.image = Some(image);
self
}
#[must_use]
pub const fn image(&self) -> Option<&ImageCostBreakdown> {
self.image.as_ref()
}
#[must_use]
pub const fn input_amount(&self) -> Money {
self.input_amount
@@ -449,11 +496,90 @@ impl CalculatedCostBreakdown {
}
#[must_use]
pub const fn calculated_cost(&self) -> CalculatedCost {
pub fn calculated_cost(&self) -> CalculatedCost {
CalculatedCost {
total: self.total_amount,
breakdown: Some(std::sync::Arc::new(self.clone())),
}
}
#[must_use]
pub const fn custom_multiplier_bps(&self) -> u32 {
self.custom_multiplier_bps
}
/// 统一调整金额和有效单价,保留服务档位倍率的独立含义。
#[must_use]
pub fn with_custom_multiplier(mut self, bps: u32) -> Option<Self> {
if bps > 1_000_000 {
return None;
}
let scale = |money: Money| {
let ticks = money
.amount()
.scaled()
.checked_mul(u128::from(bps))?
.checked_add(5_000)?
.checked_div(10_000)?;
Some(Money::new(
Decimal::from_scaled(ticks).ok()?,
money.currency(),
))
};
let previous_total = self.total_amount;
let same_standard = self.standard_amount == previous_total;
let mut token_total = self
.input_amount
.amount()
.scaled()
.checked_add(self.output_amount.amount().scaled())?
.checked_add(self.cache_read_amount.amount().scaled())?
.checked_add(self.cache_write_amount.amount().scaled())?;
if let Some(image) = &mut self.image {
token_total = token_total
.checked_add(image.input_amount.amount().scaled())?
.checked_add(image.cache_read_amount.amount().scaled())?;
image.input_amount = scale(image.input_amount)?;
image.cache_read_amount = scale(image.cache_read_amount)?;
image.input_price_per_million = scale(image.input_price_per_million)?;
image.cache_read_price_per_million = scale(image.cache_read_price_per_million)?;
}
let other = Money::new(
Decimal::from_scaled(previous_total.amount().scaled().checked_sub(token_total)?)
.ok()?,
previous_total.currency(),
);
self.input_amount = scale(self.input_amount)?;
self.output_amount = scale(self.output_amount)?;
self.cache_read_amount = scale(self.cache_read_amount)?;
self.cache_write_amount = scale(self.cache_write_amount)?;
self.standard_amount = scale(self.standard_amount)?;
// 各费用项独立四舍五入后求和,防止明细合计与账本金额相差一个 tick。
let mut total = self
.input_amount
.amount()
.scaled()
.checked_add(self.output_amount.amount().scaled())?
.checked_add(self.cache_read_amount.amount().scaled())?
.checked_add(self.cache_write_amount.amount().scaled())?
.checked_add(scale(other)?.amount().scaled())?;
if let Some(image) = &self.image {
total = total
.checked_add(image.input_amount.amount().scaled())?
.checked_add(image.cache_read_amount.amount().scaled())?;
}
self.total_amount =
Money::new(Decimal::from_scaled(total).ok()?, previous_total.currency());
if same_standard {
self.standard_amount = self.total_amount;
}
self.input_price_per_million = scale(self.input_price_per_million)?;
self.output_price_per_million = scale(self.output_price_per_million)?;
self.cache_read_price_per_million = scale(self.cache_read_price_per_million)?;
self.cache_write_price_per_million = scale(self.cache_write_price_per_million)?;
self.custom_multiplier_bps = bps;
Some(self)
}
}
impl ProviderReportedCost {
@@ -479,6 +605,7 @@ impl ProviderReportedCost {
status: CostEstimateStatus::Known,
source: CostSource::ProviderReported,
total: Some(self.total),
breakdown: None,
}
}
}
@@ -492,31 +619,39 @@ impl CalculatedCost {
pub fn from_usd_ticks(ticks: u128) -> Result<Self, MeteringError> {
Ok(Self {
total: Money::new(Decimal::from_scaled(ticks)?, CurrencyCode(*b"USD")),
breakdown: None,
})
}
#[must_use]
pub const fn total(self) -> Money {
pub const fn total(&self) -> Money {
self.total
}
#[must_use]
pub const fn into_estimate(self) -> CostEstimate {
pub fn into_estimate(self) -> CostEstimate {
CostEstimate {
status: CostEstimateStatus::Known,
source: CostSource::Calculated,
total: Some(self.total),
breakdown: self.breakdown,
}
}
}
impl CostEstimate {
#[must_use]
pub fn breakdown(&self) -> Option<&CalculatedCostBreakdown> {
self.breakdown.as_deref()
}
#[must_use]
pub const fn unavailable() -> Self {
Self {
status: CostEstimateStatus::Unknown,
source: CostSource::Unavailable,
total: None,
breakdown: None,
}
}
@@ -0,0 +1,104 @@
//! 请求冻结的本地价格覆盖;默认价目及模型别名继续由 Provider 拥有。
use std::collections::BTreeMap;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use super::Decimal;
/// 每百万 Token 的 USD 价格,保留四位小数以对齐单 Token 的金额精度。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct TokenPrice(Decimal);
impl TokenPrice {
#[must_use]
pub const fn ticks_per_token(self) -> u128 {
self.0.scaled() / 1_000_000
}
}
impl TryFrom<String> for TokenPrice {
type Error = &'static str;
fn try_from(value: String) -> Result<Self, Self::Error> {
let amount = Decimal::from_str(&value).map_err(|_| "单价必须为非负十进制字符串")?;
if amount.scaled() % 1_000_000 != 0 || amount.scaled() > 1_000_000 * 10_000_000_000 {
return Err("单价最多四位小数且不超过 1000000 USD / 百万 Token");
}
Ok(Self(amount))
}
}
impl From<TokenPrice> for String {
fn from(value: TokenPrice) -> Self {
value.0.canonical()
}
}
/// 一档完整 Token 单价;零价格与未覆盖的档位有不同含义。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TokenPriceOverride {
pub input: TokenPrice,
pub output: TokenPrice,
pub cache_read: TokenPrice,
pub cache_write: TokenPrice,
}
/// 一个精确模型的覆盖项;倍率使用基点,10000 表示 1 倍。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModelPriceOverride {
pub multiplier_bps: u32,
pub bands: BTreeMap<String, TokenPriceOverride>,
}
impl ModelPriceOverride {
/// 校验存储与管理入口共用的价格约束。
///
/// # Errors
/// 倍率超过 100 倍或档位不受支持时返回错误。
pub fn validate(&self) -> Result<(), &'static str> {
if self.multiplier_bps > 1_000_000 {
return Err("倍率必须在 0 至 100 倍之间");
}
if self.bands.keys().any(|band| {
!matches!(
band.as_str(),
"standard"
| "fast"
| "flex"
| "long_standard"
| "long_fast"
| "long_flex"
| "image"
)
}) {
return Err("价格档位不受支持");
}
Ok(())
}
}
/// 按 Provider、上游模型索引的不可变覆盖事实。
pub type PricingOverrides = BTreeMap<String, BTreeMap<String, ModelPriceOverride>>;
/// 按档位叠加配置;倍率仅由人工覆盖控制,同步价目不携带业务倍率。
pub fn merge_pricing(mut base: PricingOverrides, overrides: &PricingOverrides) -> PricingOverrides {
for (provider, models) in overrides {
let target = base.entry(provider.clone()).or_default();
for (model, pricing) in models {
let entry = target
.entry(model.clone())
.or_insert_with(|| ModelPriceOverride {
multiplier_bps: 10_000,
bands: BTreeMap::new(),
});
entry.multiplier_bps = pricing.multiplier_bps;
entry.bands.extend(pricing.bands.clone());
}
}
base
}
@@ -447,10 +447,11 @@ pub trait ProviderCatalogCachePort: Send + Sync {
/// Provider 从官方制品核验出的可重建请求画像。
///
/// Core 只用单调制品序号约束覆盖顺序;具体版本字段由对应 Provider 放在
/// `profile` 中解释。每个 Provider 在 Store 中只保留一份最新画像。
/// `profile` 中解释。每个 Provider 的各制品分别保留一份最新画像。
#[derive(Clone, PartialEq)]
pub struct ProviderArtifactProfile {
provider_kind: ProviderKind,
artifact_key: String,
artifact_sequence: u64,
verified_at: SystemTime,
profile: OpaqueProviderData,
@@ -460,12 +461,14 @@ impl ProviderArtifactProfile {
#[must_use]
pub const fn new(
provider_kind: ProviderKind,
artifact_key: String,
artifact_sequence: u64,
verified_at: SystemTime,
profile: OpaqueProviderData,
) -> Self {
Self {
provider_kind,
artifact_key,
artifact_sequence,
verified_at,
profile,
@@ -477,6 +480,11 @@ impl ProviderArtifactProfile {
&self.provider_kind
}
#[must_use]
pub fn artifact_key(&self) -> &str {
&self.artifact_key
}
#[must_use]
pub const fn artifact_sequence(&self) -> u64 {
self.artifact_sequence
@@ -506,7 +514,7 @@ impl fmt::Debug for ProviderArtifactProfile {
}
pub trait ProviderArtifactProfileCachePort: Send + Sync {
/// 覆盖同一 Provider 的固定 cache key。
/// 覆盖同一 Provider、同一制品的固定 cache key。
///
/// 返回 `false` 表示 Store 已持有更高的制品序号;相同序号但内容不同必须返回
/// [`ProviderStoreErrorKind::Conflict`],不能静默改写已核验画像。
@@ -519,6 +527,7 @@ pub trait ProviderArtifactProfileCachePort: Send + Sync {
fn read<'a>(
&'a self,
provider_kind: &'a ProviderKind,
artifact_key: &'a str,
) -> BoxFuture<'a, Result<Option<ProviderArtifactProfile>, ProviderStoreError>>;
}
@@ -927,6 +936,15 @@ fn invalid_refresh_policy(operation: &'static str) -> ProviderStoreError {
}
pub trait ProviderRuntimePolicyPort: Send + Sync {
/// 仅首次启动写入该 Provider 的默认选择;已保存的管理配置始终优先。
fn initialize_request_profile<'a>(
&'a self,
_provider: &'a ProviderKind,
initial: OpaqueProviderData,
) -> BoxFuture<'a, Result<OpaqueProviderData, ProviderStoreError>> {
Box::pin(async move { Ok(initial) })
}
fn load_refresh_policy(
&self,
) -> BoxFuture<'_, Result<ProviderRefreshPolicy, ProviderStoreError>>;
@@ -113,12 +113,17 @@ pub struct ProviderCatalogUnavailable;
/// 快照编译与对账使用的对象安全目录端口。
pub trait ProviderCatalogPort: Send + Sync {
/// 目录是否完整到足以根据缺项拒绝请求;发现型目录交由上游验证模型名。
fn model_catalog_is_exhaustive(&self, _provider: &ProviderKind) -> bool {
true
}
/// 返回全部已注册 Provider 的目录代次;注册集合在初始化后保持不变。
///
/// 即使某个目录暂时不可读,也必须保留它的 Provider 与最近成功发布的代次。
fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration>;
/// 查询指定 Provider 的模型事实;成功的空列表表示已知无模型,失败表示未知。
/// 查询模型发现事实;只有完整目录的空列表能证明无模型,失败表示未知。
fn query_model_capabilities(
&self,
provider: &ProviderKind,
@@ -536,6 +536,7 @@ impl ProviderCandidate {
/// 一次请求冻结的 Provider 尝试顺序。
#[derive(Debug, Clone)]
pub struct RoutingPlan {
pricing: Arc<crate::metering::PricingOverrides>,
request_location: Option<crate::account::RequestLocation>,
config_revision: ConfigRevision,
account_selection_policy: AccountSelectionPolicy,
@@ -546,6 +547,16 @@ pub struct RoutingPlan {
}
impl RoutingPlan {
#[must_use]
pub fn pricing(&self) -> Arc<crate::metering::PricingOverrides> {
Arc::clone(&self.pricing)
}
#[must_use]
pub fn disable_fast(&self) -> bool {
self.account_scope.disable_fast()
}
/// 本次请求冻结的全局位置,重试时沿用同一份配置。
#[must_use]
pub const fn request_location(&self) -> Option<&crate::account::RequestLocation> {
@@ -28,12 +28,15 @@ const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4;
/// Store 在一个一致性读取中提供的调度设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotSettingsFacts {
pricing: Arc<crate::metering::PricingOverrides>,
request_profiles: BTreeMap<ProviderKind, crate::account::OpaqueProviderData>,
request_location_enabled: bool,
request_location: crate::account::RequestLocation,
max_concurrent_per_account: u32,
max_waiting_per_key: u32,
max_waiting_per_account: u32,
concurrency_wait_timeout_seconds: u32,
responses_max_decompressed_body_bytes: u64,
request_interval_ms: u64,
rotation_strategy: String,
model_mappings: BTreeMap<String, String>,
@@ -42,6 +45,27 @@ pub struct SnapshotSettingsFacts {
}
impl SnapshotSettingsFacts {
#[must_use]
pub fn with_pricing(mut self, pricing: crate::metering::PricingOverrides) -> Self {
self.pricing = Arc::new(pricing);
self
}
#[must_use]
pub fn with_request_profiles(
mut self,
profiles: BTreeMap<ProviderKind, crate::account::OpaqueProviderData>,
) -> Self {
self.request_profiles = profiles;
self
}
#[must_use]
pub const fn with_responses_max_decompressed_body_bytes(mut self, bytes: u64) -> Self {
self.responses_max_decompressed_body_bytes = bytes;
self
}
#[must_use]
pub fn with_request_location(
mut self,
@@ -76,12 +100,15 @@ impl SnapshotSettingsFacts {
min_codex_cli_version: Option<String>,
) -> Self {
Self {
request_profiles: BTreeMap::new(),
pricing: Arc::default(),
request_location_enabled: false,
request_location: crate::account::RequestLocation::default(),
max_concurrent_per_account,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
request_interval_ms,
rotation_strategy: rotation_strategy.into(),
model_mappings,
@@ -94,6 +121,7 @@ impl SnapshotSettingsFacts {
/// Store 读取到的一个启用 Client API Key 策略事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotClientPolicyFacts {
request_profiles: BTreeMap<ProviderKind, crate::account::OpaqueProviderData>,
key_id: ClientApiKeyId,
plaintext_key: PlaintextClientApiKey,
group_ids: Vec<AccountGroupId>,
@@ -101,6 +129,15 @@ pub struct SnapshotClientPolicyFacts {
}
impl SnapshotClientPolicyFacts {
#[must_use]
pub fn with_request_profiles(
mut self,
profiles: BTreeMap<ProviderKind, crate::account::OpaqueProviderData>,
) -> Self {
self.request_profiles = profiles;
self
}
#[must_use]
pub fn new(
key_id: ClientApiKeyId,
@@ -110,6 +147,7 @@ impl SnapshotClientPolicyFacts {
) -> Self {
Self {
key_id,
request_profiles: BTreeMap::new(),
plaintext_key,
group_ids,
limits,
@@ -120,15 +158,27 @@ impl SnapshotClientPolicyFacts {
/// Store 读取到的账号分组事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotAccountGroupFacts {
disable_fast: bool,
id: AccountGroupId,
name: String,
enabled: bool,
}
impl SnapshotAccountGroupFacts {
#[must_use]
pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self {
self.disable_fast = disable_fast;
self
}
#[must_use]
pub fn new(id: AccountGroupId, name: String, enabled: bool) -> Self {
Self { id, name, enabled }
Self {
id,
name,
enabled,
disable_fast: false,
}
}
}
@@ -310,14 +360,16 @@ async fn compile_runtime_snapshot(
) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
let registered_providers = provider_kinds.iter().cloned().collect::<BTreeSet<_>>();
// 目录查询失败表示未知;查询成功后,即使为空,也必须与“已知缺少模型”区分。
// 只有成功取得的完整目录能证明模型缺项;发现型目录和查询失败均交由上游验证。
let mut provider_models = Vec::new();
let mut known_provider_catalogs = BTreeSet::new();
let mut exhaustive_provider_catalogs = BTreeSet::new();
for provider in &provider_kinds {
let Ok(models) = catalogs.query_model_capabilities(provider).await else {
continue;
};
known_provider_catalogs.insert(provider.clone());
if catalogs.model_catalog_is_exhaustive(provider) {
exhaustive_provider_catalogs.insert(provider.clone());
}
provider_models.extend(models.into_iter().map(|model| {
let compiled = ProviderModel::new(
provider.clone(),
@@ -389,6 +441,12 @@ async fn compile_runtime_snapshot(
{
return Err(RuntimeSnapshotCompileError::InvalidData);
}
let decompressed_body_limit =
isize::try_from(facts.settings.responses_max_decompressed_body_bytes)
.ok()
.and_then(|bytes| usize::try_from(bytes).ok())
.and_then(std::num::NonZeroUsize::new)
.ok_or(RuntimeSnapshotCompileError::InvalidData)?;
let queue_timeout =
Duration::from_secs(u64::from(facts.settings.concurrency_wait_timeout_seconds));
let client_queue_policy = ConcurrencyQueuePolicy {
@@ -426,6 +484,7 @@ async fn compile_runtime_snapshot(
});
let mut client_policies = Vec::with_capacity(facts.client_policies.len());
for policy in facts.client_policies {
let mut disable_fast = false;
let account_scope = if policy.group_ids.is_empty() {
FrozenAccountScope::new(
Arc::clone(&account_directory),
@@ -442,6 +501,8 @@ async fn compile_runtime_snapshot(
let group = groups
.get(&group_id)
.ok_or(RuntimeSnapshotCompileError::InvalidData)?;
// 禁用分组仅影响选号;Key 仍绑定其 Fast 限制。
disable_fast |= group.disable_fast;
bound_groups.push(RoutingGroupSnapshot::new(
group.id.clone(),
group.name.clone(),
@@ -458,10 +519,16 @@ async fn compile_runtime_snapshot(
.map_err(|_| RuntimeSnapshotCompileError::InvalidData)?,
)
};
let mut request_profiles = facts.settings.request_profiles.clone();
request_profiles.extend(policy.request_profiles);
client_policies.push(ClientPolicy::new(
policy.key_id,
policy.plaintext_key,
Arc::new(account_scope),
Arc::new(
account_scope
.with_disable_fast(disable_fast)
.with_request_profiles(request_profiles),
),
true,
policy.limits,
));
@@ -489,11 +556,13 @@ async fn compile_runtime_snapshot(
.map_err(|_| RuntimeSnapshotCompileError::InvalidData)
.map(|snapshot| {
snapshot
.with_pricing(facts.settings.pricing)
.with_request_location(request_location)
.with_responses_max_decompressed_body_bytes(decompressed_body_limit)
.with_client_queue_policy(client_queue_policy)
.with_model_mappings(model_mappings)
.with_account_directory(account_directory)
.with_known_provider_catalogs(known_provider_catalogs)
.with_exhaustive_provider_catalogs(exhaustive_provider_catalogs)
.with_min_codex_client_versions(min_client_versions)
})
}
@@ -501,6 +570,8 @@ async fn compile_runtime_snapshot(
/// 数据面使用的不可变配置快照。
#[derive(Debug, Clone)]
pub struct RuntimeSnapshot {
pricing: Arc<crate::metering::PricingOverrides>,
responses_max_decompressed_body_bytes: std::num::NonZeroUsize,
request_location: Option<crate::account::RequestLocation>,
revision: ConfigRevision,
client_queue_policy: ConcurrencyQueuePolicy,
@@ -511,13 +582,33 @@ pub struct RuntimeSnapshot {
Arc<BTreeMap<ProviderKind, BTreeMap<UpstreamModelId, super::ModelPresentation>>>,
model_mappings: Arc<BTreeMap<String, String>>,
provider_catalog_generations: Arc<BTreeMap<ProviderKind, ProviderCatalogGeneration>>,
known_provider_catalogs: Arc<BTreeSet<ProviderKind>>,
exhaustive_provider_catalogs: Arc<BTreeSet<ProviderKind>>,
account_directory: Arc<RuntimeAccountDirectory>,
client_policies: Arc<BTreeMap<ClientApiKeyId, ClientPolicy>>,
min_codex_client_versions: CodexClientMinVersions,
}
impl RuntimeSnapshot {
#[must_use]
pub fn with_pricing(mut self, pricing: Arc<crate::metering::PricingOverrides>) -> Self {
self.pricing = pricing;
self
}
#[must_use]
pub const fn responses_max_decompressed_body_bytes(&self) -> usize {
self.responses_max_decompressed_body_bytes.get()
}
#[must_use]
pub const fn with_responses_max_decompressed_body_bytes(
mut self,
bytes: std::num::NonZeroUsize,
) -> Self {
self.responses_max_decompressed_body_bytes = bytes;
self
}
#[must_use]
pub fn with_request_location(
mut self,
@@ -556,7 +647,7 @@ impl RuntimeSnapshot {
}
}
let mut known_provider_catalogs = BTreeSet::new();
let mut exhaustive_provider_catalogs = BTreeSet::new();
let mut model_map =
BTreeMap::<ProviderKind, BTreeMap<UpstreamModelId, ModelCapabilities>>::new();
let mut presentation_map =
@@ -568,7 +659,7 @@ impl RuntimeSnapshot {
capabilities,
presentation,
} = model;
known_provider_catalogs.insert(provider.clone());
exhaustive_provider_catalogs.insert(provider.clone());
if !provider_set.contains(&provider) {
return Err(RoutingError::NotFound {
entity: "provider",
@@ -606,6 +697,9 @@ impl RuntimeSnapshot {
client_policy_map.retain(|_, policy| policy.enabled());
Ok(Self {
responses_max_decompressed_body_bytes: std::num::NonZeroUsize::new(64 * 1024 * 1024)
.expect("positive default limit"),
pricing: Arc::default(),
request_location: None,
revision,
account_selection_policy,
@@ -615,7 +709,7 @@ impl RuntimeSnapshot {
provider_model_presentations: Arc::new(presentation_map),
model_mappings: Arc::new(BTreeMap::new()),
provider_catalog_generations: Arc::new(BTreeMap::new()),
known_provider_catalogs: Arc::new(known_provider_catalogs),
exhaustive_provider_catalogs: Arc::new(exhaustive_provider_catalogs),
account_directory: Arc::new(RuntimeAccountDirectory::default()),
client_policies: Arc::new(client_policy_map),
min_codex_client_versions: CodexClientMinVersions::default(),
@@ -641,8 +735,8 @@ impl RuntimeSnapshot {
}
#[must_use]
fn with_known_provider_catalogs(mut self, providers: BTreeSet<ProviderKind>) -> Self {
self.known_provider_catalogs = Arc::new(providers);
fn with_exhaustive_provider_catalogs(mut self, providers: BTreeSet<ProviderKind>) -> Self {
self.exhaustive_provider_catalogs = Arc::new(providers);
self
}
@@ -779,7 +873,7 @@ impl RuntimeSnapshot {
.collect()
}
/// 已取得目录时以映射后的上游模型为准;目录不可用时由 Provider 在发送前判定。
/// 完整目录按映射后的模型判定;发现型或不可用目录不作为能力白名单。
#[must_use]
pub fn contains_public_model_for_provider(
&self,
@@ -789,11 +883,13 @@ impl RuntimeSnapshot {
if !self.providers.contains(provider) {
return false;
}
let upstream_model = self.mapped_model(public_model.as_str());
match self.provider_models.get(provider) {
Some(models) => models.keys().any(|model| model.as_str() == upstream_model),
None => !self.known_provider_catalogs.contains(provider),
if !self.exhaustive_provider_catalogs.contains(provider) {
return true;
}
let upstream_model = self.mapped_model(public_model.as_str());
self.provider_models
.get(provider)
.is_some_and(|models| models.keys().any(|model| model.as_str() == upstream_model))
}
#[must_use]
@@ -883,7 +979,7 @@ impl RuntimeSnapshot {
};
emulated
}
None if self.known_provider_catalogs.contains(provider) => continue,
None if self.exhaustive_provider_catalogs.contains(provider) => continue,
None => BTreeSet::new(),
};
candidates.push(ProviderCandidate {
@@ -899,7 +995,7 @@ impl RuntimeSnapshot {
let mut scoped_providers = providers.intersection(&self.providers).peekable();
if scoped_providers.peek().is_some()
&& scoped_providers.all(|provider| {
self.known_provider_catalogs.contains(provider)
self.exhaustive_provider_catalogs.contains(provider)
&& !self.contains_public_model_for_provider(public_model, provider)
})
{
@@ -915,6 +1011,7 @@ impl RuntimeSnapshot {
Ok(RoutingPlan {
config_revision: self.revision,
pricing: Arc::clone(&self.pricing),
request_location: self.request_location.clone(),
account_selection_policy: self.account_selection_policy,
operation: operation.kind(),
@@ -957,6 +1054,7 @@ impl RuntimeSnapshot {
};
Ok(RoutingPlan {
config_revision: self.revision,
pricing: Arc::clone(&self.pricing),
request_location: self.request_location.clone(),
account_selection_policy: self.account_selection_policy,
operation: operation.kind(),
@@ -34,6 +34,58 @@ fn report_failure(
);
}
#[test]
fn capacity_rejections_should_raise_failure_rate_faster_than_regular_failures() {
let (feedback, provider, account) = feedback_subject();
let observed_at = Instant::now();
let mut failure_rates = Vec::new();
for _ in 0..2 {
feedback.report_at(
&provider,
&account,
AccountAttemptFeedback::CapacityRejected {
first_output_ms: None,
},
observed_at,
);
failure_rates.push(
feedback
.scheduling_signals_at(&provider, &account, observed_at)
.0,
);
}
assert_eq!(failure_rates, [Some(4_000), Some(6_400)]);
}
#[test]
fn capacity_failure_rate_should_keep_time_decay_and_success_recovery() {
let (feedback, provider, account) = feedback_subject();
let observed_at = Instant::now();
feedback.report_at(
&provider,
&account,
AccountAttemptFeedback::CapacityRejected {
first_output_ms: Some(100),
},
observed_at,
);
let recovered_at = observed_at + FAILURE_RATE_HALF_LIFE;
feedback.report_at(
&provider,
&account,
AccountAttemptFeedback::Succeeded {
first_output_ms: Some(200),
},
recovered_at,
);
assert_eq!(
feedback.scheduling_signals_at(&provider, &account, recovered_at),
(Some(1_600), Some(120)),
);
}
#[test]
fn account_failure_rate_should_halve_after_one_half_life() {
let (feedback, provider, account) = feedback_subject();
@@ -1,6 +1,6 @@
//! 单行 `model_requests`、账号重试与下游提交屏障测试。
use std::collections::{BTreeSet, VecDeque};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::num::NonZeroU32;
use std::sync::{
Arc, Mutex,
@@ -73,6 +73,7 @@ struct FinalState {
http_version: Option<String>,
websocket_pool: Option<String>,
service_tier: Option<String>,
upstream_response_model: Option<String>,
upstream_request_id: Option<String>,
upstream_status_code: Option<u16>,
transport_decision_wait_ms: Option<u64>,
@@ -239,6 +240,7 @@ impl ExecutionStore for FakeStore {
http_version: finalization.http_version,
websocket_pool: finalization.websocket_pool,
service_tier: finalization.service_tier,
upstream_response_model: finalization.upstream_response_model,
upstream_request_id: finalization.upstream_request_id,
upstream_status_code: finalization.upstream_status_code,
transport_decision_wait_ms: finalization.timings.transport_decision_wait_ms,
@@ -291,6 +293,7 @@ enum Script {
}
struct ScriptedProvider {
profile_generation: AtomicUsize,
scripts: Mutex<VecDeque<Script>>,
contexts: Mutex<Vec<AttemptContext>>,
operations: Mutex<Vec<Operation>>,
@@ -308,6 +311,7 @@ impl Drop for TrackedLease {
impl ScriptedProvider {
fn new(scripts: Vec<Script>) -> Self {
Self {
profile_generation: AtomicUsize::new(1),
scripts: Mutex::new(scripts.into()),
contexts: Mutex::new(Vec::new()),
operations: Mutex::new(Vec::new()),
@@ -318,6 +322,18 @@ impl ScriptedProvider {
#[async_trait]
impl Provider for ScriptedProvider {
fn resolve_request_profile(
&self,
configuration: &gateway_core::account::OpaqueProviderData,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderError> {
let mut fields = configuration.clone().into_inner();
fields.insert(
"generation".to_owned(),
json!(self.profile_generation.load(Ordering::SeqCst)),
);
Ok(gateway_core::account::OpaqueProviderData::new(fields))
}
fn name(&self) -> &'static str {
"openai"
}
@@ -342,6 +358,8 @@ impl Provider for ScriptedProvider {
.expect("operations lock")
.push(request.operation().clone());
self.contexts.lock().expect("contexts lock").push(context);
// 模拟官方发布在每次上游尝试开始后推进,重试应继续使用首次解析版本。
self.profile_generation.fetch_add(1, Ordering::SeqCst);
let script = self
.scripts
.lock()
@@ -592,6 +610,31 @@ fn plan_with_location(
operation: &Operation,
account_selection_policy: AccountSelectionPolicy,
request_location: gateway_core::account::RequestLocation,
) -> RoutingPlan {
plan_with_location_and_fast_policy(operation, account_selection_policy, request_location, false)
}
fn plan_with_location_and_fast_policy(
operation: &Operation,
account_selection_policy: AccountSelectionPolicy,
request_location: gateway_core::account::RequestLocation,
disable_fast: bool,
) -> RoutingPlan {
plan_with_profiles(
operation,
account_selection_policy,
request_location,
disable_fast,
Default::default(),
)
}
fn plan_with_profiles(
operation: &Operation,
account_selection_policy: AccountSelectionPolicy,
request_location: gateway_core::account::RequestLocation,
disable_fast: bool,
profiles: BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>,
) -> RoutingPlan {
let provider = ProviderKind::new("openai").expect("provider");
let public_model = PublicModelId::new("gpt-5").expect("public model");
@@ -640,10 +683,11 @@ fn plan_with_location(
.plan(
&public_model,
operation,
Arc::new(FrozenAccountScope::new(
directory,
ClientRoutingScope::all_accounts(),
)),
Arc::new(
FrozenAccountScope::new(directory, ClientRoutingScope::all_accounts())
.with_disable_fast(disable_fast)
.with_request_profiles(profiles),
),
&RoutingContext {
required_provider: Some(provider),
..RoutingContext::default()
@@ -1093,6 +1137,7 @@ fn response_observation_is_persisted_but_never_delivered() {
.with_websocket_pool(WebSocketPoolKind::New)
.with_status_code(200)
.with_request_id(OpaqueUpstreamValue::new("upstream-observed"))
.with_upstream_response_model_if_valid("gpt-returned")
.try_with_service_tier("priority")
.expect("service tier")
.with_timings(ProviderResponseTimings {
@@ -1132,6 +1177,10 @@ fn response_observation_is_persisted_but_never_delivered() {
assert_eq!(finalization.http_version.as_deref(), Some("HTTP/2"));
assert_eq!(finalization.websocket_pool.as_deref(), Some("new"));
assert_eq!(finalization.service_tier.as_deref(), Some("priority"));
assert_eq!(
finalization.upstream_response_model.as_deref(),
Some("gpt-returned")
);
assert_eq!(
finalization.upstream_request_id.as_deref(),
Some("upstream-observed")
@@ -1350,6 +1399,7 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
.with_http_version(UpstreamHttpVersion::Http11)
.with_status_code(503)
.with_request_id(OpaqueUpstreamValue::new("discarded-request"))
.with_upstream_response_model_if_valid("discarded-model")
.with_timings(ProviderResponseTimings {
transport_decision_wait_ms: Some(7),
connect_ms: Some(11),
@@ -1409,6 +1459,7 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
let state = store.state.lock().expect("store lock");
let finalization = &state.finalizations[0];
assert_eq!(finalization.upstream_response_model, None);
let trace: Value =
serde_json::from_str(finalization.diagnostic_trace_json.as_deref().unwrap()).unwrap();
let events = trace["events"].as_array().unwrap();
@@ -1710,61 +1761,72 @@ fn native_continuation_uses_provider_defined_owner_then_cross_account_recovery()
#[test]
fn native_continuation_client_replay_required_is_terminal() {
let Operation::Generate(generate) = generate_operation() else {
panic!("generate operation");
};
let operation = Operation::Generate(generate.with_provider_session_state(
ProviderSessionState::new("openai", Map::new()).expect("provider session state"),
));
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_one",
items: vec![Err(ProviderError::new(
ProviderErrorKind::ContinuationRecoveryRequired,
UpstreamSendState::NotSent,
)
.with_continuation_failure(ContinuationFailure::HistoryUnavailable)
.with_continuation_recovery_disposition(
ContinuationRecoveryDisposition::ClientReplayRequired,
))],
},
Script::Stream {
account_id: "acct_one",
items: complete_stream(None),
},
]);
let continuation = NativeContinuationPin::new(
PreviousResponseId::new("previous-secret-id"),
PreviousResponseId::new("provider-native-id"),
ClientApiKeyId::new("key_client_1").expect("client key"),
ProviderKind::new("openai").expect("provider"),
ProviderAccountId::new("acct_one").expect("account"),
);
for (kind, send_state) in [
(
ProviderErrorKind::ContinuationRecoveryRequired,
UpstreamSendState::NotSent,
),
(
ProviderErrorKind::QuotaExhausted,
UpstreamSendState::NotSent,
),
(ProviderErrorKind::QuotaExhausted, UpstreamSendState::Sent),
] {
let Operation::Generate(generate) = generate_operation() else {
panic!("generate operation");
};
let operation = Operation::Generate(generate.with_provider_session_state(
ProviderSessionState::new("openai", Map::new()).expect("provider session state"),
));
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_one",
items: vec![Err(ProviderError::new(kind, send_state)
.with_replay_safe()
.with_continuation_failure(ContinuationFailure::HistoryUnavailable)
.with_continuation_recovery_disposition(
ContinuationRecoveryDisposition::ClientReplayRequired,
))],
},
Script::Stream {
account_id: "acct_one",
items: complete_stream(None),
},
]);
let continuation = NativeContinuationPin::new(
PreviousResponseId::new("previous-secret-id"),
PreviousResponseId::new("provider-native-id"),
ClientApiKeyId::new("key_client_1").expect("client key"),
ProviderKind::new("openai").expect("provider"),
ProviderAccountId::new("acct_one").expect("account"),
);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
Some(ContinuationBinding::Pinned(continuation)),
CancellationToken::new(),
))
.expect("start execution");
let error = block_on(session.collect_uncommitted())
.expect_err("client replay requirement must stop proxy recovery");
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
Some(ContinuationBinding::Pinned(continuation)),
CancellationToken::new(),
))
.expect("start execution");
let error = block_on(session.collect_uncommitted())
.expect_err("client replay requirement must stop proxy recovery");
assert!(matches!(error, EngineError::Provider(_)));
let contexts = provider.contexts.lock().expect("contexts lock");
assert_eq!(contexts.len(), 1);
assert_eq!(
contexts[0].continuation_attempt(),
ContinuationAttempt::Native
);
let state = store.state.lock().expect("store lock");
assert_eq!(state.intermediate_failures, 0);
assert_eq!(state.finalizations[0].attempt_count, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
assert!(matches!(error, EngineError::Provider(_)));
let contexts = provider.contexts.lock().expect("contexts lock");
assert_eq!(contexts.len(), 1);
assert_eq!(
contexts[0].continuation_attempt(),
ContinuationAttempt::Native
);
let state = store.state.lock().expect("store lock");
assert_eq!(state.intermediate_failures, 0);
assert_eq!(state.finalizations[0].attempt_count, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
assert_eq!(state.finalizations[0].send_state, send_state);
}
}
#[test]
@@ -1826,67 +1888,69 @@ fn native_continuation_exact_retry_keeps_scope_and_account() {
#[test]
fn unavailable_native_continuation_uses_provider_recovery_with_the_configured_account_policy() {
let Operation::Generate(generate) = generate_operation() else {
panic!("generate operation");
};
let operation = Operation::Generate(generate.with_provider_session_state(
ProviderSessionState::new("openai", Map::new()).expect("provider session state"),
));
let policy = AccountSelectionPolicy::new(
RotationStrategy::RoundRobin,
NonZeroU32::new(2).expect("account concurrency"),
Duration::from_millis(50),
);
let route_plan = plan_with_policy(&operation, policy);
let (coordinator, store, provider) = coordinator(vec![
Script::Error(ProviderError::new(
ProviderErrorKind::NoEligibleAccount,
UpstreamSendState::NotSent,
)),
Script::Stream {
account_id: "acct_two",
items: complete_stream(None),
},
]);
let original = ProviderAccountId::new("acct_one").expect("account");
let continuation = NativeContinuationPin::new(
PreviousResponseId::new("previous-response"),
PreviousResponseId::new("upstream-response"),
ClientApiKeyId::new("key_client_1").expect("client key"),
ProviderKind::new("openai").expect("provider"),
original.clone(),
);
for kind in [
ProviderErrorKind::NoEligibleAccount,
ProviderErrorKind::QuotaExhausted,
] {
let Operation::Generate(generate) = generate_operation() else {
panic!("generate operation");
};
let operation = Operation::Generate(generate.with_provider_session_state(
ProviderSessionState::new("openai", Map::new()).expect("provider session state"),
));
let policy = AccountSelectionPolicy::new(
RotationStrategy::RoundRobin,
NonZeroU32::new(2).expect("account concurrency"),
Duration::from_millis(50),
);
let route_plan = plan_with_policy(&operation, policy);
let (coordinator, store, provider) = coordinator(vec![
Script::Error(ProviderError::new(kind, UpstreamSendState::NotSent)),
Script::Stream {
account_id: "acct_two",
items: complete_stream(None),
},
]);
let original = ProviderAccountId::new("acct_one").expect("account");
let continuation = NativeContinuationPin::new(
PreviousResponseId::new("previous-response"),
PreviousResponseId::new("upstream-response"),
ClientApiKeyId::new("key_client_1").expect("client key"),
ProviderKind::new("openai").expect("provider"),
original.clone(),
);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
Some(ContinuationBinding::Pinned(continuation)),
CancellationToken::new(),
))
.expect("start execution");
block_on(session.collect_uncommitted()).expect("provider-defined recovery succeeds");
block_on(session.commit_downstream(Some(200))).expect("commit response");
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
Some(ContinuationBinding::Pinned(continuation)),
CancellationToken::new(),
))
.expect("start execution");
block_on(session.collect_uncommitted()).expect("provider-defined recovery succeeds");
block_on(session.commit_downstream(Some(200))).expect("commit response");
let contexts = provider.contexts.lock().expect("contexts lock");
assert_eq!(contexts.len(), 2);
assert_eq!(
contexts[0].continuation_attempt(),
ContinuationAttempt::Native
);
assert_eq!(
contexts[1].continuation_attempt(),
ContinuationAttempt::ReplayAny
);
assert_eq!(
contexts[1].account_selection_policy().strategy(),
RotationStrategy::RoundRobin
);
assert!(contexts[1].excluded_accounts().contains(&original));
let state = store.state.lock().expect("store lock");
assert_eq!(state.attempts.len(), 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
let contexts = provider.contexts.lock().expect("contexts lock");
assert_eq!(contexts.len(), 2);
assert_eq!(
contexts[0].continuation_attempt(),
ContinuationAttempt::Native
);
assert_eq!(
contexts[1].continuation_attempt(),
ContinuationAttempt::ReplayAny
);
assert_eq!(
contexts[1].account_selection_policy().strategy(),
RotationStrategy::RoundRobin
);
assert!(contexts[1].excluded_accounts().contains(&original));
let state = store.state.lock().expect("store lock");
assert_eq!(state.attempts.len(), 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
}
}
#[test]
@@ -3609,6 +3673,7 @@ fn transient_backoff_can_be_cancelled_before_the_next_attempt() {
fn final_capacity_exhaustion_returns_the_last_retryable_upstream_failure() {
for capacity_error in [
ProviderErrorKind::AccountCapacityUnavailable,
ProviderErrorKind::QuotaExhausted,
ProviderErrorKind::ConcurrencyQueueFull,
ProviderErrorKind::ConcurrencyQueueTimeout,
] {
@@ -3880,60 +3945,70 @@ fn cancellation_before_pending_delivery_commit_reaches_terminal_state() {
#[test]
fn no_eligible_account_before_stream_records_failure_without_fabricating_an_attempt() {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, store, _) = coordinator(vec![Script::Error(ProviderError::new(
ProviderErrorKind::NoEligibleAccount,
UpstreamSendState::NotSent,
))]);
for (kind, status, gateway_kind) in [
(
ProviderErrorKind::NoEligibleAccount,
503,
GatewayErrorKind::NoAvailableProvider,
),
(
ProviderErrorKind::QuotaExhausted,
429,
GatewayErrorKind::RateLimited,
),
] {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, store, _) = coordinator(vec![Script::Error(ProviderError::new(
kind,
UpstreamSendState::NotSent,
))]);
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");
let error = block_on(session.collect_uncommitted())
.expect_err("provider failed before returning metadata");
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");
let error = block_on(session.collect_uncommitted())
.expect_err("provider failed before returning metadata");
assert!(matches!(
error,
gateway_core::engine::EngineError::Provider(ref error)
if error.kind() == ProviderErrorKind::NoEligibleAccount
));
assert!(session.provider_attempt_outcomes().is_empty());
assert!(session.is_finalized());
block_on(session.record_client_status(503)).expect("HTTP error status");
block_on(session.cancel_and_finalize()).expect("repeated finalization");
let state = store.state.lock().expect("store lock");
assert_eq!(state.created, 1);
assert!(state.attempts.is_empty());
assert_eq!(state.finalizations.len(), 1);
assert_eq!(state.recorded_statuses, [503]);
let finalization = &state.finalizations[0];
assert_eq!(finalization.outcome, ExecutionOutcome::Failed);
assert_eq!(finalization.attempt_count, 0);
assert_eq!(finalization.send_state, UpstreamSendState::NotSent);
assert_eq!(
finalization.error_kind,
Some(GatewayErrorKind::NoAvailableProvider)
);
assert_eq!(finalization.upstream_transport, None);
assert_eq!(finalization.total_tokens, None);
assert_eq!(finalization.cost_ticks, None);
assert_eq!(session.budget_charge().amount_usd.scaled(), 0);
let trace: Value =
serde_json::from_str(finalization.diagnostic_trace_json.as_deref().unwrap()).unwrap();
let events = trace["events"].as_array().unwrap();
assert!(
events
.iter()
.any(|event| event["stage"] == "attempt.failed")
);
assert_eq!(events.last().unwrap()["stage"], "request.finished");
assert!(matches!(
error,
gateway_core::engine::EngineError::Provider(ref error)
if error.kind() == kind
));
assert!(session.provider_attempt_outcomes().is_empty());
assert!(session.is_finalized());
block_on(session.record_client_status(status)).expect("HTTP error status");
block_on(session.cancel_and_finalize()).expect("repeated finalization");
let state = store.state.lock().expect("store lock");
assert_eq!(state.created, 1);
assert!(state.attempts.is_empty());
assert_eq!(state.finalizations.len(), 1);
assert_eq!(state.recorded_statuses, [status]);
let finalization = &state.finalizations[0];
assert_eq!(finalization.outcome, ExecutionOutcome::Failed);
assert_eq!(finalization.attempt_count, 0);
assert_eq!(finalization.send_state, UpstreamSendState::NotSent);
assert_eq!(finalization.error_kind, Some(gateway_kind));
assert_eq!(finalization.upstream_transport, None);
assert_eq!(finalization.total_tokens, None);
assert_eq!(finalization.cost_ticks, None);
assert_eq!(session.budget_charge().amount_usd.scaled(), 0);
let trace: Value =
serde_json::from_str(finalization.diagnostic_trace_json.as_deref().unwrap()).unwrap();
let events = trace["events"].as_array().unwrap();
assert!(
events
.iter()
.any(|event| event["stage"] == "attempt.failed")
);
assert_eq!(events.last().unwrap()["stage"], "request.finished");
}
}
#[test]
@@ -4508,16 +4583,17 @@ fn deadline_before_first_event_records_no_provider_circuit_failure() {
}
#[test]
fn global_request_location_should_reach_every_account_retry() {
fn global_location_and_group_fast_policy_reach_every_account_retry() {
let operation = generate_operation();
let location = gateway_core::account::RequestLocation {
timezone: "Asia/Tokyo".parse().unwrap(),
..Default::default()
};
let route_plan = plan_with_location(
let route_plan = plan_with_location_and_fast_policy(
&operation,
plan(&operation).account_selection_policy(),
location.clone(),
true,
);
let (coordinator, _, provider) = coordinator(vec![
Script::Stream {
@@ -4550,6 +4626,57 @@ fn global_request_location_should_reach_every_account_retry() {
assert!(
contexts
.iter()
.all(|context| context.request_location() == Some(&location))
.all(|context| context.request_location() == Some(&location) && context.disable_fast())
);
}
#[test]
fn first_resolved_profile_is_frozen_across_account_retries() {
use gateway_core::account::OpaqueProviderData;
let operation = generate_operation();
let route_plan = plan_with_profiles(
&operation,
plan(&operation).account_selection_policy(),
Default::default(),
false,
BTreeMap::from([(
ProviderKind::new("openai").unwrap(),
OpaqueProviderData::new(
json!({"selection":"key-override"})
.as_object()
.unwrap()
.clone(),
),
)]),
);
let (coordinator, _, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
items: vec![Err(ProviderError::new(
ProviderErrorKind::RateLimited,
UpstreamSendState::Sent,
)
.with_status(429)
.with_replay_safe())],
},
Script::Stream {
account_id: "acct_second",
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(),
))
.unwrap();
block_on(session.collect_uncommitted()).unwrap();
let contexts = provider.contexts.lock().unwrap();
assert_eq!(contexts.len(), 2);
let first = contexts[0].request_profile().unwrap();
assert_eq!(first.expose_to_provider()["generation"], 1);
assert_eq!(contexts[1].request_profile(), Some(first));
}
@@ -178,7 +178,7 @@ fn reused_client_uses_updated_limits_for_each_execution() {
requests_per_minute: 1,
};
for (revision, limits) in [(2, limited), (3, RateLimits::unlimited())] {
snapshots.publish(start_snapshot_with_policy(revision, true, limits));
snapshots.publish(start_snapshot_with_policy(revision, true, limits, false));
let mut next = request(&service, ClientTransport::WebSocket);
next.client = client.clone();
let started = block_on(service.start(next)).expect("new execution");
@@ -212,6 +212,7 @@ fn reused_client_cannot_start_after_key_disable_or_snapshot_suspension() {
2,
false,
RateLimits::unlimited(),
false,
));
}
let result = block_on(service.start(next));
@@ -483,7 +484,9 @@ fn detached_early_failure_resumes_cancelled_store_write_and_settles_once_for_all
});
}
#[derive(Default)]
struct ChargedProvider {
policies: Mutex<Vec<bool>>,
fail: bool,
}
@@ -510,8 +513,9 @@ impl Provider for ChargedProvider {
async fn execute(
&self,
request: ProviderRequest,
_: AttemptContext,
context: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
self.policies.lock().unwrap().push(context.disable_fast());
let candidate = request.candidate();
let metadata = ProviderCallMetadata::new(
candidate.provider().clone(),
@@ -552,7 +556,11 @@ fn charged_service(
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(start_snapshot()),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::new([Arc::new(ChargedProvider { fail }) as Arc<dyn Provider>]).unwrap(),
ProviderRegistry::new([Arc::new(ChargedProvider {
fail,
..Default::default()
}) as Arc<dyn Provider>])
.unwrap(),
admissions,
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
@@ -1886,10 +1894,15 @@ fn client_snapshot() -> RuntimeSnapshot {
}
fn start_snapshot() -> RuntimeSnapshot {
start_snapshot_with_policy(1, true, RateLimits::unlimited())
start_snapshot_with_policy(1, true, RateLimits::unlimited(), false)
}
fn start_snapshot_with_policy(revision: u64, enabled: bool, limits: RateLimits) -> RuntimeSnapshot {
fn start_snapshot_with_policy(
revision: u64,
enabled: bool,
limits: RateLimits,
disable_fast: bool,
) -> RuntimeSnapshot {
let provider = ProviderKind::new("openai").expect("provider kind");
let capabilities =
ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000));
@@ -1909,7 +1922,12 @@ fn start_snapshot_with_policy(revision: u64, enabled: bool, limits: RateLimits)
vec![ClientPolicy::new(
ClientApiKeyId::new("key_start_test").expect("client API key ID"),
PlaintextClientApiKey::new("sk_start_test").expect("plaintext client API key"),
account_scope(&provider, "acct_start"),
Arc::new(
account_scope(&provider, "acct_start")
.as_ref()
.clone()
.with_disable_fast(disable_fast),
),
enabled,
limits,
)],
@@ -2006,6 +2024,7 @@ fn account_wait_inherits_the_budget_spent_during_client_admission() {
max_concurrency: 1,
requests_per_minute: 0,
},
false,
)
.with_client_queue_policy(gateway_core::concurrency::ConcurrencyQueuePolicy {
max_waiting: 1,
@@ -2116,6 +2135,7 @@ fn queue_service(
max_concurrency,
requests_per_minute: 0,
},
false,
)
.with_client_queue_policy(gateway_core::concurrency::ConcurrencyQueuePolicy {
max_waiting,
@@ -2240,3 +2260,34 @@ fn cancelling_pending_admission_cleans_up_a_slot_acquired_before_the_reply() {
drop(pending);
assert!(admissions.active.lock().unwrap().is_empty());
}
#[test]
fn reused_websocket_client_gets_group_fast_policy_from_each_new_request_snapshot() {
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
let admissions = Arc::new(Admissions::default());
let provider = Arc::new(ChargedProvider::default());
let service = DefaultExecutionService::new(
snapshots.clone(),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::new([provider.clone() as Arc<dyn Provider>]).unwrap(),
admissions,
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
);
let client = service.authenticate("sk_start_test").unwrap();
for (revision, disable_fast) in [(2, true), (3, false)] {
snapshots.publish(start_snapshot_with_policy(
revision,
true,
RateLimits::unlimited(),
disable_fast,
));
let mut next = request(&service, ClientTransport::WebSocket);
next.client = client.clone();
let mut started = block_on(service.start(next)).unwrap();
block_on(started.session.collect_uncommitted()).unwrap();
block_on(started.session.detach_finalize());
}
assert_eq!(*provider.policies.lock().unwrap(), vec![true, false]);
}
@@ -1,5 +1,7 @@
use std::str::FromStr;
mod pricing;
use gateway_core::metering::{
CalculatedCost, CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates,
CostEstimate, CostEstimateStatus, CostSource, CurrencyCode, Decimal, Money,

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