Compare commits

...
38 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
ZyyandCodex ab828650e6 chore(release): v3.11.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 06:41:19 +08:00
ZyyandCodex 4c7e4e08f6 feat: refine admin controls and overload protection
Refine settings navigation, selective pricing sync, model deletion, and admin interactions. Consolidate unreleased migrations and keep Fast policy scoped to groups. Increase confirmed capacity-rejection feedback without changing ordinary failure recovery.

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

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

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

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

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

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

---------

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

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

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

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

Co-authored-by: Codex <noreply@openai.com>
2026-09-18 15:55:36 +08:00
366 changed files with 18157 additions and 2485 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. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
+32 -7
View File
@@ -1,6 +1,6 @@
---
name: release
description: 准备或执行当前仓库发版,维护中文发布说明。用于“发版”“发个新版本”“准备 vX.Y.Z”“推荐版本号”“整理更新日志/发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing a repository release; not for deploying/restarting an instance, publishing application data, or authoring a release skill.
description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行版和 exp 实验版发版,维护中文发布说明。用于“发版”“发布 beta/rc/实验版”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable, prerelease, or experimental releases; not for deploying/restarting an instance or authoring a release skill.
---
# 发版
@@ -12,12 +12,20 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
| 发布 alpha、beta、rc | 从 `main` 发布,确定目标正式版本和阶段,核对预发行标记与晋级路径 |
| 发布实验版、exp 版本 | 从目标实验分支发布,按下方实验版流程确定版本、文案和渠道隔离 |
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
沿用当前会话明确的版本、范围与授权;用户已要求正式发版且条件满足时直接执行,不重复确认。
沿用当前会话明确的版本、范围与授权;用户已授权发布目标版本且条件满足时直接执行,不重复确认。
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
稳定版以及面向下一正式版本的 alpha、beta、rc 从 `main` 发布,预发行 tag 使用 `vX.Y.Z-<阶段>.N`。
发版前读取[版本命名与升级规则](../../../deploy/README.md#版本命名与升级规则),作为命名、阶段晋级和更新边界的权威约定。
实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`,
展示标题包含分支用途名;先读 [实验版发布](references/experimental.md) 确定标题与编号,再复用本文的准备、执行与验收步骤。
实验功能是否合入 `main` 是单独的维护决策,发布实验版不包含合并功能的授权。
## 读取当前流程
先读 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),再按任务检查:
@@ -33,9 +41,23 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
命令从仓库根目录执行;每次以当前脚本和工作流为准,不另写发布脚本或绕过已有质量检查。
用户明确指定其他仓库时先读取其发布流程,不能直接套用这里的路径和命令。
## 发行通道核对
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
- 正式版只接收同一大版本内更新的正式版。alpha → beta → rc → 正式版只在相同 `X.Y.Z` 内晋级,
可以跳过阶段,不能倒退;转正后不自动进入下一轮预发行。exp 仅接收同基线、同一轮实验的后续编号,
不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
- 验收更新器时,核对检查更新与执行更新使用相同规则。只有当前构建支持更新且存在允许的更高目标时
才能返回 `hasUpdate=true`;被禁止的目标即使版本领先,也不能出现在更新标记、可用版本或发布说明中。
检查失败须单独报告。核对上一发行实际包含的更新能力,不能因新包支持在线更新就宣称旧包也能在线安装它。
## 准备版本与说明
1. 确认发布分支、upstream、目标仓库、工作区和完整待推送范围。本仓库沿用 `main`;
1. 确认发布类型、对应分支、upstream、目标仓库、工作区和完整待推送范围;
未提交或未跟踪内容分别核对,不因脚本只检查已跟踪文件就认定所有内容都已纳入发布。
2. 读取版本文件、已有 tag 和实际发行记录,确认上一发布基线与本次目标提交。
根据基线到目标的提交和最终差异归纳变更,剔除回退、重复和未进入发布范围的内容。
@@ -48,13 +70,13 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
涉及数据库迁移时,对照同一大版本的上一正式 Release 检查已发布迁移是否保持不变,并验证升级路径;
涉及数据库迁移时,对照同一大版本的上一正式 Release 及本轮预发行/实验线的已发布版本检查迁移是否保持不变,并验证升级路径;
跨大版本按项目部署约定判断,不默认支持在线升级。区分未发布迁移的整理、已执行这些迁移的源码部署实例,
以及 CI 实际采用的冻结基线。未发版不代表迁移从未被执行,CI 冻结检查失败也不等于正式版数据库升级失败;
按各自证据报告兼容性与检查结果,不因未发版而自动放宽现有冻结规则。
只修改说明时无需重建前后端;正式发版仍要跟踪发布工作流针对目标提交的检查与产物。
只修改说明时无需重建前后端;实际发版仍要跟踪发布工作流针对目标提交的检查与产物。
## 执行正式发版
## 执行发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
@@ -62,7 +84,7 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权正式发布后,将 `release_version` 设为已确定的版本,执行:
在准备完成且用户已授权发布后,将 `release_version` 设为已确定的版本,执行:
```bash
release/publish "$release_version"
@@ -71,6 +93,9 @@ release/publish "$release_version"
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
## 恢复与验收
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
+2 -2
View File
@@ -1,4 +1,4 @@
interface:
display_name: "release"
short_description: "按仓库流程整理中文发布说明、核对版本并完成发行与验证"
default_prompt: "使用 $release 准备下一个版本的中文发布说明,并核对发版条件。"
short_description: "按仓库流程准备稳定版或实验版的中文说明,完成发行与渠道验证"
default_prompt: "使用 $release 按指定的稳定版或实验版渠道准备中文发布说明,并核对发版条件。"
@@ -0,0 +1,65 @@
# 实验版发布
适用于独立于主版本维护、供用户主动选择体验的实验功能。发布入口仍是仓库的 `release/publish`,
构建复用目标实验分支的 `.github/workflows/release.yml` 与 `release/platforms.yaml`。
## 分支与版本
- 从用户指定或会话已确定的 `experimental/<用途>` 分支发布,核对它的 upstream 和实验功能范围。
使用独立 worktree 时保留其他工作区内容;不把实验代码合入 `main`,也不顺带升级运行实例。
- 版本采用 `vX.Y.Z-exp.N`:`X.Y.Z` 表示本轮采用的正式版基线,`N` 从 1 开始递增。
同一基线的 `exp.N` 只代表同一轮实验,不得用于不同实验分支;开始其他实验时使用不同的实际基线,不能仅接续编号。
后续只有实际同步了新的正式版基线,才调整 `X.Y.Z`;调整后属于另一实验线,发布说明需交代手动迁移方式。
- 核对已有 tag、Release 与目标分支源码,先区分重试和新发行。已发布的旧命名版本保留,
不为统一名称移动 tag 或替换原有产物。
- GitHub Release 展示标题采用 `vX.Y.Z-<用途>.M`,`<用途>` 取实验分支名去掉 `experimental/` 前缀后的部分。
发布前通过 `gh api` 核对相同正式版基线与用途的已有 Release 标题和旧命名 tag,新发行取已有最大编号加 1,首次从 1 开始。
展示编号 `M` 独立于实际 tag 的 `exp.N`,旧命名发行也计入,不能因改用 `exp.N` 而重置;同一发行重试沿用已确定编号。
例如已有 `v3.10.0-codex-anti-degradation.1`,下一次 tag 即使是 `v3.10.0-exp.1`,标题也应为 `v3.10.0-codex-anti-degradation.2`。
- tag 和产物继续使用 `exp.N`:镜像版本去掉 tag 开头的 `v`,安装包沿用项目的版本、系统和架构命名。
发布说明首行仍匹配实际 tag;关联 Issue 和试验目的写在正文中。新标题规则不自动改名已有 Release。
## 先核对发布能力
在目标实验分支读取实际工作流,确认以下行为;新实验分支若缺少支持,先在既有流程中补齐并验证:
- `exp.N` 产生 `experimental` 构建类型,所有平台的二进制与镜像使用同一版本和源码提交。
应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。
发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
正式版和 alpha、beta、rc 均不接收实验版更新;环境变量不能改变发行线。
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
核对发布附件确实包含它,不能只在文案中承诺。
- 实验版正文使用下方完整文案,不再由工作流重复追加安装章节或自动生成混入正式版基线的提交列表。
- Release 的 `name` 使用上述展示标题。工作流若默认以 tag 为标题,发布成功后用 `gh release edit --title` 设置标题,
再通过 `gh api` 回读核对;仅修改 `name`,保留 tag、产物、正文和预发行状态。
## 中文发布说明
在目标分支的 `release/notes.md` 编写,首行为 `# vX.Y.Z-exp.N`。后续内容按实际需要组织:
1. **实验说明**:开头简述正式版基线、验证的问题与适用人群。效果尚未验证时明确说明,
不把假设写成已解决的问题,不承诺实验功能一定进入主版本。
2. **本次变化**:首次发行概括相对正式版基线新增的实验能力,关联 PR;后续发行只列相对上个实验版的变化。
同步主分支修复时说明它对实验用户的影响,不复制整份正式版更新记录。
3. **安装与使用**:给出本版本真实镜像命令、支持平台、部署文件及实验部署文档链接。
交代功能是否默认启用和必要配置;保留工作流实际提供的校验与证明说明。
4. **使用须知**:只写本次适用的已知限制、数据隔离、迁移与回退边界,以及在线更新条件和手动迁移方式。
5. **反馈与致谢**:链接关联 Issue 或已有反馈入口,感谢实际贡献者。没有相应内容的章节可以省略。
用户要求先审核文案时,先展示完整正文;已批准的同一版本文案无需重复确认。
正常新发行把说明纳入版本提交并从 tag 构建,不先创建公开空 Release 再回填附件。
## 执行与验收
按主技能准备说明和其他变更后,在实验分支执行 `release/publish <X.Y.Z-exp.N>`。
入口创建版本提交和 tag,并从该分支 dispatch 发布工作流;不要从 `main` 发布实验代码。
按主技能的「发行通道核对」确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。
检查展示标题的分支用途与历史续号、它所对应的实际 tag,以及目标 tag 的源码提交、完整产物、校验和、多架构平台和 Pre-release 标记。
同基线的 `X.Y.Z-exp.N` 按 SemVer 排序低于正式 `X.Y.Z`;渠道隔离仍以更新器过滤和发布标签规则为准。
失败时按主技能从未完成步骤恢复,不通过换版本号或移动 tag 掩盖失败。
仅维护已有页面文案时不重新构建;报告实际完成状态、发布链接及验证缺口。
@@ -0,0 +1,33 @@
body:
- type: markdown
attributes:
value: |
欢迎提问。提交前可以搜索已有讨论,并查看[部署文档](https://github.com/zyycn/codex-proxy-rs/blob/main/deploy/README.md)。
明确的程序故障请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose);不确定是不是 Bug,也可以先在这里交流。
请移除密钥、令牌、Cookie、代理认证和私人内容,不要上传完整配置或请求转储。解决后请标记帮助到你的答案。
- type: textarea
id: question
attributes:
label: 想解决什么问题
description: 说明希望完成的任务、当前遇到的困难,标题尽量具体。
validations:
required: true
- type: input
id: version
attributes:
label: 版本与部署方式
description: 涉及具体实例时,填写完整版本号或提交号,以及 Docker、二进制或源码部署方式。
- type: textarea
id: attempts
attributes:
label: 已尝试的方法与结果
description: 可以附看过的文档、相关讨论、脱敏错误片段或截图;未尝试可留空。
- type: textarea
id: environment
attributes:
label: 相关环境与补充信息
description: 按问题需要补充操作系统、客户端、反向代理或中转链路,无需提供无关信息。
@@ -0,0 +1,44 @@
body:
- type: markdown
attributes:
value: |
欢迎分享实验版本的使用体验,包括有效、无明显变化或遇到限制的情况。
请先查看对应[预发行页面](https://github.com/zyycn/codex-proxy-rs/releases)的部署与数据隔离说明,并搜索已有反馈。
明确的程序缺陷请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose),在两处互相附链接即可,无需重复粘贴。
请移除密钥、令牌、Cookie、代理认证和私人内容,不要上传完整配置或请求转储。
- type: input
id: version
attributes:
label: 实验版本或提交号
description: 填写实际运行的完整版本号或提交号,不要只写 exp 或 latest。
validations:
required: true
- type: textarea
id: scenario
attributes:
label: 使用场景与环境
description: 说明启用的实验功能、部署方式、客户端及与结果相关的条件,不需要提供账号身份信息。
validations:
required: true
- type: textarea
id: observation
attributes:
label: 观察到的效果或限制
description: 描述实际现象、出现频率和观察时长;请区分观察结果与原因推测。
validations:
required: true
- type: textarea
id: comparison
attributes:
label: 对照与复现信息
description: 如已对比启用前后或不同配置,请说明条件和结果;没有对照也可以反馈,无需额外测试。
- type: textarea
id: references
attributes:
label: 相关链接与补充材料
description: 可附已有 Issue、讨论、脱敏日志片段或截图。
@@ -0,0 +1,29 @@
body:
- type: markdown
attributes:
value: |
这里适合交流尚在探索的想法、方案取舍和使用场景,不要求先给出完整实现。
已有明确场景与预期行为时,可以直接提交[功能建议 Issue](https://github.com/zyycn/codex-proxy-rs/issues/new?template=feature-request.yml),无需先开讨论。
请先搜索相关讨论,每个主题围绕一个问题展开。
- type: textarea
id: use-case
attributes:
label: 使用场景与当前不便
description: 希望完成什么任务,目前的做法有什么不足?
validations:
required: true
- type: textarea
id: idea
attributes:
label: 想法与希望讨论的方向
description: 可以说明期望体验、考虑过的替代方法,以及还不确定的地方。
validations:
required: true
- type: textarea
id: references
attributes:
label: 参考资料
description: 可附相关 Issue、讨论或其他项目的具体行为;示例和截图请先脱敏。
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: 使用问答
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答
about: 安装部署、配置和日常使用求助;明确的 Bug 请选择上方问题反馈模板。
- name: 想法与方案讨论
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论
about: 探索尚未明确的需求与方案;具体功能需求也可以直接提交功能建议。
- name: 实验版体验反馈
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈
about: 分享实验版本的使用效果、适用场景和限制。
+5 -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
+10 -1
View File
@@ -4,7 +4,16 @@
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
## 提交 Issue
## 交流与反馈
[讨论区](https://github.com/zyycn/codex-proxy-rs/discussions) 用于使用问答、想法讨论、实验反馈和经验分享。
提问时按需补充版本、部署方式、已尝试的方法及脱敏信息;问题解决后,请在「使用问答」中标记有帮助的答案。
明确的 Bug、具体功能需求或协作规则建议使用 Issue,无需先开讨论。
讨论形成可执行事项后,可以新建 Issue 并互相附链接;已有 Issue 的后续信息继续补充在原帖,避免重复记录。
围绕问题友善交流,欢迎不同使用场景和方案观点。
### 提交 Issue
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏。
+38 -19
View File
@@ -12,22 +12,36 @@
[![GHCR](https://img.shields.io/badge/GHCR-codex--proxy--rs-2496ED?logo=docker&logoColor=white&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square)](LICENSE)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
[快速预览](#快速预览) · [快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
</div>
> [!NOTE]
> 本项目提供 Responses API,不支持 `/v1/chat/completions`。接入前请确认客户端支持 Responses 协议。
> 本项目提供 Responses API,不支持 `/v1/chat/completions`,接入前请确认客户端支持 Responses 协议
## 快速预览
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计
| 登录信息 | 默认值 |
| --- | --- |
| 地址 | <https://codex-proxy-rs.ainz.cc> |
| 登录身份 | 管理员 |
| 账号 | `admin@cpr.local` |
| 密码 | `039c18de2aeac46d23ead7766bb07bbbe3747233c66a128e` |
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据。
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息
## 快速开始
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
以下命令适用于 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
@@ -35,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` 时传入环境变量:
@@ -52,35 +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 客户端**:填写以下信息:
| 配置 | 值 |
| --- | --- |
@@ -99,6 +111,7 @@ curl http://127.0.0.1:8080/v1/models \
- [客户端接入与生图](deploy/README.md#客户端配置)
- [部署、备份与恢复](deploy/README.md)
- [API 参考](docs/api.md)
- [模型定价与手动同步](docs/api.md#模型定价)
- [系统架构](docs/architecture.md)
- [管理端主题](docs/theme.md)
- [数据库迁移](backend/migrations/README.md)
@@ -106,8 +119,14 @@ curl http://127.0.0.1:8080/v1/models \
## 社区
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台。
欢迎到 [Discussions 讨论区](https://github.com/zyycn/codex-proxy-rs/discussions)交流:
[使用问答](https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答)、
[想法讨论](https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论)、
[实验反馈](https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈)与
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台
## 许可证
本项目基于 [Apache License 2.0](LICENSE) 开源。
本项目基于 [Apache License 2.0](LICENSE) 开源
+52 -8
View File
@@ -154,9 +154,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -165,14 +165,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.39.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -2203,6 +2204,17 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "markup5ever"
version = "0.39.0"
@@ -2723,6 +2735,7 @@ dependencies = [
"chrono",
"chrono-tz",
"cookie",
"crc32fast",
"flate2",
"futures",
"gateway-admin",
@@ -2747,6 +2760,7 @@ dependencies = [
"serde_json",
"sha2 0.11.0",
"subtle",
"tar",
"tempfile",
"thiserror",
"tokio",
@@ -2760,6 +2774,8 @@ dependencies = [
"url",
"uuid",
"wiremock",
"xz2",
"zip",
"zstd",
]
@@ -3142,9 +3158,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.36"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
@@ -3180,9 +3196,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
@@ -4281,6 +4297,12 @@ dependencies = [
"thiserror",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -4796,6 +4818,15 @@ version = "0.8.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576"
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yaml-rust2"
version = "0.11.0"
@@ -4924,6 +4955,19 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
]
[[package]]
name = "zlib-rs"
version = "0.6.6"
+3 -6
View File
@@ -17,8 +17,8 @@ pub struct GatewayConfig {
#[serde(default)]
client: gateway_admin::ClientConfig,
api: gateway_api::ApiConfig,
#[serde(default)]
openai: provider_openai::OpenAiConfig,
xai: provider_xai::XaiConfig,
}
impl LoadableConfig for GatewayConfig {
@@ -46,9 +46,6 @@ impl LoadableConfig for GatewayConfig {
self.openai
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
self.xai
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("xai"))?;
Ok(())
}
}
@@ -64,7 +61,6 @@ pub async fn run() -> Result<(), BootstrapError> {
client,
api,
openai,
xai,
} = config;
let host = gateway_host::initialize(host).await?;
@@ -74,7 +70,7 @@ pub async fn run() -> Result<(), BootstrapError> {
let provider_ports = store.provider_ports();
let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?;
host.report_startup_ready("OpenAI Provider");
let mut xai = provider_xai::initialize(xai, provider_ports).await?;
let mut xai = provider_xai::initialize(provider_ports).await?;
host.report_startup_ready("xAI Provider");
let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
let mut core = gateway_core::initialize(store.core_ports(), providers).await?;
@@ -84,6 +80,7 @@ pub async fn run() -> Result<(), BootstrapError> {
client,
store.admin_ports(),
gateway_admin::AdminRuntimePorts {
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
providers: vec![openai.admin_provider(), xai.admin_provider()],
snapshot: core.snapshot_control(),
account_probe: core.account_probe(),
+2 -4
View File
@@ -184,6 +184,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
&[
"client_distribution",
"config",
"pricing",
"proxy_probe",
"serve",
"system_update",
@@ -195,10 +196,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
"crates/providers/openai",
&["config", "credential", "transport"],
),
(
"crates/providers/xai",
&["config", "credential", "transport"],
),
("crates/providers/xai", &["credential", "transport"]),
];
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景。
+25 -30
View File
@@ -291,9 +291,6 @@ fn config_loader_should_ignore_unknown_fields_in_configuration_sections() {
"/client",
"/api",
"/openai",
"/openai/wire_profile",
"/xai",
"/xai/wire_profile",
] {
let mut extended = document.clone();
extended.pointer_mut(path).expect("configuration section")["unused_setting"] =
@@ -364,7 +361,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
);
match case {
"unused" => assert!(
stderr.contains("openai.wire_profile.location") && stderr.contains("已忽略"),
stderr.contains("openai.wire_profile") && stderr.contains("已忽略"),
"{stderr}"
),
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
@@ -380,6 +377,26 @@ fn config_loader_should_reject_missing_explicit_fields() {
assert_rejected(valid_config().replace(" request_id_header: 'x-request-id'\n", ""));
}
#[test]
fn config_loader_should_allow_missing_provider_configuration() {
let mut document = valid_config_document();
document.as_object_mut().unwrap().remove("openai");
document.as_object_mut().unwrap().remove("xai");
parse_config(&document.to_string()).expect("provider defaults require no YAML identity");
}
#[test]
fn config_loader_should_ignore_removed_provider_identity_sections() {
let mut document = valid_config_document();
document["openai"]["wire_profile"] = serde_json::json!({"codex_version":"legacy-invalid"});
document["xai"] = serde_json::json!({"wire_profile": {"client_identifier": "legacy-client"}});
let (config, _directory) =
parse_config(&document.to_string()).expect("removed identities ignored");
let debug = format!("{config:?}");
assert!(!debug.contains("legacy-invalid"));
assert!(!debug.contains("legacy-client"));
}
#[test]
fn config_loader_should_reject_unsupported_schema_version() {
assert_rejected(valid_config().replace("schema_version: 1", "schema_version: 2"));
@@ -444,11 +461,6 @@ fn config_loader_should_reject_missing_client_session_ttl() {
assert_rejected(config.to_string());
}
#[test]
fn config_loader_should_reject_invalid_codex_cli_version() {
assert_rejected(valid_config().replace("codex_version: '0.153.4'", "codex_version: 'latest'"));
}
#[test]
fn config_loader_should_reject_disabled_all_log_outputs() {
assert_rejected(
@@ -464,27 +476,10 @@ fn config_loader_should_reject_zero_server_port() {
}
#[test]
fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replace("desktop_build: '8109'", "desktop_build: 'build'"));
}
#[test]
fn config_loader_should_ignore_removed_request_location_setting() {
let original = valid_config();
parse_config(&original).expect("location belongs to runtime settings");
for value in [
"null",
"{}",
"{ country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
] {
let legacy = original.replacen(
" residency: us",
&format!(" residency: us\n location: {value}"),
1,
);
assert_ne!(legacy, original);
parse_config(&legacy).expect("removed request location does not prevent startup");
}
fn config_loader_should_validate_openai_residency() {
let mut document = valid_config_document();
document["openai"]["residency"] = serde_json::json!("invalid");
assert_rejected(document.to_string());
}
fn assert_rejected(config: String) {
+1 -1
View File
@@ -25,6 +25,7 @@ rand_core = { version = "0.6.4", features = ["getrandom"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
secrecy = { version = "0.10.3", features = ["serde"] }
sha2 = "0.11"
subtle = "2.6.1"
thiserror = "2.0.18"
tracing = "0.1.44"
@@ -34,7 +35,6 @@ chrono-tz = "0.10"
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
[dev-dependencies]
sha2 = "0.11"
tempfile = "3"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] }
+13 -5
View File
@@ -51,7 +51,7 @@ use use_case::{
const OPENAI_PROVIDER_KIND: &str = "openai";
const XAI_PROVIDER_KIND: &str = "xai";
const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12;
const WEAK_INITIAL_PASSWORDS: &[&str] = &[
const WEAK_ADMIN_PASSWORDS: &[&str] = &[
"",
"admin",
"123456",
@@ -149,7 +149,7 @@ impl AdminConfig {
let password = self.default_password.expose().trim();
if password.len() < MINIMUM_INITIAL_PASSWORD_BYTES
|| password.contains('$')
|| WEAK_INITIAL_PASSWORDS.contains(&password.to_ascii_lowercase().as_str())
|| WEAK_ADMIN_PASSWORDS.contains(&password.to_ascii_lowercase().as_str())
{
return Err(AdminConfigError::WeakInitialPassword);
}
@@ -296,6 +296,7 @@ impl AdminBundle {
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
pub struct AdminRuntimePorts {
pub pricing_source: Arc<dyn ports::pricing::PricingSource>,
pub providers: Vec<Arc<dyn ProviderAdmin>>,
pub snapshot: Arc<dyn SnapshotControl>,
pub account_probe: Arc<dyn AccountProbe>,
@@ -317,6 +318,7 @@ pub async fn initialize(
runtime: AdminRuntimePorts,
) -> Result<AdminBundle, AdminError> {
let AdminRuntimePorts {
pricing_source,
providers,
snapshot,
account_probe: probe,
@@ -344,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?;
@@ -368,10 +370,13 @@ 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 openai = Arc::new(DefaultOpenAiService::new(
openai,
@@ -406,19 +411,22 @@ pub async fn initialize(
client_keys: Arc::new(DefaultClientKeyService::new(
store.client_keys(),
snapshot.clone(),
registry.clone(),
)),
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
observability: Arc::new(DefaultObservabilityService::new(
store.observability(),
store.accounts(),
store.settings(),
registry,
registry.clone(),
)),
settings: Arc::new(DefaultSettingsService::new(
store.settings(),
snapshot.clone(),
registry,
pricing_source,
)),
system: Arc::new(DefaultSystemService::new(system)),
system,
openai,
xai,
import_tasks,
+19 -2
View File
@@ -109,8 +109,25 @@ impl From<ClientAuthenticationError> for LoginError {
/// 服务端已验证的身份绑定,不保存密码或原始 API Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionSubject {
Admin { admin_user_id: String },
Key { client_key_id: ClientApiKeyId },
Admin {
admin_user_id: String,
credential_fingerprint: String,
},
Key {
client_key_id: ClientApiKeyId,
},
}
/// 仅已登录管理员可以修改自己的密码。
pub struct ChangePassword {
pub current_password: String,
pub new_password: String,
}
impl std::fmt::Debug for ChangePassword {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("ChangePassword([REDACTED])")
}
}
/// 两种登录方式共用的固定有效期会话。
@@ -86,6 +86,8 @@ pub struct ClientKeyListQuery {
/// 不含完整明文 Key 的管理投影。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyRecord {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -109,7 +111,7 @@ pub struct ClientKeyPage {
pub next_cursor: Option<ClientKeyCursor>,
}
/// 仅在创建或显式 reveal 时跨越管理边界的明文 Key。
/// 仅在创建或显式读取密钥配置时返回的明文 Key;Debug 隐去明文。
#[derive(Clone, PartialEq, Eq)]
pub struct ClientKeySecret {
pub record: ClientKeyRecord,
@@ -144,6 +146,8 @@ impl fmt::Debug for ClientKeySecret {
/// API 提交的 Client Key 创建命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub custom_key: Option<PlaintextClientApiKey>,
pub name: String,
pub label: Option<String>,
@@ -155,6 +159,8 @@ pub struct CreateClientKey {
/// 管理用例生成 ID 与明文后的持久化命令。
#[derive(Clone, PartialEq, Eq)]
pub struct NewClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -179,6 +185,8 @@ impl fmt::Debug for NewClientKey {
/// 修改 Client Key 的公开策略字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKey {
pub openai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub xai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -195,6 +203,21 @@ pub struct SetClientKeyEnabled {
pub enabled: bool,
}
/// 管理员选择的金额重置范围。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientKeyBudgetPeriod {
Daily,
Weekly,
All,
}
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResetClientKeyBudget {
pub id: ClientApiKeyId,
pub period: ClientKeyBudgetPeriod,
}
/// 删除 Client Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteClientKey {
@@ -11,6 +11,7 @@ pub mod client_keys;
pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod pricing;
pub mod provider_credentials;
pub mod proxies;
pub mod quota_forecast;
@@ -297,6 +297,7 @@ pub struct ProviderBillingInput {
/// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageCalculatedBillingFact {
pub breakdown: Option<CalculatedBillingBreakdown>,
pub bucket_start: DateTime<Utc>,
pub provider_kind: String,
pub upstream_model_id: String,
@@ -311,6 +312,9 @@ pub struct UsageCalculatedBillingFact {
/// Provider 已确认的逐项费用与单价。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedBillingBreakdown {
pub long_context_billing_applied: bool,
pub image: Option<ImageBillingBreakdown>,
pub custom_multiplier_bps: u32,
pub input_amount: CurrencyCost,
pub output_amount: CurrencyCost,
pub cache_read_amount: CurrencyCost,
@@ -325,6 +329,16 @@ pub struct CalculatedBillingBreakdown {
pub multiplier_percent: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageBillingBreakdown {
pub input_tokens: u64,
pub cached_tokens: u64,
pub input_amount: CurrencyCost,
pub cache_read_amount: CurrencyCost,
pub input_price_per_million: CurrencyCost,
pub cache_read_price_per_million: CurrencyCost,
}
/// 单次请求的费用语义。
///
/// Provider 上报费用或无法恢复逐项价格时保留总额;Provider 验证成功后升级为完整分解。
@@ -533,6 +547,7 @@ pub struct DashboardObservation {
/// 使用记录表格的窄读模型。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageListRecord {
pub client_api_key_name: Option<String>,
pub id: String,
pub endpoint: String,
pub client_transport: String,
@@ -541,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>,
@@ -762,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>,
@@ -0,0 +1,52 @@
//! 全局模型定价的管理合同。
use gateway_core::metering::{ModelPriceOverride, PricingOverrides};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PricingCatalog {
pub defaults: PricingOverrides,
pub overrides: PricingOverrides,
pub synced: PricingOverrides,
pub synced_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StoredPricing {
pub overrides: PricingOverrides,
pub synced: PricingOverrides,
pub synced_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PricingSyncPreview {
pub prices: PricingOverrides,
pub skipped: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SyncPricing {
pub preview: PricingSyncPreview,
pub models: BTreeMap<String, BTreeSet<String>>,
}
pub type PricingSyncChanges = BTreeMap<String, BTreeMap<String, Option<ModelPriceOverride>>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PricingChange {
Replace(ModelPriceOverride),
Multiplier(u32),
Reset,
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdatePricing {
pub provider: String,
pub models: Vec<String>,
pub change: PricingChange,
}
pub type ProviderPricingCatalog = BTreeMap<String, ModelPriceOverride>;
@@ -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;
}
@@ -17,7 +17,8 @@ pub use gateway_core::account::RotationStrategy;
/// 完整运行设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeSettings {
pub disable_fast: bool,
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,
@@ -49,7 +50,8 @@ pub struct RuntimeSettings {
/// 原子替换运行设置的命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplaceRuntimeSettings {
pub disable_fast: Option<bool>,
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 {
@@ -2,6 +2,7 @@
pub mod backup;
pub mod client_distribution;
pub mod pricing;
pub mod provider;
pub mod proxy;
pub mod store;
@@ -0,0 +1,9 @@
//! 手动同步的外部价目来源;网络和外部文档解析由 Host 实现。
use crate::model::{AdminError, pricing::PricingSyncPreview};
use async_trait::async_trait;
#[async_trait]
pub trait PricingSource: Send + Sync {
async fn fetch(&self) -> Result<PricingSyncPreview, AdminError>;
}
@@ -110,8 +110,28 @@ impl ProviderAdminError {
/// 全部由 [`crate::ports::store::AccountStore`] 提交。运行时资源通知只在事务成功后发生。
#[async_trait]
pub trait ProviderAdmin: Send + Sync {
/// 只读内置价目;没有本地计价能力的 Provider 返回空目录。
fn pricing_catalog(&self) -> crate::model::pricing::ProviderPricingCatalog {
Default::default()
}
fn provider_kind(&self) -> &ProviderKind;
/// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段。
fn client_profile_options(
&self,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 校验并投影客户端身份,结果不含认证或账号材料。
fn preview_client_profile(
&self,
_configuration: &gateway_core::account::OpaqueProviderData,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 将原始套餐值投影为展示名称;默认保留未知 Provider 的原始名称。
fn plan_type_display(&self, plan_type: &str) -> String {
plan_type.to_owned()
@@ -139,7 +159,15 @@ pub trait ProviderAdmin: Send + Sync {
/// 返回该 Provider 实际持有的 Dashboard 上游身份画像。
fn dashboard_wire_profile(&self) -> Option<DashboardWireProfile>;
fn configured_wire_profile(
&self,
_configuration: &gateway_core::account::OpaqueProviderData,
) -> Option<DashboardWireProfile> {
self.dashboard_wire_profile()
}
/// 使用 Provider-owned 价格规则恢复持久请求的逐项费用。
/// 未保存的长上下文计费标记不得从当前价格反推。
fn calculated_billing(
&self,
input: &ProviderBillingInput,
@@ -251,6 +279,14 @@ pub struct ProviderAdminRegistry {
}
impl ProviderAdminRegistry {
#[must_use]
pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides {
self.providers
.iter()
.map(|(kind, provider)| (kind.as_str().to_owned(), provider.pricing_catalog()))
.collect()
}
/// 创建无重复 ProviderKind 的注册表。
///
/// # Errors
@@ -313,10 +349,19 @@ impl ProviderAdminRegistry {
}
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
pub fn dashboard_wire_profiles(&self) -> Vec<DashboardWireProfile> {
pub fn dashboard_wire_profiles(
&self,
configurations: &std::collections::BTreeMap<
ProviderKind,
gateway_core::account::OpaqueProviderData,
>,
) -> Vec<DashboardWireProfile> {
self.providers
.values()
.filter_map(|provider| provider.dashboard_wire_profile())
.iter()
.filter_map(|(kind, provider)| match configurations.get(kind) {
Some(configuration) => provider.configured_wire_profile(configuration),
None => provider.dashboard_wire_profile(),
})
.collect()
}
@@ -23,7 +23,7 @@ use crate::model::{
auth::{AdminAuditEvent, AuthSession},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled, UpdateClientKey,
},
observability::{
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
@@ -227,6 +227,15 @@ pub trait AccountRuntimeStore: Send + Sync {
pub trait AuthStore: Send + Sync {
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
/// 密码更新与审计必须在同一事务提交;旧哈希不匹配时不写入。
async fn change_password(
&self,
admin_user_id: &str,
expected_hash: &str,
password_hash: &str,
audit: AdminAuditEvent,
) -> AdminStoreResult<bool>;
async fn create_password_hash_if_absent(
&self,
admin_user_id: &str,
@@ -297,6 +306,13 @@ pub trait ClientKeyStore: Send + Sync {
command: DeleteClientKey,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
/// 仅修改运行时账本并原子记录审计,不推进配置版本。
async fn reset_client_key_budget(
&self,
command: ResetClientKeyBudget,
context: &MutationContext,
) -> AdminStoreResult<()>;
}
/// Provider-neutral account group management transactions.
@@ -401,6 +417,18 @@ pub trait ObservabilityStore: Send + Sync {
/// Runtime settings 与管理员 API Key 写入。
#[async_trait]
pub trait SettingsStore: Send + Sync {
async fn load_pricing(&self) -> AdminStoreResult<crate::model::pricing::StoredPricing>;
async fn sync_pricing(
&self,
changes: crate::model::pricing::PricingSyncChanges,
context: &MutationContext,
) -> AdminStoreResult<crate::model::Revision>;
async fn update_pricing(
&self,
command: crate::model::pricing::UpdatePricing,
context: &MutationContext,
) -> AdminStoreResult<crate::model::Revision>;
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings>;
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool>;
@@ -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 {
@@ -8,6 +8,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{Duration, Utc};
use gateway_core::engine::execution::ClientKeyVerifier;
use rand_core::{OsRng, RngCore as _};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq as _;
use uuid::Uuid;
@@ -15,8 +16,8 @@ use crate::{
model::{
AdminError, AdminErrorKind,
auth::{
AdminAuditEvent, AuditActorKind, AuthSession, LoginCommand, LoginError, LoginResult,
SessionSubject,
AdminAuditEvent, AuditActorKind, AuthSession, ChangePassword, LoginCommand, LoginError,
LoginResult, SessionSubject,
},
},
ports::store::AuthStore,
@@ -27,6 +28,12 @@ use super::map_store_error;
/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定。
#[async_trait]
pub trait AuthService: Send + Sync {
async fn change_password(
&self,
session_id: Option<&str>,
command: ChangePassword,
source_ip: IpAddr,
) -> Result<(), AdminError>;
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError>;
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError>;
async fn resolve_admin_user_id(
@@ -98,17 +105,21 @@ impl DefaultAuthService {
{
return Err(LoginError::InvalidCredentials);
}
let hash = self
let password_hash = self
.store
.load_password_hash(&self.default_admin_user_id)
.await
.map_err(|_| LoginError::Unavailable)?
.ok_or(LoginError::InvalidCredentials)?;
if !verify_admin_password(&password, &hash).map_err(|_| LoginError::Unavailable)? {
if password.len() > 4096
|| !verify_admin_password(&password, &password_hash)
.map_err(|_| LoginError::Unavailable)?
{
return Err(LoginError::InvalidCredentials);
}
Ok(SessionSubject::Admin {
admin_user_id: self.default_admin_user_id.clone(),
credential_fingerprint: password_fingerprint(&password_hash),
})
}
LoginCommand::Key { api_key } => {
@@ -132,6 +143,75 @@ impl DefaultAuthService {
#[async_trait]
impl AuthService for DefaultAuthService {
async fn change_password(
&self,
session_id: Option<&str>,
command: ChangePassword,
source_ip: IpAddr,
) -> Result<(), AdminError> {
let session = self
.session(session_id)
.await?
.ok_or_else(|| AdminError::new(AdminErrorKind::Unauthorized, "请先登录"))?;
let SessionSubject::Admin {
admin_user_id,
credential_fingerprint,
} = session.subject
else {
return Err(AdminError::new(
AdminErrorKind::Forbidden,
"仅管理员可以修改密码",
));
};
if self
.store
.consume_login_attempt(
source_ip,
LOGIN_ATTEMPTS_PER_SOURCE,
LOGIN_ATTEMPTS_GLOBAL,
LOGIN_WINDOW,
)
.await
.map_err(|error| map_store_error(error, "password change limit"))?
.is_some()
{
return Err(AdminError::new(
AdminErrorKind::RateLimited,
"尝试过于频繁,请稍后再试",
));
}
validate_new_password(&command.new_password)?;
let password_hash = self
.store
.load_password_hash(&admin_user_id)
.await
.map_err(|error| map_store_error(error, "administrator"))?
.filter(|hash| password_fingerprint(hash) == credential_fingerprint)
.ok_or_else(|| AdminError::conflict("密码已变更,请重新登录"))?;
if command.current_password.len() > 4096
|| !verify_admin_password(&command.current_password, &password_hash)?
{
return Err(AdminError::invalid("当前密码不正确"));
}
if command.new_password == command.current_password {
return Err(AdminError::invalid("新密码不能与当前密码相同"));
}
let hash = hash_admin_password(&command.new_password)?;
let mut audit = self.auth_audit("admin.password_changed", &admin_user_id);
audit.entity_kind = "admin_user".to_owned();
audit.changed_fields = vec!["password".to_owned()];
if !self
.store
.change_password(&admin_user_id, &password_hash, &hash, audit)
.await
.map_err(|error| map_store_error(error, "administrator password"))?
{
return Err(AdminError::conflict("密码已变更,请重新登录"));
}
// 密码事务提交后旧指纹不再匹配,会话撤销不依赖 Redis 删除成功。
Ok(())
}
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError> {
let hash = hash_admin_password(password)?;
self.store
@@ -156,6 +236,23 @@ impl AuthService for DefaultAuthService {
let _ = self.store.delete_session(session_id).await;
return Ok(None);
}
if let SessionSubject::Admin {
admin_user_id,
credential_fingerprint,
} = &session.subject
{
let password_hash = self
.store
.load_password_hash(admin_user_id)
.await
.map_err(|error| map_store_error(error, "administrator session"))?;
if password_hash
.is_none_or(|hash| password_fingerprint(&hash) != *credential_fingerprint)
{
let _ = self.store.delete_session(session_id).await;
return Ok(None);
}
}
if let SessionSubject::Key { client_key_id } = &session.subject
&& !self
.store
@@ -178,7 +275,7 @@ impl AuthService for DefaultAuthService {
.await?
.map(|session| session.subject)
{
Some(SessionSubject::Admin { admin_user_id }) => Ok(Some(admin_user_id)),
Some(SessionSubject::Admin { admin_user_id, .. }) => Ok(Some(admin_user_id)),
Some(SessionSubject::Key { .. }) => Err(AdminError::new(
AdminErrorKind::Forbidden,
"当前身份无权访问管理接口",
@@ -237,7 +334,7 @@ impl AuthService for DefaultAuthService {
.store_session(&session_id, &session)
.await
.map_err(|_| LoginError::Unavailable)?;
if let SessionSubject::Admin { admin_user_id } = &session.subject
if let SessionSubject::Admin { admin_user_id, .. } = &session.subject
&& self
.store
.append_audit_event(self.auth_audit("admin.login", admin_user_id))
@@ -267,7 +364,7 @@ impl AuthService for DefaultAuthService {
.await
.map_err(|error| map_store_error(error, "authentication session"))?;
if let Some(AuthSession {
subject: SessionSubject::Admin { admin_user_id },
subject: SessionSubject::Admin { admin_user_id, .. },
..
}) = session
{
@@ -288,6 +385,19 @@ fn session_ttl(minutes: u64) -> Duration {
)
}
fn validate_new_password(password: &str) -> Result<(), AdminError> {
if password.trim().chars().count() < 12
|| password.len() > 1024
|| password.chars().any(char::is_control)
|| crate::WEAK_ADMIN_PASSWORDS.contains(&password.trim().to_ascii_lowercase().as_str())
{
return Err(AdminError::invalid(
"新密码至少需要 12 个字符,最多 1024 字节,不能使用常见弱口令或控制字符",
));
}
Ok(())
}
fn hash_admin_password(password: &str) -> Result<String, AdminError> {
Argon2::default()
.hash_password(password.as_bytes())
@@ -314,3 +424,8 @@ fn valid_admin_api_key_shape(value: &str) -> bool {
&& value.starts_with("admin-")
&& value[6..].bytes().all(|byte| byte.is_ascii_hexdigit())
}
// 指纹只绑定已加盐的密码哈希,不把密码或原始哈希复制到 Redis 会话。
fn password_fingerprint(password_hash: &str) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(password_hash.as_bytes()))
}
@@ -15,7 +15,8 @@ use crate::{
client_keys::{
ClientKeyCursorValue, ClientKeyListQuery, ClientKeyMutation, ClientKeyPage,
ClientKeySecret, ClientKeySortField, CreateClientKey, CreatedClientKey,
DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey,
DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
UpdateClientKey,
},
},
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
@@ -48,22 +49,49 @@ pub trait ClientKeyService: Send + Sync {
context: &MutationContext,
command: DeleteClientKey,
) -> Result<ClientKeyMutation, AdminError>;
async fn reset_budget(
&self,
context: &MutationContext,
command: ResetClientKeyBudget,
) -> Result<ClientApiKeyId, AdminError>;
}
pub(crate) struct DefaultClientKeyService {
providers: crate::ports::provider::ProviderAdminRegistry,
store: Arc<dyn ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultClientKeyService {
#[must_use]
pub(crate) fn new(store: Arc<dyn ClientKeyStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
Self { store, snapshot }
pub(crate) fn new(
store: Arc<dyn ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
providers: crate::ports::provider::ProviderAdminRegistry,
) -> Self {
Self {
store,
snapshot,
providers,
}
}
}
#[async_trait]
impl ClientKeyService for DefaultClientKeyService {
async fn reset_budget(
&self,
context: &MutationContext,
command: ResetClientKeyBudget,
) -> Result<ClientApiKeyId, AdminError> {
let id = command.id.clone();
self.store
.reset_client_key_budget(command, context)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
Ok(id)
}
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError> {
validate_cursor(&query)?;
self.store
@@ -85,6 +113,19 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError> {
for (provider, profile) in [
("openai", &command.openai_client_profile_override),
("xai", &command.xai_client_profile_override),
] {
if let Some(profile) = profile {
let kind = gateway_core::routing::ProviderKind::new(provider)
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
self.providers
.require(&kind)
.and_then(|provider| provider.preview_client_profile(profile))
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
}
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
let plaintext = if let Some(key) = command.custom_key {
@@ -98,6 +139,8 @@ impl ClientKeyService for DefaultClientKeyService {
.store
.create_client_key(
NewClientKey {
openai_client_profile_override: command.openai_client_profile_override,
xai_client_profile_override: command.xai_client_profile_override,
id,
name: command.name,
label: command.label,
@@ -122,6 +165,19 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: UpdateClientKey,
) -> Result<ClientKeyMutation, AdminError> {
for (provider, profile) in [
("openai", &command.openai_client_profile_override),
("xai", &command.xai_client_profile_override),
] {
if let Some(Some(profile)) = profile {
let kind = gateway_core::routing::ProviderKind::new(provider)
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
self.providers
.require(&kind)
.and_then(|provider| provider.preview_client_profile(profile))
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
}
let id = command.id.clone();
let (config_revision, record) = self
.store
@@ -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>,
@@ -192,7 +192,20 @@ impl DefaultObservabilityService {
average(first_token_latency_sum_ms, first_token_latency_count);
let trend = trend(TrendKind::Usage, observation.trend.clone())?;
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
let wire_profiles = self.providers.dashboard_wire_profiles();
let mut configurations = std::collections::BTreeMap::new();
if let Some(configuration) = &settings.openai_client_profile {
configurations.insert(
gateway_core::routing::ProviderKind::new("openai").expect("static provider kind"),
configuration.clone(),
);
}
if let Some(configuration) = &settings.xai_client_profile {
configurations.insert(
gateway_core::routing::ProviderKind::new("xai").expect("static provider kind"),
configuration.clone(),
);
}
let wire_profiles = self.providers.dashboard_wire_profiles(&configurations);
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
let total_slots = runtime_slots.as_ref().map_or_else(
|| {
@@ -612,7 +625,7 @@ async fn recover_standard_costs(
mut facts: UsageCalculatedBillingStream<'_>,
) -> Result<UsageCostScenarios, AdminError> {
let mut scenarios = UsageCostScenarios::default();
while let Some(fact) = facts
while let Some(mut fact) = facts
.try_next()
.await
.map_err(|error| map_store_error(error, "usage billing facts"))?
@@ -629,7 +642,14 @@ async fn recover_standard_costs(
cache_write_tokens: fact.cache_write_tokens,
total: fact.total.clone(),
};
let breakdown = match providers.calculated_billing(&provider_kind, &input) {
let breakdown = match fact
.breakdown
.take()
.filter(|b| b.total_amount == input.total)
.map_or_else(
|| providers.calculated_billing(&provider_kind, &input),
|b| Ok(Some(b)),
) {
Ok(breakdown) => breakdown,
Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => continue,
Err(error) => return Err(map_provider_error(error, "usage billing")),
@@ -658,8 +678,15 @@ fn no_cache_cost(
fact: &UsageCalculatedBillingFact,
breakdown: &crate::model::observability::CalculatedBillingBreakdown,
) -> Option<DecimalAmount> {
let input_tokens = fact.input_tokens?;
let cached_tokens = fact.cached_tokens.unwrap_or_default().min(input_tokens);
let image = breakdown.image.as_ref();
let input_tokens = fact
.input_tokens?
.checked_sub(image.map_or(0, |i| i.input_tokens))?;
let cached_tokens = fact
.cached_tokens
.unwrap_or_default()
.checked_sub(image.map_or(0, |i| i.cached_tokens))?
.min(input_tokens);
let cache_write_tokens = fact
.cache_write_tokens
.unwrap_or_default()
@@ -669,11 +696,20 @@ fn no_cache_cost(
.scaled()
.checked_mul(u128::from(cached_tokens.saturating_add(cache_write_tokens)))?
.checked_div(1_000_000)?;
let total = decimal(&breakdown.total_amount.amount)?
let mut total = decimal(&breakdown.total_amount.amount)?
.scaled()
.checked_sub(decimal(&breakdown.cache_read_amount.amount)?.scaled())?
.checked_sub(decimal(&breakdown.cache_write_amount.amount)?.scaled())?
.checked_add(replaced_input_amount)?;
if let Some(image) = image {
let image_no_cache = decimal(&image.input_price_per_million.amount)?
.scaled()
.checked_mul(u128::from(image.cached_tokens))?
.checked_div(1_000_000)?;
total = total
.checked_sub(decimal(&image.cache_read_amount.amount)?.scaled())?
.checked_add(image_no_cache)?;
}
DecimalAmount::from_str(
&gateway_core::metering::Decimal::from_scaled(total)
.ok()?
@@ -23,6 +23,33 @@ use super::{map_store_error, publish_committed};
/// API 消费的 Runtime settings 管理服务。
#[async_trait]
pub trait SettingsService: Send + Sync {
async fn preview_pricing_sync(
&self,
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError>;
async fn sync_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::SyncPricing,
) -> Result<(), AdminError>;
async fn pricing(&self) -> Result<crate::model::pricing::PricingCatalog, AdminError>;
async fn update_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::UpdatePricing,
) -> Result<(), AdminError>;
async fn client_profile_options(
&self,
_provider: &str,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
}
async fn preview_client_profile(
&self,
_provider: &str,
_configuration: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
}
async fn load(&self) -> Result<RuntimeSettings, AdminError>;
async fn replace(
&self,
@@ -41,19 +68,244 @@ pub trait SettingsService: Send + Sync {
}
pub(crate) struct DefaultSettingsService {
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
store: Arc<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultSettingsService {
fn profile_provider(
&self,
provider: &str,
) -> Result<Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
let kind = gateway_core::routing::ProviderKind::new(provider)
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
self.providers
.require(&kind)
.map_err(|error| super::map_provider_error(error, "client profile"))
}
#[must_use]
pub(crate) fn new(store: Arc<dyn SettingsStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
Self { store, snapshot }
pub(crate) fn new(
store: Arc<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
) -> Self {
Self {
providers,
pricing_source,
store,
snapshot,
}
}
}
#[async_trait]
impl SettingsService for DefaultSettingsService {
async fn preview_pricing_sync(
&self,
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError> {
self.pricing_source.fetch().await
}
async fn sync_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::SyncPricing,
) -> Result<(), AdminError> {
let count = command
.models
.values()
.map(std::collections::BTreeSet::len)
.sum::<usize>();
if count == 0
|| count > 10_000
|| command
.models
.values()
.any(std::collections::BTreeSet::is_empty)
{
return Err(AdminError::invalid("请选择 1 至 10000 个模型"));
}
let mut current = self.pricing_source.fetch().await?;
if current != command.preview {
return Err(AdminError::invalid(
"models.dev 价目已变化,请重新预览后确认",
));
}
let stored = self
.store
.load_pricing()
.await
.map_err(|error| map_store_error(error, "model pricing"))?;
let mut changes = crate::model::pricing::PricingSyncChanges::new();
for (provider, models) in command.models {
let selected = changes.entry(provider.clone()).or_default();
for model in models {
let price = current
.prices
.get_mut(&provider)
.and_then(|prices| prices.remove(&model));
if price.is_none()
&& !stored
.synced
.get(&provider)
.is_some_and(|prices| prices.contains_key(&model))
{
return Err(AdminError::invalid("所选模型不在来源价目中,请重新预览"));
}
selected.insert(model, price);
}
}
let revision = self
.store
.sync_pricing(changes, context)
.await
.map_err(|error| map_store_error(error, "model pricing sync"))?;
publish_committed(self.snapshot.as_ref(), revision).await
}
async fn pricing(&self) -> Result<crate::model::pricing::PricingCatalog, AdminError> {
let stored = self
.store
.load_pricing()
.await
.map_err(|error| map_store_error(error, "model pricing"))?;
Ok(crate::model::pricing::PricingCatalog {
defaults: self.providers.pricing_catalog(),
overrides: stored.overrides,
synced: stored.synced,
synced_at: stored.synced_at,
})
}
async fn update_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::UpdatePricing,
) -> Result<(), AdminError> {
use crate::model::pricing::PricingChange;
let catalog = self.pricing().await?;
if !catalog.defaults.contains_key(&command.provider)
|| command.models.is_empty()
|| command.models.len() > 500
|| command.models.iter().any(|model| {
model.is_empty()
|| model.len() > 128
|| model.trim() != model
|| model.chars().any(char::is_whitespace)
|| model.chars().any(char::is_control)
})
{
return Err(AdminError::invalid("Provider、模型 ID 或批量数量不合法"));
}
if command.change == PricingChange::Delete
&& command.models.iter().any(|model| {
catalog
.defaults
.get(&command.provider)
.is_some_and(|models| models.contains_key(model))
})
{
return Err(AdminError::invalid("内置价目不能删除,仅支持人工覆盖"));
}
let defaults = gateway_core::metering::merge_pricing(catalog.defaults, &catalog.synced);
let defaults = defaults.get(&command.provider);
match &command.change {
PricingChange::Replace(pricing) => {
pricing.validate().map_err(AdminError::invalid)?;
if command.provider == "xai"
&& pricing
.bands
.keys()
.any(|band| matches!(band.as_str(), "image" | "flex" | "long_flex"))
{
return Err(AdminError::invalid("xAI 不支持此价格档位"));
}
if !pricing.bands.contains_key("standard")
&& command
.models
.iter()
.any(|model| !defaults.is_some_and(|p| p.contains_key(model)))
{
return Err(AdminError::invalid("未知模型必须提供标准档价格"));
}
}
PricingChange::Multiplier(bps) if *bps > 1_000_000 => {
return Err(AdminError::invalid("倍率必须在 0 至 100 倍之间"));
}
PricingChange::Multiplier(_)
if command.models.iter().any(|model| {
!defaults.is_some_and(|p| p.contains_key(model))
&& !catalog
.overrides
.get(&command.provider)
.and_then(|p| p.get(model))
.is_some_and(|p| p.bands.contains_key("standard"))
}) =>
{
return Err(AdminError::invalid("请先为未知模型配置标准档价格"));
}
_ => {}
}
let revision = self
.store
.update_pricing(command, context)
.await
.map_err(|error| map_store_error(error, "model pricing"))?;
publish_committed(self.snapshot.as_ref(), revision).await
}
async fn client_profile_options(
&self,
provider: &str,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
let mut options = self
.profile_provider(provider)?
.client_profile_options()
.map_err(|error| super::map_provider_error(error, "client profile"))?
.into_inner();
let configuration = self
.load()
.await?
.client_profile(provider)
.cloned()
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
options.insert(
"globalConfiguration".to_owned(),
serde_json::Value::Object(configuration.into_inner()),
);
Ok(gateway_core::account::OpaqueProviderData::new(options))
}
async fn preview_client_profile(
&self,
provider: &str,
configuration: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
let profile_provider = self.profile_provider(provider)?;
let global;
let (configuration, source) = if let Some(configuration) = configuration {
(configuration, "override")
} else {
global = self
.load()
.await?
.client_profile(provider)
.cloned()
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
(&global, "global")
};
let mut preview = profile_provider
.preview_client_profile(configuration)
.map_err(|error| super::map_provider_error(error, "client profile"))?
.into_inner();
preview.insert("source".to_owned(), serde_json::Value::from(source));
Ok(gateway_core::account::OpaqueProviderData::new(preview))
}
async fn load(&self) -> Result<RuntimeSettings, AdminError> {
self.store
.load_runtime_settings()
@@ -67,6 +319,16 @@ impl SettingsService for DefaultSettingsService {
command: ReplaceRuntimeSettings,
) -> Result<RuntimeSettings, AdminError> {
validate_settings(&command)?;
for (provider, profile) in [
("openai", &command.openai_client_profile),
("xai", &command.xai_client_profile),
] {
if let Some(profile) = profile {
self.profile_provider(provider)?
.preview_client_profile(profile)
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
}
let settings = self
.store
.replace_runtime_settings(command, context)
@@ -618,6 +618,16 @@ impl AuthStore for FakeAuthStore {
async fn load_password_hash(&self, _admin_user_id: &str) -> AdminStoreResult<Option<String>> {
Ok(None)
}
async fn change_password(
&self,
_: &str,
_: &str,
_: &str,
_: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn create_password_hash_if_absent(
&self,
_admin_user_id: &str,
@@ -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
@@ -971,9 +978,27 @@ struct StaticSettingsStore;
#[async_trait]
impl SettingsStore for StaticSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
disable_fast: false,
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
@@ -1342,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,
@@ -1389,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();
@@ -2022,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]
@@ -21,12 +21,83 @@ struct MemoryAuthStore {
audits: Mutex<Vec<AdminAuditEvent>>,
}
#[tokio::test]
async fn password_change_obeys_rate_limit_and_rejects_unbound_legacy_sessions() {
use gateway_admin::model::{
AdminErrorKind,
auth::{ChangePassword, SessionSubject},
};
let store = std::sync::Arc::new(MemoryAuthStore::default());
let services = super::AdminHarness::new().auth(store.clone()).build().await;
let auth = services.auth();
let login = auth
.login(
LoginCommand::Admin {
username: None,
password: "strong-test-password".into(),
},
std::net::Ipv4Addr::LOCALHOST.into(),
None,
)
.await
.unwrap();
*store.retry_after.lock().unwrap() = Some(std::time::Duration::from_secs(30));
let error = auth
.change_password(
Some(&login.session_id),
ChangePassword {
current_password: "strong-test-password".into(),
new_password: "new-strong-password".into(),
},
std::net::Ipv4Addr::LOCALHOST.into(),
)
.await
.unwrap_err();
assert_eq!(error.kind(), AdminErrorKind::RateLimited);
assert!(
auth.session(Some(&login.session_id))
.await
.unwrap()
.is_some()
);
store.sessions.lock().unwrap().insert(
"legacy".into(),
AuthSession {
subject: SessionSubject::Admin {
admin_user_id: "admin".into(),
credential_fingerprint: String::new(),
},
expires_at: Utc::now() + TimeDelta::hours(1),
},
);
assert!(auth.session(Some("legacy")).await.unwrap().is_none());
}
#[async_trait]
impl AuthStore for MemoryAuthStore {
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn change_password(
&self,
_: &str,
expected_hash: &str,
password_hash: &str,
audit: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
let mut stored = self.password_hash.lock().unwrap();
let Some(credentials) = stored
.as_mut()
.filter(|value| value.as_str() == expected_hash)
else {
return Ok(false);
};
*credentials = password_hash.to_owned();
self.audits.lock().unwrap().push(audit);
Ok(true)
}
async fn create_password_hash_if_absent(
&self,
_: &str,
@@ -197,6 +268,7 @@ async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logou
"expired".to_owned(),
AuthSession {
subject: SessionSubject::Admin {
credential_fingerprint: String::new(),
admin_user_id: "admin".to_owned(),
},
expires_at: Utc::now() - TimeDelta::seconds(1),
@@ -242,6 +242,16 @@ impl AuthStore for MemoryKeyAuthStore {
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
Ok(None)
}
async fn change_password(
&self,
_: &str,
_: &str,
_: &str,
_: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn create_password_hash_if_absent(&self, _: &str, _: &str) -> AdminStoreResult<bool> {
Ok(true)
}
@@ -22,10 +22,20 @@ struct TestClientKeyStore {
plaintexts: Mutex<Vec<String>>,
create_error: Option<AdminStoreErrorKind>,
update_error: Option<AdminStoreErrorKind>,
resets: Mutex<Vec<gateway_admin::model::client_keys::ResetClientKeyBudget>>,
}
#[async_trait]
impl ClientKeyStore for TestClientKeyStore {
async fn reset_client_key_budget(
&self,
command: gateway_admin::model::client_keys::ResetClientKeyBudget,
_: &MutationContext,
) -> AdminStoreResult<()> {
self.resets.lock().unwrap().push(command);
Ok(())
}
async fn get_client_key(
&self,
_: &ClientApiKeyId,
@@ -64,6 +74,8 @@ impl ClientKeyStore for TestClientKeyStore {
}
self.plaintexts.lock().unwrap().push(key.plaintext);
let record = ClientKeyRecord {
openai_client_profile_override: None,
xai_client_profile_override: None,
id: key.id,
name: key.name,
label: key.label,
@@ -112,6 +124,27 @@ impl ClientKeyStore for TestClientKeyStore {
}
}
#[tokio::test]
async fn reset_budget_forwards_scope_and_returns_only_key_identity() {
use gateway_admin::model::client_keys::{ClientKeyBudgetPeriod, ResetClientKeyBudget};
let store = Arc::new(TestClientKeyStore::default());
let services = super::AdminHarness::new()
.client_keys(store.clone())
.build()
.await;
let command = ResetClientKeyBudget {
id: ClientApiKeyId::new("key_reset").unwrap(),
period: ClientKeyBudgetPeriod::Weekly,
};
let result = services
.client_keys()
.reset_budget(&mutation_context(), command.clone())
.await
.unwrap();
assert_eq!(result, command.id);
assert_eq!(*store.resets.lock().unwrap(), vec![command]);
}
#[tokio::test]
async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
let services = super::AdminHarness::new()
@@ -227,6 +260,8 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
.update(
&mutation_context(),
UpdateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
id: ClientApiKeyId::new("key_existing").unwrap(),
name: "Migration".to_owned(),
label: None,
@@ -246,6 +281,8 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
fn create_command(key: Option<&str>) -> CreateClientKey {
CreateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
name: "Migration".to_owned(),
label: None,
@@ -25,7 +25,8 @@ use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, event
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
RuntimeSettings {
disable_fast: false,
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
@@ -61,6 +62,23 @@ struct FreezeSettingsStore {
#[async_trait]
impl SettingsStore for FreezeSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(self.settings.clone())
}
@@ -243,6 +243,7 @@ impl AdminHarness {
self.backup,
),
gateway_admin::AdminRuntimePorts {
pricing_source: Arc::new(UnavailablePricingSource),
providers: self.providers,
snapshot: Arc::new(NoopSnapshot),
account_probe: self.probe,
@@ -282,6 +283,25 @@ impl AuthStore for BootstrapAuthStore {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn change_password(
&self,
_: &str,
expected_hash: &str,
password_hash: &str,
audit: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
let mut stored = self.password_hash.lock().unwrap();
let Some(credentials) = stored
.as_mut()
.filter(|value| value.as_str() == expected_hash)
else {
return Ok(false);
};
*credentials = password_hash.to_owned();
let _ = audit;
Ok(true)
}
async fn create_password_hash_if_absent(
&self,
_: &str,
@@ -567,6 +587,14 @@ impl AccountRuntimeStore for UnavailableStore {
#[async_trait]
impl ClientKeyStore for UnavailableStore {
async fn reset_client_key_budget(
&self,
_: gateway_admin::model::client_keys::ResetClientKeyBudget,
_: &MutationContext,
) -> AdminStoreResult<()> {
Err(unavailable("client key budget reset"))
}
async fn get_client_key(
&self,
_: &ClientApiKeyId,
@@ -678,6 +706,23 @@ impl ObservabilityStore for UnavailableStore {
#[async_trait]
impl SettingsStore for UnavailableStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unavailable("settings"))
}
@@ -716,6 +761,19 @@ struct UnavailableProvider {
calculated_billing: Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
}
struct UnavailablePricingSource;
#[async_trait]
impl gateway_admin::ports::pricing::PricingSource for UnavailablePricingSource {
async fn fetch(
&self,
) -> Result<gateway_admin::model::pricing::PricingSyncPreview, gateway_admin::model::AdminError>
{
Err(gateway_admin::model::AdminError::internal(
"pricing source unavailable",
))
}
}
impl UnavailableProvider {
fn new(kind: &str) -> Self {
Self {
@@ -863,6 +921,9 @@ pub(super) fn calculated_billing_provider() -> Arc<dyn ProviderAdmin> {
dashboard_profile: None,
calculated_billing: Some(
gateway_admin::model::observability::CalculatedBillingBreakdown {
long_context_billing_applied: false,
custom_multiplier_bps: 10_000,
image: None,
input_amount: amount("0.8"),
output_amount: amount("0.2"),
cache_read_amount: amount("0"),
@@ -372,6 +372,7 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
}],
}]);
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
breakdown: None,
bucket_start: quarter_hour_start(now),
provider_kind: "openai".to_owned(),
upstream_model_id: "gpt-5.5".to_owned(),
@@ -543,6 +544,7 @@ async fn usage_insights_should_reject_partial_costs_when_billing_stream_fails()
let range = observation_range(now);
let store = Arc::new(FixtureObservabilityStore::new(range));
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
breakdown: None,
bucket_start: quarter_hour_start(now),
provider_kind: "openai".to_owned(),
upstream_model_id: "gpt-5.5".to_owned(),
@@ -751,9 +753,27 @@ struct FixtureSettingsStore;
#[async_trait]
impl SettingsStore for FixtureSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
disable_fast: false,
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(1).expect("revision"),
@@ -851,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(),
@@ -859,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,8 +13,39 @@ use gateway_admin::{
struct UnusedSettingsStore;
#[tokio::test]
async fn client_profile_preview_rejects_unknown_provider_before_loading_settings() {
let services = super::AdminHarness::new()
.settings(std::sync::Arc::new(UnusedSettingsStore))
.build()
.await;
let error = services
.settings()
.preview_client_profile("unknown", None)
.await
.expect_err("unknown provider cannot use global settings");
assert_eq!(error.kind(), AdminErrorKind::Invalid);
}
#[async_trait]
impl SettingsStore for UnusedSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unused())
}
@@ -61,7 +92,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
request_id: "request-settings".to_owned(),
},
ReplaceRuntimeSettings {
disable_fast: None,
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 {
@@ -7,10 +7,11 @@ use std::fmt;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use gateway_admin::model::client_keys::{
ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue, ClientKeyListQuery,
ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret,
ClientKeySort as DomainSort, ClientKeySortField as DomainSortField, CreateClientKey,
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
ClientKeyBudgetPeriod, ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue,
ClientKeyListQuery, ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord,
ClientKeySecret, ClientKeySort as DomainSort, ClientKeySortField as DomainSortField,
CreateClientKey, CreatedClientKey, DeleteClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
SortDirection, UpdateClientKey,
};
use gateway_core::{
engine::budget::ClientBudgetLimits,
@@ -146,6 +147,8 @@ impl ClientKeySort {
#[derive(Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateClientKeyRequest {
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
xai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
custom_key: Option<String>,
name: String,
label: Option<String>,
@@ -175,6 +178,12 @@ impl CreateClientKeyRequest {
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(CreateClientKey {
openai_client_profile_override: self
.openai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::new),
xai_client_profile_override: self
.xai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::new),
custom_key: self
.custom_key
.filter(|key| !key.is_empty())
@@ -201,6 +210,10 @@ impl CreateClientKeyRequest {
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateClientKeyRequest {
#[serde(default, deserialize_with = "deserialize_profile_override")]
openai_client_profile_override: Option<Option<serde_json::Map<String, serde_json::Value>>>,
#[serde(default, deserialize_with = "deserialize_profile_override")]
xai_client_profile_override: Option<Option<serde_json::Map<String, serde_json::Value>>>,
id: String,
name: String,
label: Option<String>,
@@ -221,6 +234,12 @@ impl UpdateClientKeyRequest {
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(UpdateClientKey {
openai_client_profile_override: self
.openai_client_profile_override
.map(|value| value.map(gateway_core::account::OpaqueProviderData::new)),
xai_client_profile_override: self
.xai_client_profile_override
.map(|value| value.map(gateway_core::account::OpaqueProviderData::new)),
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
name: self.name,
label: self.label,
@@ -235,6 +254,36 @@ impl UpdateClientKeyRequest {
}
}
/// 重置指定周期已用金额的请求。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ResetClientKeyBudgetRequest {
id: String,
period: BudgetResetPeriod,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
enum BudgetResetPeriod {
Daily,
Weekly,
All,
}
impl ResetClientKeyBudgetRequest {
pub fn into_command(self) -> Result<ResetClientKeyBudget, WireValidationError> {
validate_required_text(&self.id, "id")?;
Ok(ResetClientKeyBudget {
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
period: match self.period {
BudgetResetPeriod::Daily => ClientKeyBudgetPeriod::Daily,
BudgetResetPeriod::Weekly => ClientKeyBudgetPeriod::Weekly,
BudgetResetPeriod::All => ClientKeyBudgetPeriod::All,
},
})
}
}
/// 只携带 ID 的 Client Key mutation 请求。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -276,6 +325,8 @@ impl ClientKeyMutationRequest {
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientKeyView {
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
xai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
id: String,
name: String,
label: Option<String>,
@@ -314,6 +365,12 @@ impl From<ClientKeyRecord> for ClientKeyView {
"groups"
};
Self {
openai_client_profile_override: record
.openai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::into_inner),
xai_client_profile_override: record
.xai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::into_inner),
id: record.id.to_string(),
name: record.name,
label: record.label,
@@ -690,6 +747,10 @@ where
post(create_client_key::<S>),
)
.route("/api/admin/client-keys/reveal", get(reveal_client_key::<S>))
.route(
"/api/admin/client-keys/reset-budget",
post(reset_client_key_budget::<S>),
)
.route(
"/api/admin/client-keys/update",
post(update_client_key::<S>),
@@ -791,6 +852,29 @@ where
)
}
async fn reset_client_key_budget<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(payload): AdminJson<ResetClientKeyBudgetRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let id = state
.admin_services()
.client_keys()
.reset_budget(
&auth.context().mutation_context(),
payload.into_command().map_err(map_wire_error)?,
)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(MutatedClientKeyData::new(id.as_str().to_owned())),
))
}
async fn disable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
@@ -874,3 +958,10 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error)
}
// 省略表示不修改,null 表示恢复跟随通用设置。
fn deserialize_profile_override<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Option<serde_json::Map<String, serde_json::Value>>>, D::Error> {
Option::<serde_json::Map<String, serde_json::Value>>::deserialize(deserializer).map(Some)
}
@@ -214,6 +214,8 @@ fn capitalize_first(value: &str) -> String {
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
match billing? {
domain::UsageBilling::Total { source, total } => Some(BillingView {
long_context_billing_applied: false,
image: None,
input_amount_display: "—".to_owned(),
output_amount_display: "—".to_owned(),
cache_read_amount_display: "—".to_owned(),
@@ -232,6 +234,18 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<Bil
multiplier_display: "—".to_owned(),
}),
domain::UsageBilling::Calculated(value) => Some(BillingView {
long_context_billing_applied: value.long_context_billing_applied,
image: value
.image
.as_ref()
.map(|image| super::wire::ImageBillingView {
input_amount_display: format_money(&image.input_amount),
cache_read_amount_display: format_money(&image.cache_read_amount),
input_price_display: format_token_price(&image.input_price_per_million),
cache_read_price_display: format_token_price(
&image.cache_read_price_per_million,
),
}),
input_amount_display: format_money(&value.input_amount),
output_amount_display: format_money(&value.output_amount),
cache_read_amount_display: format_money(&value.cache_read_amount),
@@ -243,7 +257,15 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<Bil
cache_read_price_display: format_token_price(&value.cache_read_price_per_million),
cache_write_price_display: format_token_price(&value.cache_write_price_per_million),
service_tier_display: format_service_tier(value.service_tier.as_deref()),
multiplier_display: format!("{:.2}x", f64::from(value.multiplier_percent) / 100.0),
multiplier_display: if value.custom_multiplier_bps == 10_000 {
format!("{:.2}x", f64::from(value.multiplier_percent) / 100.0)
} else {
format!(
"档位 {:.2}x · 自定义 {:.4}x",
f64::from(value.multiplier_percent) / 100.0,
f64::from(value.custom_multiplier_bps) / 10_000.0
)
},
}),
}
}
@@ -257,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,
@@ -544,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,9 @@ pub struct CostCoverageView {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BillingView {
pub long_context_billing_applied: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<ImageBillingView>,
pub input_amount_display: String,
pub output_amount_display: String,
pub cache_read_amount_display: String,
@@ -70,16 +73,27 @@ pub struct BillingView {
pub multiplier_display: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageBillingView {
pub input_amount_display: String,
pub cache_read_amount_display: String,
pub input_price_display: String,
pub cache_read_price_display: String,
}
/// 使用记录表格的窄展示。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageListRecordView {
pub client_api_key_name: Option<String>,
pub id: String,
pub provider: Option<String>,
pub authentication_kind: Option<String>,
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>,
@@ -762,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},
@@ -34,7 +34,8 @@ pub type ModelMappings = BTreeMap<String, String>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
pub disable_fast: bool,
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,
@@ -66,7 +67,10 @@ pub struct RuntimeSettingsView {
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateRuntimeSettingsRequest {
pub disable_fast: Option<bool>,
#[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,
@@ -177,7 +181,12 @@ impl UpdateRuntimeSettingsRequest {
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
self.validate()?;
Ok(ReplaceRuntimeSettings {
disable_fast: self.disable_fast,
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
@@ -219,7 +228,12 @@ impl UpdateRuntimeSettingsRequest {
impl From<RuntimeSettings> for RuntimeSettingsView {
fn from(settings: RuntimeSettings) -> Self {
Self {
disable_fast: settings.disable_fast,
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),
@@ -344,7 +358,25 @@ where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/settings/pricing", get(pricing::<S>))
.route(
"/api/admin/settings/pricing/update",
post(update_pricing::<S>),
)
.route(
"/api/admin/settings/pricing/sync/preview",
post(preview_pricing_sync::<S>),
)
.route("/api/admin/settings/pricing/sync", post(sync_pricing::<S>))
.route("/api/admin/settings", get(settings::<S>))
.route(
"/api/admin/settings/client-profiles/{provider}",
get(client_profile_options::<S>),
)
.route(
"/api/admin/settings/client-profiles/{provider}/preview",
post(preview_client_profile::<S>),
)
.route("/api/admin/settings/update", post(update_settings::<S>))
.route(
"/api/admin/settings/client-downloads/codex-desktop/windows",
@@ -383,6 +415,125 @@ where
)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PricingUpdateRequest {
provider: String,
models: Vec<String>,
change: PricingChangeRequest,
}
#[derive(Deserialize)]
#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)]
enum PricingChangeRequest {
Replace {
pricing: gateway_core::metering::ModelPriceOverride,
},
Multiplier {
#[serde(rename = "multiplierBps")]
multiplier_bps: u32,
},
Reset {},
Delete {},
}
async fn pricing<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let pricing = state
.admin_services()
.settings()
.pricing()
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({
"defaults": pricing.defaults, "overrides": pricing.overrides,
"synced": pricing.synced, "syncedAt": pricing.synced_at,
})),
))
}
async fn preview_pricing_sync<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let preview = state
.admin_services()
.settings()
.preview_pricing_sync()
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(preview),
))
}
async fn sync_pricing<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(command): AdminJson<gateway_admin::model::pricing::SyncPricing>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
state
.admin_services()
.settings()
.sync_pricing(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"saved": true})),
))
}
async fn update_pricing<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<PricingUpdateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
use gateway_admin::model::pricing::{PricingChange, UpdatePricing};
let change = match request.change {
PricingChangeRequest::Replace { pricing } => PricingChange::Replace(pricing),
PricingChangeRequest::Multiplier { multiplier_bps } => {
PricingChange::Multiplier(multiplier_bps)
}
PricingChangeRequest::Reset {} => PricingChange::Reset,
PricingChangeRequest::Delete {} => PricingChange::Delete,
};
state
.admin_services()
.settings()
.update_pricing(
&auth.context().mutation_context(),
UpdatePricing {
provider: request.provider,
models: request.models,
change,
},
)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"saved": true})),
))
}
async fn settings<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -579,3 +730,60 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error)
}
// 字段省略时保留已有配置;显式 null 不能清空唯一的通用默认。
fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<serde_json::Map<String, serde_json::Value>>, D::Error> {
serde_json::Map::<String, serde_json::Value>::deserialize(deserializer).map(Some)
}
async fn client_profile_options<S>(
_auth: AdminAuth,
Path(provider): Path<String>,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
.settings()
.client_profile_options(&provider)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result.into_inner()),
))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ClientProfilePreviewRequest {
configuration: Option<serde_json::Map<String, serde_json::Value>>,
}
async fn preview_client_profile<S>(
_auth: AdminAuth,
Path(provider): Path<String>,
State(state): State<S>,
AdminJson(request): AdminJson<ClientProfilePreviewRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let configuration = request
.configuration
.map(gateway_core::account::OpaqueProviderData::new);
let result = state
.admin_services()
.settings()
.preview_client_profile(&provider, configuration.as_ref())
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result.into_inner()),
))
}
@@ -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,
}),
))
+56 -3
View File
@@ -12,7 +12,7 @@ use axum::{
};
use gateway_admin::{
AdminServices,
model::auth::{AuthSession, LoginCommand, LoginError, SessionSubject},
model::auth::{AuthSession, ChangePassword, LoginCommand, LoginError, SessionSubject},
};
use serde::{Deserialize, Serialize};
@@ -39,6 +39,19 @@ pub enum LoginRequest {
},
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ChangePasswordRequest {
current_password: String,
new_password: String,
}
impl fmt::Debug for ChangePasswordRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ChangePasswordRequest([REDACTED])")
}
}
impl fmt::Debug for LoginRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -102,6 +115,7 @@ where
.route("/api/auth/login", post(login::<S>))
.route("/api/auth/status", get(session_status::<S>))
.route("/api/auth/logout", post(logout::<S>))
.route("/api/auth/password", post(change_password::<S>))
.route("/api/auth", any(not_found))
.route("/api/auth/{*path}", any(not_found))
.method_not_allowed_fallback(method_not_allowed)
@@ -189,17 +203,56 @@ where
.await
.map_err(map_admin_service_error)?;
}
let mut response = AdminResponse::new(
let response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(LogoutData {
message: "Logged out successfully",
}),
)
.into_response();
clear_session_cookie(response, &headers)
}
async fn change_password<S>(
State(state): State<S>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
AdminJson(payload): AdminJson<ChangePasswordRequest>,
) -> Result<Response, AdminError>
where
S: SessionState + Send + Sync,
{
state
.admin_services()
.auth()
.change_password(
session_cookie::value(&headers).as_deref(),
ChangePassword {
current_password: payload.current_password,
new_password: payload.new_password,
},
peer.ip(),
)
.await
.map_err(map_admin_service_error)?;
let response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(LogoutData {
message: "密码已修改,请重新登录",
}),
)
.into_response();
clear_session_cookie(response, &headers)
}
fn clear_session_cookie(
mut response: Response,
headers: &HeaderMap,
) -> Result<Response, AdminError> {
let cookie = format!(
"{}=; {}; Max-Age=0",
session_cookie::NAME,
session_cookie::attributes(&headers)
session_cookie::attributes(headers)
);
response.headers_mut().insert(
header::SET_COOKIE,
@@ -23,6 +23,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 {
+74 -14
View File
@@ -8,7 +8,7 @@ use axum::{
};
use gateway_core::{
engine::EngineError,
error::{GatewayError, GatewayErrorKind},
error::{GatewayError, GatewayErrorKind, ProviderErrorKind},
policy::{ClientVersionRejection, CodexClientKind},
};
use serde_json::{Value, json};
@@ -22,16 +22,54 @@ pub fn openai_error_response(
error_type: &str,
code: &str,
) -> (StatusCode, Json<Value>) {
(
status,
Json(json!({
"error": {
"message": message,
"type": error_type,
"code": code
}
})),
)
let body = json!({
"error": {"message": message, "type": error_type, "code": code}
});
match capacity_error_for_client(&body) {
Some(body) => (StatusCode::SERVICE_UNAVAILABLE, Json(body)),
None => (status, Json(body)),
}
}
/// Codex 将这两个容量码视为不可重试;仅在交付边界转换,内部仍保留真实上游事实。
pub(super) fn client_error_code(code: &str) -> &str {
match code {
"server_is_overloaded" | "slow_down" => "server_error",
_ => code,
}
}
/// 为 HTTP 错误或流内失败保留原有字段,仅投影客户端需要的重试信号。
pub(super) fn capacity_error_for_client(data: &Value) -> Option<Value> {
const CODE_PATHS: [&str; 2] = ["/error/code", "/response/error/code"];
let capacity_code = |value: &Value| {
value
.as_str()
.is_some_and(|code| client_error_code(code) != code)
};
if !CODE_PATHS
.iter()
.any(|path| data.pointer(path).is_some_and(capacity_code))
{
return None;
}
let mut projected = data.clone();
for path in CODE_PATHS {
if let Some(code) = projected.pointer_mut(path)
&& capacity_code(code)
{
*code = Value::String("server_error".to_owned());
}
}
// WS 包装错误按 HTTP 状态分类;保留 400/429 会使客户端终止重试。
for field in ["status", "status_code"] {
if let Some(status) = projected.get_mut(field)
&& status.is_number()
{
*status = json!(503);
}
}
Some(projected)
}
/// 严格协议 decoder/encoder 返回的安全错误 body。
@@ -166,14 +204,33 @@ pub fn gateway_error_response(error: &GatewayError) -> Response {
response
}
/// 在下游尚未提交时优先交付 Provider 的原始 HTTP 失败响应。
/// 在下游尚未提交时交付 Provider 的 HTTP 失败响应,并应用客户端容量恢复合同。
pub fn engine_error_response(error: &EngineError) -> Response {
let capacity_unavailable = matches!(error, EngineError::Provider(error)
if error.kind() == ProviderErrorKind::UpstreamCapacityUnavailable);
if let EngineError::Provider(error) = error
&& let Some(upstream) = error.client_visible_upstream_response()
&& let Ok(status) = StatusCode::from_u16(upstream.status())
{
let mut response = Response::new(Body::from(upstream.body().clone()));
*response.status_mut() = status;
let projected = serde_json::from_slice::<Value>(upstream.body())
.ok()
.and_then(|body| capacity_error_for_client(&body));
let mut response = match projected {
Some(body) => {
let mut response = Response::new(Body::from(body.to_string()));
*response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
response
}
None => {
let mut response = Response::new(Body::from(upstream.body().clone()));
*response.status_mut() = if capacity_unavailable {
StatusCode::SERVICE_UNAVAILABLE
} else {
status
};
response
}
};
if let Some(content_type) = upstream.content_type()
&& let Ok(content_type) = HeaderValue::from_bytes(content_type)
{
@@ -191,6 +248,9 @@ pub fn engine_error_response(error: &EngineError) -> Response {
return response;
}
let mut response = gateway_error_response(&gateway_error_from_engine(error));
if capacity_unavailable {
*response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
}
// 正文未完整取得时不能透传残缺响应,但已确认的上游关联 ID 仍应交付。
if let EngineError::Provider(error) = error
&& let Some(request_id) = error.upstream_request_id()
@@ -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(
@@ -635,7 +635,9 @@ impl ResponsesStreamState {
.push_back(Bytes::from(response_failed_sse_event_with_id(
self.encoder.response_id(),
error.client_error_type().unwrap_or(default_type),
error.client_error_code().unwrap_or(default_code),
super::super::error::client_error_code(
error.client_error_code().unwrap_or(default_code),
),
error.client_message(),
)));
self.pending
@@ -8,6 +8,7 @@ use gateway_protocol::openai::sse::{
use serde_json::Value;
use super::error::ResponseEncodeError;
use crate::openai::error::capacity_error_for_client;
const OPENAI_PROTOCOL: &str = "openai";
@@ -15,7 +16,7 @@ const OPENAI_PROTOCOL: &str = "openai";
///
/// OpenAI Provider 与 xAI adapter 都必须交付 OpenAI wire。wire 是客户端交付
/// 与终态判断的事实来源;canonical facts 只在 wire 暂未携带 ID 时提供旁路观测,
/// 不得反过来拒绝或改写上游事件。
/// 不得反过来拒绝或改写上游事件;客户端错误兼容投影只发生在编码出口。
#[derive(Debug, Default)]
pub struct OpenAiResponsesEncoder {
response_id: Option<String>,
@@ -43,13 +44,15 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
let projected = client_failure_payload(wire);
let data = projected.as_ref().unwrap_or_else(|| wire.data());
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因。
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`。
if wire.event_type() == Some("error")
if effective_event_type(wire) == Some("error")
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
data,
)
{
return vec![Bytes::from(encode_sse_event_with_metadata(
@@ -59,12 +62,14 @@ impl OpenAiResponsesEncoder {
wire.sse_retry(),
))];
}
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
if projected.is_none()
&& let Some(raw_sse_frame) = wire.raw_sse_frame()
{
return vec![raw_sse_frame.clone()];
}
vec![Bytes::from(encode_sse_event_with_metadata(
wire.event_type().unwrap_or_default(),
&wire.data().to_string(),
effective_event_type(wire).unwrap_or_default(),
&data.to_string(),
wire.sse_id(),
wire.sse_retry(),
))]
@@ -77,22 +82,24 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
let projected = client_failure_payload(wire);
let data = projected.as_ref().unwrap_or_else(|| wire.data());
// WS 客户端只对它无法消费的裸 `error` 帧做投影:codex 的 WS 端点
// 会静默忽略缺少 status 且不含内置可重试错误码的 `error` 帧,客户
// 端只能空等到 idle 超时。投影成 `response.failed`(消息 JSON 自带
// type 字段),codex 将其映射为可重试错误并立即重试。可消费形状
// (带非 2xx status 或特殊错误码)原样透传,保持业务语义。
if wire.event_type() == Some("error")
&& !ws_client_consumable_error(wire.data())
// type 字段),让 codex 按错误码处理。可消费形状(带非 2xx status
// 或特殊错误码)保留 envelope,容量码已由共享客户端投影处理。
if effective_event_type(wire) == Some("error")
&& !ws_client_consumable_error(data)
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
data,
)
{
return vec![data.to_string()];
}
vec![wire.data().to_string()]
vec![data.to_string()]
}
/// 返回是否已经看到客户端可见的 wire 终态。
@@ -142,9 +149,7 @@ impl OpenAiResponsesEncoder {
{
self.response_id = Some(response_id.to_owned());
}
let effective_type = wire
.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str));
let effective_type = effective_event_type(wire);
if matches!(
effective_type,
Some("response.created" | "response.in_progress" | "response.queued")
@@ -172,6 +177,22 @@ fn openai_wire(event: &ProviderEvent) -> Option<&ProtocolWireEvent> {
.filter(|wire| wire.protocol() == OPENAI_PROTOCOL)
}
fn effective_event_type(wire: &ProtocolWireEvent) -> Option<&str> {
wire.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str))
}
fn client_failure_payload(wire: &ProtocolWireEvent) -> Option<Value> {
if matches!(
effective_event_type(wire),
Some("error" | "response.failed")
) {
capacity_error_for_client(wire.data())
} else {
None
}
}
/// 判断 WS 上游的 `error` 帧是否已经是客户端可直接消费的形状。
///
/// codex 的 WS 端点只消费两类 `error` 帧:带非 2xx HTTP status 的包装错误,
@@ -2,11 +2,14 @@
use axum::http::{HeaderName, HeaderValue, StatusCode};
use gateway_core::engine::EngineError;
use gateway_core::error::ProviderErrorKind;
use gateway_core::event::ProviderResponseHeader;
use serde_json::{Map, Value, json};
use thiserror::Error;
use crate::openai::error::{gateway_error_contract, gateway_error_from_engine};
use crate::openai::error::{
capacity_error_for_client, gateway_error_contract, gateway_error_from_engine,
};
use super::super::{
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError,
@@ -152,12 +155,18 @@ pub(super) fn initial_engine_error_event(
headers.remove("x-request-id");
headers.remove("x-oai-request-id");
}
let status = upstream
.map(|response| response.status())
.or_else(|| provider.and_then(|error| error.upstream_status()))
.and_then(|value| StatusCode::from_u16(value).ok())
.filter(|value| !value.is_success() && !value.is_informational())
.unwrap_or(default_status);
let status = if provider
.is_some_and(|error| error.kind() == ProviderErrorKind::UpstreamCapacityUnavailable)
{
StatusCode::SERVICE_UNAVAILABLE
} else {
upstream
.map(|response| response.status())
.or_else(|| provider.and_then(|error| error.upstream_status()))
.and_then(|value| StatusCode::from_u16(value).ok())
.filter(|value| !value.is_success() && !value.is_informational())
.unwrap_or(default_status)
};
// 无原响应关联头时使用 Provider 的失败事实;不能让旧会话 ID 遮住当前失败。
if !has_request_id(&headers)
&& let Some(request_id) = provider.and_then(|error| error.upstream_request_id())
@@ -224,7 +233,10 @@ pub(super) fn error_event(
if !headers.is_empty() {
event.insert("headers".to_owned(), Value::Object(headers));
}
Value::Object(event).to_string()
let event = Value::Object(event);
capacity_error_for_client(&event)
.unwrap_or(event)
.to_string()
}
pub(super) fn connection_limit_event() -> String {
@@ -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
}
@@ -4,6 +4,21 @@ use serde_json::json;
use super::AdminTestFixture;
#[test]
fn password_change_request_rejects_unknown_fields_and_redacts_both_passwords() {
let request: gateway_api::auth::ChangePasswordRequest = serde_json::from_value(json!({
"currentPassword": "current-password-secret", "newPassword": "new-password-secret"
}))
.unwrap();
assert!(!format!("{request:?}").contains("password-secret"));
assert!(
serde_json::from_value::<gateway_api::auth::ChangePasswordRequest>(json!({
"currentPassword": "old", "newPassword": "new", "username": "another-admin"
}))
.is_err()
);
}
#[test]
fn login_request_should_deny_unknown_fields_and_redact_password_debug() {
let password = "admin-password-must-not-leak";
@@ -10,6 +10,127 @@ use serde_json::json;
use super::{AdminTestFixture, AdminTestState};
#[test]
fn reset_budget_requires_an_explicit_supported_period_and_valid_key() {
use gateway_api::admin::client_keys::ResetClientKeyBudgetRequest;
for period in ["daily", "weekly", "all"] {
let command = serde_json::from_value::<ResetClientKeyBudgetRequest>(
json!({"id":"key_reset", "period":period}),
)
.unwrap()
.into_command()
.unwrap();
assert_eq!(command.id.as_str(), "key_reset");
}
for payload in [
json!({"id":"key_reset"}),
json!({"id":"key_reset", "period":"monthly"}),
json!({"id":"key_reset", "period":"all", "amount":0}),
] {
assert!(serde_json::from_value::<ResetClientKeyBudgetRequest>(payload).is_err());
}
assert!(
serde_json::from_value::<ResetClientKeyBudgetRequest>(json!({"id":" ", "period":"all"}))
.unwrap()
.into_command()
.is_err()
);
}
#[tokio::test]
async fn reset_budget_route_requires_admin_and_maps_missing_keys() {
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use tower::ServiceExt as _;
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (cookie, expected) in [
("", StatusCode::UNAUTHORIZED),
("cpr_session=valid-session", StatusCode::NOT_FOUND),
] {
let response = client_keys::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/client-keys/reset-budget")
.header(header::COOKIE, cookie)
.header(header::CONTENT_TYPE, "application/json")
.header("x-request-id", "req_reset")
.body(Body::from(r#"{"id":"missing","period":"all"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), expected);
}
}
#[tokio::test]
async fn budget_reset_allows_admin_but_rejects_key_sessions_without_changing_usage() {
use crate::support::{RAW_KEY, json_request, key_fixture, response_json};
use axum::http::{Method, StatusCode, header};
use gateway_core::policy::ClientApiKeyId;
use tower::ServiceExt as _;
let fixture = key_fixture().await;
fixture.auth.insert_session("valid-admin");
let mut record = fixture
.services
.client_keys()
.reveal(&ClientApiKeyId::new("key-42").unwrap())
.await
.unwrap()
.record;
record.budget.daily_used_usd = "1.25".parse().unwrap();
record.budget.weekly_used_usd = "4.5".parse().unwrap();
*fixture.client_key.lock().unwrap() = Some(record);
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let login = app
.clone()
.oneshot(json_request(
Method::POST,
"/api/auth/login",
json!({"mode":"key", "apiKey":RAW_KEY}),
))
.await
.unwrap();
assert_eq!(login.status(), StatusCode::OK);
let key_cookie = login.headers()[header::SET_COOKIE]
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_owned();
for (cookie, expected, used) in [
(key_cookie.as_str(), StatusCode::FORBIDDEN, "1.25"),
("cpr_session=valid-admin", StatusCode::OK, "0"),
] {
let mut request = json_request(
Method::POST,
"/api/admin/client-keys/reset-budget",
json!({"id":"key-42", "period":"daily"}),
);
request
.headers_mut()
.insert(header::COOKIE, cookie.parse().unwrap());
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), expected);
if expected == StatusCode::OK {
assert_eq!(
response_json(response).await["data"],
json!({"id":"key-42"})
);
}
let key = fixture.client_key.lock().unwrap().clone().unwrap();
assert_eq!(key.budget.daily_used_usd.canonical(), used);
assert_eq!(key.budget.weekly_used_usd.canonical(), "4.5");
}
}
#[test]
fn custom_client_keys_preserve_migrated_values_and_redact_debug() {
for value in [
@@ -311,6 +432,8 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
.single()
.expect("valid time");
let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord {
openai_client_profile_override: None,
xai_client_profile_override: None,
budget: Default::default(),
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
name: "visible".to_owned(),
@@ -446,3 +569,38 @@ async fn list_route_should_accept_the_full_nonzero_u16_page_size() {
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn profile_override_distinguishes_omission_from_explicit_inheritance() {
let mut payload = json!({"id":"key_profile", "name":"profile", "groupIds":[], "maxConcurrency":0, "requestsPerMinute":0});
let decode = |body| {
serde_json::from_value::<UpdateClientKeyRequest>(body)
.unwrap()
.into_command()
.unwrap()
.openai_client_profile_override
};
assert_eq!(decode(payload.clone()), None);
payload["openaiClientProfileOverride"] = json!(null);
assert_eq!(decode(payload.clone()), Some(None));
payload["openaiClientProfileOverride"] =
json!({"client":"cli", "platform":"linux", "versionMode":"latest"});
assert!(decode(payload).unwrap().is_some());
}
#[test]
fn xai_profile_override_distinguishes_omission_from_explicit_inheritance() {
let mut payload = json!({"id":"key_profile", "name":"profile", "groupIds":[], "maxConcurrency":0, "requestsPerMinute":0});
let decode = |body| {
serde_json::from_value::<UpdateClientKeyRequest>(body)
.unwrap()
.into_command()
.unwrap()
.xai_client_profile_override
};
assert_eq!(decode(payload.clone()), None);
payload["xaiClientProfileOverride"] = json!(null);
assert_eq!(decode(payload.clone()), Some(None));
payload["xaiClientProfileOverride"] = json!({"versionMode":"latest"});
assert!(decode(payload).unwrap().is_some());
}
+165 -2
View File
@@ -176,6 +176,7 @@ impl AdminTestFixture {
ClientConfig::default(),
stores,
gateway_admin::AdminRuntimePorts {
pricing_source: Arc::new(StaticPricingSource),
providers,
snapshot: Arc::new(NoopSnapshot),
account_probe: Arc::new(NoopProbe),
@@ -270,6 +271,20 @@ impl MemoryAuthStore {
session_id.to_owned(),
AuthSession {
subject: gateway_admin::model::auth::SessionSubject::Admin {
credential_fingerprint: {
use base64::Engine as _;
use sha2::Digest as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
sha2::Sha256::digest(
self.password_hash
.lock()
.unwrap()
.as_ref()
.unwrap()
.as_bytes(),
),
)
},
admin_user_id: "admin_1".to_owned(),
},
expires_at: Utc::now() + Duration::hours(1),
@@ -300,6 +315,28 @@ impl AuthStore for MemoryAuthStore {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn change_password(
&self,
_: &str,
expected_hash: &str,
password_hash: &str,
audit: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
if self.fail_audit.load(Ordering::SeqCst) {
return Err(unavailable("audit"));
}
let mut stored = self.password_hash.lock().unwrap();
let Some(credentials) = stored
.as_mut()
.filter(|value| value.as_str() == expected_hash)
else {
return Ok(false);
};
*credentials = password_hash.to_owned();
self.audits.lock().unwrap().push(audit);
Ok(true)
}
async fn create_password_hash_if_absent(
&self,
_: &str,
@@ -368,14 +405,31 @@ impl AuthStore for MemoryAuthStore {
}
pub(super) struct MemorySettingsStore {
pricing: Mutex<gateway_admin::model::pricing::StoredPricing>,
settings: Mutex<RuntimeSettings>,
api_key: Arc<Mutex<Option<AdminApiKey>>>,
}
struct StaticPricingSource;
#[async_trait]
impl gateway_admin::ports::pricing::PricingSource for StaticPricingSource {
async fn fetch(
&self,
) -> Result<gateway_admin::model::pricing::PricingSyncPreview, gateway_admin::model::AdminError>
{
Ok(gateway_admin::model::pricing::PricingSyncPreview {
prices: serde_json::from_value(serde_json::json!({"openai":{"gpt-5.4":{
"multiplierBps":10000,"bands":{"standard":{"input":"2.5","output":"15","cacheRead":"0.25","cacheWrite":"0"}}
}}})).expect("prices"), skipped: vec![],
})
}
}
impl MemorySettingsStore {
fn new(api_key: Arc<Mutex<Option<AdminApiKey>>>) -> Self {
Self {
settings: Mutex::new(test_runtime_settings()),
pricing: Mutex::default(),
api_key,
}
}
@@ -387,6 +441,70 @@ impl MemorySettingsStore {
#[async_trait]
impl SettingsStore for MemorySettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(self.pricing.lock().expect("pricing").clone())
}
async fn sync_pricing(
&self,
changes: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
let mut pricing = self.pricing.lock().expect("pricing");
for (provider, models) in changes {
let stored = pricing.synced.entry(provider).or_default();
for (model, price) in models {
if let Some(price) = price {
stored.insert(model, price);
} else {
stored.remove(&model);
}
}
}
pricing.synced.retain(|_, models| !models.is_empty());
pricing.synced_at = Some(Utc::now());
let mut settings = self.settings.lock().expect("settings");
settings.config_revision = next_revision(settings.config_revision);
Ok(settings.config_revision)
}
async fn update_pricing(
&self,
command: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
use gateway_admin::model::pricing::PricingChange;
let mut pricing = self.pricing.lock().expect("pricing");
let gateway_admin::model::pricing::StoredPricing {
overrides, synced, ..
} = &mut *pricing;
let models = overrides.entry(command.provider.clone()).or_default();
let source = synced.entry(command.provider).or_default();
for model in command.models {
match &command.change {
PricingChange::Reset => {
models.remove(&model);
}
PricingChange::Delete => {
models.remove(&model);
source.remove(&model);
}
PricingChange::Replace(p) => {
models.insert(model, p.clone());
}
PricingChange::Multiplier(bps) => {
models
.entry(model)
.or_insert_with(|| gateway_core::metering::ModelPriceOverride {
multiplier_bps: 10_000,
bands: Default::default(),
})
.multiplier_bps = *bps;
}
}
}
let mut settings = self.settings.lock().expect("settings");
settings.config_revision = next_revision(settings.config_revision);
Ok(settings.config_revision)
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(self.settings.lock().expect("settings").clone())
}
@@ -402,7 +520,8 @@ impl SettingsStore for MemorySettingsStore {
) -> AdminStoreResult<RuntimeSettings> {
let mut settings = self.settings.lock().expect("settings");
let updated = RuntimeSettings {
disable_fast: command.disable_fast.unwrap_or(settings.disable_fast),
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),
@@ -717,6 +836,34 @@ fn mutation(
#[async_trait]
impl ClientKeyStore for MemoryClientKeyStore {
async fn reset_client_key_budget(
&self,
command: gateway_admin::model::client_keys::ResetClientKeyBudget,
_: &MutationContext,
) -> AdminStoreResult<()> {
use gateway_admin::model::client_keys::ClientKeyBudgetPeriod;
let mut record = self.0.lock().unwrap();
let record = record
.as_mut()
.filter(|record| record.id == command.id)
.ok_or_else(|| {
AdminStoreError::new(AdminStoreErrorKind::NotFound, "client key", "missing key")
})?;
if matches!(
command.period,
ClientKeyBudgetPeriod::Daily | ClientKeyBudgetPeriod::All
) {
record.budget.daily_used_usd = gateway_core::metering::Decimal::ZERO;
}
if matches!(
command.period,
ClientKeyBudgetPeriod::Weekly | ClientKeyBudgetPeriod::All
) {
record.budget.weekly_used_usd = gateway_core::metering::Decimal::ZERO;
}
Ok(())
}
async fn get_client_key(
&self,
id: &ClientApiKeyId,
@@ -742,9 +889,15 @@ impl ClientKeyStore for MemoryClientKeyStore {
&self,
id: &ClientApiKeyId,
) -> AdminStoreResult<Option<ClientKeySecret>> {
if let Some(record) = self.0.lock().expect("client key").clone() {
return Ok((record.id == *id)
.then(|| ClientKeySecret::new(record, format!("sk_{}", "a".repeat(43)))));
}
let now = Utc::now();
Ok(Some(ClientKeySecret::new(
ClientKeyRecord {
openai_client_profile_override: None,
xai_client_profile_override: None,
budget: Default::default(),
id: id.clone(),
name: "revealed".to_owned(),
@@ -1113,6 +1266,15 @@ impl UnusedProvider {
#[async_trait]
impl ProviderAdmin for UnusedProvider {
fn pricing_catalog(&self) -> gateway_admin::model::pricing::ProviderPricingCatalog {
serde_json::from_value(serde_json::json!({
"builtin-model": {
"multiplierBps": 10000,
"bands": {"standard": {"input": "1", "output": "2", "cacheRead": "0", "cacheWrite": "1"}}
}
})).unwrap()
}
fn provider_kind(&self) -> &ProviderKind {
&self.kind
}
@@ -1278,7 +1440,8 @@ fn test_runtime_settings() -> RuntimeSettings {
),
]);
RuntimeSettings {
disable_fast: false,
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,8 @@ fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() {
#[test]
fn billing_view_should_preserve_the_original_detail_contract() {
let value = serde_json::to_value(BillingView {
long_context_billing_applied: false,
image: None,
input_amount_display: "$0.03".to_owned(),
output_amount_display: "$0.00".to_owned(),
cache_read_amount_display: "$0.14".to_owned(),
@@ -630,6 +632,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
.lock()
.expect("ops errors")
.push(OpsError {
client_api_key_name: Some("Production".to_owned()),
source: "model_request".to_owned(),
event_id: "err_snapshot".to_owned(),
request_id: Some("req_err".to_owned()),
@@ -700,6 +703,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
.await
.expect("ops errors body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("ops errors JSON");
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
assert_eq!(
serde_json::json!({
"provider": value["data"]["items"][0]["provider"],
@@ -953,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(),
@@ -961,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()),
@@ -979,6 +985,9 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
cost_currency: None,
billing: Some(UsageBilling::Calculated(Box::new(
CalculatedBillingBreakdown {
long_context_billing_applied: true,
custom_multiplier_bps: 10_000,
image: None,
input_amount: usd("0.03"),
output_amount: usd("0.07"),
cache_read_amount: usd("0.00"),
@@ -1048,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"
@@ -37,7 +37,6 @@ async fn response_json(response: axum::response::Response) -> Value {
fn update_body() -> Value {
json!({
"disableFast": false,
"requestLocationEnabled": false,
"requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"},
"modelMappings": {
@@ -103,7 +102,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
use gateway_core::routing::{PublicModelId, UpstreamModelId};
let settings = RuntimeSettings {
disable_fast: false,
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
@@ -148,7 +148,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
assert_eq!(
value,
json!({
"disableFast": false,
"openaiClientProfile": null,
"xaiClientProfile": null,
"requestLocationEnabled": false,
"requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"},
"modelMappings": {
@@ -202,7 +203,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
.cloned()
.collect();
let settings = RuntimeSettings {
disable_fast: false,
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 +253,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
.cloned()
.collect();
let mut expected_fields = request_fields;
expected_fields.insert("openaiClientProfile".to_owned());
expected_fields.insert("xaiClientProfile".to_owned());
expected_fields.insert("updatedAt".to_owned());
assert_eq!(response_fields, expected_fields);
@@ -606,17 +610,14 @@ fn decompression_setting_should_reject_invalid_values() {
}
#[tokio::test]
async fn disable_fast_settings_updates_preserve_omitted_values() {
async fn settings_reject_removed_global_fast_policy() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
for (value, expected) in [(Some(true), true), (None, true), (Some(false), false)] {
let initial = fixture.settings.settings.lock().unwrap().clone();
for value in [json!(true), json!(false), Value::Null] {
let mut body = update_body();
if let Some(value) = value {
body["disableFast"] = json!(value);
} else {
body.as_object_mut().unwrap().remove("disableFast");
}
body["disableFast"] = value;
let response = app
.clone()
.oneshot(request(
@@ -626,15 +627,424 @@ async fn disable_fast_settings_updates_preserve_omitted_values() {
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(*fixture.settings.settings.lock().unwrap(), initial);
}
let response = app
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response_json(response).await["data"]
.get("disableFast")
.is_none()
);
}
#[test]
fn global_profile_can_be_omitted_but_cannot_be_cleared() {
for field in ["openaiClientProfile", "xaiClientProfile"] {
let mut body = update_body();
body.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_ok());
body[field] = json!(null);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_err());
body[field] = json!({"versionMode":"latest"});
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_ok());
}
}
fn custom_pricing() -> Value {
json!({"multiplierBps":12500,"bands":{"standard":{"input":"3","output":"12","cacheRead":"0","cacheWrite":"0"}}})
}
#[tokio::test]
async fn pricing_routes_keep_manual_overrides_during_sync_and_reset_to_source() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
let response = app.clone().oneshot(request(Method::POST, "/api/admin/settings/pricing/update", Some(json!({
"provider":"openai", "models":["gpt-5.4"], "change":{"action":"replace", "pricing":custom_pricing()}
})))).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let preview = response_json(
app.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
))
.await
.unwrap(),
)
.await;
let approved = preview["data"].clone();
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview": approved, "models": {"openai": ["gpt-5.4"]}})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let result = response_json(
app.clone()
.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
assert_eq!(
result["data"]["overrides"]["openai"]["gpt-5.4"],
custom_pricing()
);
assert_eq!(
result["data"]["synced"]["openai"]["gpt-5.4"]["bands"]["standard"]["input"],
"2.5"
);
assert!(result["data"]["syncedAt"].is_string());
for _ in 0..2 {
let response = app.clone().oneshot(request(Method::POST, "/api/admin/settings/pricing/update", Some(json!({
"provider":"openai", "models":["gpt-5.4"], "change":{"action":"multiplier", "multiplierBps":20000}
})))).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
let result = response_json(
app.clone()
.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
assert_eq!(
result["data"]["overrides"]["openai"]["gpt-5.4"]["multiplierBps"],
20000
);
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(json!({
"provider":"openai", "models":["gpt-5.4"], "change":{"action":"reset"}
})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let result = response_json(
app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
assert!(result["data"]["overrides"]["openai"]["gpt-5.4"].is_null());
assert!(result["data"]["synced"]["openai"]["gpt-5.4"].is_object());
}
#[tokio::test]
async fn pricing_delete_rejects_builtin_models_even_with_overrides_without_partial_writes() {
for overridden in [false, true] {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
{
let mut stored = fixture.settings.pricing.lock().unwrap();
stored.synced = serde_json::from_value(json!({
"openai": {"builtin-model": custom_pricing(), "custom-model": custom_pricing()}
}))
.unwrap();
if overridden {
stored.overrides = stored.synced.clone();
}
}
let before = fixture.settings.pricing.lock().unwrap().clone();
let revision = fixture.settings.settings.lock().unwrap().config_revision;
for models in [
json!(["builtin-model"]),
json!(["custom-model", "builtin-model"]),
] {
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(
json!({"provider":"openai", "models":models, "change":{"action":"delete"}}),
),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(*fixture.settings.pricing.lock().unwrap(), before);
assert_eq!(
fixture.settings.settings.lock().unwrap().config_revision,
revision
);
}
}
}
#[tokio::test]
async fn pricing_delete_removes_selected_nonbuiltin_models_from_both_layers() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
{
let mut stored = fixture.settings.pricing.lock().unwrap();
stored.synced = serde_json::from_value(json!({
"openai": {"shared":custom_pricing(), "synced-only":custom_pricing(), "untouched":custom_pricing()},
"xai": {"shared":custom_pricing()}
})).unwrap();
stored.overrides = serde_json::from_value(json!({
"openai": {"shared":custom_pricing(), "custom-only":custom_pricing(), "untouched":custom_pricing()},
"xai": {"shared":custom_pricing()}
})).unwrap();
}
for _ in 0..2 {
let response = app.clone().oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(json!({
"provider":"openai", "models":["shared", "synced-only", "custom-only"], "change":{"action":"delete"}
})),
)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
let result = response_json(
app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
let expected =
json!({"openai":{"untouched":custom_pricing()},"xai":{"shared":custom_pricing()}});
assert_eq!(result["data"]["overrides"], expected);
assert_eq!(result["data"]["synced"], expected);
assert!(result["data"]["defaults"]["openai"]["builtin-model"].is_object());
}
#[tokio::test]
async fn pricing_rejects_invalid_edits_and_tampered_sync_without_writes() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
for body in [
json!({"provider":"unknown","models":["model"],"change":{"action":"reset"}}),
json!({"provider":"openai","models":[],"change":{"action":"reset"}}),
json!({"provider":"openai","models":["bad model"],"change":{"action":"reset"}}),
json!({"provider":"openai","models":["model"],"change":{"action":"multiplier","multiplierBps":1_000_001}}),
json!({"provider":"openai","models":["model"],"change":{"action":"replace","pricing":{"multiplierBps":10000,"bands":{}}}}),
] {
let scenario = body.to_string();
let response = app
.clone()
.oneshot(request(Method::GET, "/api/admin/settings", None))
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{scenario}");
}
// JSON 合同错误沿用 AdminJson 的 422,业务校验错误为 400。
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(json!({
"provider":"openai","models":["model"],"change":{"action":"reset","extra":true}
})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview":{"prices":{},"skipped":[]},"models":{"openai":["gpt-5.4"]}})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let result = response_json(
app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
assert_eq!(result["data"]["overrides"], json!({}));
assert_eq!(result["data"]["synced"], json!({}));
}
#[tokio::test]
async fn pricing_sync_only_updates_selected_provider_models() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let old = custom_pricing();
{
let mut pricing = fixture.settings.pricing.lock().unwrap();
pricing.synced = serde_json::from_value(json!({
"openai": {"gpt-5.4": old, "untouched": old},
"xai": {"gpt-5.4": old}
}))
.unwrap();
pricing.overrides = serde_json::from_value(json!({"openai": {"gpt-5.4": old}})).unwrap();
}
let app = app(fixture.state());
let preview = response_json(
app.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
))
.await
.unwrap(),
)
.await;
let response = app
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview": preview["data"], "models": {"openai": ["gpt-5.4"]}})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let pricing = fixture.settings.pricing.lock().unwrap();
assert_eq!(
serde_json::to_value(&pricing.synced).unwrap(),
json!({
"openai": {"gpt-5.4": preview["data"]["prices"]["openai"]["gpt-5.4"], "untouched": old},
"xai": {"gpt-5.4": old}
})
);
assert_eq!(
serde_json::to_value(&pricing.overrides).unwrap(),
json!({"openai": {"gpt-5.4": old}})
);
}
#[tokio::test]
async fn pricing_sync_removes_only_selected_retired_source_prices() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
{
let mut pricing = fixture.settings.pricing.lock().unwrap();
pricing.synced = serde_json::from_value(json!({
"openai": {"retired": custom_pricing(), "untouched": custom_pricing()}
}))
.unwrap();
}
let app = app(fixture.state());
let preview = response_json(
app.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
))
.await
.unwrap(),
)
.await;
let response = app
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview": preview["data"], "models": {"openai": ["retired"]}})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let pricing = fixture.settings.pricing.lock().unwrap();
assert_eq!(
serde_json::to_value(&pricing.synced).unwrap(),
json!({
"openai": {"untouched": custom_pricing()}
})
);
}
#[tokio::test]
async fn pricing_sync_rejects_empty_unknown_or_oversized_selections_without_writes() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
let preview = response_json(
app.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
))
.await
.unwrap(),
)
.await;
for models in [
json!({}),
json!({"openai": []}),
json!({"unknown": ["gpt-5.4"]}),
json!({"openai": ["gpt-5.4", "missing"]}),
json!({"openai": ["gpt-5.4"], "xai": []}),
json!({"openai": (0..10_001).map(|index| format!("model-{index}")).collect::<Vec<_>>()}),
] {
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview": preview["data"], "models": models})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
let pricing = fixture.settings.pricing.lock().unwrap();
assert_eq!(
*pricing,
gateway_admin::model::pricing::StoredPricing::default()
);
}
#[tokio::test]
async fn pricing_endpoints_require_administrator_authentication() {
let fixture = AdminTestFixture::new().await;
let app = app(fixture.state());
for (method, path, body) in [
(Method::GET, "/api/admin/settings/pricing", None),
(
Method::POST,
"/api/admin/settings/pricing/update",
Some(json!({"provider":"openai","models":["model"],"change":{"action":"reset"}})),
),
(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
),
(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview":{"prices":{},"skipped":[]},"models":{"openai":["gpt-5.4"]}})),
),
] {
assert_eq!(
response_json(response).await["data"]["disableFast"],
expected
app.clone()
.oneshot(request(method, path, body))
.await
.unwrap()
.status(),
StatusCode::UNAUTHORIZED
);
}
}
@@ -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();
+167
View File
@@ -42,6 +42,173 @@ async fn get(app: &axum::Router, path: &str, cookie: &str) -> axum::response::Re
.expect("GET response")
}
async fn change_password(
app: &axum::Router,
cookie: Option<&str>,
current: &str,
new: &str,
) -> axum::response::Response {
let mut request = json_request(
Method::POST,
"/api/auth/password",
json!({"currentPassword": current, "newPassword": new}),
);
if let Some(cookie) = cookie {
request
.headers_mut()
.insert(header::COOKIE, cookie.parse().unwrap());
}
app.clone().oneshot(request).await.unwrap()
}
#[tokio::test]
async fn password_change_revokes_all_admin_sessions_but_preserves_key_sessions() {
let (app, fixture) = auth_app().await;
let admin_body = json!({"mode": "admin", "password": "strong-admin-password"});
let first = session_cookie(&login(&app, admin_body.clone(), None).await);
let second = session_cookie(&login(&app, admin_body.clone(), None).await);
let key = session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
fixture.set_api_key(&format!("admin-{}", "b".repeat(64)));
assert_eq!(
get(&app, "/api/admin/system/version", &first)
.await
.status(),
StatusCode::OK
);
let response = change_password(
&app,
Some(&first),
"strong-admin-password",
"new-strong-admin-password",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(
response.headers()[header::SET_COOKIE]
.to_str()
.unwrap()
.contains("Max-Age=0")
);
for cookie in [&first, &second] {
assert_eq!(
get(&app, "/api/admin/system/version", cookie)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
assert_eq!(
response_json(get(&app, "/api/auth/status", &key).await).await["data"]["authenticated"],
true
);
assert_eq!(
login(&app, admin_body, None).await.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
login(
&app,
json!({"mode": "admin", "password": "new-strong-admin-password"}),
None
)
.await
.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn password_change_requires_admin_session_and_keeps_session_on_validation_failure() {
let (app, _) = auth_app().await;
assert_eq!(
change_password(&app, None, "strong-admin-password", "new-strong-password")
.await
.status(),
StatusCode::UNAUTHORIZED
);
let key = session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
assert_eq!(
change_password(
&app,
Some(&key),
"strong-admin-password",
"new-strong-password"
)
.await
.status(),
StatusCode::FORBIDDEN
);
let admin = session_cookie(
&login(
&app,
json!({"mode": "admin", "password": "strong-admin-password"}),
None,
)
.await,
);
for (current, new) in [
("wrong-current-password", "new-strong-password"),
("strong-admin-password", "short"),
("strong-admin-password", "strong-admin-password"),
] {
assert_eq!(
change_password(&app, Some(&admin), current, new)
.await
.status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
get(&app, "/api/admin/system/version", &admin)
.await
.status(),
StatusCode::OK
);
}
}
#[tokio::test]
async fn password_change_audit_failure_preserves_password_and_session() {
let (app, fixture) = auth_app().await;
let admin = session_cookie(
&login(
&app,
json!({"mode": "admin", "password": "strong-admin-password"}),
None,
)
.await,
);
fixture.fail_audit(true);
assert_eq!(
change_password(
&app,
Some(&admin),
"strong-admin-password",
"new-strong-password"
)
.await
.status(),
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
get(&app, "/api/admin/system/version", &admin)
.await
.status(),
StatusCode::OK
);
fixture.fail_audit(false);
assert_eq!(
login(
&app,
json!({"mode": "admin", "password": "strong-admin-password"}),
None
)
.await
.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn unified_login_returns_server_identity_and_rotates_the_previous_session() {
let (app, _) = auth_app().await;
@@ -17,6 +17,8 @@ pub(super) async fn fixture() -> AdminTestFixture {
let fixture = key_fixture().await;
let now = Utc::now();
*fixture.client_key.lock().unwrap() = Some(ClientKeyRecord {
openai_client_profile_override: None,
xai_client_profile_override: None,
id: ClientApiKeyId::new("key-42").unwrap(),
name: "Development".to_owned(),
label: Some("private-sentinel".to_owned()),
@@ -89,6 +91,7 @@ pub(super) async fn fixture() -> AdminTestFixture {
fn usage_record() -> UsageListRecord {
UsageListRecord {
client_api_key_name: Some("Production".to_owned()),
id: "req-visible".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
@@ -97,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()),
@@ -145,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()
@@ -7,10 +7,127 @@ use gateway_core::error::{
use gateway_core::upstream::UpstreamSendState;
use gateway_api::openai::error::{
gateway_error_contract, gateway_error_from_engine, gateway_error_response,
openai_error_response,
engine_error_response, gateway_error_contract, gateway_error_from_engine,
gateway_error_response, openai_error_response,
};
#[tokio::test]
async fn capacity_errors_offer_client_retry_without_changing_upstream_facts() {
use bytes::Bytes;
use gateway_core::error::ClientVisibleUpstreamResponse;
use gateway_core::event::ProviderResponseHeader;
use gateway_core::upstream::OpaqueUpstreamValue;
use serde_json::{Value, json};
for code in ["server_is_overloaded", "slow_down"] {
for status in [400, 429, 503] {
for raw_response in [false, true] {
let original = json!({"error": {
"code": code, "type": "service_unavailable_error", "message": "busy",
"param": "model", "future": {"keep": true}
}});
let mut provider = ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_status(status)
.with_upstream_code(OpaqueUpstreamValue::new(code.to_owned()))
.with_client_visible_upstream_error(
ClientVisibleUpstreamError::new(
"busy",
Some(code.to_owned()),
Some("service_unavailable_error".to_owned()),
),
);
if raw_response {
provider = provider.with_client_visible_upstream_response(
ClientVisibleUpstreamResponse::new(
status,
Some(b"application/json".to_vec()),
Bytes::from(original.to_string()),
)
.with_headers(vec![
ProviderResponseHeader::new(
"x-request-id",
Bytes::from_static(b"req_capacity"),
),
ProviderResponseHeader::new("retry-after", Bytes::from_static(b"7")),
]),
);
}
let error = EngineError::Provider(provider);
let response = engine_error_response(&error);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
if raw_response {
assert_eq!(response.headers()["x-request-id"], "req_capacity");
assert_eq!(response.headers()["retry-after"], "7");
}
let body: Value = serde_json::from_slice(
&to_bytes(response.into_body(), 4096)
.await
.expect("response body"),
)
.expect("JSON");
assert_eq!(body["error"]["code"], "server_error");
assert_eq!(body["error"]["message"], "busy");
assert_eq!(body["error"]["type"], "service_unavailable_error");
if raw_response {
let mut expected = original.clone();
expected["error"]["code"] = json!("server_error");
assert_eq!(body, expected);
}
let EngineError::Provider(provider) = error else {
unreachable!()
};
assert_eq!(provider.upstream_status(), Some(status));
assert_eq!(
provider.upstream_code().map(OpaqueUpstreamValue::as_str),
Some(code)
);
assert_eq!(
provider
.client_visible_upstream_error()
.expect("detail")
.code(),
Some(code)
);
if let Some(raw) = provider.client_visible_upstream_response() {
assert_eq!(
serde_json::from_slice::<Value>(raw.body()).expect("original JSON"),
original
);
}
}
}
}
}
#[tokio::test]
async fn classified_capacity_error_without_special_code_returns_retryable_http_status() {
use bytes::Bytes;
use gateway_core::error::ClientVisibleUpstreamResponse;
let body = Bytes::from_static(br#"{"error":{"code":null,"type":"server_error","message":"Selected model is at capacity. Please try a different model."}}"#);
let error = EngineError::Provider(
ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_status(400)
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
400,
Some(b"application/json".to_vec()),
body.clone(),
)),
);
let response = engine_error_response(&error);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
to_bytes(response.into_body(), 4096).await.expect("body"),
body
);
}
#[tokio::test]
async fn key_budget_errors_preserve_limit_code_and_retry_after() {
for code in ["key_daily_budget_exceeded", "key_weekly_budget_exceeded"] {
@@ -329,6 +446,27 @@ fn engine_provider_quota_exhaustion_should_use_the_retryable_capacity_contract()
);
}
#[tokio::test]
async fn locally_exhausted_account_pool_returns_official_usage_limit_contract() {
let error = EngineError::Provider(
ProviderError::new(
ProviderErrorKind::QuotaExhausted,
UpstreamSendState::NotSent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"All eligible accounts have exhausted their quota.",
Some("usage_limit_reached".to_owned()),
Some("usage_limit_reached".to_owned()),
)),
);
let response = engine_error_response(&error);
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap();
assert_eq!(body["error"]["type"], "usage_limit_reached");
assert_eq!(body["error"]["code"], "usage_limit_reached");
}
#[test]
fn engine_provider_timeout_and_cancellation_should_remain_distinct() {
let timeout = EngineError::Provider(ProviderError::new(
@@ -6,6 +6,7 @@ mod models;
mod responses;
mod router;
mod search;
mod usage;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
@@ -1698,7 +1698,7 @@ async fn streaming_upstream_error_event_should_be_translated_to_response_failed_
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"code": "server_error",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
@@ -1715,6 +1715,45 @@ async fn streaming_upstream_error_event_should_be_translated_to_response_failed_
);
}
#[tokio::test]
async fn streaming_capacity_error_after_commit_offers_client_retry() {
for code in ["server_is_overloaded", "slow_down"] {
let trace = Arc::new(Trace::default());
let error = ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"busy",
Some(code.to_owned()),
Some("service_unavailable_error".to_owned()),
));
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)),
NextStep::Error(EngineError::Provider(error)),
],
);
let response = stream_execution_response(Box::new(session), None).await;
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 16 * 1024)
.await
.expect("SSE body");
let text = std::str::from_utf8(&body).expect("UTF-8");
let events = parse_sse_events(text).expect("SSE");
let failed = events
.iter()
.find(|event| event.event.as_deref() == Some("response.failed"))
.expect("failed event");
let failed: Value = serde_json::from_str(&failed.data).expect("JSON");
assert_eq!(failed["response"]["error"]["code"], "server_error");
assert_eq!(failed["response"]["error"]["message"], "busy");
assert_eq!(text.matches("event: response.failed").count(), 1);
assert!(text.ends_with("data: [DONE]\n\n"));
}
}
#[tokio::test]
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
let trace = Arc::new(Trace::default());
@@ -846,7 +846,7 @@ fn transparent_encoder_should_translate_error_wire_to_response_failed_for_websoc
assert_eq!(payload["type"], "response.failed");
assert_eq!(payload["response"]["id"], response_id);
assert_eq!(payload["response"]["status"], "failed");
assert_eq!(payload["response"]["error"]["code"], "server_is_overloaded");
assert_eq!(payload["response"]["error"]["code"], "server_error");
// 与 SSE 边界投影到同一份 data 负载,两条客户端通道行为一致。
let mut sse_encoder = OpenAiResponsesEncoder::new();
@@ -920,6 +920,108 @@ fn transparent_encoder_should_follow_wire_when_canonical_identity_changes() {
);
}
#[test]
fn capacity_failure_projection_preserves_wire_metadata_and_original_events() {
for code in ["server_is_overloaded", "slow_down"] {
for (event_type, explicit_type) in [
("error", true),
("error", false),
("response.failed", true),
("response.failed", false),
] {
for status in [None, Some(400), Some(429), Some(503)] {
let error = json!({"code": code, "type": "service_unavailable_error", "message": "busy", "future": 42});
let mut original = if event_type == "error" {
json!({"type": event_type, "error": error, "sequence_number": 3})
} else {
json!({"type": event_type, "response": {"id": "resp_capacity", "status": "failed", "error": error}, "sequence_number": 3})
};
if let Some(status) = status {
original["status_code"] = json!(status);
}
let raw = Bytes::from(format!(
"id: capacity\r\nretry: 1700\r\nevent: {event_type}\r\ndata: {original}\r\n\r\n"
));
let event = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
explicit_type.then(|| event_type.to_owned()),
original.clone(),
raw.clone(),
Some("capacity".to_owned()),
Some(1700),
)
.expect("wire"),
);
let mut sse = OpenAiResponsesEncoder::new();
let mut ws = OpenAiResponsesEncoder::new();
let created = openai_wire_event(
Vec::new(),
"response.created",
json!({"type": "response.created", "response": {"id": "resp_capacity", "status": "in_progress"}}),
);
sse.push_sse(&created);
ws.push_websocket(&created);
let frames = sse.push_sse(&event);
let parsed =
parse_sse_events(std::str::from_utf8(&frames[0]).expect("UTF-8")).expect("SSE");
let sse_data: Value = serde_json::from_str(&parsed[0].data).expect("JSON");
assert_eq!(parsed[0].id.as_deref(), Some("capacity"));
assert_eq!(parsed[0].retry, Some(1700));
assert_eq!(parsed[0].event.as_deref(), Some("response.failed"));
assert_eq!(sse_data["response"]["error"]["code"], "server_error");
assert_eq!(sse_data["response"]["error"]["message"], "busy");
assert_eq!(sse_data["response"]["error"]["future"], 42);
let messages = ws.push_websocket(&event);
let ws_data: Value = serde_json::from_str(&messages[0]).expect("JSON");
if event_type == "error" && status.is_some() {
let mut expected = original.clone();
expected["error"]["code"] = json!("server_error");
expected["status_code"] = json!(503);
assert_eq!(ws_data, expected);
} else {
assert_eq!(ws_data, sse_data);
}
assert!(sse.has_wire_failure());
assert!(ws.has_wire_failure());
let wire = event.wire_event().expect("original wire");
assert_eq!(wire.data(), &original);
assert_eq!(wire.raw_sse_frame(), Some(&raw));
}
}
}
}
#[test]
fn capacity_client_projection_does_not_touch_other_codes_or_non_failure_events() {
for (event_type, code) in [
("response.failed", "rate_limit_exceeded"),
("response.failed", "insufficient_quota"),
("response.failed", "previous_response_not_found"),
("response.future", "server_is_overloaded"),
] {
let original =
json!({"type": event_type, "response": {"error": {"code": code, "message": "busy"}}});
let raw = Bytes::from(format!("event: {event_type}\r\ndata: {original}\r\n\r\n"));
let event = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some(event_type.to_owned()),
original.clone(),
raw.clone(),
None,
None,
)
.expect("wire"),
);
assert_eq!(OpenAiResponsesEncoder::new().push_sse(&event), vec![raw]);
assert_eq!(
OpenAiResponsesEncoder::new().push_websocket(&event),
vec![original.to_string()]
);
}
}
#[test]
fn transparent_encoder_should_only_require_wire_terminal_for_buffered_conversion() {
let mut missing_terminal = OpenAiResponsesEncoder::new();
@@ -200,6 +200,54 @@ async fn non_structured_bodies_use_safe_fallback_without_losing_status_or_ids()
}
}
#[tokio::test]
async fn initial_capacity_failure_offers_client_retry_with_upstream_correlation() {
for code in ["server_is_overloaded", "slow_down"] {
for status in [400, 429, 503] {
let provider = upstream_failure(
status,
b"",
vec![
header("x-request-id", b"req_capacity"),
header("retry-after", b"7"),
],
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"busy",
Some(code.to_owned()),
Some("service_unavailable_error".to_owned()),
));
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
assert_eq!(error["status"], 503);
assert_eq!(error["error"]["code"], "server_error");
assert_eq!(error["error"]["message"], "busy");
assert_eq!(error["headers"]["x-request-id"], "req_capacity");
assert_eq!(error["headers"]["retry-after"], "7");
}
}
}
#[tokio::test]
async fn classified_capacity_error_without_special_code_returns_retryable_websocket_status() {
let provider = ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_status(400)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"Selected model is at capacity. Please try a different model.",
None,
Some("server_error".to_owned()),
));
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
assert_eq!(error["status"], 503);
assert_eq!(error["error"]["code"], "upstream_unavailable");
assert_eq!(
error["error"]["message"],
"Selected model is at capacity. Please try a different model."
);
}
#[tokio::test]
async fn final_failure_headers_take_precedence_over_observed_opening_headers() {
let provider = upstream_failure(403, b"", vec![header("x-oai-request-id", b"req_final")])
@@ -473,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"
);
}
}
@@ -205,7 +205,7 @@ impl fmt::Debug for PlaintextCredential {
}
/// Provider-owned 的任意 JSON object;公共层只搬运、不读取内部 key。
#[derive(Clone, PartialEq)]
#[derive(Clone, PartialEq, Eq)]
pub struct OpaqueProviderData(Map<String, Value>);
impl OpaqueProviderData {
@@ -248,11 +248,27 @@ impl ClientRoutingScope {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrozenAccountScope {
disable_fast: bool,
request_profiles: BTreeMap<ProviderKind, super::OpaqueProviderData>,
directory: Arc<RuntimeAccountDirectory>,
client_scope: ClientRoutingScope,
}
impl FrozenAccountScope {
/// 与 Key 授权范围一同冻结;具体字段只由对应 Provider 解释。
#[must_use]
pub fn with_request_profiles(
mut self,
profiles: BTreeMap<ProviderKind, super::OpaqueProviderData>,
) -> Self {
self.request_profiles = profiles;
self
}
#[must_use]
pub fn request_profile(&self, provider: &ProviderKind) -> Option<&super::OpaqueProviderData> {
self.request_profiles.get(provider)
}
/// Key 绑定分组的冻结 Fast 限制,与账号成员资格无关。
#[must_use]
pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self {
@@ -272,6 +288,7 @@ impl FrozenAccountScope {
) -> Self {
Self {
disable_fast: false,
request_profiles: BTreeMap::new(),
directory,
client_scope,
}
@@ -112,6 +112,7 @@ pub struct AccountRuntimeSignals {
}
const ACCOUNT_FEEDBACK_EWMA_ALPHA: f64 = 0.2;
const ACCOUNT_CAPACITY_FAILURE_EWMA_ALPHA: f64 = 0.4;
const ACCOUNT_FAILURE_RATE_HALF_LIFE: Duration = Duration::from_secs(15 * 60);
const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits();
@@ -120,6 +121,7 @@ const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits();
pub enum AccountAttemptFeedback {
Succeeded { first_output_ms: Option<u64> },
Failed { first_output_ms: Option<u64> },
CapacityRejected { first_output_ms: Option<u64> },
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -150,13 +152,12 @@ impl DecayingAccountFailureRate {
* 0.5_f64.powf(elapsed.as_secs_f64() / ACCOUNT_FAILURE_RATE_HALF_LIFE.as_secs_f64())
}
fn report_at(&mut self, sample: f64, now: Instant) {
fn report_at(&mut self, sample: f64, alpha: f64, now: Instant) {
let now = self
.updated_at
.map_or(now, |updated_at| updated_at.max(now));
let decayed = self.value_at(now);
self.value =
ACCOUNT_FEEDBACK_EWMA_ALPHA * sample + (1.0 - ACCOUNT_FEEDBACK_EWMA_ALPHA) * decayed;
self.value = alpha * sample + (1.0 - alpha) * decayed;
self.updated_at = Some(now);
}
}
@@ -173,14 +174,21 @@ impl Default for AccountFeedback {
impl AccountFeedback {
fn report_at(&self, feedback: AccountAttemptFeedback, now: Instant) {
let (failure, first_output_ms) = match feedback {
AccountAttemptFeedback::Succeeded { first_output_ms } => (0.0, first_output_ms),
AccountAttemptFeedback::Failed { first_output_ms } => (1.0, first_output_ms),
let (failure, alpha, first_output_ms) = match feedback {
AccountAttemptFeedback::Succeeded { first_output_ms } => {
(0.0, ACCOUNT_FEEDBACK_EWMA_ALPHA, first_output_ms)
}
AccountAttemptFeedback::Failed { first_output_ms } => {
(1.0, ACCOUNT_FEEDBACK_EWMA_ALPHA, first_output_ms)
}
AccountAttemptFeedback::CapacityRejected { first_output_ms } => {
(1.0, ACCOUNT_CAPACITY_FAILURE_EWMA_ALPHA, first_output_ms)
}
};
self.failure_rate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.report_at(failure, now);
.report_at(failure, alpha, now);
if let Some(first_output_ms) = first_output_ms.filter(|value| *value > 0) {
update_feedback_ewma(&self.first_output_ms, first_output_ms as f64);
}
@@ -196,6 +196,7 @@ where
recovery_account: None,
pending_retry: None,
transient_retry_counts: BTreeMap::new(),
request_profiles: BTreeMap::new(),
current: None,
send_state_watermark: UpstreamSendState::NotSent,
downstream_committed_at: None,
@@ -304,6 +305,8 @@ pub struct ResponseExecutionSession<S: ?Sized> {
pending_retry: Option<PendingAttemptRetry>,
/// 请求内按账号累计的瞬时拒绝重试次数,不能跨请求污染账号健康状态。
transient_retry_counts: BTreeMap<crate::account::ProviderAccountId, u32>,
/// 只在首次进入对应 Provider 时解析,避免后台发布更新改变同一请求的重试身份。
request_profiles: BTreeMap<crate::identity::ProviderKind, crate::account::OpaqueProviderData>,
current: Option<CurrentAttempt>,
/// 请求级发送状态水位;跨 attempt 单调不降,终态写回不得低于此档。
send_state_watermark: UpstreamSendState,
@@ -786,9 +789,36 @@ where
self.credential_recovery_attempted_accounts
.contains(account)
}));
let provider = self
.engine
.providers()
.get(candidate.provider())
.cloned()
.ok_or_else(|| EngineError::ProviderNotRegistered {
provider: candidate.provider().as_str().to_owned(),
})?;
if !self.request_profiles.contains_key(candidate.provider())
&& let Some(configuration) = self
.plan
.account_scope()
.request_profile(candidate.provider())
{
match provider.resolve_request_profile(configuration) {
Ok(profile) => {
self.request_profiles
.insert(candidate.provider().clone(), profile);
}
Err(error) => {
self.finish_provider_error(&error).await?;
return Err(provider_engine_error(error));
}
}
}
let context = AttemptContext::new(
RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone())
.with_request_profile(self.request_profiles.get(candidate.provider()).cloned())
.with_disable_fast(self.plan.disable_fast())
.with_pricing(self.plan.pricing())
.with_request_location(self.plan.request_location().cloned())
.with_concurrency_wait_budget(self.concurrency_wait_budget.clone())
.with_timing_started_at(self.observation.timing_started_at)
@@ -807,14 +837,6 @@ where
} else {
AttemptTrigger::AccountRetry
};
let provider = self
.engine
.providers()
.get(candidate.provider())
.cloned()
.ok_or_else(|| EngineError::ProviderNotRegistered {
provider: candidate.provider().as_str().to_owned(),
})?;
let attempt_trace = self.trace.attempt(next_attempt.get());
attempt_trace.record(
"attempt.started",
@@ -859,6 +881,7 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
@@ -875,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()
@@ -897,6 +921,7 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
@@ -1425,6 +1450,7 @@ where
error.kind(),
ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::QuotaExhausted
)
{
return false;
@@ -314,6 +314,8 @@ impl ContinuationAttempt {
/// Provider 每次执行可见的 request-local context。
#[derive(Debug, Clone)]
pub struct RequestAttemptContext {
pricing: Arc<crate::metering::PricingOverrides>,
request_profile: Option<crate::account::OpaqueProviderData>,
disable_fast: bool,
request_location: Option<crate::account::RequestLocation>,
request_id: ModelRequestId,
@@ -324,6 +326,21 @@ pub struct RequestAttemptContext {
}
impl RequestAttemptContext {
#[must_use]
pub fn with_pricing(mut self, pricing: Arc<crate::metering::PricingOverrides>) -> Self {
self.pricing = pricing;
self
}
#[must_use]
pub fn with_request_profile(
mut self,
profile: Option<crate::account::OpaqueProviderData>,
) -> Self {
self.request_profile = profile;
self
}
#[must_use]
pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self {
self.disable_fast = disable_fast;
@@ -344,6 +361,8 @@ impl RequestAttemptContext {
Self {
request_id,
client_api_key_ref,
request_profile: None,
pricing: Arc::default(),
disable_fast: false,
request_location: None,
timing_started_at: Instant::now(),
@@ -407,6 +426,17 @@ pub struct AttemptContext {
}
impl AttemptContext {
#[must_use]
pub fn pricing(&self) -> &crate::metering::PricingOverrides {
&self.request.pricing
}
/// 本次逻辑请求首次解析的 Provider 身份,换号及传输重试保持不变。
#[must_use]
pub const fn request_profile(&self) -> Option<&crate::account::OpaqueProviderData> {
self.request.request_profile.as_ref()
}
#[must_use]
pub const fn disable_fast(&self) -> bool {
self.request.disable_fast
@@ -44,7 +44,7 @@ impl ResponseObservation {
if let GatewayEvent::CalculatedCost(observed) = event
&& self.cost.source() != CostSource::ProviderReported
{
self.cost = observed.into_estimate();
self.cost = observed.clone().into_estimate();
}
if let GatewayEvent::ProviderCost(observed) = event {
self.cost = observed.into_estimate();
@@ -264,13 +264,17 @@ impl ProviderStreamAccountFeedback {
{
return;
}
self.stats.report(
&self.provider_kind,
&self.account_id,
let feedback = if error.kind() == ProviderErrorKind::UpstreamCapacityUnavailable {
AccountAttemptFeedback::CapacityRejected {
first_output_ms: self.first_output_ms,
}
} else {
AccountAttemptFeedback::Failed {
first_output_ms: self.first_output_ms,
},
);
}
};
self.stats
.report(&self.provider_kind, &self.account_id, feedback);
self.reported = true;
}
}
@@ -479,6 +483,14 @@ impl fmt::Debug for ProviderRequest {
/// 新的 attempt 再次调用。
#[async_trait]
pub trait Provider: Send + Sync {
/// 从已冻结的配置解析请求身份;只读取本地发布资料,不执行网络请求。
fn resolve_request_profile(
&self,
configuration: &crate::account::OpaqueProviderData,
) -> Result<crate::account::OpaqueProviderData, ProviderError> {
Ok(configuration.clone())
}
/// 返回编译期注册名称。
fn name(&self) -> &'static str;
+147 -6
View File
@@ -5,6 +5,11 @@ use std::str::FromStr;
use crate::validation::MeteringError;
mod pricing;
pub use pricing::{
ModelPriceOverride, PricingOverrides, TokenPrice, TokenPriceOverride, merge_pricing,
};
const DECIMAL_SCALE: u128 = 10_000_000_000;
const MAX_SCALED_DECIMAL: u128 = 99_999_999_999_999_999_999;
@@ -34,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
@@ -272,12 +283,13 @@ impl CostEstimateStatus {
}
}
/// 单次模型请求的总费用,不保存价格版本或 breakdown。
/// 单次模型请求的总费用及当次确定的本地计算明细。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CostEstimate {
status: CostEstimateStatus,
source: CostSource,
total: Option<Money>,
breakdown: Option<std::sync::Arc<CalculatedCostBreakdown>>,
}
/// Provider 在单次请求终态上报的实际已计费总额。
@@ -287,16 +299,20 @@ pub struct ProviderReportedCost {
}
/// Provider 域依据公开单价和实际用量算出的单次总额。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedCost {
total: Money,
breakdown: Option<std::sync::Arc<CalculatedCostBreakdown>>,
}
/// Provider 受控价格规则计算出的运行时费用明细。
///
/// 该值只用于生成事件和管理端展示,不是持久化模型;数据库仍只保存最终总额、货币和来源。
/// 随本地费用事件保存,以免后续改价改变历史明细。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedCostBreakdown {
// Provider 选中的价格区间事实,与服务档位和自定义倍率独立。
long_context_billing_applied: bool,
image: Option<ImageCostBreakdown>,
input_amount: Money,
output_amount: Money,
cache_read_amount: Money,
@@ -309,6 +325,18 @@ pub struct CalculatedCostBreakdown {
cache_write_price_per_million: Money,
service_tier: Option<String>,
multiplier_percent: u32,
custom_multiplier_bps: u32,
}
/// 图像输入是总输入的子集,单独保留其价格,不能伪装成文本平均单价。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageCostBreakdown {
pub input_tokens: u64,
pub cached_tokens: u64,
pub input_amount: Money,
pub cache_read_amount: Money,
pub input_price_per_million: Money,
pub cache_read_price_per_million: Money,
}
/// 一次请求的费用组成,全部使用同一币种。
@@ -373,7 +401,9 @@ impl CalculatedCostBreakdown {
multiplier_percent: u32,
) -> Self {
Self {
long_context_billing_applied: false,
input_amount: amounts.input,
image: None,
output_amount: amounts.output,
cache_read_amount: amounts.cache_read,
cache_write_amount: amounts.cache_write,
@@ -385,9 +415,32 @@ impl CalculatedCostBreakdown {
cache_write_price_per_million: rates.cache_write,
service_tier,
multiplier_percent,
custom_multiplier_bps: 10_000,
}
}
#[must_use]
pub const fn with_long_context_billing(mut self, applied: bool) -> Self {
self.long_context_billing_applied = applied;
self
}
#[must_use]
pub const fn long_context_billing_applied(&self) -> bool {
self.long_context_billing_applied
}
#[must_use]
pub fn with_image(mut self, image: ImageCostBreakdown) -> Self {
self.image = Some(image);
self
}
#[must_use]
pub const fn image(&self) -> Option<&ImageCostBreakdown> {
self.image.as_ref()
}
#[must_use]
pub const fn input_amount(&self) -> Money {
self.input_amount
@@ -449,11 +502,90 @@ impl CalculatedCostBreakdown {
}
#[must_use]
pub const fn calculated_cost(&self) -> CalculatedCost {
pub fn calculated_cost(&self) -> CalculatedCost {
CalculatedCost {
total: self.total_amount,
breakdown: Some(std::sync::Arc::new(self.clone())),
}
}
#[must_use]
pub const fn custom_multiplier_bps(&self) -> u32 {
self.custom_multiplier_bps
}
/// 统一调整金额和有效单价,保留服务档位倍率的独立含义。
#[must_use]
pub fn with_custom_multiplier(mut self, bps: u32) -> Option<Self> {
if bps > 1_000_000 {
return None;
}
let scale = |money: Money| {
let ticks = money
.amount()
.scaled()
.checked_mul(u128::from(bps))?
.checked_add(5_000)?
.checked_div(10_000)?;
Some(Money::new(
Decimal::from_scaled(ticks).ok()?,
money.currency(),
))
};
let previous_total = self.total_amount;
let same_standard = self.standard_amount == previous_total;
let mut token_total = self
.input_amount
.amount()
.scaled()
.checked_add(self.output_amount.amount().scaled())?
.checked_add(self.cache_read_amount.amount().scaled())?
.checked_add(self.cache_write_amount.amount().scaled())?;
if let Some(image) = &mut self.image {
token_total = token_total
.checked_add(image.input_amount.amount().scaled())?
.checked_add(image.cache_read_amount.amount().scaled())?;
image.input_amount = scale(image.input_amount)?;
image.cache_read_amount = scale(image.cache_read_amount)?;
image.input_price_per_million = scale(image.input_price_per_million)?;
image.cache_read_price_per_million = scale(image.cache_read_price_per_million)?;
}
let other = Money::new(
Decimal::from_scaled(previous_total.amount().scaled().checked_sub(token_total)?)
.ok()?,
previous_total.currency(),
);
self.input_amount = scale(self.input_amount)?;
self.output_amount = scale(self.output_amount)?;
self.cache_read_amount = scale(self.cache_read_amount)?;
self.cache_write_amount = scale(self.cache_write_amount)?;
self.standard_amount = scale(self.standard_amount)?;
// 各费用项独立四舍五入后求和,防止明细合计与账本金额相差一个 tick。
let mut total = self
.input_amount
.amount()
.scaled()
.checked_add(self.output_amount.amount().scaled())?
.checked_add(self.cache_read_amount.amount().scaled())?
.checked_add(self.cache_write_amount.amount().scaled())?
.checked_add(scale(other)?.amount().scaled())?;
if let Some(image) = &self.image {
total = total
.checked_add(image.input_amount.amount().scaled())?
.checked_add(image.cache_read_amount.amount().scaled())?;
}
self.total_amount =
Money::new(Decimal::from_scaled(total).ok()?, previous_total.currency());
if same_standard {
self.standard_amount = self.total_amount;
}
self.input_price_per_million = scale(self.input_price_per_million)?;
self.output_price_per_million = scale(self.output_price_per_million)?;
self.cache_read_price_per_million = scale(self.cache_read_price_per_million)?;
self.cache_write_price_per_million = scale(self.cache_write_price_per_million)?;
self.custom_multiplier_bps = bps;
Some(self)
}
}
impl ProviderReportedCost {
@@ -479,6 +611,7 @@ impl ProviderReportedCost {
status: CostEstimateStatus::Known,
source: CostSource::ProviderReported,
total: Some(self.total),
breakdown: None,
}
}
}
@@ -492,31 +625,39 @@ impl CalculatedCost {
pub fn from_usd_ticks(ticks: u128) -> Result<Self, MeteringError> {
Ok(Self {
total: Money::new(Decimal::from_scaled(ticks)?, CurrencyCode(*b"USD")),
breakdown: None,
})
}
#[must_use]
pub const fn total(self) -> Money {
pub const fn total(&self) -> Money {
self.total
}
#[must_use]
pub const fn into_estimate(self) -> CostEstimate {
pub fn into_estimate(self) -> CostEstimate {
CostEstimate {
status: CostEstimateStatus::Known,
source: CostSource::Calculated,
total: Some(self.total),
breakdown: self.breakdown,
}
}
}
impl CostEstimate {
#[must_use]
pub fn breakdown(&self) -> Option<&CalculatedCostBreakdown> {
self.breakdown.as_deref()
}
#[must_use]
pub const fn unavailable() -> Self {
Self {
status: CostEstimateStatus::Unknown,
source: CostSource::Unavailable,
total: None,
breakdown: None,
}
}
@@ -0,0 +1,104 @@
//! 请求冻结的本地价格覆盖;默认价目及模型别名继续由 Provider 拥有。
use std::collections::BTreeMap;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use super::Decimal;
/// 每百万 Token 的 USD 价格,保留四位小数以对齐单 Token 的金额精度。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct TokenPrice(Decimal);
impl TokenPrice {
#[must_use]
pub const fn ticks_per_token(self) -> u128 {
self.0.scaled() / 1_000_000
}
}
impl TryFrom<String> for TokenPrice {
type Error = &'static str;
fn try_from(value: String) -> Result<Self, Self::Error> {
let amount = Decimal::from_str(&value).map_err(|_| "单价必须为非负十进制字符串")?;
if amount.scaled() % 1_000_000 != 0 || amount.scaled() > 1_000_000 * 10_000_000_000 {
return Err("单价最多四位小数且不超过 1000000 USD / 百万 Token");
}
Ok(Self(amount))
}
}
impl From<TokenPrice> for String {
fn from(value: TokenPrice) -> Self {
value.0.canonical()
}
}
/// 一档完整 Token 单价;零价格与未覆盖的档位有不同含义。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TokenPriceOverride {
pub input: TokenPrice,
pub output: TokenPrice,
pub cache_read: TokenPrice,
pub cache_write: TokenPrice,
}
/// 一个精确模型的覆盖项;倍率使用基点,10000 表示 1 倍。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModelPriceOverride {
pub multiplier_bps: u32,
pub bands: BTreeMap<String, TokenPriceOverride>,
}
impl ModelPriceOverride {
/// 校验存储与管理入口共用的价格约束。
///
/// # Errors
/// 倍率超过 100 倍或档位不受支持时返回错误。
pub fn validate(&self) -> Result<(), &'static str> {
if self.multiplier_bps > 1_000_000 {
return Err("倍率必须在 0 至 100 倍之间");
}
if self.bands.keys().any(|band| {
!matches!(
band.as_str(),
"standard"
| "fast"
| "flex"
| "long_standard"
| "long_fast"
| "long_flex"
| "image"
)
}) {
return Err("价格档位不受支持");
}
Ok(())
}
}
/// 按 Provider、上游模型索引的不可变覆盖事实。
pub type PricingOverrides = BTreeMap<String, BTreeMap<String, ModelPriceOverride>>;
/// 按档位叠加配置;倍率仅由人工覆盖控制,同步价目不携带业务倍率。
pub fn merge_pricing(mut base: PricingOverrides, overrides: &PricingOverrides) -> PricingOverrides {
for (provider, models) in overrides {
let target = base.entry(provider.clone()).or_default();
for (model, pricing) in models {
let entry = target
.entry(model.clone())
.or_insert_with(|| ModelPriceOverride {
multiplier_bps: 10_000,
bands: BTreeMap::new(),
});
entry.multiplier_bps = pricing.multiplier_bps;
entry.bands.extend(pricing.bands.clone());
}
}
base
}
@@ -447,10 +447,11 @@ pub trait ProviderCatalogCachePort: Send + Sync {
/// Provider 从官方制品核验出的可重建请求画像。
///
/// Core 只用单调制品序号约束覆盖顺序;具体版本字段由对应 Provider 放在
/// `profile` 中解释。每个 Provider 在 Store 中只保留一份最新画像。
/// `profile` 中解释。每个 Provider 的各制品分别保留一份最新画像。
#[derive(Clone, PartialEq)]
pub struct ProviderArtifactProfile {
provider_kind: ProviderKind,
artifact_key: String,
artifact_sequence: u64,
verified_at: SystemTime,
profile: OpaqueProviderData,
@@ -460,12 +461,14 @@ impl ProviderArtifactProfile {
#[must_use]
pub const fn new(
provider_kind: ProviderKind,
artifact_key: String,
artifact_sequence: u64,
verified_at: SystemTime,
profile: OpaqueProviderData,
) -> Self {
Self {
provider_kind,
artifact_key,
artifact_sequence,
verified_at,
profile,
@@ -477,6 +480,11 @@ impl ProviderArtifactProfile {
&self.provider_kind
}
#[must_use]
pub fn artifact_key(&self) -> &str {
&self.artifact_key
}
#[must_use]
pub const fn artifact_sequence(&self) -> u64 {
self.artifact_sequence
@@ -506,7 +514,7 @@ impl fmt::Debug for ProviderArtifactProfile {
}
pub trait ProviderArtifactProfileCachePort: Send + Sync {
/// 覆盖同一 Provider 的固定 cache key。
/// 覆盖同一 Provider、同一制品的固定 cache key。
///
/// 返回 `false` 表示 Store 已持有更高的制品序号;相同序号但内容不同必须返回
/// [`ProviderStoreErrorKind::Conflict`],不能静默改写已核验画像。
@@ -519,6 +527,7 @@ pub trait ProviderArtifactProfileCachePort: Send + Sync {
fn read<'a>(
&'a self,
provider_kind: &'a ProviderKind,
artifact_key: &'a str,
) -> BoxFuture<'a, Result<Option<ProviderArtifactProfile>, ProviderStoreError>>;
}
@@ -927,6 +936,15 @@ fn invalid_refresh_policy(operation: &'static str) -> ProviderStoreError {
}
pub trait ProviderRuntimePolicyPort: Send + Sync {
/// 仅首次启动写入该 Provider 的默认选择;已保存的管理配置始终优先。
fn initialize_request_profile<'a>(
&'a self,
_provider: &'a ProviderKind,
initial: OpaqueProviderData,
) -> BoxFuture<'a, Result<OpaqueProviderData, ProviderStoreError>> {
Box::pin(async move { Ok(initial) })
}
fn load_refresh_policy(
&self,
) -> BoxFuture<'_, Result<ProviderRefreshPolicy, ProviderStoreError>>;
@@ -536,7 +536,7 @@ impl ProviderCandidate {
/// 一次请求冻结的 Provider 尝试顺序。
#[derive(Debug, Clone)]
pub struct RoutingPlan {
disable_fast: bool,
pricing: Arc<crate::metering::PricingOverrides>,
request_location: Option<crate::account::RequestLocation>,
config_revision: ConfigRevision,
account_selection_policy: AccountSelectionPolicy,
@@ -548,8 +548,13 @@ pub struct RoutingPlan {
impl RoutingPlan {
#[must_use]
pub const fn disable_fast(&self) -> bool {
self.disable_fast
pub fn pricing(&self) -> Arc<crate::metering::PricingOverrides> {
Arc::clone(&self.pricing)
}
#[must_use]
pub fn disable_fast(&self) -> bool {
self.account_scope.disable_fast()
}
/// 本次请求冻结的全局位置,重试时沿用同一份配置。
@@ -28,7 +28,8 @@ const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4;
/// Store 在一个一致性读取中提供的调度设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotSettingsFacts {
disable_fast: bool,
pricing: Arc<crate::metering::PricingOverrides>,
request_profiles: BTreeMap<ProviderKind, crate::account::OpaqueProviderData>,
request_location_enabled: bool,
request_location: crate::account::RequestLocation,
max_concurrent_per_account: u32,
@@ -45,8 +46,17 @@ pub struct SnapshotSettingsFacts {
impl SnapshotSettingsFacts {
#[must_use]
pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self {
self.disable_fast = disable_fast;
pub fn with_pricing(mut self, pricing: crate::metering::PricingOverrides) -> Self {
self.pricing = Arc::new(pricing);
self
}
#[must_use]
pub fn with_request_profiles(
mut self,
profiles: BTreeMap<ProviderKind, crate::account::OpaqueProviderData>,
) -> Self {
self.request_profiles = profiles;
self
}
@@ -90,7 +100,8 @@ impl SnapshotSettingsFacts {
min_codex_cli_version: Option<String>,
) -> Self {
Self {
disable_fast: false,
request_profiles: BTreeMap::new(),
pricing: Arc::default(),
request_location_enabled: false,
request_location: crate::account::RequestLocation::default(),
max_concurrent_per_account,
@@ -110,6 +121,7 @@ impl SnapshotSettingsFacts {
/// Store 读取到的一个启用 Client API Key 策略事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotClientPolicyFacts {
request_profiles: BTreeMap<ProviderKind, crate::account::OpaqueProviderData>,
key_id: ClientApiKeyId,
plaintext_key: PlaintextClientApiKey,
group_ids: Vec<AccountGroupId>,
@@ -117,6 +129,15 @@ pub struct SnapshotClientPolicyFacts {
}
impl SnapshotClientPolicyFacts {
#[must_use]
pub fn with_request_profiles(
mut self,
profiles: BTreeMap<ProviderKind, crate::account::OpaqueProviderData>,
) -> Self {
self.request_profiles = profiles;
self
}
#[must_use]
pub fn new(
key_id: ClientApiKeyId,
@@ -126,6 +147,7 @@ impl SnapshotClientPolicyFacts {
) -> Self {
Self {
key_id,
request_profiles: BTreeMap::new(),
plaintext_key,
group_ids,
limits,
@@ -497,10 +519,16 @@ async fn compile_runtime_snapshot(
.map_err(|_| RuntimeSnapshotCompileError::InvalidData)?,
)
};
let mut request_profiles = facts.settings.request_profiles.clone();
request_profiles.extend(policy.request_profiles);
client_policies.push(ClientPolicy::new(
policy.key_id,
policy.plaintext_key,
Arc::new(account_scope.with_disable_fast(disable_fast)),
Arc::new(
account_scope
.with_disable_fast(disable_fast)
.with_request_profiles(request_profiles),
),
true,
policy.limits,
));
@@ -528,7 +556,7 @@ async fn compile_runtime_snapshot(
.map_err(|_| RuntimeSnapshotCompileError::InvalidData)
.map(|snapshot| {
snapshot
.with_disable_fast(facts.settings.disable_fast)
.with_pricing(facts.settings.pricing)
.with_request_location(request_location)
.with_responses_max_decompressed_body_bytes(decompressed_body_limit)
.with_client_queue_policy(client_queue_policy)
@@ -542,7 +570,7 @@ async fn compile_runtime_snapshot(
/// 数据面使用的不可变配置快照。
#[derive(Debug, Clone)]
pub struct RuntimeSnapshot {
disable_fast: bool,
pricing: Arc<crate::metering::PricingOverrides>,
responses_max_decompressed_body_bytes: std::num::NonZeroUsize,
request_location: Option<crate::account::RequestLocation>,
revision: ConfigRevision,
@@ -562,8 +590,8 @@ pub struct RuntimeSnapshot {
impl RuntimeSnapshot {
#[must_use]
pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self {
self.disable_fast = disable_fast;
pub fn with_pricing(mut self, pricing: Arc<crate::metering::PricingOverrides>) -> Self {
self.pricing = pricing;
self
}
@@ -671,7 +699,7 @@ impl RuntimeSnapshot {
Ok(Self {
responses_max_decompressed_body_bytes: std::num::NonZeroUsize::new(64 * 1024 * 1024)
.expect("positive default limit"),
disable_fast: false,
pricing: Arc::default(),
request_location: None,
revision,
account_selection_policy,
@@ -983,7 +1011,7 @@ impl RuntimeSnapshot {
Ok(RoutingPlan {
config_revision: self.revision,
disable_fast: self.disable_fast || account_scope.disable_fast(),
pricing: Arc::clone(&self.pricing),
request_location: self.request_location.clone(),
account_selection_policy: self.account_selection_policy,
operation: operation.kind(),
@@ -1026,7 +1054,7 @@ impl RuntimeSnapshot {
};
Ok(RoutingPlan {
config_revision: self.revision,
disable_fast: self.disable_fast || account_scope.disable_fast(),
pricing: Arc::clone(&self.pricing),
request_location: self.request_location.clone(),
account_selection_policy: self.account_selection_policy,
operation: operation.kind(),
@@ -34,6 +34,58 @@ fn report_failure(
);
}
#[test]
fn capacity_rejections_should_raise_failure_rate_faster_than_regular_failures() {
let (feedback, provider, account) = feedback_subject();
let observed_at = Instant::now();
let mut failure_rates = Vec::new();
for _ in 0..2 {
feedback.report_at(
&provider,
&account,
AccountAttemptFeedback::CapacityRejected {
first_output_ms: None,
},
observed_at,
);
failure_rates.push(
feedback
.scheduling_signals_at(&provider, &account, observed_at)
.0,
);
}
assert_eq!(failure_rates, [Some(4_000), Some(6_400)]);
}
#[test]
fn capacity_failure_rate_should_keep_time_decay_and_success_recovery() {
let (feedback, provider, account) = feedback_subject();
let observed_at = Instant::now();
feedback.report_at(
&provider,
&account,
AccountAttemptFeedback::CapacityRejected {
first_output_ms: Some(100),
},
observed_at,
);
let recovered_at = observed_at + FAILURE_RATE_HALF_LIFE;
feedback.report_at(
&provider,
&account,
AccountAttemptFeedback::Succeeded {
first_output_ms: Some(200),
},
recovered_at,
);
assert_eq!(
feedback.scheduling_signals_at(&provider, &account, recovered_at),
(Some(1_600), Some(120)),
);
}
#[test]
fn account_failure_rate_should_halve_after_one_half_life() {
let (feedback, provider, account) = feedback_subject();
@@ -1,6 +1,6 @@
//! 单行 `model_requests`、账号重试与下游提交屏障测试。
use std::collections::{BTreeSet, VecDeque};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::num::NonZeroU32;
use std::sync::{
Arc, Mutex,
@@ -293,6 +293,7 @@ enum Script {
}
struct ScriptedProvider {
profile_generation: AtomicUsize,
scripts: Mutex<VecDeque<Script>>,
contexts: Mutex<Vec<AttemptContext>>,
operations: Mutex<Vec<Operation>>,
@@ -310,6 +311,7 @@ impl Drop for TrackedLease {
impl ScriptedProvider {
fn new(scripts: Vec<Script>) -> Self {
Self {
profile_generation: AtomicUsize::new(1),
scripts: Mutex::new(scripts.into()),
contexts: Mutex::new(Vec::new()),
operations: Mutex::new(Vec::new()),
@@ -320,6 +322,18 @@ impl ScriptedProvider {
#[async_trait]
impl Provider for ScriptedProvider {
fn resolve_request_profile(
&self,
configuration: &gateway_core::account::OpaqueProviderData,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderError> {
let mut fields = configuration.clone().into_inner();
fields.insert(
"generation".to_owned(),
json!(self.profile_generation.load(Ordering::SeqCst)),
);
Ok(gateway_core::account::OpaqueProviderData::new(fields))
}
fn name(&self) -> &'static str {
"openai"
}
@@ -344,6 +358,8 @@ impl Provider for ScriptedProvider {
.expect("operations lock")
.push(request.operation().clone());
self.contexts.lock().expect("contexts lock").push(context);
// 模拟官方发布在每次上游尝试开始后推进,重试应继续使用首次解析版本。
self.profile_generation.fetch_add(1, Ordering::SeqCst);
let script = self
.scripts
.lock()
@@ -603,6 +619,22 @@ fn plan_with_location_and_fast_policy(
account_selection_policy: AccountSelectionPolicy,
request_location: gateway_core::account::RequestLocation,
disable_fast: bool,
) -> RoutingPlan {
plan_with_profiles(
operation,
account_selection_policy,
request_location,
disable_fast,
Default::default(),
)
}
fn plan_with_profiles(
operation: &Operation,
account_selection_policy: AccountSelectionPolicy,
request_location: gateway_core::account::RequestLocation,
disable_fast: bool,
profiles: BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>,
) -> RoutingPlan {
let provider = ProviderKind::new("openai").expect("provider");
let public_model = PublicModelId::new("gpt-5").expect("public model");
@@ -645,17 +677,17 @@ fn plan_with_location_and_fast_policy(
Vec::new(),
)
.expect("snapshot")
.with_disable_fast(disable_fast)
.with_request_location(Some(request_location))
.with_account_directory(Arc::clone(&directory));
snapshot
.plan(
&public_model,
operation,
Arc::new(FrozenAccountScope::new(
directory,
ClientRoutingScope::all_accounts(),
)),
Arc::new(
FrozenAccountScope::new(directory, ClientRoutingScope::all_accounts())
.with_disable_fast(disable_fast)
.with_request_profiles(profiles),
),
&RoutingContext {
required_provider: Some(provider),
..RoutingContext::default()
@@ -1856,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]
@@ -3639,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,
] {
@@ -3910,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]
@@ -4538,7 +4583,7 @@ fn deadline_before_first_event_records_no_provider_circuit_failure() {
}
#[test]
fn global_location_and_fast_policy_reach_every_account_retry() {
fn global_location_and_group_fast_policy_reach_every_account_retry() {
let operation = generate_operation();
let location = gateway_core::account::RequestLocation {
timezone: "Asia/Tokyo".parse().unwrap(),
@@ -4584,3 +4629,54 @@ fn global_location_and_fast_policy_reach_every_account_retry() {
.all(|context| context.request_location() == Some(&location) && context.disable_fast())
);
}
#[test]
fn first_resolved_profile_is_frozen_across_account_retries() {
use gateway_core::account::OpaqueProviderData;
let operation = generate_operation();
let route_plan = plan_with_profiles(
&operation,
plan(&operation).account_selection_policy(),
Default::default(),
false,
BTreeMap::from([(
ProviderKind::new("openai").unwrap(),
OpaqueProviderData::new(
json!({"selection":"key-override"})
.as_object()
.unwrap()
.clone(),
),
)]),
);
let (coordinator, _, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
items: vec![Err(ProviderError::new(
ProviderErrorKind::RateLimited,
UpstreamSendState::Sent,
)
.with_status(429)
.with_replay_safe())],
},
Script::Stream {
account_id: "acct_second",
items: complete_stream(None),
},
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.unwrap();
block_on(session.collect_uncommitted()).unwrap();
let contexts = provider.contexts.lock().unwrap();
assert_eq!(contexts.len(), 2);
let first = contexts[0].request_profile().unwrap();
assert_eq!(first.expose_to_provider()["generation"], 1);
assert_eq!(contexts[1].request_profile(), Some(first));
}
@@ -178,7 +178,7 @@ fn reused_client_uses_updated_limits_for_each_execution() {
requests_per_minute: 1,
};
for (revision, limits) in [(2, limited), (3, RateLimits::unlimited())] {
snapshots.publish(start_snapshot_with_policy(revision, true, limits));
snapshots.publish(start_snapshot_with_policy(revision, true, limits, false));
let mut next = request(&service, ClientTransport::WebSocket);
next.client = client.clone();
let started = block_on(service.start(next)).expect("new execution");
@@ -212,6 +212,7 @@ fn reused_client_cannot_start_after_key_disable_or_snapshot_suspension() {
2,
false,
RateLimits::unlimited(),
false,
));
}
let result = block_on(service.start(next));
@@ -1893,10 +1894,15 @@ fn client_snapshot() -> RuntimeSnapshot {
}
fn start_snapshot() -> RuntimeSnapshot {
start_snapshot_with_policy(1, true, RateLimits::unlimited())
start_snapshot_with_policy(1, true, RateLimits::unlimited(), false)
}
fn start_snapshot_with_policy(revision: u64, enabled: bool, limits: RateLimits) -> RuntimeSnapshot {
fn start_snapshot_with_policy(
revision: u64,
enabled: bool,
limits: RateLimits,
disable_fast: bool,
) -> RuntimeSnapshot {
let provider = ProviderKind::new("openai").expect("provider kind");
let capabilities =
ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000));
@@ -1916,7 +1922,12 @@ fn start_snapshot_with_policy(revision: u64, enabled: bool, limits: RateLimits)
vec![ClientPolicy::new(
ClientApiKeyId::new("key_start_test").expect("client API key ID"),
PlaintextClientApiKey::new("sk_start_test").expect("plaintext client API key"),
account_scope(&provider, "acct_start"),
Arc::new(
account_scope(&provider, "acct_start")
.as_ref()
.clone()
.with_disable_fast(disable_fast),
),
enabled,
limits,
)],
@@ -2013,6 +2024,7 @@ fn account_wait_inherits_the_budget_spent_during_client_admission() {
max_concurrency: 1,
requests_per_minute: 0,
},
false,
)
.with_client_queue_policy(gateway_core::concurrency::ConcurrencyQueuePolicy {
max_waiting: 1,
@@ -2123,6 +2135,7 @@ fn queue_service(
max_concurrency,
requests_per_minute: 0,
},
false,
)
.with_client_queue_policy(gateway_core::concurrency::ConcurrencyQueuePolicy {
max_waiting,
@@ -2249,7 +2262,7 @@ fn cancelling_pending_admission_cleans_up_a_slot_acquired_before_the_reply() {
}
#[test]
fn reused_websocket_client_gets_fast_policy_from_each_new_request_snapshot() {
fn reused_websocket_client_gets_group_fast_policy_from_each_new_request_snapshot() {
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
let admissions = Arc::new(Admissions::default());
let provider = Arc::new(ChargedProvider::default());
@@ -2264,10 +2277,12 @@ fn reused_websocket_client_gets_fast_policy_from_each_new_request_snapshot() {
);
let client = service.authenticate("sk_start_test").unwrap();
for (revision, disable_fast) in [(2, true), (3, false)] {
snapshots.publish(
start_snapshot_with_policy(revision, true, RateLimits::unlimited())
.with_disable_fast(disable_fast),
);
snapshots.publish(start_snapshot_with_policy(
revision,
true,
RateLimits::unlimited(),
disable_fast,
));
let mut next = request(&service, ClientTransport::WebSocket);
next.client = client.clone();
let mut started = block_on(service.start(next)).unwrap();
@@ -1,5 +1,7 @@
use std::str::FromStr;
mod pricing;
use gateway_core::metering::{
CalculatedCost, CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates,
CostEstimate, CostEstimateStatus, CostSource, CurrencyCode, Decimal, Money,
@@ -0,0 +1,97 @@
use gateway_core::metering::{ModelPriceOverride, PricingOverrides, TokenPrice, merge_pricing};
use serde_json::json;
#[test]
fn token_price_preserves_zero_and_exact_precision_but_rejects_unrepresentable_prices() {
for (price, ticks) in [
("0", 0),
("0.0001", 1),
("2.5000", 25_000),
("1000000", 10_000_000_000),
] {
let price: TokenPrice = price.to_owned().try_into().unwrap();
assert_eq!(price.ticks_per_token(), ticks);
}
for price in ["-1", "1e3", "NaN", "0.00001", "1000000.0001", "", "+2"] {
assert!(TokenPrice::try_from(price.to_owned()).is_err(), "{price}");
}
assert!(serde_json::from_value::<TokenPrice>(json!(2.5)).is_err());
}
#[test]
fn override_validation_rejects_unsupported_bands_and_excessive_multiplier() {
let mut price: ModelPriceOverride =
serde_json::from_value(json!({"multiplierBps": 1_000_001, "bands": {}})).unwrap();
assert!(price.validate().is_err());
price.multiplier_bps = 0;
assert!(price.validate().is_ok());
price.bands.insert(
"unknown".to_owned(),
serde_json::from_value(
json!({"input":"1", "output":"1", "cacheRead":"0", "cacheWrite":"0"}),
)
.unwrap(),
);
assert!(price.validate().is_err());
}
#[test]
fn sync_layer_cannot_erase_manual_bands_or_multiplier() {
let synced: PricingOverrides = serde_json::from_value(json!({"openai":{"model":{
"multiplierBps":10000, "bands":{"standard":{"input":"2", "output":"10", "cacheRead":"0.2", "cacheWrite":"0"}}
}}})).unwrap();
let manual: PricingOverrides = serde_json::from_value(json!({"openai":{"model":{
"multiplierBps":12345, "bands":{"fast":{"input":"6", "output":"30", "cacheRead":"0.6", "cacheWrite":"0"}}
}}})).unwrap();
let effective = merge_pricing(synced.clone(), &manual);
let model = &effective["openai"]["model"];
assert_eq!(model.multiplier_bps, 12345);
assert_eq!(
model.bands["standard"],
synced["openai"]["model"].bands["standard"]
);
assert_eq!(model.bands["fast"], manual["openai"]["model"].bands["fast"]);
assert_eq!(manual["openai"]["model"].bands.len(), 1);
}
#[test]
fn custom_multiplier_rounds_components_once_and_snapshot_survives_conversion() {
use gateway_core::metering::{
CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates, CurrencyCode, Decimal,
Money,
};
let money = |ticks| {
Money::new(
Decimal::from_scaled(ticks).unwrap(),
CurrencyCode::new("USD").unwrap(),
)
};
let original = CalculatedCostBreakdown::new(
CalculatedCostAmounts::new(money(1), money(1), money(1), money(0), money(3), money(3)),
CalculatedCostRates::new(
money(1_000_000),
money(1_000_000),
money(1_000_000),
money(0),
),
None,
100,
);
let adjusted = original.clone().with_custom_multiplier(5000).unwrap();
assert_eq!(adjusted.total_amount().amount().scaled(), 3);
assert_eq!(adjusted.standard_amount(), adjusted.total_amount());
let estimate = adjusted.calculated_cost().into_estimate();
assert_eq!(estimate.breakdown(), Some(&adjusted));
assert_eq!(estimate.total(), Some(adjusted.total_amount()));
assert_eq!(
original
.clone()
.with_custom_multiplier(0)
.unwrap()
.total_amount()
.amount()
.scaled(),
0
);
assert!(original.with_custom_multiplier(1_000_001).is_none());
}
@@ -135,6 +135,45 @@ fn compiler_should_reject_revision_changed_during_consistent_read() {
assert_eq!(error, RuntimeSnapshotCompileError::RevisionChanged);
}
#[test]
fn routing_plans_share_frozen_pricing_after_a_new_snapshot_is_published() {
use gateway_core::{metering::PricingOverrides, runtime::RuntimeSnapshotHandle};
let prices = |bps| -> Arc<PricingOverrides> {
Arc::new(
serde_json::from_value(serde_json::json!({"openai":{"gpt-5.5":{
"multiplierBps":bps,"bands":{}
}}}))
.unwrap(),
)
};
let original = prices(12500);
let handle = RuntimeSnapshotHandle::new(super::snapshot().with_pricing(original.clone()));
let frozen = handle.acquire().unwrap();
let plan = |snapshot: &gateway_core::routing::RuntimeSnapshot| {
snapshot
.plan(
&PublicModelId::new("gpt-5.4").unwrap(),
&super::operation(),
snapshot.all_account_scope(),
&gateway_core::routing::RoutingContext::default(),
)
.unwrap()
};
let old_plan = plan(&frozen);
handle.publish(super::snapshot().with_pricing(prices(20000)));
let new_plan = plan(&handle.acquire().unwrap());
assert!(Arc::ptr_eq(&old_plan.pricing(), &original));
assert!(Arc::ptr_eq(&plan(&frozen).pricing(), &original));
assert_eq!(
old_plan.pricing()["openai"]["gpt-5.5"].multiplier_bps,
12500
);
assert_eq!(
new_plan.pricing()["openai"]["gpt-5.5"].multiplier_bps,
20000
);
}
#[test]
fn compiler_should_preserve_passthrough_when_provider_catalog_is_unavailable() {
let compiler = RuntimeSnapshotCompiler::new(
@@ -455,10 +494,10 @@ fn decompression_setting_should_validate_and_remain_frozen_across_publication()
}
#[test]
fn disable_fast_uses_bound_groups_and_global_policy_without_changing_account_scope() {
fn disable_fast_uses_only_bound_groups_without_changing_account_scope() {
use gateway_core::account::ProviderAccountId;
use gateway_core::routing::AccountGroupId;
for global in [false, true] {
for disable_fast in [false, true] {
for group_enabled in [false, true] {
for bound in [false, true] {
let group_id = AccountGroupId::new("grp_00000000000000000000000000000001").unwrap();
@@ -468,8 +507,7 @@ fn disable_fast_uses_bound_groups_and_global_policy_without_changing_account_sco
let facts = SnapshotFacts::new(
revision(1),
revision(1),
SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None)
.with_disable_fast(global),
SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None),
vec![SnapshotClientPolicyFacts::new(
ClientApiKeyId::new("key_fast_policy").unwrap(),
PlaintextClientApiKey::new("sk_fast_policy").unwrap(),
@@ -486,7 +524,7 @@ fn disable_fast_uses_bound_groups_and_global_policy_without_changing_account_sco
"Restricted".to_owned(),
group_enabled,
)
.with_disable_fast(true),
.with_disable_fast(disable_fast),
SnapshotAccountGroupFacts::new(
open_group_id.clone(),
"Open".to_owned(),
@@ -527,10 +565,80 @@ fn disable_fast_uses_bound_groups_and_global_policy_without_changing_account_sco
.unwrap();
assert_eq!(
plan.disable_fast(),
global || bound,
"global={global}, enabled={group_enabled}, bound={bound}"
disable_fast && bound,
"disable_fast={disable_fast}, enabled={group_enabled}, bound={bound}"
);
}
}
}
}
#[test]
fn key_profiles_replace_whole_global_choice_and_previous_snapshot_stays_frozen() {
use gateway_core::account::OpaqueProviderData;
let provider = ProviderKind::new("alpha").unwrap();
let document = |label| {
OpaqueProviderData::new(
serde_json::json!({"choice":label})
.as_object()
.unwrap()
.clone(),
)
};
let build = |global, overridden| {
let profiles = BTreeMap::from([(provider.clone(), document(global))]);
let settings = SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None)
.with_request_profiles(profiles);
let inherited = SnapshotClientPolicyFacts::new(
ClientApiKeyId::new("key_inherited").unwrap(),
PlaintextClientApiKey::new("sk_inherited").unwrap(),
vec![],
RateLimits::unlimited(),
);
let independent = SnapshotClientPolicyFacts::new(
ClientApiKeyId::new("key_independent").unwrap(),
PlaintextClientApiKey::new("sk_independent").unwrap(),
vec![],
RateLimits::unlimited(),
)
.with_request_profiles(if overridden {
BTreeMap::from([(provider.clone(), document("override"))])
} else {
BTreeMap::new()
});
block_on(
compiler(Arc::new(TestSnapshotStore::new(Ok(SnapshotFacts::new(
revision(1),
revision(1),
settings,
vec![inherited, independent],
vec![],
vec![],
vec![],
)))))
.compile(),
)
.unwrap()
};
let values = |snapshot: &gateway_core::routing::RuntimeSnapshot| {
let mut values: Vec<_> = snapshot
.client_policies()
.map(|policy| {
policy
.account_scope()
.request_profile(&provider)
.unwrap()
.expose_to_provider()["choice"]
.as_str()
.unwrap()
.to_owned()
})
.collect();
values.sort();
values
};
let previous = build("global-a", true);
assert_eq!(values(&build("global-b", true)), ["global-b", "override"]);
assert_eq!(values(&previous), ["global-a", "override"]);
assert_eq!(values(&build("global-b", false)), ["global-b", "global-b"]);
}
+1
View File
@@ -3,6 +3,7 @@
pub mod client_distribution;
pub mod config;
mod logging;
pub mod pricing;
pub mod proxy_probe;
pub mod serve;
pub mod system_update;

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