Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

* fix(xai): keep catalog credential revision aligned

---------

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

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

Refs #139

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

* test(openai): make SOCKS negotiation coverage deterministic

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

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

---------

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

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

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

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

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

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

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

---------

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

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

Refs #129

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

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

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

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 14:54:17 +08:00
ZyyandCodex 590c4a163b fix(store): release rejected proxy import locks before returning
Explicitly unlock rejected import reservations before returning their error so immediate proxy operations do not race connection teardown. Cover repeated rejection followed by writes with PostgreSQL integration tests.

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

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 05:15:31 +08:00
423 changed files with 22353 additions and 2994 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 -6
View File
@@ -35,6 +35,7 @@ jobs:
docker_platforms: ${{ steps.version.outputs.docker_platforms }}
previous_tag: ${{ steps.version.outputs.previous_tag }}
prerelease: ${{ steps.version.outputs.prerelease }}
build_type: ${{ steps.version.outputs.build_type }}
steps:
- name: Checkout
@@ -57,7 +58,7 @@ jobs:
require "yaml"
APP_NAME = "codex-proxy-rs"
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc|exp)\.[1-9]\d*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/
def fail(message)
warn(message)
@@ -161,6 +162,7 @@ jobs:
puts "docker_platforms=#{docker_platforms.join(",")}"
puts "previous_tag=#{previous_tag}"
puts "prerelease=#{version.split("+", 2).first.include?("-")}"
puts "build_type=#{version.split("+", 2).first.include?("-exp.") ? "experimental" : "release"}"
RUBY
quality:
@@ -221,7 +223,7 @@ jobs:
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
CPR_BUILD_TYPE: release
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
- name: Upload binary artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -274,7 +276,7 @@ jobs:
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
CPR_BUILD_TYPE: release
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
- name: Upload binary artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -669,9 +671,6 @@ jobs:
shell: bash
env:
INSTALLATION_NOTES: |
> [!WARNING]
> **跨大版本升级:** 不支持跨大版本在线升级。请使用全新的 `.runtime/` 数据目录重新部署,再重新导入或重新授权服务商账号,并重新导入客户端密钥。详见[升级说明](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md#镜像升级与源码构建)。
## 📥 安装
**Docker 部署:**
+1 -10
View File
@@ -40,16 +40,7 @@ jobs:
- name: Run cargo audit
working-directory: backend
shell: bash
run: |
# 临时接受此中危风险以保持 Codex 0.154.0 的 TLS 指纹;直接升级会新增 ML-DSA 签名算法。
# 仅豁免本条公告,2026-09-21 UTC 起恢复阻断;兼容升级验证完成后应提前删除豁免。
# https://github.com/rustls/rustls/security/advisories/GHSA-2mjx-qc3c-rqvc
audit_advisory_args=()
if [[ "$(date -u +%F)" < "2026-09-21" ]]; then
audit_advisory_args+=(--ignore RUSTSEC-2026-0285)
fi
cargo audit "${audit_advisory_args[@]}"
run: cargo audit
frontend-security:
runs-on: ubuntu-latest
+10 -1
View File
@@ -4,7 +4,16 @@
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
## 提交 Issue
## 交流与反馈
[讨论区](https://github.com/zyycn/codex-proxy-rs/discussions) 用于使用问答、想法讨论、实验反馈和经验分享。
提问时按需补充版本、部署方式、已尝试的方法及脱敏信息;问题解决后,请在「使用问答」中标记有帮助的答案。
明确的 Bug、具体功能需求或协作规则建议使用 Issue,无需先开讨论。
讨论形成可执行事项后,可以新建 Issue 并互相附链接;已有 Issue 的后续信息继续补充在原帖,避免重复记录。
围绕问题友善交流,欢迎不同使用场景和方案观点。
### 提交 Issue
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏。
+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,
@@ -79,6 +79,7 @@ pub struct AccountGroupListQuery {
/// Complete account group summary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupRecord {
pub disable_fast: bool,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
@@ -107,6 +108,7 @@ pub struct AccountGroupPage {
/// Create an account group.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAccountGroup {
pub disable_fast: bool,
pub name: String,
pub description: Option<String>,
pub color: AccountGroupColor,
@@ -115,6 +117,7 @@ pub struct CreateAccountGroup {
/// Store-ready create command with a generated stable ID.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewAccountGroup {
pub disable_fast: bool,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
@@ -124,6 +127,7 @@ pub struct NewAccountGroup {
/// Update an account group's descriptive fields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccountGroup {
pub disable_fast: Option<bool>,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
+19 -2
View File
@@ -109,8 +109,25 @@ impl From<ClientAuthenticationError> for LoginError {
/// 服务端已验证的身份绑定,不保存密码或原始 API Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionSubject {
Admin { admin_user_id: String },
Key { client_key_id: ClientApiKeyId },
Admin {
admin_user_id: String,
credential_fingerprint: String,
},
Key {
client_key_id: ClientApiKeyId,
},
}
/// 仅已登录管理员可以修改自己的密码。
pub struct ChangePassword {
pub current_password: String,
pub new_password: String,
}
impl std::fmt::Debug for ChangePassword {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("ChangePassword([REDACTED])")
}
}
/// 两种登录方式共用的固定有效期会话。
@@ -86,6 +86,8 @@ pub struct ClientKeyListQuery {
/// 不含完整明文 Key 的管理投影。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyRecord {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -109,7 +111,7 @@ pub struct ClientKeyPage {
pub next_cursor: Option<ClientKeyCursor>,
}
/// 仅在创建或显式 reveal 时跨越管理边界的明文 Key。
/// 仅在创建或显式读取密钥配置时返回的明文 Key;Debug 隐去明文。
#[derive(Clone, PartialEq, Eq)]
pub struct ClientKeySecret {
pub record: ClientKeyRecord,
@@ -144,6 +146,8 @@ impl fmt::Debug for ClientKeySecret {
/// API 提交的 Client Key 创建命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub custom_key: Option<PlaintextClientApiKey>,
pub name: String,
pub label: Option<String>,
@@ -155,6 +159,8 @@ pub struct CreateClientKey {
/// 管理用例生成 ID 与明文后的持久化命令。
#[derive(Clone, PartialEq, Eq)]
pub struct NewClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -179,6 +185,8 @@ impl fmt::Debug for NewClientKey {
/// 修改 Client Key 的公开策略字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKey {
pub openai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub xai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -195,6 +203,21 @@ pub struct SetClientKeyEnabled {
pub enabled: bool,
}
/// 管理员选择的金额重置范围。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientKeyBudgetPeriod {
Daily,
Weekly,
All,
}
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResetClientKeyBudget {
pub id: ClientApiKeyId,
pub period: ClientKeyBudgetPeriod,
}
/// 删除 Client Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteClientKey {
@@ -11,6 +11,7 @@ pub mod client_keys;
pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod pricing;
pub mod provider_credentials;
pub mod proxies;
pub mod quota_forecast;
@@ -297,6 +297,7 @@ pub struct ProviderBillingInput {
/// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageCalculatedBillingFact {
pub breakdown: Option<CalculatedBillingBreakdown>,
pub bucket_start: DateTime<Utc>,
pub provider_kind: String,
pub upstream_model_id: String,
@@ -311,6 +312,9 @@ pub struct UsageCalculatedBillingFact {
/// Provider 已确认的逐项费用与单价。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedBillingBreakdown {
pub long_context_billing_applied: bool,
pub image: Option<ImageBillingBreakdown>,
pub custom_multiplier_bps: u32,
pub input_amount: CurrencyCost,
pub output_amount: CurrencyCost,
pub cache_read_amount: CurrencyCost,
@@ -325,6 +329,16 @@ pub struct CalculatedBillingBreakdown {
pub multiplier_percent: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageBillingBreakdown {
pub input_tokens: u64,
pub cached_tokens: u64,
pub input_amount: CurrencyCost,
pub cache_read_amount: CurrencyCost,
pub input_price_per_million: CurrencyCost,
pub cache_read_price_per_million: CurrencyCost,
}
/// 单次请求的费用语义。
///
/// Provider 上报费用或无法恢复逐项价格时保留总额;Provider 验证成功后升级为完整分解。
@@ -533,6 +547,7 @@ pub struct DashboardObservation {
/// 使用记录表格的窄读模型。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageListRecord {
pub client_api_key_name: Option<String>,
pub id: String,
pub endpoint: String,
pub client_transport: String,
@@ -541,9 +556,12 @@ 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>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
@@ -602,6 +620,7 @@ pub struct UsageRecord {
pub upstream_transport: Option<String>,
pub http_version: Option<String>,
pub websocket_pool: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明。
pub provider_metadata_json: Option<String>,
@@ -760,6 +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>;
@@ -60,6 +60,8 @@ pub struct ProxyTestResult {
pub success: bool,
pub latency_ms: u64,
pub exit_ip: Option<std::net::IpAddr>,
pub exit_ipv4: Option<std::net::Ipv4Addr>,
pub exit_ipv6: Option<std::net::Ipv6Addr>,
pub message: String,
}
@@ -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,6 +17,8 @@ pub use gateway_core::account::RotationStrategy;
/// 完整运行设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeSettings {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub config_revision: Revision,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
@@ -28,6 +30,7 @@ pub struct RuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -47,6 +50,8 @@ pub struct RuntimeSettings {
/// 原子替换运行设置的命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplaceRuntimeSettings {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
@@ -57,6 +62,7 @@ pub struct ReplaceRuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -122,3 +128,16 @@ impl fmt::Debug for RegeneratedAdminApiKey {
.finish()
}
}
impl RuntimeSettings {
pub fn client_profile(
&self,
provider: &str,
) -> Option<&gateway_core::account::OpaqueProviderData> {
match provider {
"openai" => self.openai_client_profile.as_ref(),
"xai" => self.xai_client_profile.as_ref(),
_ => None,
}
}
}
@@ -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>;
@@ -144,6 +144,7 @@ impl AccountGroupService for DefaultAccountGroupService {
id,
name: command.name,
description: command.description,
disable_fast: command.disable_fast,
color: command.color,
},
context,
@@ -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>,
@@ -191,9 +191,6 @@ async fn pending_authorization(
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
if !record.last_test.is_some_and(|test| test.success) {
return Err(AdminError::conflict("请先测试代理连接"));
}
proxy_id = Some(record.id);
Some(record.proxy)
}
@@ -192,7 +192,20 @@ impl DefaultObservabilityService {
average(first_token_latency_sum_ms, first_token_latency_count);
let trend = trend(TrendKind::Usage, observation.trend.clone())?;
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
let wire_profiles = self.providers.dashboard_wire_profiles();
let mut configurations = std::collections::BTreeMap::new();
if let Some(configuration) = &settings.openai_client_profile {
configurations.insert(
gateway_core::routing::ProviderKind::new("openai").expect("static provider kind"),
configuration.clone(),
);
}
if let Some(configuration) = &settings.xai_client_profile {
configurations.insert(
gateway_core::routing::ProviderKind::new("xai").expect("static provider kind"),
configuration.clone(),
);
}
let wire_profiles = self.providers.dashboard_wire_profiles(&configurations);
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
let total_slots = runtime_slots.as_ref().map_or_else(
|| {
@@ -612,7 +625,7 @@ async fn recover_standard_costs(
mut facts: UsageCalculatedBillingStream<'_>,
) -> Result<UsageCostScenarios, AdminError> {
let mut scenarios = UsageCostScenarios::default();
while let Some(fact) = facts
while let Some(mut fact) = facts
.try_next()
.await
.map_err(|error| map_store_error(error, "usage billing facts"))?
@@ -629,7 +642,14 @@ async fn recover_standard_costs(
cache_write_tokens: fact.cache_write_tokens,
total: fact.total.clone(),
};
let breakdown = match providers.calculated_billing(&provider_kind, &input) {
let breakdown = match fact
.breakdown
.take()
.filter(|b| b.total_amount == input.total)
.map_or_else(
|| providers.calculated_billing(&provider_kind, &input),
|b| Ok(Some(b)),
) {
Ok(breakdown) => breakdown,
Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => continue,
Err(error) => return Err(map_provider_error(error, "usage billing")),
@@ -658,8 +678,15 @@ fn no_cache_cost(
fact: &UsageCalculatedBillingFact,
breakdown: &crate::model::observability::CalculatedBillingBreakdown,
) -> Option<DecimalAmount> {
let input_tokens = fact.input_tokens?;
let cached_tokens = fact.cached_tokens.unwrap_or_default().min(input_tokens);
let image = breakdown.image.as_ref();
let input_tokens = fact
.input_tokens?
.checked_sub(image.map_or(0, |i| i.input_tokens))?;
let cached_tokens = fact
.cached_tokens
.unwrap_or_default()
.checked_sub(image.map_or(0, |i| i.cached_tokens))?
.min(input_tokens);
let cache_write_tokens = fact
.cache_write_tokens
.unwrap_or_default()
@@ -669,11 +696,20 @@ fn no_cache_cost(
.scaled()
.checked_mul(u128::from(cached_tokens.saturating_add(cache_write_tokens)))?
.checked_div(1_000_000)?;
let total = decimal(&breakdown.total_amount.amount)?
let mut total = decimal(&breakdown.total_amount.amount)?
.scaled()
.checked_sub(decimal(&breakdown.cache_read_amount.amount)?.scaled())?
.checked_sub(decimal(&breakdown.cache_write_amount.amount)?.scaled())?
.checked_add(replaced_input_amount)?;
if let Some(image) = image {
let image_no_cache = decimal(&image.input_price_per_million.amount)?
.scaled()
.checked_mul(u128::from(image.cached_tokens))?
.checked_div(1_000_000)?;
total = total
.checked_sub(decimal(&image.cache_read_amount.amount)?.scaled())?
.checked_add(image_no_cache)?;
}
DecimalAmount::from_str(
&gateway_core::metering::Decimal::from_scaled(total)
.ok()?
@@ -23,6 +23,33 @@ use super::{map_store_error, publish_committed};
/// API 消费的 Runtime settings 管理服务。
#[async_trait]
pub trait SettingsService: Send + Sync {
async fn preview_pricing_sync(
&self,
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError>;
async fn sync_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::SyncPricing,
) -> Result<(), AdminError>;
async fn pricing(&self) -> Result<crate::model::pricing::PricingCatalog, AdminError>;
async fn update_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::UpdatePricing,
) -> Result<(), AdminError>;
async fn client_profile_options(
&self,
_provider: &str,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
}
async fn preview_client_profile(
&self,
_provider: &str,
_configuration: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
}
async fn load(&self) -> Result<RuntimeSettings, AdminError>;
async fn replace(
&self,
@@ -41,19 +68,244 @@ pub trait SettingsService: Send + Sync {
}
pub(crate) struct DefaultSettingsService {
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
store: Arc<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultSettingsService {
fn profile_provider(
&self,
provider: &str,
) -> Result<Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
let kind = gateway_core::routing::ProviderKind::new(provider)
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
self.providers
.require(&kind)
.map_err(|error| super::map_provider_error(error, "client profile"))
}
#[must_use]
pub(crate) fn new(store: Arc<dyn SettingsStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
Self { store, snapshot }
pub(crate) fn new(
store: Arc<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
) -> Self {
Self {
providers,
pricing_source,
store,
snapshot,
}
}
}
#[async_trait]
impl SettingsService for DefaultSettingsService {
async fn preview_pricing_sync(
&self,
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError> {
self.pricing_source.fetch().await
}
async fn sync_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::SyncPricing,
) -> Result<(), AdminError> {
let count = command
.models
.values()
.map(std::collections::BTreeSet::len)
.sum::<usize>();
if count == 0
|| count > 10_000
|| command
.models
.values()
.any(std::collections::BTreeSet::is_empty)
{
return Err(AdminError::invalid("请选择 1 至 10000 个模型"));
}
let mut current = self.pricing_source.fetch().await?;
if current != command.preview {
return Err(AdminError::invalid(
"models.dev 价目已变化,请重新预览后确认",
));
}
let stored = self
.store
.load_pricing()
.await
.map_err(|error| map_store_error(error, "model pricing"))?;
let mut changes = crate::model::pricing::PricingSyncChanges::new();
for (provider, models) in command.models {
let selected = changes.entry(provider.clone()).or_default();
for model in models {
let price = current
.prices
.get_mut(&provider)
.and_then(|prices| prices.remove(&model));
if price.is_none()
&& !stored
.synced
.get(&provider)
.is_some_and(|prices| prices.contains_key(&model))
{
return Err(AdminError::invalid("所选模型不在来源价目中,请重新预览"));
}
selected.insert(model, price);
}
}
let revision = self
.store
.sync_pricing(changes, context)
.await
.map_err(|error| map_store_error(error, "model pricing sync"))?;
publish_committed(self.snapshot.as_ref(), revision).await
}
async fn pricing(&self) -> Result<crate::model::pricing::PricingCatalog, AdminError> {
let stored = self
.store
.load_pricing()
.await
.map_err(|error| map_store_error(error, "model pricing"))?;
Ok(crate::model::pricing::PricingCatalog {
defaults: self.providers.pricing_catalog(),
overrides: stored.overrides,
synced: stored.synced,
synced_at: stored.synced_at,
})
}
async fn update_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::UpdatePricing,
) -> Result<(), AdminError> {
use crate::model::pricing::PricingChange;
let catalog = self.pricing().await?;
if !catalog.defaults.contains_key(&command.provider)
|| command.models.is_empty()
|| command.models.len() > 500
|| command.models.iter().any(|model| {
model.is_empty()
|| model.len() > 128
|| model.trim() != model
|| model.chars().any(char::is_whitespace)
|| model.chars().any(char::is_control)
})
{
return Err(AdminError::invalid("Provider、模型 ID 或批量数量不合法"));
}
if command.change == PricingChange::Delete
&& command.models.iter().any(|model| {
catalog
.defaults
.get(&command.provider)
.is_some_and(|models| models.contains_key(model))
})
{
return Err(AdminError::invalid("内置价目不能删除,仅支持人工覆盖"));
}
let defaults = gateway_core::metering::merge_pricing(catalog.defaults, &catalog.synced);
let defaults = defaults.get(&command.provider);
match &command.change {
PricingChange::Replace(pricing) => {
pricing.validate().map_err(AdminError::invalid)?;
if command.provider == "xai"
&& pricing
.bands
.keys()
.any(|band| matches!(band.as_str(), "image" | "flex" | "long_flex"))
{
return Err(AdminError::invalid("xAI 不支持此价格档位"));
}
if !pricing.bands.contains_key("standard")
&& command
.models
.iter()
.any(|model| !defaults.is_some_and(|p| p.contains_key(model)))
{
return Err(AdminError::invalid("未知模型必须提供标准档价格"));
}
}
PricingChange::Multiplier(bps) if *bps > 1_000_000 => {
return Err(AdminError::invalid("倍率必须在 0 至 100 倍之间"));
}
PricingChange::Multiplier(_)
if command.models.iter().any(|model| {
!defaults.is_some_and(|p| p.contains_key(model))
&& !catalog
.overrides
.get(&command.provider)
.and_then(|p| p.get(model))
.is_some_and(|p| p.bands.contains_key("standard"))
}) =>
{
return Err(AdminError::invalid("请先为未知模型配置标准档价格"));
}
_ => {}
}
let revision = self
.store
.update_pricing(command, context)
.await
.map_err(|error| map_store_error(error, "model pricing"))?;
publish_committed(self.snapshot.as_ref(), revision).await
}
async fn client_profile_options(
&self,
provider: &str,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
let mut options = self
.profile_provider(provider)?
.client_profile_options()
.map_err(|error| super::map_provider_error(error, "client profile"))?
.into_inner();
let configuration = self
.load()
.await?
.client_profile(provider)
.cloned()
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
options.insert(
"globalConfiguration".to_owned(),
serde_json::Value::Object(configuration.into_inner()),
);
Ok(gateway_core::account::OpaqueProviderData::new(options))
}
async fn preview_client_profile(
&self,
provider: &str,
configuration: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
let profile_provider = self.profile_provider(provider)?;
let global;
let (configuration, source) = if let Some(configuration) = configuration {
(configuration, "override")
} else {
global = self
.load()
.await?
.client_profile(provider)
.cloned()
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
(&global, "global")
};
let mut preview = profile_provider
.preview_client_profile(configuration)
.map_err(|error| super::map_provider_error(error, "client profile"))?
.into_inner();
preview.insert("source".to_owned(), serde_json::Value::from(source));
Ok(gateway_core::account::OpaqueProviderData::new(preview))
}
async fn load(&self) -> Result<RuntimeSettings, AdminError> {
self.store
.load_runtime_settings()
@@ -67,6 +319,16 @@ impl SettingsService for DefaultSettingsService {
command: ReplaceRuntimeSettings,
) -> Result<RuntimeSettings, AdminError> {
validate_settings(&command)?;
for (provider, profile) in [
("openai", &command.openai_client_profile),
("xai", &command.xai_client_profile),
] {
if let Some(profile) = profile {
self.profile_provider(provider)?
.preview_client_profile(profile)
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
}
let settings = self
.store
.replace_runtime_settings(command, context)
@@ -115,6 +377,8 @@ impl SettingsService for DefaultSettingsService {
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
let valid = command.request_location.validate().is_ok()
&& command.responses_max_decompressed_body_bytes > 0
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
&& command.refresh_margin_seconds > 0
&& command.refresh_concurrency > 0
&& command.max_concurrent_per_account > 0
@@ -618,6 +618,16 @@ impl AuthStore for FakeAuthStore {
async fn load_password_hash(&self, _admin_user_id: &str) -> AdminStoreResult<Option<String>> {
Ok(None)
}
async fn change_password(
&self,
_: &str,
_: &str,
_: &str,
_: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn create_password_hash_if_absent(
&self,
_admin_user_id: &str,
@@ -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);
}
@@ -202,6 +202,7 @@ impl AccountRuntimeStore for FakeRuntimeStore {
fn group_record() -> AccountGroupRecord {
let now = Utc::now();
AccountGroupRecord {
disable_fast: false,
id: group_id(),
name: "Primary".to_owned(),
description: None,
@@ -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,8 +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 {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
@@ -984,6 +1010,7 @@ impl SettingsStore for StaticSettingsStore {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -1340,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,
@@ -1387,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();
@@ -2020,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,6 +25,8 @@ use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, event
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
@@ -36,6 +38,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -59,6 +62,23 @@ struct FreezeSettingsStore {
#[async_trait]
impl SettingsStore for FreezeSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(self.settings.clone())
}
@@ -243,6 +243,7 @@ impl AdminHarness {
self.backup,
),
gateway_admin::AdminRuntimePorts {
pricing_source: Arc::new(UnavailablePricingSource),
providers: self.providers,
snapshot: Arc::new(NoopSnapshot),
account_probe: self.probe,
@@ -282,6 +283,25 @@ impl AuthStore for BootstrapAuthStore {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn change_password(
&self,
_: &str,
expected_hash: &str,
password_hash: &str,
audit: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
let mut stored = self.password_hash.lock().unwrap();
let Some(credentials) = stored
.as_mut()
.filter(|value| value.as_str() == expected_hash)
else {
return Ok(false);
};
*credentials = password_hash.to_owned();
let _ = audit;
Ok(true)
}
async fn create_password_hash_if_absent(
&self,
_: &str,
@@ -567,6 +587,14 @@ impl AccountRuntimeStore for UnavailableStore {
#[async_trait]
impl ClientKeyStore for UnavailableStore {
async fn reset_client_key_budget(
&self,
_: gateway_admin::model::client_keys::ResetClientKeyBudget,
_: &MutationContext,
) -> AdminStoreResult<()> {
Err(unavailable("client key budget reset"))
}
async fn get_client_key(
&self,
_: &ClientApiKeyId,
@@ -678,6 +706,23 @@ impl ObservabilityStore for UnavailableStore {
#[async_trait]
impl SettingsStore for UnavailableStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unavailable("settings"))
}
@@ -716,6 +761,19 @@ struct UnavailableProvider {
calculated_billing: Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
}
struct UnavailablePricingSource;
#[async_trait]
impl gateway_admin::ports::pricing::PricingSource for UnavailablePricingSource {
async fn fetch(
&self,
) -> Result<gateway_admin::model::pricing::PricingSyncPreview, gateway_admin::model::AdminError>
{
Err(gateway_admin::model::AdminError::internal(
"pricing source unavailable",
))
}
}
impl UnavailableProvider {
fn new(kind: &str) -> Self {
Self {
@@ -863,6 +921,9 @@ pub(super) fn calculated_billing_provider() -> Arc<dyn ProviderAdmin> {
dashboard_profile: None,
calculated_billing: Some(
gateway_admin::model::observability::CalculatedBillingBreakdown {
long_context_billing_applied: false,
custom_multiplier_bps: 10_000,
image: None,
input_amount: amount("0.8"),
output_amount: amount("0.2"),
cache_read_amount: amount("0"),
@@ -372,6 +372,7 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
}],
}]);
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
breakdown: None,
bucket_start: quarter_hour_start(now),
provider_kind: "openai".to_owned(),
upstream_model_id: "gpt-5.5".to_owned(),
@@ -543,6 +544,7 @@ async fn usage_insights_should_reject_partial_costs_when_billing_stream_fails()
let range = observation_range(now);
let store = Arc::new(FixtureObservabilityStore::new(range));
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
breakdown: None,
bucket_start: quarter_hour_start(now),
provider_kind: "openai".to_owned(),
upstream_model_id: "gpt-5.5".to_owned(),
@@ -751,8 +753,27 @@ struct FixtureSettingsStore;
#[async_trait]
impl SettingsStore for FixtureSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(1).expect("revision"),
@@ -764,6 +785,7 @@ impl SettingsStore for FixtureSettingsStore {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -849,6 +871,7 @@ fn total_record(
now: DateTime<Utc>,
) -> UsageListRecord {
UsageListRecord {
client_api_key_name: Some("Production".to_owned()),
id: id.to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
@@ -857,9 +880,11 @@ 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,
upstream_response_model: None,
service_tier: None,
input_tokens: Some(800),
output_tokens: Some(200),
@@ -12,6 +12,7 @@ use gateway_core::account::{OutboundProxy, ProviderAccountId};
pub(super) struct TestProxies {
pub events: Option<super::accounts::EventLog>,
pub accounts: Option<Vec<ProxyAccountRef>>,
pub record: Option<ProxyRecord>,
}
struct ImportGuard(super::accounts::EventLog);
@@ -70,8 +71,11 @@ impl ProxyStore for TestProxies {
page_size: query.page_size.get(),
})
}
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
Err(super::unavailable("proxy"))
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord> {
self.record
.clone()
.filter(|record| record.id == id)
.ok_or_else(|| super::unavailable("proxy"))
}
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
Err(super::unavailable("proxy"))
@@ -105,6 +109,60 @@ impl ProxyProbe for TestProxies {
}
}
#[tokio::test]
async fn authorization_uses_selected_proxy_regardless_of_probe_status() {
use super::accounts::{FakeAccountStore, FakeProviderAdmin, context, events, recorded};
use gateway_admin::model::provider_credentials::StartAuthorization;
use std::sync::Arc;
for kind in ["openai", "xai"] {
for probe_success in [None, Some(false), Some(true)] {
let events = events();
let now = chrono::Utc::now();
let services = super::AdminHarness::new()
.provider(FakeProviderAdmin::new(kind, events.clone()))
.accounts(FakeAccountStore::new(kind, events.clone()))
.proxies(Arc::new(TestProxies {
record: Some(ProxyRecord {
location: None,
id: "proxy_oauth".to_owned(),
name: "授权出口".to_owned(),
proxy: OutboundProxy::parse("http://127.0.0.1:8080").unwrap(),
revision: Revision::new(1).unwrap(),
account_count: 0,
last_test_at: probe_success.map(|_| now),
last_test: probe_success.map(|success| ProxyTestResult {
success,
latency_ms: 10,
exit_ip: None,
exit_ipv4: None,
exit_ipv6: None,
message: "出口探测结果".to_owned(),
}),
created_at: now,
updated_at: now,
}),
..Default::default()
}))
.build()
.await;
let command = StartAuthorization {
outbound_proxy: Some(AccountProxySelection::Saved("proxy_oauth".to_owned())),
context: context("oauth-proxy-status"),
name: "授权账号".to_owned(),
reauthorization: None,
};
let result = if kind == "openai" {
services.openai().start_authorization(command).await
} else {
services.xai().start_authorization(command).await
};
assert!(result.is_ok(), "{kind}, {probe_success:?}: {result:?}");
assert_eq!(recorded(&events), ["provider.start_authorization"]);
}
}
}
#[tokio::test]
async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
use super::accounts::{FakeProviderAdmin, events};
@@ -13,8 +13,39 @@ use gateway_admin::{
struct UnusedSettingsStore;
#[tokio::test]
async fn client_profile_preview_rejects_unknown_provider_before_loading_settings() {
let services = super::AdminHarness::new()
.settings(std::sync::Arc::new(UnusedSettingsStore))
.build()
.await;
let error = services
.settings()
.preview_client_profile("unknown", None)
.await
.expect_err("unknown provider cannot use global settings");
assert_eq!(error.kind(), AdminErrorKind::Invalid);
}
#[async_trait]
impl SettingsStore for UnusedSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unused())
}
@@ -61,6 +92,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
request_id: "request-settings".to_owned(),
},
ReplaceRuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
model_mappings: Default::default(),
@@ -71,6 +104,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -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 {
@@ -72,6 +72,8 @@ impl ListAccountGroupsQuery {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateAccountGroupRequest {
#[serde(default)]
disable_fast: bool,
name: String,
description: Option<String>,
color: String,
@@ -80,6 +82,7 @@ struct CreateAccountGroupRequest {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct UpdateAccountGroupRequest {
disable_fast: Option<bool>,
id: String,
name: String,
description: Option<String>,
@@ -95,6 +98,7 @@ struct AccountGroupIdRequest {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountGroupView {
disable_fast: bool,
id: String,
name: String,
description: Option<String>,
@@ -138,6 +142,7 @@ impl From<AccountGroupRecord> for AccountGroupView {
id: record.id.to_string(),
name: record.name,
description: record.description,
disable_fast: record.disable_fast,
color: record.color.as_str().to_owned(),
enabled: record.enabled,
member_count: record.member_count,
@@ -273,6 +278,7 @@ where
CreateAccountGroup {
name: request.name,
description: request.description,
disable_fast: request.disable_fast,
color: group_color(&request.color)?,
},
)
@@ -300,6 +306,7 @@ where
id: group_id(request.id)?,
name: request.name,
description: request.description,
disable_fast: request.disable_fast,
color: group_color(&request.color)?,
},
)
@@ -7,10 +7,11 @@ use std::fmt;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use gateway_admin::model::client_keys::{
ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue, ClientKeyListQuery,
ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret,
ClientKeySort as DomainSort, ClientKeySortField as DomainSortField, CreateClientKey,
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
ClientKeyBudgetPeriod, ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue,
ClientKeyListQuery, ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord,
ClientKeySecret, ClientKeySort as DomainSort, ClientKeySortField as DomainSortField,
CreateClientKey, CreatedClientKey, DeleteClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
SortDirection, UpdateClientKey,
};
use gateway_core::{
engine::budget::ClientBudgetLimits,
@@ -146,6 +147,8 @@ impl ClientKeySort {
#[derive(Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateClientKeyRequest {
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
xai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
custom_key: Option<String>,
name: String,
label: Option<String>,
@@ -175,6 +178,12 @@ impl CreateClientKeyRequest {
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(CreateClientKey {
openai_client_profile_override: self
.openai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::new),
xai_client_profile_override: self
.xai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::new),
custom_key: self
.custom_key
.filter(|key| !key.is_empty())
@@ -201,6 +210,10 @@ impl CreateClientKeyRequest {
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateClientKeyRequest {
#[serde(default, deserialize_with = "deserialize_profile_override")]
openai_client_profile_override: Option<Option<serde_json::Map<String, serde_json::Value>>>,
#[serde(default, deserialize_with = "deserialize_profile_override")]
xai_client_profile_override: Option<Option<serde_json::Map<String, serde_json::Value>>>,
id: String,
name: String,
label: Option<String>,
@@ -221,6 +234,12 @@ impl UpdateClientKeyRequest {
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(UpdateClientKey {
openai_client_profile_override: self
.openai_client_profile_override
.map(|value| value.map(gateway_core::account::OpaqueProviderData::new)),
xai_client_profile_override: self
.xai_client_profile_override
.map(|value| value.map(gateway_core::account::OpaqueProviderData::new)),
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
name: self.name,
label: self.label,
@@ -235,6 +254,36 @@ impl UpdateClientKeyRequest {
}
}
/// 重置指定周期已用金额的请求。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ResetClientKeyBudgetRequest {
id: String,
period: BudgetResetPeriod,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
enum BudgetResetPeriod {
Daily,
Weekly,
All,
}
impl ResetClientKeyBudgetRequest {
pub fn into_command(self) -> Result<ResetClientKeyBudget, WireValidationError> {
validate_required_text(&self.id, "id")?;
Ok(ResetClientKeyBudget {
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
period: match self.period {
BudgetResetPeriod::Daily => ClientKeyBudgetPeriod::Daily,
BudgetResetPeriod::Weekly => ClientKeyBudgetPeriod::Weekly,
BudgetResetPeriod::All => ClientKeyBudgetPeriod::All,
},
})
}
}
/// 只携带 ID 的 Client Key mutation 请求。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -276,6 +325,8 @@ impl ClientKeyMutationRequest {
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientKeyView {
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
xai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
id: String,
name: String,
label: Option<String>,
@@ -314,6 +365,12 @@ impl From<ClientKeyRecord> for ClientKeyView {
"groups"
};
Self {
openai_client_profile_override: record
.openai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::into_inner),
xai_client_profile_override: record
.xai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::into_inner),
id: record.id.to_string(),
name: record.name,
label: record.label,
@@ -690,6 +747,10 @@ where
post(create_client_key::<S>),
)
.route("/api/admin/client-keys/reveal", get(reveal_client_key::<S>))
.route(
"/api/admin/client-keys/reset-budget",
post(reset_client_key_budget::<S>),
)
.route(
"/api/admin/client-keys/update",
post(update_client_key::<S>),
@@ -791,6 +852,29 @@ where
)
}
async fn reset_client_key_budget<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(payload): AdminJson<ResetClientKeyBudgetRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let id = state
.admin_services()
.client_keys()
.reset_budget(
&auth.context().mutation_context(),
payload.into_command().map_err(map_wire_error)?,
)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(MutatedClientKeyData::new(id.as_str().to_owned())),
))
}
async fn disable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
@@ -874,3 +958,10 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error)
}
// 省略表示不修改,null 表示恢复跟随通用设置。
fn deserialize_profile_override<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Option<serde_json::Map<String, serde_json::Value>>>, D::Error> {
Option::<serde_json::Map<String, serde_json::Value>>::deserialize(deserializer).map(Some)
}
@@ -214,6 +214,8 @@ fn capitalize_first(value: &str) -> String {
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
match billing? {
domain::UsageBilling::Total { source, total } => Some(BillingView {
long_context_billing_applied: false,
image: None,
input_amount_display: "—".to_owned(),
output_amount_display: "—".to_owned(),
cache_read_amount_display: "—".to_owned(),
@@ -232,6 +234,18 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<Bil
multiplier_display: "—".to_owned(),
}),
domain::UsageBilling::Calculated(value) => Some(BillingView {
long_context_billing_applied: value.long_context_billing_applied,
image: value
.image
.as_ref()
.map(|image| super::wire::ImageBillingView {
input_amount_display: format_money(&image.input_amount),
cache_read_amount_display: format_money(&image.cache_read_amount),
input_price_display: format_token_price(&image.input_price_per_million),
cache_read_price_display: format_token_price(
&image.cache_read_price_per_million,
),
}),
input_amount_display: format_money(&value.input_amount),
output_amount_display: format_money(&value.output_amount),
cache_read_amount_display: format_money(&value.cache_read_amount),
@@ -243,7 +257,15 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<Bil
cache_read_price_display: format_token_price(&value.cache_read_price_per_million),
cache_write_price_display: format_token_price(&value.cache_write_price_per_million),
service_tier_display: format_service_tier(value.service_tier.as_deref()),
multiplier_display: format!("{:.2}x", f64::from(value.multiplier_percent) / 100.0),
multiplier_display: if value.custom_multiplier_bps == 10_000 {
format!("{:.2}x", f64::from(value.multiplier_percent) / 100.0)
} else {
format!(
"档位 {:.2}x · 自定义 {:.4}x",
f64::from(value.multiplier_percent) / 100.0,
f64::from(value.custom_multiplier_bps) / 10_000.0
)
},
}),
}
}
@@ -257,16 +279,19 @@ 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,
upstream_model: record.upstream_model_id,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
client_transport: record.client_transport,
upstream_transport: record.upstream_transport,
@@ -360,6 +385,7 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
model,
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
status_code,
client_transport: record.client_transport,
@@ -542,6 +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,20 +73,32 @@ 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>,
pub upstream_model: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub client_transport: String,
pub upstream_transport: Option<String>,
@@ -122,6 +137,7 @@ pub struct UsageRecordView {
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub status_code: Option<i64>,
pub client_transport: String,
@@ -760,6 +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>,
@@ -92,6 +92,8 @@ struct ProxyTestView {
success: bool,
latency_ms: u64,
exit_ip: Option<String>,
exit_ipv4: Option<String>,
exit_ipv6: Option<String>,
message: String,
}
@@ -101,6 +103,8 @@ impl From<ProxyTestResult> for ProxyTestView {
success: result.success,
latency_ms: result.latency_ms,
exit_ip: result.exit_ip.map(|ip| ip.to_string()),
exit_ipv4: result.exit_ipv4.map(|ip| ip.to_string()),
exit_ipv6: result.exit_ipv6.map(|ip| ip.to_string()),
message: result.message,
}
}
@@ -6,7 +6,7 @@ use std::{collections::BTreeMap, fmt};
use axum::{
Router,
extract::State,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
@@ -34,6 +34,8 @@ pub type ModelMappings = BTreeMap<String, String>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
@@ -44,6 +46,7 @@ pub struct RuntimeSettingsView {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -64,6 +67,10 @@ pub struct RuntimeSettingsView {
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateRuntimeSettingsRequest {
#[serde(default, deserialize_with = "deserialize_profile_update")]
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default, deserialize_with = "deserialize_profile_update")]
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
@@ -74,6 +81,7 @@ pub struct UpdateRuntimeSettingsRequest {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -104,6 +112,13 @@ impl UpdateRuntimeSettingsRequest {
return Err(WireValidationError::new(field));
}
}
if self.responses_max_decompressed_body_bytes == 0
|| isize::try_from(self.responses_max_decompressed_body_bytes).is_err()
{
return Err(WireValidationError::new(
"responsesMaxDecompressedBodyBytes",
));
}
if !(1..=120).contains(&self.concurrency_wait_timeout_seconds) {
return Err(WireValidationError::new("concurrencyWaitTimeoutSeconds"));
}
@@ -166,6 +181,12 @@ impl UpdateRuntimeSettingsRequest {
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
self.validate()?;
Ok(ReplaceRuntimeSettings {
openai_client_profile: self
.openai_client_profile
.map(gateway_core::account::OpaqueProviderData::new),
xai_client_profile: self
.xai_client_profile
.map(gateway_core::account::OpaqueProviderData::new),
request_location_enabled: self.request_location_enabled,
request_location: self
.request_location
@@ -181,6 +202,7 @@ impl UpdateRuntimeSettingsRequest {
max_waiting_per_key: self.max_waiting_per_key,
max_waiting_per_account: self.max_waiting_per_account,
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
min_codex_desktop_version: self.min_codex_desktop_version,
@@ -206,6 +228,12 @@ impl UpdateRuntimeSettingsRequest {
impl From<RuntimeSettings> for RuntimeSettingsView {
fn from(settings: RuntimeSettings) -> Self {
Self {
openai_client_profile: settings
.openai_client_profile
.map(gateway_core::account::OpaqueProviderData::into_inner),
xai_client_profile: settings
.xai_client_profile
.map(gateway_core::account::OpaqueProviderData::into_inner),
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
model_mappings: wire_model_mappings(settings.model_mappings),
@@ -216,6 +244,7 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
rotation_strategy: settings.rotation_strategy.as_str().to_owned(),
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
@@ -329,7 +358,25 @@ where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/settings/pricing", get(pricing::<S>))
.route(
"/api/admin/settings/pricing/update",
post(update_pricing::<S>),
)
.route(
"/api/admin/settings/pricing/sync/preview",
post(preview_pricing_sync::<S>),
)
.route("/api/admin/settings/pricing/sync", post(sync_pricing::<S>))
.route("/api/admin/settings", get(settings::<S>))
.route(
"/api/admin/settings/client-profiles/{provider}",
get(client_profile_options::<S>),
)
.route(
"/api/admin/settings/client-profiles/{provider}/preview",
post(preview_client_profile::<S>),
)
.route("/api/admin/settings/update", post(update_settings::<S>))
.route(
"/api/admin/settings/client-downloads/codex-desktop/windows",
@@ -368,6 +415,125 @@ where
)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PricingUpdateRequest {
provider: String,
models: Vec<String>,
change: PricingChangeRequest,
}
#[derive(Deserialize)]
#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)]
enum PricingChangeRequest {
Replace {
pricing: gateway_core::metering::ModelPriceOverride,
},
Multiplier {
#[serde(rename = "multiplierBps")]
multiplier_bps: u32,
},
Reset {},
Delete {},
}
async fn pricing<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let pricing = state
.admin_services()
.settings()
.pricing()
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({
"defaults": pricing.defaults, "overrides": pricing.overrides,
"synced": pricing.synced, "syncedAt": pricing.synced_at,
})),
))
}
async fn preview_pricing_sync<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let preview = state
.admin_services()
.settings()
.preview_pricing_sync()
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(preview),
))
}
async fn sync_pricing<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(command): AdminJson<gateway_admin::model::pricing::SyncPricing>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
state
.admin_services()
.settings()
.sync_pricing(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"saved": true})),
))
}
async fn update_pricing<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<PricingUpdateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
use gateway_admin::model::pricing::{PricingChange, UpdatePricing};
let change = match request.change {
PricingChangeRequest::Replace { pricing } => PricingChange::Replace(pricing),
PricingChangeRequest::Multiplier { multiplier_bps } => {
PricingChange::Multiplier(multiplier_bps)
}
PricingChangeRequest::Reset {} => PricingChange::Reset,
PricingChangeRequest::Delete {} => PricingChange::Delete,
};
state
.admin_services()
.settings()
.update_pricing(
&auth.context().mutation_context(),
UpdatePricing {
provider: request.provider,
models: request.models,
change,
},
)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"saved": true})),
))
}
async fn settings<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -564,3 +730,60 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error)
}
// 字段省略时保留已有配置;显式 null 不能清空唯一的通用默认。
fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<serde_json::Map<String, serde_json::Value>>, D::Error> {
serde_json::Map::<String, serde_json::Value>::deserialize(deserializer).map(Some)
}
async fn client_profile_options<S>(
_auth: AdminAuth,
Path(provider): Path<String>,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
.settings()
.client_profile_options(&provider)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result.into_inner()),
))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ClientProfilePreviewRequest {
configuration: Option<serde_json::Map<String, serde_json::Value>>,
}
async fn preview_client_profile<S>(
_auth: AdminAuth,
Path(provider): Path<String>,
State(state): State<S>,
AdminJson(request): AdminJson<ClientProfilePreviewRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let configuration = request
.configuration
.map(gateway_core::account::OpaqueProviderData::new);
let result = state
.admin_services()
.settings()
.preview_client_profile(&provider, configuration.as_ref())
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result.into_inner()),
))
}
@@ -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 {
+40 -7
View File
@@ -143,7 +143,7 @@ pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response
}
}
/// Desktop 优先于其内嵌的 CLI/Core 标记;未知客户端保持兼容并返回 `None`。
/// Desktop 优先于内嵌 CLI/Core;未知客户端及未提供应用版本的手机远程客户端返回 `None`。
#[must_use]
pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClient> {
const MAXIMUM_HEADER_LENGTH: usize = 4096;
@@ -154,11 +154,21 @@ pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClien
|| user_agent.is_some_and(|value| contains_ascii_case_insensitive(value, "Codex Desktop"));
if is_desktop {
let explicit_version = bounded_ascii_header(headers, "version", MAXIMUM_HEADER_LENGTH);
let user_agent_version = user_agent.and_then(desktop_version_from_user_agent);
// 手机远程初始化会覆盖进程级 UA 后缀,保留的 Desktop/Core 前缀不代表应用版本。
// 仅缺失版本信息时免于门禁;已提供的版本即使非法,也继续按 Desktop 校验。
if !headers.contains_key("version")
&& user_agent_version.is_none()
&& headers
.get("user-agent")
.is_some_and(|value| value.as_bytes().len() <= MAXIMUM_HEADER_LENGTH)
&& user_agent.is_some_and(has_chatgpt_remote_user_agent_suffix)
{
return None;
}
let version = match explicit_version {
Some(value) => CodexClientVersion::parse(value).ok(),
None => user_agent
.and_then(desktop_version_from_user_agent)
.and_then(|value| CodexClientVersion::parse(value).ok()),
None => user_agent_version.and_then(|value| CodexClientVersion::parse(value).ok()),
};
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Desktop,
@@ -206,11 +216,34 @@ fn bounded_ascii_header<'a>(
fn desktop_version_from_user_agent(user_agent: &str) -> Option<&str> {
let marker = "Codex Desktop;";
let start = find_ascii_case_insensitive(user_agent, marker)? + marker.len();
let version = user_agent[start..]
// 空候选也代表已提供应用版本,不能被手机远程的缺失版本规则放行。
user_agent[start..]
.trim_start()
.split([')', ' ', ';', ','])
.next()?;
(!version.is_empty()).then_some(version)
.next()
}
fn has_chatgpt_remote_user_agent_suffix(user_agent: &str) -> bool {
let Some((_, suffix)) = user_agent
.strip_suffix(')')
.and_then(|value| value.rsplit_once(" ("))
else {
return false;
};
let Some((name, version)) = suffix.split_once("; ") else {
return false;
};
let Some(platform) = name
.strip_prefix("codex_chatgpt_")
.and_then(|value| value.strip_suffix("_remote"))
else {
return false;
};
[platform, version].into_iter().all(|value| {
!value.is_empty()
&& !value.contains(['(', ')', ';'])
&& !value.bytes().any(|byte| byte.is_ascii_whitespace())
})
}
fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
+74 -14
View File
@@ -8,7 +8,7 @@ use axum::{
};
use gateway_core::{
engine::EngineError,
error::{GatewayError, GatewayErrorKind},
error::{GatewayError, GatewayErrorKind, ProviderErrorKind},
policy::{ClientVersionRejection, CodexClientKind},
};
use serde_json::{Value, json};
@@ -22,16 +22,54 @@ pub fn openai_error_response(
error_type: &str,
code: &str,
) -> (StatusCode, Json<Value>) {
(
status,
Json(json!({
"error": {
"message": message,
"type": error_type,
"code": code
}
})),
)
let body = json!({
"error": {"message": message, "type": error_type, "code": code}
});
match capacity_error_for_client(&body) {
Some(body) => (StatusCode::SERVICE_UNAVAILABLE, Json(body)),
None => (status, Json(body)),
}
}
/// Codex 将这两个容量码视为不可重试;仅在交付边界转换,内部仍保留真实上游事实。
pub(super) fn client_error_code(code: &str) -> &str {
match code {
"server_is_overloaded" | "slow_down" => "server_error",
_ => code,
}
}
/// 为 HTTP 错误或流内失败保留原有字段,仅投影客户端需要的重试信号。
pub(super) fn capacity_error_for_client(data: &Value) -> Option<Value> {
const CODE_PATHS: [&str; 2] = ["/error/code", "/response/error/code"];
let capacity_code = |value: &Value| {
value
.as_str()
.is_some_and(|code| client_error_code(code) != code)
};
if !CODE_PATHS
.iter()
.any(|path| data.pointer(path).is_some_and(capacity_code))
{
return None;
}
let mut projected = data.clone();
for path in CODE_PATHS {
if let Some(code) = projected.pointer_mut(path)
&& capacity_code(code)
{
*code = Value::String("server_error".to_owned());
}
}
// WS 包装错误按 HTTP 状态分类;保留 400/429 会使客户端终止重试。
for field in ["status", "status_code"] {
if let Some(status) = projected.get_mut(field)
&& status.is_number()
{
*status = json!(503);
}
}
Some(projected)
}
/// 严格协议 decoder/encoder 返回的安全错误 body。
@@ -166,14 +204,33 @@ pub fn gateway_error_response(error: &GatewayError) -> Response {
response
}
/// 在下游尚未提交时优先交付 Provider 的原始 HTTP 失败响应。
/// 在下游尚未提交时交付 Provider 的 HTTP 失败响应,并应用客户端容量恢复合同。
pub fn engine_error_response(error: &EngineError) -> Response {
let capacity_unavailable = matches!(error, EngineError::Provider(error)
if error.kind() == ProviderErrorKind::UpstreamCapacityUnavailable);
if let EngineError::Provider(error) = error
&& let Some(upstream) = error.client_visible_upstream_response()
&& let Ok(status) = StatusCode::from_u16(upstream.status())
{
let mut response = Response::new(Body::from(upstream.body().clone()));
*response.status_mut() = status;
let projected = serde_json::from_slice::<Value>(upstream.body())
.ok()
.and_then(|body| capacity_error_for_client(&body));
let mut response = match projected {
Some(body) => {
let mut response = Response::new(Body::from(body.to_string()));
*response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
response
}
None => {
let mut response = Response::new(Body::from(upstream.body().clone()));
*response.status_mut() = if capacity_unavailable {
StatusCode::SERVICE_UNAVAILABLE
} else {
status
};
response
}
};
if let Some(content_type) = upstream.content_type()
&& let Ok(content_type) = HeaderValue::from_bytes(content_type)
{
@@ -191,6 +248,9 @@ pub fn engine_error_response(error: &EngineError) -> Response {
return response;
}
let mut response = gateway_error_response(&gateway_error_from_engine(error));
if capacity_unavailable {
*response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
}
// 正文未完整取得时不能透传残缺响应,但已确认的上游关联 ID 仍应交付。
if let EngineError::Provider(error) = error
&& let Some(request_id) = error.upstream_request_id()
@@ -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(
@@ -42,7 +42,10 @@ pub enum RequestDecodeError {
MalformedJson,
/// 请求体解压后超过允许上限。
#[error("decompressed request body is too large")]
DecompressedBodyTooLarge,
DecompressedBodyTooLarge {
/// 当前请求快照中的解压输出上限。
limit_bytes: usize,
},
/// 请求使用了网关不支持的 `Content-Encoding`。
#[error("request content-encoding `{encoding}` is not supported")]
UnsupportedContentEncoding {
@@ -108,9 +111,11 @@ impl RequestDecodeError {
"Request body must be valid JSON.".to_owned(),
None,
),
Self::DecompressedBodyTooLarge => (
Self::DecompressedBodyTooLarge { limit_bytes } => (
"request_too_large",
"Decompressed request body exceeds the allowed size.".to_owned(),
format!(
"Decompressed request body exceeds the allowed size ({limit_bytes} bytes)."
),
None,
),
Self::UnsupportedContentEncoding { encoding } => (
@@ -52,7 +52,11 @@ pub(crate) async fn responses(
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
let decoded = match decode_request_with_headers(&body, &headers) {
let decoded = match decode_request_with_headers(
&body,
&headers,
client.snapshot().responses_max_decompressed_body_bytes(),
) {
Ok(decoded) => decoded,
Err(error) => {
return protocol_error_response(StatusCode::BAD_REQUEST, error.protocol_body());
@@ -631,7 +635,9 @@ impl ResponsesStreamState {
.push_back(Bytes::from(response_failed_sse_event_with_id(
self.encoder.response_id(),
error.client_error_type().unwrap_or(default_type),
error.client_error_code().unwrap_or(default_code),
super::super::error::client_error_code(
error.client_error_code().unwrap_or(default_code),
),
error.client_message(),
)));
self.pending
@@ -327,13 +327,14 @@ impl fmt::Debug for DecodedResponsesRequest {
pub fn decode_request_with_headers(
body: &[u8],
headers: &HeaderMap,
max_decompressed_bytes: usize,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
let body = decompress_request_body(body, headers)?;
let body = decompress_request_body(body, headers, max_decompressed_bytes)?;
decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))
}
/// 下游请求体解压后的最大字节数;防御解压炸弹。
const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024;
/// zstd 回溯窗口独立于输出上限,调整设置不能放大解码器内部窗口分配。
const MAX_ZSTD_WINDOW_LOG: u32 = 26;
/// 按 `Content-Encoding` 解压下游请求体。
///
@@ -342,6 +343,7 @@ const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024;
fn decompress_request_body<'a>(
body: &'a [u8],
headers: &HeaderMap,
max_decompressed_bytes: usize,
) -> Result<Cow<'a, [u8]>, RequestDecodeError> {
let encoding = headers
.get_all(axum::http::header::CONTENT_ENCODING)
@@ -357,20 +359,20 @@ fn decompress_request_body<'a>(
"" | "identity" => Ok(Cow::Borrowed(body)),
"gzip" => {
let decoder = flate2::read::MultiGzDecoder::new(body);
read_bounded(decoder).map(Cow::Owned)
read_bounded(decoder, max_decompressed_bytes).map(Cow::Owned)
}
"deflate" => {
let decoder = flate2::read::ZlibDecoder::new(body);
read_bounded(decoder).map(Cow::Owned)
read_bounded(decoder, max_decompressed_bytes).map(Cow::Owned)
}
"zstd" => {
let mut decoder = zstd::stream::read::Decoder::with_buffer(body)
.map_err(|_| RequestDecodeError::MalformedJson)?;
// 输出有界之外,也限制压缩帧声明的回溯窗口,防止解码器内部过量分配。
decoder
.window_log_max(MAX_DECOMPRESSED_REQUEST_BYTES.ilog2())
.window_log_max(MAX_ZSTD_WINDOW_LOG)
.map_err(|_| RequestDecodeError::MalformedJson)?;
read_bounded(decoder).map(Cow::Owned)
read_bounded(decoder, max_decompressed_bytes).map(Cow::Owned)
}
other => Err(RequestDecodeError::UnsupportedContentEncoding {
encoding: other.to_owned(),
@@ -378,12 +380,15 @@ fn decompress_request_body<'a>(
}
}
fn read_bounded<R: std::io::Read>(mut reader: R) -> Result<Vec<u8>, RequestDecodeError> {
fn read_bounded<R: std::io::Read>(
mut reader: R,
max_decompressed_bytes: usize,
) -> Result<Vec<u8>, RequestDecodeError> {
let mut decoded = Vec::new();
let mut chunk = [0; 8192];
loop {
let remaining = MAX_DECOMPRESSED_REQUEST_BYTES - decoded.len();
let read_limit = chunk.len().min(remaining + 1);
let remaining = max_decompressed_bytes - decoded.len();
let read_limit = chunk.len().min(remaining.saturating_add(1));
let read = reader
.read(&mut chunk[..read_limit])
.map_err(|_| RequestDecodeError::MalformedJson)?;
@@ -391,15 +396,19 @@ fn read_bounded<R: std::io::Read>(mut reader: R) -> Result<Vec<u8>, RequestDecod
return Ok(decoded);
}
if read > remaining {
return Err(RequestDecodeError::DecompressedBodyTooLarge);
return Err(RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: max_decompressed_bytes,
});
}
// 默认 Vec 扩容和 read_to_end 的 EOF 探测可能突破输出上限;
// 容量增长也受相同边界约束,越界探测只使用栈上分块缓冲区。
let required = decoded.len() + read;
if required > decoded.capacity() {
let capacity = (decoded.capacity() * 2)
let capacity = decoded
.capacity()
.saturating_mul(2)
.max(required)
.min(MAX_DECOMPRESSED_REQUEST_BYTES);
.min(max_decompressed_bytes);
decoded.reserve_exact(capacity - decoded.len());
}
decoded.extend_from_slice(&chunk[..read]);
@@ -8,6 +8,7 @@ use gateway_protocol::openai::sse::{
use serde_json::Value;
use super::error::ResponseEncodeError;
use crate::openai::error::capacity_error_for_client;
const OPENAI_PROTOCOL: &str = "openai";
@@ -15,7 +16,7 @@ const OPENAI_PROTOCOL: &str = "openai";
///
/// OpenAI Provider 与 xAI adapter 都必须交付 OpenAI wire。wire 是客户端交付
/// 与终态判断的事实来源;canonical facts 只在 wire 暂未携带 ID 时提供旁路观测,
/// 不得反过来拒绝或改写上游事件。
/// 不得反过来拒绝或改写上游事件;客户端错误兼容投影只发生在编码出口。
#[derive(Debug, Default)]
pub struct OpenAiResponsesEncoder {
response_id: Option<String>,
@@ -43,13 +44,15 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
let projected = client_failure_payload(wire);
let data = projected.as_ref().unwrap_or_else(|| wire.data());
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因。
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`。
if wire.event_type() == Some("error")
if effective_event_type(wire) == Some("error")
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
data,
)
{
return vec![Bytes::from(encode_sse_event_with_metadata(
@@ -59,12 +62,14 @@ impl OpenAiResponsesEncoder {
wire.sse_retry(),
))];
}
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
if projected.is_none()
&& let Some(raw_sse_frame) = wire.raw_sse_frame()
{
return vec![raw_sse_frame.clone()];
}
vec![Bytes::from(encode_sse_event_with_metadata(
wire.event_type().unwrap_or_default(),
&wire.data().to_string(),
effective_event_type(wire).unwrap_or_default(),
&data.to_string(),
wire.sse_id(),
wire.sse_retry(),
))]
@@ -77,22 +82,24 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
let projected = client_failure_payload(wire);
let data = projected.as_ref().unwrap_or_else(|| wire.data());
// WS 客户端只对它无法消费的裸 `error` 帧做投影:codex 的 WS 端点
// 会静默忽略缺少 status 且不含内置可重试错误码的 `error` 帧,客户
// 端只能空等到 idle 超时。投影成 `response.failed`(消息 JSON 自带
// type 字段),codex 将其映射为可重试错误并立即重试。可消费形状
// (带非 2xx status 或特殊错误码)原样透传,保持业务语义。
if wire.event_type() == Some("error")
&& !ws_client_consumable_error(wire.data())
// type 字段),让 codex 按错误码处理。可消费形状(带非 2xx status
// 或特殊错误码)保留 envelope,容量码已由共享客户端投影处理。
if effective_event_type(wire) == Some("error")
&& !ws_client_consumable_error(data)
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
data,
)
{
return vec![data.to_string()];
}
vec![wire.data().to_string()]
vec![data.to_string()]
}
/// 返回是否已经看到客户端可见的 wire 终态。
@@ -142,9 +149,7 @@ impl OpenAiResponsesEncoder {
{
self.response_id = Some(response_id.to_owned());
}
let effective_type = wire
.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str));
let effective_type = effective_event_type(wire);
if matches!(
effective_type,
Some("response.created" | "response.in_progress" | "response.queued")
@@ -172,6 +177,22 @@ fn openai_wire(event: &ProviderEvent) -> Option<&ProtocolWireEvent> {
.filter(|wire| wire.protocol() == OPENAI_PROTOCOL)
}
fn effective_event_type(wire: &ProtocolWireEvent) -> Option<&str> {
wire.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str))
}
fn client_failure_payload(wire: &ProtocolWireEvent) -> Option<Value> {
if matches!(
effective_event_type(wire),
Some("error" | "response.failed")
) {
capacity_error_for_client(wire.data())
} else {
None
}
}
/// 判断 WS 上游的 `error` 帧是否已经是客户端可直接消费的形状。
///
/// codex 的 WS 端点只消费两类 `error` 帧:带非 2xx HTTP status 的包装错误,
@@ -2,11 +2,14 @@
use axum::http::{HeaderName, HeaderValue, StatusCode};
use gateway_core::engine::EngineError;
use gateway_core::error::ProviderErrorKind;
use gateway_core::event::ProviderResponseHeader;
use serde_json::{Map, Value, json};
use thiserror::Error;
use crate::openai::error::{gateway_error_contract, gateway_error_from_engine};
use crate::openai::error::{
capacity_error_for_client, gateway_error_contract, gateway_error_from_engine,
};
use super::super::{
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError,
@@ -152,12 +155,18 @@ pub(super) fn initial_engine_error_event(
headers.remove("x-request-id");
headers.remove("x-oai-request-id");
}
let status = upstream
.map(|response| response.status())
.or_else(|| provider.and_then(|error| error.upstream_status()))
.and_then(|value| StatusCode::from_u16(value).ok())
.filter(|value| !value.is_success() && !value.is_informational())
.unwrap_or(default_status);
let status = if provider
.is_some_and(|error| error.kind() == ProviderErrorKind::UpstreamCapacityUnavailable)
{
StatusCode::SERVICE_UNAVAILABLE
} else {
upstream
.map(|response| response.status())
.or_else(|| provider.and_then(|error| error.upstream_status()))
.and_then(|value| StatusCode::from_u16(value).ok())
.filter(|value| !value.is_success() && !value.is_informational())
.unwrap_or(default_status)
};
// 无原响应关联头时使用 Provider 的失败事实;不能让旧会话 ID 遮住当前失败。
if !has_request_id(&headers)
&& let Some(request_id) = provider.and_then(|error| error.upstream_request_id())
@@ -224,7 +233,10 @@ pub(super) fn error_event(
if !headers.is_empty() {
event.insert("headers".to_owned(), Value::Object(headers));
}
Value::Object(event).to_string()
let event = Value::Object(event);
capacity_error_for_client(&event)
.unwrap_or(event)
.to_string()
}
pub(super) fn connection_limit_event() -> String {
@@ -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
}
@@ -272,3 +272,27 @@ async fn response_json(response: axum::response::Response) -> Value {
.expect("account group response body");
serde_json::from_slice(&body).expect("account group response JSON")
}
#[tokio::test]
async fn disable_fast_group_updates_preserve_omitted_values() {
let fixture = authenticated_fixture().await;
for (value, expected) in [(Some(true), true), (None, true), (Some(false), false)] {
let mut body = json!({"id": PRIMARY_GROUP_ID, "name":"Policy", "description":null,"color":"#F43F5ECC"});
if let Some(value) = value {
body["disableFast"] = json!(value);
}
let response = request(
router(&fixture),
Method::POST,
"/api/admin/account-groups/update",
Some(body),
true,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response_json(response).await["data"]["record"]["disableFast"],
expected
);
}
}
@@ -4,6 +4,21 @@ use serde_json::json;
use super::AdminTestFixture;
#[test]
fn password_change_request_rejects_unknown_fields_and_redacts_both_passwords() {
let request: gateway_api::auth::ChangePasswordRequest = serde_json::from_value(json!({
"currentPassword": "current-password-secret", "newPassword": "new-password-secret"
}))
.unwrap();
assert!(!format!("{request:?}").contains("password-secret"));
assert!(
serde_json::from_value::<gateway_api::auth::ChangePasswordRequest>(json!({
"currentPassword": "old", "newPassword": "new", "username": "another-admin"
}))
.is_err()
);
}
#[test]
fn login_request_should_deny_unknown_fields_and_redact_password_debug() {
let password = "admin-password-must-not-leak";
@@ -10,6 +10,127 @@ use serde_json::json;
use super::{AdminTestFixture, AdminTestState};
#[test]
fn reset_budget_requires_an_explicit_supported_period_and_valid_key() {
use gateway_api::admin::client_keys::ResetClientKeyBudgetRequest;
for period in ["daily", "weekly", "all"] {
let command = serde_json::from_value::<ResetClientKeyBudgetRequest>(
json!({"id":"key_reset", "period":period}),
)
.unwrap()
.into_command()
.unwrap();
assert_eq!(command.id.as_str(), "key_reset");
}
for payload in [
json!({"id":"key_reset"}),
json!({"id":"key_reset", "period":"monthly"}),
json!({"id":"key_reset", "period":"all", "amount":0}),
] {
assert!(serde_json::from_value::<ResetClientKeyBudgetRequest>(payload).is_err());
}
assert!(
serde_json::from_value::<ResetClientKeyBudgetRequest>(json!({"id":" ", "period":"all"}))
.unwrap()
.into_command()
.is_err()
);
}
#[tokio::test]
async fn reset_budget_route_requires_admin_and_maps_missing_keys() {
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use tower::ServiceExt as _;
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (cookie, expected) in [
("", StatusCode::UNAUTHORIZED),
("cpr_session=valid-session", StatusCode::NOT_FOUND),
] {
let response = client_keys::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/client-keys/reset-budget")
.header(header::COOKIE, cookie)
.header(header::CONTENT_TYPE, "application/json")
.header("x-request-id", "req_reset")
.body(Body::from(r#"{"id":"missing","period":"all"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), expected);
}
}
#[tokio::test]
async fn budget_reset_allows_admin_but_rejects_key_sessions_without_changing_usage() {
use crate::support::{RAW_KEY, json_request, key_fixture, response_json};
use axum::http::{Method, StatusCode, header};
use gateway_core::policy::ClientApiKeyId;
use tower::ServiceExt as _;
let fixture = key_fixture().await;
fixture.auth.insert_session("valid-admin");
let mut record = fixture
.services
.client_keys()
.reveal(&ClientApiKeyId::new("key-42").unwrap())
.await
.unwrap()
.record;
record.budget.daily_used_usd = "1.25".parse().unwrap();
record.budget.weekly_used_usd = "4.5".parse().unwrap();
*fixture.client_key.lock().unwrap() = Some(record);
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let login = app
.clone()
.oneshot(json_request(
Method::POST,
"/api/auth/login",
json!({"mode":"key", "apiKey":RAW_KEY}),
))
.await
.unwrap();
assert_eq!(login.status(), StatusCode::OK);
let key_cookie = login.headers()[header::SET_COOKIE]
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_owned();
for (cookie, expected, used) in [
(key_cookie.as_str(), StatusCode::FORBIDDEN, "1.25"),
("cpr_session=valid-admin", StatusCode::OK, "0"),
] {
let mut request = json_request(
Method::POST,
"/api/admin/client-keys/reset-budget",
json!({"id":"key-42", "period":"daily"}),
);
request
.headers_mut()
.insert(header::COOKIE, cookie.parse().unwrap());
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), expected);
if expected == StatusCode::OK {
assert_eq!(
response_json(response).await["data"],
json!({"id":"key-42"})
);
}
let key = fixture.client_key.lock().unwrap().clone().unwrap();
assert_eq!(key.budget.daily_used_usd.canonical(), used);
assert_eq!(key.budget.weekly_used_usd.canonical(), "4.5");
}
}
#[test]
fn custom_client_keys_preserve_migrated_values_and_redact_debug() {
for value in [
@@ -311,6 +432,8 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
.single()
.expect("valid time");
let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord {
openai_client_profile_override: None,
xai_client_profile_override: None,
budget: Default::default(),
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
name: "visible".to_owned(),
@@ -446,3 +569,38 @@ async fn list_route_should_accept_the_full_nonzero_u16_page_size() {
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn profile_override_distinguishes_omission_from_explicit_inheritance() {
let mut payload = json!({"id":"key_profile", "name":"profile", "groupIds":[], "maxConcurrency":0, "requestsPerMinute":0});
let decode = |body| {
serde_json::from_value::<UpdateClientKeyRequest>(body)
.unwrap()
.into_command()
.unwrap()
.openai_client_profile_override
};
assert_eq!(decode(payload.clone()), None);
payload["openaiClientProfileOverride"] = json!(null);
assert_eq!(decode(payload.clone()), Some(None));
payload["openaiClientProfileOverride"] =
json!({"client":"cli", "platform":"linux", "versionMode":"latest"});
assert!(decode(payload).unwrap().is_some());
}
#[test]
fn xai_profile_override_distinguishes_omission_from_explicit_inheritance() {
let mut payload = json!({"id":"key_profile", "name":"profile", "groupIds":[], "maxConcurrency":0, "requestsPerMinute":0});
let decode = |body| {
serde_json::from_value::<UpdateClientKeyRequest>(body)
.unwrap()
.into_command()
.unwrap()
.xai_client_profile_override
};
assert_eq!(decode(payload.clone()), None);
payload["xaiClientProfileOverride"] = json!(null);
assert_eq!(decode(payload.clone()), Some(None));
payload["xaiClientProfileOverride"] = json!({"versionMode":"latest"});
assert!(decode(payload).unwrap().is_some());
}
@@ -176,6 +176,7 @@ impl AdminTestFixture {
ClientConfig::default(),
stores,
gateway_admin::AdminRuntimePorts {
pricing_source: Arc::new(StaticPricingSource),
providers,
snapshot: Arc::new(NoopSnapshot),
account_probe: Arc::new(NoopProbe),
@@ -270,6 +271,20 @@ impl MemoryAuthStore {
session_id.to_owned(),
AuthSession {
subject: gateway_admin::model::auth::SessionSubject::Admin {
credential_fingerprint: {
use base64::Engine as _;
use sha2::Digest as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
sha2::Sha256::digest(
self.password_hash
.lock()
.unwrap()
.as_ref()
.unwrap()
.as_bytes(),
),
)
},
admin_user_id: "admin_1".to_owned(),
},
expires_at: Utc::now() + Duration::hours(1),
@@ -300,6 +315,28 @@ impl AuthStore for MemoryAuthStore {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn change_password(
&self,
_: &str,
expected_hash: &str,
password_hash: &str,
audit: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
if self.fail_audit.load(Ordering::SeqCst) {
return Err(unavailable("audit"));
}
let mut stored = self.password_hash.lock().unwrap();
let Some(credentials) = stored
.as_mut()
.filter(|value| value.as_str() == expected_hash)
else {
return Ok(false);
};
*credentials = password_hash.to_owned();
self.audits.lock().unwrap().push(audit);
Ok(true)
}
async fn create_password_hash_if_absent(
&self,
_: &str,
@@ -368,14 +405,31 @@ impl AuthStore for MemoryAuthStore {
}
pub(super) struct MemorySettingsStore {
pricing: Mutex<gateway_admin::model::pricing::StoredPricing>,
settings: Mutex<RuntimeSettings>,
api_key: Arc<Mutex<Option<AdminApiKey>>>,
}
struct StaticPricingSource;
#[async_trait]
impl gateway_admin::ports::pricing::PricingSource for StaticPricingSource {
async fn fetch(
&self,
) -> Result<gateway_admin::model::pricing::PricingSyncPreview, gateway_admin::model::AdminError>
{
Ok(gateway_admin::model::pricing::PricingSyncPreview {
prices: serde_json::from_value(serde_json::json!({"openai":{"gpt-5.4":{
"multiplierBps":10000,"bands":{"standard":{"input":"2.5","output":"15","cacheRead":"0.25","cacheWrite":"0"}}
}}})).expect("prices"), skipped: vec![],
})
}
}
impl MemorySettingsStore {
fn new(api_key: Arc<Mutex<Option<AdminApiKey>>>) -> Self {
Self {
settings: Mutex::new(test_runtime_settings()),
pricing: Mutex::default(),
api_key,
}
}
@@ -387,6 +441,70 @@ impl MemorySettingsStore {
#[async_trait]
impl SettingsStore for MemorySettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(self.pricing.lock().expect("pricing").clone())
}
async fn sync_pricing(
&self,
changes: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
let mut pricing = self.pricing.lock().expect("pricing");
for (provider, models) in changes {
let stored = pricing.synced.entry(provider).or_default();
for (model, price) in models {
if let Some(price) = price {
stored.insert(model, price);
} else {
stored.remove(&model);
}
}
}
pricing.synced.retain(|_, models| !models.is_empty());
pricing.synced_at = Some(Utc::now());
let mut settings = self.settings.lock().expect("settings");
settings.config_revision = next_revision(settings.config_revision);
Ok(settings.config_revision)
}
async fn update_pricing(
&self,
command: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
use gateway_admin::model::pricing::PricingChange;
let mut pricing = self.pricing.lock().expect("pricing");
let gateway_admin::model::pricing::StoredPricing {
overrides, synced, ..
} = &mut *pricing;
let models = overrides.entry(command.provider.clone()).or_default();
let source = synced.entry(command.provider).or_default();
for model in command.models {
match &command.change {
PricingChange::Reset => {
models.remove(&model);
}
PricingChange::Delete => {
models.remove(&model);
source.remove(&model);
}
PricingChange::Replace(p) => {
models.insert(model, p.clone());
}
PricingChange::Multiplier(bps) => {
models
.entry(model)
.or_insert_with(|| gateway_core::metering::ModelPriceOverride {
multiplier_bps: 10_000,
bands: Default::default(),
})
.multiplier_bps = *bps;
}
}
}
let mut settings = self.settings.lock().expect("settings");
settings.config_revision = next_revision(settings.config_revision);
Ok(settings.config_revision)
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(self.settings.lock().expect("settings").clone())
}
@@ -402,6 +520,8 @@ impl SettingsStore for MemorySettingsStore {
) -> AdminStoreResult<RuntimeSettings> {
let mut settings = self.settings.lock().expect("settings");
let updated = RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: command.request_location_enabled,
request_location: command.request_location,
config_revision: next_revision(settings.config_revision),
@@ -413,6 +533,7 @@ impl SettingsStore for MemorySettingsStore {
max_waiting_per_key: command.max_waiting_per_key,
max_waiting_per_account: command.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: command.responses_max_decompressed_body_bytes,
rotation_strategy: command.rotation_strategy,
min_codex_desktop_version: command.min_codex_desktop_version,
min_codex_cli_version: command.min_codex_cli_version,
@@ -491,6 +612,7 @@ impl MemoryAccountGroupStore {
(
primary_id.clone(),
AccountGroupRecord {
disable_fast: false,
id: primary_id,
name: "Alpha routing".to_owned(),
description: Some("Primary traffic".to_owned()),
@@ -512,6 +634,7 @@ impl MemoryAccountGroupStore {
(
secondary_id.clone(),
AccountGroupRecord {
disable_fast: false,
id: secondary_id,
name: "Beta routing".to_owned(),
description: None,
@@ -627,6 +750,7 @@ impl AccountGroupStore for MemoryAccountGroupStore {
let mut state = self.state.lock().expect("account groups");
let now = Utc::now();
let record = AccountGroupRecord {
disable_fast: command.disable_fast,
id: command.id.clone(),
name: command.name,
description: command.description,
@@ -658,6 +782,9 @@ impl AccountGroupStore for MemoryAccountGroupStore {
record.name = command.name;
record.description = command.description;
record.color = command.color;
if let Some(disable_fast) = command.disable_fast {
record.disable_fast = disable_fast;
}
record.updated_at = Utc::now();
mutation(&mut state, command.id, true)
}
@@ -709,6 +836,34 @@ fn mutation(
#[async_trait]
impl ClientKeyStore for MemoryClientKeyStore {
async fn reset_client_key_budget(
&self,
command: gateway_admin::model::client_keys::ResetClientKeyBudget,
_: &MutationContext,
) -> AdminStoreResult<()> {
use gateway_admin::model::client_keys::ClientKeyBudgetPeriod;
let mut record = self.0.lock().unwrap();
let record = record
.as_mut()
.filter(|record| record.id == command.id)
.ok_or_else(|| {
AdminStoreError::new(AdminStoreErrorKind::NotFound, "client key", "missing key")
})?;
if matches!(
command.period,
ClientKeyBudgetPeriod::Daily | ClientKeyBudgetPeriod::All
) {
record.budget.daily_used_usd = gateway_core::metering::Decimal::ZERO;
}
if matches!(
command.period,
ClientKeyBudgetPeriod::Weekly | ClientKeyBudgetPeriod::All
) {
record.budget.weekly_used_usd = gateway_core::metering::Decimal::ZERO;
}
Ok(())
}
async fn get_client_key(
&self,
id: &ClientApiKeyId,
@@ -734,9 +889,15 @@ impl ClientKeyStore for MemoryClientKeyStore {
&self,
id: &ClientApiKeyId,
) -> AdminStoreResult<Option<ClientKeySecret>> {
if let Some(record) = self.0.lock().expect("client key").clone() {
return Ok((record.id == *id)
.then(|| ClientKeySecret::new(record, format!("sk_{}", "a".repeat(43)))));
}
let now = Utc::now();
Ok(Some(ClientKeySecret::new(
ClientKeyRecord {
openai_client_profile_override: None,
xai_client_profile_override: None,
budget: Default::default(),
id: id.clone(),
name: "revealed".to_owned(),
@@ -1105,6 +1266,15 @@ impl UnusedProvider {
#[async_trait]
impl ProviderAdmin for UnusedProvider {
fn pricing_catalog(&self) -> gateway_admin::model::pricing::ProviderPricingCatalog {
serde_json::from_value(serde_json::json!({
"builtin-model": {
"multiplierBps": 10000,
"bands": {"standard": {"input": "1", "output": "2", "cacheRead": "0", "cacheWrite": "1"}}
}
})).unwrap()
}
fn provider_kind(&self) -> &ProviderKind {
&self.kind
}
@@ -1270,6 +1440,8 @@ fn test_runtime_settings() -> RuntimeSettings {
),
]);
RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
@@ -1281,6 +1453,7 @@ fn test_runtime_settings() -> RuntimeSettings {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -169,6 +169,8 @@ fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() {
#[test]
fn billing_view_should_preserve_the_original_detail_contract() {
let value = serde_json::to_value(BillingView {
long_context_billing_applied: false,
image: None,
input_amount_display: "$0.03".to_owned(),
output_amount_display: "$0.00".to_owned(),
cache_read_amount_display: "$0.14".to_owned(),
@@ -630,6 +632,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
.lock()
.expect("ops errors")
.push(OpsError {
client_api_key_name: Some("Production".to_owned()),
source: "model_request".to_owned(),
event_id: "err_snapshot".to_owned(),
request_id: Some("req_err".to_owned()),
@@ -700,6 +703,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
.await
.expect("ops errors body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("ops errors JSON");
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
assert_eq!(
serde_json::json!({
"provider": value["data"]["items"][0]["provider"],
@@ -840,6 +844,7 @@ fn usage_record_with_account(
upstream_transport: Some("http_sse".to_owned()),
http_version: Some("h2".to_owned()),
websocket_pool: None,
upstream_response_model: None,
service_tier: None,
provider_metadata_json: None,
attempt_count: 1,
@@ -952,6 +957,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
.lock()
.expect("usage records")
.push(UsageListRecord {
client_api_key_name: Some("Production".to_owned()),
id: "request_endpoint".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "websocket".to_owned(),
@@ -960,9 +966,11 @@ 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()),
upstream_response_model: Some("grok-4.6-build".to_owned()),
service_tier: Some("default".to_owned()),
input_tokens: Some(1),
output_tokens: Some(1),
@@ -977,6 +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"),
@@ -1046,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"
@@ -1075,6 +1096,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
"imageOutputTokens": value["data"]["items"][0]["tokenDetails"]["imageOutputTokens"],
"requestedModel": value["data"]["items"][0]["requestedModel"],
"upstreamModel": value["data"]["items"][0]["upstreamModel"],
"upstreamResponseModel": value["data"]["items"][0]["upstreamResponseModel"],
"reasoningEffort": value["data"]["items"][0]["reasoningEffort"],
"reasoningPreset": value["data"]["items"][0]["reasoningPreset"],
"subagentKind": value["data"]["items"][0]["subagentKind"],
@@ -1098,6 +1120,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
"imageOutputTokens": 9,
"requestedModel": "grok-4.5",
"upstreamModel": "grok-4.5",
"upstreamResponseModel": "grok-4.6-build",
"reasoningEffort": "max",
"reasoningPreset": "ultra",
"subagentKind": "worker",
@@ -237,6 +237,8 @@ impl ProxyProbe for SuccessfulProbe {
success: true,
latency_ms: 15,
exit_ip: Some("203.0.113.2".parse().unwrap()),
exit_ipv4: Some("203.0.113.2".parse().unwrap()),
exit_ipv6: None,
message: "Connected".to_owned(),
}
}
@@ -390,7 +392,14 @@ async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records
assert_eq!(status, StatusCode::OK);
assert_eq!(
probed["data"],
json!({"success": true, "latencyMs": 15, "exitIp": "203.0.113.2", "message": "Connected"})
json!({
"success": true,
"latencyMs": 15,
"exitIp": "203.0.113.2",
"exitIpv4": "203.0.113.2",
"exitIpv6": null,
"message": "Connected"
})
);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["page"]["total"], 0);
@@ -50,6 +50,7 @@ fn update_body() -> Value {
"maxWaitingPerKey": 0,
"maxWaitingPerAccount": 0,
"concurrencyWaitTimeoutSeconds": 30,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
@@ -101,6 +102,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
use gateway_core::routing::{PublicModelId, UpstreamModelId};
let settings = RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
@@ -121,6 +124,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::RoundRobin,
min_codex_desktop_version: Some("26.825.6671".to_owned()),
min_codex_cli_version: Some("0.40.0".to_owned()),
@@ -144,7 +148,9 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
assert_eq!(
value,
json!({
"requestLocationEnabled": false,
"openaiClientProfile": null,
"xaiClientProfile": null,
"requestLocationEnabled": false,
"requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"},
"modelMappings": {
"gpt-5.4": "gpt-5.5",
@@ -157,6 +163,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"maxWaitingPerKey": 0,
"maxWaitingPerAccount": 0,
"concurrencyWaitTimeoutSeconds": 30,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
@@ -196,6 +203,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
.cloned()
.collect();
let settings = RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
@@ -217,6 +226,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
.expect("fixture rotation strategy"),
min_codex_desktop_version: request.min_codex_desktop_version,
@@ -243,6 +253,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
.cloned()
.collect();
let mut expected_fields = request_fields;
expected_fields.insert("openaiClientProfile".to_owned());
expected_fields.insert("xaiClientProfile".to_owned());
expected_fields.insert("updatedAt".to_owned());
assert_eq!(response_fields, expected_fields);
@@ -575,3 +587,464 @@ async fn request_location_should_reject_invalid_or_missing_fields_without_replac
original
);
}
#[test]
fn decompression_setting_should_reject_invalid_values() {
let field = "responsesMaxDecompressedBodyBytes";
for invalid in [
json!(0),
json!(-1),
json!(1.5),
json!(u64::MAX),
Value::Null,
] {
let mut body = update_body();
body[field] = invalid;
if let Ok(request) = serde_json::from_value::<UpdateRuntimeSettingsRequest>(body) {
assert_eq!(request.validate().unwrap_err().field(), field);
}
}
let mut body = update_body();
body.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
}
#[tokio::test]
async fn settings_reject_removed_global_fast_policy() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
let initial = fixture.settings.settings.lock().unwrap().clone();
for value in [json!(true), json!(false), Value::Null] {
let mut body = update_body();
body["disableFast"] = value;
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(*fixture.settings.settings.lock().unwrap(), initial);
}
let response = app
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response_json(response).await["data"]
.get("disableFast")
.is_none()
);
}
#[test]
fn global_profile_can_be_omitted_but_cannot_be_cleared() {
for field in ["openaiClientProfile", "xaiClientProfile"] {
let mut body = update_body();
body.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_ok());
body[field] = json!(null);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_err());
body[field] = json!({"versionMode":"latest"});
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_ok());
}
}
fn custom_pricing() -> Value {
json!({"multiplierBps":12500,"bands":{"standard":{"input":"3","output":"12","cacheRead":"0","cacheWrite":"0"}}})
}
#[tokio::test]
async fn pricing_routes_keep_manual_overrides_during_sync_and_reset_to_source() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
let response = app.clone().oneshot(request(Method::POST, "/api/admin/settings/pricing/update", Some(json!({
"provider":"openai", "models":["gpt-5.4"], "change":{"action":"replace", "pricing":custom_pricing()}
})))).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let preview = response_json(
app.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
))
.await
.unwrap(),
)
.await;
let approved = preview["data"].clone();
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview": approved, "models": {"openai": ["gpt-5.4"]}})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let result = response_json(
app.clone()
.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
assert_eq!(
result["data"]["overrides"]["openai"]["gpt-5.4"],
custom_pricing()
);
assert_eq!(
result["data"]["synced"]["openai"]["gpt-5.4"]["bands"]["standard"]["input"],
"2.5"
);
assert!(result["data"]["syncedAt"].is_string());
for _ in 0..2 {
let response = app.clone().oneshot(request(Method::POST, "/api/admin/settings/pricing/update", Some(json!({
"provider":"openai", "models":["gpt-5.4"], "change":{"action":"multiplier", "multiplierBps":20000}
})))).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
let result = response_json(
app.clone()
.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
assert_eq!(
result["data"]["overrides"]["openai"]["gpt-5.4"]["multiplierBps"],
20000
);
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(json!({
"provider":"openai", "models":["gpt-5.4"], "change":{"action":"reset"}
})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let result = response_json(
app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
assert!(result["data"]["overrides"]["openai"]["gpt-5.4"].is_null());
assert!(result["data"]["synced"]["openai"]["gpt-5.4"].is_object());
}
#[tokio::test]
async fn pricing_delete_rejects_builtin_models_even_with_overrides_without_partial_writes() {
for overridden in [false, true] {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
{
let mut stored = fixture.settings.pricing.lock().unwrap();
stored.synced = serde_json::from_value(json!({
"openai": {"builtin-model": custom_pricing(), "custom-model": custom_pricing()}
}))
.unwrap();
if overridden {
stored.overrides = stored.synced.clone();
}
}
let before = fixture.settings.pricing.lock().unwrap().clone();
let revision = fixture.settings.settings.lock().unwrap().config_revision;
for models in [
json!(["builtin-model"]),
json!(["custom-model", "builtin-model"]),
] {
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(
json!({"provider":"openai", "models":models, "change":{"action":"delete"}}),
),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(*fixture.settings.pricing.lock().unwrap(), before);
assert_eq!(
fixture.settings.settings.lock().unwrap().config_revision,
revision
);
}
}
}
#[tokio::test]
async fn pricing_delete_removes_selected_nonbuiltin_models_from_both_layers() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
{
let mut stored = fixture.settings.pricing.lock().unwrap();
stored.synced = serde_json::from_value(json!({
"openai": {"shared":custom_pricing(), "synced-only":custom_pricing(), "untouched":custom_pricing()},
"xai": {"shared":custom_pricing()}
})).unwrap();
stored.overrides = serde_json::from_value(json!({
"openai": {"shared":custom_pricing(), "custom-only":custom_pricing(), "untouched":custom_pricing()},
"xai": {"shared":custom_pricing()}
})).unwrap();
}
for _ in 0..2 {
let response = app.clone().oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(json!({
"provider":"openai", "models":["shared", "synced-only", "custom-only"], "change":{"action":"delete"}
})),
)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
let result = response_json(
app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
let expected =
json!({"openai":{"untouched":custom_pricing()},"xai":{"shared":custom_pricing()}});
assert_eq!(result["data"]["overrides"], expected);
assert_eq!(result["data"]["synced"], expected);
assert!(result["data"]["defaults"]["openai"]["builtin-model"].is_object());
}
#[tokio::test]
async fn pricing_rejects_invalid_edits_and_tampered_sync_without_writes() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
for body in [
json!({"provider":"unknown","models":["model"],"change":{"action":"reset"}}),
json!({"provider":"openai","models":[],"change":{"action":"reset"}}),
json!({"provider":"openai","models":["bad model"],"change":{"action":"reset"}}),
json!({"provider":"openai","models":["model"],"change":{"action":"multiplier","multiplierBps":1_000_001}}),
json!({"provider":"openai","models":["model"],"change":{"action":"replace","pricing":{"multiplierBps":10000,"bands":{}}}}),
] {
let scenario = body.to_string();
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{scenario}");
}
// JSON 合同错误沿用 AdminJson 的 422,业务校验错误为 400。
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/update",
Some(json!({
"provider":"openai","models":["model"],"change":{"action":"reset","extra":true}
})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview":{"prices":{},"skipped":[]},"models":{"openai":["gpt-5.4"]}})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let result = response_json(
app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
.await
.unwrap(),
)
.await;
assert_eq!(result["data"]["overrides"], json!({}));
assert_eq!(result["data"]["synced"], json!({}));
}
#[tokio::test]
async fn pricing_sync_only_updates_selected_provider_models() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let old = custom_pricing();
{
let mut pricing = fixture.settings.pricing.lock().unwrap();
pricing.synced = serde_json::from_value(json!({
"openai": {"gpt-5.4": old, "untouched": old},
"xai": {"gpt-5.4": old}
}))
.unwrap();
pricing.overrides = serde_json::from_value(json!({"openai": {"gpt-5.4": old}})).unwrap();
}
let app = app(fixture.state());
let preview = response_json(
app.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
))
.await
.unwrap(),
)
.await;
let response = app
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview": preview["data"], "models": {"openai": ["gpt-5.4"]}})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let pricing = fixture.settings.pricing.lock().unwrap();
assert_eq!(
serde_json::to_value(&pricing.synced).unwrap(),
json!({
"openai": {"gpt-5.4": preview["data"]["prices"]["openai"]["gpt-5.4"], "untouched": old},
"xai": {"gpt-5.4": old}
})
);
assert_eq!(
serde_json::to_value(&pricing.overrides).unwrap(),
json!({"openai": {"gpt-5.4": old}})
);
}
#[tokio::test]
async fn pricing_sync_removes_only_selected_retired_source_prices() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
{
let mut pricing = fixture.settings.pricing.lock().unwrap();
pricing.synced = serde_json::from_value(json!({
"openai": {"retired": custom_pricing(), "untouched": custom_pricing()}
}))
.unwrap();
}
let app = app(fixture.state());
let preview = response_json(
app.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
))
.await
.unwrap(),
)
.await;
let response = app
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview": preview["data"], "models": {"openai": ["retired"]}})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let pricing = fixture.settings.pricing.lock().unwrap();
assert_eq!(
serde_json::to_value(&pricing.synced).unwrap(),
json!({
"openai": {"untouched": custom_pricing()}
})
);
}
#[tokio::test]
async fn pricing_sync_rejects_empty_unknown_or_oversized_selections_without_writes() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
let preview = response_json(
app.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
))
.await
.unwrap(),
)
.await;
for models in [
json!({}),
json!({"openai": []}),
json!({"unknown": ["gpt-5.4"]}),
json!({"openai": ["gpt-5.4", "missing"]}),
json!({"openai": ["gpt-5.4"], "xai": []}),
json!({"openai": (0..10_001).map(|index| format!("model-{index}")).collect::<Vec<_>>()}),
] {
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview": preview["data"], "models": models})),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
let pricing = fixture.settings.pricing.lock().unwrap();
assert_eq!(
*pricing,
gateway_admin::model::pricing::StoredPricing::default()
);
}
#[tokio::test]
async fn pricing_endpoints_require_administrator_authentication() {
let fixture = AdminTestFixture::new().await;
let app = app(fixture.state());
for (method, path, body) in [
(Method::GET, "/api/admin/settings/pricing", None),
(
Method::POST,
"/api/admin/settings/pricing/update",
Some(json!({"provider":"openai","models":["model"],"change":{"action":"reset"}})),
),
(
Method::POST,
"/api/admin/settings/pricing/sync/preview",
None,
),
(
Method::POST,
"/api/admin/settings/pricing/sync",
Some(json!({"preview":{"prices":{},"skipped":[]},"models":{"openai":["gpt-5.4"]}})),
),
] {
assert_eq!(
app.clone()
.oneshot(request(method, path, body))
.await
.unwrap()
.status(),
StatusCode::UNAUTHORIZED
);
}
}
@@ -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,9 +100,11 @@ 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()),
upstream_response_model: Some("private-sentinel".to_owned()),
service_tier: Some("private-sentinel".to_owned()),
input_tokens: Some(1000),
output_tokens: Some(100),
@@ -144,6 +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()
@@ -180,3 +180,121 @@ fn cli_user_agent_should_expose_semver_or_recognized_missing_version() {
.is_none()
);
}
#[test]
fn chatgpt_remote_desktop_without_app_version_should_not_use_a_version_gate() {
for name in [
"codex_chatgpt_android_remote",
"codex_chatgpt_ios_remote",
"codex_chatgpt_future_os_remote",
] {
for remote_version in ["dev", "1.2.3"] {
for (product, originator) in [
("Codex Desktop", None),
("codex_cli_rs", Some("Codex Desktop")),
] {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_str(&format!(
"{product}/0.154.0-alpha.6.2 (Windows 10.0.26200; x86_64) unknown ({name}; {remote_version})"
))
.unwrap(),
);
if let Some(originator) = originator {
headers.insert("originator", HeaderValue::from_static(originator));
}
assert_eq!(identify_codex_client(&headers), None, "{headers:?}");
}
}
}
}
#[test]
fn chatgpt_remote_marker_should_require_a_complete_exact_client_suffix() {
for suffix in [
"(codex_chatgpt_android_remote_extra; dev)",
"(unofficial_codex_chatgpt_ios_remote; dev)",
"(codex_chatgpt__remote; dev)",
"(codex_chatgpt_remote; dev)",
"(codex_chatgpt_future os_remote; dev)",
"(codex_chatgpt_future;os_remote; dev)",
"codex_chatgpt_android_remote; dev",
"(codex_chatgpt_android_remote; dev",
"(codex_chatgpt_android_remote; dev) trailing",
"(codex_chatgpt_android_remote; dev) (another_client; dev)",
"(codex_chatgpt_android_remote; )",
"(another_client; codex_chatgpt_android_remote)",
"(Codex Desktop; dev)",
"(Codex Desktop; ) (codex_chatgpt_android_remote; dev)",
"(Codex Desktop; invalid) (codex_chatgpt_ios_remote; dev)",
"(Codex Desktop)",
] {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_str(&format!("Codex Desktop/0.154.0-alpha.6.2 {suffix}")).unwrap(),
);
let client = identify_codex_client(&headers).expect("Desktop still requires a version");
assert_eq!(client.kind(), CodexClientKind::Desktop, "{suffix}");
assert!(client.version().is_none(), "{suffix}");
}
}
#[test]
fn chatgpt_remote_desktop_should_preserve_explicit_version_validation() {
for (value, expected) in [
(
HeaderValue::from_static("26.908.70816"),
Some("26.908.70816"),
),
(HeaderValue::from_static("26.1.0"), Some("26.1.0")),
(HeaderValue::from_static("dev"), None),
(HeaderValue::from_static(""), None),
(HeaderValue::from_bytes(&[0xff]).unwrap(), None),
] {
let mut headers = HeaderMap::new();
headers.insert("version", value);
headers.insert(
"user-agent",
HeaderValue::from_static(
"Codex Desktop/0.154.0-alpha.6.2 (codex_chatgpt_android_remote; dev)",
),
);
let client = identify_codex_client(&headers).expect("explicit Desktop version");
assert_eq!(client.kind(), CodexClientKind::Desktop);
assert_eq!(
client.version().map(ToString::to_string).as_deref(),
expected
);
}
}
#[test]
fn chatgpt_remote_marker_should_not_override_an_existing_desktop_version_suffix() {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_static(
"Codex Desktop/0.154.0-alpha.6.2 (Codex Desktop; 26.1.0) (codex_chatgpt_ios_remote; dev)",
),
);
let client = identify_codex_client(&headers).expect("Desktop version remains available");
assert_eq!(client.version().unwrap().to_string(), "26.1.0");
}
#[test]
fn chatgpt_remote_suffix_created_by_header_truncation_should_not_skip_the_gate() {
let suffix = " (codex_chatgpt_android_remote; dev)";
let mut user_agent = "Codex Desktop/0.154.0-alpha.6.2 ".to_owned();
user_agent.push_str(&"x".repeat(4096 - user_agent.len() - suffix.len()));
user_agent.push_str(suffix);
user_agent.push_str(" (another_client; dev)");
let mut headers = HeaderMap::new();
headers.insert("user-agent", HeaderValue::from_str(&user_agent).unwrap());
let client =
identify_codex_client(&headers).expect("incomplete headers still require a version");
assert_eq!(client.kind(), CodexClientKind::Desktop);
assert!(client.version().is_none());
}
@@ -7,10 +7,127 @@ use gateway_core::error::{
use gateway_core::upstream::UpstreamSendState;
use gateway_api::openai::error::{
gateway_error_contract, gateway_error_from_engine, gateway_error_response,
openai_error_response,
engine_error_response, gateway_error_contract, gateway_error_from_engine,
gateway_error_response, openai_error_response,
};
#[tokio::test]
async fn capacity_errors_offer_client_retry_without_changing_upstream_facts() {
use bytes::Bytes;
use gateway_core::error::ClientVisibleUpstreamResponse;
use gateway_core::event::ProviderResponseHeader;
use gateway_core::upstream::OpaqueUpstreamValue;
use serde_json::{Value, json};
for code in ["server_is_overloaded", "slow_down"] {
for status in [400, 429, 503] {
for raw_response in [false, true] {
let original = json!({"error": {
"code": code, "type": "service_unavailable_error", "message": "busy",
"param": "model", "future": {"keep": true}
}});
let mut provider = ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_status(status)
.with_upstream_code(OpaqueUpstreamValue::new(code.to_owned()))
.with_client_visible_upstream_error(
ClientVisibleUpstreamError::new(
"busy",
Some(code.to_owned()),
Some("service_unavailable_error".to_owned()),
),
);
if raw_response {
provider = provider.with_client_visible_upstream_response(
ClientVisibleUpstreamResponse::new(
status,
Some(b"application/json".to_vec()),
Bytes::from(original.to_string()),
)
.with_headers(vec![
ProviderResponseHeader::new(
"x-request-id",
Bytes::from_static(b"req_capacity"),
),
ProviderResponseHeader::new("retry-after", Bytes::from_static(b"7")),
]),
);
}
let error = EngineError::Provider(provider);
let response = engine_error_response(&error);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
if raw_response {
assert_eq!(response.headers()["x-request-id"], "req_capacity");
assert_eq!(response.headers()["retry-after"], "7");
}
let body: Value = serde_json::from_slice(
&to_bytes(response.into_body(), 4096)
.await
.expect("response body"),
)
.expect("JSON");
assert_eq!(body["error"]["code"], "server_error");
assert_eq!(body["error"]["message"], "busy");
assert_eq!(body["error"]["type"], "service_unavailable_error");
if raw_response {
let mut expected = original.clone();
expected["error"]["code"] = json!("server_error");
assert_eq!(body, expected);
}
let EngineError::Provider(provider) = error else {
unreachable!()
};
assert_eq!(provider.upstream_status(), Some(status));
assert_eq!(
provider.upstream_code().map(OpaqueUpstreamValue::as_str),
Some(code)
);
assert_eq!(
provider
.client_visible_upstream_error()
.expect("detail")
.code(),
Some(code)
);
if let Some(raw) = provider.client_visible_upstream_response() {
assert_eq!(
serde_json::from_slice::<Value>(raw.body()).expect("original JSON"),
original
);
}
}
}
}
}
#[tokio::test]
async fn classified_capacity_error_without_special_code_returns_retryable_http_status() {
use bytes::Bytes;
use gateway_core::error::ClientVisibleUpstreamResponse;
let body = Bytes::from_static(br#"{"error":{"code":null,"type":"server_error","message":"Selected model is at capacity. Please try a different model."}}"#);
let error = EngineError::Provider(
ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_status(400)
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
400,
Some(b"application/json".to_vec()),
body.clone(),
)),
);
let response = engine_error_response(&error);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
to_bytes(response.into_body(), 4096).await.expect("body"),
body
);
}
#[tokio::test]
async fn key_budget_errors_preserve_limit_code_and_retry_after() {
for code in ["key_daily_budget_exceeded", "key_weekly_budget_exceeded"] {
@@ -329,6 +446,27 @@ fn engine_provider_quota_exhaustion_should_use_the_retryable_capacity_contract()
);
}
#[tokio::test]
async fn locally_exhausted_account_pool_returns_official_usage_limit_contract() {
let error = EngineError::Provider(
ProviderError::new(
ProviderErrorKind::QuotaExhausted,
UpstreamSendState::NotSent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"All eligible accounts have exhausted their quota.",
Some("usage_limit_reached".to_owned()),
Some("usage_limit_reached".to_owned()),
)),
);
let response = engine_error_response(&error);
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap();
assert_eq!(body["error"]["type"], "usage_limit_reached");
assert_eq!(body["error"]["code"], "usage_limit_reached");
}
#[test]
fn engine_provider_timeout_and_cancellation_should_remain_distinct() {
let timeout = EngineError::Provider(ProviderError::new(
@@ -615,3 +753,33 @@ mod model_routing {
);
}
}
#[tokio::test]
async fn quota_recovery_http_response_uses_projection_without_replacing_upstream_facts() {
use bytes::Bytes;
use gateway_core::error::{ClientVisibleUpstreamResponse, OpaqueUpstreamValue};
let body = Bytes::from_static(br#"{"error":{"code":"previous_response_not_found","type":"invalid_request_error","message":"Previous response was not found. Retrying the full request."}}"#);
let provider = ProviderError::new(ProviderErrorKind::QuotaExhausted, UpstreamSendState::Sent)
.with_status(429)
.with_upstream_code(OpaqueUpstreamValue::new("usage_limit_reached"))
.with_retry_after(std::time::Duration::from_secs(129_600))
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
400,
Some(b"application/json".to_vec()),
body.clone(),
));
let error = EngineError::Provider(provider);
let response = gateway_api::openai::error::engine_error_response(&error);
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(response.headers()["content-type"], "application/json");
assert!(!response.headers().contains_key("retry-after"));
assert_eq!(to_bytes(response.into_body(), 4096).await.unwrap(), body);
let EngineError::Provider(provider) = error else {
unreachable!()
};
assert_eq!(provider.upstream_status(), Some(429));
assert_eq!(
provider.upstream_code().unwrap().as_str(),
"usage_limit_reached"
);
}
+12 -1
View File
@@ -6,6 +6,7 @@ mod models;
mod responses;
mod router;
mod search;
mod usage;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
@@ -135,8 +136,18 @@ pub(super) fn authenticated_client_for_provider(
plaintext: &str,
provider_name: &str,
) -> AuthenticatedClient {
authenticated_client_for_provider_with_limit(plaintext, provider_name, 64 * 1024 * 1024)
}
pub(super) fn authenticated_client_for_provider_with_limit(
plaintext: &str,
provider_name: &str,
bytes: usize,
) -> AuthenticatedClient {
let snapshot = snapshot(plaintext, provider_name)
.with_responses_max_decompressed_body_bytes(std::num::NonZeroUsize::new(bytes).unwrap());
let source = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot(plaintext, provider_name)),
RuntimeSnapshotHandle::new(snapshot),
Arc::new(UnusedExecutionStore),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
@@ -41,7 +41,9 @@ use serde_json::{Value, json};
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
use tower::ServiceExt;
use crate::openai::{api_router, authenticated_client_for_provider};
use crate::openai::{
api_router, authenticated_client_for_provider, authenticated_client_with_min_versions,
};
#[derive(Default)]
struct Trace {
@@ -546,7 +548,7 @@ async fn invalid_compressed_http_requests_should_fail_before_execution() {
super::request::encode_body("zstd", std::io::repeat(0).take(64 * 1024 * 1024 + 1))
.into(),
"request_too_large",
"Decompressed request body exceeds the allowed size.",
"Decompressed request body exceeds the allowed size (67108864 bytes).",
),
] {
let headers = HeaderMap::from_iter([
@@ -1696,7 +1698,7 @@ async fn streaming_upstream_error_event_should_be_translated_to_response_failed_
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"code": "server_error",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
@@ -1713,6 +1715,45 @@ async fn streaming_upstream_error_event_should_be_translated_to_response_failed_
);
}
#[tokio::test]
async fn streaming_capacity_error_after_commit_offers_client_retry() {
for code in ["server_is_overloaded", "slow_down"] {
let trace = Arc::new(Trace::default());
let error = ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"busy",
Some(code.to_owned()),
Some("service_unavailable_error".to_owned()),
));
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)),
NextStep::Error(EngineError::Provider(error)),
],
);
let response = stream_execution_response(Box::new(session), None).await;
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 16 * 1024)
.await
.expect("SSE body");
let text = std::str::from_utf8(&body).expect("UTF-8");
let events = parse_sse_events(text).expect("SSE");
let failed = events
.iter()
.find(|event| event.event.as_deref() == Some("response.failed"))
.expect("failed event");
let failed: Value = serde_json::from_str(&failed.data).expect("JSON");
assert_eq!(failed["response"]["error"]["code"], "server_error");
assert_eq!(failed["response"]["error"]["message"], "busy");
assert_eq!(text.matches("event: response.failed").count(), 1);
assert!(text.ends_with("data: [DONE]\n\n"));
}
}
#[tokio::test]
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
let trace = Arc::new(Trace::default());
@@ -1968,6 +2009,112 @@ struct SessionExecution {
session: Mutex<Option<Box<dyn ExecutionSession>>>,
}
#[tokio::test]
async fn chatgpt_remote_responses_should_only_skip_missing_desktop_versions() {
for name in [
"codex_chatgpt_android_remote",
"codex_chatgpt_ios_remote",
"codex_chatgpt_future_os_remote",
] {
for (version, expected_code) in [
(None, None),
(Some("26.908.70816"), None),
(Some("26.1.0"), Some("client_version_too_old")),
(Some("dev"), Some("client_version_unavailable")),
] {
let trace = Arc::new(Trace::default());
let execution = Arc::new(SessionExecution {
client: authenticated_client_with_min_versions(
"sk_remote_test",
Some("26.908.70816"),
Some("0.154.0"),
),
session: Mutex::new(Some(Box::new(FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(delivery(
started(),
CommitRequirement::CommitBeforeDelivery,
)),
NextStep::Event(delivery(completed(), CommitRequirement::AlreadyCommitted)),
NextStep::FinalizeSuccess,
],
)))),
});
let mut request = Request::post("/v1/responses")
.header(AUTHORIZATION, "Bearer sk_remote_test")
.header("user-agent", format!(
"Codex Desktop/0.154.0-alpha.6.2 (Windows 10.0.26200; x86_64) unknown ({name}; dev)"
));
if let Some(version) = version {
request = request.header("version", version);
}
let response = api_router(execution.clone())
.await
.oneshot(
request
.body(Body::from(
json!({"model": "model-a", "input": "synthetic", "stream": true})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
if let Some(code) = expected_code {
let value: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(status, StatusCode::UPGRADE_REQUIRED, "{name} {version:?}");
assert_eq!(value["error"]["code"], code);
assert!(execution.session.lock().unwrap().is_some());
} else {
assert_eq!(status, StatusCode::OK, "{name} {version:?}");
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains("event: response.completed\n"));
assert!(body.ends_with("data: [DONE]\n\n"));
assert!(trace.snapshot().contains(&"commit"));
}
}
}
}
#[tokio::test]
async fn chatgpt_remote_responses_should_still_require_client_key_authentication() {
let observed = Arc::new(Mutex::new(None));
let execution = Arc::new(ContextCaptureExecution {
observed: Arc::clone(&observed),
client: authenticated_client_with_min_versions(
"sk_context_test",
Some("26.908.70816"),
None,
),
});
let router = api_router(execution).await;
for authorization in [None, Some("Bearer sk_invalid")] {
let mut request = Request::post("/v1/responses").header(
"user-agent",
"Codex Desktop/0.154.0-alpha.6.2 (codex_chatgpt_android_remote; dev)",
);
if let Some(authorization) = authorization {
request = request.header(AUTHORIZATION, authorization);
}
let response = router
.clone()
.oneshot(
request
.body(Body::from(
json!({"model": "model-a", "input": "synthetic"}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(observed.lock().unwrap().is_none());
}
}
impl ExecutionService for SessionExecution {
fn authenticate(&self, _: &str) -> Result<AuthenticatedClient, ClientAuthenticationError> {
Ok(self.client.clone())
@@ -2180,3 +2327,50 @@ async fn local_execution_failure_uses_the_existing_model_id() {
}
}
}
#[tokio::test]
async fn compressed_http_request_above_default_limit_should_reach_execution_after_increase() {
let body = json!({"model": "model-a", "input": "x".repeat(64 * 1024 * 1024)}).to_string();
let compressed = super::request::encode_body("zstd", body.as_bytes());
for limit in [64 * 1024 * 1024, 128 * 1024 * 1024] {
let observed = Arc::new(Mutex::new(None));
let execution = Arc::new(ContextCaptureExecution {
observed: Arc::clone(&observed),
client: crate::openai::authenticated_client_for_provider_with_limit(
"sk_context_test",
"openai",
limit,
),
});
let response = api_router(execution)
.await
.oneshot(
Request::post("/v1/responses")
.header(AUTHORIZATION, "Bearer sk_context_test")
.header(CONTENT_ENCODING, "zstd")
.body(Body::from(compressed.clone()))
.unwrap(),
)
.await
.unwrap();
if limit == 64 * 1024 * 1024 {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(observed.lock().unwrap().is_none());
let error: Value =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert_eq!(error["error"]["code"], "request_too_large");
} else {
// 捕获执行服务刻意返回 500;断言完整输入到达执行层,而不是只验证路由状态码。
let captured = observed
.lock()
.unwrap()
.take()
.expect("request reached execution");
assert_eq!(
captured.input.unwrap().as_str().unwrap().len(),
64 * 1024 * 1024
);
}
}
}
@@ -19,7 +19,7 @@ use gateway_protocol::openai::sse::parse_sse_events;
use serde_json::{Value, json};
fn decode_request(body: &[u8]) -> Result<DecodedResponsesRequest, RequestDecodeError> {
decode_request_with_headers(body, &HeaderMap::new())
decode_request_with_headers(body, &HeaderMap::new(), 64 * 1024 * 1024)
}
fn decode_response_create(
@@ -205,8 +205,9 @@ fn decoder_should_preserve_connection_metadata_outside_the_openai_wire_body() {
}
});
let decoded = decode_request_with_headers(body.to_string().as_bytes(), &headers)
.expect("request should decode");
let decoded =
decode_request_with_headers(body.to_string().as_bytes(), &headers, 64 * 1024 * 1024)
.expect("request should decode");
assert_eq!(Value::Object(openai_wire_body(&decoded).clone()), body);
assert_eq!(
@@ -317,9 +318,12 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
HeaderValue::from_static("client-is-update"),
);
let decoded =
decode_request_with_headers(br#"{"model":"smart-code","input":"hello"}"#, &headers)
.expect("opaque request headers should not affect body decoding");
let decoded = decode_request_with_headers(
br#"{"model":"smart-code","input":"hello"}"#,
&headers,
64 * 1024 * 1024,
)
.expect("opaque request headers should not affect body decoding");
let entries = openai_protocol_context(&decoded)
.get("opaque_request_headers")
.and_then(Value::as_array)
@@ -420,9 +424,12 @@ fn decoder_should_strip_http_transport_but_leave_source_headers_for_provider() {
headers.insert("content-encoding", HeaderValue::from_static("identity"));
headers.insert("x-openai-future-mode", HeaderValue::from_static("keep"));
let decoded =
decode_request_with_headers(br#"{"model":"smart-code","input":"hello"}"#, &headers)
.expect("decode request behind a reverse proxy");
let decoded = decode_request_with_headers(
br#"{"model":"smart-code","input":"hello"}"#,
&headers,
64 * 1024 * 1024,
)
.expect("decode request behind a reverse proxy");
assert_eq!(
openai_protocol_context(&decoded).get("opaque_request_headers"),
@@ -482,7 +489,8 @@ fn downstream_client_headers_should_remain_opaque_without_losing_session_semanti
frame["type"] = json!("response.create");
let opening = OpenAiRequestHeaders::from_headers(&headers);
for decoded in [
decode_request_with_headers(body.to_string().as_bytes(), &headers).unwrap(),
decode_request_with_headers(body.to_string().as_bytes(), &headers, 64 * 1024 * 1024)
.unwrap(),
decode_response_create_with_context(&frame.to_string(), &opening).unwrap(),
decode_response_create_with_context(&frame.to_string(), &opening).unwrap(),
] {
@@ -565,7 +573,7 @@ fn decoder_should_preserve_unknown_arbitrary_precision_numbers() {
let number = "12345678901234567890123456789012345678901234567890";
let body = format!(r#"{{"model":"smart-code","input":"hello","future_number":{number}}}"#);
let decoded = decode_request_with_headers(body.as_bytes(), &HeaderMap::new())
let decoded = decode_request_with_headers(body.as_bytes(), &HeaderMap::new(), 64 * 1024 * 1024)
.expect("opaque numeric field should decode");
let encoded = serde_json::to_string(openai_wire_body(&decoded)).expect("encode request body");
@@ -838,7 +846,7 @@ fn transparent_encoder_should_translate_error_wire_to_response_failed_for_websoc
assert_eq!(payload["type"], "response.failed");
assert_eq!(payload["response"]["id"], response_id);
assert_eq!(payload["response"]["status"], "failed");
assert_eq!(payload["response"]["error"]["code"], "server_is_overloaded");
assert_eq!(payload["response"]["error"]["code"], "server_error");
// 与 SSE 边界投影到同一份 data 负载,两条客户端通道行为一致。
let mut sse_encoder = OpenAiResponsesEncoder::new();
@@ -912,6 +920,108 @@ fn transparent_encoder_should_follow_wire_when_canonical_identity_changes() {
);
}
#[test]
fn capacity_failure_projection_preserves_wire_metadata_and_original_events() {
for code in ["server_is_overloaded", "slow_down"] {
for (event_type, explicit_type) in [
("error", true),
("error", false),
("response.failed", true),
("response.failed", false),
] {
for status in [None, Some(400), Some(429), Some(503)] {
let error = json!({"code": code, "type": "service_unavailable_error", "message": "busy", "future": 42});
let mut original = if event_type == "error" {
json!({"type": event_type, "error": error, "sequence_number": 3})
} else {
json!({"type": event_type, "response": {"id": "resp_capacity", "status": "failed", "error": error}, "sequence_number": 3})
};
if let Some(status) = status {
original["status_code"] = json!(status);
}
let raw = Bytes::from(format!(
"id: capacity\r\nretry: 1700\r\nevent: {event_type}\r\ndata: {original}\r\n\r\n"
));
let event = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
explicit_type.then(|| event_type.to_owned()),
original.clone(),
raw.clone(),
Some("capacity".to_owned()),
Some(1700),
)
.expect("wire"),
);
let mut sse = OpenAiResponsesEncoder::new();
let mut ws = OpenAiResponsesEncoder::new();
let created = openai_wire_event(
Vec::new(),
"response.created",
json!({"type": "response.created", "response": {"id": "resp_capacity", "status": "in_progress"}}),
);
sse.push_sse(&created);
ws.push_websocket(&created);
let frames = sse.push_sse(&event);
let parsed =
parse_sse_events(std::str::from_utf8(&frames[0]).expect("UTF-8")).expect("SSE");
let sse_data: Value = serde_json::from_str(&parsed[0].data).expect("JSON");
assert_eq!(parsed[0].id.as_deref(), Some("capacity"));
assert_eq!(parsed[0].retry, Some(1700));
assert_eq!(parsed[0].event.as_deref(), Some("response.failed"));
assert_eq!(sse_data["response"]["error"]["code"], "server_error");
assert_eq!(sse_data["response"]["error"]["message"], "busy");
assert_eq!(sse_data["response"]["error"]["future"], 42);
let messages = ws.push_websocket(&event);
let ws_data: Value = serde_json::from_str(&messages[0]).expect("JSON");
if event_type == "error" && status.is_some() {
let mut expected = original.clone();
expected["error"]["code"] = json!("server_error");
expected["status_code"] = json!(503);
assert_eq!(ws_data, expected);
} else {
assert_eq!(ws_data, sse_data);
}
assert!(sse.has_wire_failure());
assert!(ws.has_wire_failure());
let wire = event.wire_event().expect("original wire");
assert_eq!(wire.data(), &original);
assert_eq!(wire.raw_sse_frame(), Some(&raw));
}
}
}
}
#[test]
fn capacity_client_projection_does_not_touch_other_codes_or_non_failure_events() {
for (event_type, code) in [
("response.failed", "rate_limit_exceeded"),
("response.failed", "insufficient_quota"),
("response.failed", "previous_response_not_found"),
("response.future", "server_is_overloaded"),
] {
let original =
json!({"type": event_type, "response": {"error": {"code": code, "message": "busy"}}});
let raw = Bytes::from(format!("event: {event_type}\r\ndata: {original}\r\n\r\n"));
let event = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some(event_type.to_owned()),
original.clone(),
raw.clone(),
None,
None,
)
.expect("wire"),
);
assert_eq!(OpenAiResponsesEncoder::new().push_sse(&event), vec![raw]);
assert_eq!(
OpenAiResponsesEncoder::new().push_websocket(&event),
vec![original.to_string()]
);
}
}
#[test]
fn transparent_encoder_should_only_require_wire_terminal_for_buffered_conversion() {
let mut missing_terminal = OpenAiResponsesEncoder::new();
@@ -41,7 +41,9 @@ fn http_decode_should_preserve_compressed_request_fields() {
let expected = serde_json::from_slice::<serde_json::Value>(REQUEST).expect("fixture JSON");
for encoding in ENCODINGS {
let compressed = encode_body(encoding, REQUEST);
let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding);
let decoded =
decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect(encoding);
assert_eq!(
openai_wire_body(&decoded),
expected.as_object().expect("object")
@@ -52,10 +54,10 @@ fn http_decode_should_preserve_compressed_request_fields() {
#[test]
fn http_decode_should_accept_plain_identity_and_case_insensitive_encodings() {
for request_headers in [HeaderMap::new(), headers(""), headers(" identity ")] {
assert!(decode_request_with_headers(REQUEST, &request_headers).is_ok());
assert!(decode_request_with_headers(REQUEST, &request_headers, 64 * 1024 * 1024).is_ok());
}
let compressed = encode_body("gzip", REQUEST);
assert!(decode_request_with_headers(&compressed, &headers(" GZip ")).is_ok());
assert!(decode_request_with_headers(&compressed, &headers(" GZip "), 64 * 1024 * 1024).is_ok());
}
#[test]
@@ -63,7 +65,7 @@ fn http_decode_should_reject_unknown_and_stacked_encodings() {
let mut repeated = headers("identity");
repeated.append(CONTENT_ENCODING, HeaderValue::from_static("gzip"));
for request_headers in [headers("br"), headers("gzip, zstd"), repeated] {
let error = decode_request_with_headers(REQUEST, &request_headers)
let error = decode_request_with_headers(REQUEST, &request_headers, 64 * 1024 * 1024)
.expect_err("unsupported encoding");
assert!(matches!(
error,
@@ -82,7 +84,8 @@ fn http_decode_should_reject_corrupted_and_truncated_compressed_bodies() {
let mut truncated = encode_body(encoding, REQUEST);
truncated.truncate(truncated.len() - 4);
for body in [b"not-a-compressed-stream".as_slice(), truncated.as_slice()] {
let error = decode_request_with_headers(body, &headers(encoding)).expect_err(encoding);
let error = decode_request_with_headers(body, &headers(encoding), 64 * 1024 * 1024)
.expect_err(encoding);
assert_eq!(
error.protocol_body().into_value(),
json!({"error": {
@@ -99,7 +102,9 @@ fn http_decode_should_read_all_gzip_members_and_zstd_frames() {
for encoding in ["gzip", "zstd"] {
let mut compressed = encode_body(encoding, &REQUEST[..12]);
compressed.extend(encode_body(encoding, &REQUEST[12..]));
let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding);
let decoded =
decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect(encoding);
assert_eq!(openai_wire_body(&decoded)["input"], "hello");
}
}
@@ -110,7 +115,8 @@ fn http_decode_should_not_ignore_invalid_trailing_members() {
let mut compressed = encode_body(encoding, REQUEST);
compressed.extend(encode_body(encoding, b"invalid trailing JSON".as_slice()));
assert_eq!(
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding),
decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect_err(encoding),
RequestDecodeError::MalformedJson
);
}
@@ -129,7 +135,8 @@ fn http_decode_should_reject_zstd_frames_with_an_excessive_window() {
REQUEST
);
assert_eq!(
decode_request_with_headers(&compressed, &headers("zstd")).expect_err("oversized window"),
decode_request_with_headers(&compressed, &headers("zstd"), 128 * 1024 * 1024)
.expect_err("oversized window"),
RequestDecodeError::MalformedJson
);
}
@@ -138,7 +145,8 @@ fn http_decode_should_reject_zstd_frames_with_an_excessive_window() {
fn http_decode_should_accept_the_exact_decompressed_limit() {
let padding = repeat(b' ').take(DECOMPRESSED_LIMIT - REQUEST.len() as u64);
let compressed = encode_body("zstd", REQUEST.chain(padding));
let decoded = decode_request_with_headers(&compressed, &headers("zstd")).expect("exact limit");
let decoded = decode_request_with_headers(&compressed, &headers("zstd"), 64 * 1024 * 1024)
.expect("exact limit");
assert_eq!(decoded.metadata().requested_model(), "gpt-test");
}
@@ -146,11 +154,13 @@ fn http_decode_should_accept_the_exact_decompressed_limit() {
fn http_decode_should_reject_bodies_above_the_decompressed_limit() {
for encoding in ENCODINGS {
let compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1));
let error =
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding);
let error = decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect_err(encoding);
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge,
RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: 64 * 1024 * 1024
},
"{encoding}"
);
}
@@ -163,12 +173,54 @@ fn http_decode_should_stop_at_limit_before_reading_invalid_trailing_frames() {
// 这个断言验证越界时已经停止读取,而不只是最终返回了某个超限错误。
let mut compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1));
compressed.extend_from_slice(b"invalid trailing frame");
let error =
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding);
let error = decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect_err(encoding);
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge,
RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: 64 * 1024 * 1024
},
"{encoding}"
);
}
}
#[test]
fn http_decode_should_apply_configured_limits_to_every_encoding_and_frame() {
for encoding in ENCODINGS {
let compressed = encode_body(encoding, REQUEST);
assert!(
decode_request_with_headers(&compressed, &headers(encoding), REQUEST.len()).is_ok()
);
let error = decode_request_with_headers(&compressed, &headers(encoding), REQUEST.len() - 1)
.unwrap_err();
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: REQUEST.len() - 1
}
);
assert!(
error
.protocol_body()
.error
.message
.contains(&(REQUEST.len() - 1).to_string())
);
}
for encoding in ["gzip", "zstd"] {
let split = REQUEST.len() / 2;
let mut compressed = encode_body(encoding, &REQUEST[..split]);
compressed.extend(encode_body(encoding, &REQUEST[split..]));
assert!(
decode_request_with_headers(&compressed, &headers(encoding), REQUEST.len()).is_ok()
);
assert!(matches!(
decode_request_with_headers(&compressed, &headers(encoding), REQUEST.len() - 1),
Err(RequestDecodeError::DecompressedBodyTooLarge { .. })
));
}
for request_headers in [HeaderMap::new(), headers("identity")] {
assert!(decode_request_with_headers(REQUEST, &request_headers, 1).is_ok());
}
}
@@ -200,6 +200,54 @@ async fn non_structured_bodies_use_safe_fallback_without_losing_status_or_ids()
}
}
#[tokio::test]
async fn initial_capacity_failure_offers_client_retry_with_upstream_correlation() {
for code in ["server_is_overloaded", "slow_down"] {
for status in [400, 429, 503] {
let provider = upstream_failure(
status,
b"",
vec![
header("x-request-id", b"req_capacity"),
header("retry-after", b"7"),
],
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"busy",
Some(code.to_owned()),
Some("service_unavailable_error".to_owned()),
));
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
assert_eq!(error["status"], 503);
assert_eq!(error["error"]["code"], "server_error");
assert_eq!(error["error"]["message"], "busy");
assert_eq!(error["headers"]["x-request-id"], "req_capacity");
assert_eq!(error["headers"]["retry-after"], "7");
}
}
}
#[tokio::test]
async fn classified_capacity_error_without_special_code_returns_retryable_websocket_status() {
let provider = ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
)
.with_status(400)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"Selected model is at capacity. Please try a different model.",
None,
Some("server_error".to_owned()),
));
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
assert_eq!(error["status"], 503);
assert_eq!(error["error"]["code"], "upstream_unavailable");
assert_eq!(
error["error"]["message"],
"Selected model is at capacity. Please try a different model."
);
}
#[tokio::test]
async fn final_failure_headers_take_precedence_over_observed_opening_headers() {
let provider = upstream_failure(403, b"", vec![header("x-oai-request-id", b"req_final")])
@@ -447,3 +495,46 @@ async fn deliverable_business_failures_are_not_rewritten_or_followed_by_a_second
socket.close(None).await.unwrap();
}
}
#[tokio::test]
async fn initial_quota_recovery_delivers_client_projection_instead_of_upstream_status() {
let detail = json!({"message":"Previous response was not found. Retrying the full request.","code":"previous_response_not_found","type":"invalid_request_error"});
let provider = ProviderError::new(ProviderErrorKind::QuotaExhausted, UpstreamSendState::Sent)
.with_status(429)
.with_retry_after(Duration::from_secs(129_600))
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
detail["message"].as_str().unwrap(),
Some("previous_response_not_found".to_owned()),
Some("invalid_request_error".to_owned()),
))
.with_client_visible_upstream_response(
ClientVisibleUpstreamResponse::new(
400,
Some(b"application/json".to_vec()),
Bytes::from(json!({"error":detail}).to_string()),
)
.with_headers(vec![header("x-request-id", b"req-quota-rejected")]),
);
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
assert_eq!(error["status"], 400);
assert_eq!(error["error"], detail);
assert_eq!(error["headers"]["x-request-id"], "req-quota-rejected");
assert!(error["headers"].get("retry-after").is_none());
}
#[tokio::test]
async fn locally_exhausted_account_pool_sends_one_official_usage_limit_error() {
let provider = ProviderError::new(
ProviderErrorKind::QuotaExhausted,
UpstreamSendState::NotSent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"All eligible accounts have exhausted their quota.",
Some("usage_limit_reached".to_owned()),
Some("usage_limit_reached".to_owned()),
));
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
assert_eq!(error["status"], 429);
assert_eq!(error["error"]["type"], "usage_limit_reached");
assert_eq!(error["error"]["code"], "usage_limit_reached");
}
@@ -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 {
@@ -247,17 +247,48 @@ impl ClientRoutingScope {
/// 一次认证随 RuntimeSnapshot 冻结的账号目录与 Key 权限。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrozenAccountScope {
disable_fast: bool,
request_profiles: BTreeMap<ProviderKind, super::OpaqueProviderData>,
directory: Arc<RuntimeAccountDirectory>,
client_scope: ClientRoutingScope,
}
impl FrozenAccountScope {
/// 与 Key 授权范围一同冻结;具体字段只由对应 Provider 解释。
#[must_use]
pub fn with_request_profiles(
mut self,
profiles: BTreeMap<ProviderKind, super::OpaqueProviderData>,
) -> Self {
self.request_profiles = profiles;
self
}
#[must_use]
pub fn request_profile(&self, provider: &ProviderKind) -> Option<&super::OpaqueProviderData> {
self.request_profiles.get(provider)
}
/// Key 绑定分组的冻结 Fast 限制,与账号成员资格无关。
#[must_use]
pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self {
self.disable_fast = disable_fast;
self
}
#[must_use]
pub const fn disable_fast(&self) -> bool {
self.disable_fast
}
#[must_use]
pub const fn new(
directory: Arc<RuntimeAccountDirectory>,
client_scope: ClientRoutingScope,
) -> Self {
Self {
disable_fast: false,
request_profiles: BTreeMap::new(),
directory,
client_scope,
}
@@ -112,6 +112,7 @@ pub struct AccountRuntimeSignals {
}
const ACCOUNT_FEEDBACK_EWMA_ALPHA: f64 = 0.2;
const ACCOUNT_CAPACITY_FAILURE_EWMA_ALPHA: f64 = 0.4;
const ACCOUNT_FAILURE_RATE_HALF_LIFE: Duration = Duration::from_secs(15 * 60);
const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits();
@@ -120,6 +121,7 @@ const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits();
pub enum AccountAttemptFeedback {
Succeeded { first_output_ms: Option<u64> },
Failed { first_output_ms: Option<u64> },
CapacityRejected { first_output_ms: Option<u64> },
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -150,13 +152,12 @@ impl DecayingAccountFailureRate {
* 0.5_f64.powf(elapsed.as_secs_f64() / ACCOUNT_FAILURE_RATE_HALF_LIFE.as_secs_f64())
}
fn report_at(&mut self, sample: f64, now: Instant) {
fn report_at(&mut self, sample: f64, alpha: f64, now: Instant) {
let now = self
.updated_at
.map_or(now, |updated_at| updated_at.max(now));
let decayed = self.value_at(now);
self.value =
ACCOUNT_FEEDBACK_EWMA_ALPHA * sample + (1.0 - ACCOUNT_FEEDBACK_EWMA_ALPHA) * decayed;
self.value = alpha * sample + (1.0 - alpha) * decayed;
self.updated_at = Some(now);
}
}
@@ -173,14 +174,21 @@ impl Default for AccountFeedback {
impl AccountFeedback {
fn report_at(&self, feedback: AccountAttemptFeedback, now: Instant) {
let (failure, first_output_ms) = match feedback {
AccountAttemptFeedback::Succeeded { first_output_ms } => (0.0, first_output_ms),
AccountAttemptFeedback::Failed { first_output_ms } => (1.0, first_output_ms),
let (failure, alpha, first_output_ms) = match feedback {
AccountAttemptFeedback::Succeeded { first_output_ms } => {
(0.0, ACCOUNT_FEEDBACK_EWMA_ALPHA, first_output_ms)
}
AccountAttemptFeedback::Failed { first_output_ms } => {
(1.0, ACCOUNT_FEEDBACK_EWMA_ALPHA, first_output_ms)
}
AccountAttemptFeedback::CapacityRejected { first_output_ms } => {
(1.0, ACCOUNT_CAPACITY_FAILURE_EWMA_ALPHA, first_output_ms)
}
};
self.failure_rate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.report_at(failure, now);
.report_at(failure, alpha, now);
if let Some(first_output_ms) = first_output_ms.filter(|value| *value > 0) {
update_feedback_ewma(&self.first_output_ms, first_output_ms as f64);
}
@@ -196,6 +196,7 @@ where
recovery_account: None,
pending_retry: None,
transient_retry_counts: BTreeMap::new(),
request_profiles: BTreeMap::new(),
current: None,
send_state_watermark: UpstreamSendState::NotSent,
downstream_committed_at: None,
@@ -304,6 +305,8 @@ pub struct ResponseExecutionSession<S: ?Sized> {
pending_retry: Option<PendingAttemptRetry>,
/// 请求内按账号累计的瞬时拒绝重试次数,不能跨请求污染账号健康状态。
transient_retry_counts: BTreeMap<crate::account::ProviderAccountId, u32>,
/// 只在首次进入对应 Provider 时解析,避免后台发布更新改变同一请求的重试身份。
request_profiles: BTreeMap<crate::identity::ProviderKind, crate::account::OpaqueProviderData>,
current: Option<CurrentAttempt>,
/// 请求级发送状态水位;跨 attempt 单调不降,终态写回不得低于此档。
send_state_watermark: UpstreamSendState,
@@ -786,8 +789,36 @@ where
self.credential_recovery_attempted_accounts
.contains(account)
}));
let provider = self
.engine
.providers()
.get(candidate.provider())
.cloned()
.ok_or_else(|| EngineError::ProviderNotRegistered {
provider: candidate.provider().as_str().to_owned(),
})?;
if !self.request_profiles.contains_key(candidate.provider())
&& let Some(configuration) = self
.plan
.account_scope()
.request_profile(candidate.provider())
{
match provider.resolve_request_profile(configuration) {
Ok(profile) => {
self.request_profiles
.insert(candidate.provider().clone(), profile);
}
Err(error) => {
self.finish_provider_error(&error).await?;
return Err(provider_engine_error(error));
}
}
}
let context = AttemptContext::new(
RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone())
.with_request_profile(self.request_profiles.get(candidate.provider()).cloned())
.with_disable_fast(self.plan.disable_fast())
.with_pricing(self.plan.pricing())
.with_request_location(self.plan.request_location().cloned())
.with_concurrency_wait_budget(self.concurrency_wait_budget.clone())
.with_timing_started_at(self.observation.timing_started_at)
@@ -806,14 +837,6 @@ where
} else {
AttemptTrigger::AccountRetry
};
let provider = self
.engine
.providers()
.get(candidate.provider())
.cloned()
.ok_or_else(|| EngineError::ProviderNotRegistered {
provider: candidate.provider().as_str().to_owned(),
})?;
let attempt_trace = self.trace.attempt(next_attempt.get());
attempt_trace.record(
"attempt.started",
@@ -858,6 +881,7 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
@@ -874,6 +898,7 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
) && let Some(last_failure) = self.last_retryable_failure.take()
@@ -896,6 +921,7 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
@@ -1424,6 +1450,7 @@ where
error.kind(),
ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::QuotaExhausted
)
{
return false;
@@ -1498,6 +1525,12 @@ where
http_version,
websocket_pool,
service_tier,
upstream_response_model: self
.current
.as_ref()
.and_then(|current| current.response_observation.as_ref())
.and_then(ProviderResponseObservation::upstream_response_model)
.map(str::to_owned),
provider_metadata_json,
diagnostic_trace_json: self.trace.snapshot().map(|value| value.to_string()),
error: None,
@@ -1659,6 +1692,12 @@ where
http_version,
websocket_pool,
service_tier,
upstream_response_model: self
.current
.as_ref()
.and_then(|current| current.response_observation.as_ref())
.and_then(ProviderResponseObservation::upstream_response_model)
.map(str::to_owned),
provider_metadata_json,
diagnostic_trace_json: self.trace.snapshot().map(|value| value.to_string()),
error: Some(finalization.error),

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