Compare commits

...
26 Commits
Author SHA1 Message Date
ZyyandCodex debba0e13c chore(release): v3.13.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-21 01:06:20 +08:00
ZyyandCodex a0836ec3f2 feat(key-usage): add API key budgets to CC Switch imports (#202)
Closes #198

Co-authored-by: Codex <noreply@openai.com>
2026-09-21 00:26:36 +08:00
ZyyandCodex dc0cdf2930 fix(openai): normalize rejected reasoning replay fields
Refs #194, #200.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 23:09:23 +08:00
ZyyandCodex d76bb57b8c fix(usage): show current account notes below email
Read account notes by internal account ID for usage lists and Dashboard recent
requests. Show nonempty notes as secondary text using the existing label style.

Cover same-email accounts, note changes and removal, deleted-account history,
and the admin API and client key visibility boundaries.

Refs #190

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 19:13:52 +08:00
ZyyandCodex 6434edd554 fix(accounts): preserve quota when recovering disabled accounts
Enable scheduling without clearing observed quota, credential errors, or cooldowns for disabled accounts. Keep explicit recovery for enabled accounts and document both paths.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 18:41:22 +08:00
ZyyandCodex 3a90d8d0b6 fix(accounts): forecast quota totals within the current cycle
Preserve recorded cycle usage and estimate only remaining capacity from
recent samples. Restart cycle totals and sampling after quota resets,
including resets following an exhausted observation.

Clarify cycle totals and duration conversions in the quota modal and API
documentation, and prevent narrow layouts from overflowing.

Validated with the full backend suite using isolated PostgreSQL and Redis,
strict Clippy, Rustfmt, frontend lint and build, and browser checks.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 18:25:17 +08:00
ZyyandCodex f3236d0410 feat(key-usage): add about dialog with build information
Reuse the shared About dialog from a toolbar icon and expose version and commit identifiers through an authenticated key-only endpoint. Align the About and refresh button backgrounds with the configuration button in dark mode.

Validate the response field allowlist and session boundaries, and document the new endpoint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 16:27:55 +08:00
ZyyandCodex ccb7357914 fix(usage): indicate long-context billing with a yellow info icon
Replace the inline long-context badge with a themed yellow detail trigger and retain the billing context in its title and accessible label.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 15:44:46 +08:00
ZyyandCodex c0ad2ae656 fix(openai): restrict capacity cooldown to explicit overload
Count only confirmed capacity rejections toward account cooldown, excluding
generic server failures and unrecognized upstream errors. Classify streamed
invalid_prompt failures as invalid requests, matching official Codex.

Cover HTTP and WebSocket failures around the cooldown threshold while
preserving retry boundaries, upstream error payloads, and Smart scoring.
Update the capacity cooldown contract in the API and architecture docs.

Validation: Rustfmt, strict workspace Clippy, and all backend suites with
isolated PostgreSQL 18 / Redis 8 (--test-threads=4).

Reference: openai/codex@7498521d28,
codex-rs/codex-api/src/sse/responses.rs.

Refs #177

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 14:01:29 +08:00
ZyyandCodex 798d4f518e fix(update): run updates in background and retain release notes (#188)
Detach online updates from HTTP request lifetimes and recover their persisted status in the admin UI. Retain current release notes when no upgrade is available while preserving upgrade restrictions.

Fixes #185

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 13:29:48 +08:00
ZyyandCodex 6819682176 docs(ui): define concise copy and layout guidance
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 11:28:17 +08:00
ZyyandCodex b2d4a7e833 docs(readme): simplify punctuation and community guidance
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 11:13:39 +08:00
ZyyandCodex 01006380ee chore(release): v3.12.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 01:18:37 +08:00
ZyyandCodex c79e032575 fix(ui): refine usage page layout and controls
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 01:14:13 +08:00
Akide LiuandClaude Fable 5 96cf46cca8 fix(openai): expand string input into a user message item (#179)
The public Responses API accepts `input` as a plain string, but the Codex
backend only accepts an item list and rejects such requests with
400 "Input must be a list". Expand a string `input` into a single user
message item in the downstream body compatibility layer so that HTTP/SSE
and WebSocket share the same rule; arrays and other shapes pass through
untouched.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-20 00:58:58 +08:00
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
211 changed files with 6588 additions and 1685 deletions
+7 -1
View File
@@ -16,7 +16,7 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
| 前端开发、管理端组件、主题 Token、视觉验证 | 加载并使用 `$frontend-design`,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
@@ -25,6 +25,12 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
| 整理或提交 Issue | [github-issue](../github-issue/SKILL.md) |
| 发布说明、版本规划与发版 | [release](../release/SKILL.md) |
## 前端任务
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
- 开发前按该章节的参考入口和相邻页面核对布局、文案密度与操作位置,再选择共用组件
- 交付前按该章节检查界面文案中的句号、分号,以及过大的标题、重复说明和说明块占用的空间,结合 [界面验证](../../../CONTRIBUTING.md#界面验证) 核对实际页面
## 执行顺序
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
+20 -3
View File
@@ -1,6 +1,6 @@
---
name: release
description: 准备或执行当前仓库的稳定版、实验版发版,维护中文发布说明。用于“发版”“发布实验版/exp 版本”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable or experimental releases; not for deploying/restarting an instance 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,6 +12,7 @@ description: 准备或执行当前仓库的稳定版、实验版发版,维护
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
| 发布 alpha、beta、rc | 从 `main` 发布,确定目标正式版本和阶段,核对预发行标记与晋级路径 |
| 发布实验版、exp 版本 | 从目标实验分支发布,按下方实验版流程确定版本、文案和渠道隔离 |
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
@@ -19,7 +20,9 @@ description: 准备或执行当前仓库的稳定版、实验版发版,维护
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
稳定版从 `main` 发布。实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`,
稳定版以及面向下一正式版本的 alpha、beta、rc 从 `main` 发布,预发行 tag 使用 `vX.Y.Z-<阶段>.N`。
发版前读取[版本命名与升级规则](../../../deploy/README.md#版本命名与升级规则),作为命名、阶段晋级和更新边界的权威约定。
实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`,
展示标题包含分支用途名;先读 [实验版发布](references/experimental.md) 确定标题与编号,再复用本文的准备、执行与验收步骤。
实验功能是否合入 `main` 是单独的维护决策,发布实验版不包含合并功能的授权。
@@ -38,6 +41,20 @@ 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、目标仓库、工作区和完整待推送范围;
@@ -53,7 +70,7 @@ description: 准备或执行当前仓库的稳定版、实验版发版,维护
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
涉及数据库迁移时,对照同一大版本的上一正式 Release 检查已发布迁移是否保持不变,并验证升级路径;
涉及数据库迁移时,对照同一大版本的上一正式 Release 及本轮预发行/实验线的已发布版本检查迁移是否保持不变,并验证升级路径;
跨大版本按项目部署约定判断,不默认支持在线升级。区分未发布迁移的整理、已执行这些迁移的源码部署实例,
以及 CI 实际采用的冻结基线。未发版不代表迁移从未被执行,CI 冻结检查失败也不等于正式版数据库升级失败;
按各自证据报告兼容性与检查结果,不因未发版而自动放宽现有冻结规则。
@@ -8,8 +8,8 @@
- 从用户指定或会话已确定的 `experimental/<用途>` 分支发布,核对它的 upstream 和实验功能范围。
使用独立 worktree 时保留其他工作区内容;不把实验代码合入 `main`,也不顺带升级运行实例。
- 版本采用 `vX.Y.Z-exp.N`:`X.Y.Z` 表示本轮采用的正式版基线,`N` 从 1 开始递增。
编号在仓库内唯一;多个实验分支共享 Git tag 命名空间,不能分别占用相同版本号。
后续只有实际同步了新的正式版基线,才调整 `X.Y.Z`。
同一基线的 `exp.N` 只代表同一轮实验,不得用于不同实验分支;开始其他实验时使用不同的实际基线,不能仅接续编号。
后续只有实际同步了新的正式版基线,才调整 `X.Y.Z`;调整后属于另一实验线,发布说明需交代手动迁移方式。
- 核对已有 tag、Release 与目标分支源码,先区分重试和新发行。已发布的旧命名版本保留,
不为统一名称移动 tag 或替换原有产物。
- GitHub Release 展示标题采用 `vX.Y.Z-<用途>.M`,`<用途>` 取实验分支名去掉 `experimental/` 前缀后的部分。
@@ -24,9 +24,10 @@
在目标实验分支读取实际工作流,确认以下行为;新实验分支若缺少支持,先在既有流程中补齐并验证:
- `exp.N` 产生 `experimental` 构建类型,所有平台的二进制与镜像使用同一版本和源码提交。
本项目实验构建禁用应用内一键更新,用户按部署说明手动升级。
应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。
发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
默认 `stable` 更新渠道忽略实验版;主动配置为 `preview` 的用户可能看到预发行。
正式版和 alpha、beta、rc 均不接收实验版更新;环境变量不能改变发行线。
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
@@ -45,7 +46,7 @@
同步主分支修复时说明它对实验用户的影响,不复制整份正式版更新记录。
3. **安装与使用**:给出本版本真实镜像命令、支持平台、部署文件及实验部署文档链接。
交代功能是否默认启用和必要配置;保留工作流实际提供的校验与证明说明。
4. **使用须知**:只写本次适用的已知限制、数据隔离、迁移与回退边界,以及手动更新方式。
4. **使用须知**:只写本次适用的已知限制、数据隔离、迁移与回退边界,以及在线更新条件和手动迁移方式。
5. **反馈与致谢**:链接关联 Issue 或已有反馈入口,感谢实际贡献者。没有相应内容的章节可以省略。
用户要求先审核文案时,先展示完整正文;已批准的同一版本文案无需重复确认。
@@ -56,7 +57,7 @@
按主技能准备说明和其他变更后,在实验分支执行 `release/publish <X.Y.Z-exp.N>`。
入口创建版本提交和 tag,并从该分支 dispatch 发布工作流;不要从 `main` 发布实验代码。
发布前记录 GitHub Latest 的版本与 Docker `latest` 摘要;发布后核对二者仍对应稳定版。
按主技能的「发行通道核对」确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。
检查展示标题的分支用途与历史续号、它所对应的实际 tag,以及目标 tag 的源码提交、完整产物、校验和、多架构平台和 Pre-release 标记。
同基线的 `X.Y.Z-exp.N` 按 SemVer 排序低于正式 `X.Y.Z`;渠道隔离仍以更新器过滤和发布标签规则为准。
+5 -3
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
+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
+20 -20
View File
@@ -17,13 +17,13 @@
</div>
> [!NOTE]
> 本项目提供 Responses API,不支持 `/v1/chat/completions`。接入前请确认客户端支持 Responses 协议。
> 本项目提供 Responses API,不支持 `/v1/chat/completions`,接入前请确认客户端支持 Responses 协议
## 快速预览
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计。
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计
| 登录信息 | 值 |
| 登录信息 | 默认值 |
| --- | --- |
| 地址 | <https://codex-proxy-rs.ainz.cc> |
| 登录身份 | 管理员 |
@@ -31,17 +31,17 @@
| 密码 | `039c18de2aeac46d23ead7766bb07bbbe3747233c66a128e` |
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据。
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息。
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息
## 快速开始
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
### 一键安装
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限。
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限
```bash
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/install.sh -o install.sh && bash install.sh
@@ -49,7 +49,7 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
自动生成密码、设置目录权限并启动服务。完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
[添加账号与客户端密钥](#添加账号与客户端密钥)。
[添加账号与客户端密钥](#添加账号与客户端密钥)
可在执行 `bash install.sh` 时传入环境变量:
@@ -66,32 +66,33 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
```
重复运行时请使用同一安装目录。检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
忽略传入的管理员密码,也不执行版本升级。
忽略传入的管理员密码,也不执行版本升级
### 手动安装
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)。
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)
### 登录管理端
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch,导入时同时启用当前 Key 的日/周额度查询,默认刷新间隔为 30 分钟
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)。
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)
### 添加账号与客户端密钥
1. 在「账号」中添加账号,完成授权或导入。
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
3. 打开密钥的「使用密钥」,复制客户端配置。
1. 在「账号」中添加账号,完成授权或导入
2. 按需建立账号分组,再创建客户端密钥并选择可用分组,**不选分组表示可使用全部账号**
3. 打开密钥的「使用密钥」,复制客户端配置
## 客户端接入
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)。
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
**其他 Responses API 客户端**:填写以下信息。
**其他 Responses API 客户端**:填写以下信息:
| 配置 | 值 |
| --- | --- |
@@ -122,11 +123,10 @@ curl http://127.0.0.1:8080/v1/models \
[使用问答](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),无需先发讨论。
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台。
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台
## 许可证
本项目基于 [Apache License 2.0](LICENSE) 开源。
本项目基于 [Apache License 2.0](LICENSE) 开源
+9 -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]]
@@ -3157,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",
@@ -3195,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",
+2 -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?;
+1 -4
View File
@@ -196,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 -38
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,15 +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().replacen(
" residency: us",
" residency: us\n codex_version: 'latest'",
1,
));
}
#[test]
fn config_loader_should_reject_disabled_all_log_outputs() {
assert_rejected(
@@ -468,31 +476,10 @@ fn config_loader_should_reject_zero_server_port() {
}
#[test]
fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replacen(
" residency: us",
" residency: us\n desktop_build: 'build'",
1,
));
}
#[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) {
+6 -5
View File
@@ -346,7 +346,7 @@ pub async fn initialize(
config.session_ttl_minutes,
client_config.session_ttl_minutes,
store.auth(),
client_key_verifier,
client_key_verifier.clone(),
));
auth.ensure_default_admin(config.default_password.expose())
.await?;
@@ -370,12 +370,14 @@ pub async fn initialize(
backup_ports.dump(),
backup_ports.object_store(),
);
let system = Arc::new(DefaultSystemService::new(system));
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
auth.clone(),
client_key_verifier,
store.client_keys(),
store.observability(),
system.clone(),
));
let profile_provider = Arc::clone(&openai);
let openai = Arc::new(DefaultOpenAiService::new(
openai,
store.accounts(),
@@ -409,7 +411,7 @@ pub async fn initialize(
client_keys: Arc::new(DefaultClientKeyService::new(
store.client_keys(),
snapshot.clone(),
Arc::clone(&profile_provider),
registry.clone(),
)),
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
observability: Arc::new(DefaultObservabilityService::new(
@@ -421,11 +423,10 @@ pub async fn initialize(
settings: Arc::new(DefaultSettingsService::new(
store.settings(),
snapshot.clone(),
profile_provider,
registry,
pricing_source,
)),
system: Arc::new(DefaultSystemService::new(system)),
system,
openai,
xai,
import_tasks,
@@ -87,6 +87,7 @@ pub struct ClientKeyListQuery {
#[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>,
@@ -110,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,
@@ -146,6 +147,7 @@ impl fmt::Debug for ClientKeySecret {
#[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>,
@@ -158,6 +160,7 @@ pub struct CreateClientKey {
#[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>,
@@ -183,6 +186,7 @@ impl fmt::Debug for NewClientKey {
#[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>,
@@ -312,6 +312,7 @@ 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,
@@ -546,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,
@@ -554,6 +556,8 @@ pub struct UsageListRecord {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
pub provider_account_notes: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -775,6 +779,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>,
@@ -108,7 +108,7 @@ impl AccountQuotaForecast {
let percent = window
.used_percent
.filter(|p| p.is_finite() && (0.0..=100.0).contains(p));
let usage = sample.map(|sample| &sample.usage);
let usage = sample.map(|sample| &sample.cycle_usage);
let usd = usage
.filter(|usage| usage.known_cost_count > 0)
.map(|usage| usage.usd)
@@ -175,14 +175,21 @@ impl AccountQuotaForecast {
|| (method == QuotaForecastMethod::Incremental && sample.block_count < 2);
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗。
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。
let capacity_factor = 100.0 / sample.sampled_percent;
let factor = capacity_factor * self.target_seconds as f64 / seconds as f64;
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0);
self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor));
self.estimated_usd = usd.and_then(|value| estimate(value, factor));
// 已发生的用量保持本周期累计,只把近期消耗比例用于尚未使用的额度。
let factor = self.target_seconds as f64 / seconds as f64;
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
self.remaining_tokens = tokens.and_then(|value| estimate_tokens(value, remaining_factor));
self.remaining_usd = usd.and_then(|value| estimate(value, remaining_factor));
let remaining_tokens = Some(sample.usage.tokens)
.filter(|tokens| *tokens > 0)
.and_then(|value| estimate(value as f64, remaining_factor));
self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0));
self.estimated_tokens = remaining_tokens
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
self.remaining_usd = Some(&sample.usage)
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
.and_then(|usage| estimate(usage.usd, remaining_factor));
self.estimated_usd = usd
.zip(self.remaining_usd)
.and_then(|(used, remaining)| estimate(used + remaining, factor));
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
{
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
@@ -197,8 +204,8 @@ fn estimate(value: f64, factor: f64) -> Option<f64> {
(estimate.is_finite() && estimate >= 0.0).then_some(estimate)
}
fn estimate_tokens(value: u64, factor: f64) -> Option<u64> {
estimate(value as f64, factor)
fn estimate_tokens(value: f64, factor: f64) -> Option<u64> {
estimate(value, factor)
.filter(|value| value.round() < u64::MAX as f64)
.map(|value| value.round() as u64)
}
@@ -101,6 +101,8 @@ pub struct QuotaForecastSample {
pub observation_count: usize,
pub pending_request_count: u64,
pub discontinuous: bool,
/// 本周期已记录用量;近期采样仅用于预测剩余量,不能覆盖这份累计值。
pub cycle_usage: QuotaForecastUsage,
pub usage: QuotaForecastUsage,
}
@@ -113,12 +115,13 @@ pub fn select_forecast_sample(
current: QuotaForecastPoint,
mut history: Vec<QuotaForecastPoint>,
pending_request_count: u64,
interrupted: bool,
) -> QuotaForecastSample {
history.retain(|point| {
start_at <= point.observed_at
&& point.observed_at < current.observed_at
&& point.used_percent.is_finite()
&& (0.0..100.0).contains(&point.used_percent)
&& (0.0..=100.0).contains(&point.used_percent)
});
history.sort_by_key(|point| point.observed_at);
let mut sample = QuotaForecastSample {
@@ -131,10 +134,13 @@ pub fn select_forecast_sample(
block_count: 0,
observation_count: history.len(),
pending_request_count,
discontinuous: false,
discontinuous: interrupted,
cycle_usage: current.usage.clone(),
usage: current.usage.clone(),
};
history.push(current);
// 重置边界不在查询起点时,从首个新观测重新计数,避免把旧周期累计带入总量。
let mut cycle_baseline = interrupted.then(|| &history[0]);
let mut anchors: Vec<&QuotaForecastPoint> = Vec::new();
let mut high_water: Option<&QuotaForecastPoint> = None;
for point in &history {
@@ -149,6 +155,7 @@ pub fn select_forecast_sample(
}
sample.discontinuous = true;
anchors.clear();
cycle_baseline = Some(point);
}
}
high_water = Some(point);
@@ -162,6 +169,13 @@ pub fn select_forecast_sample(
let Some(current) = history.last() else {
return sample;
};
if let Some(baseline) = cycle_baseline {
let Some(usage) = current.usage.difference(&baseline.usage) else {
sample.discontinuous = true;
return sample;
};
sample.cycle_usage = usage;
}
if anchors.len() < 2 {
return sample;
}
@@ -18,6 +18,7 @@ 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,
@@ -50,6 +51,7 @@ 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,
@@ -126,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,
}
}
}
@@ -71,6 +71,7 @@ pub struct SystemOperationState {
pub struct SystemUpdateStatus {
pub previous_version: Option<String>,
pub current_version: Option<String>,
pub need_restart: bool,
pub operation: SystemOperationState,
}
@@ -103,7 +104,6 @@ pub enum SystemOperationAccepted {
operation_id: String,
deployment_mode: String,
message: String,
need_restart: bool,
target_version: String,
},
Rollback {
@@ -167,6 +167,7 @@ pub trait ProviderAdmin: Send + Sync {
}
/// 使用 Provider-owned 价格规则恢复持久请求的逐项费用。
/// 未保存的长上下文计费标记不得从当前价格反推。
fn calculated_billing(
&self,
input: &ProviderBillingInput,
@@ -548,19 +548,39 @@ impl AccountsService for DefaultAccountsService {
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<AccountRefreshResult, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
let result = self
.accounts
.recover_account(&account_id, context)
.await
.map_err(|error| map_store_error(error, "provider account recovery"))?;
let (stored, provider) = self.provider_for_account(&account_id).await?;
let config_revision = if stored.account.enabled {
self.accounts
.recover_account(&account_id, context)
.await
.map_err(|error| map_store_error(error, "provider account recovery"))?
.config_revision
} else {
// 停用只表示不参与调度,重新启用不能抹除已观测的额度、凭据或冷却事实。
self.accounts
.batch_update_accounts(
BatchUpdateAccounts {
account_ids: vec![account_id.to_string()],
enabled: Some(true),
concurrency_limit: None,
weight: None,
model_access: None,
group_ids: None,
outbound_proxy: None,
},
context,
)
.await
.map_err(|error| map_store_error(error, "enable provider account"))?
.config_revision
};
provider
.account_facts_changed(std::slice::from_ref(&account_id))
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
let account = self.load_directory_item(&account_id, false).await?;
Ok(AccountRefreshResult {
config_revision: result.config_revision,
config_revision,
account,
})
}
@@ -740,7 +760,7 @@ impl AccountsService for DefaultAccountsService {
usage: point.usage,
});
}
let mut sample = select_forecast_sample(
let sample = select_forecast_sample(
window.key.clone(),
query.range.start,
QuotaForecastPoint {
@@ -750,13 +770,8 @@ impl AccountsService for DefaultAccountsService {
},
points,
history.pending_request_count,
interrupted,
);
if interrupted
&& sample.method
== crate::model::quota_forecast_sampling::QuotaForecastMethod::Cumulative
{
sample.discontinuous = true;
}
samples.push(sample);
}
Ok(AccountQuotaForecastReport {
@@ -57,7 +57,7 @@ pub trait ClientKeyService: Send + Sync {
}
pub(crate) struct DefaultClientKeyService {
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
providers: crate::ports::provider::ProviderAdminRegistry,
store: Arc<dyn ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
@@ -67,12 +67,12 @@ impl DefaultClientKeyService {
pub(crate) fn new(
store: Arc<dyn ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
providers: crate::ports::provider::ProviderAdminRegistry,
) -> Self {
Self {
store,
snapshot,
profile_provider,
providers,
}
}
}
@@ -113,10 +113,18 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError> {
if let Some(profile) = &command.openai_client_profile_override {
self.profile_provider
.preview_client_profile(profile)
.map_err(|error| super::map_provider_error(error, "client profile"))?;
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 失败"))?;
@@ -132,6 +140,7 @@ impl ClientKeyService for DefaultClientKeyService {
.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,
@@ -156,10 +165,18 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: UpdateClientKey,
) -> Result<ClientKeyMutation, AdminError> {
if let Some(Some(profile)) = &command.openai_client_profile_override {
self.profile_provider
.preview_client_profile(profile)
.map_err(|error| super::map_provider_error(error, "client profile"))?;
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
@@ -1,4 +1,4 @@
//! Key 自助查询只从统一会话取得范围,复用现有观测和额度账本。
//! Key 自助查询从统一会话或只读 Key 校验取得范围,复用现有观测和额度账本。
use std::sync::Arc;
@@ -6,10 +6,11 @@ use async_trait::async_trait;
use chrono::Utc;
use crate::{
AuthService,
AuthService, SystemService,
model::{
AdminError, AdminErrorKind,
auth::SessionSubject,
client_keys::ClientKeySecret,
key_usage::{
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
KeyUsageRecordsQuery,
@@ -17,15 +18,30 @@ use crate::{
observability::{
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
},
system::SystemVersion,
},
ports::store::{ClientKeyStore, ObservabilityStore},
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::{
engine::{
budget::ClientBudgetStatus,
execution::{ClientAuthenticationError, ClientKeyVerifier},
},
policy::ClientApiKeyId,
};
use super::{map_store_error, observability::health_timeline_at};
#[async_trait]
pub trait KeyUsageService: Send + Sync {
/// 验证 Key 并只读查询当前额度,不记录 Key 使用或执行推理准入。
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, AdminError>;
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError>;
async fn config(&self, session_id: Option<&str>)
-> Result<Option<ClientKeySecret>, AdminError>;
async fn overview(
&self,
session_id: Option<&str>,
@@ -41,20 +57,26 @@ pub trait KeyUsageService: Send + Sync {
pub(crate) struct DefaultKeyUsageService {
auth: Arc<dyn AuthService>,
verifier: Arc<dyn ClientKeyVerifier>,
keys: Arc<dyn ClientKeyStore>,
observations: Arc<dyn ObservabilityStore>,
system: Arc<dyn SystemService>,
}
impl DefaultKeyUsageService {
pub(crate) fn new(
auth: Arc<dyn AuthService>,
verifier: Arc<dyn ClientKeyVerifier>,
keys: Arc<dyn ClientKeyStore>,
observations: Arc<dyn ObservabilityStore>,
system: Arc<dyn SystemService>,
) -> Self {
Self {
auth,
verifier,
keys,
observations,
system,
}
}
@@ -85,6 +107,46 @@ fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
#[async_trait]
impl KeyUsageService for DefaultKeyUsageService {
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, AdminError> {
let id = match self.verifier.verify_client_key(plaintext) {
Ok(id) => id,
Err(ClientAuthenticationError::InvalidKey) => return Ok(None),
Err(ClientAuthenticationError::SnapshotUnavailable) => {
return Err(AdminError::new(
AdminErrorKind::Unavailable,
"密钥验证暂时不可用",
));
}
};
self.keys
.get_client_key(&id)
.await
.map(|key| key.filter(|key| key.enabled).map(|key| key.budget))
.map_err(|error| map_store_error(error, "key usage budget"))
}
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError> {
if self.key_id(session_id).await?.is_none() {
return Ok(None);
}
self.system.version().await.map(Some)
}
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>,
@@ -199,6 +199,12 @@ impl DefaultObservabilityService {
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(
@@ -39,11 +39,13 @@ pub trait SettingsService: Send + Sync {
) -> 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 不支持客户端身份配置"))
@@ -68,17 +70,26 @@ pub trait SettingsService: Send + Sync {
pub(crate) struct DefaultSettingsService {
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
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>,
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
) -> Self {
@@ -87,7 +98,6 @@ impl DefaultSettingsService {
pricing_source,
store,
snapshot,
profile_provider,
}
}
}
@@ -250,16 +260,18 @@ impl SettingsService for DefaultSettingsService {
async fn client_profile_options(
&self,
provider: &str,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
let mut options = self
.profile_provider
.profile_provider(provider)?
.client_profile_options()
.map_err(|error| super::map_provider_error(error, "client profile"))?
.into_inner();
let configuration = self
.load()
.await?
.openai_client_profile
.client_profile(provider)
.cloned()
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
options.insert(
"globalConfiguration".to_owned(),
@@ -270,8 +282,10 @@ impl SettingsService for DefaultSettingsService {
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")
@@ -279,12 +293,12 @@ impl SettingsService for DefaultSettingsService {
global = self
.load()
.await?
.openai_client_profile
.client_profile(provider)
.cloned()
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
(&global, "global")
};
let mut preview = self
.profile_provider
let mut preview = profile_provider
.preview_client_profile(configuration)
.map_err(|error| super::map_provider_error(error, "client profile"))?
.into_inner();
@@ -305,10 +319,15 @@ impl SettingsService for DefaultSettingsService {
command: ReplaceRuntimeSettings,
) -> Result<RuntimeSettings, AdminError> {
validate_settings(&command)?;
if let Some(profile) = &command.openai_client_profile {
self.profile_provider
.preview_client_profile(profile)
.map_err(|error| super::map_provider_error(error, "client profile"))?;
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
@@ -76,6 +76,23 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
.filter_map(|window| {
let usage = window.local_usage.as_ref()?;
let usd = usage.costs.iter().find(|cost| cost.currency == "USD");
let usage = QuotaForecastUsage {
request_count: usage.request_count,
tokens: usage.total_tokens.unwrap_or(0),
input_tokens: usage.input_tokens.unwrap_or(0),
output_tokens: usage.output_tokens.unwrap_or(0),
cached_tokens: usage.cached_tokens.unwrap_or(0),
missing_token_count: u64::from(usage.total_tokens.is_none()),
known_cost_count: if usd.is_some() {
usage.cost_coverage.known_count()
} else {
0
},
unavailable_cost_count: usage.cost_coverage.partial_count
+ usage.cost_coverage.unavailable_count,
usd: usd.map_or(0.0, |cost| cost.amount.as_str().parse().unwrap()),
excluded_request_count: 0,
};
Some(QuotaForecastSample {
key: window.key.clone(),
method: QuotaForecastMethod::Cumulative,
@@ -89,23 +106,8 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
observation_count: 0,
pending_request_count: 0,
discontinuous: false,
usage: QuotaForecastUsage {
request_count: usage.request_count,
tokens: usage.total_tokens.unwrap_or(0),
input_tokens: usage.input_tokens.unwrap_or(0),
output_tokens: usage.output_tokens.unwrap_or(0),
cached_tokens: usage.cached_tokens.unwrap_or(0),
missing_token_count: u64::from(usage.total_tokens.is_none()),
known_cost_count: if usd.is_some() {
usage.cost_coverage.known_count()
} else {
0
},
unavailable_cost_count: usage.cost_coverage.partial_count
+ usage.cost_coverage.unavailable_count,
usd: usd.map_or(0.0, |cost| cost.amount.as_str().parse().unwrap()),
excluded_request_count: 0,
},
cycle_usage: usage.clone(),
usage,
})
})
.collect()
@@ -238,7 +240,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
sample.block_count = 2;
let [week, month] =
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
assert_eq!(week.estimated_tokens, Some(10_000));
assert_eq!(week.estimated_tokens, Some(9_000));
assert_eq!(week.remaining_tokens, Some(8_000));
assert_eq!(month.remaining_tokens, Some(8_000));
assert!(week.unavailable_reason.is_none());
@@ -250,6 +252,7 @@ fn missing_tokens_keep_estimates_from_recorded_usage() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
sample.usage.missing_token_count = 1;
sample.cycle_usage = sample.usage.clone();
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.incomplete_tokens);
assert!(week.unavailable_reason.is_none());
@@ -304,6 +307,7 @@ fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimate
sample.usage.unavailable_cost_count = 2;
sample.usage.excluded_request_count = 1;
sample.pending_request_count = 1;
sample.cycle_usage = sample.usage.clone();
let [week, month] =
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.incomplete_tokens);
@@ -345,6 +349,7 @@ fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
unavailable_cost_count: 2,
..Default::default()
};
sample.cycle_usage = sample.usage.clone();
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.unavailable_reason.is_some());
assert_eq!(week.estimated_tokens, None);
@@ -380,3 +385,50 @@ fn oversized_window_or_estimate_is_unavailable_without_panics_or_saturation() {
assert!(week.estimated_tokens.is_none());
assert_eq!(week.estimated_usd, Some(10.0));
}
#[test]
fn slower_recent_consumption_preserves_recorded_cycle_usage_in_total_forecast() {
let mut source = quota(vec![window("week", 7)]);
source.windows[0].used_percent = Some(92.0);
let mut sample = samples(&source).remove(0);
sample.method = QuotaForecastMethod::Incremental;
sample.start_at = now() - Duration::hours(20);
sample.baseline_percent = 75.0;
sample.sampled_percent = 17.0;
sample.block_count = 3;
sample.usage.tokens = 141_226_903;
sample.usage.usd = 237.21007884;
sample.cycle_usage.tokens = 1_084_786_565;
sample.cycle_usage.usd = 1_412.57957384;
let [cycle, month] =
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(1_084_786_565));
assert_eq!(cycle.source.as_ref().unwrap().usd, Some(1_412.57957384));
assert_eq!(cycle.remaining_tokens, Some(66_459_719));
assert_eq!(cycle.estimated_tokens, Some(1_151_246_284));
assert!((cycle.remaining_usd.unwrap() - 111.628272395294).abs() < 1e-8);
assert!((cycle.estimated_usd.unwrap() - 1_524.207846235294).abs() < 1e-8);
assert_eq!(month.remaining_tokens, cycle.remaining_tokens);
assert!(
(month.estimated_usd.unwrap() - cycle.estimated_usd.unwrap() * 30.0 / 7.0).abs() < 1e-8
);
}
#[test]
fn exhausted_cycle_total_is_recorded_usage_even_when_recent_sample_is_small() {
let mut source = quota(vec![window("week", 7)]);
source.windows[0].used_percent = Some(100.0);
let mut sample = samples(&source).remove(0);
sample.method = QuotaForecastMethod::Incremental;
sample.start_at = now() - Duration::hours(2);
sample.baseline_percent = 85.0;
sample.sampled_percent = 15.0;
sample.block_count = 3;
sample.usage.tokens = 100;
sample.usage.usd = 0.1;
let [cycle, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert_eq!(cycle.estimated_tokens, Some(1_000));
assert_eq!(cycle.estimated_usd, Some(2.0));
assert_eq!(cycle.remaining_tokens, Some(0));
assert_eq!(cycle.remaining_usd, Some(0.0));
}
@@ -24,7 +24,7 @@ fn point(minute: i64, percent: f64, tokens: u64) -> QuotaForecastPoint {
}
fn select(points: Vec<QuotaForecastPoint>, current: QuotaForecastPoint) -> QuotaForecastSample {
select_forecast_sample("week".to_owned(), time(0), current, points, 2)
select_forecast_sample("week".to_owned(), time(0), current, points, 2, false)
}
#[test]
@@ -55,6 +55,7 @@ fn recent_blocks_and_tail_replace_old_workload_without_averaging_individual_rati
assert_eq!(sample.baseline_percent, 10.0);
assert_eq!(sample.sampled_percent, 17.0);
assert_eq!(sample.usage.tokens, 700);
assert_eq!(sample.cycle_usage.tokens, 900);
assert_eq!(sample.block_count, 3);
}
@@ -77,6 +78,7 @@ fn large_percentage_reversal_restarts_sampling_without_crossing_old_consumption(
);
assert_eq!(sample.baseline_percent, 1.0);
assert_eq!(sample.usage.tokens, 70);
assert_eq!(sample.cycle_usage.tokens, 70);
assert_eq!(sample.sampled_percent, 7.0);
assert!(!sample.discontinuous);
let waiting = select(
@@ -122,3 +124,41 @@ fn counter_regressions_fail_closed_and_future_points_are_not_used() {
assert_eq!(future.method, QuotaForecastMethod::Cumulative);
assert_eq!(future.observation_count, 0);
}
#[test]
fn exhausted_history_is_retained_to_detect_a_reset_with_an_unchanged_deadline() {
let sample = select(
vec![point(1, 100.0, 10_000), point(2, 0.0, 10_000)],
point(3, 10.0, 10_100),
);
assert_eq!(sample.cycle_usage.tokens, 100);
assert_eq!(sample.usage.tokens, 100);
assert_eq!(sample.sampled_percent, 10.0);
assert!(!sample.discontinuous);
}
#[test]
fn changed_window_restarts_cycle_totals_at_first_matching_observation() {
let sample = select_forecast_sample(
"week".to_owned(),
time(0),
point(4, 12.0, 10_120),
vec![point(2, 0.0, 10_000), point(3, 5.0, 10_050)],
0,
true,
);
assert_eq!(sample.cycle_usage.tokens, 120);
assert!((sample.cycle_usage.usd - 0.12).abs() < 1e-10);
assert_eq!(sample.usage.tokens, 120);
assert!(!sample.discontinuous);
let waiting = select_forecast_sample(
"week".to_owned(),
time(0),
point(4, 2.0, 10_020),
vec![],
0,
true,
);
assert_eq!(waiting.cycle_usage.tokens, 0);
assert!(waiting.discontinuous);
}
@@ -935,6 +935,13 @@ impl AccountStore for FakeAccountStore {
self.record("store.batch_update_accounts");
self.record_context(context);
self.require_commit()?;
if let Some(enabled) = command.enabled {
for account in self.accounts.lock().expect("accounts").iter_mut() {
if command.account_ids.contains(&account.id) {
account.enabled = enabled;
}
}
}
Ok(AccountsUpdateResult {
config_revision: revision(2),
account_ids: command
@@ -991,6 +998,7 @@ impl SettingsStore for StaticSettingsStore {
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),
@@ -1359,7 +1367,6 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let mut account = account_record("openai");
account.enabled = false;
account.credential_state = CredentialState::Invalid;
account.quota = QuotaState::exhausted(
QuotaEvidence::UsageLimitReached,
@@ -1406,6 +1413,110 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
assert_eq!(store.audit_requests(), ["recover-request"]);
}
#[tokio::test]
async fn accounts_recover_disabled_should_only_enable_scheduling_and_preserve_quota() {
for has_error in [false, true] {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let mut account = account_record("openai");
account.enabled = false;
if has_error {
account.credential_state = CredentialState::Invalid;
account.last_error_reason =
Some(gateway_core::account::AccountErrorReason::CredentialInvalid);
account.last_error_message = Some("invalid credential".to_owned());
account.quota = QuotaState::exhausted(
QuotaEvidence::UsageLimitReached,
std::time::SystemTime::now(),
None,
);
}
let quota = ProviderQuota {
observed_at: Some(Utc::now()),
windows: vec![ProviderQuotaWindow {
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(92.0),
reset_at: Some(Utc::now() + TimeDelta::hours(1)),
limit_reached: false,
local_usage: None,
provider_data: None,
}],
..empty_quota()
};
provider.set_quota(quota.clone());
let store = FakeAccountStore::with_account(account.clone(), events.clone());
let services = accounts_service(provider.clone(), store.clone()).await;
let result = services
.accounts()
.recover(
&context("enable-request"),
ProviderAccountId::new("acct_test").expect("account ID"),
)
.await
.expect("enable disabled account");
account.enabled = true;
assert_eq!(result.config_revision, revision(2));
assert_eq!(result.account.account, account);
assert_eq!(result.account.quota, quota);
assert_eq!(
result.account.projection.status,
if has_error {
gateway_admin::model::accounts::AccountStatus::Error
} else {
gateway_admin::model::accounts::AccountStatus::Normal
}
);
assert_eq!(
recorded(&events),
[
"store.load_account",
"store.batch_update_accounts",
"provider.account_facts_changed",
"store.load_account",
]
);
assert_eq!(store.audit_requests(), ["enable-request"]);
let requests = provider.quota_requests();
assert_eq!(requests.len(), 1);
assert!(!requests[0].refresh);
}
}
#[tokio::test]
async fn accounts_recover_disabled_should_not_publish_when_enabling_fails() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let mut account = account_record("openai");
account.enabled = false;
let store = FakeAccountStore::with_account(account.clone(), events.clone());
store.fail_next_commit();
let services = accounts_service(provider, store.clone()).await;
let result = services
.accounts()
.recover(
&context("enable-failed"),
ProviderAccountId::new("acct_test").expect("account ID"),
)
.await;
assert!(result.is_err());
assert_eq!(*store.accounts.lock().expect("accounts"), [account]);
assert_eq!(
recorded(&events),
["store.load_account", "store.batch_update_accounts"]
);
}
#[tokio::test]
async fn accounts_update_should_commit_then_release_disabled_account_and_publish_facts() {
let events = events();
@@ -2039,6 +2150,29 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
.unwrap()
.contains("不连续")
);
// 新额度段已有足够观测后恢复预测,但总量不能带回重置前的累计用量。
let mut first = make_point(1, 5.0, 1_750, 0);
first.completed_at = now - TimeDelta::minutes(45);
first.started_at = first.completed_at - TimeDelta::seconds(10);
let mut next = make_point(1, 20.0, 1_900, 0);
next.completed_at = now - TimeDelta::minutes(20);
next.started_at = next.completed_at - TimeDelta::seconds(10);
store
.quota_forecast_history
.lock()
.unwrap()
.points
.extend([first, next]);
let result = services
.accounts()
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
let cycle = &result.forecasts[0];
assert!(cycle.unavailable_reason.is_none());
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(250));
assert_eq!(cycle.remaining_tokens, Some(429));
assert_eq!(cycle.estimated_tokens, Some(679));
}
#[tokio::test]
@@ -75,6 +75,7 @@ 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,
@@ -260,6 +261,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
&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,
@@ -280,6 +282,7 @@ 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,
@@ -26,6 +26,7 @@ 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),
@@ -921,6 +921,7 @@ 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"),
@@ -773,6 +773,7 @@ impl SettingsStore for FixtureSettingsStore {
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"),
@@ -870,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(),
@@ -878,6 +880,7 @@ fn total_record(
provider_account_ref: None,
provider_account_name: None,
provider_account_email: None,
provider_account_notes: None,
provider_account_authentication_kind: None,
upstream_model_id: Some("gpt-5.5".to_owned()),
upstream_transport: None,
@@ -13,6 +13,20 @@ 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> {
@@ -79,6 +93,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
},
ReplaceRuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
model_mappings: Default::default(),
@@ -60,13 +60,13 @@ impl SystemOperations for RecordingSystemOperations {
operation_id: "operation-update".to_owned(),
deployment_mode: "source".to_owned(),
message: "accepted".to_owned(),
need_restart: true,
target_version: target_version.unwrap_or_else(|| "latest".to_owned()),
})
}
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
Ok(SystemUpdateStatus {
need_restart: false,
previous_version: None,
current_version: Some("1.0.0".to_owned()),
operation: SystemOperationState {
@@ -148,6 +148,7 @@ impl ClientKeySort {
#[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>,
@@ -180,6 +181,9 @@ impl CreateClientKeyRequest {
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())
@@ -208,6 +212,8 @@ impl CreateClientKeyRequest {
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>,
@@ -231,6 +237,9 @@ impl UpdateClientKeyRequest {
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,
@@ -317,6 +326,7 @@ impl ClientKeyMutationRequest {
#[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>,
@@ -358,6 +368,9 @@ impl From<ClientKeyRecord> for ClientKeyView {
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,
@@ -214,6 +214,7 @@ 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(),
@@ -233,6 +234,7 @@ 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()
@@ -277,12 +279,14 @@ 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,
account_id: record.provider_account_ref,
account_email: record.provider_account_email,
account_name: record.provider_account_name,
account_notes: record.provider_account_notes,
route: record.endpoint,
model,
requested_model: record.requested_model_id,
@@ -564,6 +568,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,7 @@ 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,
@@ -85,12 +86,14 @@ pub struct ImageBillingView {
#[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>,
pub account_id: Option<String>,
pub account_email: Option<String>,
pub account_name: Option<String>,
pub account_notes: Option<String>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
@@ -773,6 +776,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>,
@@ -6,7 +6,7 @@ use std::{collections::BTreeMap, fmt};
use axum::{
Router,
extract::State,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
@@ -35,6 +35,7 @@ pub type ModelMappings = BTreeMap<String, String>;
#[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,
@@ -68,6 +69,8 @@ pub struct RuntimeSettingsView {
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,
@@ -181,6 +184,9 @@ impl UpdateRuntimeSettingsRequest {
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
@@ -225,6 +231,9 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
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),
@@ -361,11 +370,11 @@ where
.route("/api/admin/settings/pricing/sync", post(sync_pricing::<S>))
.route("/api/admin/settings", get(settings::<S>))
.route(
"/api/admin/settings/client-profiles/openai",
"/api/admin/settings/client-profiles/{provider}",
get(client_profile_options::<S>),
)
.route(
"/api/admin/settings/client-profiles/openai/preview",
"/api/admin/settings/client-profiles/{provider}/preview",
post(preview_client_profile::<S>),
)
.route("/api/admin/settings/update", post(update_settings::<S>))
@@ -731,6 +740,7 @@ fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>(
async fn client_profile_options<S>(
_auth: AdminAuth,
Path(provider): Path<String>,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
@@ -739,7 +749,7 @@ where
let result = state
.admin_services()
.settings()
.client_profile_options()
.client_profile_options(&provider)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
@@ -756,6 +766,7 @@ struct ClientProfilePreviewRequest {
async fn preview_client_profile<S>(
_auth: AdminAuth,
Path(provider): Path<String>,
State(state): State<S>,
AdminJson(request): AdminJson<ClientProfilePreviewRequest>,
) -> Result<impl IntoResponse, AdminError>
@@ -768,7 +779,7 @@ where
let result = state
.admin_services()
.settings()
.preview_client_profile(configuration.as_ref())
.preview_client_profile(&provider, configuration.as_ref())
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
@@ -163,6 +163,7 @@ impl From<SystemOperationState> for SystemOperationStateView {
struct SystemUpdateStatusView {
previous_version: Option<String>,
current_version: Option<String>,
need_restart: bool,
operation: SystemOperationStateView,
}
@@ -171,6 +172,7 @@ impl From<SystemUpdateStatus> for SystemUpdateStatusView {
Self {
previous_version: status.previous_version,
current_version: status.current_version,
need_restart: status.need_restart,
operation: status.operation.into(),
}
}
@@ -182,7 +184,6 @@ struct UpdateAcceptedView {
operation_id: String,
deployment_mode: String,
message: String,
need_restart: bool,
target_version: String,
}
@@ -308,19 +309,17 @@ where
operation_id,
deployment_mode,
message,
need_restart,
target_version,
} = result
else {
return Err(AdminError::internal());
};
Ok(AdminResponse::new(
StatusCode::OK,
StatusCode::ACCEPTED,
AdminEnvelope::ok(UpdateAcceptedView {
operation_id,
deployment_mode,
message,
need_restart,
target_version,
}),
))
@@ -23,6 +23,8 @@ where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/key-usage/version", get(version::<S>))
.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 +33,48 @@ where
.layer(middleware::map_response(no_store))
}
async fn version<S>(
State(state): State<S>,
headers: HeaderMap,
AdminQuery(_): AdminQuery<query::EmptyQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let version = state
.admin_services()
.key_usage()
.version(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::version(version)),
))
}
async fn config<S>(
State(state): State<S>,
headers: HeaderMap,
AdminQuery(_): AdminQuery<query::EmptyQuery>,
) -> 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,11 +1,13 @@
//! 只投影当前 Key 可见的用量字段,不序列化账号、凭据、上游标识或诊断正文。
//! 用量响应不含凭据;配置响应仅在显式读取时返回当前 Key 的名称与明文。
use chrono::{DateTime, Utc};
use gateway_admin::model::{
client_keys::ClientKeySecret,
key_usage::{KeyUsageOverview, KeyUsageRecords},
observability::{
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
},
system::SystemVersion,
};
use serde::Serialize;
@@ -14,6 +16,36 @@ use crate::admin::observability::{
health_timeline_view, usage_list_token_details,
};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct VersionView {
version: String,
git_sha: String,
}
pub(super) fn version(version: SystemVersion) -> VersionView {
// 密钥用户仅查看构建标识,不暴露部署环境、更新状态或内部诊断。
VersionView {
version: version.version,
git_sha: version.git_sha,
}
}
#[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 EmptyQuery {}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct OverviewQuery {
@@ -9,6 +9,7 @@ pub mod responses;
pub mod router;
pub mod search;
pub(crate) mod service;
mod usage;
/// 客户端可读的 ID 必须能按现有请求记录检索;不以无关入口 ID 补位。
fn with_model_request_id(
@@ -11,6 +11,7 @@ use super::{
models::{model_detail, models},
responses::{responses, responses_websocket},
search::standalone_search,
usage,
};
use crate::ApiState;
@@ -25,6 +26,7 @@ pub(crate) fn router() -> Router<ApiState> {
.route("/v1/models", get(models))
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
.route("/v1/models/{model_id}", get(model_detail))
.merge(usage::router())
// OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝
// 上游本可接受的未来 payload。
.layer(DefaultBodyLimit::disable())
@@ -0,0 +1,90 @@
//! Bearer Key 的只读额度查询,与自助页面共用当前预算账本。
use std::time::SystemTime;
use axum::{
Json, Router,
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
middleware,
response::{IntoResponse, Response},
routing::get,
};
use chrono::{DateTime, Utc};
use gateway_core::metering::Decimal;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{ApiState, auth::SessionState as _};
use super::{
auth::{bearer_client_api_key, client_access_error_response},
error::{missing_client_api_key_response, openai_error_response},
};
pub(super) fn router() -> Router<ApiState> {
Router::new()
.route("/v1/usage", get(usage))
.layer(middleware::map_response(no_store))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct UsageQuery {}
async fn usage(
State(state): State<ApiState>,
headers: HeaderMap,
uri: axum::http::Uri,
) -> Response {
let key = match bearer_client_api_key(&headers) {
Ok(key) => key,
Err(error) => return client_access_error_response(error.into()),
};
// 范围只能来自 Bearer 身份,不接受调用者指定 Key、账号或时间窗口。
if Query::<UsageQuery>::try_from_uri(&uri).is_err() {
return openai_error_response(
StatusCode::BAD_REQUEST,
"Usage query does not accept query parameters",
"invalid_request_error",
"invalid_usage_query",
)
.into_response();
}
let budget = match state.admin_services().key_usage().budget(key).await {
Ok(Some(budget)) => budget,
Ok(None) => return missing_client_api_key_response().into_response(),
Err(_) => {
return openai_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Key usage is temporarily unavailable",
"server_error",
"usage_unavailable",
)
.into_response();
}
};
Json(json!({
"unit": "USD",
"daily": window(budget.limits.daily_usd, budget.daily_used_usd, budget.daily_resets_at),
"weekly": window(budget.limits.weekly_usd, budget.weekly_used_usd, budget.weekly_resets_at),
}))
.into_response()
}
fn window(total: Decimal, used: Decimal, resets_at: Option<SystemTime>) -> Value {
let limited = total != Decimal::ZERO;
json!({
"total": limited.then(|| total.canonical()),
"used": used.canonical(),
"remaining": limited.then(|| total.saturating_sub(used).canonical()),
"resetsAt": resets_at.map(DateTime::<Utc>::from),
})
}
async fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
@@ -433,6 +433,7 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
.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(),
@@ -586,3 +587,20 @@ fn profile_override_distinguishes_omission_from_explicit_inheritance() {
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());
}
@@ -521,6 +521,7 @@ impl SettingsStore for MemorySettingsStore {
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),
@@ -888,10 +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(),
@@ -1435,6 +1441,7 @@ 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"),
@@ -169,6 +169,7 @@ 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(),
@@ -631,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()),
@@ -701,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"],
@@ -954,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(),
@@ -962,6 +966,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
provider_account_ref: Some("acct_snapshot".to_owned()),
provider_account_name: Some("Snapshot Alpha".to_owned()),
provider_account_email: Some("alpha@example.invalid".to_owned()),
provider_account_notes: Some("Team workspace".to_owned()),
provider_account_authentication_kind: Some("oauth".to_owned()),
upstream_model_id: Some("grok-4.5".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
@@ -980,6 +985,7 @@ 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"),
@@ -1051,6 +1057,16 @@ 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]["accountNotes"], "Team workspace");
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"
@@ -103,6 +103,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
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"),
@@ -148,6 +149,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
value,
json!({
"openaiClientProfile": null,
"xaiClientProfile": null,
"requestLocationEnabled": false,
"requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"},
"modelMappings": {
@@ -202,6 +204,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
.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"),
@@ -251,6 +254,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
.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);
@@ -640,14 +644,15 @@ async fn settings_reject_removed_global_fast_policy() {
#[test]
fn global_profile_can_be_omitted_but_cannot_be_cleared() {
let mut body = update_body();
body.as_object_mut().unwrap().remove("openaiClientProfile");
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_ok());
body["openaiClientProfile"] = json!(null);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_err());
body["openaiClientProfile"] =
json!({"client":"cli", "platform":"linux", "versionMode":"latest"});
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_ok());
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 {
@@ -4,14 +4,15 @@ use async_trait::async_trait;
use axum::{
Router,
body::{Body, to_bytes},
http::{Request, header},
http::{Request, StatusCode, header},
};
use chrono::Utc;
use futures::stream;
use gateway_admin::{
model::system::{
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel,
SystemUpdateStatus, SystemVersion,
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdateStatus,
SystemVersion,
},
ports::system::{SystemOperationError, SystemOperations, SystemUpdateEventStream},
};
@@ -67,6 +68,51 @@ async fn update_event_stream_should_preserve_event_id_and_download_progress_perc
);
}
#[tokio::test]
async fn update_should_return_accepted_and_report_restart_only_through_status() {
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem)).await;
fixture.auth.insert_session("valid-session");
let router = app(fixture.state());
let response = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/system/update")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_system_update")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"targetVersion":"0.2.0"}"#))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.expect("body"))
.expect("JSON");
assert_eq!(body["data"]["operationId"], "update-1");
assert_eq!(body["data"]["targetVersion"], "0.2.0");
assert!(body["data"].get("needRestart").is_none());
let response = router
.oneshot(
Request::builder()
.uri("/api/admin/system/update/status")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_system_update")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.expect("body"))
.expect("JSON");
assert_eq!(body["data"]["operation"]["status"], "succeeded");
assert_eq!(body["data"]["needRestart"], true);
}
fn app(state: AdminTestState) -> Router {
system::router::<AdminTestState>().with_state(state)
}
@@ -100,13 +146,32 @@ impl SystemOperations for ProgressSystem {
async fn perform_update(
&self,
_: Option<String>,
target: Option<String>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
Ok(SystemOperationAccepted::Update {
operation_id: "update-1".to_owned(),
deployment_mode: "docker".to_owned(),
message: "更新已开始".to_owned(),
target_version: target.expect("target"),
})
}
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
Err(unavailable_system())
Ok(SystemUpdateStatus {
previous_version: Some("0.1.0".to_owned()),
current_version: Some("0.2.0".to_owned()),
need_restart: true,
operation: SystemOperationState {
operation_id: Some("update-1".to_owned()),
kind: Some(SystemOperationKind::Update),
status: SystemOperationStatus::Succeeded,
target_version: Some("0.2.0".to_owned()),
message: Some("done".to_owned()),
error: None,
started_at: Some(Utc::now()),
finished_at: Some(Utc::now()),
},
})
}
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
@@ -81,6 +81,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/openai/search/http.rs",
"src/openai/search/mod.rs",
"src/openai/service.rs",
"src/openai/usage.rs",
"src/session_cookie.rs",
];
expected.sort_unstable();
@@ -132,6 +133,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/openai/responses/websocket/protocol.rs",
"tests/openai/router.rs",
"tests/openai/search/mod.rs",
"tests/openai/usage.rs",
];
expected.sort_unstable();
@@ -18,6 +18,7 @@ pub(super) async fn fixture() -> AdminTestFixture {
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()),
@@ -90,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(),
@@ -98,6 +100,7 @@ fn usage_record() -> UsageListRecord {
provider_account_ref: Some("private-sentinel".to_owned()),
provider_account_name: Some("private-sentinel".to_owned()),
provider_account_email: Some("private-sentinel".to_owned()),
provider_account_notes: Some("private-sentinel".to_owned()),
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
@@ -146,6 +149,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 matches!(resource, "config" | "version") {
format!("/api/key-usage/{resource}{suffix}")
} else {
format!("/api/key-usage/{resource}?{RANGE}{suffix}")
};
let response = app
.clone()
.oneshot(cookie_request(Method::GET, &path, cookie))
@@ -50,6 +54,99 @@ async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Respon
response
}
#[tokio::test]
async fn version_exposes_only_build_identifiers_for_key_sessions() {
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, "version", "", &cookie).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response_json(response).await["data"],
json!({"version": "3.7.0", "gitSha": "internal-revision"})
);
assert_eq!(
get(&app, "version", "?keyId=other", &cookie).await.status(),
StatusCode::BAD_REQUEST
);
let response = app
.clone()
.oneshot(cookie_request(
Method::GET,
"/api/admin/system/version",
&cookie,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[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 +315,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", "version"] {
assert_eq!(
get(&app, resource, "", "").await.status(),
StatusCode::UNAUTHORIZED
@@ -352,6 +449,16 @@ 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,
),
(
Method::POST,
"/api/key-usage/version",
StatusCode::METHOD_NOT_ALLOWED,
),
] {
let response = app
.clone()
@@ -446,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(
@@ -6,6 +6,7 @@ mod models;
mod responses;
mod router;
mod search;
mod usage;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
@@ -521,3 +521,20 @@ async fn initial_quota_recovery_delivers_client_projection_instead_of_upstream_s
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");
}
@@ -0,0 +1,189 @@
use axum::{
Router,
http::{Method, StatusCode, header},
response::Response,
};
use chrono::{Duration, Utc};
use gateway_core::{
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
policy::ClientApiKeyId,
};
use serde_json::json;
use tower::ServiceExt as _;
use crate::{
admin::AdminTestFixture,
support::{RAW_KEY, empty_request, key_fixture, response_json},
};
const KEY: &str = RAW_KEY;
async fn fixture() -> (AdminTestFixture, Router) {
let fixture = key_fixture().await;
let id = ClientApiKeyId::new("key-42").unwrap();
let mut key = fixture
.services
.client_keys()
.reveal(&id)
.await
.unwrap()
.record;
key.id = id;
key.label = Some("private-usage-sentinel".to_owned());
key.budget = ClientBudgetStatus {
limits: ClientBudgetLimits {
daily_usd: "1".parse().unwrap(),
weekly_usd: "5".parse().unwrap(),
},
daily_used_usd: "0.6400000001".parse().unwrap(),
weekly_used_usd: "2.35".parse().unwrap(),
daily_resets_at: Some((Utc::now() + Duration::days(1)).into()),
weekly_resets_at: Some((Utc::now() + Duration::days(7)).into()),
};
*fixture.client_key.lock().unwrap() = Some(key);
let app = super::api_router_with_admin(fixture.services.clone());
(fixture, app)
}
async fn query(app: &Router, path: &str, authorization: Option<&str>) -> Response {
let mut request = empty_request(Method::GET, path);
if let Some(value) = authorization {
request
.headers_mut()
.insert(header::AUTHORIZATION, value.parse().unwrap());
}
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
response
}
#[tokio::test]
async fn usage_returns_current_key_budget_without_exposing_private_data_or_writing_usage() {
let (fixture, app) = fixture().await;
let before = fixture.client_key.lock().unwrap().clone().unwrap();
let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(
body,
json!({
"unit": "USD",
"daily": {
"total": "1", "used": "0.6400000001", "remaining": "0.3599999999",
"resetsAt": before.budget.daily_resets_at.map(chrono::DateTime::<Utc>::from),
},
"weekly": {
"total": "5", "used": "2.35", "remaining": "2.65",
"resetsAt": before.budget.weekly_resets_at.map(chrono::DateTime::<Utc>::from),
},
})
);
assert!(!body.to_string().contains("private-usage-sentinel"));
assert_eq!(fixture.client_key.lock().unwrap().as_ref(), Some(&before));
assert!(fixture.observations.lock().unwrap().summaries.is_empty());
}
#[tokio::test]
async fn usage_remains_readable_when_exhausted_and_never_returns_negative_remaining() {
let (fixture, app) = fixture().await;
{
let mut key = fixture.client_key.lock().unwrap();
let budget = &mut key.as_mut().unwrap().budget;
budget.daily_used_usd = "1".parse().unwrap();
budget.weekly_used_usd = "5.75".parse().unwrap();
}
let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["daily"]["remaining"], "0");
assert_eq!(body["weekly"]["remaining"], "0");
assert_eq!(body["weekly"]["used"], "5.75");
}
#[tokio::test]
async fn usage_distinguishes_unlimited_and_unused_windows_from_exhausted_budgets() {
let (fixture, app) = fixture().await;
for (daily, weekly) in [("0", "0"), ("1", "0"), ("0", "5")] {
fixture.client_key.lock().unwrap().as_mut().unwrap().budget = ClientBudgetStatus {
limits: ClientBudgetLimits {
daily_usd: daily.parse().unwrap(),
weekly_usd: weekly.parse().unwrap(),
},
..Default::default()
};
let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
for (period, limit) in [("daily", daily), ("weekly", weekly)] {
assert_eq!(
body[period],
json!({
"total": (limit != "0").then_some(limit),
"used": "0",
"remaining": (limit != "0").then_some(limit),
"resetsAt": null,
})
);
}
}
}
#[tokio::test]
async fn usage_rejects_missing_invalid_disabled_deleted_and_other_keys() {
let (fixture, app) = fixture().await;
for authorization in [None, Some("Basic invalid"), Some("Bearer unknown-key")] {
let response = query(&app, "/v1/usage", authorization).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(response_json(response).await.get("error").is_some());
}
let original = fixture.client_key.lock().unwrap().clone().unwrap();
for mode in ["disabled", "deleted", "other"] {
{
let mut key = fixture.client_key.lock().unwrap();
*key = Some(original.clone());
match mode {
"disabled" => key.as_mut().unwrap().enabled = false,
"deleted" => *key = None,
_ => key.as_mut().unwrap().id = ClientApiKeyId::new("other-key").unwrap(),
}
}
let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{mode}");
}
}
#[tokio::test]
async fn usage_does_not_accept_sessions_admin_keys_or_caller_selected_scope() {
let (fixture, app) = fixture().await;
fixture.auth.insert_session("valid-admin");
for (name, value) in [
(header::COOKIE.as_str(), "cpr_session=valid-admin"),
("x-api-key", KEY),
] {
let mut request = empty_request(Method::GET, "/v1/usage");
request.headers_mut().insert(
axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
value.parse().unwrap(),
);
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
for suffix in [
"?keyId=other",
"?clientApiKeyRef=other",
"?startTime=2026-01-01",
"?api_key=test",
] {
let response = query(
&app,
&format!("/v1/usage{suffix}"),
Some(&format!("Bearer {KEY}")),
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
response_json(response).await["error"]["code"],
"invalid_usage_query"
);
}
}
@@ -881,6 +881,7 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
@@ -897,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()
@@ -919,6 +921,7 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
@@ -1447,6 +1450,7 @@ where
error.kind(),
ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::QuotaExhausted
)
{
return false;
@@ -39,6 +39,12 @@ impl Decimal {
self.0
}
/// 非负金额相减,超支时返回零。
#[must_use]
pub const fn saturating_sub(self, other: Self) -> Self {
Self(self.0.saturating_sub(other.0))
}
#[must_use]
pub fn checked_add(self, other: Self) -> Option<Self> {
self.0
@@ -304,6 +310,8 @@ pub struct CalculatedCost {
/// 随本地费用事件保存,以免后续改价改变历史明细。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedCostBreakdown {
// Provider 选中的价格区间事实,与服务档位和自定义倍率独立。
long_context_billing_applied: bool,
image: Option<ImageCostBreakdown>,
input_amount: Money,
output_amount: Money,
@@ -393,6 +401,7 @@ impl CalculatedCostBreakdown {
multiplier_percent: u32,
) -> Self {
Self {
long_context_billing_applied: false,
input_amount: amounts.input,
image: None,
output_amount: amounts.output,
@@ -410,6 +419,17 @@ impl CalculatedCostBreakdown {
}
}
#[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);
@@ -1888,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]
@@ -3671,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,
] {
@@ -3942,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]
@@ -10,6 +10,7 @@ mod swap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
@@ -32,11 +33,12 @@ use self::archive::extract_release;
use self::download::{MAX_CHECKSUM_SIZE, MAX_DOWNLOAD_SIZE, download_file, verify_checksum};
use self::process::{environment_value, spawn_replacement};
use self::release::{
ReleaseCache, confirmed_target, detail_from_release, fetch_latest, select_archive,
versions_cross_major,
ReleaseCache, UpdateChannel, confirmed_target, detail_from_release, fetch_latest,
select_archive, validate_update_target, version_channel,
};
use self::state::{
OperationFileLock, UpdateTempDir, finish, operation_id, read_status, set_running,
OperationFileLock, UpdateOperation, UpdateTempDir, finish, operation_id, read_status,
recover_interrupted, set_running,
};
use self::swap::{replace_release_files, rollback_release};
@@ -57,7 +59,6 @@ pub struct SystemUpdateConfig {
pub build_time: String,
pub deployment_mode: String,
pub build_type: String,
pub update_channel: String,
pub update_repository: Option<String>,
pub github_api_base: String,
pub executable_path: Option<PathBuf>,
@@ -98,8 +99,6 @@ impl Default for SystemUpdateConfig {
.to_owned(),
deployment_mode,
build_type: option_env!("CPR_BUILD_TYPE").unwrap_or("source").to_owned(),
update_channel: environment_value("CPR_UPDATE_CHANNEL")
.unwrap_or_else(|| "stable".to_owned()),
update_repository: Some(
environment_value("CPR_UPDATE_REPOSITORY")
.unwrap_or_else(|| DEFAULT_UPDATE_REPOSITORY.to_owned()),
@@ -158,17 +157,18 @@ impl SystemUpdateConfig {
"host.system_update.deployment_mode",
));
}
if !matches!(self.update_channel.as_str(), "stable" | "preview") {
return Err(ConfigError::InvalidField(
"host.system_update.update_channel",
));
}
Ok(())
}
fn update_support_error(&self) -> Option<String> {
if self.build_type != "release" {
return Some("一键更新需要正式构建包".to_owned());
if !matches!(self.build_type.as_str(), "release" | "experimental") {
return Some("在线更新需要官方发布构建".to_owned());
}
let Some(channel) = version_channel(&self.version) else {
return Some("当前版本不符合发行命名规范,无法确定更新通道".to_owned());
};
if self.build_type == "experimental" && channel != UpdateChannel::Experimental {
return Some("实验构建必须使用 exp.N 版本,无法在线更新".to_owned());
}
let Some(repository) = self.update_repository.as_deref() else {
return Some("检查更新需要配置 CPR_UPDATE_REPOSITORY".to_owned());
@@ -187,13 +187,12 @@ impl SystemUpdateConfig {
fn release_cache_key(&self) -> String {
format!(
"{}|{}|{}|{}|{}|{}",
"{}|{}|{}|{}|{}",
self.update_repository.as_deref().unwrap_or_default(),
self.github_api_base,
self.version,
self.deployment_mode,
self.build_type,
self.update_channel,
)
}
@@ -215,12 +214,13 @@ impl SystemUpdateConfig {
}
/// gateway-admin 消费的真实进程/文件系统操作实现。
#[derive(Clone)]
pub struct ProcessSystemOperations {
cancellation: CancellationToken,
events: UpdateEvents,
config: SystemUpdateConfig,
operation_lock: AsyncMutex<()>,
release_cache: ReleaseCache,
events: Arc<UpdateEvents>,
config: Arc<SystemUpdateConfig>,
operation_lock: Arc<AsyncMutex<()>>,
release_cache: Arc<ReleaseCache>,
}
impl ProcessSystemOperations {
@@ -228,110 +228,117 @@ impl ProcessSystemOperations {
pub fn new(cancellation: CancellationToken, config: SystemUpdateConfig) -> Self {
Self {
cancellation,
events: UpdateEvents::default(),
config,
operation_lock: AsyncMutex::const_new(()),
release_cache: ReleaseCache::default(),
events: Arc::new(UpdateEvents::default()),
config: Arc::new(config),
operation_lock: Arc::new(AsyncMutex::new(())),
release_cache: Arc::new(ReleaseCache::default()),
}
}
async fn perform_update_inner(
fn start_update(
&self,
target_version: Option<String>,
) -> Result<SystemOperationAccepted, OperationError> {
let _operation = self
.operation_lock
.try_lock()
let operation_lock = Arc::clone(&self.operation_lock)
.try_lock_owned()
.map_err(|_| conflict("system operation is already running"))?;
if self.cancellation.is_cancelled() {
return Err(conflict("服务正在关闭"));
}
if let Some(reason) = self.config.update_support_error() {
self.events
.error_terminal(None, Some("preflight"), reason.clone());
return Err(conflict(reason));
}
let target = confirmed_target(target_version)?;
if versions_cross_major(&self.config.version, &target).unwrap_or(false) {
let reason = "跨大版本升级不提供自动更新,请按发布说明手动迁移";
self.events.error_terminal(None, Some("preflight"), reason);
return Err(conflict(reason));
if let Err(error) = validate_update_target(&self.config.version, &target) {
self.events
.error_terminal(None, Some("preflight"), error.to_string());
return Err(error);
}
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
let status = read_status(&self.config.update_state_file, &self.config.version)?;
if status.need_restart {
return Err(conflict("更新已完成,请先重启服务"));
}
let operation_id = operation_id("update");
let mut operation = UpdateOperation::start(
&self.config.update_state_file,
&operation_id,
&target,
&self.config.version,
file_lock,
)?;
let accepted = SystemOperationAccepted::Update {
operation_id: operation_id.clone(),
deployment_mode: self.config.deployment_mode.clone(),
message: "更新已开始".to_owned(),
target_version: target.clone(),
};
let service = self.clone();
// 任务持有互斥锁和落盘守卫,HTTP 断开不会取消更新;Host 关闭仍会收敛终态。
drop(tokio::spawn(async move {
let _operation_lock = operation_lock;
let result = tokio::select! {
biased;
() = service.cancellation.cancelled() => Err(conflict("服务关闭,更新已中断")),
result = service.perform_update_inner(&target, &operation_id) => result,
};
if let Err(error) = operation.complete(&result) {
tracing::warn!(error = %error, "系统更新终态落盘失败");
return;
}
match result {
Ok(()) => service.events.success_terminal(
Some(&operation_id),
Some("done"),
"更新文件已替换,等待服务重启生效",
),
Err(error) => service.events.error_terminal(
Some(&operation_id),
Some("failed"),
error.to_string(),
),
}
}));
Ok(accepted)
}
async fn perform_update_inner(
&self,
target: &str,
operation_id: &str,
) -> Result<(), OperationError> {
let repository = self
.config
.update_repository
.as_deref()
.ok_or_else(|| conflict("update repository is not configured"))?;
self.events
.info(None, Some("release"), "正在获取最新 Release 信息");
let release = match fetch_latest(
self.events.info(
Some(operation_id),
Some("release"),
"正在获取最新 Release 信息",
);
let release = fetch_latest(
&self.config.github_api_base,
repository,
&self.config.update_channel,
&self.config.version,
)
.await
{
Ok(release) => release,
Err(error) => {
self.events
.error_terminal(None, Some("release"), error.to_string());
return Err(error);
}
};
.await?
.ok_or_else(|| conflict("当前发行通道没有可用更新"))?;
let detail = detail_from_release(&self.config, &release);
if detail.latest_version != target {
self.events.warning_terminal(
None,
Some("release"),
"remote latest version changed; confirm again",
);
return Err(conflict("remote latest version changed"));
return Err(conflict("远端最新版本已变化,请重新确认"));
}
if !detail.has_update {
self.events
.warning_terminal(None, Some("release"), "already up to date");
return Err(conflict("already up to date"));
return Err(conflict("当前没有可用更新"));
}
let operation_id = operation_id("update");
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
set_running(
&self.config.update_state_file,
&operation_id,
SystemOperationKind::Update,
Some(&target),
&self.config.version,
)?;
self.events.info(
Some(&operation_id),
Some(operation_id),
Some("prepare"),
format!("准备更新到 v{target}"),
);
let result = self.install_release(&release, &target, &operation_id).await;
match &result {
Ok(()) => self.events.success_terminal(
Some(&operation_id),
Some("done"),
"更新文件已替换,等待服务重启生效",
),
Err(error) => {
self.events
.error_terminal(Some(&operation_id), Some("failed"), error.to_string())
}
}
finish(
&self.config.update_state_file,
&operation_id,
SystemOperationKind::Update,
result.as_ref().ok().map(|()| target.clone()),
result.as_ref().err().map(ToString::to_string),
);
drop(file_lock);
result?;
Ok(SystemOperationAccepted::Update {
operation_id,
deployment_mode: self.config.deployment_mode.clone(),
message: "更新完成,请重启服务。".to_owned(),
need_restart: true,
target_version: target,
})
self.install_release(&release, target, operation_id).await
}
async fn install_release(
@@ -428,7 +435,9 @@ impl SystemOperations for ProcessSystemOperations {
git_sha: self.config.git_sha.clone(),
build_time: self.config.build_time.clone(),
deployment_mode: self.config.deployment_mode.clone(),
update_channel: self.config.update_channel.clone(),
update_channel: version_channel(&self.config.version)
.map(|channel| channel.label().to_owned())
.unwrap_or_else(|| "unknown".to_owned()),
latest_version: detail.latest_version,
has_update: detail.has_update,
update_cached: detail.cached,
@@ -470,11 +479,17 @@ impl SystemOperations for ProcessSystemOperations {
&self,
target_version: Option<String>,
) -> Result<SystemOperationAccepted, OperationError> {
self.perform_update_inner(target_version).await
self.start_update(target_version)
}
async fn update_status(&self) -> Result<SystemUpdateStatus, OperationError> {
read_status(&self.config.update_state_file)
if let Ok(_operation) = self.operation_lock.try_lock() {
recover_interrupted(
&self.config.update_state_file,
&self.config.update_lock_file,
)?;
}
read_status(&self.config.update_state_file, &self.config.version)
}
async fn rollback(&self) -> Result<SystemOperationAccepted, OperationError> {
@@ -495,6 +510,13 @@ impl SystemOperations for ProcessSystemOperations {
&self.config.version,
)?;
let result = rollback_release(&self.config);
finish(
&self.config.update_state_file,
&operation_id,
SystemOperationKind::Rollback,
None,
result.as_ref().err().map(ToString::to_string),
)?;
match &result {
Ok(()) => self.events.success_terminal(
Some(&operation_id),
@@ -506,13 +528,6 @@ impl SystemOperations for ProcessSystemOperations {
.error_terminal(Some(&operation_id), Some("failed"), error.to_string())
}
}
finish(
&self.config.update_state_file,
&operation_id,
SystemOperationKind::Rollback,
None,
result.as_ref().err().map(ToString::to_string),
);
drop(file_lock);
result?;
Ok(SystemOperationAccepted::Rollback {
@@ -613,22 +628,6 @@ impl UpdateEvents {
);
}
fn warning_terminal(
&self,
operation_id: Option<&str>,
step: Option<&str>,
message: impl Into<String>,
) {
self.emit(
SystemUpdateEventLevel::Warning,
operation_id,
step,
message,
None,
true,
);
}
fn success_terminal(
&self,
operation_id: Option<&str>,
@@ -12,6 +12,27 @@ use super::{OperationError, SystemUpdateConfig, conflict, invalid, upstream};
const APP_BINARY_NAME: &str = "codex-proxy-rs";
const CACHE_TTL: Duration = Duration::from_secs(20 * 60);
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum UpdateChannel {
Stable,
Alpha,
Beta,
Rc,
Experimental,
}
impl UpdateChannel {
pub(crate) fn label(&self) -> &str {
match self {
Self::Stable => "stable",
Self::Alpha => "alpha",
Self::Beta => "beta",
Self::Rc => "rc",
Self::Experimental => "exp",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct GitHubRelease {
pub(crate) tag_name: String,
@@ -49,6 +70,9 @@ impl ReleaseCache {
config: &SystemUpdateConfig,
refresh: bool,
) -> Result<SystemUpdateDetail, OperationError> {
if let Some(reason) = config.update_support_error() {
return Ok(super::base_update_detail(config, Some(reason), None));
}
let repository = config
.update_repository
.as_deref()
@@ -59,9 +83,12 @@ impl ReleaseCache {
if !refresh && let Some(detail) = self.cached(&key).await {
return Ok(detail);
}
match fetch_latest(&config.github_api_base, repository, &config.update_channel).await {
match fetch_latest(&config.github_api_base, repository, &config.version).await {
Ok(release) => {
let detail = detail_from_release(config, &release);
let detail = release.as_ref().map_or_else(
|| super::base_update_detail(config, config.update_support_error(), None),
|release| detail_from_release(config, release),
);
*self.entry.lock().await = Some(CachedRelease {
key,
detail: detail.clone(),
@@ -69,7 +96,11 @@ impl ReleaseCache {
});
Ok(detail)
}
Err(error) => self.cached(&key).await.ok_or(error),
Err(error) => {
// 强制检查失败后清掉旧结果,避免后续版本查询再次显示旧的更新标记。
*self.entry.lock().await = None;
Err(error)
}
}
}
@@ -84,35 +115,66 @@ impl ReleaseCache {
}
}
/// 优先返回可升级的最高版本,没有候选时保留当前版本的发布信息。
pub(crate) async fn fetch_latest(
api_base: &str,
repository: &str,
update_channel: &str,
) -> Result<GitHubRelease, OperationError> {
current_version: &str,
) -> Result<Option<GitHubRelease>, OperationError> {
validate_api_base(api_base).map_err(conflict)?;
validate_repository(repository)?;
let base = api_base.trim_end_matches('/');
// GitHub 的 /releases/latest 永远只返回正式版;preview 渠道需要覆盖
// prerelease,因此改查列表端点并取最新一条非草稿 Release。
if update_channel == "preview" {
let releases: Vec<GitHubRelease> =
fetch_release_json(&format!("{base}/{repository}/releases?per_page=10")).await?;
return releases
.into_iter()
.find(|release| !release.draft)
.ok_or_else(|| upstream("no published release available"));
}
fetch_release_json(&format!("{base}/{repository}/releases/latest")).await
}
async fn fetch_release_json<T: serde::de::DeserializeOwned>(
url: &str,
) -> Result<T, OperationError> {
let current = semver::Version::parse(&normalize_version(current_version))
.map_err(|_| invalid("current version is invalid"))?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| upstream(format!("failed to create release client: {error}")))?;
// 所有发行线先过滤再排序,避免更高大版本或其他实验遮住仍可安装的更新。
let mut latest: Option<(semver::Version, GitHubRelease)> = None;
let mut page = 1;
loop {
let releases: Vec<GitHubRelease> = fetch_release_json(
&client,
&format!("{base}/{repository}/releases?per_page=100&page={page}"),
)
.await?;
let last_page = releases.len() < 100;
for release in releases {
let Some(version) = eligible_release_version(&release, &current) else {
continue;
};
if latest
.as_ref()
.is_none_or(|(previous, _)| version.cmp_precedence(previous).is_gt())
{
latest = Some((version, release));
}
}
if last_page {
return Ok(latest.map(|(_, release)| release));
}
page += 1;
}
}
fn eligible_release_version(
release: &GitHubRelease,
current: &semver::Version,
) -> Option<semver::Version> {
let version = semver::Version::parse(&normalize_version(&release.tag_name)).ok()?;
(!release.draft
&& release.prerelease != version.pre.is_empty()
&& (version == *current || update_target_allowed(current, &version)))
.then_some(version)
}
async fn fetch_release_json<T: serde::de::DeserializeOwned>(
client: &reqwest::Client,
url: &str,
) -> Result<T, OperationError> {
let response = client
.get(url)
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
@@ -136,24 +198,26 @@ pub(crate) fn detail_from_release(
config: &SystemUpdateConfig,
release: &GitHubRelease,
) -> SystemUpdateDetail {
let latest = normalize_version(&release.tag_name);
let newer = (config.update_channel != "stable" || !release.prerelease)
&& version_is_newer(&config.version, &latest).unwrap_or(false);
let cross_major = versions_cross_major(&config.version, &latest).unwrap_or(false);
let unsupported_reason = config.update_support_error();
let latest_version = normalize_version(&release.tag_name);
let has_update = validate_update_target(&config.version, &latest_version).is_ok();
if unsupported_reason.is_some()
|| (!has_update && latest_version != normalize_version(&config.version))
{
return super::base_update_detail(config, unsupported_reason, None);
}
SystemUpdateDetail {
current_version: config.version.clone(),
latest_version: latest.clone(),
has_update: newer && !cross_major,
latest_version,
has_update,
deployment_mode: config.deployment_mode.clone(),
build_type: config.build_type.clone(),
release_url: release.html_url.clone(),
notes: release.body.clone().or_else(|| release.name.clone()),
cached: false,
update_supported: config.update_support_error().is_none(),
unsupported_reason: config.update_support_error(),
warning: (newer && cross_major).then(|| {
format!("检测到新的大版本 v{latest}:跨大版本升级不提供自动更新,请按发布说明手动迁移")
}),
update_supported: true,
unsupported_reason: None,
warning: None,
}
}
@@ -278,17 +342,68 @@ fn normalize_version(version: &str) -> String {
version.trim().trim_start_matches('v').to_owned()
}
fn version_is_newer(current: &str, latest: &str) -> Option<bool> {
let current = semver::Version::parse(&normalize_version(current)).ok()?;
let latest = semver::Version::parse(&normalize_version(latest)).ok()?;
Some(latest > current)
fn channel_for_version(version: &semver::Version) -> Option<UpdateChannel> {
if version.pre.is_empty() {
return Some(UpdateChannel::Stable);
}
let (name, sequence) = version.pre.as_str().split_once('.')?;
if sequence.is_empty() || sequence == "0" || !sequence.bytes().all(|byte| byte.is_ascii_digit())
{
return None;
}
match name {
"alpha" => Some(UpdateChannel::Alpha),
"beta" => Some(UpdateChannel::Beta),
"rc" => Some(UpdateChannel::Rc),
"exp" => Some(UpdateChannel::Experimental),
_ => None,
}
}
/// 自动更新只在同 major 内提供;跨大版本的配置与数据迁移契约不保证兼容。
pub(crate) fn versions_cross_major(current: &str, latest: &str) -> Option<bool> {
let current = semver::Version::parse(&normalize_version(current)).ok()?;
let latest = semver::Version::parse(&normalize_version(latest)).ok()?;
Some(current.major != latest.major)
pub(crate) fn version_channel(version: &str) -> Option<UpdateChannel> {
let version = semver::Version::parse(&normalize_version(version)).ok()?;
channel_for_version(&version)
}
/// 检查更新与执行更新共用同一规则;构建元数据不构成更新。
fn update_target_allowed(current: &semver::Version, target: &semver::Version) -> bool {
use UpdateChannel::{Alpha, Beta, Experimental, Rc, Stable};
if current.major != target.major || !target.cmp_precedence(current).is_gt() {
return false;
}
let (Some(current_channel), Some(target_channel)) =
(channel_for_version(current), channel_for_version(target))
else {
return false;
};
if current_channel == Stable {
return target_channel == Stable;
}
// 预发行只承接本轮目标版本;exp 的相同基线也只代表同一轮实验。
if (current.minor, current.patch) != (target.minor, target.patch) {
return false;
}
matches!(
(current_channel, target_channel),
(Alpha, Alpha | Beta | Rc | Stable)
| (Beta, Beta | Rc | Stable)
| (Rc, Rc | Stable)
| (Experimental, Experimental)
)
}
pub(crate) fn validate_update_target(current: &str, target: &str) -> Result<(), OperationError> {
let current = semver::Version::parse(&normalize_version(current))
.map_err(|_| conflict("当前版本格式无效"))?;
let target = semver::Version::parse(&normalize_version(target))
.map_err(|_| invalid("target version is invalid"))?;
if !update_target_allowed(&current, &target) {
return Err(conflict(
"当前版本不允许更新到此目标:不支持跨通道、跨发行线、跨大版本或降级",
));
}
Ok(())
}
fn repository_character(character: char) -> bool {
@@ -3,6 +3,7 @@
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use chrono::{DateTime, Utc};
@@ -154,8 +155,101 @@ impl Drop for UpdateTempDir {
}
}
/// 更新任务拥有持久化终态的责任,Future 在取消或 panic 时析构也必须收尾。
pub(crate) struct UpdateOperation {
path: PathBuf,
operation_id: String,
target: String,
completed: bool,
_file_lock: OperationFileLock,
}
impl UpdateOperation {
pub(crate) fn start(
path: &Path,
operation_id: &str,
target: &str,
current_version: &str,
file_lock: OperationFileLock,
) -> Result<Self, OperationError> {
set_running(
path,
operation_id,
SystemOperationKind::Update,
Some(target),
current_version,
)?;
Ok(Self {
path: path.to_owned(),
operation_id: operation_id.to_owned(),
target: target.to_owned(),
completed: false,
_file_lock: file_lock,
})
}
pub(crate) fn complete(
&mut self,
result: &Result<(), OperationError>,
) -> Result<(), OperationError> {
finish(
&self.path,
&self.operation_id,
SystemOperationKind::Update,
result.as_ref().ok().map(|()| self.target.clone()),
result.as_ref().err().map(ToString::to_string),
)?;
self.completed = true;
Ok(())
}
}
impl Drop for UpdateOperation {
fn drop(&mut self) {
if !self.completed
&& let Err(error) = finish(
&self.path,
&self.operation_id,
SystemOperationKind::Update,
None,
Some("更新任务已中断,请重新发起更新".to_owned()),
)
{
tracing::warn!(error = %error, "收敛中断的系统更新状态失败");
}
}
}
/// 只在拿到进程内锁后调用,旧版本断连遗留且已无执行者的 running 不能永久保留。
pub(crate) fn recover_interrupted(path: &Path, lock_path: &Path) -> Result<(), OperationError> {
let state = read_persisted(path)?;
if !matches!(state.operation.status, PersistedStatus::Running) {
return Ok(());
}
let _lock = match OperationFileLock::acquire(lock_path) {
Ok(lock) => lock,
Err(error) if error.kind() == super::SystemOperationErrorKind::Conflict => return Ok(()),
Err(error) => return Err(error),
};
if let (Some(operation_id), Some(kind)) = (state.operation.operation_id, state.operation.kind) {
finish(
path,
&operation_id,
kind.into(),
None,
Some("更新任务已中断,请重新发起更新".to_owned()),
)?;
}
Ok(())
}
pub(crate) fn operation_id(kind: &str) -> String {
format!("sysop-{kind}-{}", Utc::now().timestamp_millis())
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
format!(
"sysop-{kind}-{}-{}",
Utc::now().timestamp_millis(),
SEQUENCE.fetch_add(1, Ordering::Relaxed)
)
}
pub(crate) fn set_running(
@@ -188,16 +282,10 @@ pub(crate) fn finish(
kind: SystemOperationKind,
version: Option<String>,
error: Option<String>,
) {
let mut state = match read_persisted(path) {
Ok(state) => state,
Err(error) => {
tracing::warn!(error = %error, "读取系统更新状态失败");
return;
}
};
) -> Result<(), OperationError> {
let mut state = read_persisted(path)?;
if state.operation.operation_id.as_deref() != Some(operation_id) {
return;
return Ok(());
}
if let Some(error) = error {
state.operation.status = PersistedStatus::Failed;
@@ -222,15 +310,20 @@ pub(crate) fn finish(
state.operation.target_version = version;
}
state.operation.finished_at = Some(Utc::now().to_rfc3339());
if let Err(error) = write_persisted(path, &state) {
tracing::warn!(error = %error, "写入系统更新状态失败");
}
write_persisted(path, &state)
}
pub(crate) fn read_status(path: &Path) -> Result<SystemUpdateStatus, OperationError> {
pub(crate) fn read_status(
path: &Path,
running_version: &str,
) -> Result<SystemUpdateStatus, OperationError> {
let state = read_persisted(path)?;
let operation = state.operation;
Ok(SystemUpdateStatus {
need_restart: matches!(operation.status, PersistedStatus::Succeeded)
&& state.current_version.as_deref().is_some_and(|version| {
version.trim_start_matches('v') != running_version.trim_start_matches('v')
}),
previous_version: state.previous_version,
current_version: state.current_version,
operation: SystemOperationState {
File diff suppressed because it is too large Load Diff
@@ -87,6 +87,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
let replacement = postgres::ControlPlaneReplacement {
settings: postgres::RuntimeSettingsUpdate {
openai_client_profile: command.openai_client_profile,
xai_client_profile: command.xai_client_profile,
admin_api_key: current.settings.admin_api_key,
refresh_margin_seconds: command.refresh_margin_seconds,
refresh_concurrency: command.refresh_concurrency,
@@ -232,6 +233,7 @@ pub(crate) fn admin_runtime_settings(
.collect::<AdminStoreResult<ModelMappings>>()?;
Ok(AdminRuntimeSettings {
openai_client_profile: settings.openai_client_profile,
xai_client_profile: settings.xai_client_profile,
config_revision: admin_revision(settings.config_revision)?,
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
@@ -112,6 +112,7 @@ impl fmt::Debug for ClientApiKeySecret {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientApiKeyRecord {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: String,
pub name: String,
pub label: Option<String>,
@@ -273,6 +274,7 @@ pub struct ClientApiKeyPage {
#[derive(Clone)]
pub struct NewClientApiKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: String,
pub name: String,
pub label: Option<String>,
@@ -306,6 +308,7 @@ impl NewClientApiKey {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientApiKeyDetails {
pub openai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub xai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub id: String,
pub name: String,
pub label: Option<String>,
@@ -363,6 +366,7 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository {
let total = count_client_api_keys(&self.pool, query.search.as_deref()).await?;
let mut statement = QueryBuilder::<Postgres>::new(
"select k.id, k.name, k.label, k.provider_request_profiles_json -> 'openai' as openai_client_profile_override,
k.provider_request_profiles_json -> 'xai' as xai_client_profile_override,
left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled,
k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at,
k.updated_at, '[]'::jsonb as groups, '{}'::text[] as provider_kinds
@@ -423,6 +427,7 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository {
require_nonempty(ENTITY, "id", id)?;
let record = sqlx::query(
"select k.id, k.name, k.label, k.provider_request_profiles_json -> 'openai' as openai_client_profile_override,
k.provider_request_profiles_json -> 'xai' as xai_client_profile_override,
left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled,
k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at,
k.updated_at, coalesce(groups.groups, '[]'::jsonb) as groups,
@@ -769,6 +774,7 @@ impl ClientKeyStore for PgAdminClientKeyStore {
.create_client_api_key(
NewClientApiKey {
openai_client_profile_override: command.openai_client_profile_override,
xai_client_profile_override: command.xai_client_profile_override,
id: id.as_str().to_owned(),
name: command.name,
label: command.label,
@@ -820,6 +826,7 @@ impl ClientKeyStore for PgAdminClientKeyStore {
.update_client_api_key(
UpdateClientApiKeyDetails {
openai_client_profile_override: command.openai_client_profile_override,
xai_client_profile_override: command.xai_client_profile_override,
id: id.as_str().to_owned(),
name: command.name,
label: command.label,
@@ -978,6 +985,7 @@ fn admin_client_key_cursor(cursor: ClientApiKeyCursor) -> AdminStoreResult<Admin
fn admin_client_key_record(record: ClientApiKeyRecord) -> AdminStoreResult<AdminClientKeyRecord> {
Ok(AdminClientKeyRecord {
openai_client_profile_override: record.openai_client_profile_override,
xai_client_profile_override: record.xai_client_profile_override,
id: ClientApiKeyId::new(record.id)
.map_err(|_| admin_store_error(ENTITY, invalid("invalid client key id")))?,
name: record.name,
@@ -1029,7 +1037,8 @@ pub(crate) async fn insert_client_api_key_in_transaction(
"insert into client_api_keys (
id, name, label, key, enabled, max_concurrency, requests_per_minute,
last_used_at, created_at, updated_at, daily_limit_usd, weekly_limit_usd, provider_request_profiles_json
) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric, case when $9::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $9::jsonb) end)",
) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric, case when $9::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $9::jsonb) end
|| case when $10::jsonb is null then '{}'::jsonb else jsonb_build_object('xai', $10::jsonb) end)",
)
.bind(&key.id)
.bind(key.name.trim())
@@ -1040,6 +1049,7 @@ pub(crate) async fn insert_client_api_key_in_transaction(
.bind(key.budget.daily_usd.canonical())
.bind(key.budget.weekly_usd.canonical())
.bind(key.openai_client_profile_override.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider())))
.bind(key.xai_client_profile_override.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider())))
.execute(&mut **transaction)
.await
.map_err(|error| {
@@ -1072,9 +1082,11 @@ pub(crate) async fn update_client_api_key_in_transaction(
requests_per_minute = $5, updated_at = now(),
daily_limit_usd = coalesce($6::text::numeric, daily_limit_usd),
weekly_limit_usd = coalesce($7::text::numeric, weekly_limit_usd),
provider_request_profiles_json = case when not $8 then provider_request_profiles_json
when $9::jsonb is null then provider_request_profiles_json - 'openai'
else jsonb_set(provider_request_profiles_json, '{openai}', $9) end
provider_request_profiles_json = (provider_request_profiles_json
- case when $8 then array['openai'] else array[]::text[] end
- case when $10 then array['xai'] else array[]::text[] end)
|| case when $9::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $9::jsonb) end
|| case when $11::jsonb is null then '{}'::jsonb else jsonb_build_object('xai', $11::jsonb) end
where id = $1",
)
.bind(&key.id)
@@ -1091,6 +1103,13 @@ pub(crate) async fn update_client_api_key_in_transaction(
.and_then(Option::as_ref)
.map(|profile| sqlx::types::Json(profile.expose_to_provider())),
)
.bind(key.xai_client_profile_override.is_some())
.bind(
key.xai_client_profile_override
.as_ref()
.and_then(Option::as_ref)
.map(|profile| sqlx::types::Json(profile.expose_to_provider())),
)
.execute(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("update client API key in transaction"))?;
@@ -1246,6 +1265,12 @@ fn client_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<ClientApiK
)
.map_err(|_| invalid("invalid request profile"))?
.map(|value| gateway_core::account::OpaqueProviderData::new(value.0)),
xai_client_profile_override: row
.try_get::<Option<sqlx::types::Json<serde_json::Map<String, serde_json::Value>>>, _>(
"xai_client_profile_override",
)
.map_err(|_| invalid("invalid request profile"))?
.map(|value| gateway_core::account::OpaqueProviderData::new(value.0)),
id: row.try_get("id").map_err(|_| invalid("invalid id"))?,
name: row.try_get("name").map_err(|_| invalid("invalid name"))?,
label: row.try_get("label").map_err(|_| invalid("invalid label"))?,
@@ -449,6 +449,7 @@ pub(crate) fn admin_usage_list_record(
};
let billing = restore_billing_snapshot(billing, record.billing_snapshot_json.as_ref());
Ok(admin_observability::UsageListRecord {
client_api_key_name: record.client_api_key_name,
id: record.id,
endpoint: record.endpoint,
client_transport: record.client_transport,
@@ -457,6 +458,7 @@ pub(crate) fn admin_usage_list_record(
provider_account_ref: record.provider_account_ref,
provider_account_name: record.provider_account_name,
provider_account_email: record.provider_account_email,
provider_account_notes: record.provider_account_notes,
provider_account_authentication_kind: record.provider_account_authentication_kind,
upstream_model_id: record.upstream_model_id,
upstream_transport: record.upstream_transport,
@@ -720,6 +722,7 @@ pub(crate) fn admin_ops_error_page(
pub(crate) fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::OpsError {
admin_observability::OpsError {
client_api_key_name: error.client_api_key_name,
source: error.source,
event_id: error.event_id,
request_id: error.request_id,
@@ -777,6 +780,7 @@ pub(crate) fn usage_list_record_from_row(
row: &sqlx::postgres::PgRow,
) -> StoreResult<UsageListRecord> {
Ok(UsageListRecord {
client_api_key_name: get(row, "client_api_key_name")?,
billing_snapshot_json: get(row, "billing_snapshot_json")?,
id: get(row, "id")?,
endpoint: get(row, "endpoint")?,
@@ -786,6 +790,7 @@ pub(crate) fn usage_list_record_from_row(
provider_account_ref: get(row, "provider_account_ref")?,
provider_account_name: get(row, "provider_account_name")?,
provider_account_email: get(row, "provider_account_email")?,
provider_account_notes: get(row, "provider_account_notes")?,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
@@ -912,6 +917,7 @@ pub(crate) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<
pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<OpsErrorRecord> {
Ok(OpsErrorRecord {
client_api_key_name: get(row, "client_api_key_name")?,
source: get(row, "source")?,
event_id: get(row, "event_id")?,
request_id: get(row, "request_id")?,
@@ -480,6 +480,7 @@ pub struct DashboardObservation {
/// 使用记录列表所需的窄投影;完整执行、路由和客户端详情按 ID 单独读取。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageListRecord {
pub client_api_key_name: Option<String>,
pub billing_snapshot_json: Option<serde_json::Value>,
pub id: String,
pub endpoint: String,
@@ -489,6 +490,7 @@ pub struct UsageListRecord {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_notes: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -693,6 +695,7 @@ pub struct DiagnosticObservation {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsErrorRecord {
pub client_api_key_name: Option<String>,
pub source: String,
pub event_id: String,
pub request_id: Option<String>,
@@ -5,7 +5,7 @@ use super::super::*;
const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
mr.id as event_id, mr.id as request_id,
nullif(mr.attempt_count, 0) as attempt_index,
mr.client_api_key_ref, 'model_request'::text as component, mr.operation,
mr.client_api_key_ref, client_key.name as client_api_key_name, 'model_request'::text as component, mr.operation,
mr.protocol, mr.client_transport, mr.requested_model_id, mr.service_tier,
mr.endpoint, mr.provider_kind, mr.provider_account_ref,
mr.provider_account_name_snapshot as provider_account_name,
@@ -32,11 +32,12 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
mr.completed_at as occurred_at,
'model_request:' || mr.id as stable_sort_id
from model_requests mr
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
where true";
const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
oe.id as event_id, oe.model_request_id as request_id, oe.attempt_index,
mr.client_api_key_ref, oe.component, oe.operation,
mr.client_api_key_ref, client_key.name as client_api_key_name, oe.component, oe.operation,
mr.protocol, mr.client_transport, mr.requested_model_id, mr.service_tier,
mr.endpoint, oe.provider_kind, oe.provider_account_ref,
oe.provider_account_name_snapshot as provider_account_name,
@@ -64,6 +65,7 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
'ops_event:' || oe.id as stable_sort_id
from ops_events oe
left join model_requests mr on mr.id = oe.model_request_id
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
where true";
pub(crate) async fn list_ops_errors(
@@ -124,10 +124,11 @@ pub(crate) fn literal_prefix_pattern(value: &str) -> String {
}
pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
"select mr.id, mr.endpoint, mr.client_transport, mr.requested_model_id,
"select mr.id, client_key.name as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id,
mr.provider_kind, mr.provider_account_ref,
mr.provider_account_name_snapshot as provider_account_name,
mr.provider_account_email_snapshot as provider_account_email,
account.notes as provider_account_notes,
mr.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
mr.upstream_model_id, mr.upstream_transport, mr.upstream_response_model, mr.service_tier,
@@ -141,7 +142,9 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
host(mr.client_ip) as client_ip, mr.user_agent,
mr.reasoning_effort, mr.reasoning_preset, mr.subagent_kind, mr.compact,
mr.started_at
from model_requests mr";
from model_requests mr
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
left join provider_accounts account on account.id = mr.provider_account_ref";
pub(crate) const USAGE_RECORD_DETAIL_SELECT: &str =
"select mr.id, mr.client_api_key_ref, mr.config_revision,
@@ -15,6 +15,7 @@ pub(crate) fn encode_billing_snapshot(
) -> serde_json::Value {
serde_json::json!({
"version": 1,
"longContextBillingApplied": b.long_context_billing_applied(),
"image": b.image().map(|image| serde_json::json!({
"inputTokens": image.input_tokens, "cachedTokens": image.cached_tokens,
"input": image.input_amount.amount().canonical(),
@@ -55,6 +56,11 @@ pub(crate) fn decode_billing_snapshot(
})
};
Some(CalculatedBillingBreakdown {
// 旧快照未记录长上下文计费事实,不根据当前价格倒推历史标识。
long_context_billing_applied: value
.get("longContextBillingApplied")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
image: match value.get("image").filter(|v| !v.is_null()) {
Some(image) => {
let amount = |key| {
@@ -20,6 +20,7 @@ use crate::{Revision, StoreError, StoreResult, postgres_unavailable};
#[derive(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 admin_api_key: Option<String>,
pub refresh_margin_seconds: u64,
@@ -110,6 +111,7 @@ impl fmt::Debug for RuntimeSettings {
#[derive(Clone)]
pub struct RuntimeSettingsUpdate {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub admin_api_key: Option<String>,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
@@ -373,7 +375,9 @@ pub(crate) async fn update_runtime_settings_in_transaction(
request_location_json = $23,
request_location_enabled = $24,
responses_max_decompressed_body_bytes = $25,
provider_request_profiles_json = case when $26::jsonb is null then provider_request_profiles_json else jsonb_set(provider_request_profiles_json, '{openai}', $26) end,
provider_request_profiles_json = provider_request_profiles_json
|| case when $26::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $26::jsonb) end
|| case when $27::jsonb is null then '{}'::jsonb else jsonb_build_object('xai', $27::jsonb) end,
updated_at = now()
where id = 1
returning config_revision",
@@ -416,6 +420,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
.map_err(|_| invalid_numeric())?,
)
.bind(update.openai_client_profile.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider())))
.bind(update.xai_client_profile.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider())))
.fetch_optional(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("update runtime settings in transaction"))?
@@ -504,6 +509,11 @@ fn runtime_settings_from_row(mut row: RuntimeSettingsRow) -> StoreResult<Runtime
.0
.remove("openai")
.map(gateway_core::account::OpaqueProviderData::new),
xai_client_profile: row
.provider_request_profiles_json
.0
.remove("xai")
.map(gateway_core::account::OpaqueProviderData::new),
config_revision: Revision::new(to_u64(row.config_revision)?)?,
admin_api_key: row.admin_api_key,
refresh_margin_seconds: to_u64(row.refresh_margin_seconds)?,
@@ -171,6 +171,7 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip
.update_client_key(
UpdateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
daily_limit_usd: None,
weekly_limit_usd: None,
id: client_key_id("key_group_one"),
@@ -197,6 +198,7 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip
.update_client_key(
UpdateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
daily_limit_usd: None,
weekly_limit_usd: None,
id: client_key_id("key_group_one"),
@@ -321,6 +323,7 @@ fn new_key(id: &str, group_ids: Vec<AccountGroupId>) -> NewClientKey {
let marker = char::from(id.as_bytes().last().copied().unwrap_or(b'k'));
NewClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
budget: Default::default(),
id: client_key_id(id),
name: id.to_owned(),
@@ -472,6 +472,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() {
.unwrap();
let update = UpdateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
id: ClientApiKeyId::new("key").unwrap(),
name: "key".to_owned(),
label: None,
@@ -491,6 +492,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() {
.update_client_key(
UpdateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
daily_limit_usd: None,
weekly_limit_usd: None,
..update.clone()
@@ -515,6 +517,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() {
.update_client_key(
UpdateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
daily_limit_usd: Some("0".parse().unwrap()),
weekly_limit_usd: None,
..update
@@ -50,6 +50,7 @@ async fn migrated_keys_persist_exactly_and_keep_short_keys_masked() {
.create_client_key(
NewClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
id: id.clone(),
name: format!("Migrated {index}"),
label: None,
@@ -105,6 +106,7 @@ async fn duplicate_migrated_keys_conflict_atomically_without_extra_audits() {
let key = "legacy-key-case-sensitive!";
let command = |id: &str| NewClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
id: ClientApiKeyId::new(id).unwrap(),
name: id.to_owned(),
label: None,
@@ -141,6 +143,7 @@ async fn duplicate_migrated_keys_conflict_atomically_without_extra_audits() {
fn generated_client_key_format_remains_valid() {
let key = NewClientApiKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
budget: Default::default(),
id: "key-1".to_owned(),
name: "default".to_owned(),
@@ -174,6 +177,7 @@ async fn client_key_names_are_checked_atomically_on_create_and_rename() {
};
let create = |id: &str, name: &str| NewClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
id: ClientApiKeyId::new(id).unwrap(),
name: name.to_owned(),
label: None,
@@ -184,6 +188,7 @@ async fn client_key_names_are_checked_atomically_on_create_and_rename() {
};
let update = |id: ClientApiKeyId, name: &str| UpdateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
id,
name: name.to_owned(),
label: None,
@@ -639,6 +644,7 @@ fn client_key_debug_redacts_plaintext() {
let secret = format!("sk_{}", "s".repeat(43));
let key = NewClientApiKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
budget: Default::default(),
id: "key-1".to_owned(),
name: "default".to_owned(),
@@ -717,12 +723,14 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance
budget: Default::default(),
plaintext: "synthetic-profile-test-key".to_owned(),
openai_client_profile_override: Some(profile.clone()),
xai_client_profile_override: Some(profile.clone()),
},
&context,
)
.await
.unwrap();
assert_eq!(record.openai_client_profile_override, Some(profile.clone()));
assert_eq!(record.xai_client_profile_override, Some(profile.clone()));
let update = |override_value| UpdateClientKey {
id: id.clone(),
name: "profile".to_owned(),
@@ -732,6 +740,7 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance
daily_limit_usd: None,
weekly_limit_usd: None,
openai_client_profile_override: override_value,
xai_client_profile_override: None,
};
let (_, unchanged) = store
.update_client_key(update(None), &context)
@@ -741,6 +750,7 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance
unchanged.openai_client_profile_override,
Some(profile.clone())
);
assert_eq!(unchanged.xai_client_profile_override, Some(profile.clone()));
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
.await
@@ -749,6 +759,20 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance
snapshot.client_api_keys[0].request_profiles.values().next(),
Some(&profile)
);
assert_eq!(
snapshot.client_api_keys[0]
.request_profiles
.get(&gateway_core::routing::ProviderKind::new("xai").unwrap()),
Some(&profile)
);
let mut clear_xai = update(None);
clear_xai.xai_client_profile_override = Some(None);
let (_, cleared_xai) = store.update_client_key(clear_xai, &context).await.unwrap();
assert_eq!(cleared_xai.xai_client_profile_override, None);
assert_eq!(
cleared_xai.openai_client_profile_override,
Some(profile.clone())
);
let (_, cleared) = store
.update_client_key(update(Some(None)), &context)
.await
@@ -465,6 +465,7 @@ async fn billing_snapshot_survives_later_price_changes_and_usage_detail_reads()
Some("default".to_owned()),
100,
)
.with_long_context_billing(true)
.with_custom_multiplier(12500)
.unwrap();
let mut finalization = successful_core_finalization("req_billing_snapshot");
@@ -484,6 +485,7 @@ async fn billing_snapshot_survives_later_price_changes_and_usage_detail_reads()
else {
panic!("persisted billing breakdown must be restored without provider recalculation");
};
assert!(saved.long_context_billing_applied);
assert_eq!(saved.custom_multiplier_bps, 12500);
assert_eq!(saved.total_amount.amount, "0.00075".parse().unwrap());
sqlx::query("update runtime_settings set pricing_overrides_json = $1 where id = 1")
@@ -496,6 +498,18 @@ async fn billing_snapshot_survives_later_price_changes_and_usage_detail_reads()
.await
.unwrap();
assert_eq!(original.request.billing, after.request.billing);
sqlx::query("update model_requests set billing_snapshot_json = billing_snapshot_json - 'longContextBillingApplied' where id = 'req_billing_snapshot'")
.execute(&database.pool).await.unwrap();
let legacy = observation
.usage_record_detail("req_billing_snapshot")
.await
.unwrap();
let Some(admin_observability::UsageBilling::Calculated(legacy)) = legacy.request.billing else {
panic!("legacy billing snapshot must remain readable");
};
assert!(!legacy.long_context_billing_applied);
assert_eq!(legacy.total_amount, saved.total_amount);
database.close().await;
}
@@ -155,6 +155,125 @@ async fn usage_page_should_always_return_total() {
assert_eq!(page.total, 1);
assert_eq!(page.current_page, 1);
assert_eq!(page.page_size, 10);
assert_eq!(page.items[0].client_api_key_name, None);
database.close().await;
}
#[tokio::test]
async fn usage_list_should_resolve_current_notes_by_account_id() {
let Some(database) = TestDatabase::create("usage_account_notes").await else {
return;
};
let now = Utc::now();
seed_observability_facts(&database.pool, now)
.await
.expect("seed observability facts");
sqlx::query(
"insert into provider_accounts (
id, provider_kind, name, email, notes, upstream_user_id,
upstream_account_id, plan_type, authentication_kind,
provider_credentials_json, credential_revision, credential_observed_at,
has_refresh_token, created_at, updated_at
) select 'acct_team', provider_kind, name, email, '团队工作区', upstream_user_id,
'team-workspace', 'team', authentication_kind,
provider_credentials_json, credential_revision, credential_observed_at,
has_refresh_token, created_at, updated_at
from provider_accounts where id = 'acct_observe'",
)
.execute(&database.pool)
.await
.expect("seed same-email team account");
sqlx::query(
"update model_requests
set provider_account_id = 'acct_team', provider_account_ref = 'acct_team',
downstream_committed_at = completed_at
where id = 'req_observe_uncommitted'",
)
.execute(&database.pool)
.await
.expect("assign completed request to team account");
let range =
admin_observability::TimeRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1))
.expect("admin observability range");
let store = admin_observability_store(&database.pool);
for notes in [None, Some("个人主号"), Some("个人备用号"), None] {
sqlx::query("update provider_accounts set notes = $1 where id = 'acct_observe'")
.bind(notes)
.execute(&database.pool)
.await
.expect("update current account notes");
let page = store
.list_usage_records(admin_observability::UsageQuery {
range,
filter: admin_observability::UsageFilter::default(),
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
})
.await
.expect("usage list with current notes");
assert_eq!(page.total, 2);
assert_eq!(page.items.len(), 2);
let personal = page
.items
.iter()
.find(|record| record.id == "req_observe_success")
.expect("personal request");
let team = page
.items
.iter()
.find(|record| record.id == "req_observe_uncommitted")
.expect("team request");
assert_eq!(
personal.provider_account_ref.as_deref(),
Some("acct_observe")
);
assert_eq!(personal.provider_account_email, team.provider_account_email);
assert_eq!(personal.provider_account_notes.as_deref(), notes);
assert_eq!(team.provider_account_notes.as_deref(), Some("团队工作区"));
}
let dashboard = store
.dashboard_summary(range, now)
.await
.expect("dashboard with current account notes");
assert_eq!(
dashboard
.recent_requests
.iter()
.find(|record| record.provider_account_ref.as_deref() == Some("acct_team"))
.expect("team dashboard request")
.provider_account_notes
.as_deref(),
Some("团队工作区")
);
sqlx::query("delete from provider_accounts where id = 'acct_team'")
.execute(&database.pool)
.await
.expect("delete team account");
let page = store
.list_usage_records(admin_observability::UsageQuery {
range,
filter: admin_observability::UsageFilter {
provider_account_ref: Some("acct_team".to_owned()),
..admin_observability::UsageFilter::default()
},
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
})
.await
.expect("deleted account history");
assert_eq!(page.total, 1);
assert_eq!(
page.items[0].provider_account_ref.as_deref(),
Some("acct_team")
);
assert_eq!(
page.items[0].provider_account_email.as_deref(),
Some("account@example.invalid")
);
assert_eq!(page.items[0].provider_account_notes, None);
database.close().await;
}
@@ -383,6 +502,10 @@ async fn usage_and_error_search_match_key_names_instead_of_credentials() {
assert_eq!(page.items.len() as u64, expected);
if expected == 1 {
assert_eq!(page.items[0].id, "req_observe_success");
assert_eq!(
page.items[0].client_api_key_name.as_deref(),
Some("Production_%专用")
);
}
let errors = repository
.list_ops_errors(OpsErrorQuery {
@@ -398,8 +521,55 @@ async fn usage_and_error_search_match_key_names_instead_of_credentials() {
.unwrap();
assert_eq!(errors.total, expected * 2, "error search: {search}");
assert_eq!(errors.items.len() as u64, expected * 2);
for error in &errors.items {
assert_eq!(
error.client_api_key_name.as_deref(),
Some("Production_%专用")
);
}
}
}
for name in [Some("Renamed Key"), None] {
if let Some(name) = name {
sqlx::query("update client_api_keys set name = $1 where id = 'key_observe'")
.bind(name)
.execute(&database.pool)
.await
.unwrap();
} else {
sqlx::query("delete from client_api_keys where id = 'key_observe'")
.execute(&database.pool)
.await
.unwrap();
}
let page = repository
.list_usage_records(UsageRecordQuery {
range,
filter: UsageRecordFilter::default(),
current_page: 1,
page_size: ObservabilityPageSize::new(10).unwrap(),
})
.await
.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.items[0].client_api_key_name.as_deref(), name);
let errors = repository
.list_ops_errors(OpsErrorQuery {
range,
filter: OpsErrorFilter::default(),
current_page: 1,
page_size: ObservabilityPageSize::new(10).unwrap(),
})
.await
.unwrap();
assert_eq!(errors.total, 2);
assert!(
errors
.items
.iter()
.all(|error| error.client_api_key_name.as_deref() == name)
);
}
database.close().await;
}
@@ -1519,7 +1519,7 @@ async fn invalid_account_notes_roll_back_scheduling_revision_and_audit() {
}
#[tokio::test]
async fn account_recovery_resets_status_facts_without_changing_credentials_or_scheduling() {
async fn account_enable_preserves_facts_and_explicit_recovery_clears_them() {
const GROUP_ID: &str = "grp_00000000000000000000000000000070";
let Some(database) = TestDatabase::create("provider_account_recovery").await else {
return;
@@ -1594,6 +1594,57 @@ async fn account_recovery_resets_status_facts_without_changing_credentials_or_sc
.await
.expect("load account before recovery");
let store = admin_account_store(&database.pool);
let facts_query = "select to_jsonb(a) - 'enabled' - 'updated_at'
from provider_accounts a where id = 'acct_recovery'";
let before_enable: serde_json::Value = sqlx::query_scalar(facts_query)
.fetch_one(&database.pool)
.await
.expect("load disabled account facts");
let enabled = store
.batch_update_accounts(
BatchUpdateAccounts {
account_ids: vec!["acct_recovery".to_owned()],
enabled: Some(true),
concurrency_limit: None,
weight: None,
model_access: None,
group_ids: None,
outbound_proxy: None,
},
&MutationContext {
actor: MutationActor::System,
request_id: "request_account_enable".to_owned(),
},
)
.await
.expect("enable account scheduling");
assert_eq!(enabled.config_revision.get(), 2);
assert!(
repository
.load_provider_account("acct_recovery")
.await
.unwrap()
.unwrap()
.summary
.enabled
);
assert_eq!(
sqlx::query_scalar::<_, serde_json::Value>(facts_query)
.fetch_one(&database.pool)
.await
.expect("load enabled account facts"),
before_enable
);
assert_eq!(
account_group_ids(&database.pool, "acct_recovery").await,
[GROUP_ID]
);
assert_eq!(
audit_count(&database.pool, "request_account_enable").await,
1
);
let result = store
.recover_account(
@@ -1606,7 +1657,7 @@ async fn account_recovery_resets_status_facts_without_changing_credentials_or_sc
.await
.expect("recover account");
assert_eq!(result.config_revision.get(), 2);
assert_eq!(result.config_revision.get(), 3);
let current = sqlx::query_as::<_, RecoveredAccountRow>(
"select enabled, credential_state, quota_access_state, quota_evidence,
last_error_message, provider_quota_json, concurrency_limit, weight,
@@ -10,6 +10,7 @@ use super::TestDatabase;
fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate {
RuntimeSettingsUpdate {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
admin_api_key: None,
@@ -502,3 +503,53 @@ async fn request_profile_initialization_is_idempotent_and_old_updates_preserve_i
);
database.close().await;
}
#[tokio::test]
async fn xai_profile_initialization_and_updates_preserve_other_providers() {
use gateway_core::{
account::OpaqueProviderData, provider_ports::ProviderRuntimePolicyPort,
routing::ProviderKind,
};
let Some(database) = TestDatabase::create("xai_profiles").await else {
return;
};
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
let document = |version: &str| {
OpaqueProviderData::new(
serde_json::json!({"clientVersion":version})
.as_object()
.unwrap()
.clone(),
)
};
let provider = ProviderKind::new("xai").unwrap();
repository
.initialize_request_profile(&provider, document("initial"))
.await
.unwrap();
assert_eq!(
repository
.initialize_request_profile(&provider, document("ignored"))
.await
.unwrap(),
document("initial")
);
let mut update = settings_with_margin(3600);
update.openai_client_profile = Some(document("openai"));
update.xai_client_profile = Some(document("xai"));
repository.update_runtime_settings(update).await.unwrap();
let mut update = settings_with_margin(3600);
update.xai_client_profile = Some(document("edited"));
repository.update_runtime_settings(update).await.unwrap();
let settings = repository.load_runtime_settings().await.unwrap();
assert_eq!(settings.openai_client_profile, Some(document("openai")));
assert_eq!(settings.xai_client_profile, Some(document("edited")));
assert_eq!(
repository
.initialize_request_profile(&provider, document("old-yaml"))
.await
.unwrap(),
document("edited")
);
database.close().await;
}
+2 -2
View File
@@ -26,7 +26,7 @@ hex = "0.4.3"
# TLS 依赖按官方 Core 锁文件固定,包含用于约束传递依赖的直接声明。
# hyper 受 workspace 的 SOCKS IPv6 修复补丁约束,保留补丁要求的兼容版本。
# 升级前须重复采样 ClientHello,核对算法、扩展集合及排序约束、key_share 与 ALPN。
aws-lc-rs = "=1.16.2"
aws-lc-rs = "=1.18.1"
hyper = { version = "=1.11.1", default-features = false }
hyper-rustls = "=0.27.7"
hyper-util = { version = "0.1.20", features = ["client-legacy", "http1", "tokio"] }
@@ -38,7 +38,7 @@ roxmltree = "0.21.1"
semver = "1.0.27"
# 与官方 rustls 路径一致:aws-lc 支持 P-521,优先提供 X25519MLKEM768。
# reqwest 的 roots feature 仍会编译 ring;实际 provider 由 TLS 初始化显式选择。
rustls = { version = "=0.23.36", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std", "tls12"] }
rustls = { version = "=0.23.45", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std", "tls12"] }
rustls-native-certs = "=0.8.3"
rustls-pki-types = "=1.14.0"
secrecy = "0.10.3"
@@ -371,6 +371,8 @@ impl ProviderAdmin for OpenAiAdminProvider {
return Ok(None);
}
Ok(Some(CalculatedBillingBreakdown {
// 历史总额只能核对费用拆分,不能证明当时保存过长上下文标记。
long_context_billing_applied: false,
image: None,
custom_multiplier_bps: breakdown.custom_multiplier_bps(),
input_amount: currency_cost(breakdown.input_amount())?,
+4 -171
View File
@@ -1,14 +1,13 @@
//! OpenAI Provider 启动配置与 Codex Desktop 请求画像校验。
//! OpenAI Provider 启动配置;客户端身份由管理端设置持久化。
use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use url::Url;
use crate::credential::CodexQuotaRefreshPolicy;
use crate::transport::profile::{CodexResidency, CodexWireProfile, CodexWireProfileState};
use crate::transport::profile::CodexResidency;
use crate::transport::session::{CodexSessionIdentity, CodexSessionIdentityError};
use crate::transport::websocket::CodexWebSocketPoolConfig;
use crate::{
@@ -41,7 +40,7 @@ pub struct OpenAiConfig {
#[serde(default = "default_stream_max_retries")]
pub stream_max_retries: u64,
#[serde(default)]
pub wire_profile: CodexWireProfileConfig,
pub residency: Option<CodexResidency>,
#[serde(skip)]
identity_secret_path: PathBuf,
}
@@ -56,69 +55,10 @@ impl OpenAiConfig {
self.ws_pool.validate()?;
self.quota.validate()?;
self.auth.validate()?;
self.wire_profile.validate()?;
self.identity_secret_path = runtime_data_dir.join("identity_hmac_secret");
Ok(())
}
#[must_use]
pub fn wire_profile_state(&self) -> CodexWireProfileState {
// 官方发布基线独立于用户固定配置;旧 YAML 只用于首次导入管理设置。
CodexWireProfileState::new(
CodexWireProfileConfig {
residency: self.wire_profile.residency,
..CodexWireProfileConfig::default()
}
.into(),
)
}
/// 将旧部署选择转换为一次性初始化值;数据库已有选择时不覆盖。
pub fn initial_client_profile(
&self,
) -> Result<gateway_core::account::OpaqueProviderData, OpenAiConfigError> {
use crate::transport::profile::selection::{
ClientKind, ClientPlatform, ClientProfileSelection, VersionMode,
};
let profile = &self.wire_profile;
let baseline = CodexWireProfileConfig::default();
let platform = match profile.os_type.as_str() {
"Mac OS" | "Darwin" | "macOS" => ClientPlatform::Macos,
"Linux" => ClientPlatform::Linux,
"Windows" => ClientPlatform::Windows,
_ => {
return Err(OpenAiConfigError::InvalidField(
"openai.wire_profile.os_type",
));
}
};
let fixed = profile.codex_version != baseline.codex_version
|| profile.desktop_version != baseline.desktop_version
|| profile.desktop_build != baseline.desktop_build
|| platform != ClientPlatform::Macos
|| profile.arch != "arm64";
let selection = ClientProfileSelection {
client: ClientKind::Desktop,
platform,
version_mode: if fixed {
VersionMode::Fixed
} else {
VersionMode::Latest
},
originator: (profile.originator != baseline.originator)
.then(|| profile.originator.clone()),
os_version: Some(profile.os_version.clone()),
arch: Some(profile.arch.clone()),
terminal: Some(profile.terminal.clone()),
codex_version: fixed.then(|| profile.codex_version.clone()),
desktop_version: fixed.then(|| profile.desktop_version.clone()),
desktop_build: fixed.then(|| profile.desktop_build.clone()),
};
selection
.document()
.map_err(|_| OpenAiConfigError::InvalidField("openai.wire_profile"))
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.api.base_url
@@ -174,7 +114,7 @@ impl Default for OpenAiConfig {
quota: CodexQuotaSettings::default(),
auth: CodexAuthSettings::default(),
stream_max_retries: DEFAULT_STREAM_MAX_RETRIES,
wire_profile: CodexWireProfileConfig::default(),
residency: None,
identity_secret_path: PathBuf::new(),
}
}
@@ -307,115 +247,8 @@ impl CodexAuthSettings {
}
}
/// 经审计固定的 Codex Desktop 上游请求画像。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct CodexWireProfileConfig {
pub originator: String,
/// 官方 Desktop ZIP 内嵌 Core 的启动基线;运行时按完整制品元组更新。
pub codex_version: String,
pub desktop_version: String,
pub desktop_build: String,
pub os_type: String,
pub os_version: String,
pub arch: String,
pub terminal: String,
#[serde(default)]
pub residency: Option<CodexResidency>,
pub verified_at: DateTime<Utc>,
}
impl Default for CodexWireProfileConfig {
fn default() -> Self {
Self {
originator: "Codex Desktop".to_owned(),
codex_version: "0.153.4".to_owned(),
desktop_version: "26.901.51231".to_owned(),
desktop_build: "8109".to_owned(),
os_type: "Mac OS".to_owned(),
os_version: "15.7.1".to_owned(),
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
residency: None,
// 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。
verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084),
}
}
}
impl CodexWireProfileConfig {
fn validate(&self) -> Result<(), OpenAiConfigError> {
for (field, value) in [
("openai.wire_profile.originator", self.originator.as_str()),
(
"openai.wire_profile.codex_version",
self.codex_version.as_str(),
),
(
"openai.wire_profile.desktop_version",
self.desktop_version.as_str(),
),
(
"openai.wire_profile.desktop_build",
self.desktop_build.as_str(),
),
("openai.wire_profile.os_type", self.os_type.as_str()),
("openai.wire_profile.os_version", self.os_version.as_str()),
("openai.wire_profile.arch", self.arch.as_str()),
("openai.wire_profile.terminal", self.terminal.as_str()),
] {
if value.trim().is_empty() {
return Err(OpenAiConfigError::InvalidField(field));
}
}
if semver::Version::parse(&self.codex_version).is_err() {
return Err(OpenAiConfigError::InvalidField(
"openai.wire_profile.codex_version",
));
}
if !numeric_dotted_version(&self.desktop_version) {
return Err(OpenAiConfigError::InvalidField(
"openai.wire_profile.desktop_version",
));
}
if !self.desktop_build.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(OpenAiConfigError::InvalidField(
"openai.wire_profile.desktop_build",
));
}
Ok(())
}
}
impl From<CodexWireProfileConfig> for CodexWireProfile {
fn from(value: CodexWireProfileConfig) -> Self {
Self {
client_kind: crate::transport::profile::selection::ClientKind::Desktop,
originator: value.originator,
codex_version: value.codex_version,
desktop_version: value.desktop_version,
desktop_build: value.desktop_build,
os_type: value.os_type,
os_version: value.os_version,
arch: value.arch,
terminal: value.terminal,
residency: value.residency,
verified_at: value.verified_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OpenAiConfigError {
#[error("OpenAI configuration field is invalid: {0}")]
InvalidField(&'static str),
}
fn numeric_dotted_version(value: &str) -> bool {
let mut parts = value.split('.');
let valid_parts = parts
.by_ref()
.filter(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
.count();
valid_parts >= 2 && valid_parts == value.split('.').count()
}
@@ -60,8 +60,8 @@ use snapshot::{
const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60);
pub(crate) const QUOTA_SCHEDULING_TTL: Duration = Duration::from_secs(10 * 60);
const QUOTA_HYDRATION_FAILURE_TTL: Duration = Duration::from_secs(5);
const EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(30 * 60);
const EXHAUSTED_QUOTA_RESET_GRACE: Duration = Duration::from_secs(2 * 60);
const PERIODIC_QUOTA_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(30 * 60);
const QUOTA_RESET_GRACE: Duration = Duration::from_secs(2 * 60);
/// 首次 OAuth 异步观察失败时,由既有 quota worker 兜底重试的单轮上限。
const INITIAL_QUOTA_SYNC_BATCH: usize = 100;
// 5xx 上游拒绝的短退避重试预算;指数退避 1s/2s,吞掉瞬时抖动。
@@ -394,15 +394,19 @@ impl CodexQuotaSchedulingProjection {
fn reserve_periodic_refreshes(
&self,
accounts: Vec<ProviderAccount>,
observed_snapshots: &BTreeMap<ProviderAccountId, CodexAccountQuotaSnapshot>,
now: SystemTime,
) -> Vec<ProviderAccount> {
let candidates = accounts
.into_iter()
.filter_map(|account| quota_refresh_candidate(account, now))
.filter_map(|account| {
let snapshot = observed_snapshots.get(account.id());
quota_refresh_candidate(account, snapshot, now)
})
.collect::<Vec<_>>();
let candidate_ids = candidates
.iter()
.map(|account| account.id().clone())
.map(|(account, _)| account.id().clone())
.collect::<BTreeSet<_>>();
let refreshed_at = Instant::now();
let mut state = self
@@ -413,18 +417,12 @@ impl CodexQuotaSchedulingProjection {
.last_periodic_refresh_at
.retain(|account_id, _| candidate_ids.contains(account_id));
// 正常账号只由真实请求的响应头和 `codex.rate_limits` 被动同步。
// 已耗尽账号每 30 分钟复核,不能等待旧 reset:官方活动可能提前重置额度。
// reset + 2 分钟可提前触发一次复核,给上游重置留出传播时间。
// 已耗尽账号首次立即复核,之后每 30 分钟复核,以发现官方提前重置。
// reset + 2 分钟额外触发一次复核,给上游重置留出传播时间。
// 正常账号仅在非零用量窗口经过宽限期后参与,未更新时复用周期节流。
let mut reserved = Vec::new();
for account in candidates {
if !periodic_quota_refresh_due(
&state,
account.id(),
account.quota().reset_at(),
now,
refreshed_at,
) {
for (account, target_reset) in candidates {
if !periodic_quota_refresh_due(&state, account.id(), target_reset, now, refreshed_at) {
continue;
}
state.last_periodic_refresh_at.insert(
@@ -440,9 +438,35 @@ impl CodexQuotaSchedulingProjection {
}
}
fn quota_refresh_candidate(account: ProviderAccount, now: SystemTime) -> Option<ProviderAccount> {
(eligible_periodic_quota_refresh(&account, now) && account.quota().is_exhausted())
.then_some(account)
fn quota_refresh_candidate(
account: ProviderAccount,
snapshot: Option<&CodexAccountQuotaSnapshot>,
now: SystemTime,
) -> Option<(ProviderAccount, Option<SystemTime>)> {
if !eligible_periodic_quota_refresh(&account, now) {
return None;
}
if account.quota().is_exhausted() {
let reset_at = account.quota().reset_at();
return Some((account, reset_at));
}
// 正常账号首次进入周期候选也要等待宽限期,不能由“无刷新历史”绕过。
let snapshot = snapshot?;
let expired_window_reset = snapshot
.windows()
.iter()
.filter(|window| {
window.used_percent().is_some_and(|used| used > 0.0) || window.limit_reached()
})
.filter_map(CodexQuotaWindow::reset_at)
.map(SystemTime::from)
.filter(|reset| {
reset
.checked_add(QUOTA_RESET_GRACE)
.is_some_and(|due_at| due_at <= now)
})
.min();
expired_window_reset.map(|reset_at| (account, Some(reset_at)))
}
fn periodic_quota_refresh_due(
@@ -457,9 +481,9 @@ fn periodic_quota_refresh_due(
.get(account_id)
.is_none_or(|last| {
monotonic_now.saturating_duration_since(last.monotonic_at)
>= EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL
>= PERIODIC_QUOTA_REFRESH_RETRY_INTERVAL
|| reset_at
.and_then(|reset| reset.checked_add(EXHAUSTED_QUOTA_RESET_GRACE))
.and_then(|reset| reset.checked_add(QUOTA_RESET_GRACE))
// 已在该边界之后复核过时回到周期重试,避免过期 reset 每轮触发。
.is_some_and(|due_at| last.wall_at < due_at && due_at <= now)
})
@@ -781,8 +805,26 @@ impl CodexCredentialQuotaService {
});
let mut summary = CodexQuotaSyncSummary::default();
let now = SystemTime::now();
let initial = self.initial_quota_sync_accounts(&accounts, now).await?;
let periodic = self.scheduling.reserve_periodic_refreshes(accounts, now);
let account_ids = accounts
.iter()
.map(|account| account.id().clone())
.collect::<Vec<_>>();
let observed = self.store.get_quotas(&account_ids).await?;
let observed_snapshots = observed
.iter()
.filter_map(|obs| {
quota_snapshot_from_observation(obs)
.map(|snapshot| (obs.account_id.clone(), snapshot))
})
.collect::<BTreeMap<_, _>>();
let observed_ids = observed
.into_iter()
.map(|observation| observation.account_id)
.collect::<BTreeSet<_>>();
let initial = Self::initial_quota_sync_accounts(&accounts, &observed_ids, now);
let periodic =
self.scheduling
.reserve_periodic_refreshes(accounts, &observed_snapshots, now);
let accounts = initial
.into_iter()
.chain(periodic)
@@ -857,28 +899,19 @@ impl CodexCredentialQuotaService {
}
/// `quota_observed_at` 为空代表首次异步观察尚未成功;不另建同步状态表。
async fn initial_quota_sync_accounts(
&self,
fn initial_quota_sync_accounts(
accounts: &[ProviderAccount],
observed_ids: &BTreeSet<ProviderAccountId>,
now: SystemTime,
) -> Result<Vec<ProviderAccount>, CodexCredentialQuotaError> {
let account_ids = accounts
.iter()
.map(|account| account.id().clone())
.collect::<Vec<_>>();
let observed = self.store.get_quotas(&account_ids).await?;
let observed_ids = observed
.into_iter()
.map(|observation| observation.account_id)
.collect::<BTreeSet<_>>();
Ok(accounts
) -> Vec<ProviderAccount> {
accounts
.iter()
.filter(|account| {
!observed_ids.contains(account.id()) && eligible_initial_quota_sync(account, now)
})
.take(INITIAL_QUOTA_SYNC_BATCH)
.cloned()
.collect())
.collect()
}
/// 解析并 revision-fenced 落库单账号的 Provider quota JSON。
@@ -580,10 +580,25 @@ impl CodexCredentialSelector {
})?;
continue 'capacity;
}
// 只判断本次账号范围;空池、认证失效和租约失败不能伪装成额度耗尽。
// 使用最初的排除集合,避免本轮临时跳过的繁忙账号丢失其可恢复语义。
let mut statuses = candidates
.iter()
.filter(|candidate| !base_excluded.contains(candidate.account.id()))
.map(|candidate| {
candidate
.account
.status_projection(context.now, candidate.signals.cooldown)
.status
});
let quota_exhausted = !diagnostic
&& statuses.next() == Some(AccountStatus::QuotaExhausted)
&& statuses.all(|status| status == AccountStatus::QuotaExhausted);
return match shortest_retry {
Some(retry_after) => Err(CredentialSelectionError::CapacityUnavailable {
retry_after: Some(retry_after),
}),
None if quota_exhausted => Err(CredentialSelectionError::QuotaExhausted),
None => Err(CredentialSelectionError::NoEligibleCredential),
};
};
@@ -1450,6 +1465,8 @@ pub enum CredentialSelectionError {
QueueRejected(#[from] QueueRejection),
#[error("no eligible Codex account")]
NoEligibleCredential,
#[error("all eligible Codex accounts have exhausted their quota")]
QuotaExhausted,
#[error("Codex account capacity is unavailable")]
CapacityUnavailable { retry_after: Option<Duration> },
#[error("Codex account data is invalid")]
+9 -7
View File
@@ -27,7 +27,7 @@ use crate::transport::profile::{
};
use crate::transport::{CodexWebSocketPool, build_reqwest_client};
pub use config::{CodexWireProfileConfig, OpenAiConfig, OpenAiConfigError};
pub use config::{OpenAiConfig, OpenAiConfigError};
pub use provider::{
CodexProvider, CodexProviderConfigError, CodexProviderTransport, OFFICIAL_CODEX_BASE_PATH,
OFFICIAL_CODEX_BASE_URL, openai_failure_affects_account_score,
@@ -62,9 +62,9 @@ pub async fn initialize(
let session_exclusions = ports.session_exclusions();
let account_feedback = ports.account_feedback();
let runtime_policy = ports.runtime_policy();
let initial_profile = config
.initial_client_profile()
.map_err(OpenAiInitializeError::Config)?;
let initial_profile = transport::profile::selection::ClientProfileSelection::default()
.document()
.map_err(|_| OpenAiInitializeError::RuntimePolicy)?;
let configured_profile = runtime_policy
.initialize_request_profile(&provider_kind, initial_profile)
.await
@@ -72,7 +72,11 @@ pub async fn initialize(
transport::profile::selection::ClientProfileSelection::parse(&configured_profile)
.map_err(|_| OpenAiInitializeError::RuntimePolicy)?;
let credential_state = ports.credential_state();
let profile = config.wire_profile_state();
let profile =
transport::profile::CodexWireProfileState::new(transport::profile::CodexWireProfile {
residency: config.residency,
..Default::default()
});
let artifact_cache =
CodexArtifactProfileCache::new(provider_kind.clone(), ports.artifact_profiles());
let configured_build = profile.snapshot().desktop_build.parse::<u64>().ok();
@@ -269,8 +273,6 @@ impl ProviderBundle {
/// OpenAI 初始化失败的脱敏分类。
#[derive(Debug, thiserror::Error)]
pub enum OpenAiInitializeError {
#[error(transparent)]
Config(OpenAiConfigError),
#[error("OpenAI runtime policy is unavailable")]
RuntimePolicy,
#[error("OpenAI Provider kind is invalid")]
@@ -1339,12 +1339,8 @@ pub(super) fn map_upstream_failure(
),
error_message: failure.client_message,
cyber_policy_failure,
// 仅上游明确返回的容量/服务不可用计数;本地连接保护也会映射为
// Unavailable,不能用通用 ProviderErrorKind 推断容量证据。
upstream_capacity_failure: matches!(
category,
CodexFailureCategory::CapacityUnavailable | CodexFailureCategory::Unavailable
),
// 普通 5xx 与未知流内错误不足以证明容量拒绝,不能用于冻结账号。
upstream_capacity_failure: capacity_unavailable,
set_cookie_headers: failure.set_cookie_headers,
rate_limit_headers: failure.rate_limit_headers,
observation,
@@ -92,7 +92,7 @@ use crate::transport::{
CodexBackendJsonResponse, CodexBackendStreamingResponse, CodexBackendTransport,
CodexClientError, CodexRateLimitUpdates, CodexRequestContext, CodexResponseMetadata,
CodexResponseMetadataUpdates, CodexTransportMetrics, CodexUpstreamDiagnostics,
CodexWebSocketPool, endpoint_url,
CodexWebSocketPool, endpoint_url, normalize_non_codex_request_body,
};
mod execution;
@@ -549,6 +549,12 @@ impl Provider for CodexProvider {
lease.installation_id(),
account_scope,
);
if matches!(
lease.authentication(),
crate::credential::CodexRuntimeAuthentication::OAuth(_)
) {
normalize_non_codex_request_body(upstream_request.body_mut());
}
// 每次执行从原始请求编码,选定出口后再覆盖,避免换号时携带上次位置。
if let Some(location) = lease
.account()
@@ -788,6 +788,15 @@ pub(super) fn map_selection_error(error: CredentialSelectionError) -> ProviderEr
ProviderErrorKind::NoEligibleAccount,
UpstreamSendState::NotSent,
),
CredentialSelectionError::QuotaExhausted => provider_error(
ProviderErrorKind::QuotaExhausted,
UpstreamSendState::NotSent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"All eligible accounts have exhausted their quota. Retry after quota resets or add an available account.",
Some("usage_limit_reached".to_owned()),
Some("usage_limit_reached".to_owned()),
)),
CredentialSelectionError::InvalidCredential
| CredentialSelectionError::Store
| CredentialSelectionError::Coordinator
@@ -453,6 +453,11 @@ fn classify_upstream_failure(
};
}
// 官方 Codex 将流内 invalid_prompt 归为请求错误,不依赖 HTTP 状态码。
if matches!(source, UpstreamFailureSource::SseFailure) && code == "invalid_prompt" {
return CodexFailureCategory::InvalidRequest;
}
if [code.as_str(), message.as_str(), body.as_str()]
.into_iter()
.any(is_model_unsupported)
@@ -1,8 +1,39 @@
//! 下游客户端模型请求正文的 Codex 协议兼容。
use serde_json::{Map, Value};
use serde_json::{Map, Value, json};
/// 补齐 Codex 请求缺省字段并移除已确认不适用的顶层参数,不递归清洗业务正文。
/// 非官方客户端及跨客户端历史回填的兼容入口;调用方必须已选定 Codex/OAuth 上游。
/// 只处理已验证被拒绝的字段,不根据客户端品牌推断整份历史是否合法。
pub(crate) fn normalize_non_codex_request_body(body: &mut Map<String, Value>) {
let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) else {
return;
};
for item in input {
let Some(item) = item.as_object_mut() else {
continue;
};
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
continue;
}
// SDK 输出项的 status 不属于 Codex reasoning 输入合同;其他项的 status 可能合法。
item.shift_remove("status");
// Codex 只接受空 content 数组;仅在加密历史仍可回填时去掉冗余明文,
// 没有加密内容的历史不自动丢弃,保留给上游明确拒绝。
if item
.get("encrypted_content")
.and_then(Value::as_str)
.is_some_and(|content| !content.trim().is_empty())
&& item
.get("content")
.and_then(Value::as_array)
.is_some_and(|content| !content.is_empty())
{
item.shift_remove("content");
}
}
}
/// 补齐 Codex 请求缺省字段并适配已确认不兼容的请求形状,不递归清洗业务正文。
///
/// 兼容基准是 Codex Core/Desktop 的模型请求,不是公开 OpenAI Responses API。
/// 未知字段继续透传,不能因官方请求结构中没有某个字段就将其列入过滤规则。
@@ -10,6 +41,36 @@ pub(in crate::transport) fn normalize_codex_request_body(body: &mut Map<String,
// 官方 Core/Desktop 显式发送 store=false;仅为缺字段的下游请求补齐,保留显式值。
body.entry("store").or_insert(Value::Bool(false));
// 公开 Responses API 允许 `input` 为字符串(等价于一条 user 文本消息),
// 而 Codex 后端只接受条目数组,否则返回 400 "Input must be a list"。
// 这里按官方 ResponseItem::Message 的形状展开;其他非数组类型不猜测语义,交给上游判定。
if let Some(Value::String(text)) = body.get_mut("input") {
let text = std::mem::take(text);
body.insert(
"input".to_owned(),
json!([{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": text}],
}]),
);
}
// Codex 上游拒绝显式 message 的 system role;沿用官方客户端的 developer
// role 承载指令,只转换已确认的消息形状,保留内容与其他字段。
if let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) {
for item in input {
let Some(item) = item.as_object_mut() else {
continue;
};
if item.get("type").and_then(Value::as_str) == Some("message")
&& item.get("role").and_then(Value::as_str) == Some("system")
{
item.insert("role".to_owned(), Value::String("developer".to_owned()));
}
}
}
for field in [
// Pi 普通 Responses 适配将 maxTokens 映射为 max_output_tokens,
// temperature 则原样写入;Pi 的 Codex 适配也可能发送 temperature。
@@ -5,4 +5,5 @@ mod body;
mod headers;
pub(super) use body::normalize_codex_request_body;
pub(crate) use body::normalize_non_codex_request_body;
pub(super) use headers::is_non_codex_request_header;
@@ -19,6 +19,7 @@ mod response_meta;
pub(crate) mod session;
pub mod subscription;
mod time;
pub(crate) use downstream::normalize_non_codex_request_body;
pub(crate) use endpoints::valid_upstream_base_url;
pub mod tls;
pub mod usage;
@@ -50,7 +50,7 @@ pub use gateway_core::account::RequestLocation as CodexRequestLocation;
/// Codex Desktop 上游请求身份。
///
/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。运行时只会使用
/// 内置基线提供经源码审计的 Core、运行环境和 Desktop 版本。运行时只会使用
/// 同一个官方 Desktop ZIP 中核验出的 Core、Desktop 版本及构建号原子替换版本字段。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodexWireProfile {
@@ -77,6 +77,25 @@ pub struct CodexWireProfile {
pub verified_at: DateTime<Utc>,
}
impl Default for CodexWireProfile {
fn default() -> Self {
Self {
client_kind: ClientKind::Desktop,
originator: "Codex Desktop".to_owned(),
codex_version: "0.153.4".to_owned(),
desktop_version: "26.901.51231".to_owned(),
desktop_build: "8109".to_owned(),
os_type: "Mac OS".to_owned(),
os_version: "15.7.1".to_owned(),
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
residency: None,
// 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。
verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084),
}
}
}
impl CodexWireProfile {
/// 按 bundled Core app-server 的官方格式生成最终 User-Agent。
pub fn user_agent(&self) -> String {
@@ -513,8 +513,10 @@ fn openai_billing_breakdown_with_context(
let pricing = model_pricing(model);
let normalized_tier = normalize_service_tier(service_tier);
let tier = pricing_tier(normalized_tier.as_deref())?;
let standard_rates = effective_rates(pricing, custom, PricingTier::Standard, long_context)?;
let selected_rates = effective_rates(pricing, custom, tier, long_context)?;
let (standard_rates, _) =
effective_rates(pricing, custom, PricingTier::Standard, long_context)?;
let (selected_rates, long_context_billing_applied) =
effective_rates(pricing, custom, tier, long_context)?;
let cache_write_percent = pricing.map_or(0, |pricing| pricing.cache_write_percent);
let mut standard = token_amounts(
standard_rates,
@@ -556,6 +558,7 @@ fn openai_billing_breakdown_with_context(
Some(normalized_tier.unwrap_or_else(|| "default".to_owned())),
multiplier_percent,
)
.with_long_context_billing(long_context_billing_applied)
.with_custom_multiplier(custom.map_or(10_000, |pricing| pricing.multiplier_bps))
}
@@ -564,7 +567,7 @@ fn effective_rates(
custom: Option<&gateway_core::metering::ModelPriceOverride>,
tier: PricingTier,
long_context: bool,
) -> Option<TokenRates> {
) -> Option<(TokenRates, bool)> {
let long_band = match tier {
PricingTier::Standard => "long_standard",
PricingTier::Fast => "long_fast",
@@ -582,15 +585,22 @@ fn effective_rates(
(PricingTier::Flex, true) => "long_flex",
};
if let Some(rates) = custom.and_then(|p| p.bands.get(band)) {
return Some(TokenRates {
input_ticks: rates.input.ticks_per_token(),
output_ticks: rates.output.ticks_per_token(),
cache_read_ticks: rates.cache_read.ticks_per_token(),
cache_write_ticks: Some(rates.cache_write.ticks_per_token()),
explicit_cache: true,
});
return Some((
TokenRates {
input_ticks: rates.input.ticks_per_token(),
output_ticks: rates.output.ticks_per_token(),
cache_read_ticks: rates.cache_read.ticks_per_token(),
cache_write_ticks: Some(rates.cache_write.ticks_per_token()),
explicit_cache: true,
},
uses_long,
));
}
default?.rates(tier, long_context)
let default = default?;
Some((
default.rates(tier, long_context)?,
long_context && default.long_standard.is_configured(),
))
}
pub(crate) fn pricing_catalog()
+151 -18
View File
@@ -44,7 +44,7 @@ use gateway_core::routing::{
RuntimeSnapshot, UpstreamModelId,
};
use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable};
use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig};
use provider_openai::config::OpenAiConfig;
use provider_openai::credential::{CodexCredentialCodec, ImportCodexOAuthCredential};
use provider_openai::transport::profile::APPCAST_POLL_INTERVAL;
use secrecy::SecretString;
@@ -321,9 +321,14 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing()
baseline.release.as_ref().map(|release| release.status),
Some(DesktopReleaseStatus::Unchecked)
);
let selection = OpaqueProviderData::new(json!({
"client": "desktop", "platform": "macos", "versionMode": "fixed",
"codexVersion": "0.102.0", "desktopVersion": "1.2026.190", "desktopBuild": "19012345678",
"osVersion": "15.5.0", "arch": "arm64", "terminal": "xterm-256color"
}).as_object().unwrap().clone());
let profile = admin
.configured_wire_profile(&config.config.initial_client_profile().unwrap())
.expect("imported fixed profile");
.configured_wire_profile(&selection)
.expect("managed fixed profile");
assert_eq!(profile.version, "0.102.0");
assert_eq!(profile.build, None);
assert_eq!(profile.target.os_type, "Mac OS");
@@ -380,6 +385,34 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing()
assert_eq!(fast_billing.total_amount.amount.as_str(), "4.25");
}
#[tokio::test]
async fn openai_legacy_billing_should_not_infer_long_context_flag() {
let config = valid_config();
let bundle = provider_openai::initialize(config.config.clone(), provider_ports())
.await
.expect("OpenAI bundle");
let billing = bundle
.admin_provider()
.calculated_billing(&ProviderBillingInput {
upstream_model_id: "gpt-5.4".to_owned(),
service_tier: None,
input_tokens: Some(300_000),
output_tokens: Some(0),
cached_tokens: Some(0),
cache_write_tokens: Some(0),
total: CurrencyCost {
currency: "USD".to_owned(),
amount: "1.5".parse().expect("stored total"),
},
})
.expect("legacy billing")
.expect("matching billing breakdown");
assert_eq!(billing.total_amount.amount.as_str(), "1.5");
assert_eq!(billing.input_price_per_million.amount.as_str(), "5");
assert!(!billing.long_context_billing_applied);
}
#[tokio::test]
async fn reset_credit_success_with_invalid_body_should_remain_an_unknown_consume_result() {
let server = MockServer::start().await;
@@ -1109,6 +1142,121 @@ async fn openai_admin_keeps_confirmed_exhaustion_separate_from_raw_usage_display
assert_eq!(recovered.windows[0].used_percent, Some(86.0));
}
#[tokio::test]
async fn openai_admin_preserves_expired_window_usage_and_exhaustion_attribution() {
for exhausted in [false, true] {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_admin_expired_window";
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_owned(),
name: "admin expired window".to_owned(),
secret: secret("admin-expired-window-access"),
verified_account: profile("chatgpt-admin-expired-window"),
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
let account = store.account(account_id).expect("stored account");
let past_reset_at = Utc::now().timestamp() - 60;
let observed_at = SystemTime::now() - Duration::from_secs(300);
let weekly_used = if exhausted { 100.0 } else { 74.0 };
let state = if exhausted {
QuotaState::exhausted(
QuotaEvidence::ProviderDenied,
observed_at,
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(past_reset_at as u64)),
)
} else {
QuotaState::allowed(observed_at)
};
store
.compare_and_swap_quota(QuotaObservation {
plan_type: None,
account_id: account.id().clone(),
expected_revision: account.revision(),
quota: OpaqueProviderData::new(
json!({
"rate_limit": {
"allowed": !exhausted,
"limit_reached": exhausted,
"primary_window": {
"used_percent": 15,
"reset_at": past_reset_at + 18_000,
"limit_window_seconds": 18_000,
},
"secondary_window": {
"used_percent": weekly_used,
"reset_at": past_reset_at,
"limit_window_seconds": 604_800,
}
}
})
.as_object()
.expect("quota object")
.clone(),
),
observed_at,
state,
})
.await
.expect("persist raw quota");
let config = valid_config();
let bundle = provider_openai::initialize(
config.config.clone(),
provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())),
)
.await
.expect("OpenAI bundle");
let mut projected = bundle
.admin_provider()
.quota(ProviderQuotaRequest {
account_id: account.id().clone(),
refresh: false,
rolling_usage: None,
})
.await
.expect("project quota");
assert_eq!(projected.limit_reached, exhausted);
// 账号接口还会归一化耗尽展示;过期周窗口不能把触顶错误转移到短期窗口。
projected.apply_limit_reached_display();
let primary = projected
.windows
.iter()
.find(|w| w.window_seconds == Some(18_000))
.expect("primary");
let weekly = projected
.windows
.iter()
.find(|w| w.window_seconds == Some(604_800))
.expect("weekly");
assert_eq!(
(primary.used_percent, primary.limit_reached),
(Some(15.0), false)
);
assert_eq!(
(weekly.used_percent, weekly.limit_reached),
(Some(weekly_used), exhausted)
);
assert_eq!(
weekly.reset_at.map(|reset| reset.timestamp()),
Some(past_reset_at)
);
let raw = store
.get_quotas(std::slice::from_ref(account.id()))
.await
.expect("raw quota")
.pop()
.expect("observation");
assert_eq!(raw.observed_at, observed_at);
assert_eq!(
raw.quota.expose_to_provider()["rate_limit"]["secondary_window"]["used_percent"],
weekly_used
);
}
}
#[tokio::test]
async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit() {
let store = Arc::new(MemoryAccountStore::default());
@@ -1416,21 +1564,6 @@ struct TestOpenAiConfig {
fn valid_config() -> TestOpenAiConfig {
let mut config = OpenAiConfig::default();
config.wire_profile = CodexWireProfileConfig {
originator: "Codex Desktop".to_owned(),
codex_version: "0.102.0".to_owned(),
desktop_version: "1.2026.190".to_owned(),
desktop_build: "19012345678".to_owned(),
os_type: "Mac OS".to_owned(),
os_version: "15.5.0".to_owned(),
arch: "arm64".to_owned(),
terminal: "xterm-256color".to_owned(),
residency: None,
verified_at: Utc
.with_ymd_and_hms(2026, 7, 19, 0, 0, 0)
.single()
.expect("valid test time"),
};
let runtime = tempfile::tempdir().expect("test runtime directory");
config
.resolve_and_validate(&runtime.path().join("deploy"))
+15 -69
View File
@@ -1,32 +1,21 @@
use std::path::Path;
use chrono::{TimeZone as _, Utc};
use provider_openai::config::{
CodexWireProfileConfig, DEFAULT_STREAM_MAX_RETRIES, MAX_STREAM_MAX_RETRIES, OpenAiConfig,
};
use provider_openai::config::{DEFAULT_STREAM_MAX_RETRIES, MAX_STREAM_MAX_RETRIES, OpenAiConfig};
#[test]
fn openai_config_imports_legacy_fixed_profile_without_changing_official_release_facts() {
let mut config = valid_config();
config
.resolve_and_validate(Path::new("/srv/gateway"))
.expect("valid OpenAI config");
let state = config.wire_profile_state();
let selection = provider_openai::transport::profile::selection::ClientProfileSelection::parse(
&config.initial_client_profile().unwrap(),
)
.unwrap();
let profile = selection.resolve(&state).unwrap();
assert_eq!(
state.snapshot().codex_version,
CodexWireProfileConfig::default().codex_version
);
fn openai_config_ignores_removed_yaml_identity_fields() {
let config: OpenAiConfig = serde_json::from_value(serde_json::json!({
"wire_profile": {"originator": "legacy-client", "codex_version": "invalid", "residency": "us"}
})).unwrap();
assert_eq!(config, OpenAiConfig::default());
}
assert_eq!(
profile.user_agent(),
"Codex Desktop/0.102.0 (Mac OS 15.5.0; arm64) xterm-256color (Codex Desktop; 1.2026.190)"
);
assert_eq!(profile.desktop_build, "19012345678");
#[test]
fn openai_config_keeps_residency_separate_from_client_identity() {
use provider_openai::transport::profile::CodexResidency;
let config: OpenAiConfig = serde_json::from_str(r#"{"residency":"us"}"#).unwrap();
assert_eq!(config.residency, Some(CodexResidency::Us));
assert!(serde_json::from_str::<OpenAiConfig>(r#"{"residency":"invalid"}"#).is_err());
}
#[test]
@@ -39,33 +28,6 @@ fn openai_config_derives_identity_secret_from_runtime_data_dir() {
assert!(format!("{config:?}").contains("/srv/gateway/runtime-data/identity_hmac_secret"));
}
#[test]
fn openai_config_rejects_noncanonical_versions_and_empty_fields() {
let mut config = valid_config();
config.wire_profile.codex_version = "latest".to_owned();
assert!(
config
.resolve_and_validate(Path::new("/srv/gateway"))
.is_err()
);
let mut config = valid_config();
config.wire_profile.desktop_version = "1.preview".to_owned();
assert!(
config
.resolve_and_validate(Path::new("/srv/gateway"))
.is_err()
);
let mut config = valid_config();
config.wire_profile.originator.clear();
assert!(
config
.resolve_and_validate(Path::new("/srv/gateway"))
.is_err()
);
}
#[test]
fn openai_config_restricts_upstream_base_url_to_https_or_loopback_http() {
for base_url in [
@@ -135,7 +97,7 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
)
);
assert_eq!(
config.wire_profile_state().snapshot().user_agent(),
provider_openai::transport::profile::CodexWireProfile::default().user_agent(),
"Codex Desktop/0.153.4 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.901.51231)"
);
}
@@ -152,21 +114,5 @@ fn openai_stream_retry_budget_uses_the_official_hard_cap() {
}
fn valid_config() -> OpenAiConfig {
let mut config = OpenAiConfig::default();
config.wire_profile = CodexWireProfileConfig {
originator: "Codex Desktop".to_owned(),
codex_version: "0.102.0".to_owned(),
desktop_version: "1.2026.190".to_owned(),
desktop_build: "19012345678".to_owned(),
os_type: "Mac OS".to_owned(),
os_version: "15.5.0".to_owned(),
arch: "arm64".to_owned(),
terminal: "xterm-256color".to_owned(),
residency: None,
verified_at: Utc
.with_ymd_and_hms(2026, 7, 19, 0, 0, 0)
.single()
.expect("valid test time"),
};
config
OpenAiConfig::default()
}
@@ -277,7 +277,7 @@ fn pat_service(server: &MockServer) -> CodexCredentialAdminService {
client_id: "test-public-client".to_owned(),
token_endpoint: format!("{}/oauth/token", server.uri()),
},
provider_openai::OpenAiConfig::default().wire_profile_state(),
provider_openai::transport::profile::CodexWireProfileState::new(Default::default()),
)
.expect("auth client");
CodexCredentialAdminService::new(
@@ -389,7 +389,7 @@ async fn pat_import_times_out_without_falling_back_to_document_identity() {
client_id: "test-public-client".to_owned(),
token_endpoint: format!("{}/oauth/token", server.uri()),
},
provider_openai::OpenAiConfig::default().wire_profile_state(),
provider_openai::transport::profile::CodexWireProfileState::new(Default::default()),
);
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
@@ -1207,10 +1207,7 @@ fn native_continuation_surfaces_the_original_accounts_quota_status_to_the_coordi
)
.expect_err("the selector must surface the unavailable native account");
assert!(matches!(
error,
CredentialSelectionError::NoEligibleCredential
));
assert!(matches!(error, CredentialSelectionError::QuotaExhausted));
}
#[test]
@@ -1279,10 +1276,7 @@ fn native_continuation_surfaces_the_original_accounts_quota_signal_to_the_coordi
)
.expect_err("the selector must surface the quota-limited native account");
assert!(matches!(
error,
CredentialSelectionError::NoEligibleCredential
));
assert!(matches!(error, CredentialSelectionError::QuotaExhausted));
}
#[test]
@@ -10,7 +10,6 @@ use gateway_admin::model::provider_credentials::{
use gateway_admin::model::{MutationActor, MutationContext};
use gateway_core::account::{AccountStatus, ProviderAccountId};
use gateway_core::routing::ProviderKind;
use provider_openai::OpenAiConfig;
use provider_openai::credential::token_client::{
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
AuthorizationTokenSet,
@@ -362,7 +361,7 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() {
}
fn profile() -> CodexWireProfileState {
OpenAiConfig::default().wire_profile_state()
provider_openai::transport::profile::CodexWireProfileState::new(Default::default())
}
#[tokio::test]
@@ -128,3 +128,97 @@ async fn periodic_checks_continue_when_reset_is_unknown_or_far_in_the_future() {
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
}
}
#[tokio::test]
async fn allowed_account_with_expired_window_synchronizes_at_reset_grace() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_allowed_expired_window").await;
let account = store
.account("acct_allowed_expired_window")
.expect("account");
let server = MockServer::start().await;
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
let short_reset = Utc::now().timestamp() - 114;
let usage = |used: u32, reset: i64| {
json!({"rate_limit": {
"allowed": true,
"primary_window": {
"used_percent": used,
"reset_at": reset,
"limit_window_seconds": 18_000,
}
}})
};
mount_usage(&server, usage(100, short_reset)).await;
service
.refresh_account(account.id())
.await
.expect("seed quota with 100% usage");
assert!(
Utc::now().timestamp() < short_reset + 120,
"fixture must precede grace deadline"
);
let requests = server.received_requests().await.expect("requests").len();
service.synchronize().await.expect("initial periodic check");
service
.synchronize()
.await
.expect("repeat check before grace");
assert_eq!(
server.received_requests().await.expect("requests").len(),
requests,
"正常账号首次复核也必须等待 reset 宽限期"
);
// 当到达 reset + 120s 宽限期后,账号虽然处于 allowed 状态,但包含已到期的非零用量窗口,被调度主动同步。
mount_usage(&server, usage(0, short_reset + 18_000)).await;
wait_for_reset_grace(short_reset).await;
let summary = service
.synchronize()
.await
.expect("synchronize expired window");
assert_eq!(summary.updated, 1);
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
let snapshot = service
.read_account(account.id())
.await
.expect("read quota")
.expect("snapshot");
assert_eq!(snapshot.windows()[0].used_percent(), Some(0.0));
service.synchronize().await.expect("skip fresh window");
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
}
#[tokio::test]
async fn allowed_expired_window_refresh_is_throttled_when_observation_is_unchanged() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_allowed_expired_retry").await;
let account = store
.account("acct_allowed_expired_retry")
.expect("account");
let server = MockServer::start().await;
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
let usage = json!({"rate_limit": {
"allowed": true,
"primary_window": {
"used_percent": 74,
"reset_at": Utc::now().timestamp() - 180,
"limit_window_seconds": 18_000,
}
}});
mount_usage(&server, usage.clone()).await;
service
.refresh_account(account.id())
.await
.expect("seed quota");
mount_usage(&server, usage).await;
assert_eq!(service.synchronize().await.expect("first check").updated, 1);
service.synchronize().await.expect("throttled repeat check");
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
}
@@ -431,7 +431,7 @@ async fn scheduled_refresh_persists_retryable_message_inside_the_two_hour_window
client_id: "test-public-client".to_owned(),
token_endpoint: format!("{}/oauth/token", server.uri()),
},
provider_openai::OpenAiConfig::default().wire_profile_state(),
provider_openai::transport::profile::CodexWireProfileState::new(Default::default()),
)),
Arc::new(RefreshLeases),
Arc::new(RefreshCredentialState),
@@ -57,7 +57,8 @@ async fn refresh_tracks_the_online_profile_without_contaminating_code_exchange()
.expect(4)
.mount(&server)
.await;
let profile = provider_openai::OpenAiConfig::default().wire_profile_state();
let profile =
provider_openai::transport::profile::CodexWireProfileState::new(Default::default());
let client = provider_openai::credential::token_client::openai_token_client(
TokenClientConfig {
client_id: "audit-client".to_owned(),
@@ -123,7 +124,7 @@ fn client(server: &MockServer) -> OpenAiTokenClient {
client_id: "test-public-client".to_owned(),
token_endpoint: format!("{}/oauth/token", server.uri()),
},
provider_openai::OpenAiConfig::default().wire_profile_state(),
provider_openai::transport::profile::CodexWireProfileState::new(Default::default()),
)
}

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