Compare commits

...
29 Commits
Author SHA1 Message Date
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
ZyyandCodex 42c2b249c2 chore(release): v3.9.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 04:08:44 +08:00
ZyyandCodex 0b3ebc23f5 chore(workflow): refine documentation and release checks
Require documentation diff review by purpose, distinguish release migration baselines, and use the version tag directly in release commit titles.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 04:08:30 +08:00
ZyyandCodex 65bb9ab9cf docs: clarify setup and current contracts
Document one-click installation, consolidate manual deployment instructions, and describe current API and architecture contracts. Keep applicable configuration and upgrade guidance without recording implementation history.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 04:08:30 +08:00
ZyyandCodex 50d6f7444d fix(deploy): render installer configuration with Bash
Replace the embedded Python configuration writer with Bash quoting and placeholder validation. Reject control characters in administrator passwords and preserve existing deployment files on reruns.

Validation: Bash syntax, isolated installation checks, and loading generated YAML through the application configuration loader passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 04:08:17 +08:00
ZyyandCodex bcfea792e8 fix(config): tolerate unused startup settings
Ignore unused YAML fields across startup configuration and report their paths without values. Report missing required fields while retaining existing type and value validation.

Validation: configuration and bootstrap tests, strict Clippy, and architecture checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 04:08:17 +08:00
ZyyandCodex 1e7ea8d46f refactor(store): consolidate unreleased account migrations
Default account auto-freeze to off in its initial migration and renumber request location to 0011. Update checksum entries and migration tests.

These migrations were not included in v3.8.0. Existing PR freeze rules remain unchanged.

Validation: 18 gateway-store tests with isolated PostgreSQL, upgrade from the v3.8.0 schema, migration checksums and numbering, and Rustfmt.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 03:47:53 +08:00
bd61e0391a feat(settings): configure global and per-proxy request location (#123)
* feat(proxies): configure per-proxy request location

* feat(settings): manage global request timezone and location

Move request location from YAML into runtime settings with an enable switch. Preserve client fields when disabled and keep per-proxy overrides first. Share location inputs and simplify proxy and settings controls.

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 03:30:54 +08:00
499b43043e feat(accounts): add opt-in capacity cooldown and recovery (#126)
Add opt-in account capacity cooldown, recovery probes, and atomic adaptive concurrency updates. Include overload and slow_down in Smart scheduling feedback, expose rate-limit reasons, and keep probe results fenced by cooldown generation.

Exclude diagnostic probes and local connection failures from capacity evidence. Default auto-freeze to disabled and preserve explicitly saved settings with a forward migration.

Closes #120

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 03:23:38 +08:00
ZyyandCodex ae2da31380 fix(accounts): improve provider selection and forecast hints
Expand provider choices to full-width labeled options and move the forecast caveat into an icon-only explanation popover.

Validation: frontend lint, typecheck and build passed; verified narrow-screen selection, keyboard navigation and explanation popover. Available forecast data layout was not exercised because local observations are insufficient.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 00:45:55 +08:00
1e6d69a349 fix(openai): align downstream requests with Codex protocol (#117)
Centralize downstream SDK, browser and proxy header compatibility in the
OpenAI provider. Normalize the session header alias and isolate known
non-Codex body parameters while preserving ordinary extensions.

Align account installation metadata with Codex Core and remove unverified
identity header classifications. Cover HTTP and WebSocket behavior and
document the request boundaries.

Validation: 1,105 scoped and architecture tests, Rustfmt and strict scoped
Clippy passed. Four real-account HTTP/WebSocket cases passed on gpt-5.5,
including connection reuse. Earlier gpt-reserve overload failures were
recorded separately without changing production retry policy.

Co-authored-by: Clarence <1297529513@qq.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 00:30:52 +08:00
2han9wen71anandCodex dac57a81c6 fix(providers): keep disabled accounts reachable for admin connection tests (#122)
Admin connection tests pin an account and bypass local availability, but
the scheduling store excludes disabled accounts before selection. Load
the pinned diagnostic account explicitly for OpenAI and xAI while keeping
normal scheduling and disabled-account state-write guards unchanged.

Apply the shared authentication and transport filters after restoring
OpenAI candidates. Align the memory stores with PostgreSQL filtering and
cover disabled-account diagnostics plus API key transport restrictions.

Validation: 652 OpenAI, 403 xAI, 350 Core, and 42 architecture/bootstrap
tests passed; Rustfmt and strict workspace Clippy passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:53:52 +08:00
d162f9b7f6 fix(accounts): run bulk imports as background tasks (#119)
* fix(openai): sync account plans with quota updates

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

* fix(accounts): run bulk imports as background tasks

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

* test(store): preserve settings in account plan refresh fixture

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-16 18:24:34 +08:00
866f71e3fb fix(openai): sync account plans with quota updates (#112)
* fix(openai): sync account plans with quota updates

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

* test(store): preserve settings in account plan refresh fixture

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-16 18:22:01 +08:00
yertandCodex 47ab48b4cf feat(deploy): add one-click installer (#116)
Automate version-matched Compose deployment, credential generation, bind-mount permissions, and startup health validation while preserving existing deployments.

Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:12:38 +08:00
e3cf4ab8be feat(ui): add persistent table column settings (#121)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:07:58 +08:00
4fb10f33f1 fix(update): reuse the served asset directory (#111)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:05:14 +08:00
c9318c54cd fix(proxy): support IPv6 egress probes (#110)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:05:08 +08:00
8d9aaf0bbd feat(accounts): support upstream API key accounts (#107)
Add Responses-compatible upstream accounts with isolated credentials, transport,
model catalogs, atomic settings updates, and account management controls.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:04:41 +08:00
ZyyandCodex 52d24d29c7 chore(github): improve issue reporting templates
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 17:29:01 +08:00
ZyyandCodex 3fbd447d94 chore(release): prepare v3.8.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 03:03:43 +08:00
ZyyandCodex 5a5cdbdcde fix(ui): refine account settings and key displays
Show all batch account settings by default while updating only edited fields. Add ellipsis and full-text titles to API key names and labels, and adjust the token usage summary icon size and alignment.

Co-authored-by: Codex <noreply@openai.com>
2026-09-16 02:54:23 +08:00
ZyyandCodex cca89f2f96 feat(accounts): add per-account model restrictions (#105)
Add per-account model allowlists and denylists to frozen routing, account settings, and CPR import/export. Preserve existing scheduling and account state boundaries.

Closes #97

Co-authored-by: Codex <noreply@openai.com>
2026-09-16 02:29:56 +08:00
e25aad2231 feat(gateway): add bounded concurrency queues for keys and accounts (#104)
* feat(auth): add API key workspace

Add persistent API key sessions and enforce admin/key authorization boundaries. Expose key-scoped quotas, trends, health, and paginated usage records through server-projected data.

Share the application shell, system overview, and usage views between admin and key sessions, reorganize frontend domains around view ownership, and omit account, provider, and authentication details from the key workspace.

BREAKING CHANGE: Browser authentication now uses /api/auth/* and cpr_session; existing browser sessions must sign in again.

Validated frontend lint, type checking, production build, backend workspace tests, and live authenticated key views.

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

* feat(console): refine key workspace experience

Unify role-aware console routes and use mode/role in authentication contracts.
Reuse request state, stable pagination and performance projections across workspaces.
Refine quota cards, usage panels, empty states and key-aware theme previews.
Skip session restoration on login and retain form inputs until unmount.
Keep the full health timeline grid visible without samples.

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

* feat(key-usage): replace shared workspace with focused usage page

Preserve unified admin/key authentication, restore admin pages, and remove the previous client workspace implementation. Add a scoped usage page with metrics, trends, budgets, request logs, and section loading placeholders.

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

* feat(gateway): queue requests at key and account concurrency limits

Share one wait deadline across client admission and account selection.

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-16 02:00:26 +08:00
84382761fc feat(auth): add focused API key usage page (#102)
* feat(auth): add API key workspace

Add persistent API key sessions and enforce admin/key authorization boundaries. Expose key-scoped quotas, trends, health, and paginated usage records through server-projected data.

Share the application shell, system overview, and usage views between admin and key sessions, reorganize frontend domains around view ownership, and omit account, provider, and authentication details from the key workspace.

BREAKING CHANGE: Browser authentication now uses /api/auth/* and cpr_session; existing browser sessions must sign in again.

Validated frontend lint, type checking, production build, backend workspace tests, and live authenticated key views.

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

* feat(console): refine key workspace experience

Unify role-aware console routes and use mode/role in authentication contracts.
Reuse request state, stable pagination and performance projections across workspaces.
Refine quota cards, usage panels, empty states and key-aware theme previews.
Skip session restoration on login and retain form inputs until unmount.
Keep the full health timeline grid visible without samples.

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

* feat(key-usage): replace shared workspace with focused usage page

Preserve unified admin/key authentication, restore admin pages, and remove the previous client workspace implementation. Add a scoped usage page with metrics, trends, budgets, request logs, and section loading placeholders.

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-16 01:49:37 +08:00
0a09c1572f feat(accounts): add notes and refine account interactions (#100)
Persist optional account notes across creation, editing, imports, and credential refreshes. Show notes in compact account rows with overflow-only popovers.

Support vertically resizable textareas, bounded select menus with custom scrollbars, and quota updates without table loading.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 01:36:34 +08:00
ZyyandCodex 90deabdfe3 docs(skills): ground upstream compatibility reviews in evidence
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 16:36:59 +08:00
2han9wen71an b1c24e276b fix(gateway-api): project bare WebSocket errors to response.failed (#99)
codex 的 WS 客户端(codex-api/src/endpoint/responses_websocket.rs)只消费
两类 error 帧:带非 2xx HTTP status 的包装错误,以及
websocket_connection_limit_reached / previous_response_not_found 内置可重
试错误码。OpenAI 上游的中流错误帧(如 server_is_overloaded)没有 status
字段,map_wrapped_websocket_error_event 返回 None,落回
process_responses_event 也没有 error 分支——整帧被静默忽略。

客户端收不到可识别的终态,只能空等到自身 300s stream_idle_timeout 超时
再重连,5 次重连累计 20-30 分钟"正在思考",最后才把失败呈现出来。CPA
等实现之所以秒级报错,正是因为它把错误负载重写成 codex 可消费的终态。

#90 已在 SSE 边界做了同样的投影,但客户端 WS 边界(push_websocket)仍原
样转发;使用 WS transport 的 codex 桌面端不受 #90 保护。本提交在该边界
对不可消费的错误帧投影成 response.failed(消息 JSON 自带 type 字段,经
process_responses_event 的 response.failed 分支映射为可重试错误并立即重
试);带非 2xx status 或特殊错误码的可消费形状原样透传,保持
deliverable_business_failures_are_not_rewritten... 既有契约。
2026-09-15 16:31:07 +08:00
380 changed files with 22757 additions and 3948 deletions
+4 -1
View File
@@ -33,6 +33,9 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
5. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
5. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
6. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
+4 -3
View File
@@ -5,8 +5,9 @@ description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用
# 整理与提交 Issue
本仓库流程见 [贡献与审查](../../../CONTRIBUTING.md),问题反馈使用当前的
[YAML Issue Form](../../../.github/ISSUE_TEMPLATE/bug_report.yml)。从表单读取字段与必填项,不在技能中复制一份字段清单。
本仓库流程见 [贡献与审查](../../../CONTRIBUTING.md),按场景选择当前的
[接口问题反馈](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)
或 [功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml) 表单。从表单读取字段与必填项,不在技能中复制一份字段清单。
用户指定其他仓库时,使用该仓库当前的贡献规则与 Issue 模板,不能把这里的表单套过去。
## 确定目标和动作
@@ -21,7 +22,7 @@ description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用
## 证据与去重
1. 读取目标仓库实际 Issue 模板和配置,区分缺陷、功能建议与协作规则提案。
本仓库后两类允许普通 Issue;其他仓库按其配置选择入口。
本仓库功能建议有独立表单,协作规则提案允许普通 Issue;其他仓库按其配置选择入口。
2. 先搜索 open 和 closed Issues,结合关键词、错误信息与触发条件检查重复,而不只比较标题。
同时核对相关 PR 与发布记录,关联已有修复及其发布版本;仅主分支已修复时明确尚未发布。
同一问题已有记录且没有新增证据时返回已有链接;用户仍明确要求新建时按其要求处理。
+9
View File
@@ -42,6 +42,10 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
按 [贡献与审查的文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否需要更新文档。
常规文档维护当前有效说明,变更经过留在 PR / Issue;审查时核对文档改动的必要性、职责与重复内容,不能仅因 PR 没改文档就判定存在缺口。
创建前和审查时逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
是否重复已有说明,脱离本次变更背景能否独立理解。仅说明实现经过、前后对比或验证结果的段落归入
PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断依据是内容用途,不按“新增”“不再”等词机械判错。
## 创建 PR
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
@@ -56,6 +60,7 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
按上述文档复核步骤检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
前端改动提供实际界面证据或预览步骤;缺少证据时写明,不用构建成功替代视觉验收。
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
再给必要的设计取舍、验证与遗留事项。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
@@ -83,6 +88,9 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
按上述核对流程收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
@@ -90,6 +98,7 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
按上述文档复核步骤检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
工具不可用、集成测试跳过或环境不足时具体说明影响范围。
7. 按 [审查标准](../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
再列出实现问题与验证缺口,结论附具体依据。
+5 -1
View File
@@ -48,13 +48,17 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
涉及数据库迁移时,对照同一大版本的上一正式 Release 检查已发布迁移是否保持不变,并验证升级路径;
跨大版本按项目部署约定判断,不默认支持在线升级。区分未发布迁移的整理、已执行这些迁移的源码部署实例,
以及 CI 实际采用的冻结基线。未发版不代表迁移从未被执行,CI 冻结检查失败也不等于正式版数据库升级失败;
按各自证据报告兼容性与检查结果,不因未发版而自动放宽现有冻结规则。
只修改说明时无需重建前后端;正式发版仍要跟踪发布工作流针对目标提交的检查与产物。
## 执行正式发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): prepare vX.Y.Z`。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
+174
View File
@@ -0,0 +1,174 @@
name: 接口问题反馈
description: 客户端调用模型、搜索等 API 时的报错、断流、超时或响应异常
body:
- type: markdown
attributes:
value: |
管理端页面、账号导入或服务部署的问题请选择“常规问题反馈”;新增能力请选择“功能建议”。
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: textarea
id: description
attributes:
label: 问题描述
description: 说明发生了什么、实际结果以及出现的频率;已有正常账号、模型或客户端的对照结果也可说明。
validations:
required: true
- type: input
id: version
attributes:
label: 服务版本
description: 填写实际运行的完整版本号,不要只写“最新”或 latest;源码运行填写提交号。
placeholder: 完整版本号或提交号
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
options:
- Docker Compose
- 其他容器部署
- 二进制运行
- 源码运行
- 其他方式(请在环境信息中说明)
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 填写服务端与客户端的操作系统及版本;性能或超时问题可补充 CPU、内存和容器资源限制。
placeholder: |
服务端操作系统与版本:
客户端操作系统与版本:
相关资源限制(如有):
validations:
required: true
- type: input
id: client-name
attributes:
label: 客户端名称
description: 区分 CLI、桌面端、编辑器扩展、SDK 或自编脚本。
placeholder: 例如 Codex CLI、Codex Desktop 或某个编辑器扩展
validations:
required: true
- type: input
id: client-version
attributes:
label: 客户端版本
description: 填写完整版本号;Codex CLI 可填写 codex --version 的输出。无法取得时说明原因。
placeholder: 完整版本号或相关 SDK 版本
validations:
required: true
- type: input
id: client-source
attributes:
label: 客户端安装来源与启动方式
description: 说明安装来源,以及是否通过包装脚本、启动器或第三方修改版运行;不确定时填写已知部分。
placeholder: 例如通过 npm 安装 @openai/codex,直接执行 codex
validations:
required: true
- type: input
id: endpoint
attributes:
label: 请求方法与端点
description: 填写发生异常的请求方法与路径,无需真实域名;不清楚端点时说明客户端当时执行的具体操作。
placeholder: 例如 POST /v1/responses、POST /v1/alpha/search 或 WebSocket /v1/responses
validations:
required: true
- type: dropdown
id: intermediary
attributes:
label: 是否经过中转或反向代理
description: |
指客户端到本服务之间的链路。API 中转包括 New API、One API、CCH 等;反向代理或 CDN 包括 Nginx、Caddy、Cloudflare Tunnel 等。
如有中转,请在下方填写链路详情;本服务到上游的出站代理请在补充信息中说明。
options:
- 否,直接连接本服务
- 是,仅经过 API 中转服务
- 是,仅经过反向代理或 CDN
- 是,同时经过 API 中转和反向代理或 CDN
- 不确定
validations:
required: true
- type: textarea
id: request-chain
attributes:
label: 请求链路
description: |
按调用顺序填写中间服务的名称与版本,例如:Codex → New API → Nginx → 本服务。不确定时填写已知部分。
如果已测试过直连本服务,请说明是否仍能复现;未测试可注明,不要求先完成对照测试。
placeholder: |
调用顺序与各中间服务版本:
是否启用请求参数或请求头改写(如已知):
直连本服务的对照结果(如已测试):
- type: textarea
id: account-model
attributes:
label: 平台、账号类型与模型
description: 填写与失败请求相关的信息,不要提供账号凭据;不涉及模型或无法确认的信息请注明。
placeholder: |
平台与认证类型:
账号套餐(如相关):
请求模型与映射后的上游模型(如已知):
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 描述触发问题的操作;无法稳定复现时,说明已知触发条件。
placeholder: |
1. 使用什么配置或输入
2. 执行什么操作
3. 出现什么结果
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
- type: textarea
id: request-response
attributes:
label: 请求、响应与诊断附件
description: |
提供脱敏后的最小请求、网关实际状态/响应片段和客户端显示,区分上游、中转、网关与客户端的错误来源。
可在此直接上传管理端默认诊断包并检查其缺失说明;原始错误与日志片段需另行审阅脱敏,粘贴时请使用 Markdown 代码块。
按部署文档的“请求错误排查”检查关联 ID、错误详情与 application 日志,不默认开启完整 dump;无法取得的信息请注明。
placeholder: |
发生时间与时区:
传输(HTTP JSON / SSE / WebSocket):
脱敏后的最小请求:
网关关联 ID(x-gateway-request-id;配置的入口头请单独注明):
上游关联 ID(x-request-id / x-oai-request-id,注明来源):
网关实际状态、Content-Type 与响应片段:
中转服务的状态与错误(如有):
客户端显示的错误:
诊断包或无法取得的信息:
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 最近有升级时填写升级前后版本及前后端是否同步更新;网络问题补充出站代理、系统代理、TUN 和自定义证书等相关环境。
placeholder: |
服务或客户端升级前版本 → 当前版本(如有):
服务前后端是否同步更新(如有升级):
最近的配置变更:
其他相关环境:
-120
View File
@@ -1,120 +0,0 @@
name: 问题反馈
description: 提交可复现的问题,帮助定位和修复
body:
- type: markdown
attributes:
value: |
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: dropdown
id: issue-type
attributes:
label: 问题类型
options:
- 接口请求或响应异常
- 页面或交互异常
- 配置或部署异常
- 其他问题
validations:
required: true
- type: textarea
id: description
attributes:
label: 问题描述
description: 说明发生了什么、实际结果以及出现的频率。
validations:
required: true
- type: input
id: version
attributes:
label: 服务版本
description: 填写具体版本号;从源码运行时填写提交号。
placeholder: 版本号或提交号
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
options:
- Docker Compose
- 其他容器部署
- 二进制运行
- 源码运行
- 其他方式(请在环境信息中说明)
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 填写操作系统与版本;页面问题同时填写浏览器与版本。
placeholder: |
操作系统与版本:
浏览器与版本(页面问题):
validations:
required: true
- type: textarea
id: account-client
attributes:
label: 账号与客户端
description: 接口问题请填写平台、认证类型、模型和客户端信息,不要提供账号凭据。
placeholder: |
平台与认证类型:
模型名称:
客户端名称与版本:
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 描述触发问题的操作;无法稳定复现时,说明已知触发条件。
placeholder: |
1. 使用什么配置或输入
2. 执行什么操作
3. 出现什么结果
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
- type: textarea
id: request-response
attributes:
label: 请求与响应
description: |
接口问题请提供脱敏后的最小请求、网关实际状态/响应片段和客户端终端显示,区分两者。
优先附管理端默认诊断包并检查其缺失说明;原始错误与日志片段需另行审阅脱敏。
先按部署文档的“请求错误排查”检查关联 ID、错误详情与 application 日志,不默认开启完整 dump。
placeholder: |
发生时间与时区:
传输(HTTP JSON / SSE / WebSocket):
网关关联 ID(x-gateway-request-id / 配置的入口头):
上游关联 ID(x-request-id / x-oai-request-id,注明来源):
网关实际状态与响应片段:
客户端终端显示:
诊断包或无法取得的信息:
render: text
- type: textarea
id: screenshots
attributes:
label: 截图或录屏
description: 页面或交互问题可在此拖入相关截图、录屏,并说明对应的操作。
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 最近的升级或配置变更;网络问题补充系统代理、TUN 和自定义证书等相关环境。
@@ -0,0 +1,39 @@
name: 功能建议
description: 说明使用场景、当前不便和期望行为,提出新能力或体验改进
body:
- type: markdown
attributes:
value: |
如果已有功能无法正常使用,请选择对应的故障模板。提交前可搜索已有 Issues,相关讨论请附链接。
每个 Issue 尽量围绕一个使用场景;截图和示例请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: textarea
id: use-case
attributes:
label: 使用场景与当前不便
description: 说明你希望完成什么任务、目前如何操作,以及现有能力有什么不足。
placeholder: |
希望完成的任务:
当前做法与不便:
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 期望行为
description: 描述改进后希望如何使用,最好给出一个具体例子;不要求先设计技术方案。
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 已尝试的替代方案
description: 如果已有配置、操作方式或其他工具能部分满足需求,说明仍缺少什么;未尝试可留空。
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 可附当前使用的具体版本、相关 Issue、截图或参考链接;引用其他项目时说明希望借鉴的具体行为。
@@ -0,0 +1,143 @@
name: 常规问题反馈
description: 管理端页面、账号导入、安装部署、升级或其他非客户端 API 问题
body:
- type: markdown
attributes:
value: |
客户端调用模型、搜索等 API 的问题请选择“接口问题反馈”;新增能力或体验改进请选择“功能建议”。
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: dropdown
id: issue-type
attributes:
label: 问题类型
options:
- 页面或交互异常
- 配置或部署异常
- 升级异常
- 其他问题
validations:
required: true
- type: textarea
id: description
attributes:
label: 问题描述
description: 说明发生了什么、实际结果以及出现的频率。
validations:
required: true
- type: input
id: version
attributes:
label: 服务版本
description: 填写实际运行的完整版本号,不要只写“最新”或 latest;源码运行填写提交号。服务无法启动时填写准备运行的版本。
placeholder: 完整版本号或提交号
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
options:
- Docker Compose
- 其他容器部署
- 二进制运行
- 源码运行
- 其他方式(请在环境信息中说明)
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 填写操作系统与版本,并补充与问题相关的浏览器、访问方式或资源信息。
placeholder: |
操作系统与版本:
浏览器与版本(页面问题):
页面访问方式(HTTP / HTTPS,localhost / IP / 域名,无需真实地址):
CPU 核数与内存、容器资源限制(部署或性能问题):
Docker / Compose 版本(容器部署问题):
validations:
required: true
- type: dropdown
id: intermediary
attributes:
label: 是否经过中转或反向代理
description: |
指客户端或浏览器到本服务之间的链路。API 中转包括 New API、One API、CCH 等;反向代理或 CDN 包括 Nginx、Caddy、Cloudflare Tunnel 等。
如有中转,请在下方填写链路详情;本服务到上游的出站代理请在补充信息中说明。
options:
- 否,直接连接本服务
- 是,仅经过 API 中转服务
- 是,仅经过反向代理或 CDN
- 是,同时经过 API 中转和反向代理或 CDN
- 不确定
- 不适用(例如服务尚未启动)
validations:
required: true
- type: textarea
id: request-chain
attributes:
label: 请求链路
description: |
按调用顺序填写中间服务的名称与版本,例如:浏览器 → Cloudflare → Nginx → 本服务。不确定时填写已知部分。
如果已测试过直连本服务,请说明是否仍能复现;未测试可注明,不要求先完成对照测试。
placeholder: |
调用顺序与各中间服务版本:
是否启用请求参数或请求头改写(如已知):
直连本服务的对照结果(如已测试):
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 描述触发问题的操作;批量操作补充数量,升级问题说明升级方式。无法稳定复现时,说明已知触发条件。
placeholder: |
1. 使用什么配置或输入
2. 执行什么操作
3. 出现什么结果
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
- type: textarea
id: logs
attributes:
label: 错误信息与日志
description: |
提供脱敏后的页面提示、浏览器控制台错误或相关启动、升级日志;如有失败请求,补充状态码与响应片段。
可以在此上传诊断包或日志附件;粘贴日志时请使用 Markdown 代码块。不要上传完整配置或请求转储。
placeholder: |
发生时间与时区:
页面提示或终端错误:
浏览器控制台 / 服务日志:
失败请求的状态码与响应片段(如有):
- type: textarea
id: screenshots
attributes:
label: 截图或录屏
description: 页面或交互问题可在此拖入相关截图、录屏,并说明对应的操作。
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 最近有升级时填写升级前后版本及前后端是否同步更新;网络问题补充出站代理、系统代理、TUN 和自定义证书等相关环境。
placeholder: |
升级前版本 → 当前版本(如有):
前后端是否同步更新(如有升级):
最近的配置变更:
其他相关环境:
+2 -2
View File
@@ -6,9 +6,9 @@
## 提交 Issue
问题反馈使用仓库的 [Issue 表单](.github/ISSUE_TEMPLATE/bug_report.yml),填写问题类型、描述、版本与环境、复现步骤和预期结果。接口问题补充平台与认证类型、模型、客户端,以及脱敏后的请求、响应和请求 ID;页面问题附相关截图或录屏。
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏。
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议与协作规则调整可直接提交 Issue,说明场景、原因和建议方案。
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案。
## 问题与方案依据
+28 -35
View File
@@ -25,63 +25,56 @@
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
### 1. 下载部署文件并配置
### 一键安装
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限。
```bash
mkdir -p codex-proxy-rs/deploy && cd codex-proxy-rs
# 只解析一次最新正式版本,确保两个文件来自同一 Release。
CPR_RELEASE_URL="$(curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/zyycn/codex-proxy-rs/releases/latest)"
CPR_RELEASE_TAG="${CPR_RELEASE_URL##*/}"
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/compose.yaml" \
-o deploy/compose.yaml
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/config.example.yaml" \
-o deploy/config.example.yaml
install -d -m 0750 .runtime/postgres .runtime/redis
sudo install -d -m 0770 -o "$(id -u)" -g 10001 .runtime/data .runtime/logs
sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/install.sh -o install.sh && bash install.sh
```
也可将 `CPR_RELEASE_TAG` 设置为指定的发布标签。Release 附带的 `compose.yaml` 默认使用该版本镜像,
配置模板和部署文件均包含在 `checksums.txt` 中;不要混用 `main` 分支模板与已发布镜像。
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
自动生成密码、设置目录权限并启动服务。完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
[添加账号与客户端密钥](#添加账号与客户端密钥)。
分别生成数据库和 Redis 密码:
可在执行 `bash install.sh` 时传入环境变量:
| 变量 | 用途 | 默认值 |
| --- | --- | --- |
| `INSTALL_DIR` | 安装目录,建议使用绝对路径 | 当前目录下的 `codex-proxy-rs/` |
| `CPR_RELEASE_TAG` | 指定发布标签 | 最新正式版本 |
| `ADMIN_PASSWORD` | 管理员初始密码,至少 12 位,不能包含 `$`,不能使用常见弱口令 | 随机生成 |
例如,自定义安装目录:
```bash
openssl rand -hex 24
openssl rand -hex 24
INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
```
编辑 `deploy/config.yaml`,填好以下三项:
重复运行时请使用同一安装目录。检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
忽略传入的管理员密码,也不执行版本升级。
| 配置项 | 填写内容 |
| --- | --- |
| `store.database.password` | 第一个生成的 48 位十六进制密码 |
| `store.redis.password` | 第二个生成的 48 位十六进制密码 |
| `admin.default_password` | 管理员初始密码,至少 12 位,不能包含 `$` |
### 手动安装
### 2. 启动服务
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)。
```bash
docker compose -f deploy/compose.yaml config --quiet
docker compose -f deploy/compose.yaml pull
docker compose -f deploy/compose.yaml up -d --no-build --wait
curl -i http://127.0.0.1:8080/healthz
```
### 登录管理端
健康检查返回 `204 No Content` 后,打开 `http://127.0.0.1:8080`,
使用 `admin@cpr.local` 和刚设置的管理员密码登录。
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)。
### 3. 添加账号与客户端密钥
### 添加账号与客户端密钥
1. 在「账号」中添加账号,完成授权或导入。
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
3. 打开密钥的「使用密钥」,复制客户端配置。
账号自动冻结默认关闭,可在「系统设置 → 运行参数」中启用。启用后,账号频繁命中上游容量错误时
会暂停调度并显示为限流中;若开启恢复前探测,冷却到期后需探测成功才恢复。
## 客户端接入
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
+13
View File
@@ -1491,6 +1491,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror",
"tokio",
"tokio-tungstenite 0.29.0",
@@ -1509,6 +1510,7 @@ dependencies = [
"async-trait",
"base64 0.23.1",
"bytes",
"chrono-tz",
"futures",
"futures-timer",
"semver",
@@ -1539,6 +1541,7 @@ dependencies = [
"scraper",
"semver",
"serde",
"serde_ignored",
"serde_json",
"sha2 0.11.0",
"tar",
@@ -3344,6 +3347,16 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.151"
+23 -15
View File
@@ -7,26 +7,32 @@ use serde::Deserialize;
const CONFIG_SCHEMA_VERSION: u32 = 1;
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GatewayConfig {
schema_version: u32,
host: HostConfig,
store: gateway_store::StoreConfig,
admin: gateway_admin::AdminConfig,
#[serde(default)]
client: gateway_admin::ClientConfig,
api: gateway_api::ApiConfig,
openai: provider_openai::OpenAiConfig,
xai: provider_xai::XaiConfig,
#[serde(default, rename = "services")]
_compose_services: Option<serde::de::IgnoredAny>,
}
impl LoadableConfig for GatewayConfig {
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
if self.schema_version != CONFIG_SCHEMA_VERSION {
return Err(ConfigError::InvalidField("schema_version"));
}
self.host.resolve_and_validate(source_dir)?;
self.api
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.host
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(&runtime_data_dir)
@@ -34,9 +40,9 @@ impl LoadableConfig for GatewayConfig {
self.admin
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("admin"))?;
self.api
self.client
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
.map_err(|_| ConfigError::InvalidField("client"))?;
self.openai
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
@@ -55,10 +61,10 @@ pub async fn run() -> Result<(), BootstrapError> {
host,
store,
admin,
client,
api,
openai,
xai,
_compose_services: _,
} = config;
let host = gateway_host::initialize(host).await?;
@@ -75,15 +81,17 @@ pub async fn run() -> Result<(), BootstrapError> {
host.report_startup_ready("Core");
let mut admin = gateway_admin::initialize(
admin,
client,
store.admin_ports(),
vec![openai.admin_provider(), xai.admin_provider()],
core.snapshot_control(),
(
core.account_probe(),
host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
),
host.client_distribution_resolver(),
host.system_operations(),
gateway_admin::AdminRuntimePorts {
providers: vec![openai.admin_provider(), xai.admin_provider()],
snapshot: core.snapshot_control(),
account_probe: core.account_probe(),
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
client_distribution: host.client_distribution_resolver(),
system: host.system_operations(),
client_key_verifier: core.client_key_verifier(),
},
)
.await?;
host.report_startup_ready("Admin");
+3 -1
View File
@@ -70,6 +70,8 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
"identity.rs" => Some(&["validation"]),
"upstream.rs" => Some(&["validation"]),
"event.rs" => Some(&["metering", "operation", "upstream", "validation"]),
"account/selection.rs" => Some(&["account", "concurrency", "identity", "validation"]),
"concurrency.rs" => Some(&["error"]),
"account/store.rs" => Some(&["account", "error", "identity", "validation"]),
path if path.starts_with("policy/") => Some(&["account", "policy", "validation"]),
path if path.starts_with("account/") => Some(&["account", "identity", "validation"]),
@@ -176,7 +178,7 @@ const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
/// Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
("crates/gateway-api", &["admin", "openai"]),
("crates/gateway-api", &["admin", "auth", "openai"]),
(
"crates/gateway-host",
&[
+196 -40
View File
@@ -168,6 +168,50 @@ fn config_loader_should_resolve_paths_relative_to_config_file() {
assert!(debug.contains("frontend/dist"));
}
#[test]
fn config_loader_should_share_resolved_assets_with_system_update() {
const CHILD_ENV: &str = "CPR_TEST_UPDATE_ASSETS_CHILD";
let Ok(case) = std::env::var(CHILD_ENV) else {
// 使用子进程覆盖无环境变量、Docker 路径和相对路径,避免污染并行测试。
for (case, web_dist) in [
("binary", None),
("docker", Some("/app/web/dist")),
("relative", Some("../custom/dist")),
] {
let mut child = Command::new(std::env::current_exe().expect("test executable"));
child
.args([
"--exact",
"bootstrap::config_loader_should_share_resolved_assets_with_system_update",
])
.env(CHILD_ENV, case)
.env_remove("CPR_WEB_DIST_DIR");
if let Some(web_dist) = web_dist {
child.env("CPR_WEB_DIST_DIR", web_dist);
}
let output = child.output().expect("isolated configuration test");
assert!(
output.status.success(),
"{case}: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
return;
};
let mut document = valid_config_document();
document["api"]["asset_directory"] = serde_json::json!("../web/dist");
let (config, directory) = parse_config(&document.to_string()).expect("binary configuration");
let assets = match case.as_str() {
"docker" => std::path::PathBuf::from("/app/web/dist"),
"relative" => directory.path().join("deploy/../custom/dist"),
_ => directory.path().join("deploy/../web/dist"),
};
let debug = format!("{config:?}");
assert!(debug.contains(&format!("asset_directory: {assets:?}")));
assert!(debug.contains(&format!("web_dist_dir: Some({assets:?})")));
}
#[test]
fn config_loader_should_reject_missing_runtime_data_dir() {
let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", "");
@@ -228,16 +272,107 @@ fn bootstrap_config_debug_should_redact_all_passwords() {
}
#[test]
fn config_loader_should_reject_unknown_fields() {
assert_rejected(format!(
"{}\nunknown_terminal_field: true\n",
valid_config()
));
fn config_loader_should_ignore_unknown_fields_in_configuration_sections() {
let mut document = valid_config_document();
document["host"]["system_update"] = serde_json::json!({});
document["store"]["pool"] = serde_json::json!({});
for path in [
"",
"/host",
"/host/listen",
"/host/logging",
"/host/logging/file",
"/host/system_update",
"/store",
"/store/database",
"/store/redis",
"/store/pool",
"/admin",
"/client",
"/api",
"/openai",
"/openai/wire_profile",
"/xai",
"/xai/wire_profile",
] {
let mut extended = document.clone();
extended.pointer_mut(path).expect("configuration section")["unused_setting"] =
serde_json::json!({"nested": [true, null, "ignored"]});
parse_config(&extended.to_string())
.unwrap_or_else(|error| panic!("unknown field in {path}: {error}"));
}
}
#[test]
fn config_loader_should_reject_removed_tls_section() {
assert_rejected(valid_config().replace("openai:\n", "openai:\n tls: {}\n"));
fn config_loader_should_ignore_removed_tls_and_fingerprint_sections() {
let mut document = valid_config_document();
document["openai"]["tls"] = serde_json::json!({});
document["openai"]["fingerprint"] = serde_json::json!({"browser": "removed"});
parse_config(&document.to_string()).expect("removed provider settings are ignored");
}
#[test]
fn config_loader_should_reject_invalid_known_fields_alongside_unknown_fields() {
for port in [serde_json::json!(0), serde_json::json!("not-a-port")] {
let mut document = valid_config_document();
document["host"]["listen"]["unused_setting"] = serde_json::json!(true);
document["host"]["listen"]["port"] = port;
assert_rejected(document.to_string());
}
}
#[test]
fn config_loader_should_report_startup_configuration_diagnostics() {
const CHILD_ENV: &str = "CPR_TEST_CONFIG_DIAGNOSTICS_CHILD";
const UNUSED_SECRET: &str = "unused-value-must-not-appear-in-diagnostics";
if std::env::var_os(CHILD_ENV).is_some() {
gateway_host::load_config::<GatewayConfig>().expect("startup configuration");
return;
}
for case in ["normal", "unused", "missing"] {
let mut document = valid_config_document();
if case == "unused" {
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
} else if case == "missing" {
document["host"]["listen"]
.as_object_mut()
.unwrap()
.remove("port");
}
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("deploy")).unwrap();
fs::write(
directory.path().join("deploy/config.yaml"),
document.to_string(),
)
.unwrap();
let output = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"bootstrap::config_loader_should_report_startup_configuration_diagnostics",
"--nocapture",
])
.env(CHILD_ENV, "1")
.current_dir(directory.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.success(),
case != "missing",
"{case}: {stderr}"
);
match case {
"unused" => assert!(
stderr.contains("openai.wire_profile.location") && stderr.contains("已忽略"),
"{stderr}"
),
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
_ => assert!(!stderr.contains("警告"), "{stderr}"),
}
assert!(!stderr.contains(UNUSED_SECRET), "{stderr}");
assert!(!stderr.contains("services"), "{stderr}");
}
}
#[test]
@@ -279,11 +414,34 @@ fn config_loader_should_reject_admin_password_with_compose_interpolation() {
}
#[test]
fn config_loader_should_reject_removed_fingerprint_section() {
assert_rejected(valid_config().replace(
"openai:\n",
"openai:\n fingerprint:\n browser: removed\n",
));
fn config_loader_should_reject_zero_client_session_ttl() {
let mut config = valid_config_document();
*config
.pointer_mut("/client/session_ttl_minutes")
.expect("example client session TTL") = serde_json::json!(0);
assert_rejected(config.to_string());
}
#[test]
fn config_loader_should_default_missing_client_section() {
let mut config = valid_config_document();
config
.as_object_mut()
.expect("example config mapping")
.remove("client")
.expect("example client section");
parse_config(&config.to_string()).expect("client defaults when the section is omitted");
}
#[test]
fn config_loader_should_reject_missing_client_session_ttl() {
let mut config = valid_config_document();
config["client"]
.as_object_mut()
.expect("example client mapping")
.remove("session_ttl_minutes")
.expect("example client session TTL");
assert_rejected(config.to_string());
}
#[test]
@@ -311,36 +469,22 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() {
}
#[test]
fn config_loader_should_accept_empty_and_custom_request_locations() {
fn config_loader_should_ignore_removed_request_location_setting() {
let original = valid_config();
let omitted = original
.lines()
.filter(|line| !line.trim_start().starts_with("location:"))
.collect::<Vec<_>>()
.join("\n");
assert_ne!(original, omitted);
parse_config(&omitted).expect("location passthrough when omitted");
let location_line = original
.lines()
.find(|line| line.trim_start().starts_with("location:"))
.expect("example location");
for empty in [" location:", " location: null", " location: ~"] {
parse_config(&original.replace(location_line, empty)).expect("empty YAML location");
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");
}
let custom = original.replace(
"location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
"location: { country: 'NZ', region: 'Auckland', city: 'Auckland', timezone: 'Pacific/Auckland' }",
);
assert_ne!(original, custom);
parse_config(&custom).expect("custom location from YAML");
}
#[test]
fn config_loader_should_reject_invalid_request_location_timezones() {
let original = valid_config();
let invalid = original.replace("timezone: 'America/New_York'", "timezone: 'Not/A_Timezone'");
assert_ne!(original, invalid);
assert_rejected(invalid);
}
fn assert_rejected(config: String) {
@@ -365,6 +509,18 @@ fn valid_config() -> String {
)
}
fn valid_config_document() -> serde_json::Value {
// 按字段修改样例,避免注释或排版变化让测试输入悄悄失效;JSON 仍可由 YAML 文件入口加载。
config::Config::builder()
.add_source(config::File::from_str(
&valid_config(),
config::FileFormat::Yaml,
))
.build()
.and_then(config::Config::try_deserialize)
.expect("example config document")
}
fn parse_config(config: &str) -> Result<(GatewayConfig, tempfile::TempDir), String> {
let directory = tempfile::tempdir().map_err(|error| error.to_string())?;
let deploy = directory.path().join("deploy");
+1 -1
View File
@@ -36,7 +36,7 @@ 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"] }
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] }
[lints]
workspace = true
@@ -0,0 +1,304 @@
//! 容量熔断冻结的恢复编排:到期探测、失败顺延与自适应并发下调。
//!
//! 冻结事实保存在可丢失的 Redis 冷却中,本服务只组合 Admin 端口:
//! 观测(`AccountRuntimeStore`)、探测(`AccountsService`)与写回
//! (原子降低并发 / 按冻结代次结束探测)。旧观测不会覆盖管理员操作。
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use chrono::{DateTime, Utc};
use futures::StreamExt as _;
use gateway_core::account::{AccountConcurrencyLimit, ProviderAccountId};
use gateway_core::routing::UpstreamModelId;
use gateway_core::task::{ScheduledTask, WorkerCycleContext, WorkerTaskError};
use tracing::warn;
use crate::model::accounts::{
AccountConnectionTestEvent, AccountFreeze, AccountPageItem, AccountRuntimeSnapshot,
};
use crate::model::{MutationActor, MutationContext};
use crate::ports::store::{AccountRuntimeStore, AccountStore, SettingsStore};
use crate::use_case::accounts::AccountsService;
/// 自适应并发下调保留系数:下调到观测在途峰值的 80%。
const ADAPTIVE_CONCURRENCY_FACTOR: f64 = 0.8;
/// 自适应并发下限:过低的并发让账号几乎不可用,宁可保持冻结。
const ADAPTIVE_CONCURRENCY_FLOOR: u32 = 2;
/// worker 周期;冷却观测与探测都依赖该节奏。
pub const FREEZE_RECOVERY_INTERVAL: Duration = Duration::from_secs(30);
pub const FREEZE_RECOVERY_WORKER_OWNER: &str = "account-freeze-recovery";
pub const WORKER_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
pub const WORKER_MAXIMUM_BACKOFF: Duration = Duration::from_secs(60);
pub const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
pub const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
/// 系统审计上下文的固定请求 ID;账号并发更新审计由此溯源到自动冻结编排。
const SYSTEM_REQUEST_ID: &str = "account-freeze-recovery";
/// 冻结恢复所需的管理端口组合。
#[derive(Clone)]
pub struct FreezeRecoveryDeps {
pub accounts: Arc<dyn AccountsService>,
pub store: Arc<dyn AccountStore>,
pub runtime: Arc<dyn AccountRuntimeStore>,
pub settings: Arc<dyn SettingsStore>,
}
pub struct FreezeRecoveryTask {
deps: FreezeRecoveryDeps,
}
impl FreezeRecoveryTask {
pub fn new(deps: FreezeRecoveryDeps) -> Self {
Self { deps }
}
fn system_context() -> MutationContext {
MutationContext {
actor: MutationActor::System,
request_id: SYSTEM_REQUEST_ID.to_owned(),
}
}
/// 读取冻结策略;读取或校验失败跳过本轮,不能把未知配置当作允许解冻。
async fn freeze_policy(&self) -> Option<gateway_core::provider_ports::ProviderFreezePolicy> {
let settings = self.deps.settings.load_runtime_settings().await.ok()?;
gateway_core::provider_ports::ProviderFreezePolicy::try_new(
settings.account_auto_freeze_enabled,
settings.account_auto_freeze_threshold,
settings.account_auto_freeze_window_seconds,
settings.account_auto_freeze_duration_seconds,
settings.account_auto_freeze_probe_enabled,
settings.account_auto_freeze_probe_model,
settings.account_auto_freeze_adaptive_concurrency,
)
.ok()
}
async fn active_freezes(&self) -> BTreeMap<String, AccountFreeze> {
self.deps.runtime.active_freezes().await.unwrap_or_default()
}
async fn run_cycle_inner(&self) {
let Some(policy) = self.freeze_policy().await else {
return;
};
let freezes = self.active_freezes().await;
if freezes.is_empty() {
return;
}
if policy.enabled() && policy.adaptive_concurrency() {
self.adapt_concurrency_limits(&freezes, &policy).await;
}
self.recover_due_freezes(&freezes, &policy).await;
}
/// 对冻结中的账号执行自适应并发下调:目标为观测在途峰值的 80%(下限 2),
/// 只降不升;未观测到峰值证据的账号保持现状。
async fn adapt_concurrency_limits(
&self,
freezes: &BTreeMap<String, AccountFreeze>,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) {
let account_ids = freezes.keys().cloned().collect::<Vec<_>>();
let Ok(peaks) = self.deps.runtime.capacity_peaks(&account_ids).await else {
return;
};
for (account_id, peak) in peaks {
let Some(target) = adaptive_target(peak) else {
continue;
};
let Ok(account) = ProviderAccountId::new(account_id.clone()) else {
continue;
};
let Some(limit) = AccountConcurrencyLimit::new(target) else {
continue;
};
match self
.deps
.accounts
.lower_concurrency_limit(&Self::system_context(), account, limit)
.await
{
Ok(Some(_)) => {
tracing::info!(
account_id,
adapted_limit = target,
freeze_seconds = policy.freeze_duration().as_secs(),
"容量熔断:按观测在途峰值下调账号并发上限",
);
}
Ok(None) => {}
Err(error) => {
warn!(account_id, error = %error, "自适应并发下调失败");
}
}
}
}
async fn load_account(&self, account_id: &str) -> Option<Option<AccountPageItem>> {
let account_id = ProviderAccountId::new(account_id.to_owned()).ok()?;
self.deps
.store
.load_account(account_id.as_str(), AccountRuntimeSnapshot::default())
.await
.ok()
}
/// 到达探测时间后才恢复;关闭自动冻结或探测时,已有冻结仍等待原冷却结束。
async fn recover_due_freezes(
&self,
freezes: &BTreeMap<String, AccountFreeze>,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) {
for (account_id, freeze) in freezes {
if freeze.until > Utc::now() {
continue;
}
if policy.enabled() && policy.probe_enabled() && freeze.requires_probe {
self.probe_and_recover(account_id, freeze, policy).await;
} else if let Err(error) = self
.deps
.runtime
.finish_freeze(account_id, freeze, None)
.await
{
warn!(account_id, error = %error, "解除到期冻结失败");
}
}
}
async fn probe_and_recover(
&self,
account_id: &str,
freeze: &AccountFreeze,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) {
let Some(Some(item)) = self.load_account(account_id).await else {
return;
};
if !item.account.enabled {
// 停用账号没有自动恢复意义;解冻交给管理员手动恢复。
return;
}
let Ok(account) = ProviderAccountId::new(account_id.to_owned()) else {
return;
};
let Some(model) = self.resolve_probe_model(&account, policy).await else {
// 没有可用探测模型时按失败处理,顺延冻结等待下一轮。
self.postpone(&account, freeze, policy).await;
return;
};
let probe_succeeded = match self
.deps
.accounts
.test_connection(account.clone(), model)
.await
{
Ok(events) => drain_probe(events).await,
Err(error) => {
tracing::debug!(account_id, error = %error, "冻结恢复探测发起失败");
false
}
};
if probe_succeeded {
match self
.deps
.runtime
.finish_freeze(account_id, freeze, None)
.await
{
Ok(true) => tracing::info!(account_id, "冻结恢复探测成功:账号已解冻"),
Ok(false) => {}
Err(error) => warn!(account_id, error = %error, "解除冻结失败"),
}
} else {
self.postpone(&account, freeze, policy).await;
}
}
/// 探测失败只顺延本次仍存在的冻结,旧探测不能重新冻结已手动恢复的账号。
async fn postpone(
&self,
account: &ProviderAccountId,
freeze: &AccountFreeze,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) {
let Some(until) = SystemTime::now().checked_add(policy.freeze_duration()) else {
return;
};
let until = DateTime::<Utc>::from(until);
match self
.deps
.runtime
.finish_freeze(account.as_str(), freeze, Some(until))
.await
{
Ok(true) => {
tracing::info!(
account_id = account.as_str(),
postpone_seconds = policy.freeze_duration().as_secs(),
"冻结恢复探测失败:账号冻结顺延",
);
}
Ok(false) => {}
Err(error) => {
warn!(
account_id = account.as_str(),
error = %error,
"冻结顺延失败"
);
}
}
}
async fn resolve_probe_model(
&self,
account: &ProviderAccountId,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) -> Option<UpstreamModelId> {
if let Some(model) = policy.probe_model() {
return UpstreamModelId::new(model.to_owned()).ok();
}
let models = self.deps.accounts.models(account, false).await.ok()?;
models.models.into_iter().next().map(|model| model.id)
}
}
impl ScheduledTask for FreezeRecoveryTask {
fn run_cycle(
&self,
context: WorkerCycleContext,
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
if context.cancellation().is_cancelled() {
return Ok(());
}
self.run_cycle_inner().await;
Ok(())
})
}
}
/// 自适应目标并发:观测峰值 × 0.8 向下取整,下限 2;无证据(峰值为 0)不调整。
fn adaptive_target(peak_in_flight: u32) -> Option<u32> {
if peak_in_flight == 0 {
return None;
}
let scaled = (f64::from(peak_in_flight) * ADAPTIVE_CONCURRENCY_FACTOR).floor() as u32;
Some(scaled.max(ADAPTIVE_CONCURRENCY_FLOOR))
}
/// 消耗连接测试事件流并返回是否以 `Completed` 终止。
async fn drain_probe(mut events: crate::model::accounts::AccountConnectionTestEventStream) -> bool {
let mut completed = false;
while let Some(event) = events.next().await {
match event {
AccountConnectionTestEvent::Completed => completed = true,
AccountConnectionTestEvent::Failed { .. } => return false,
_ => {}
}
}
completed
}
+162 -26
View File
@@ -5,27 +5,32 @@
use std::{fmt, path::Path, sync::Arc, time::Duration};
use gateway_core::{
engine::execution::ClientKeyVerifier,
engine::probe::AccountProbe,
routing::ProviderKind,
runtime::SnapshotControl,
task::{
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerRegistration,
WorkerRunnable,
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerLeaseRequest,
WorkerRegistration, WorkerRunnable, WorkerSchedule,
},
};
use secrecy::{ExposeSecret as _, SecretString};
use serde::Deserialize;
pub mod backup;
pub mod freeze_recovery;
pub mod model;
pub mod ports;
mod use_case;
pub use use_case::key_usage::KeyUsageService;
pub use use_case::{
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
backup::BackupService, client_distribution::ClientDistributionService,
client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService,
proxies::ProxiesService, settings::SettingsService, system::SystemService, xai::XaiService,
client_keys::ClientKeyService, import_tasks::ImportTasksService,
observability::ObservabilityService, openai::OpenAiService, proxies::ProxiesService,
settings::SettingsService, system::SystemService, xai::XaiService,
};
use model::{AdminError, AdminErrorKind};
@@ -58,6 +63,7 @@ const WEAK_INITIAL_PASSWORDS: &[&str] = &[
];
const BACKUP_WORKER_OWNER: &str = "backup";
const DEFAULT_CLIENT_SESSION_TTL_MINUTES: u64 = 24 * 60;
/// 只用于首次幂等创建默认管理员的启动密码。
#[derive(Clone, Deserialize)]
@@ -91,13 +97,40 @@ impl fmt::Debug for InitialAdminPassword {
/// 管理控制面的启动配置。
#[derive(Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AdminConfig {
pub session_ttl_minutes: u64,
pub default_username: String,
pub default_password: InitialAdminPassword,
}
/// Client 登录域的通用启动配置。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ClientConfig {
pub session_ttl_minutes: u64,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
session_ttl_minutes: DEFAULT_CLIENT_SESSION_TTL_MINUTES,
}
}
}
impl ClientConfig {
/// 校验 Client session TTL;当前配置不含相对路径。
///
/// # Errors
///
/// 会话有效期为零或无法安全换算时返回错误。
pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), AdminConfigError> {
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
return Err(AdminConfigError::InvalidField("client.session_ttl_minutes"));
}
Ok(())
}
}
impl AdminConfig {
/// 校验 Admin-owned 字段;当前配置不含相对路径。
///
@@ -151,6 +184,7 @@ pub enum AdminConfigError {
pub struct AdminServices {
proxies: Arc<dyn ProxiesService>,
auth: Arc<dyn AuthService>,
key_usage: Arc<dyn KeyUsageService>,
accounts: Arc<dyn AccountsService>,
account_groups: Arc<dyn AccountGroupService>,
client_keys: Arc<dyn ClientKeyService>,
@@ -161,9 +195,26 @@ pub struct AdminServices {
openai: Arc<dyn OpenAiService>,
xai: Arc<dyn XaiService>,
backups: Arc<dyn BackupService>,
import_tasks: Arc<dyn ImportTasksService>,
}
impl AdminServices {
#[must_use]
pub fn import_tasks(&self) -> &dyn ImportTasksService {
self.import_tasks.as_ref()
}
#[must_use]
pub fn key_usage(&self) -> &dyn KeyUsageService {
self.key_usage.as_ref()
}
/// 取得账号服务的共享句柄;后台编排(冻结恢复 worker)需要持有 Arc。
#[must_use]
pub fn accounts_handle(&self) -> Arc<dyn AccountsService> {
Arc::clone(&self.accounts)
}
#[must_use]
pub fn proxies(&self) -> &dyn ProxiesService {
self.proxies.as_ref()
@@ -237,12 +288,23 @@ impl AdminBundle {
self.services.clone()
}
/// 取出 Backup Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host。
/// 取出 Admin Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host。
pub fn take_worker_contributions(&mut self) -> Vec<WorkerContribution> {
std::mem::take(&mut self.worker_contributions)
}
}
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
pub struct AdminRuntimePorts {
pub providers: Vec<Arc<dyn ProviderAdmin>>,
pub snapshot: Arc<dyn SnapshotControl>,
pub account_probe: Arc<dyn AccountProbe>,
pub proxy_probe: Arc<dyn ports::proxy::ProxyProbe>,
pub client_distribution: Arc<dyn ClientDistributionResolver>,
pub system: Arc<dyn SystemOperations>,
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
}
/// 校验配置、建立动态 Provider 注册表并完成默认管理员幂等初始化。
///
/// # Errors
@@ -250,17 +312,25 @@ impl AdminBundle {
/// 配置非法、Provider 注册冲突/缺失或默认管理员初始化失败时返回错误。
pub async fn initialize(
mut config: AdminConfig,
mut client_config: ClientConfig,
store: AdminStorePorts,
providers: Vec<Arc<dyn ProviderAdmin>>,
snapshot: Arc<dyn SnapshotControl>,
probes: (Arc<dyn AccountProbe>, Arc<dyn ports::proxy::ProxyProbe>),
client_distribution: Arc<dyn ClientDistributionResolver>,
system: Arc<dyn SystemOperations>,
runtime: AdminRuntimePorts,
) -> Result<AdminBundle, AdminError> {
let (probe, proxy_probe) = probes;
let AdminRuntimePorts {
providers,
snapshot,
account_probe: probe,
proxy_probe,
client_distribution,
system,
client_key_verifier,
} = runtime;
config
.resolve_and_validate(Path::new("."))
.map_err(|error| AdminError::invalid(error.to_string()))?;
client_config
.resolve_and_validate(Path::new("."))
.map_err(|error| AdminError::invalid(error.to_string()))?;
let registry = ProviderAdminRegistry::new(providers).map_err(map_provider_registry_error)?;
let openai = registry
.require(&provider_kind(OPENAI_PROVIDER_KIND)?)
@@ -272,7 +342,9 @@ pub async fn initialize(
let auth = Arc::new(DefaultAuthService::new(
config.default_username,
config.session_ttl_minutes,
client_config.session_ttl_minutes,
store.auth(),
client_key_verifier,
));
auth.ensure_default_admin(config.default_password.expose())
.await?;
@@ -296,7 +368,28 @@ pub async fn initialize(
backup_ports.dump(),
backup_ports.object_store(),
);
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
auth.clone(),
store.client_keys(),
store.observability(),
));
let openai = Arc::new(DefaultOpenAiService::new(
openai,
store.accounts(),
store.proxies(),
snapshot.clone(),
));
let xai = Arc::new(DefaultXaiService::new(
xai,
store.accounts(),
store.proxies(),
snapshot.clone(),
));
let import_tasks =
use_case::import_tasks::DefaultImportTasksService::new(openai.clone(), xai.clone());
let import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone());
let services = AdminServices {
key_usage,
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
store.proxies(),
proxy_probe,
@@ -304,7 +397,7 @@ pub async fn initialize(
registry.clone(),
)),
auth,
accounts,
accounts: accounts.clone(),
account_groups: Arc::new(DefaultAccountGroupService::new(
store.account_groups(),
store.account_runtime(),
@@ -326,21 +419,33 @@ pub async fn initialize(
snapshot.clone(),
)),
system: Arc::new(DefaultSystemService::new(system)),
openai: Arc::new(DefaultOpenAiService::new(
openai,
store.accounts(),
store.proxies(),
snapshot.clone(),
)),
xai: Arc::new(DefaultXaiService::new(
xai,
store.accounts(),
store.proxies(),
snapshot.clone(),
)),
openai,
xai,
import_tasks,
backups,
};
let worker_contributions = backup_worker_contribution(backup_task)?;
let freeze_recovery =
freeze_recovery::FreezeRecoveryTask::new(freeze_recovery::FreezeRecoveryDeps {
accounts: Arc::clone(&accounts) as Arc<dyn AccountsService>,
store: store.accounts(),
runtime: store.account_runtime(),
settings: store.settings(),
});
let mut worker_contributions = backup_worker_contribution(backup_task)?;
let id = WorkerId::try_new(WorkerKind::AccountImport, "admin")
.map_err(|_| AdminError::internal("导入 Worker ID 不合法"))?;
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
.map_err(|_| AdminError::internal("导入 Worker 重启策略不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Daemon {
restart,
task: Box::new(import_task),
},
)
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
worker_contributions.push(WorkerContribution::Registration(registration));
worker_contributions.extend(freeze_recovery_worker_contribution(freeze_recovery)?);
Ok(AdminBundle {
services,
worker_contributions,
@@ -366,6 +471,37 @@ fn backup_worker_contribution(
Ok(vec![WorkerContribution::Registration(registration)])
}
/// 冻结恢复 Worker 注册:按固定周期扫描活跃冻结,owner 固定。
fn freeze_recovery_worker_contribution(
task: freeze_recovery::FreezeRecoveryTask,
) -> Result<Vec<WorkerContribution>, AdminError> {
let id = WorkerId::try_new(
WorkerKind::AccountFreezeRecovery,
freeze_recovery::FREEZE_RECOVERY_WORKER_OWNER,
)
.map_err(|_| AdminError::internal("冻结恢复 Worker ID 不合法"))?;
let schedule = WorkerSchedule::try_new(
freeze_recovery::FREEZE_RECOVERY_INTERVAL,
freeze_recovery::WORKER_INITIAL_BACKOFF,
freeze_recovery::WORKER_MAXIMUM_BACKOFF,
freeze_recovery::WORKER_LEASE_TTL,
freeze_recovery::WORKER_LEASE_RENEWAL,
)
.map_err(|_| AdminError::internal("冻结恢复 Worker 调度配置不合法"))?;
let lease = WorkerLeaseRequest::try_new(id.clone(), freeze_recovery::WORKER_LEASE_TTL)
.map_err(|_| AdminError::internal("冻结恢复 Worker 租约配置不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task: Box::new(task),
},
)
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
Ok(vec![WorkerContribution::Registration(registration)])
}
fn provider_kind(value: &'static str) -> Result<ProviderKind, AdminError> {
ProviderKind::new(value).map_err(|_| AdminError::internal("内置 Provider 类型不合法"))
}
@@ -18,12 +18,14 @@ pub use gateway_core::account::{
QuotaState, resolve_account_status,
};
/// 导入时统一应用的账号调度与分组设置;缺省时保留原有导入语义。
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountImportSettings {
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
}
@@ -67,11 +69,20 @@ pub struct AccountListQuery {
/// Admin query service 从运行态存储取得的当前账号冷却快照。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AccountRuntimeSnapshot {
pub rate_limited_until: BTreeMap<String, DateTime<Utc>>,
pub cooldown: BTreeMap<String, gateway_core::account::AccountCooldown>,
/// `None` 表示实时 lease 存储不可用;`Some` 中未出现的账号当前使用量为零。
pub in_flight: Option<BTreeMap<String, u64>>,
}
/// 恢复任务读取的冻结快照;generation 将异步结果绑定到本次冻结。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountFreeze {
pub credential_revision: Revision,
pub until: DateTime<Utc>,
pub generation: String,
pub requires_probe: bool,
}
/// Optional account membership filter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountGroupFilter {
@@ -86,6 +97,7 @@ pub struct AccountRecord {
pub provider_kind: ProviderKind,
pub groups: Vec<AccountGroupRef>,
pub name: String,
pub notes: Option<String>,
pub email: Option<String>,
pub upstream_user_id: Option<String>,
pub upstream_account_id: Option<String>,
@@ -98,6 +110,7 @@ pub struct AccountRecord {
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub model_access: gateway_core::account::AccountModelAccess,
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub credential_state: CredentialState,
pub credential_observed_at: DateTime<Utc>,
@@ -217,9 +230,12 @@ pub struct AccountSummary {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccount {
pub account_id: String,
/// 缺省保留备注;空字符串清空备注。
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
@@ -231,14 +247,15 @@ pub struct AccountUpdateResult {
pub account_id: gateway_core::account::ProviderAccountId,
}
/// 一批账号可编辑事实的一次性替换命令。
/// 仅修改显式字段的批量账号设置命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchUpdateAccounts {
pub account_ids: Vec<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
pub enabled: Option<bool>,
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
pub weight: Option<AccountWeight>,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Option<Vec<gateway_core::routing::AccountGroupId>>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
+57 -17
View File
@@ -1,6 +1,7 @@
//! 管理员认证、会话和安全审计事实。
//! 控制面认证、统一会话和管理员安全审计事实。
use chrono::{DateTime, Utc};
use gateway_core::{engine::execution::ClientAuthenticationError, policy::ClientApiKeyId};
use super::{MutationActor, MutationContext, Revision};
@@ -40,43 +41,82 @@ impl AdminRequestContext {
}
}
/// 管理员登录命令。
/// 登录方式只决定凭据验证流程,不能直接授予会话权限。
#[derive(Clone, PartialEq, Eq)]
pub struct LoginCommand {
pub username: Option<String>,
pub password: String,
pub enum LoginCommand {
Admin {
username: Option<String>,
password: String,
},
Key {
api_key: String,
},
}
impl std::fmt::Debug for LoginCommand {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("LoginCommand")
.field("username", &self.username)
.field("password", &"[REDACTED]")
.finish()
match self {
Self::Admin { username, .. } => formatter
.debug_struct("AdminLogin")
.field("username", username)
.field("password", &"[REDACTED]")
.finish(),
Self::Key { .. } => formatter
.debug_struct("KeyLogin")
.field("api_key", &"[REDACTED]")
.finish(),
}
}
}
/// 登录成功后返回的会话事实。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Clone, PartialEq, Eq)]
pub struct LoginResult {
pub session_id: String,
pub expires_at: DateTime<Utc>,
pub session: AuthSession,
}
impl std::fmt::Debug for LoginResult {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("LoginResult")
.field("session_id", &"[REDACTED]")
.field("session", &self.session)
.finish()
}
}
/// 登录状态机可被 API 精确映射的失败类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum LoginError {
#[error("invalid administrator credentials")]
#[error("invalid login credentials")]
InvalidCredentials,
#[error("administrator authentication is unavailable")]
#[error("too many login attempts")]
TooManyAttempts { retry_after_seconds: u64 },
#[error("authentication is unavailable")]
Unavailable,
}
/// Redis 中可恢复的管理员会话。
impl From<ClientAuthenticationError> for LoginError {
fn from(error: ClientAuthenticationError) -> Self {
match error {
ClientAuthenticationError::InvalidKey => Self::InvalidCredentials,
ClientAuthenticationError::SnapshotUnavailable => Self::Unavailable,
}
}
}
/// 服务端已验证的身份绑定,不保存密码或原始 API Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminSession {
pub admin_user_id: String,
pub enum SessionSubject {
Admin { admin_user_id: String },
Key { client_key_id: ClientApiKeyId },
}
/// 两种登录方式共用的固定有效期会话。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthSession {
pub subject: SessionSubject,
pub expires_at: DateTime<Utc>,
}
@@ -0,0 +1,84 @@
//! 进程内账号导入任务;只保存执行所需输入与可公开的结果。
use chrono::{DateTime, Utc};
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
use uuid::Uuid;
use super::{MutationContext, provider_credentials::ImportCredentials};
pub const MAX_IMPORT_TASK_ITEMS: usize = 200;
#[derive(Debug)]
pub struct ImportTaskInput {
pub provider: ProviderKind,
pub command: ImportCredentials,
}
#[derive(Debug)]
pub struct SubmitImportTask {
pub submission_id: Uuid,
/// API 对完整输入计算摘要;用于区分丢失响应后的重试与修改后的新提交。
pub fingerprint: [u8; 32],
pub context: MutationContext,
pub items: Vec<ImportTaskInput>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportItemStatus {
Pending,
Running,
Succeeded,
Failed,
Unknown,
Skipped,
}
impl ImportItemStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Running => "running",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::Unknown => "unknown",
Self::Skipped => "skipped",
}
}
}
#[derive(Debug, Clone)]
pub struct ImportTaskItem {
pub index: usize,
pub provider: ProviderKind,
pub status: ImportItemStatus,
pub account_ids: Vec<ProviderAccountId>,
pub message: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ImportTaskCounts {
pub pending: usize,
pub running: usize,
pub succeeded: usize,
pub failed: usize,
pub unknown: usize,
pub skipped: usize,
pub imported_accounts: usize,
}
#[derive(Debug, Clone)]
pub struct ImportTaskSummary {
pub task_id: Uuid,
pub created_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub stop_requested: bool,
pub total: usize,
pub counts: ImportTaskCounts,
}
#[derive(Debug, Clone)]
pub struct ImportTaskDetail {
pub summary: ImportTaskSummary,
pub items: Vec<ImportTaskItem>,
}
@@ -0,0 +1,41 @@
//! 单页 Key 用量的查询合同;身份范围不由调用方提供。
use super::{
PageSize,
client_keys::ClientKeyRecord,
observability::{
HealthTimeline, OpsErrorPage, RequestMetricPoint, TimeRange, UsageOverview, UsagePage,
},
};
#[derive(Debug, Clone)]
pub struct KeyUsageQuery {
pub range: TimeRange,
pub model: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub enum KeyUsageRecordKind {
Success,
Error,
}
#[derive(Debug, Clone)]
pub struct KeyUsageRecordsQuery {
pub usage: KeyUsageQuery,
pub kind: KeyUsageRecordKind,
pub current_page: u32,
pub page_size: PageSize,
}
pub struct KeyUsageOverview {
pub key: ClientKeyRecord,
pub overview: UsageOverview,
pub trend: Vec<RequestMetricPoint>,
pub health_timeline: HealthTimeline,
}
pub enum KeyUsageRecords {
Success(UsagePage),
Error(OpsErrorPage),
}
@@ -8,6 +8,8 @@ pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod provider_credentials;
pub mod proxies;
@@ -21,6 +23,7 @@ pub mod system;
pub enum AdminErrorKind {
Invalid,
Unauthorized,
Forbidden,
NotFound,
Conflict,
RateLimited,
@@ -99,6 +99,7 @@ impl fmt::Debug for PrepareCredentialImport {
/// Provider 已验证、可由 Store 原子创建的一份 credential。
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialCreate {
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub account_id: ProviderAccountId,
pub provider_kind: ProviderKind,
@@ -584,6 +585,7 @@ pub struct CredentialDeletionResult {
pub struct RotateCredential {
pub mutation: CredentialMutation,
pub provider_material: ProviderDocument,
pub settings: Option<super::accounts::UpdateAccount>,
}
/// Provider 校验手工轮换材料时所需的非事务输入。
@@ -612,6 +614,8 @@ pub struct PreparedCredentialRotationFacts {
pub name: String,
pub email: Option<String>,
pub plan_type: Option<String>,
/// Token 刷新保留提交时的资料,不以准备阶段的副本覆盖新套餐。
pub preserve_profile: bool,
pub provider_material: ProviderDocument,
pub has_refresh_token: bool,
pub access_token_expires_at: Option<DateTime<Utc>>,
@@ -663,6 +667,7 @@ impl fmt::Debug for PreparedCredentialRotation {
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialRotationCommit {
pub prepared: PreparedCredentialRotationFacts,
pub settings: Option<super::accounts::UpdateAccount>,
}
impl fmt::Debug for RotateCredential {
@@ -671,6 +676,7 @@ impl fmt::Debug for RotateCredential {
.debug_struct("RotateCredential")
.field("mutation", &self.mutation)
.field("provider_material", &self.provider_material)
.field("settings", &self.settings)
.finish()
}
}
@@ -65,6 +65,7 @@ pub struct ProxyTestResult {
#[derive(Debug, Clone)]
pub struct ProxyRecord {
pub location: Option<gateway_core::account::RequestLocation>,
pub id: String,
pub name: String,
pub proxy: OutboundProxy,
@@ -86,12 +87,15 @@ pub struct ProxyPage {
#[derive(Debug, Clone)]
pub struct NewProxy {
pub location: Option<gateway_core::account::RequestLocation>,
pub name: String,
pub proxy: OutboundProxy,
}
#[derive(Debug, Clone)]
pub struct UpdateProxy {
/// 外层为空保留配置,内层为空恢复全局继承。
pub location: Option<Option<gateway_core::account::RequestLocation>>,
pub id: String,
pub revision: Revision,
pub name: String,
@@ -18,34 +18,58 @@ pub use gateway_core::account::RotationStrategy;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeSettings {
pub config_revision: Revision,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u32,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub updated_at: DateTime<Utc>,
}
/// 原子替换运行设置的命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplaceRuntimeSettings {
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u32,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
}
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
@@ -170,6 +170,14 @@ pub trait ProviderAdmin: Send + Sync {
command: PrepareCredentialRefresh,
) -> Result<PreparedCredentialRotation, ProviderAdminError>;
/// 可公开的账号连接设置;实现只能显式投影非敏感字段,不能返回凭据原文。
async fn account_configuration(
&self,
_account_id: &ProviderAccountId,
) -> Result<Option<ProviderDocument>, ProviderAdminError> {
Ok(None)
}
async fn quota(
&self,
request: ProviderQuotaRequest,
@@ -2,7 +2,7 @@
//!
//! 端口按业务资源拆分,方法使用领域模型,不暴露连接池、事务或 Redis client。
use std::sync::Arc;
use std::{collections::BTreeMap, net::IpAddr, sync::Arc, time::Duration};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
@@ -20,7 +20,7 @@ use crate::model::{
AccountUpdateResult, AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult,
AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
},
auth::{AdminAuditEvent, AdminSession},
auth::{AdminAuditEvent, AuthSession},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
@@ -159,6 +159,14 @@ pub trait AccountStore: Send + Sync {
context: &MutationContext,
) -> AdminStoreResult<AccountUpdateResult>;
/// 在事务内按最新启用状态、账号上限和全局默认值判断,只降低并发上限。
async fn lower_concurrency_limit(
&self,
account_id: &gateway_core::account::ProviderAccountId,
limit: gateway_core::account::AccountConcurrencyLimit,
context: &MutationContext,
) -> AdminStoreResult<Option<AccountUpdateResult>>;
async fn recover_account(
&self,
account_id: &gateway_core::account::ProviderAccountId,
@@ -193,9 +201,28 @@ pub trait AccountRuntimeStore: Send + Sync {
&self,
account_ids: &[String],
) -> AdminStoreResult<AccountRuntimeSnapshot>;
/// 容量熔断自动冻结中的账号与其冻结截止时间;429 临时限流不包含在内。
async fn active_freezes(
&self,
) -> AdminStoreResult<BTreeMap<String, crate::model::accounts::AccountFreeze>>;
/// 读取容量失败窗口内观测到的在途并发峰值(自适应并发下调的证据)。
async fn capacity_peaks(
&self,
account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>>;
/// 仅当冻结快照仍匹配时解除或顺延;旧探测不得覆盖手动恢复或新一轮冻结。
async fn finish_freeze(
&self,
account_id: &str,
expected: &crate::model::accounts::AccountFreeze,
postpone_until: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool>;
}
/// 管理员密码、会话和安全审计。
/// 控制面凭据、统一会话、登录限流与管理员安全审计。
#[async_trait]
pub trait AuthStore: Send + Sync {
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
@@ -208,19 +235,38 @@ pub trait AuthStore: Send + Sync {
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>>;
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>>;
async fn store_session(&self, session_id: &str, session: &AdminSession)
-> AdminStoreResult<()>;
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()>;
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>>;
async fn client_key_enabled(
&self,
id: &gateway_core::policy::ClientApiKeyId,
) -> AdminStoreResult<bool>;
/// 原子消费来源桶与全局桶的一次登录尝试;被拒绝时返回建议重试间隔。
async fn consume_login_attempt(
&self,
source_ip: IpAddr,
source_limit: u32,
global_limit: u32,
window: Duration,
) -> AdminStoreResult<Option<Duration>>;
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
}
/// Client API Key 管理写入。
/// Client API Key 资料读取与管理写入。
#[async_trait]
pub trait ClientKeyStore: Send + Sync {
/// 按已验证的 ID 读取资料,不读取完整明文 Key。
async fn get_client_key(
&self,
id: &gateway_core::policy::ClientApiKeyId,
) -> AdminStoreResult<Option<ClientKeyRecord>>;
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage>;
async fn reveal_client_key(
@@ -192,11 +192,7 @@ fn project_group_runtime(
let mut accounts_by_group = BTreeMap::<&str, Vec<&str>>::new();
for member in members {
let mut status = member.status.clone();
status.rate_limited_until = runtime
.rate_limited_until
.get(&member.account_id)
.copied()
.map(Into::into);
status.cooldown = runtime.cooldown.get(&member.account_id).copied();
status_by_account
.entry(member.account_id.as_str())
.or_insert_with(|| resolve_account_status(&status, now).status);
@@ -17,8 +17,8 @@ use crate::{
AdminError, MutationContext,
accounts::{
AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery,
AccountPageItem, AccountUpdateResult, AccountUsageWindowQuery, AccountsUpdateResult,
BatchUpdateAccounts, UpdateAccount,
AccountPageItem, AccountUpdateResult, AccountUsage, AccountUsageWindowQuery,
AccountsUpdateResult, BatchUpdateAccounts, UpdateAccount,
},
observability::TimeRange,
provider_credentials::{
@@ -73,12 +73,26 @@ pub trait AccountsService: Send + Sync {
command: UpdateAccount,
) -> Result<AccountUpdateResult, AdminError>;
async fn lower_concurrency_limit(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
limit: gateway_core::account::AccountConcurrencyLimit,
) -> Result<Option<AccountUpdateResult>, AdminError>;
async fn batch_update(
&self,
context: &MutationContext,
command: BatchUpdateAccounts,
) -> Result<AccountsUpdateResult, AdminError>;
async fn account_configuration(
&self,
_account_id: &ProviderAccountId,
) -> Result<Option<crate::model::provider_credentials::ProviderDocument>, AdminError> {
Ok(None)
}
async fn quota(
&self,
account_id: &ProviderAccountId,
@@ -250,6 +264,37 @@ impl DefaultAccountsService {
Ok(())
}
async fn load_api_key_usage(
&self,
accounts: &[AccountPageItem],
) -> Result<BTreeMap<String, AccountUsage>, AdminError> {
let now = Utc::now();
// API Key 没有套餐周期;本地累计直接查询账号创建后仍保留的请求记录。
let windows = accounts
.iter()
.filter(|item| item.account.authentication_kind == "api_key")
.map(|item| AccountUsageWindowQuery {
account_id: item.account.id.clone(),
key: "account-lifetime".to_owned(),
range: TimeRange {
start: item.account.created_at,
end: now,
},
})
.collect::<Vec<_>>();
if windows.is_empty() {
return Ok(BTreeMap::new());
}
Ok(self
.accounts
.load_account_usage_by_windows(&windows)
.await
.map_err(|error| map_store_error(error, "API Key account usage"))?
.into_iter()
.map(|result| (result.account_id, result.usage))
.collect())
}
async fn load_directory_item(
&self,
account_id: &ProviderAccountId,
@@ -287,9 +332,15 @@ impl DefaultAccountsService {
std::slice::from_mut(&mut quota),
)
.await?;
let usage = quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone());
let usage = self
.load_api_key_usage(std::slice::from_ref(&stored))
.await?
.remove(&stored.account.id)
.or_else(|| {
quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone())
});
Ok(AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
stored.account.provider_kind.as_str(),
@@ -377,14 +428,17 @@ impl AccountsService for DefaultAccountsService {
.collect::<Result<Vec<_>, AdminError>>()?;
self.attach_quota_local_usage(&page.items, &mut quotas)
.await?;
let mut api_key_usage = self.load_api_key_usage(&page.items).await?;
let items = page
.items
.into_iter()
.zip(quotas)
.map(|(mut item, quota)| {
let usage = quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone());
let usage = api_key_usage.remove(&item.account.id).or_else(|| {
quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone())
});
AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
item.account.provider_kind.as_str(),
@@ -535,6 +589,27 @@ impl AccountsService for DefaultAccountsService {
Ok(result)
}
async fn lower_concurrency_limit(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
limit: gateway_core::account::AccountConcurrencyLimit,
) -> Result<Option<AccountUpdateResult>, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
let result = self
.accounts
.lower_concurrency_limit(&account_id, limit, context)
.await
.map_err(|error| map_store_error(error, "provider account"))?;
if let Some(result) = &result {
provider
.account_facts_changed(std::slice::from_ref(&account_id))
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
}
Ok(result)
}
async fn batch_update(
&self,
context: &MutationContext,
@@ -570,7 +645,7 @@ impl AccountsService for DefaultAccountsService {
.await
.map_err(|error| map_store_error(error, "provider accounts"))?;
for (provider, provider_ids) in providers.values() {
if !enabled {
if enabled == Some(false) {
for account_id in provider_ids {
provider.account_unavailable(account_id).await;
}
@@ -581,6 +656,17 @@ impl AccountsService for DefaultAccountsService {
Ok(result)
}
async fn account_configuration(
&self,
account_id: &ProviderAccountId,
) -> Result<Option<crate::model::provider_credentials::ProviderDocument>, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.account_configuration(account_id)
.await
.map_err(|error| map_provider_error(error, "provider account configuration"))
}
async fn quota(
&self,
account_id: &ProviderAccountId,
+171 -77
View File
@@ -1,20 +1,22 @@
//! 管理员认证状态机。
//! 控制面登录、会话恢复与身份校验的唯一 owner。
use std::sync::Arc;
use std::{net::IpAddr, sync::Arc, time::Duration as StdDuration};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use async_trait::async_trait;
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 subtle::ConstantTimeEq as _;
use uuid::Uuid;
use crate::{
model::{
AdminError,
AdminError, AdminErrorKind,
auth::{
AdminAuditEvent, AdminSession, AuditActorKind, LoginCommand, LoginError, LoginResult,
AdminAuditEvent, AuditActorKind, AuthSession, LoginCommand, LoginError, LoginResult,
SessionSubject,
},
},
ports::store::AuthStore,
@@ -22,61 +24,108 @@ use crate::{
use super::map_store_error;
/// API 鉴权与管理员登录消费的统一服务。
/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定。
#[async_trait]
pub trait AuthService: Send + Sync {
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(
&self,
session_id: Option<&str>,
) -> Result<Option<String>, AdminError>;
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError>;
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError>;
async fn validate_session(&self, session_id: Option<&str>) -> Result<bool, AdminError>;
async fn login(
&self,
command: LoginCommand,
source_ip: IpAddr,
previous_session_id: Option<&str>,
) -> Result<LoginResult, LoginError>;
async fn logout(&self, session_id: &str) -> Result<(), AdminError>;
}
/// 会话有效期上限(366 天);超出的配置按上限截断,保证 TTL 构造与
/// `Utc::now() + session_ttl` 永不越界。
const MAX_SESSION_TTL_MINUTES: i64 = 366 * 24 * 60;
const LOGIN_WINDOW: StdDuration = StdDuration::from_secs(60);
const LOGIN_ATTEMPTS_PER_SOURCE: u32 = 10;
const LOGIN_ATTEMPTS_GLOBAL: u32 = 200;
/// 单管理员认证策略的最终实现。
pub(crate) struct DefaultAuthService {
default_admin_user_id: String,
session_ttl: Duration,
admin_session_ttl: Duration,
key_session_ttl: Duration,
store: Arc<dyn AuthStore>,
verifier: Arc<dyn ClientKeyVerifier>,
}
impl DefaultAuthService {
#[must_use]
pub(crate) fn new(
default_admin_user_id: impl Into<String>,
session_ttl_minutes: u64,
admin_session_ttl_minutes: u64,
key_session_ttl_minutes: u64,
store: Arc<dyn AuthStore>,
verifier: Arc<dyn ClientKeyVerifier>,
) -> Self {
let minutes = i64::try_from(session_ttl_minutes)
.unwrap_or(MAX_SESSION_TTL_MINUTES)
.clamp(1, MAX_SESSION_TTL_MINUTES);
Self {
default_admin_user_id: default_admin_user_id.into(),
session_ttl: Duration::minutes(minutes),
admin_session_ttl: session_ttl(admin_session_ttl_minutes),
key_session_ttl: session_ttl(key_session_ttl_minutes),
store,
verifier,
}
}
fn auth_audit(&self, action: &str, occurred_at: chrono::DateTime<Utc>) -> AdminAuditEvent {
fn auth_audit(&self, action: &str, admin_user_id: &str) -> AdminAuditEvent {
AdminAuditEvent {
id: format!("audit_{}", Uuid::now_v7().simple()),
actor_kind: AuditActorKind::AdminSession,
actor_admin_user_id: Some(self.default_admin_user_id.clone()),
actor_ref: crate::model::auth::admin_session_actor_ref(&self.default_admin_user_id),
actor_admin_user_id: Some(admin_user_id.to_owned()),
actor_ref: crate::model::auth::admin_session_actor_ref(admin_user_id),
request_id: None,
action: action.to_owned(),
entity_kind: "admin_session".to_owned(),
entity_ref: self.default_admin_user_id.clone(),
entity_ref: admin_user_id.to_owned(),
config_revision: None,
changed_fields: Vec::new(),
occurred_at,
occurred_at: Utc::now(),
}
}
async fn authenticate(&self, command: LoginCommand) -> Result<SessionSubject, LoginError> {
match command {
LoginCommand::Admin { username, password } => {
if username.as_deref().unwrap_or(&self.default_admin_user_id)
!= self.default_admin_user_id
{
return Err(LoginError::InvalidCredentials);
}
let 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)? {
return Err(LoginError::InvalidCredentials);
}
Ok(SessionSubject::Admin {
admin_user_id: self.default_admin_user_id.clone(),
})
}
LoginCommand::Key { api_key } => {
if api_key.is_empty() || api_key.len() > 4096 {
return Err(LoginError::InvalidCredentials);
}
let client_key_id = self.verifier.verify_client_key(&api_key)?;
if !self
.store
.client_key_enabled(&client_key_id)
.await
.map_err(|_| LoginError::Unavailable)?
{
return Err(LoginError::InvalidCredentials);
}
Ok(SessionSubject::Key { client_key_id })
}
}
}
}
@@ -91,22 +140,51 @@ impl AuthService for DefaultAuthService {
.map_err(|error| map_store_error(error, "administrator"))
}
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError> {
let Some(session_id) = session_id.filter(|value| !value.is_empty()) else {
return Ok(None);
};
let Some(session) = self
.store
.load_session(session_id)
.await
.map_err(|error| map_store_error(error, "authentication session"))?
else {
return Ok(None);
};
if session.expires_at <= Utc::now() {
let _ = self.store.delete_session(session_id).await;
return Ok(None);
}
if let SessionSubject::Key { client_key_id } = &session.subject
&& !self
.store
.client_key_enabled(client_key_id)
.await
.map_err(|error| map_store_error(error, "session key"))?
{
let _ = self.store.delete_session(session_id).await;
return Ok(None);
}
Ok(Some(session))
}
async fn resolve_admin_user_id(
&self,
session_id: Option<&str>,
) -> Result<Option<String>, AdminError> {
let Some(session_id) = session_id else {
return Ok(None);
};
self.store
.load_session(session_id)
.await
.map(|session| {
session
.filter(|session| session.expires_at > Utc::now())
.map(|session| session.admin_user_id)
})
.map_err(|error| map_store_error(error, "administrator session"))
match self
.session(session_id)
.await?
.map(|session| session.subject)
{
Some(SessionSubject::Admin { admin_user_id }) => Ok(Some(admin_user_id)),
Some(SessionSubject::Key { .. }) => Err(AdminError::new(
AdminErrorKind::Forbidden,
"当前身份无权访问管理接口",
)),
None => Ok(None),
}
}
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError> {
@@ -124,69 +202,77 @@ impl AuthService for DefaultAuthService {
}))
}
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError> {
if command
.username
.as_deref()
.unwrap_or(&self.default_admin_user_id)
!= self.default_admin_user_id
{
return Err(LoginError::InvalidCredentials);
}
let hash = self
async fn login(
&self,
command: LoginCommand,
source_ip: IpAddr,
previous_session_id: Option<&str>,
) -> Result<LoginResult, LoginError> {
if let Some(retry_after) = self
.store
.load_password_hash(&self.default_admin_user_id)
.await
.map_err(|_| LoginError::Unavailable)?
.ok_or(LoginError::InvalidCredentials)?;
if !verify_admin_password(&command.password, &hash).map_err(|_| LoginError::Unavailable)? {
return Err(LoginError::InvalidCredentials);
}
let session_id = random_session_token();
let expires_at = Utc::now() + self.session_ttl;
self.store
.store_session(
&session_id,
&AdminSession {
admin_user_id: self.default_admin_user_id.clone(),
expires_at,
},
.consume_login_attempt(
source_ip,
LOGIN_ATTEMPTS_PER_SOURCE,
LOGIN_ATTEMPTS_GLOBAL,
LOGIN_WINDOW,
)
.await
.map_err(|_| LoginError::Unavailable)?;
if self
.store
.append_audit_event(self.auth_audit("admin.login", Utc::now()))
.map_err(|_| LoginError::Unavailable)?
{
return Err(LoginError::TooManyAttempts {
retry_after_seconds: retry_after.as_secs().max(1),
});
}
let subject = self.authenticate(command).await?;
let ttl = match subject {
SessionSubject::Admin { .. } => self.admin_session_ttl,
SessionSubject::Key { .. } => self.key_session_ttl,
};
let session = AuthSession {
subject,
expires_at: Utc::now() + ttl,
};
let session_id = random_session_token();
self.store
.store_session(&session_id, &session)
.await
.is_err()
.map_err(|_| LoginError::Unavailable)?;
if let SessionSubject::Admin { admin_user_id } = &session.subject
&& self
.store
.append_audit_event(self.auth_audit("admin.login", admin_user_id))
.await
.is_err()
{
let _ = self.store.delete_session(&session_id).await;
return Err(LoginError::Unavailable);
}
// 新身份验证成功后才撤销旧会话;撤销失败时不向浏览器提交新会话。
if let Some(previous) = previous_session_id.filter(|value| !value.is_empty())
&& self.logout(previous).await.is_err()
{
let _ = self.store.delete_session(&session_id).await;
return Err(LoginError::Unavailable);
}
Ok(LoginResult {
session_id,
expires_at,
session,
})
}
async fn validate_session(&self, session_id: Option<&str>) -> Result<bool, AdminError> {
Ok(self.resolve_admin_user_id(session_id).await?.is_some())
}
async fn logout(&self, session_id: &str) -> Result<(), AdminError> {
let session = self
.store
.delete_session(session_id)
.await
.map_err(|error| map_store_error(error, "administrator session"))?;
if let Some(session) = session {
let mut event = self.auth_audit("admin.logout", Utc::now());
event.actor_admin_user_id = Some(session.admin_user_id.clone());
event.actor_ref = crate::model::auth::admin_session_actor_ref(&session.admin_user_id);
event.entity_ref = session.admin_user_id;
.map_err(|error| map_store_error(error, "authentication session"))?;
if let Some(AuthSession {
subject: SessionSubject::Admin { admin_user_id },
..
}) = session
{
self.store
.append_audit_event(event)
.append_audit_event(self.auth_audit("admin.logout", &admin_user_id))
.await
.map_err(|error| map_store_error(error, "administrator audit"))?;
}
@@ -194,6 +280,14 @@ impl AuthService for DefaultAuthService {
}
}
fn session_ttl(minutes: u64) -> Duration {
Duration::minutes(
i64::try_from(minutes)
.unwrap_or(MAX_SESSION_TTL_MINUTES)
.clamp(1, MAX_SESSION_TTL_MINUTES),
)
}
fn hash_admin_password(password: &str) -> Result<String, AdminError> {
Argon2::default()
.hash_password(password.as_bytes())
@@ -0,0 +1,372 @@
//! 导入队列由 Host 管理生命周期,HTTP 只负责接受输入和读取快照。
use std::{
collections::VecDeque,
panic::AssertUnwindSafe,
sync::{Arc, Mutex, MutexGuard},
time::Duration,
};
use chrono::Utc;
use futures::{FutureExt as _, StreamExt as _, future::BoxFuture, stream::FuturesUnordered};
use gateway_core::{
lifecycle::CancellationToken,
task::{DaemonTask, WorkerTaskError},
};
use tokio::{sync::Notify, time::Instant};
use uuid::Uuid;
use super::{openai::OpenAiService, xai::XaiService};
use crate::model::{
AdminError, AdminErrorKind, MutationActor, MutationContext,
import_tasks::{
ImportItemStatus, ImportTaskCounts, ImportTaskDetail, ImportTaskInput, ImportTaskItem,
ImportTaskSummary, MAX_IMPORT_TASK_ITEMS, SubmitImportTask,
},
};
const CONCURRENCY: usize = 3;
const MAX_ACTIVE_TASKS: usize = 8;
const MAX_RETAINED_TASKS: usize = 100;
const RETENTION: Duration = Duration::from_secs(3600);
pub trait ImportTasksService: Send + Sync {
fn submit(&self, command: SubmitImportTask) -> Result<ImportTaskSummary, AdminError>;
fn list(&self, context: &MutationContext) -> Vec<ImportTaskSummary>;
fn detail(
&self,
context: &MutationContext,
task_id: Uuid,
) -> Result<ImportTaskDetail, AdminError>;
fn stop(
&self,
context: &MutationContext,
task_id: Uuid,
) -> Result<ImportTaskDetail, AdminError>;
}
struct Entry {
task_id: Uuid,
submission_id: Uuid,
fingerprint: [u8; 32],
owner: MutationActor,
created_at: chrono::DateTime<Utc>,
finished_at: Option<chrono::DateTime<Utc>>,
finished_instant: Option<Instant>,
stop_requested: bool,
items: Vec<ImportTaskItem>,
inputs: Vec<Option<ImportTaskInput>>,
}
impl Entry {
fn summary(&self) -> ImportTaskSummary {
let mut counts = ImportTaskCounts::default();
for item in &self.items {
match item.status {
ImportItemStatus::Pending => counts.pending += 1,
ImportItemStatus::Running => counts.running += 1,
ImportItemStatus::Succeeded => counts.succeeded += 1,
ImportItemStatus::Failed => counts.failed += 1,
ImportItemStatus::Unknown => counts.unknown += 1,
ImportItemStatus::Skipped => counts.skipped += 1,
}
counts.imported_accounts += item.account_ids.len();
}
ImportTaskSummary {
task_id: self.task_id,
created_at: self.created_at,
finished_at: self.finished_at,
stop_requested: self.stop_requested,
total: self.items.len(),
counts,
}
}
fn detail(&self) -> ImportTaskDetail {
ImportTaskDetail {
summary: self.summary(),
items: self.items.clone(),
}
}
fn finish_if_done(&mut self) {
if self.finished_at.is_none()
&& self.items.iter().all(|item| {
!matches!(
item.status,
ImportItemStatus::Pending | ImportItemStatus::Running
)
})
{
self.finished_at = Some(Utc::now());
self.finished_instant = Some(Instant::now());
// 终态仅保留结果,连已清空的输入槽位分配也一并释放。
self.inputs = Vec::new();
}
}
fn stop(&mut self) {
if self.finished_at.is_some() {
return;
}
self.stop_requested = true;
for (item, input) in self.items.iter_mut().zip(&mut self.inputs) {
if item.status == ImportItemStatus::Pending {
item.status = ImportItemStatus::Skipped;
*input = None;
}
}
self.finish_if_done();
}
}
#[derive(Default)]
struct Registry {
tasks: VecDeque<Entry>,
shutting_down: bool,
}
pub(crate) struct DefaultImportTasksService {
registry: Mutex<Registry>,
notify: Notify,
openai: Arc<dyn OpenAiService>,
xai: Arc<dyn XaiService>,
}
impl DefaultImportTasksService {
pub(crate) fn new(openai: Arc<dyn OpenAiService>, xai: Arc<dyn XaiService>) -> Arc<Self> {
Arc::new(Self {
registry: Mutex::default(),
notify: Notify::new(),
openai,
xai,
})
}
fn registry(&self) -> MutexGuard<'_, Registry> {
let mut registry = self
.registry
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
registry.tasks.retain(|entry| {
entry
.finished_instant
.is_none_or(|at| at.elapsed() < RETENTION)
});
registry
}
fn take_next(&self) -> Option<(Uuid, usize, ImportTaskInput)> {
let mut registry = self.registry();
if registry.shutting_down {
return None;
}
let task_index = registry
.tasks
.iter()
.position(|entry| entry.inputs.iter().any(Option::is_some))?;
// 轮转任务,避免大批次独占后续全部执行机会。
let mut entry = registry.tasks.remove(task_index)?;
let index = entry.inputs.iter().position(Option::is_some)?;
let input = entry.inputs[index].take()?;
entry.items[index].status = ImportItemStatus::Running;
let id = entry.task_id;
registry.tasks.push_back(entry);
Some((id, index, input))
}
async fn execute(&self, id: Uuid, index: usize, input: ImportTaskInput) {
let operation = async {
match input.provider.as_str() {
"openai" => self.openai.import_document(input.command).await,
"xai" => self.xai.import_document(input.command).await,
_ => Err(AdminError::invalid("不支持的导入平台")),
}
};
let result = AssertUnwindSafe(operation).catch_unwind().await;
let mut registry = self.registry();
let Some(entry) = registry.tasks.iter_mut().find(|entry| entry.task_id == id) else {
return;
};
let item = &mut entry.items[index];
match result {
Ok(Ok(result)) => {
item.status = ImportItemStatus::Succeeded;
item.account_ids = result.credential_ids;
}
Ok(Err(error)) => {
// 存储或发布异常也可能发生在提交之后;不自动重放可能轮换 RT 的操作。
item.status = if matches!(
error.kind(),
AdminErrorKind::UpstreamResultUnknown
| AdminErrorKind::Internal
| AdminErrorKind::Unavailable
) {
ImportItemStatus::Unknown
} else {
ImportItemStatus::Failed
};
item.message = Some(error.message().to_owned());
}
Err(_) => {
item.status = ImportItemStatus::Unknown;
item.message = Some("执行中断,请先核对账号列表再决定是否重新导入".to_owned());
}
}
entry.finish_if_done();
}
}
impl ImportTasksService for DefaultImportTasksService {
fn submit(&self, command: SubmitImportTask) -> Result<ImportTaskSummary, AdminError> {
if command.items.is_empty()
|| command.items.len() > MAX_IMPORT_TASK_ITEMS
|| command.items.iter().any(|item| {
!matches!(item.provider.as_str(), "openai" | "xai")
|| item.command.context.actor != command.context.actor
})
{
return Err(AdminError::invalid("导入任务需包含 1–200 条有效输入"));
}
let mut registry = self.registry();
if let Some(existing) = registry.tasks.iter().find(|entry| {
entry.owner == command.context.actor && entry.submission_id == command.submission_id
}) {
return if existing.fingerprint == command.fingerprint {
Ok(existing.summary())
} else {
Err(AdminError::new(
AdminErrorKind::Conflict,
"提交标识已用于其他导入内容",
))
};
}
if registry.shutting_down {
return Err(AdminError::new(
AdminErrorKind::Unavailable,
"服务正在关闭,暂不接受导入任务",
));
}
if registry
.tasks
.iter()
.filter(|entry| entry.finished_at.is_none())
.count()
>= MAX_ACTIVE_TASKS
|| registry.tasks.len() >= MAX_RETAINED_TASKS
{
return Err(AdminError::new(
AdminErrorKind::RateLimited,
"导入任务已满,请稍后再试",
));
}
let items = command
.items
.iter()
.enumerate()
.map(|(index, input)| ImportTaskItem {
index: index + 1,
provider: input.provider.clone(),
status: ImportItemStatus::Pending,
account_ids: Vec::new(),
message: None,
})
.collect();
let entry = Entry {
task_id: Uuid::now_v7(),
submission_id: command.submission_id,
fingerprint: command.fingerprint,
owner: command.context.actor,
created_at: Utc::now(),
finished_at: None,
finished_instant: None,
stop_requested: false,
items,
inputs: command.items.into_iter().map(Some).collect(),
};
let summary = entry.summary();
registry.tasks.push_back(entry);
drop(registry);
self.notify.notify_one();
Ok(summary)
}
fn list(&self, context: &MutationContext) -> Vec<ImportTaskSummary> {
let mut tasks: Vec<_> = self
.registry()
.tasks
.iter()
.filter(|entry| entry.owner == context.actor)
.map(Entry::summary)
.collect();
tasks.sort_by_key(|entry| std::cmp::Reverse(entry.task_id));
tasks
}
fn detail(
&self,
context: &MutationContext,
task_id: Uuid,
) -> Result<ImportTaskDetail, AdminError> {
self.registry()
.tasks
.iter()
.find(|entry| entry.task_id == task_id && entry.owner == context.actor)
.map(Entry::detail)
.ok_or_else(|| AdminError::not_found("导入任务不存在或已过期"))
}
fn stop(
&self,
context: &MutationContext,
task_id: Uuid,
) -> Result<ImportTaskDetail, AdminError> {
let mut registry = self.registry();
let entry = registry
.tasks
.iter_mut()
.find(|entry| entry.task_id == task_id && entry.owner == context.actor)
.ok_or_else(|| AdminError::not_found("导入任务不存在或已过期"))?;
entry.stop();
Ok(entry.detail())
}
}
pub(crate) struct ImportTaskWorker(pub Arc<DefaultImportTasksService>);
impl DaemonTask for ImportTaskWorker {
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let mut active = FuturesUnordered::new();
// 固定一个取消 future,避免循环反复注册中立 CancellationToken 的 waiter。
let cancelled = cancellation.cancelled();
tokio::pin!(cancelled);
let mut cleanup = tokio::time::interval(Duration::from_secs(60));
loop {
if cancellation.is_cancelled() {
let mut registry = self.0.registry();
registry.shutting_down = true;
for entry in &mut registry.tasks {
entry.stop();
}
break;
}
while active.len() < CONCURRENCY {
let Some((id, index, input)) = self.0.take_next() else {
break;
};
active.push(self.0.execute(id, index, input));
}
tokio::select! {
() = &mut cancelled => {},
_ = active.next(), if !active.is_empty() => {},
() = self.0.notify.notified() => {},
_ = cleanup.tick() => { drop(self.0.registry()); },
}
}
// Host 提供总关闭预算;此处让已发出的交换与入库尽量完成。
while active.next().await.is_some() {}
Ok(())
})
}
}
@@ -0,0 +1,165 @@
//! Key 自助查询只从统一会话取得范围,复用现有观测和额度账本。
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use crate::{
AuthService,
model::{
AdminError, AdminErrorKind,
auth::SessionSubject,
key_usage::{
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
KeyUsageRecordsQuery,
},
observability::{
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
},
},
ports::store::{ClientKeyStore, ObservabilityStore},
};
use gateway_core::policy::ClientApiKeyId;
use super::{map_store_error, observability::health_timeline_at};
#[async_trait]
pub trait KeyUsageService: Send + Sync {
async fn overview(
&self,
session_id: Option<&str>,
query: KeyUsageQuery,
) -> Result<Option<KeyUsageOverview>, AdminError>;
async fn records(
&self,
session_id: Option<&str>,
query: KeyUsageRecordsQuery,
) -> Result<Option<KeyUsageRecords>, AdminError>;
}
pub(crate) struct DefaultKeyUsageService {
auth: Arc<dyn AuthService>,
keys: Arc<dyn ClientKeyStore>,
observations: Arc<dyn ObservabilityStore>,
}
impl DefaultKeyUsageService {
pub(crate) fn new(
auth: Arc<dyn AuthService>,
keys: Arc<dyn ClientKeyStore>,
observations: Arc<dyn ObservabilityStore>,
) -> Self {
Self {
auth,
keys,
observations,
}
}
async fn key_id(&self, session_id: Option<&str>) -> Result<Option<ClientApiKeyId>, AdminError> {
match self
.auth
.session(session_id)
.await?
.map(|session| session.subject)
{
Some(SessionSubject::Key { client_key_id }) => Ok(Some(client_key_id)),
Some(SessionSubject::Admin { .. }) => Err(AdminError::new(
AdminErrorKind::Forbidden,
"当前身份无权访问密钥用量接口",
)),
None => Ok(None),
}
}
}
fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
UsageFilter {
client_api_key_ref: Some(id.as_str().to_owned()),
model,
..UsageFilter::default()
}
}
#[async_trait]
impl KeyUsageService for DefaultKeyUsageService {
async fn overview(
&self,
session_id: Option<&str>,
query: KeyUsageQuery,
) -> Result<Option<KeyUsageOverview>, AdminError> {
let Some(id) = self.key_id(session_id).await? else {
return Ok(None);
};
let Some(key) = self
.keys
.get_client_key(&id)
.await
.map_err(|error| map_store_error(error, "key usage profile"))?
.filter(|key| key.enabled)
else {
return Ok(None);
};
let filter = usage_filter(&id, query.model);
let now = Utc::now();
// 健康条始终展示北京时间今日,不随历史范围或模型筛选改变。
let today = TimeRange {
start: china_day_start(now),
end: now,
};
let (overview, trend, health_points) = futures::try_join!(
self.observations.usage_summary(query.range, filter.clone()),
self.observations.usage_trend(query.range, filter),
self.observations
.usage_trend(today, usage_filter(&id, None)),
)
.map_err(|error| map_store_error(error, "key usage overview"))?;
Ok(Some(KeyUsageOverview {
key,
overview,
trend,
health_timeline: health_timeline_at(&health_points, now),
}))
}
async fn records(
&self,
session_id: Option<&str>,
query: KeyUsageRecordsQuery,
) -> Result<Option<KeyUsageRecords>, AdminError> {
let Some(id) = self.key_id(session_id).await? else {
return Ok(None);
};
let result = match query.kind {
KeyUsageRecordKind::Success => KeyUsageRecords::Success(
self.observations
.list_usage_records(UsageQuery {
range: query.usage.range,
filter: usage_filter(&id, query.usage.model),
current_page: query.current_page,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage records"))?,
),
KeyUsageRecordKind::Error => KeyUsageRecords::Error(
self.observations
.list_ops_errors(OpsErrorQuery {
range: query.usage.range,
filter: OpsErrorFilter {
client_api_key_ref: Some(id.as_str().to_owned()),
model: query.usage.model,
..OpsErrorFilter::default()
},
current_page: query.current_page,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage errors"))?,
),
};
Ok(Some(result))
}
}
@@ -6,6 +6,8 @@ pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod openai;
pub mod proxies;
@@ -344,12 +346,19 @@ async fn commit_authorization(
async fn commit_credential_rotation(
accounts: &dyn AccountStore,
prepared: PreparedCredentialRotation,
settings: Option<crate::model::accounts::UpdateAccount>,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_rotation(CredentialRotationCommit { prepared: facts }, context)
.commit_credential_rotation(
CredentialRotationCommit {
prepared: facts,
settings,
},
context,
)
.await
{
Ok(result) => {
@@ -371,7 +380,13 @@ async fn commit_credential_refresh(
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_refresh(CredentialRotationCommit { prepared: facts }, context)
.commit_credential_refresh(
CredentialRotationCommit {
prepared: facts,
settings: None,
},
context,
)
.await
{
Ok(result) => {
@@ -852,7 +852,10 @@ impl DefaultObservabilityService {
/// 按指定时刻计算中国自然日的 96 个 15 分钟健康桶。
#[must_use]
fn health_timeline_at(records: &[RequestMetricPoint], now: DateTime<Utc>) -> HealthTimeline {
pub(super) fn health_timeline_at(
records: &[RequestMetricPoint],
now: DateTime<Utc>,
) -> HealthTimeline {
let current_slot = quarter_hour_start(now);
let start = china_day_start(now);
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
@@ -189,6 +189,17 @@ impl OpenAiService for DefaultOpenAiService {
) -> Result<CredentialMutationResult, AdminError> {
let context = command.mutation.context;
let account_id = command.mutation.account_id;
if command
.settings
.as_ref()
.is_some_and(|settings| settings.account_id != account_id.as_str())
{
return Err(AdminError::invalid("凭据和账号设置的目标不一致"));
}
let disable_account = command
.settings
.as_ref()
.is_some_and(|settings| !settings.enabled);
let details = required_credential(
self.accounts.as_ref(),
self.provider.provider_kind(),
@@ -209,10 +220,14 @@ impl OpenAiService for DefaultOpenAiService {
let result = commit_credential_rotation(
self.accounts.as_ref(),
prepared,
command.settings,
&context,
"OpenAI credential rotation",
)
.await?;
if disable_account {
self.provider.account_unavailable(&account_id).await;
}
self.provider
.account_facts_changed(std::slice::from_ref(&result.account_id))
.await;
@@ -181,6 +181,11 @@ impl ProxiesService for DefaultProxiesService {
context: &MutationContext,
) -> Result<ProxyMutation, AdminError> {
command.name = validate_name(&command.name)?;
command.location = command
.location
.map(|location| location.normalized())
.transpose()
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
let result = self
.store
.create(command, context)
@@ -196,6 +201,11 @@ impl ProxiesService for DefaultProxiesService {
context: &MutationContext,
) -> Result<ProxyMutation, AdminError> {
command.name = validate_name(&command.name)?;
command.location = command
.location
.map(|location| location.map(|value| value.normalized()).transpose())
.transpose()
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
let result = self
.store
.update(command, context)
@@ -114,15 +114,23 @@ impl SettingsService for DefaultSettingsService {
}
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
let valid = command.refresh_margin_seconds > 0
let valid = command.request_location.validate().is_ok()
&& command.refresh_margin_seconds > 0
&& command.refresh_concurrency > 0
&& command.max_concurrent_per_account > 0
&& command.max_waiting_per_key <= 1_000
&& command.max_waiting_per_account <= 1_000
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
&& command.usage_retention_days >= 31
&& command.ops_event_retention_days > 0
&& command.audit_retention_days > 0
&& valid_client_version(command.min_codex_desktop_version.as_deref())
&& valid_client_version(command.min_codex_cli_version.as_deref())
&& i64::try_from(command.request_interval_ms).is_ok();
&& valid_probe_model(command.account_auto_freeze_probe_model.as_deref())
&& i64::try_from(command.request_interval_ms).is_ok()
&& (2..=1_000).contains(&command.account_auto_freeze_threshold)
&& (60..=3_600).contains(&command.account_auto_freeze_window_seconds)
&& (300..=604_800).contains(&command.account_auto_freeze_duration_seconds);
if valid {
Ok(())
} else {
@@ -133,3 +141,12 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
fn valid_client_version(value: Option<&str>) -> bool {
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
}
fn valid_probe_model(value: Option<&str>) -> bool {
value.is_none_or(|value| {
!value.is_empty()
&& value.len() <= 128
&& value == value.trim()
&& !value.bytes().any(|byte| byte.is_ascii_control())
})
}
@@ -633,22 +633,39 @@ impl AuthStore for FakeAuthStore {
async fn load_session(
&self,
_session_id: &str,
) -> AdminStoreResult<Option<gateway_admin::model::auth::AdminSession>> {
) -> AdminStoreResult<Option<gateway_admin::model::auth::AuthSession>> {
Ok(None)
}
async fn store_session(
&self,
_session_id: &str,
_session: &gateway_admin::model::auth::AdminSession,
_session: &gateway_admin::model::auth::AuthSession,
) -> AdminStoreResult<()> {
Ok(())
}
async fn delete_session(
&self,
_session_id: &str,
) -> AdminStoreResult<Option<gateway_admin::model::auth::AdminSession>> {
) -> AdminStoreResult<Option<gateway_admin::model::auth::AuthSession>> {
Ok(None)
}
async fn client_key_enabled(
&self,
_: &gateway_core::policy::ClientApiKeyId,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn consume_login_attempt(
&self,
_: std::net::IpAddr,
_: u32,
_: u32,
_: std::time::Duration,
) -> AdminStoreResult<Option<std::time::Duration>> {
Ok(None)
}
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
self.audit.lock().expect("audit").push(event);
Ok(())
+9 -1
View File
@@ -23,6 +23,14 @@ mod backup;
mod model;
mod use_case;
#[test]
fn client_config_should_default_to_a_one_day_session() {
assert_eq!(
gateway_admin::ClientConfig::default().session_ttl_minutes,
1440
);
}
#[test]
fn revision_should_reject_zero() {
assert!(Revision::new(0).is_err());
@@ -88,7 +96,7 @@ fn admin_api_key_debug_should_redact_plaintext() {
#[test]
fn login_command_debug_should_redact_password() {
let command = LoginCommand {
let command = LoginCommand::Admin {
username: None,
password: "secret-password".to_owned(),
};
@@ -5,7 +5,7 @@ use std::{
};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use chrono::{DateTime, Duration, Utc};
use gateway_admin::{
model::{
MutationContext, PageSize, Revision,
@@ -168,13 +168,35 @@ impl AccountRuntimeStore for FakeRuntimeStore {
) -> AdminStoreResult<AccountRuntimeSnapshot> {
*self.requested_accounts.lock().expect("requested accounts") = account_ids.to_vec();
Ok(AccountRuntimeSnapshot {
rate_limited_until: BTreeMap::from([(
cooldown: BTreeMap::from([(
"acct_limited".to_owned(),
Utc::now() + Duration::minutes(5),
std::time::SystemTime::from(Utc::now() + Duration::minutes(5)).into(),
)]),
in_flight: Some(BTreeMap::from([("acct_available".to_owned(), 2)])),
})
}
async fn active_freezes(
&self,
) -> AdminStoreResult<BTreeMap<String, gateway_admin::model::accounts::AccountFreeze>> {
Ok(BTreeMap::new())
}
async fn capacity_peaks(
&self,
_account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>> {
Ok(BTreeMap::new())
}
async fn finish_freeze(
&self,
_account_id: &str,
_expected: &gateway_admin::model::accounts::AccountFreeze,
_postpone_until: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool> {
Ok(false)
}
}
fn group_record() -> AccountGroupRecord {
@@ -215,7 +237,7 @@ fn member(account_id: &str, total_slots: u64) -> AccountGroupMemberFact {
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::default(),
rate_limited_until: None,
cooldown: None,
last_error_reason: None,
last_error_message: None,
},
@@ -67,6 +67,7 @@ pub(super) struct FakeProviderAdmin {
kind: ProviderKind,
events: EventLog,
failure: Mutex<Option<ProviderAdminError>>,
import_gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
quota_failure: Mutex<Option<ProviderAdminErrorKind>>,
pending: Arc<Mutex<Option<PendingAuthorizationMutation>>>,
retry_authorization_after_abort: Mutex<bool>,
@@ -90,6 +91,7 @@ impl FakeProviderAdmin {
kind: ProviderKind::new(kind).expect("provider kind"),
events,
failure: Mutex::new(None),
import_gate: Mutex::new(None),
quota_failure: Mutex::new(None),
pending: Arc::new(Mutex::new(None)),
retry_authorization_after_abort: Mutex::new(false),
@@ -108,6 +110,12 @@ impl FakeProviderAdmin {
})
}
pub(super) fn block_imports(&self) -> Arc<tokio::sync::Semaphore> {
let gate = Arc::new(tokio::sync::Semaphore::new(0));
*self.import_gate.lock().expect("import gate") = Some(gate.clone());
gate
}
pub(super) fn fail_next(&self, kind: ProviderAdminErrorKind) {
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
}
@@ -235,6 +243,7 @@ impl FakeProviderAdmin {
name: account.name.clone(),
email: account.email.clone(),
plan_type: account.plan_type.clone(),
preserve_profile: false,
provider_material: document(),
has_refresh_token: account.has_refresh_token,
access_token_expires_at: account
@@ -332,6 +341,10 @@ impl ProviderAdmin for FakeProviderAdmin {
_command: PrepareCredentialImport,
) -> Result<PreparedCredentialImport, ProviderAdminError> {
self.record("provider.prepare_import");
let gate = self.import_gate.lock().expect("import gate").clone();
if let Some(gate) = gate {
gate.acquire().await.expect("import permit").forget();
}
self.require_available()?;
let account_ids = self
.import_account_ids
@@ -531,6 +544,8 @@ pub(super) struct FakeAccountStore {
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
quota_window_queries: Mutex<Vec<AccountUsageWindowQuery>>,
quota_forecast_history: Mutex<QuotaForecastHistory>,
update_commands: Mutex<Vec<UpdateAccount>>,
pub(super) lowered_limits: Mutex<Vec<(String, u32)>>,
}
impl FakeAccountStore {
@@ -538,7 +553,7 @@ impl FakeAccountStore {
Self::with_account(account_record(kind), events)
}
fn with_account(account: AccountRecord, events: EventLog) -> Arc<Self> {
pub(super) fn with_account(account: AccountRecord, events: EventLog) -> Arc<Self> {
Arc::new(Self {
events,
accounts: Mutex::new(vec![account]),
@@ -549,9 +564,18 @@ impl FakeAccountStore {
quota_window_usage: Mutex::new(Vec::new()),
quota_window_queries: Mutex::new(Vec::new()),
quota_forecast_history: Mutex::new(QuotaForecastHistory::default()),
update_commands: Mutex::new(Vec::new()),
lowered_limits: Mutex::new(Vec::new()),
})
}
pub(super) fn update_commands(&self) -> Vec<UpdateAccount> {
self.update_commands
.lock()
.expect("update commands")
.clone()
}
pub(super) fn fail_next_commit(&self) {
*self.fail_commit.lock().expect("store failure") = true;
}
@@ -617,7 +641,7 @@ impl FakeAccountStore {
credential_state: account.credential_state,
access_token_expires_at: account.access_token_expires_at.map(Into::into),
quota: account.quota,
rate_limited_until: None,
cooldown: None,
last_error_reason: account.last_error_reason,
last_error_message: account.last_error_message.clone(),
};
@@ -839,12 +863,45 @@ impl AccountStore for FakeAccountStore {
self.record("store.update_account");
self.record_context(context);
self.require_commit()?;
self.update_commands
.lock()
.expect("update commands")
.push(command.clone());
Ok(AccountUpdateResult {
config_revision: revision(2),
account_id: ProviderAccountId::new(command.account_id).expect("account ID"),
})
}
async fn lower_concurrency_limit(
&self,
account_id: &ProviderAccountId,
limit: gateway_core::account::AccountConcurrencyLimit,
_: &MutationContext,
) -> AdminStoreResult<Option<AccountUpdateResult>> {
let mut accounts = self.accounts.lock().expect("accounts");
let Some(account) = accounts
.iter_mut()
.find(|account| account.id == account_id.as_str())
else {
return Ok(None);
};
if !account.enabled
|| account.concurrency_limit.map_or(5, |value| value.get()) <= limit.get()
{
return Ok(None);
}
account.concurrency_limit = Some(limit);
self.lowered_limits
.lock()
.expect("lowered limits")
.push((account.id.clone(), limit.get()));
Ok(Some(AccountUpdateResult {
config_revision: revision(2),
account_id: account_id.clone(),
}))
}
async fn recover_account(
&self,
account_id: &ProviderAccountId,
@@ -916,18 +973,30 @@ struct StaticSettingsStore;
impl SettingsStore for StaticSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
model_mappings: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: 1,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 30,
ops_event_retention_days: 30,
audit_retention_days: 30,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
updated_at: Utc::now(),
})
}
@@ -1330,6 +1399,8 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
.update(
&context("update-request"),
UpdateAccount {
notes: None,
model_access: Default::default(),
outbound_proxy: None,
account_id: "acct_test".to_owned(),
enabled: false,
@@ -1367,6 +1438,8 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
.update(
&context("update-failure"),
UpdateAccount {
notes: None,
model_access: Default::default(),
outbound_proxy: None,
account_id: "acct_test".to_owned(),
enabled: false,
@@ -1409,12 +1482,13 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
.batch_update(
&context("batch-update-request"),
BatchUpdateAccounts {
model_access: Default::default(),
outbound_proxy: None,
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
enabled: false,
concurrency_limit: None,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: Vec::new(),
enabled: Some(false),
concurrency_limit: Some(None),
weight: Some(gateway_core::account::AccountWeight::DEFAULT),
group_ids: Some(Vec::new()),
},
)
.await
@@ -1948,6 +2022,52 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
);
}
#[tokio::test]
async fn api_key_list_and_detail_should_accumulate_local_usage_without_subscription_windows() {
let provider = FakeProviderAdmin::new("openai", events());
let mut account = account_record("openai");
account.authentication_kind = "api_key".to_owned();
account.created_at = Utc::now() - TimeDelta::days(60);
let added_at = account.created_at;
let store = FakeAccountStore::with_account(account, events());
store.set_quota_window_usage(vec![AccountUsageWindowResult {
account_id: "acct_test".to_owned(),
key: "account-lifetime".to_owned(),
usage: quota_local_usage("acct_test", 4_330_000),
}]);
let services = accounts_service(provider, store.clone()).await;
let page = services
.accounts()
.list(AccountListQuery {
page: 1,
page_size: gateway_admin::model::PageSize::new(20).unwrap(),
provider_kind: None,
group_filter: None,
search: None,
status: None,
sort: None,
})
.await
.unwrap();
assert!(page.items[0].quota.windows.is_empty());
assert_eq!(
page.items[0].usage.as_ref().unwrap().total_tokens,
Some(4_330_000)
);
let queries = store.quota_window_queries();
assert_eq!(queries.len(), 1);
assert_eq!(queries[0].range.start, added_at);
assert!(queries[0].range.end > added_at + TimeDelta::days(59));
let detail = services
.accounts()
.quota(&ProviderAccountId::new("acct_test").unwrap(), false)
.await
.unwrap();
assert_eq!(detail.usage, page.items[0].usage);
assert!(detail.quota.windows.is_empty());
assert_eq!(store.quota_window_queries()[0].range.start, added_at);
}
#[tokio::test]
async fn accounts_list_should_attach_local_usage_to_quota_windows() {
let provider = FakeProviderAdmin::new("openai", events());
@@ -2400,6 +2520,8 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
pub(super) fn account_record(kind: &str) -> AccountRecord {
let now = Utc::now();
AccountRecord {
notes: None,
model_access: Default::default(),
outbound_proxy: None,
id: "acct_test".to_owned(),
provider_kind: ProviderKind::new(kind).expect("provider kind"),
@@ -2442,6 +2564,7 @@ fn prepared_create_with_id(
) -> PreparedCredentialCreate {
let now = Utc::now();
PreparedCredentialCreate {
model_access: Default::default(),
outbound_proxy: None,
account_id: ProviderAccountId::new(account_id).expect("prepared account ID"),
provider_kind,
@@ -2733,6 +2856,8 @@ fn unsupported() -> ProviderAdminError {
pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings {
gateway_admin::model::accounts::AccountImportSettings {
notes: Some("团队备用".to_owned()),
model_access: Default::default(),
enabled: false,
concurrency_limit: Some(
gateway_core::account::AccountConcurrencyLimit::new(3).expect("concurrency"),
@@ -5,7 +5,7 @@ use chrono::{TimeDelta, Utc};
use gateway_admin::{
model::{
auth::{AdminAuditEvent, AdminSession, LoginCommand},
auth::{AdminAuditEvent, AuthSession, LoginCommand},
settings::AdminApiKey,
},
ports::store::{AdminStoreResult, AuthStore},
@@ -13,8 +13,11 @@ use gateway_admin::{
#[derive(Default)]
struct MemoryAuthStore {
retry_after: Mutex<Option<std::time::Duration>>,
unavailable: std::sync::atomic::AtomicBool,
reject_delete: Mutex<Option<String>>,
password_hash: Mutex<Option<String>>,
sessions: Mutex<BTreeMap<String, AdminSession>>,
sessions: Mutex<BTreeMap<String, AuthSession>>,
audits: Mutex<Vec<AdminAuditEvent>>,
}
@@ -41,7 +44,10 @@ impl AuthStore for MemoryAuthStore {
Ok(None)
}
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
if self.unavailable.load(std::sync::atomic::Ordering::SeqCst) {
return Err(super::unavailable("session"));
}
Ok(self
.sessions
.lock()
@@ -50,11 +56,7 @@ impl AuthStore for MemoryAuthStore {
.cloned())
}
async fn store_session(
&self,
session_id: &str,
session: &AdminSession,
) -> AdminStoreResult<()> {
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()> {
self.sessions
.lock()
.expect("sessions")
@@ -62,10 +64,30 @@ impl AuthStore for MemoryAuthStore {
Ok(())
}
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
if self.reject_delete.lock().unwrap().as_deref() == Some(session_id) {
return Err(super::unavailable("delete session"));
}
Ok(self.sessions.lock().expect("sessions").remove(session_id))
}
async fn client_key_enabled(
&self,
_: &gateway_core::policy::ClientApiKeyId,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn consume_login_attempt(
&self,
_: std::net::IpAddr,
_: u32,
_: u32,
_: std::time::Duration,
) -> AdminStoreResult<Option<std::time::Duration>> {
Ok(*self.retry_after.lock().unwrap())
}
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
self.audits.lock().expect("audits").push(event);
Ok(())
@@ -79,19 +101,24 @@ async fn successful_login_should_create_expiring_session_and_audit() {
let result = services
.auth()
.login(LoginCommand {
username: Some("admin".to_owned()),
password: "strong-test-password".to_owned(),
})
.login(
LoginCommand::Admin {
username: Some("admin".to_owned()),
password: "strong-test-password".to_owned(),
},
std::net::Ipv4Addr::LOCALHOST.into(),
None,
)
.await
.expect("login");
assert!(
services
.auth()
.validate_session(Some(&result.session_id))
.session(Some(&result.session_id))
.await
.expect("validate")
.is_some()
);
assert_eq!(store.audits.lock().expect("audits").len(), 1);
}
@@ -113,10 +140,14 @@ async fn repeated_default_initialization_should_not_replace_password() {
assert!(
services
.auth()
.login(LoginCommand {
username: Some("admin".to_owned()),
password: "first-strong-password".to_owned(),
})
.login(
LoginCommand::Admin {
username: Some("admin".to_owned()),
password: "first-strong-password".to_owned(),
},
std::net::Ipv4Addr::LOCALHOST.into(),
None
)
.await
.is_ok()
);
@@ -134,20 +165,134 @@ async fn login_with_huge_session_ttl_should_clamp_expiry_instead_of_panicking()
let before = Utc::now();
let result = services
.auth()
.login(LoginCommand {
username: Some("admin".to_owned()),
password: "strong-test-password".to_owned(),
})
.login(
LoginCommand::Admin {
username: Some("admin".to_owned()),
password: "strong-test-password".to_owned(),
},
std::net::Ipv4Addr::LOCALHOST.into(),
None,
)
.await
.expect("login with huge session TTL");
assert!(result.expires_at > before);
assert!(result.expires_at <= before + TimeDelta::days(366) + TimeDelta::minutes(1));
assert!(result.session.expires_at > before);
assert!(result.session.expires_at <= before + TimeDelta::days(366) + TimeDelta::minutes(1));
assert!(
services
.auth()
.validate_session(Some(&result.session_id))
.session(Some(&result.session_id))
.await
.expect("validate")
.is_some()
);
}
#[tokio::test]
async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logout() {
use gateway_admin::model::{AdminErrorKind, auth::SessionSubject};
let store = std::sync::Arc::new(MemoryAuthStore::default());
let services = super::AdminHarness::new().auth(store.clone()).build().await;
store.sessions.lock().unwrap().insert(
"expired".to_owned(),
AuthSession {
subject: SessionSubject::Admin {
admin_user_id: "admin".to_owned(),
},
expires_at: Utc::now() - TimeDelta::seconds(1),
},
);
assert!(
services
.auth()
.session(Some("expired"))
.await
.unwrap()
.is_none()
);
assert!(store.sessions.lock().unwrap().is_empty());
store
.unavailable
.store(true, std::sync::atomic::Ordering::SeqCst);
assert_eq!(
services
.auth()
.session(Some("existing"))
.await
.unwrap_err()
.kind(),
AdminErrorKind::Unavailable
);
}
#[tokio::test]
async fn both_login_types_share_the_login_limit_before_credentials_are_checked() {
use gateway_admin::model::auth::LoginError;
let store = std::sync::Arc::new(MemoryAuthStore::default());
*store.retry_after.lock().unwrap() = Some(std::time::Duration::from_secs(37));
let services = super::AdminHarness::new().auth(store).build().await;
for command in [
LoginCommand::Admin {
username: None,
password: "wrong-password".to_owned(),
},
LoginCommand::Key {
api_key: "unknown-key".to_owned(),
},
] {
assert_eq!(
services
.auth()
.login(command, std::net::Ipv4Addr::LOCALHOST.into(), None)
.await
.unwrap_err(),
LoginError::TooManyAttempts {
retry_after_seconds: 37
}
);
}
}
#[tokio::test]
async fn failed_rotation_discards_the_new_session_and_leaves_the_old_session_retryable() {
use gateway_admin::model::auth::LoginError;
let store = std::sync::Arc::new(MemoryAuthStore::default());
let services = super::AdminHarness::new().auth(store.clone()).build().await;
let command = || LoginCommand::Admin {
username: None,
password: "strong-test-password".to_owned(),
};
let original = services
.auth()
.login(command(), std::net::Ipv4Addr::LOCALHOST.into(), None)
.await
.unwrap();
assert!(!format!("{original:?}").contains(&original.session_id));
*store.reject_delete.lock().unwrap() = Some(original.session_id.clone());
assert_eq!(
services
.auth()
.login(
command(),
std::net::Ipv4Addr::LOCALHOST.into(),
Some(&original.session_id)
)
.await
.unwrap_err(),
LoginError::Unavailable
);
assert_eq!(store.sessions.lock().unwrap().len(), 1);
assert!(
services
.auth()
.session(Some(&original.session_id))
.await
.unwrap()
.is_some()
);
assert!(services.auth().logout(&original.session_id).await.is_err());
*store.reject_delete.lock().unwrap() = None;
services.auth().logout(&original.session_id).await.unwrap();
services.auth().logout(&original.session_id).await.unwrap();
assert!(store.sessions.lock().unwrap().is_empty());
}
@@ -0,0 +1,293 @@
use std::{
collections::HashMap,
net::{IpAddr, Ipv4Addr},
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
time::Duration as StdDuration,
};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use gateway_admin::{
model::auth::{AdminAuditEvent, AuthSession, LoginCommand, LoginError, SessionSubject},
ports::store::{AdminStoreResult, AuthStore},
};
use gateway_core::{
engine::execution::{ClientAuthenticationError, ClientKeyVerifier},
policy::ClientApiKeyId,
};
#[test]
fn client_login_command_debug_should_redact_api_key() {
let command = LoginCommand::Key {
api_key: "cpr_should_never_be_logged".to_owned(),
};
let rendered = format!("{command:?}");
assert!(rendered.contains("[REDACTED]"));
assert!(!rendered.contains("cpr_should_never_be_logged"));
}
#[tokio::test]
async fn login_should_create_key_bound_session_with_fixed_ttl() {
let key = client_key("key-42");
let store = Arc::new(MemoryKeyAuthStore::new(Some(key.clone())));
let verifier = Arc::new(FixtureVerifier::accepting(key.clone()));
let services = super::AdminHarness::new()
.auth(store.clone())
.client_key_verifier(verifier.clone())
.client_session_ttl_minutes(30)
.build()
.await;
let login = services
.auth()
.login(
login_command("valid-client-key"),
Ipv4Addr::LOCALHOST.into(),
None,
)
.await
.expect("client login");
assert!(login.session_id.starts_with("session_"));
assert!(!login.session_id.contains("valid-client-key"));
assert_eq!(verifier.calls(), 1);
assert_eq!(
store.session(&login.session_id).map(|value| value.subject),
Some(SessionSubject::Key {
client_key_id: key.clone()
})
);
let remaining = login.session.expires_at - Utc::now();
assert!(remaining > Duration::minutes(29));
assert!(remaining <= Duration::minutes(30));
}
#[tokio::test]
async fn invalid_and_removed_keys_should_share_the_same_login_error() {
let rejected_store = Arc::new(MemoryKeyAuthStore::new(Some(client_key("key-42"))));
let rejected = super::AdminHarness::new()
.auth(rejected_store.clone())
.client_key_verifier(Arc::new(FixtureVerifier::rejecting()))
.build()
.await
.auth()
.login(
login_command("unknown-key"),
Ipv4Addr::LOCALHOST.into(),
None,
)
.await;
let removed = super::AdminHarness::new()
.auth(Arc::new(MemoryKeyAuthStore::new(None)))
.client_key_verifier(Arc::new(FixtureVerifier::accepting(
ClientApiKeyId::new("removed-key").expect("key id"),
)))
.build()
.await
.auth()
.login(
login_command("disabled-key"),
Ipv4Addr::LOCALHOST.into(),
None,
)
.await;
assert_eq!(rejected, Err(LoginError::InvalidCredentials));
assert_eq!(removed, Err(LoginError::InvalidCredentials));
}
#[tokio::test]
async fn rate_limit_should_reject_before_verifying_the_candidate_key() {
let store = Arc::new(MemoryKeyAuthStore::new(Some(client_key("key-42"))));
store.set_retry_after(Some(StdDuration::from_secs(37)));
let verifier = Arc::new(FixtureVerifier::accepting(
ClientApiKeyId::new("key-42").expect("key id"),
));
let services = super::AdminHarness::new()
.auth(store.clone())
.client_key_verifier(verifier.clone())
.build()
.await;
let result = services
.auth()
.login(
login_command("valid-client-key"),
Ipv4Addr::LOCALHOST.into(),
None,
)
.await;
assert_eq!(
result,
Err(LoginError::TooManyAttempts {
retry_after_seconds: 37,
})
);
assert_eq!(verifier.calls(), 0);
}
#[tokio::test]
async fn restored_session_should_be_revoked_when_its_key_is_no_longer_active() {
let key = client_key("key-42");
let store = Arc::new(MemoryKeyAuthStore::new(Some(key.clone())));
let services = super::AdminHarness::new()
.auth(store.clone())
.client_key_verifier(Arc::new(FixtureVerifier::accepting(key.clone())))
.build()
.await;
let login = services
.auth()
.login(
login_command("valid-client-key"),
Ipv4Addr::LOCALHOST.into(),
None,
)
.await
.expect("client login");
store.replace_key(None);
let session = services
.auth()
.session(Some(&login.session_id))
.await
.expect("session status");
assert!(session.is_none());
assert!(store.session(&login.session_id).is_none());
}
fn login_command(api_key: &str) -> LoginCommand {
LoginCommand::Key {
api_key: api_key.to_owned(),
}
}
fn client_key(id: &str) -> ClientApiKeyId {
ClientApiKeyId::new(id).expect("client key id")
}
struct FixtureVerifier {
result: Result<ClientApiKeyId, ClientAuthenticationError>,
calls: AtomicUsize,
}
impl FixtureVerifier {
fn accepting(id: ClientApiKeyId) -> Self {
Self {
result: Ok(id),
calls: AtomicUsize::new(0),
}
}
fn rejecting() -> Self {
Self {
result: Err(ClientAuthenticationError::InvalidKey),
calls: AtomicUsize::new(0),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::Relaxed)
}
}
impl ClientKeyVerifier for FixtureVerifier {
fn verify_client_key(&self, _: &str) -> Result<ClientApiKeyId, ClientAuthenticationError> {
self.calls.fetch_add(1, Ordering::Relaxed);
self.result.clone()
}
}
struct MemoryKeyAuthStore {
key: Mutex<Option<ClientApiKeyId>>,
sessions: Mutex<HashMap<String, AuthSession>>,
retry_after: Mutex<Option<StdDuration>>,
}
impl MemoryKeyAuthStore {
fn new(key: Option<ClientApiKeyId>) -> Self {
Self {
key: Mutex::new(key),
sessions: Mutex::new(HashMap::new()),
retry_after: Mutex::new(None),
}
}
fn replace_key(&self, key: Option<ClientApiKeyId>) {
*self.key.lock().expect("client key") = key;
}
fn set_retry_after(&self, retry_after: Option<StdDuration>) {
*self.retry_after.lock().expect("retry after") = retry_after;
}
fn session(&self, session_id: &str) -> Option<AuthSession> {
self.sessions
.lock()
.expect("client sessions")
.get(session_id)
.cloned()
}
}
#[async_trait]
impl AuthStore for MemoryKeyAuthStore {
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
Ok(None)
}
async fn create_password_hash_if_absent(&self, _: &str, _: &str) -> AdminStoreResult<bool> {
Ok(true)
}
async fn load_admin_api_key(
&self,
) -> AdminStoreResult<Option<gateway_admin::model::settings::AdminApiKey>> {
Ok(None)
}
async fn append_audit_event(&self, _: AdminAuditEvent) -> AdminStoreResult<()> {
Ok(())
}
async fn client_key_enabled(&self, id: &ClientApiKeyId) -> AdminStoreResult<bool> {
Ok(self
.key
.lock()
.expect("client key")
.as_ref()
.is_some_and(|key| key == id))
}
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
Ok(self.session(session_id))
}
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()> {
self.sessions
.lock()
.expect("client sessions")
.insert(session_id.to_owned(), session.clone());
Ok(())
}
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
Ok(self
.sessions
.lock()
.expect("client sessions")
.remove(session_id))
}
async fn consume_login_attempt(
&self,
_: IpAddr,
_: u32,
_: u32,
_: StdDuration,
) -> AdminStoreResult<Option<StdDuration>> {
Ok(*self.retry_after.lock().expect("retry after"))
}
}
@@ -26,6 +26,13 @@ struct TestClientKeyStore {
#[async_trait]
impl ClientKeyStore for TestClientKeyStore {
async fn get_client_key(
&self,
_: &ClientApiKeyId,
) -> AdminStoreResult<Option<ClientKeyRecord>> {
Err(unused())
}
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
assert_eq!(query.page_size.get(), u16::MAX);
Ok(ClientKeyPage {
@@ -0,0 +1,427 @@
//! 容量熔断冻结恢复编排回归:到期探测、失败顺延与自适应并发下调。
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use chrono::{DateTime, Duration as TimeDelta, Utc};
use gateway_admin::freeze_recovery::{FreezeRecoveryDeps, FreezeRecoveryTask};
use gateway_admin::model::MutationContext;
use gateway_admin::model::accounts::{AccountFreeze, AccountRuntimeSnapshot};
use gateway_admin::model::settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings,
};
use gateway_admin::ports::store::{
AccountRuntimeStore, AccountStore, AdminStoreError, AdminStoreResult, SettingsStore,
};
use gateway_core::engine::probe::{
AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult,
};
use gateway_core::lifecycle::CancellationToken;
use gateway_core::task::{ScheduledTask as _, WorkerCycleContext, WorkerId, WorkerKind};
use super::AdminHarness;
use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, events, revision};
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
RuntimeSettings {
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
model_mappings: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: 5,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
account_auto_freeze_enabled: enabled,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: probe_enabled,
account_auto_freeze_probe_model: Some("gpt-5.5".to_owned()),
account_auto_freeze_adaptive_concurrency: adaptive,
updated_at: Utc::now(),
}
}
struct FreezeSettingsStore {
settings: RuntimeSettings,
}
#[async_trait]
impl SettingsStore for FreezeSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(self.settings.clone())
}
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
Ok(false)
}
async fn replace_runtime_settings(
&self,
_: ReplaceRuntimeSettings,
_: &MutationContext,
) -> AdminStoreResult<RuntimeSettings> {
Err(store_unavailable())
}
async fn replace_admin_api_key(
&self,
_: AdminApiKey,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(store_unavailable())
}
async fn delete_admin_api_key(
&self,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(store_unavailable())
}
}
fn store_unavailable() -> AdminStoreError {
AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"freeze recovery test",
"unused",
)
}
/// 记录解冻/顺延调用的运行态 fake;冻结与峰值证据由测试预置。
struct FreezeRuntimeStore {
freezes: BTreeMap<String, AccountFreeze>,
peaks: BTreeMap<String, u32>,
cleared: Mutex<Vec<String>>,
extended: Mutex<Vec<(String, DateTime<Utc>)>>,
}
impl FreezeRuntimeStore {
fn new(freezes: BTreeMap<String, AccountFreeze>, peaks: BTreeMap<String, u32>) -> Arc<Self> {
Arc::new(Self {
freezes,
peaks,
cleared: Mutex::new(Vec::new()),
extended: Mutex::new(Vec::new()),
})
}
fn cleared(&self) -> Vec<String> {
self.cleared.lock().expect("cleared lock").clone()
}
fn extended(&self) -> Vec<(String, DateTime<Utc>)> {
self.extended.lock().expect("extended lock").clone()
}
}
#[async_trait]
impl AccountRuntimeStore for FreezeRuntimeStore {
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
async fn account_runtime(
&self,
_account_ids: &[String],
) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
async fn active_freezes(&self) -> AdminStoreResult<BTreeMap<String, AccountFreeze>> {
Ok(self.freezes.clone())
}
async fn capacity_peaks(
&self,
account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>> {
Ok(account_ids
.iter()
.filter_map(|id| self.peaks.get(id).map(|peak| (id.clone(), *peak)))
.collect())
}
async fn finish_freeze(
&self,
account_id: &str,
expected: &AccountFreeze,
postpone_until: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool> {
assert_eq!(self.freezes.get(account_id), Some(expected));
if let Some(until) = postpone_until {
self.extended
.lock()
.expect("extended")
.push((account_id.to_owned(), until));
} else {
self.cleared
.lock()
.expect("cleared")
.push(account_id.to_owned());
}
Ok(true)
}
}
struct SuccessfulProbe;
impl AccountProbe for SuccessfulProbe {
fn probe(
&self,
_: AccountProbeRequest,
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async {
Ok(AccountProbeResult {
text: vec!["OK".to_owned()],
})
})
}
}
struct FailingProbe;
impl AccountProbe for FailingProbe {
fn probe(
&self,
_: AccountProbeRequest,
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async {
Err(AccountProbeError::new(
gateway_core::error::GatewayError::new(
gateway_core::error::GatewayErrorKind::RateLimited,
"still overloaded",
),
gateway_core::engine::probe::AccountProbeErrorSource::Upstream,
Some(gateway_core::upstream::UpstreamSendState::NotSent),
None,
))
})
}
}
async fn recovery_task(
settings: RuntimeSettings,
runtime: Arc<FreezeRuntimeStore>,
probe: Arc<dyn AccountProbe>,
) -> (FreezeRecoveryTask, Arc<FakeAccountStore>) {
recovery_task_with_store(settings, runtime, probe).await
}
async fn recovery_task_with_store(
settings: RuntimeSettings,
runtime: Arc<FreezeRuntimeStore>,
probe: Arc<dyn AccountProbe>,
) -> (FreezeRecoveryTask, Arc<FakeAccountStore>) {
let store = FakeAccountStore::new("openai", events());
let services = AdminHarness::new()
.accounts(Arc::clone(&store) as Arc<dyn AccountStore>)
.account_runtime(Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>)
.settings(Arc::new(FreezeSettingsStore {
settings: settings.clone(),
}))
.provider(FakeProviderAdmin::new("openai", events()))
.probe(probe)
.build()
.await;
let task = FreezeRecoveryTask::new(FreezeRecoveryDeps {
accounts: services.accounts_handle(),
store: store.clone(),
runtime,
settings: Arc::new(FreezeSettingsStore {
settings: settings.clone(),
}),
});
(task, store)
}
async fn run_cycle(task: &FreezeRecoveryTask) {
let worker = WorkerId::try_new(WorkerKind::AccountFreezeRecovery, "test").expect("worker id");
let context = WorkerCycleContext::new(worker, None, CancellationToken::new());
task.run_cycle(context)
.await
.expect("freeze recovery cycle");
}
fn freeze_until(until: DateTime<Utc>) -> BTreeMap<String, AccountFreeze> {
BTreeMap::from([(
"acct_test".to_owned(),
AccountFreeze {
credential_revision: revision(1),
until,
generation: "freeze-generation".to_owned(),
requires_probe: true,
},
)])
}
fn freeze_due() -> BTreeMap<String, AccountFreeze> {
freeze_until(Utc::now() - TimeDelta::seconds(1))
}
#[tokio::test]
async fn probe_success_clears_freeze() {
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
let (task, _store) = recovery_task(
runtime_settings(true, true, false),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
.await;
run_cycle(&task).await;
assert_eq!(runtime.cleared(), vec!["acct_test".to_owned()]);
assert!(runtime.extended().is_empty());
}
#[tokio::test]
async fn probe_failure_postpones_freeze() {
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
let (task, _store) = recovery_task(
runtime_settings(true, true, false),
Arc::clone(&runtime),
Arc::new(FailingProbe),
)
.await;
run_cycle(&task).await;
assert!(runtime.cleared().is_empty());
let extended = runtime.extended();
assert_eq!(extended.len(), 1);
let (account_id, until) = &extended[0];
assert_eq!(account_id, "acct_test");
let remaining = until.timestamp_millis() - Utc::now().timestamp_millis();
assert!(
remaining > 7_000_000 && remaining <= 7_200_000,
"postponed freeze should be about 2 hours, got {remaining}ms"
);
}
#[tokio::test]
async fn disabled_policy_releases_due_freeze_without_probing() {
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
let (task, _store) = recovery_task(
runtime_settings(false, true, false),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
.await;
run_cycle(&task).await;
assert_eq!(runtime.cleared(), vec!["acct_test".to_owned()]);
assert!(runtime.extended().is_empty());
}
#[tokio::test]
async fn adaptive_concurrency_lowers_limit_to_observed_peak() {
let runtime = FreezeRuntimeStore::new(
freeze_until(Utc::now() + TimeDelta::hours(1)),
BTreeMap::from([("acct_test".to_owned(), 4_u32)]),
);
let (task, store) = recovery_task(
runtime_settings(true, false, true),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
.await;
run_cycle(&task).await;
// 峰值 4 × 0.8 = 3(不低于下限 2),低于全局默认 5,应下调到 3。
assert_eq!(
*store.lowered_limits.lock().expect("lowered limits"),
vec![("acct_test".to_owned(), 3)]
);
assert!(store.update_commands().is_empty());
assert!(runtime.extended().is_empty());
assert!(runtime.cleared().is_empty());
}
#[tokio::test]
async fn adaptive_concurrency_never_raises_limit() {
let runtime = FreezeRuntimeStore::new(
freeze_until(Utc::now() + TimeDelta::hours(1)),
BTreeMap::from([("acct_test".to_owned(), 40_u32)]),
);
let (task, store) = recovery_task(
runtime_settings(true, false, true),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
.await;
run_cycle(&task).await;
// 峰值 40 的目标 32 高于全局默认 5;只降不升,不下发任何更新。
assert!(store.update_commands().is_empty());
}
/// 到期时间还早(超过探测提前量)的冻结不发起探测。
#[tokio::test]
async fn probe_skips_freezes_far_from_expiry() {
let runtime = FreezeRuntimeStore::new(
freeze_until(Utc::now() + TimeDelta::hours(1)),
BTreeMap::new(),
);
let (task, _store) = recovery_task(
runtime_settings(true, true, false),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
.await;
run_cycle(&task).await;
assert!(runtime.cleared().is_empty());
assert!(runtime.extended().is_empty());
}
/// 停用账号不参与探测恢复;并发下调同样跳过。
#[tokio::test]
async fn disabled_accounts_are_skipped() {
let runtime = FreezeRuntimeStore::new(
freeze_due(),
BTreeMap::from([("acct_test".to_owned(), 4_u32)]),
);
let mut record = account_record("openai");
record.enabled = false;
let store = FakeAccountStore::with_account(record, events());
let services = AdminHarness::new()
.accounts(Arc::clone(&store) as Arc<dyn AccountStore>)
.account_runtime(Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>)
.settings(Arc::new(FreezeSettingsStore {
settings: runtime_settings(true, true, true),
}))
.provider(FakeProviderAdmin::new("openai", events()))
.probe(Arc::new(SuccessfulProbe))
.build()
.await;
let task = FreezeRecoveryTask::new(FreezeRecoveryDeps {
accounts: services.accounts_handle(),
store: store.clone(),
runtime: Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>,
settings: Arc::new(FreezeSettingsStore {
settings: runtime_settings(true, true, true),
}),
});
run_cycle(&task).await;
assert!(runtime.cleared().is_empty());
assert!(runtime.extended().is_empty());
assert!(store.update_commands().is_empty());
}
@@ -0,0 +1,370 @@
use std::{sync::Arc, time::Duration};
use gateway_admin::{
AdminBundle, AdminServices,
model::{
AdminErrorKind, MutationActor, MutationContext,
import_tasks::{ImportTaskInput, SubmitImportTask},
provider_credentials::ImportCredentials,
},
ports::provider::ProviderAdminErrorKind,
};
use gateway_core::{
lifecycle::CancellationToken,
routing::ProviderKind,
task::{WorkerContribution, WorkerKind, WorkerRunnable},
};
use uuid::Uuid;
use super::{
AdminHarness,
accounts::{
EventLog, FakeAccountStore, FakeProviderAdmin, context, document, events, recorded,
},
};
fn command(id: Uuid, count: usize) -> SubmitImportTask {
let context = context("task-submit");
SubmitImportTask {
submission_id: id,
fingerprint: [1; 32],
context: context.clone(),
items: (0..count)
.map(|_| ImportTaskInput {
provider: ProviderKind::new("openai").unwrap(),
command: ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context.clone(),
document: document(),
},
})
.collect(),
}
}
async fn harness() -> (AdminBundle, Arc<FakeProviderAdmin>, EventLog) {
let log = events();
let provider = FakeProviderAdmin::new("openai", log.clone());
let bundle = AdminHarness::new()
.provider(provider.clone())
.accounts(FakeAccountStore::new("openai", log.clone()))
.build_bundle()
.await;
(bundle, provider, log)
}
fn start(bundle: &mut AdminBundle) -> (CancellationToken, tokio::task::JoinHandle<()>) {
let registration = bundle
.take_worker_contributions()
.into_iter()
.find_map(|contribution| match contribution {
WorkerContribution::Registration(registration)
if registration.id.kind() == WorkerKind::AccountImport =>
{
Some(registration)
}
_ => None,
})
.expect("import registration");
let WorkerRunnable::Daemon { task, .. } = registration.runnable else {
panic!("import daemon")
};
let cancellation = CancellationToken::new();
let token = cancellation.clone();
let handle = tokio::spawn(async move {
task.run(token).await.expect("import daemon exits cleanly");
});
(cancellation, handle)
}
async fn wait_started(log: &EventLog, count: usize) {
tokio::time::timeout(Duration::from_secs(5), async {
while recorded(log)
.iter()
.filter(|event| **event == "provider.prepare_import")
.count()
< count
{
tokio::task::yield_now().await;
}
})
.await
.expect("imports started");
}
async fn wait_finished(services: &AdminServices, id: Uuid) {
tokio::time::timeout(Duration::from_secs(5), async {
while services
.import_tasks()
.detail(&context("poll"), id)
.unwrap()
.summary
.finished_at
.is_none()
{
tokio::task::yield_now().await;
}
})
.await
.expect("imports finished");
}
#[tokio::test]
async fn retry_should_return_the_same_task_but_reject_changed_content() {
let (bundle, _, _) = harness().await;
let services = bundle.services();
let id = Uuid::now_v7();
let first = services.import_tasks().submit(command(id, 2)).unwrap();
let retry = services.import_tasks().submit(command(id, 2)).unwrap();
assert_eq!(first.task_id, retry.task_id);
assert_eq!(
services
.import_tasks()
.list(&context("reopened-page"))
.len(),
1
);
let mut changed = command(id, 2);
changed.fingerprint = [2; 32];
assert_eq!(
services.import_tasks().submit(changed).unwrap_err().kind(),
AdminErrorKind::Conflict
);
}
#[tokio::test]
async fn task_reads_and_stop_should_be_isolated_by_owner() {
let (bundle, _, _) = harness().await;
let services = bundle.services();
let task = services
.import_tasks()
.submit(command(Uuid::now_v7(), 1))
.unwrap();
let other = MutationContext {
actor: MutationActor::AdminSession {
admin_user_id: "another-admin".to_owned(),
},
request_id: "other-session".to_owned(),
};
assert!(services.import_tasks().list(&other).is_empty());
assert_eq!(
services
.import_tasks()
.detail(&other, task.task_id)
.unwrap_err()
.kind(),
AdminErrorKind::NotFound
);
assert_eq!(
services
.import_tasks()
.stop(&other, task.task_id)
.unwrap_err()
.kind(),
AdminErrorKind::NotFound
);
}
#[tokio::test]
async fn stopping_should_skip_pending_items_and_allow_three_in_flight_to_commit() {
let (mut bundle, provider, log) = harness().await;
let gate = provider.block_imports();
let services = bundle.services();
let task = services
.import_tasks()
.submit(command(Uuid::now_v7(), 8))
.unwrap();
let (cancel, worker) = start(&mut bundle);
wait_started(&log, 3).await;
let stopped = services
.import_tasks()
.stop(&context("stop"), task.task_id)
.unwrap();
assert_eq!(stopped.summary.counts.running, 3);
assert_eq!(stopped.summary.counts.skipped, 5);
gate.add_permits(3);
wait_finished(&services, task.task_id).await;
let finished = services
.import_tasks()
.detail(&context("new-page"), task.task_id)
.unwrap();
assert_eq!(finished.summary.counts.imported_accounts, 3);
assert_eq!(
recorded(&log)
.iter()
.filter(|event| **event == "provider.prepare_import")
.count(),
3
);
cancel.cancel();
worker.await.unwrap();
}
#[tokio::test]
async fn concurrency_limit_should_be_shared_across_tasks() {
let (mut bundle, provider, log) = harness().await;
let gate = provider.block_imports();
let services = bundle.services();
let first = services
.import_tasks()
.submit(command(Uuid::now_v7(), 6))
.unwrap();
let second = services
.import_tasks()
.submit(command(Uuid::now_v7(), 6))
.unwrap();
let (cancel, worker) = start(&mut bundle);
wait_started(&log, 3).await;
let summaries = services.import_tasks().list(&context("poll"));
assert_eq!(
summaries
.iter()
.map(|task| task.counts.running)
.sum::<usize>(),
3
);
assert!(summaries.iter().all(|task| task.counts.running > 0));
gate.add_permits(12);
wait_finished(&services, first.task_id).await;
wait_finished(&services, second.task_id).await;
cancel.cancel();
worker.await.unwrap();
}
#[tokio::test]
async fn failed_and_unknown_items_should_not_abort_the_batch_or_retry_credentials() {
for (kind, failed, unknown) in [
(ProviderAdminErrorKind::Invalid, 1, 0),
(ProviderAdminErrorKind::Ambiguous, 0, 1),
] {
let (mut bundle, provider, log) = harness().await;
provider.fail_next(kind);
let services = bundle.services();
let task = services
.import_tasks()
.submit(command(Uuid::now_v7(), 4))
.unwrap();
let (cancel, worker) = start(&mut bundle);
wait_finished(&services, task.task_id).await;
let result = services
.import_tasks()
.detail(&context("poll"), task.task_id)
.unwrap();
assert_eq!(
(
result.summary.counts.succeeded,
result.summary.counts.failed,
result.summary.counts.unknown
),
(3, failed, unknown)
);
assert_eq!(
recorded(&log)
.iter()
.filter(|event| **event == "provider.prepare_import")
.count(),
4
);
cancel.cancel();
worker.await.unwrap();
}
}
#[tokio::test]
async fn shutdown_should_drain_running_items_and_reject_new_work() {
let (mut bundle, provider, log) = harness().await;
let gate = provider.block_imports();
let services = bundle.services();
let task = services
.import_tasks()
.submit(command(Uuid::now_v7(), 7))
.unwrap();
let (cancel, worker) = start(&mut bundle);
wait_started(&log, 3).await;
cancel.cancel();
tokio::task::yield_now().await;
assert_eq!(
services
.import_tasks()
.submit(command(Uuid::now_v7(), 1))
.unwrap_err()
.kind(),
AdminErrorKind::Unavailable
);
assert!(!worker.is_finished());
gate.add_permits(3);
worker.await.unwrap();
let result = services
.import_tasks()
.detail(&context("poll"), task.task_id)
.unwrap();
assert_eq!(
(
result.summary.counts.succeeded,
result.summary.counts.skipped
),
(3, 4)
);
}
#[tokio::test(start_paused = true)]
async fn completed_records_should_expire_and_new_process_should_start_empty() {
let (bundle, _, _) = harness().await;
let services = bundle.services();
let task = services
.import_tasks()
.submit(command(Uuid::now_v7(), 2))
.unwrap();
services
.import_tasks()
.stop(&context("stop"), task.task_id)
.unwrap();
tokio::time::advance(Duration::from_secs(3601)).await;
assert!(services.import_tasks().list(&context("poll")).is_empty());
assert_eq!(
services
.import_tasks()
.detail(&context("poll"), task.task_id)
.unwrap_err()
.kind(),
AdminErrorKind::NotFound
);
let (fresh, _, _) = harness().await;
assert!(
fresh
.services()
.import_tasks()
.list(&context("poll"))
.is_empty()
);
}
#[tokio::test]
async fn queue_should_reject_excess_work_before_starting_any_imports() {
let (bundle, _, log) = harness().await;
let services = bundle.services();
assert_eq!(
services
.import_tasks()
.submit(command(Uuid::now_v7(), 201))
.unwrap_err()
.kind(),
AdminErrorKind::Invalid
);
for _ in 0..8 {
services
.import_tasks()
.submit(command(Uuid::now_v7(), 1))
.unwrap();
}
assert_eq!(
services
.import_tasks()
.submit(command(Uuid::now_v7(), 1))
.unwrap_err()
.kind(),
AdminErrorKind::RateLimited
);
assert!(recorded(&log).is_empty());
}
@@ -1,8 +1,11 @@
mod account_groups;
mod accounts;
mod auth;
mod auth_key;
mod backup;
mod client_keys;
mod freeze_recovery;
mod import_tasks;
mod observability;
mod openai;
mod proxies;
@@ -19,7 +22,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::future::BoxFuture;
use gateway_admin::{
AdminConfig, AdminServices, InitialAdminPassword,
AdminConfig, AdminServices, ClientConfig, InitialAdminPassword,
model::{
MutationContext, Revision,
account_groups::{
@@ -31,7 +34,7 @@ use gateway_admin::{
AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult, AccountsUpdateResult,
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
},
auth::{AdminAuditEvent, AdminSession},
auth::{AdminAuditEvent, AuthSession},
client_distribution::CodexDesktopWindowsDownloads,
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
@@ -71,16 +74,21 @@ use gateway_admin::{
};
use gateway_core::{
account::ProviderAccountId,
engine::probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
engine::{
execution::{ClientAuthenticationError, ClientKeyVerifier},
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
},
error::{GatewayError, GatewayErrorKind},
policy::ClientApiKeyId,
routing::{ConfigRevision, ProviderKind},
runtime::SnapshotControl,
};
use std::collections::BTreeMap;
pub(super) struct AdminHarness {
default_password: String,
session_ttl_minutes: u64,
client_session_ttl_minutes: u64,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
@@ -93,6 +101,7 @@ pub(super) struct AdminHarness {
providers: Vec<Arc<dyn ProviderAdmin>>,
probe: Arc<dyn AccountProbe>,
system: Arc<dyn SystemOperations>,
client_key_verifier: Arc<dyn ClientKeyVerifier>,
}
impl AdminHarness {
@@ -101,6 +110,7 @@ impl AdminHarness {
Self {
default_password: "strong-test-password".to_owned(),
session_ttl_minutes: 60,
client_session_ttl_minutes: 1_440,
accounts: unavailable.clone(),
proxies: Arc::new(proxies::TestProxies::default()),
account_runtime: unavailable.clone(),
@@ -116,6 +126,7 @@ impl AdminHarness {
],
probe: Arc::new(UnavailableProbe),
system: Arc::new(UnavailableSystem),
client_key_verifier: Arc::new(UnavailableClientKeyVerifier),
}
}
@@ -154,6 +165,16 @@ impl AdminHarness {
self
}
pub(super) fn client_session_ttl_minutes(mut self, minutes: u64) -> Self {
self.client_session_ttl_minutes = minutes;
self
}
pub(super) fn client_key_verifier(mut self, verifier: Arc<dyn ClientKeyVerifier>) -> Self {
self.client_key_verifier = verifier;
self
}
pub(super) fn observability(mut self, store: Arc<dyn ObservabilityStore>) -> Self {
self.observability = store;
self
@@ -195,12 +216,19 @@ impl AdminHarness {
}
pub(super) async fn build(self) -> AdminServices {
self.build_bundle().await.services()
}
pub(super) async fn build_bundle(self) -> gateway_admin::AdminBundle {
gateway_admin::initialize(
AdminConfig {
session_ttl_minutes: self.session_ttl_minutes,
default_username: "admin".to_owned(),
default_password: InitialAdminPassword::new(self.default_password),
},
ClientConfig {
session_ttl_minutes: self.client_session_ttl_minutes,
},
AdminStorePorts::new(
AdminAccountStorePorts::new(
self.accounts,
@@ -214,15 +242,18 @@ impl AdminHarness {
self.settings,
self.backup,
),
self.providers,
Arc::new(NoopSnapshot),
(self.probe, Arc::new(proxies::TestProxies::default())),
Arc::new(NoopClientDistribution),
self.system,
gateway_admin::AdminRuntimePorts {
providers: self.providers,
snapshot: Arc::new(NoopSnapshot),
account_probe: self.probe,
proxy_probe: Arc::new(proxies::TestProxies::default()),
client_distribution: Arc::new(NoopClientDistribution),
system: self.system,
client_key_verifier: self.client_key_verifier,
},
)
.await
.expect("initialize admin test harness")
.services()
}
}
@@ -268,18 +299,35 @@ impl AuthStore for BootstrapAuthStore {
Ok(None)
}
async fn load_session(&self, _: &str) -> AdminStoreResult<Option<AdminSession>> {
async fn load_session(&self, _: &str) -> AdminStoreResult<Option<AuthSession>> {
Ok(None)
}
async fn store_session(&self, _: &str, _: &AdminSession) -> AdminStoreResult<()> {
async fn store_session(&self, _: &str, _: &AuthSession) -> AdminStoreResult<()> {
Err(unavailable("admin session"))
}
async fn delete_session(&self, _: &str) -> AdminStoreResult<Option<AdminSession>> {
async fn delete_session(&self, _: &str) -> AdminStoreResult<Option<AuthSession>> {
Err(unavailable("admin session"))
}
async fn client_key_enabled(
&self,
_: &gateway_core::policy::ClientApiKeyId,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn consume_login_attempt(
&self,
_: std::net::IpAddr,
_: u32,
_: u32,
_: std::time::Duration,
) -> AdminStoreResult<Option<std::time::Duration>> {
Ok(None)
}
async fn append_audit_event(&self, _: AdminAuditEvent) -> AdminStoreResult<()> {
Ok(())
}
@@ -287,6 +335,14 @@ impl AuthStore for BootstrapAuthStore {
struct UnavailableStore;
struct UnavailableClientKeyVerifier;
impl ClientKeyVerifier for UnavailableClientKeyVerifier {
fn verify_client_key(&self, _: &str) -> Result<ClientApiKeyId, ClientAuthenticationError> {
Err(ClientAuthenticationError::InvalidKey)
}
}
struct UnavailableAccountGroupStore;
#[async_trait]
@@ -434,6 +490,15 @@ impl AccountStore for UnavailableStore {
Err(unavailable("account enabled"))
}
async fn lower_concurrency_limit(
&self,
_: &gateway_core::account::ProviderAccountId,
_: gateway_core::account::AccountConcurrencyLimit,
_: &MutationContext,
) -> AdminStoreResult<Option<gateway_admin::model::accounts::AccountUpdateResult>> {
Ok(None)
}
async fn recover_account(
&self,
_: &ProviderAccountId,
@@ -476,10 +541,39 @@ impl AccountRuntimeStore for UnavailableStore {
async fn account_runtime(&self, _: &[String]) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
async fn active_freezes(
&self,
) -> AdminStoreResult<BTreeMap<String, gateway_admin::model::accounts::AccountFreeze>> {
Ok(BTreeMap::new())
}
async fn capacity_peaks(
&self,
_account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>> {
Ok(BTreeMap::new())
}
async fn finish_freeze(
&self,
_account_id: &str,
_expected: &gateway_admin::model::accounts::AccountFreeze,
_postpone_until: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool> {
Ok(false)
}
}
#[async_trait]
impl ClientKeyStore for UnavailableStore {
async fn get_client_key(
&self,
_: &ClientApiKeyId,
) -> AdminStoreResult<Option<ClientKeyRecord>> {
Err(unavailable("client key"))
}
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
Err(unavailable("client key list"))
}
@@ -753,12 +753,17 @@ struct FixtureSettingsStore;
impl SettingsStore for FixtureSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(1).expect("revision"),
model_mappings: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: 1,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -766,6 +771,13 @@ impl SettingsStore for FixtureSettingsStore {
ops_event_retention_days: 30,
audit_retention_days: 30,
updated_at: Utc::now(),
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
})
}
@@ -61,17 +61,29 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
request_id: "request-settings".to_owned(),
},
ReplaceRuntimeSettings {
request_location_enabled: false,
request_location: Default::default(),
model_mappings: Default::default(),
refresh_margin_seconds: 0,
refresh_concurrency: 1,
max_concurrent_per_account: 1,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
},
)
.await
+1
View File
@@ -24,6 +24,7 @@ gateway-protocol = { path = "../gateway-protocol" }
secrecy = "0.10"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
sha2 = "0.11.0"
thiserror = "2.0.18"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "sync", "time"] }
tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeout", "trace"] }
@@ -1,5 +1,7 @@
//! Account group HTTP wire and fixed routes.
use crate::auth::SessionState;
use std::collections::BTreeMap;
use axum::{
@@ -22,8 +24,8 @@ use gateway_core::routing::AccountGroupId;
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta, WireValidationError, wire::map_admin_service_error,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
WireValidationError, wire::map_admin_service_error,
};
const DEFAULT_PAGE_SIZE: u32 = 50;
@@ -221,7 +223,7 @@ impl From<AccountGroupMutation> for AccountGroupMutationData {
/// Construct all fixed account-group management routes.
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/account-groups", get(list::<S>))
@@ -238,7 +240,7 @@ async fn list<S>(
AdminQuery(query): AdminQuery<ListAccountGroupsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -258,7 +260,7 @@ async fn create<S>(
AdminJson(request): AdminJson<CreateAccountGroupRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
validate_group_fields(&request.name, request.description.as_deref())?;
mutation_response(
@@ -284,7 +286,7 @@ async fn update<S>(
AdminJson(request): AdminJson<UpdateAccountGroupRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
validate_group_fields(&request.name, request.description.as_deref())?;
mutation_response(
@@ -311,7 +313,7 @@ async fn enable<S>(
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
set_enabled(auth, state, request, true).await
}
@@ -322,7 +324,7 @@ async fn disable<S>(
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
set_enabled(auth, state, request, false).await
}
@@ -334,7 +336,7 @@ async fn set_enabled<S>(
enabled: bool,
) -> Result<AdminResponse<AdminEnvelope<AccountGroupMutationData>>, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
mutation_response(
StatusCode::OK,
@@ -358,7 +360,7 @@ async fn delete<S>(
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
mutation_response(
StatusCode::OK,
@@ -2,6 +2,18 @@
use super::*;
fn validate_account_notes(notes: Option<&str>) -> Result<(), WireValidationError> {
if notes.is_some_and(|notes| {
notes.chars().count() > 500
|| notes
.chars()
.any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))
}) {
return Err(WireValidationError::new("notes"));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum AccountProvider {
OpenAi,
@@ -18,19 +30,22 @@ impl AccountProvider {
}
}
/// 导入统一设置,复用编辑账号的调度和分组约束。
#[derive(Debug, Clone, Deserialize)]
/// 导入统一设置,复用编辑账号的备注、调度和分组约束。
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportSettingsRequest {
pub notes: Option<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
pub weight: u64,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Vec<String>,
}
impl AccountImportSettingsRequest {
fn validate(&self) -> Result<(), WireValidationError> {
validate_account_notes(self.notes.as_deref())?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
validate_wire_group_ids(&self.group_ids)?;
@@ -41,16 +56,18 @@ impl AccountImportSettingsRequest {
self,
) -> Result<gateway_admin::model::accounts::AccountImportSettings, WireValidationError> {
Ok(gateway_admin::model::accounts::AccountImportSettings {
notes: self.notes,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
model_access: self.model_access,
group_ids: validate_wire_group_ids(&self.group_ids)?,
})
}
}
/// Provider-owned 账号导入请求;公共 API 不解释 `data` 内部字段。
#[derive(Deserialize)]
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportRequest {
pub outbound_proxy_id: Option<String>,
@@ -200,16 +217,19 @@ pub struct UpdateAccountRequest {
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
pub account_id: String,
pub notes: Option<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
pub weight: u64,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Vec<String>,
}
impl UpdateAccountRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
validate_account_notes(self.notes.as_deref())?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
validate_wire_group_ids(&self.group_ids)?;
@@ -224,9 +244,11 @@ impl UpdateAccountRequest {
self.outbound_proxy_url,
)?,
account_id: self.account_id,
notes: self.notes,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
model_access: self.model_access,
group_ids: validate_wire_group_ids(&self.group_ids)?,
})
}
@@ -297,9 +319,13 @@ impl AccountDeletionRequest {
pub struct RotateAccountRequest {
pub provider: String,
pub account_id: String,
pub access_token: String,
pub access_token: Option<String>,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
pub transport: Option<String>,
pub settings: Option<UpdateAccountRequest>,
}
impl RotateAccountRequest {
@@ -308,11 +334,40 @@ impl RotateAccountRequest {
return Err(WireValidationError::new("provider"));
}
require_account_id(&self.account_id, "accountId")?;
validate_oauth_material(
&self.access_token,
self.refresh_token.as_deref(),
self.id_token.as_deref(),
)
if let Some(settings) = &self.settings {
settings.validate()?;
if settings.account_id != self.account_id {
return Err(WireValidationError::new("settings.accountId"));
}
}
if let Some(base_url) = &self.base_url {
if self.access_token.is_some()
|| self.refresh_token.is_some()
|| self.id_token.is_some()
|| base_url.is_empty()
|| base_url.len() > 2048
|| !matches!(self.transport.as_deref(), Some("http" | "prefer_websocket"))
|| self.api_key.as_ref().is_some_and(|key| {
key.is_empty()
|| key.len() > 16 * 1024
|| !key.bytes().all(|byte| byte.is_ascii_graphic())
})
{
return Err(WireValidationError::new("credential"));
}
Ok(())
} else {
if self.api_key.is_some() || self.transport.is_some() {
return Err(WireValidationError::new("credential"));
}
validate_oauth_material(
self.access_token
.as_deref()
.ok_or_else(|| WireValidationError::new("accessToken"))?,
self.refresh_token.as_deref(),
self.id_token.as_deref(),
)
}
}
pub(super) fn into_command(
@@ -321,16 +376,34 @@ impl RotateAccountRequest {
) -> Result<RotateCredential, WireValidationError> {
self.validate()?;
let mut material = Map::new();
material.insert("access_token".to_owned(), Value::String(self.access_token));
material.insert(
"refresh_token".to_owned(),
self.refresh_token.map_or(Value::Null, Value::String),
);
material.insert(
"id_token".to_owned(),
self.id_token.map_or(Value::Null, Value::String),
);
if let Some(base_url) = self.base_url {
material.insert("base_url".to_owned(), Value::String(base_url));
material.insert(
"transport".to_owned(),
self.transport.map_or(Value::Null, Value::String),
);
if let Some(key) = self.api_key {
material.insert("api_key".to_owned(), Value::String(key));
}
} else {
material.insert(
"access_token".to_owned(),
self.access_token.map_or(Value::Null, Value::String),
);
material.insert(
"refresh_token".to_owned(),
self.refresh_token.map_or(Value::Null, Value::String),
);
material.insert(
"id_token".to_owned(),
self.id_token.map_or(Value::Null, Value::String),
);
}
Ok(RotateCredential {
settings: self
.settings
.map(UpdateAccountRequest::into_command)
.transpose()?,
mutation: CredentialMutation {
context,
account_id: ProviderAccountId::new(self.account_id)
@@ -1,13 +1,16 @@
//! 账号管理路由与 HTTP handler 编排。
use crate::auth::SessionState;
use super::*;
/// 构造统一账号管理路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.merge(super::import_tasks::router::<S>())
.route("/api/admin/accounts", get(list_accounts::<S>))
.route("/api/admin/accounts/detail", get(account_detail::<S>))
.route("/api/admin/accounts/export", get(export_accounts::<S>))
@@ -67,7 +70,7 @@ async fn batch_update_accounts<S>(
AdminJson(request): AdminJson<BatchUpdateAccountsRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
@@ -88,7 +91,7 @@ async fn list_accounts<S>(
AdminQuery(query): AdminQuery<ListQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = query.validate().map_err(map_wire_error)?;
let page = command.page;
@@ -109,7 +112,7 @@ async fn account_detail<S>(
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
@@ -118,8 +121,15 @@ where
.quota(&account_id, false)
.await
.map_err(map_service_error)?;
let data = AccountQuotaData {
let configuration = state
.admin_services()
.accounts()
.account_configuration(&account_id)
.await
.map_err(map_service_error)?;
let data = AccountDetailData {
account: account_view(result, Utc::now()),
credential_configuration: configuration.map(provider_document_value),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -130,7 +140,7 @@ async fn export_accounts<S>(
AdminQuery(query): AdminQuery<AccountExportQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let ids = query.into_ids().map_err(map_wire_error)?;
let result = state
@@ -153,7 +163,7 @@ async fn import_accounts<S>(
AdminJson(request): AdminJson<AccountImportRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
@@ -181,7 +191,7 @@ async fn start_account_authorization<S>(
AdminJson(request): AdminJson<StartAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
@@ -215,7 +225,7 @@ async fn complete_account_authorization<S>(
AdminJson(request): AdminJson<CompleteAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
@@ -249,7 +259,7 @@ async fn rotate_account<S>(
AdminJson(request): AdminJson<RotateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
@@ -272,7 +282,7 @@ async fn update_account<S>(
AdminJson(request): AdminJson<UpdateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
@@ -293,7 +303,7 @@ async fn delete_accounts<S>(
AdminJson(request): AdminJson<AccountDeletionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
@@ -315,7 +325,7 @@ async fn refresh_account<S>(
AdminJson(request): AdminJson<AccountRefreshRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = request.into_command().map_err(map_wire_error)?;
let result = state
@@ -334,7 +344,7 @@ async fn recover_account<S>(
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
@@ -353,7 +363,7 @@ async fn account_quota<S>(
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
@@ -374,7 +384,7 @@ async fn account_quota_forecast<S>(
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
@@ -395,7 +405,7 @@ async fn account_personal_info<S>(
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
@@ -416,7 +426,7 @@ async fn account_profile_avatar<S>(
AdminQuery(query): AdminQuery<AccountProfileAvatarQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let avatar = state
@@ -479,7 +489,7 @@ async fn refresh_account_quota<S>(
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
@@ -500,7 +510,7 @@ async fn account_reset_credits<S>(
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
@@ -521,7 +531,7 @@ async fn consume_account_reset_credit<S>(
AdminJson(request): AdminJson<AccountResetCreditConsumeRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
@@ -542,7 +552,7 @@ async fn account_models<S>(
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
@@ -561,7 +571,7 @@ async fn refresh_account_models<S>(
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
@@ -580,7 +590,7 @@ async fn test_account_connection<S>(
AdminQuery(query): AdminQuery<AccountTestQuery>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let (account_id, upstream_model) = query.into_command().map_err(map_wire_error)?;
let stream = state
@@ -0,0 +1,240 @@
//! 后台导入任务的有界请求与安全结果投影。
use axum::extract::DefaultBodyLimit;
use gateway_admin::model::import_tasks::{
ImportTaskDetail, ImportTaskInput, ImportTaskSummary, MAX_IMPORT_TASK_ITEMS, SubmitImportTask,
};
use sha2::{Digest as _, Sha256};
use super::*;
use crate::auth::SessionState;
pub(super) fn router<S>() -> Router<S>
where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route(
"/api/admin/accounts/import-tasks",
get(list::<S>).post(submit::<S>),
)
.route("/api/admin/accounts/import-tasks/detail", get(detail::<S>))
.route("/api/admin/accounts/import-tasks/stop", post(stop::<S>))
.layer(DefaultBodyLimit::max(4 * 1024 * 1024))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct SubmitRequest {
submission_id: String,
items: Vec<AccountImportRequest>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct TaskId {
task_id: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SummaryView {
task_id: String,
created_at: String,
finished_at: Option<String>,
stop_requested: bool,
total: usize,
counts: CountsView,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CountsView {
pending: usize,
running: usize,
succeeded: usize,
failed: usize,
unknown: usize,
skipped: usize,
imported_accounts: usize,
}
impl From<ImportTaskSummary> for SummaryView {
fn from(task: ImportTaskSummary) -> Self {
let counts = task.counts;
Self {
task_id: task.task_id.to_string(),
created_at: task.created_at.to_rfc3339(),
finished_at: task.finished_at.map(|at| at.to_rfc3339()),
stop_requested: task.stop_requested,
total: task.total,
counts: CountsView {
pending: counts.pending,
running: counts.running,
succeeded: counts.succeeded,
failed: counts.failed,
unknown: counts.unknown,
skipped: counts.skipped,
imported_accounts: counts.imported_accounts,
},
}
}
}
#[derive(Serialize)]
struct DetailView {
#[serde(flatten)]
summary: SummaryView,
items: Vec<ItemView>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ItemView {
index: usize,
provider: String,
status: &'static str,
account_ids: Vec<String>,
message: Option<String>,
}
impl From<ImportTaskDetail> for DetailView {
fn from(task: ImportTaskDetail) -> Self {
Self {
summary: task.summary.into(),
items: task
.items
.into_iter()
.map(|item| ItemView {
index: item.index,
provider: item.provider.to_string(),
status: item.status.as_str(),
account_ids: item
.account_ids
.into_iter()
.map(|id| id.to_string())
.collect(),
message: item.message,
})
.collect(),
}
}
}
#[derive(Serialize)]
struct ListView {
items: Vec<SummaryView>,
}
async fn submit<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<SubmitRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let submission_id = Uuid::parse_str(&request.submission_id)
.map_err(|_| map_wire_error(WireValidationError::new("submissionId")))?;
if submission_id.is_nil() {
return Err(map_wire_error(WireValidationError::new("submissionId")));
}
if request.items.is_empty() || request.items.len() > MAX_IMPORT_TASK_ITEMS {
return Err(map_wire_error(WireValidationError::new("items")));
}
let encoded = serde_json::to_vec(&request.items)
.map_err(|_| map_wire_error(WireValidationError::new("items")))?;
let fingerprint = Sha256::digest(&encoded).into();
drop(encoded);
let context = auth.context().mutation_context();
let items = request
.items
.into_iter()
.map(|item| {
let (provider, command) = item.into_command(context.clone())?;
let provider = ProviderKind::new(match provider {
AccountProvider::OpenAi => "openai",
AccountProvider::Xai => "xai",
})
.map_err(|_| WireValidationError::new("provider"))?;
Ok(ImportTaskInput { provider, command })
})
.collect::<Result<Vec<_>, WireValidationError>>()
.map_err(map_wire_error)?;
let task = state
.admin_services()
.import_tasks()
.submit(SubmitImportTask {
submission_id,
fingerprint,
context,
items,
})
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::ACCEPTED,
AdminEnvelope::ok(SummaryView::from(task)),
))
}
async fn list<S>(auth: AdminAuth, State(state): State<S>) -> impl IntoResponse
where
S: SessionState + Send + Sync,
{
let items = state
.admin_services()
.import_tasks()
.list(&auth.context().mutation_context())
.into_iter()
.map(SummaryView::from)
.collect();
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(ListView { items }))
}
async fn detail<S>(
auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<TaskId>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let task = state
.admin_services()
.import_tasks()
.detail(
&auth.context().mutation_context(),
parse_task_id(&query.task_id)?,
)
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(DetailView::from(task)),
))
}
async fn stop<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<TaskId>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let task = state
.admin_services()
.import_tasks()
.stop(
&auth.context().mutation_context(),
parse_task_id(&request.task_id)?,
)
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(DetailView::from(task)),
))
}
fn parse_task_id(value: &str) -> Result<Uuid, AdminError> {
Uuid::parse_str(value).map_err(|_| map_wire_error(WireValidationError::new("taskId")))
}
@@ -46,8 +46,8 @@ use uuid::{Uuid, Version};
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta, WireValidationError,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
WireValidationError,
};
const DEFAULT_PAGE_SIZE: u32 = 50;
@@ -66,6 +66,7 @@ const MAX_AVATAR_VERSION_BYTES: usize = 32;
mod credentials;
mod handlers;
mod import_tasks;
mod presenter;
mod wire;
@@ -75,8 +76,8 @@ pub(super) use presenter::quota_window_view;
pub use wire::*;
use credentials::{
AccountProvider, deserialize_required_nullable, parse_account_weight, parse_concurrency_limit,
provider_document_value, require_account_id, validate_wire_group_ids,
AccountProvider, parse_account_weight, parse_concurrency_limit, provider_document_value,
require_account_id, validate_wire_group_ids,
};
use presenter::*;
use wire::BatchUpdatedAccountsData;
@@ -66,15 +66,20 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
quota,
} = item;
let status = projection.status.as_str().to_owned();
let rate_limited_until = projection.rate_limited_until.map(DateTime::<Utc>::from);
let cooldown = projection.cooldown;
let expires_at = account.access_token_expires_at.as_ref().map(china_rfc3339);
let added_at = china_rfc3339(&account.created_at);
let updated_at = china_rfc3339(&account.updated_at);
let usage_period = quota.usage_window().map(|(_, period)| period);
let (quota, refresh_token_expires_at) = account_quota_view(quota, rate_limited_until, now);
let mut usage = account_usage_view(usage, usage_period, now);
if account.authentication_kind == "api_key" {
usage.window_label_display = "通用额度".to_owned();
}
let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now);
AccountView {
id: account.id.clone(),
name: account.name,
notes: account.notes,
provider: account.provider_kind.to_string(),
groups: account
.groups
@@ -103,6 +108,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
enabled: account.enabled,
concurrency_limit: account.concurrency_limit.map(|limit| limit.get()),
weight: account.weight.get(),
model_access: account.model_access,
outbound_proxy_endpoint: account
.outbound_proxy
.as_ref()
@@ -119,7 +125,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
updated_at,
updated_at_display: china_datetime(&account.updated_at),
quota,
usage: account_usage_view(usage, usage_period, now),
usage,
}
}
@@ -183,7 +189,7 @@ fn forecast_usd_display(value: Option<f64>) -> String {
pub(super) fn account_quota_view(
mut quota: ProviderQuota,
rate_limited_until: Option<DateTime<Utc>>,
cooldown: Option<gateway_core::account::AccountCooldown>,
now: DateTime<Utc>,
) -> (AccountQuotaView, Option<String>) {
quota.apply_limit_reached_display();
@@ -194,12 +200,22 @@ pub(super) fn account_quota_view(
.observed_at
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now));
let windows = quota.windows.into_iter().map(quota_window_view).collect();
let rate_limited_until = rate_limited_until.map(|until| china_datetime(&until));
let rate_limited_until = cooldown.map(|value| china_datetime(&value.until.into()));
let rate_limit_reason = cooldown.map(|value| {
if value.kind.is_capacity_freeze() {
"capacity_freeze".to_owned()
} else {
"upstream_rate_limit".to_owned()
}
});
let recovery_probe_required = cooldown.is_some_and(|value| value.kind.requires_probe());
(
AccountQuotaView {
refreshed_at_display,
limit_reached: quota.limit_reached,
rate_limited_until,
rate_limit_reason,
recovery_probe_required,
windows,
},
refresh_token_expires_at,
@@ -64,11 +64,12 @@ pub struct BatchUpdateAccountsRequest {
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<AccountProxyUpdate>,
pub account_ids: Vec<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
pub weight: u64,
pub group_ids: Vec<String>,
pub enabled: Option<bool>,
#[serde(default, deserialize_with = "deserialize_optional_nullable_limit")]
pub concurrency_limit: Option<Option<u64>>,
pub weight: Option<u64>,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize)]
@@ -91,6 +92,12 @@ impl From<AccountsUpdateResult> for BatchUpdatedAccountsData {
}
}
fn deserialize_optional_nullable_limit<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Option<u64>>, D::Error> {
Option::<u64>::deserialize(deserializer).map(Some)
}
impl BatchUpdateAccountsRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if self.account_ids.is_empty()
@@ -103,9 +110,23 @@ impl BatchUpdateAccountsRequest {
{
return Err(WireValidationError::new("accountIds"));
}
validate_wire_group_ids(&self.group_ids)?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
if let Some(group_ids) = &self.group_ids {
validate_wire_group_ids(group_ids)?;
}
self.concurrency_limit
.map(parse_concurrency_limit)
.transpose()?;
self.weight.map(parse_account_weight).transpose()?;
if self.enabled.is_none()
&& self.concurrency_limit.is_none()
&& self.weight.is_none()
&& self.group_ids.is_none()
&& self.model_access.is_none()
&& self.outbound_proxy_id.is_none()
&& self.outbound_proxy_url.is_none()
{
return Err(WireValidationError::new("accountSettings"));
}
Ok(())
}
@@ -115,9 +136,17 @@ impl BatchUpdateAccountsRequest {
outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?,
account_ids: self.account_ids,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
group_ids: validate_wire_group_ids(&self.group_ids)?,
concurrency_limit: self
.concurrency_limit
.map(parse_concurrency_limit)
.transpose()?,
weight: self.weight.map(parse_account_weight).transpose()?,
model_access: self.model_access,
group_ids: self
.group_ids
.as_deref()
.map(validate_wire_group_ids)
.transpose()?,
})
}
}
@@ -240,6 +269,7 @@ pub struct AccountView {
pub outbound_proxy_endpoint: Option<String>,
pub id: String,
pub name: String,
pub notes: Option<String>,
pub provider: String,
pub groups: Vec<AccountGroupRefView>,
pub resource_ref: String,
@@ -260,6 +290,7 @@ pub struct AccountView {
pub enabled: bool,
pub concurrency_limit: Option<u32>,
pub weight: u16,
pub model_access: gateway_core::account::AccountModelAccess,
pub access_token_expires_at: Option<String>,
pub access_token_expires_at_display: Option<String>,
pub refresh_token_expires_at: Option<String>,
@@ -330,8 +361,10 @@ pub struct AccountGroupRefView {
pub struct AccountQuotaView {
pub refreshed_at_display: String,
pub limit_reached: bool,
/// 429 临时限流(Redis 冷却)到期时间展示;非限流中为 `null`。
/// 冷却到期或可开始恢复探测的时间;非限流中为 `null`。
pub rate_limited_until: Option<String>,
pub rate_limit_reason: Option<String>,
pub recovery_probe_required: bool,
pub windows: Vec<AccountQuotaWindowView>,
}
@@ -653,6 +686,14 @@ pub struct AccountQuotaData {
pub account: AccountView,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountDetailData {
pub account: AccountView,
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_configuration: Option<serde_json::Value>,
}
/// 个人资料、累计统计与订阅的统一响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
+14 -224
View File
@@ -1,54 +1,17 @@
//! 管理员登录、会话与管理请求鉴权接线。
use std::fmt;
//! 管理端权限校验与请求审计上下文。
use axum::{
Router,
extract::{FromRequestParts, State},
http::{
HeaderMap, HeaderValue, StatusCode,
header::{ORIGIN, SET_COOKIE},
request::Parts,
},
response::{IntoResponse, Response},
routing::{get, post},
extract::FromRequestParts,
http::{HeaderMap, request::Parts},
};
use gateway_admin::{
AdminServices,
model::auth::{AdminPrincipal, AdminRequestContext, LoginCommand, LoginError},
};
use serde::{Deserialize, Serialize};
use gateway_admin::model::auth::{AdminPrincipal, AdminRequestContext};
use tower_http::request_id::RequestId;
use url::Url;
use super::{AdminEnvelope, AdminError, AdminJson, AdminResponse};
use crate::{auth::SessionState, session_cookie};
use super::{AdminError, wire::map_admin_service_error};
const REQUEST_ID_HEADER: &str = "x-request-id";
const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session";
/// 所有管理 HTTP 模块从 state 消费同一个认证用例端口。
pub trait AdminSessionState {
fn admin_services(&self) -> &AdminServices;
}
fn admin_session_cookie_attrs(headers: &HeaderMap) -> &'static str {
// 同源管理端的 POST 由浏览器携带 Origin,HTTPS 反代回源 HTTP 不改变它。
// 只对明确的 HTTP 来源放宽;缺失、opaque 或非法来源继续使用 Secure。
let http_origin = headers.get_all(ORIGIN).iter().count() == 1
&& headers
.get(ORIGIN)
.and_then(|value| value.to_str().ok())
.is_some_and(|origin| {
Url::parse(origin).is_ok_and(|url| {
url.scheme() == "http" && url.origin().ascii_serialization() == origin
})
});
if http_origin {
"Path=/; HttpOnly; SameSite=Lax"
} else {
"Path=/; Secure; HttpOnly; SameSite=Lax"
}
}
/// 已通过管理员会话或部署级管理 API Key 鉴权的请求。
pub struct AdminAuth {
@@ -64,7 +27,7 @@ impl AdminAuth {
impl<S> FromRequestParts<S> for AdminAuth
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
type Rejection = AdminError;
@@ -97,23 +60,23 @@ fn admin_request_id(parts: &Parts) -> Option<String> {
pub async fn require_admin_session<S>(state: &S, headers: &HeaderMap) -> Result<String, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
match state
.admin_services()
.auth()
.resolve_admin_user_id(admin_session_cookie(headers).as_deref())
.resolve_admin_user_id(session_cookie::value(headers).as_deref())
.await
{
Ok(Some(admin_user_id)) => Ok(admin_user_id),
Ok(None) => Err(AdminError::admin_session_required()),
Err(_) => Err(AdminError::internal()),
Ok(None) => Err(AdminError::session_required()),
Err(error) => Err(map_admin_service_error(error)),
}
}
async fn require_admin_auth<S>(state: &S, headers: &HeaderMap) -> Result<AdminPrincipal, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
if let Some(api_key) = admin_api_key_header(headers) {
return match state
@@ -124,7 +87,7 @@ where
{
Ok(true) => Ok(AdminPrincipal::ApiKey),
Ok(false) => Err(AdminError::invalid_admin_api_key()),
Err(_) => Err(AdminError::internal()),
Err(error) => Err(map_admin_service_error(error)),
};
}
@@ -137,176 +100,3 @@ fn admin_api_key_header(headers: &HeaderMap) -> Option<String> {
let value = headers.get("x-api-key")?.to_str().ok()?.trim();
(!value.is_empty()).then(|| value.to_owned())
}
/// 管理员登录请求;密码不得进入 Debug。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AdminLoginRequest {
username: Option<String>,
password: String,
}
impl AdminLoginRequest {
/// 取出认证用例所需字段。
#[must_use]
pub fn into_parts(self) -> (Option<String>, String) {
(self.username, self.password)
}
}
impl fmt::Debug for AdminLoginRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AdminLoginRequest")
.field("username", &self.username)
.field("password", &"[REDACTED]")
.finish()
}
}
/// 登录成功响应。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminLoginData {
expires_at: String,
}
impl AdminLoginData {
#[must_use]
pub fn new(expires_at: String) -> Self {
Self { expires_at }
}
}
/// 管理员会话状态响应。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminSessionStatusData {
authenticated: bool,
}
impl AdminSessionStatusData {
#[must_use]
pub const fn new(authenticated: bool) -> Self {
Self { authenticated }
}
}
/// 登出成功响应。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct AdminLogoutData {
message: &'static str,
}
impl AdminLogoutData {
#[must_use]
pub const fn new() -> Self {
Self {
message: "Logged out successfully",
}
}
}
impl Default for AdminLogoutData {
fn default() -> Self {
Self::new()
}
}
/// 构造固定 GET/POST 管理员认证路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/auth/login", post(login::<S>))
.route("/api/admin/auth/status", get(session_status::<S>))
.route("/api/admin/auth/logout", post(logout::<S>))
}
async fn login<S>(
State(state): State<S>,
headers: HeaderMap,
AdminJson(payload): AdminJson<AdminLoginRequest>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (username, password) = payload.into_parts();
let session = state
.admin_services()
.auth()
.login(LoginCommand { username, password })
.await
.map_err(map_login_error)?;
let mut response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminLoginData::new(session.expires_at.to_rfc3339())),
)
.into_response();
let cookie = format!(
"{ADMIN_SESSION_COOKIE}={}; {}",
session.session_id,
admin_session_cookie_attrs(&headers)
);
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
);
Ok(response)
}
async fn session_status<S>(
State(state): State<S>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let authenticated = state
.admin_services()
.auth()
.validate_session(admin_session_cookie(&headers).as_deref())
.await
.map_err(|_| AdminError::internal())?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminSessionStatusData::new(authenticated)),
))
}
async fn logout<S>(State(state): State<S>, headers: HeaderMap) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
{
if let Some(session_id) = admin_session_cookie(&headers) {
let _ = state.admin_services().auth().logout(&session_id).await;
}
let mut response =
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(AdminLogoutData::new()))
.into_response();
let cookie = format!(
"{ADMIN_SESSION_COOKIE}=; {}; Max-Age=0",
admin_session_cookie_attrs(&headers)
);
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
);
Ok(response)
}
fn admin_session_cookie(headers: &HeaderMap) -> Option<String> {
let cookie = headers.get("cookie")?.to_str().ok()?;
cookie.split(';').find_map(|part| {
let (name, value) = part.trim().split_once('=')?;
(name == ADMIN_SESSION_COOKIE).then(|| value.to_owned())
})
}
fn map_login_error(error: LoginError) -> AdminError {
match error {
LoginError::InvalidCredentials => AdminError::invalid_admin_credentials(),
LoginError::Unavailable => AdminError::internal(),
}
}
+13 -10
View File
@@ -1,7 +1,10 @@
//! 备份设置、计划、手动创建、记录、下载与删除路由。
//!
//! 路径统一位于 `/api/admin/settings/backups/*`,内部由独立 `BackupService` 承担业务。
use crate::auth::SessionState;
use axum::{
Router,
extract::State,
@@ -19,7 +22,7 @@ use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminPageData, AdminQuery, AdminResponse,
AdminSessionState, PageMeta, wire::map_admin_service_error,
PageMeta, wire::map_admin_service_error,
};
/// 备份设置视图;`secretAccessKey` 返回已保存的明文凭据,由前端掩码显示。
@@ -200,7 +203,7 @@ impl From<ConnectionTestResult> for ConnectionTestView {
/// 构造固定 GET/POST 备份路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/settings/backups", get(backup_settings::<S>))
@@ -239,7 +242,7 @@ async fn backup_settings<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let settings = state
.admin_services()
@@ -259,7 +262,7 @@ async fn update_backup_storage<S>(
AdminJson(request): AdminJson<UpdateBackupStorageRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = UpdateBackupStorageCommand {
endpoint: request.endpoint,
@@ -287,7 +290,7 @@ async fn test_backup_storage<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -307,7 +310,7 @@ async fn update_backup_schedule<S>(
AdminJson(request): AdminJson<UpdateBackupScheduleRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = UpdateBackupScheduleCommand {
schedule_enabled: request.schedule_enabled,
@@ -334,7 +337,7 @@ async fn backup_records<S>(
AdminQuery(query): AdminQuery<BackupRecordsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let page_size = gateway_admin::model::PageSize::new(query.page_size.unwrap_or(20))
.map_err(|_| AdminError::bad_request("pageSize 不合法"))?;
@@ -389,7 +392,7 @@ async fn create_backup<S>(
body: Option<AdminJson<CreateBackupRequest>>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let expires_at = body
.and_then(|body| body.0.expires_in_days)
@@ -420,7 +423,7 @@ async fn download_backup_url<S>(
AdminJson(request): AdminJson<BackupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -440,7 +443,7 @@ async fn delete_backup<S>(
AdminJson(request): AdminJson<BackupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let record = state
.admin_services()
@@ -1,5 +1,7 @@
//! Client API Key 管理 wire contract。
use crate::auth::SessionState;
use std::fmt;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -27,7 +29,7 @@ use axum::{
};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
WireValidationError, wire::map_admin_service_error,
};
@@ -679,7 +681,7 @@ fn validate_optional_text(
/// 构造固定 GET/POST 且 ID 仅位于 query/body 的 Client API Key 路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/client-keys", get(list_client_keys::<S>))
@@ -712,7 +714,7 @@ async fn list_client_keys<S>(
AdminQuery(query): AdminQuery<ListClientKeysQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -730,7 +732,7 @@ async fn create_client_key<S>(
AdminJson(payload): AdminJson<CreateClientKeyRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = payload.into_command().map_err(map_wire_error)?;
let result = state
@@ -751,7 +753,7 @@ async fn reveal_client_key<S>(
AdminQuery(query): AdminQuery<ClientKeyIdQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let id = query.into_domain_id().map_err(map_wire_error)?;
let result = state
@@ -777,7 +779,7 @@ async fn update_client_key<S>(
AdminJson(payload): AdminJson<UpdateClientKeyRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = payload.into_command().map_err(map_wire_error)?;
mutation_response(
@@ -795,7 +797,7 @@ async fn disable_client_key<S>(
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let id = payload.into_domain_id().map_err(map_wire_error)?;
mutation_response(
@@ -816,7 +818,7 @@ async fn enable_client_key<S>(
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let id = payload.into_domain_id().map_err(map_wire_error)?;
mutation_response(
@@ -837,7 +839,7 @@ async fn delete_client_key<S>(
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let id = payload.into_domain_id().map_err(map_wire_error)?;
mutation_response(
+4 -3
View File
@@ -1,5 +1,7 @@
//! 管理端 HTTP adapter、wire contract 与固定路由。
use crate::auth::SessionState;
use axum::{
Router,
http::{HeaderValue, header},
@@ -21,7 +23,7 @@ pub mod settings;
pub mod system;
pub mod wire;
pub use auth::{AdminAuth, AdminSessionState};
pub use auth::AdminAuth;
pub use extract::{AdminJson, AdminQuery};
pub use wire::{
ADMIN_OK_CODE, ADMIN_OK_MESSAGE, AdminEnvelope, AdminError, AdminErrorBody, AdminErrorCode,
@@ -31,13 +33,12 @@ pub use wire::{
/// 构造完整且固定的 `/api/admin` 路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.merge(account_groups::router::<S>())
.merge(proxies::router::<S>())
.merge(accounts::router::<S>())
.merge(auth::router::<S>())
.merge(backups::router::<S>())
.merge(client_keys::router::<S>())
.merge(observability::router::<S>())
@@ -10,8 +10,8 @@ use serde_json::Value;
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminQuery, AdminResponse, AdminSessionState,
WireValidationError, wire::map_admin_service_error,
AdminAuth, AdminEnvelope, AdminError, AdminQuery, AdminResponse, WireValidationError,
wire::map_admin_service_error,
};
mod presenter;
@@ -1,10 +1,12 @@
//! 固定 `/api/admin` 观测路由与 handler。
use crate::auth::SessionState;
use super::*;
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/dashboard/summary", get(dashboard_summary::<S>))
@@ -35,7 +37,7 @@ pub(crate) async fn dashboard_summary<S>(
AdminQuery(query): AdminQuery<DashboardQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let kind = query.trend_kind().map_err(map_wire_error)?;
// 概览默认按中国时区当日统计,与单独趋势接口保持同一口径。
@@ -59,7 +61,7 @@ pub(crate) async fn dashboard_trend<S>(
AdminQuery(query): AdminQuery<DashboardQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let kind = query.trend_kind().map_err(map_wire_error)?;
let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref())
@@ -82,7 +84,7 @@ pub(crate) async fn usage_records<S>(
AdminQuery(query): AdminQuery<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = usage_command(&query).map_err(map_wire_error)?;
let result = state
@@ -101,7 +103,7 @@ pub(crate) async fn usage_record_detail<S>(
AdminQuery(query): AdminQuery<DetailQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
query.validate().map_err(map_wire_error)?;
let result = state
@@ -122,7 +124,7 @@ pub(crate) async fn usage_records_summary<S>(
AdminQuery(query): AdminQuery<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
@@ -145,7 +147,7 @@ pub(crate) async fn usage_insights_overview<S>(
AdminQuery(query): AdminQuery<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
@@ -168,7 +170,7 @@ pub(crate) async fn usage_insights_diagnostics<S>(
AdminQuery(query): AdminQuery<DiagnosticsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let dimension = query.dimension().map_err(map_wire_error)?;
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
@@ -198,7 +200,7 @@ pub(crate) async fn ops_errors<S>(
AdminQuery(query): AdminQuery<OpsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = ops_command(&query).map_err(map_wire_error)?;
let result = state
+28 -11
View File
@@ -1,3 +1,5 @@
use crate::auth::SessionState;
use axum::{
Router,
extract::State,
@@ -15,8 +17,7 @@ use gateway_admin::model::{
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
accounts::{AccountGroupRefView, AccountProxyUpdate},
};
@@ -47,6 +48,7 @@ struct RemoveAccountRequest {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateRequest {
location: Option<gateway_core::account::RequestLocation>,
name: String,
proxy_url: AccountProxyUpdate,
}
@@ -54,12 +56,23 @@ struct CreateRequest {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct UpdateRequest {
#[serde(default, deserialize_with = "deserialize_location_update")]
location: Option<Option<gateway_core::account::RequestLocation>>,
id: String,
revision: u64,
name: String,
proxy_url: Option<AccountProxyUpdate>,
}
fn deserialize_location_update<'de, D>(
deserializer: D,
) -> Result<Option<Option<gateway_core::account::RequestLocation>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::deserialize(deserializer).map(Some)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct IdRequest {
@@ -110,6 +123,7 @@ struct ProxyAccountView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyView {
location: Option<gateway_core::account::RequestLocation>,
id: String,
name: String,
endpoint: String,
@@ -126,6 +140,7 @@ impl From<ProxyRecord> for ProxyView {
fn from(record: ProxyRecord) -> Self {
let endpoint = record.proxy.endpoint();
Self {
location: record.location,
id: record.id,
name: record.name,
has_authentication: record.proxy.expose_url() != endpoint,
@@ -170,7 +185,7 @@ impl From<ProxyMutation> for MutationView {
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/proxies", get(list::<S>))
@@ -203,7 +218,7 @@ async fn list<S>(
AdminQuery(query): AdminQuery<ListQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -237,7 +252,7 @@ async fn list_accounts<S>(
AdminQuery(query): AdminQuery<AccountsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -295,7 +310,7 @@ async fn create<S>(
AdminJson(request): AdminJson<CreateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let proxy = request
.proxy_url
@@ -306,6 +321,7 @@ where
.proxies()
.create(
NewProxy {
location: request.location,
name: request.name,
proxy,
},
@@ -325,7 +341,7 @@ async fn remove_account<S>(
AdminJson(request): AdminJson<RemoveAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let revision = state
.admin_services()
@@ -349,7 +365,7 @@ async fn update<S>(
AdminJson(request): AdminJson<UpdateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let proxy = request
.proxy_url
@@ -364,6 +380,7 @@ where
.proxies()
.update(
UpdateProxy {
location: request.location,
id: request.id,
revision: revision(request.revision)?,
name: request.name,
@@ -385,7 +402,7 @@ async fn delete<S>(
AdminJson(request): AdminJson<IdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -409,7 +426,7 @@ async fn probe<S>(
AdminJson(request): AdminJson<ProbeRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let proxy = request
.proxy_url
@@ -433,7 +450,7 @@ async fn test<S>(
AdminJson(request): AdminJson<IdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -1,5 +1,7 @@
//! Runtime settings、旧设置页聚合投影与明文 Admin API Key wire。
use crate::auth::SessionState;
use std::{collections::BTreeMap, fmt};
use axum::{
@@ -21,7 +23,7 @@ use gateway_core::routing::{PublicModelId, UpstreamModelId};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
WireValidationError, wire::map_admin_service_error,
};
@@ -32,17 +34,29 @@ pub type ModelMappings = BTreeMap<String, String>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u64,
pub max_concurrent_per_account: u64,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u64,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub updated_at: DateTime<Utc>,
}
@@ -50,23 +64,49 @@ pub struct RuntimeSettingsView {
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateRuntimeSettingsRequest {
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u64,
pub max_concurrent_per_account: u64,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u64,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
}
impl UpdateRuntimeSettingsRequest {
/// 校验公共运行参数。
pub fn validate(&self) -> Result<(), WireValidationError> {
self.request_location
.validate()
.map_err(|_| WireValidationError::new("requestLocation"))?;
validate_model_mappings(&self.model_mappings)?;
for (value, field) in [
(self.max_waiting_per_key, "maxWaitingPerKey"),
(self.max_waiting_per_account, "maxWaitingPerAccount"),
] {
if value > 1_000 {
return Err(WireValidationError::new(field));
}
}
if !(1..=120).contains(&self.concurrency_wait_timeout_seconds) {
return Err(WireValidationError::new("concurrencyWaitTimeoutSeconds"));
}
for (value, field) in [
(self.refresh_margin_seconds, "refreshMarginSeconds"),
(self.refresh_concurrency, "refreshConcurrency"),
@@ -91,12 +131,46 @@ impl UpdateRuntimeSettingsRequest {
self.min_codex_cli_version.as_deref(),
"minCodexCliVersion",
)?;
for (value, field) in [
(
self.account_auto_freeze_threshold,
"accountAutoFreezeThreshold",
),
(
self.account_auto_freeze_window_seconds,
"accountAutoFreezeWindowSeconds",
),
(
self.account_auto_freeze_duration_seconds,
"accountAutoFreezeDurationSeconds",
),
] {
require_positive_i64(value, field)?;
}
if !(2..=1_000).contains(&self.account_auto_freeze_threshold) {
return Err(WireValidationError::new("accountAutoFreezeThreshold"));
}
if !(60..=3_600).contains(&self.account_auto_freeze_window_seconds) {
return Err(WireValidationError::new("accountAutoFreezeWindowSeconds"));
}
if !(300..=604_800).contains(&self.account_auto_freeze_duration_seconds) {
return Err(WireValidationError::new("accountAutoFreezeDurationSeconds"));
}
validate_optional_probe_model(
self.account_auto_freeze_probe_model.as_deref(),
"accountAutoFreezeProbeModel",
)?;
Ok(())
}
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
self.validate()?;
Ok(ReplaceRuntimeSettings {
request_location_enabled: self.request_location_enabled,
request_location: self
.request_location
.normalized()
.map_err(|_| WireValidationError::new("requestLocation"))?,
model_mappings: domain_model_mappings(self.model_mappings)?,
refresh_margin_seconds: self.refresh_margin_seconds,
refresh_concurrency: u32::try_from(self.refresh_concurrency)
@@ -104,6 +178,9 @@ impl UpdateRuntimeSettingsRequest {
max_concurrent_per_account: u32::try_from(self.max_concurrent_per_account)
.map_err(|_| WireValidationError::new("settingsMaxConcurrencyOverflow"))?,
request_interval_ms: self.request_interval_ms,
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,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
min_codex_desktop_version: self.min_codex_desktop_version,
@@ -114,6 +191,14 @@ impl UpdateRuntimeSettingsRequest {
.map_err(|_| WireValidationError::new("settingsOpsRetentionOverflow"))?,
audit_retention_days: u32::try_from(self.audit_retention_days)
.map_err(|_| WireValidationError::new("settingsAuditRetentionOverflow"))?,
account_auto_freeze_enabled: self.account_auto_freeze_enabled,
account_auto_freeze_threshold: u32::try_from(self.account_auto_freeze_threshold)
.map_err(|_| WireValidationError::new("settingsFreezeThresholdOverflow"))?,
account_auto_freeze_window_seconds: self.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: self.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: self.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: self.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: self.account_auto_freeze_adaptive_concurrency,
})
}
}
@@ -121,17 +206,30 @@ impl UpdateRuntimeSettingsRequest {
impl From<RuntimeSettings> for RuntimeSettingsView {
fn from(settings: RuntimeSettings) -> Self {
Self {
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
model_mappings: wire_model_mappings(settings.model_mappings),
refresh_margin_seconds: settings.refresh_margin_seconds,
refresh_concurrency: u64::from(settings.refresh_concurrency),
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
request_interval_ms: settings.request_interval_ms,
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,
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,
usage_retention_days: u64::from(settings.usage_retention_days),
ops_event_retention_days: u64::from(settings.ops_event_retention_days),
audit_retention_days: u64::from(settings.audit_retention_days),
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
account_auto_freeze_threshold: u64::from(settings.account_auto_freeze_threshold),
account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: settings
.account_auto_freeze_adaptive_concurrency,
updated_at: settings.updated_at,
}
}
@@ -228,7 +326,7 @@ impl Default for DeletedAdminApiKey {
/// 构造固定 GET/POST 设置路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/settings", get(settings::<S>))
@@ -257,7 +355,7 @@ async fn codex_desktop_windows_downloads<S>(
AdminQuery(query): AdminQuery<ClientDownloadsQuery>,
) -> impl IntoResponse
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let downloads = state
.admin_services()
@@ -275,7 +373,7 @@ async fn settings<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -295,7 +393,7 @@ async fn update_settings<S>(
AdminJson(request): AdminJson<UpdateRuntimeSettingsRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
@@ -315,7 +413,7 @@ async fn admin_api_key_status<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let exists = state
.admin_services()
@@ -334,7 +432,7 @@ async fn regenerate_admin_api_key<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -355,7 +453,7 @@ async fn delete_admin_api_key<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
state
.admin_services()
@@ -427,6 +525,22 @@ fn validate_optional_client_version(
Ok(())
}
/// 探测模型为可选自由文本:非空、去首尾空白后不变、无控制字符且不超过 128 字节。
fn validate_optional_probe_model(
value: Option<&str>,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.is_some_and(|value| {
value.is_empty()
|| value.len() > 128
|| value != value.trim()
|| value.bytes().any(|byte| byte.is_ascii_control())
}) {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn map_wire_error(error: WireValidationError) -> AdminError {
let message = match error.field() {
"settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(),
@@ -434,6 +548,12 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
"settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(),
"settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(),
"settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(),
"requestLocation" => "请求位置不合法,请检查国家代码、地区和城市".to_owned(),
"settingsFreezeThresholdOverflow" => "accountAutoFreezeThreshold 不合法".to_owned(),
"accountAutoFreezeThreshold" => "账号自动冻结阈值应为 2~1000 的整数".to_owned(),
"accountAutoFreezeWindowSeconds" => "账号自动冻结统计窗口应为 60~3600 秒".to_owned(),
"accountAutoFreezeDurationSeconds" => "账号自动冻结时长应为 300~604800 秒".to_owned(),
"accountAutoFreezeProbeModel" => "探测模型格式不合法".to_owned(),
"minCodexDesktopVersion" => "Codex Desktop 最低版本格式不合法".to_owned(),
"minCodexCliVersion" => "Codex CLI 最低版本格式不合法".to_owned(),
field => format!("{field} 字段不合法"),
+12 -9
View File
@@ -1,7 +1,10 @@
//! 系统管理接口的查询与请求 wire contract。
//!
//! 这里不依赖更新服务或进程控制;应用层通过窄端口提供系统操作事实。
use crate::auth::SessionState;
use std::convert::Infallible;
use axum::{
@@ -23,7 +26,7 @@ use gateway_admin::model::system::{
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
wire::map_admin_service_error,
};
@@ -214,7 +217,7 @@ struct SystemUpdateEventView {
/// 构造固定 GET/POST 系统管理路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/system/version", get(version::<S>))
@@ -234,7 +237,7 @@ async fn version<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let version = state
.admin_services()
@@ -254,7 +257,7 @@ async fn update_detail<S>(
AdminQuery(query): AdminQuery<UpdateDetailQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let detail = state
.admin_services()
@@ -273,7 +276,7 @@ async fn update_event_stream<S>(
State(state): State<S>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let stream = state
.admin_services()
@@ -293,7 +296,7 @@ async fn perform_update<S>(
payload: Option<AdminJson<UpdateRequest>>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -328,7 +331,7 @@ async fn update_status<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let status = state
.admin_services()
@@ -347,7 +350,7 @@ async fn rollback<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -378,7 +381,7 @@ async fn restart<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
+32 -9
View File
@@ -96,17 +96,19 @@ impl AdminErrorCode {
pub const INVALID_TIME_RANGE: Self = Self(40002);
/// 模型来源不合法。
pub const INVALID_MODEL_SOURCE: Self = Self(40003);
/// 缺少管理员会话。
/// 缺少登录会话。
pub const SESSION_REQUIRED: Self = Self(40101);
/// 管理员登录凭据错误。
/// 登录凭据错误。
pub const INVALID_CREDENTIALS: Self = Self(40102);
/// 管理 API Key 错误。
pub const INVALID_API_KEY: Self = Self(40103);
/// 当前会话无权访问。
pub const FORBIDDEN: Self = Self(40301);
/// 资源不存在。
pub const NOT_FOUND: Self = Self(40401);
/// 配置 revision 或资源状态冲突。
pub const CONFLICT: Self = Self(40901);
/// 管理员登录尝试过多。
/// 登录尝试过多。
pub const TOO_MANY_LOGIN_ATTEMPTS: Self = Self(42901);
/// 设置持久化失败。
pub const SETTINGS_PERSIST: Self = Self(50000);
@@ -165,6 +167,7 @@ impl AdminErrorBody {
pub struct AdminError {
status: StatusCode,
body: AdminErrorBody,
retry_after_seconds: Option<u64>,
}
#[derive(Debug, Clone, Copy)]
@@ -202,12 +205,12 @@ const INVALID_TIME_RANGE: AdminErrorSpec = AdminErrorSpec::new(
const SESSION_REQUIRED: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::SESSION_REQUIRED,
"需要管理员登录",
"需要登录",
);
const INVALID_CREDENTIALS: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::INVALID_CREDENTIALS,
"管理员用户名或密码错误",
"登录凭据错误",
);
const INVALID_API_KEY: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::UNAUTHORIZED,
@@ -255,6 +258,7 @@ impl AdminError {
Self {
status,
body: AdminErrorBody::new(code, message),
retry_after_seconds: None,
}
}
@@ -286,11 +290,11 @@ impl AdminError {
Self::new(BAD_REQUEST.status, BAD_REQUEST.code, message)
}
pub fn admin_session_required() -> Self {
pub fn session_required() -> Self {
Self::from_spec(SESSION_REQUIRED)
}
pub fn invalid_admin_credentials() -> Self {
pub fn invalid_credentials() -> Self {
Self::from_spec(INVALID_CREDENTIALS)
}
@@ -302,6 +306,10 @@ impl AdminError {
Self::from_spec(TOO_MANY_LOGIN_ATTEMPTS)
}
pub fn forbidden() -> Self {
Self::new(StatusCode::FORBIDDEN, AdminErrorCode::FORBIDDEN, "无权访问")
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, AdminErrorCode::CONFLICT, message)
}
@@ -325,6 +333,12 @@ impl AdminError {
pub fn service_unavailable() -> Self {
Self::from_spec(SERVICE_UNAVAILABLE)
}
#[must_use]
pub fn with_retry_after(mut self, seconds: u64) -> Self {
self.retry_after_seconds = Some(seconds.max(1));
self
}
}
/// 把管理用例的稳定错误分类映射到既有 HTTP 错误 contract。
@@ -333,7 +347,8 @@ pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -
let mut response = match error.kind() {
AdminErrorKind::Invalid => AdminError::bad_request(error.message()),
AdminErrorKind::Unauthorized => AdminError::admin_session_required(),
AdminErrorKind::Forbidden => AdminError::forbidden(),
AdminErrorKind::Unauthorized => AdminError::session_required(),
AdminErrorKind::NotFound => AdminError::not_found(error.message()),
AdminErrorKind::Conflict => AdminError::conflict(error.message()),
AdminErrorKind::RateLimited => AdminError::too_many_login_attempts(),
@@ -358,7 +373,15 @@ pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -
impl IntoResponse for AdminError {
fn into_response(self) -> Response {
(self.status, Json(self.body)).into_response()
let mut response = (self.status, Json(self.body)).into_response();
if let Some(seconds) = self.retry_after_seconds
&& let Ok(value) = axum::http::HeaderValue::from_str(&seconds.to_string())
{
response
.headers_mut()
.insert(axum::http::header::RETRY_AFTER, value);
}
response
}
}
+232
View File
@@ -0,0 +1,232 @@
//! 控制面统一登录、会话恢复与退出;身份由认证用例返回。
use std::{fmt, net::SocketAddr};
use axum::{
Router,
extract::{ConnectInfo, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
middleware,
response::{IntoResponse, Response},
routing::{any, get, post},
};
use gateway_admin::{
AdminServices,
model::auth::{AuthSession, LoginCommand, LoginError, SessionSubject},
};
use serde::{Deserialize, Serialize};
use crate::{
admin::{AdminEnvelope, AdminError, AdminJson, AdminResponse, wire::map_admin_service_error},
session_cookie,
};
/// 控制面 HTTP adapter 消费同一组用例;权限由各入口服务端校验。
pub trait SessionState {
fn admin_services(&self) -> &AdminServices;
}
#[derive(Deserialize)]
#[serde(tag = "mode", rename_all = "camelCase", deny_unknown_fields)]
pub enum LoginRequest {
Admin {
username: Option<String>,
password: String,
},
Key {
#[serde(rename = "apiKey")]
api_key: String,
},
}
impl fmt::Debug for LoginRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Admin { username, .. } => formatter
.debug_struct("AdminLogin")
.field("username", username)
.field("password", &"[REDACTED]")
.finish(),
Self::Key { .. } => formatter
.debug_struct("KeyLogin")
.field("api_key", &"[REDACTED]")
.finish(),
}
}
}
impl From<LoginRequest> for LoginCommand {
fn from(request: LoginRequest) -> Self {
match request {
LoginRequest::Admin { username, password } => Self::Admin { username, password },
LoginRequest::Key { api_key } => Self::Key { api_key },
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SessionData {
role: &'static str,
expires_at: String,
}
impl From<&AuthSession> for SessionData {
fn from(session: &AuthSession) -> Self {
Self {
role: match session.subject {
SessionSubject::Admin { .. } => "admin",
SessionSubject::Key { .. } => "key",
},
expires_at: session.expires_at.to_rfc3339(),
}
}
}
#[derive(Debug, Serialize)]
struct SessionStatusData {
authenticated: bool,
session: Option<SessionData>,
}
#[derive(Debug, Serialize)]
struct LogoutData {
message: &'static str,
}
pub(crate) fn router<S>() -> Router<S>
where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.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", any(not_found))
.route("/api/auth/{*path}", any(not_found))
.method_not_allowed_fallback(method_not_allowed)
.layer(middleware::map_response(no_store))
}
async fn login<S>(
State(state): State<S>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
AdminJson(payload): AdminJson<LoginRequest>,
) -> Result<Response, AdminError>
where
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
.auth()
.login(
payload.into(),
peer.ip(),
session_cookie::value(&headers).as_deref(),
)
.await
.map_err(map_login_error)?;
let max_age = result
.session
.expires_at
.signed_duration_since(chrono::Utc::now())
.num_seconds()
.max(1);
let expires = result
.session
.expires_at
.format("%a, %d %b %Y %H:%M:%S GMT");
let cookie = format!(
"{}={}; {}; Max-Age={max_age}; Expires={expires}",
session_cookie::NAME,
result.session_id,
session_cookie::attributes(&headers)
);
let mut response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(SessionData::from(&result.session)),
)
.into_response();
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
);
Ok(response)
}
async fn session_status<S>(
State(state): State<S>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let session = state
.admin_services()
.auth()
.session(session_cookie::value(&headers).as_deref())
.await
.map_err(map_admin_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(SessionStatusData {
authenticated: session.is_some(),
session: session.as_ref().map(SessionData::from),
}),
))
}
async fn logout<S>(State(state): State<S>, headers: HeaderMap) -> Result<Response, AdminError>
where
S: SessionState + Send + Sync,
{
if let Some(session_id) = session_cookie::value(&headers) {
state
.admin_services()
.auth()
.logout(&session_id)
.await
.map_err(map_admin_service_error)?;
}
let mut response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(LogoutData {
message: "Logged out successfully",
}),
)
.into_response();
let cookie = format!(
"{}=; {}; Max-Age=0",
session_cookie::NAME,
session_cookie::attributes(&headers)
);
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
);
Ok(response)
}
fn map_login_error(error: LoginError) -> AdminError {
match error {
LoginError::InvalidCredentials => AdminError::invalid_credentials(),
LoginError::TooManyAttempts {
retry_after_seconds,
} => AdminError::too_many_login_attempts().with_retry_after(retry_after_seconds),
LoginError::Unavailable => AdminError::service_unavailable(),
}
}
async fn method_not_allowed() -> AdminError {
AdminError::method_not_allowed()
}
async fn not_found() -> AdminError {
AdminError::not_found("认证接口不存在")
}
async fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
@@ -0,0 +1,93 @@
//! 当前 Key 的单页用量 API;只暴露页面需要的只读字段。
use axum::{
Router,
extract::State,
http::{HeaderMap, HeaderValue, StatusCode, header},
middleware,
response::{IntoResponse, Response},
routing::{any, get},
};
use crate::{
admin::{AdminEnvelope, AdminError, AdminQuery, AdminResponse, wire::map_admin_service_error},
auth::SessionState,
session_cookie,
};
mod presenter;
mod query;
pub(crate) fn router<S>() -> Router<S>
where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/key-usage/overview", get(overview::<S>))
.route("/api/key-usage/records", get(records::<S>))
.route("/api/key-usage", any(not_found))
.route("/api/key-usage/{*path}", any(not_found))
.method_not_allowed_fallback(method_not_allowed)
.layer(middleware::map_response(no_store))
}
async fn overview<S>(
State(state): State<S>,
headers: HeaderMap,
AdminQuery(query): AdminQuery<query::OverviewQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let overview = state
.admin_services()
.key_usage()
.overview(
session_cookie::value(&headers).as_deref(),
query.into_domain()?,
)
.await
.map_err(map_admin_service_error)?
.ok_or_else(AdminError::session_required)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(presenter::overview(overview)),
))
}
async fn records<S>(
State(state): State<S>,
headers: HeaderMap,
AdminQuery(query): AdminQuery<query::RecordsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let records = state
.admin_services()
.key_usage()
.records(
session_cookie::value(&headers).as_deref(),
query.into_domain()?,
)
.await
.map_err(map_admin_service_error)?
.ok_or_else(AdminError::session_required)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(presenter::records(records)),
))
}
async fn not_found() -> AdminError {
AdminError::not_found("密钥用量接口不存在")
}
async fn method_not_allowed() -> AdminError {
AdminError::method_not_allowed()
}
async fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
@@ -0,0 +1,222 @@
//! 只投影当前 Key 可见的用量字段,不序列化账号、凭据、上游标识或诊断正文。
use chrono::{DateTime, Utc};
use gateway_admin::model::{
key_usage::{KeyUsageOverview, KeyUsageRecords},
observability::{
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
},
};
use serde::Serialize;
use crate::admin::observability::{
BillingView, HealthTimelineView, PageData, TokenDetailsView, billing_view,
health_timeline_view, usage_list_token_details,
};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct OverviewView {
as_of: DateTime<Utc>,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
key: KeyView,
summary: MetricsView,
trend: Vec<TrendPointView>,
health_timeline: HealthTimelineView,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct KeyView {
name: String,
prefix: String,
max_concurrency: u64,
requests_per_minute: u64,
daily_limit_usd: String,
daily_used_usd: String,
daily_resets_at: Option<DateTime<Utc>>,
weekly_limit_usd: String,
weekly_used_usd: String,
weekly_resets_at: Option<DateTime<Utc>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MetricsView {
requests: u64,
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
reasoning_tokens: u64,
total_tokens: u64,
cost_usd: Option<String>,
cost_incomplete: bool,
}
fn metrics(value: &RequestMetrics, costs: &[CurrencyCost], coverage: &CostCoverage) -> MetricsView {
MetricsView {
requests: value.request_count,
input_tokens: value.input_tokens,
output_tokens: value.output_tokens,
cached_tokens: value.cached_tokens,
cache_write_tokens: value.cache_write_tokens,
reasoning_tokens: value.reasoning_tokens,
total_tokens: value.total_tokens,
cost_usd: costs
.iter()
.find(|cost| cost.currency.eq_ignore_ascii_case("USD"))
.map(|cost| cost.amount.as_str().to_owned())
.or_else(|| (value.request_count == 0).then(|| "0".to_owned())),
cost_incomplete: coverage.partial_count > 0 || coverage.unavailable_count > 0,
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct TrendPointView {
time: DateTime<Utc>,
bucket_seconds: u32,
#[serde(flatten)]
metrics: MetricsView,
}
pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
let key = value.key;
OverviewView {
as_of: Utc::now(),
start_time: value.overview.range.start,
end_time: value.overview.range.end,
key: KeyView {
name: key.name,
prefix: key.prefix,
max_concurrency: key.limits.max_concurrency,
requests_per_minute: key.limits.requests_per_minute,
daily_limit_usd: key.budget.limits.daily_usd.canonical(),
daily_used_usd: key.budget.daily_used_usd.canonical(),
daily_resets_at: key.budget.daily_resets_at.map(DateTime::from),
weekly_limit_usd: key.budget.limits.weekly_usd.canonical(),
weekly_used_usd: key.budget.weekly_used_usd.canonical(),
weekly_resets_at: key.budget.weekly_resets_at.map(DateTime::from),
},
summary: metrics(
&value.overview.requests,
&value.overview.attempts.costs,
&value.overview.attempts.cost_coverage,
),
trend: value
.trend
.into_iter()
.map(|point| TrendPointView {
time: point.bucket_start,
bucket_seconds: match point.granularity {
Granularity::FifteenMinutes => 900,
Granularity::Hour => 3600,
Granularity::Day => 86400,
},
metrics: metrics(&point.metrics, &point.costs, &point.cost_coverage),
})
.collect(),
health_timeline: health_timeline_view(value.health_timeline),
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RecordView {
id: String,
created_at: DateTime<Utc>,
model: Option<String>,
route: Option<String>,
reasoning_effort: Option<String>,
client_transport: Option<String>,
upstream_transport: Option<String>,
token_details: Option<TokenDetailsView>,
billing: Option<BillingView>,
latency_ms: Option<u64>,
first_token_latency_ms: Option<u64>,
latency_details: OutputTimingView,
client_ip: Option<String>,
user_agent: Option<String>,
status: &'static str,
status_code: Option<u16>,
}
// Key 只查看自身请求的输出时间,不包含账号容量、调度等待等管理侧观测。
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct OutputTimingView {
#[serde(skip_serializing_if = "Option::is_none")]
first_event_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
first_reasoning_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
first_text_ms: Option<u64>,
}
fn success_record(value: UsageListRecord) -> RecordView {
let token_details = Some(usage_list_token_details(&value));
let billing = billing_view(value.billing.as_ref());
RecordView {
id: value.id,
created_at: value.started_at,
model: value.requested_model_id,
route: Some(value.endpoint),
reasoning_effort: value.reasoning_effort,
client_transport: Some(value.client_transport),
upstream_transport: value.upstream_transport,
token_details,
billing,
latency_ms: value.latency_ms,
first_token_latency_ms: value.first_token_ms,
latency_details: OutputTimingView {
first_event_ms: value.first_event_ms,
first_reasoning_ms: value.first_reasoning_ms,
first_text_ms: value.first_text_ms,
},
client_ip: value.client_ip,
user_agent: value.user_agent,
status: "success",
// 成功记录不保存 HTTP 状态,不能用 200 伪造缺失的原始事实。
status_code: None,
}
}
fn error_record(value: OpsError) -> RecordView {
RecordView {
id: value.event_id,
created_at: value.occurred_at,
model: value.requested_model_id,
route: value.endpoint,
reasoning_effort: value.reasoning_effort,
client_transport: value.client_transport,
upstream_transport: value.upstream_transport,
token_details: None,
billing: None,
latency_ms: value.latency_ms,
first_token_latency_ms: None,
latency_details: OutputTimingView::default(),
client_ip: value.client_ip,
user_agent: value.user_agent,
status: "error",
status_code: value.client_status_code,
}
}
pub(super) fn records(value: KeyUsageRecords) -> PageData<RecordView> {
match value {
KeyUsageRecords::Success(page) => PageData {
items: page.items.into_iter().map(success_record).collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
},
KeyUsageRecords::Error(page) => PageData {
items: page.items.into_iter().map(error_record).collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
},
}
}
@@ -0,0 +1,106 @@
//! 查询不接受 Key、账号或 Provider 范围;禁止未知字段穿透权限边界。
use axum::http::StatusCode;
use chrono::{DateTime, Duration};
use gateway_admin::model::{
PageSize,
key_usage::{KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecordsQuery},
observability::TimeRange,
};
use serde::Deserialize;
use crate::admin::AdminError;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct OverviewQuery {
start_time: String,
end_time: String,
model: Option<String>,
}
impl OverviewQuery {
pub(super) fn into_domain(self) -> Result<KeyUsageQuery, AdminError> {
let parse = |value: &str| {
DateTime::parse_from_rfc3339(value)
.map(|value| value.to_utc())
.map_err(|_| {
AdminError::invalid_request(
StatusCode::BAD_REQUEST,
"时间必须使用 RFC3339 格式",
)
})
};
let range = TimeRange::new(parse(&self.start_time)?, parse(&self.end_time)?)
.map_err(|_| AdminError::invalid_request(StatusCode::BAD_REQUEST, "时间范围不合法"))?;
if range.end - range.start > Duration::days(31) {
return Err(AdminError::invalid_request(
StatusCode::BAD_REQUEST,
"一次最多查询 31 天的用量",
));
}
let model = self
.model
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty());
if model
.as_ref()
.is_some_and(|value| value.len() > 256 || value.chars().any(char::is_control))
{
return Err(AdminError::invalid_request(
StatusCode::BAD_REQUEST,
"模型筛选内容不合法",
));
}
Ok(KeyUsageQuery { range, model })
}
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase")]
enum RecordKind {
#[default]
Success,
Error,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct RecordsQuery {
start_time: String,
end_time: String,
model: Option<String>,
current_page: Option<u32>,
page_size: Option<u16>,
#[serde(default)]
kind: RecordKind,
}
impl RecordsQuery {
pub(super) fn into_domain(self) -> Result<KeyUsageRecordsQuery, AdminError> {
let current_page = self.current_page.unwrap_or(1);
let page_size = self.page_size.unwrap_or(20);
if current_page == 0 || !(1..=100).contains(&page_size) {
return Err(AdminError::invalid_request(
StatusCode::BAD_REQUEST,
"页码必须大于 0,每页数量为 1–100",
));
}
Ok(KeyUsageRecordsQuery {
usage: OverviewQuery {
start_time: self.start_time,
end_time: self.end_time,
model: self.model,
}
.into_domain()?,
kind: match self.kind {
RecordKind::Success => KeyUsageRecordKind::Success,
RecordKind::Error => KeyUsageRecordKind::Error,
},
current_page,
page_size: PageSize::new(page_size).map_err(|_| {
AdminError::invalid_request(StatusCode::BAD_REQUEST, "每页数量不合法")
})?,
})
}
}
+16 -8
View File
@@ -29,12 +29,14 @@ use crate::health::HealthStatus;
use crate::openai::service::OpenAiService;
pub mod admin;
pub mod auth;
mod health;
mod key_usage;
pub mod openai;
mod session_cookie;
/// API-owned HTTP 与静态资源配置。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ApiConfig {
pub asset_directory: PathBuf,
pub cors_allowed_origins: Vec<String>,
@@ -59,12 +61,17 @@ impl ApiConfig {
return Err(ApiConfigError::InvalidAssetDirectory);
}
}
if self.asset_directory.as_os_str().is_empty() {
return Err(ApiConfigError::InvalidAssetDirectory);
}
self.validate()?;
if self.asset_directory.is_relative() {
self.asset_directory = source_dir.join(&self.asset_directory);
}
Ok(())
}
fn validate(&mut self) -> Result<(), ApiConfigError> {
if self.asset_directory.as_os_str().is_empty() {
return Err(ApiConfigError::InvalidAssetDirectory);
}
if self.request_timeout_seconds == Some(0) {
return Err(ApiConfigError::InvalidRequestTimeout);
}
@@ -121,9 +128,8 @@ pub fn initialize(
worker_health: Arc<dyn WorkerHealthSource>,
lifecycle: Arc<dyn ConnectionLifecycle>,
) -> Result<ApiBundle, ApiError> {
config
.resolve_and_validate(Path::new("."))
.map_err(ApiError::Config)?;
// 配置加载已解析环境变量与相对路径,初始化只校验,避免覆盖最终目录。
config.validate().map_err(ApiError::Config)?;
let request_id_header = HeaderName::from_str(&config.request_id_header)
.map_err(|_| ApiError::Config(ApiConfigError::InvalidRequestIdHeader))?;
let state = ApiState {
@@ -136,6 +142,8 @@ pub fn initialize(
.route("/healthz", get(health::healthz))
.merge(openai::router::router())
.merge(admin::router::<ApiState>())
.merge(auth::router::<ApiState>())
.merge(key_usage::router::<ApiState>())
.fallback_service(ServeDir::new(config.asset_directory).fallback(ServeFile::new(index)));
if !config.cors_allowed_origins.is_empty() {
let origins = config
@@ -225,7 +233,7 @@ impl ApiState {
}
}
impl admin::AdminSessionState for ApiState {
impl auth::SessionState for ApiState {
fn admin_services(&self) -> &AdminServices {
&self.admin
}
@@ -247,6 +247,16 @@ pub const fn gateway_error_contract(
"server_error",
"provider_infrastructure_unavailable",
),
GatewayErrorKind::ConcurrencyQueueFull => (
StatusCode::TOO_MANY_REQUESTS,
"rate_limit_error",
"concurrency_queue_full",
),
GatewayErrorKind::ConcurrencyQueueTimeout => (
StatusCode::TOO_MANY_REQUESTS,
"rate_limit_error",
"concurrency_queue_timeout",
),
GatewayErrorKind::RateLimited => (
StatusCode::TOO_MANY_REQUESTS,
"rate_limit_error",
@@ -535,8 +535,6 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
.iter()
.any(|connection_header| connection_header.eq_ignore_ascii_case(name))
|| is_transport_managed_request_header(name)
|| name.starts_with("x-grok-")
|| name.starts_with("x-xai-")
{
return false;
}
@@ -551,15 +549,9 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
| "cookie"
| "cookie2"
| "chatgpt-account-id"
| "chatgpt-organization-id"
| "chatgpt-org-id"
| "chatgpt-project-id"
| "openai-organization"
| "openai-project"
| "x-openai-organization"
| "x-openai-project"
// installation ID 始终由当前 lease 重建。
| "x-codex-installation-id"
// 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent/version
// 不能作为不透明头透传覆盖,避免不同下游客户端暴露不一致的设备指纹。
| "originator"
@@ -77,6 +77,21 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
// 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())
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
)
{
return vec![data.to_string()];
}
vec![wire.data().to_string()]
}
@@ -156,3 +171,29 @@ fn openai_wire(event: &ProviderEvent) -> Option<&ProtocolWireEvent> {
.wire_event()
.filter(|wire| wire.protocol() == OPENAI_PROTOCOL)
}
/// 判断 WS 上游的 `error` 帧是否已经是客户端可直接消费的形状。
///
/// codex 的 WS 端点只消费两类 `error` 帧:带非 2xx HTTP status 的包装错误,
/// 以及连接数上限 / previous_response_not_found 这类内置可重试错误码;其余
/// 帧(包括 status 为 2xx 的矛盾形状)会被静默忽略。可消费的帧原样透传,
/// 不可消费的才在 WS 边界投影成 `response.failed`。
fn ws_client_consumable_error(data: &Value) -> bool {
if data
.get("error")
.and_then(|error| error.get("code"))
.and_then(Value::as_str)
.is_some_and(|code| {
matches!(
code,
"websocket_connection_limit_reached" | "previous_response_not_found"
)
})
{
return true;
}
data.get("status")
.or_else(|| data.get("status_code"))
.and_then(Value::as_u64)
.is_some_and(|status| !(200..300).contains(&status))
}
@@ -0,0 +1,33 @@
//! 控制面会话 Cookie 的同源 HTTP / HTTPS 属性与解析。
use axum::http::{HeaderMap, header::ORIGIN};
use url::Url;
pub(crate) const NAME: &str = "cpr_session";
pub(crate) fn attributes(headers: &HeaderMap) -> &'static str {
// HTTPS 反代回源 HTTP 不改变浏览器 Origin。只有单个、严格合法的 HTTP Origin
// 才允许省略 Secure;缺失、opaque 或非法来源继续 fail closed。
let http_origin = headers.get_all(ORIGIN).iter().count() == 1
&& headers
.get(ORIGIN)
.and_then(|value| value.to_str().ok())
.is_some_and(|origin| {
Url::parse(origin).is_ok_and(|url| {
url.scheme() == "http" && url.origin().ascii_serialization() == origin
})
});
if http_origin {
"Path=/; HttpOnly; SameSite=Lax"
} else {
"Path=/; Secure; HttpOnly; SameSite=Lax"
}
}
pub(crate) fn value(headers: &HeaderMap) -> Option<String> {
let cookie = headers.get("cookie")?.to_str().ok()?;
cookie.split(';').find_map(|part| {
let (candidate, value) = part.trim().split_once('=')?;
(candidate == NAME).then(|| value.to_owned())
})
}
@@ -9,7 +9,7 @@ use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState, PRIMARY_GROUP_ID};
const SESSION_COOKIE: &str = "cpr_admin_session=valid-session";
const SESSION_COOKIE: &str = "cpr_session=valid-session";
#[tokio::test]
async fn list_route_should_keep_camel_case_group_and_page_wire() {
@@ -31,7 +31,7 @@ async fn personal_info_requires_admin_and_a_valid_account_query() {
.uri(format!("/api/admin/accounts/personal-info{query}"))
.header("x-request-id", "req_personal_info");
if authenticated {
request = request.header(header::COOKIE, "cpr_admin_session=valid-session");
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = admin::router::<AdminTestState>()
.with_state(fixture.state())
@@ -73,7 +73,7 @@ async fn quota_forecast_requires_admin_and_valid_account_query() {
.uri(uri)
.header("x-request-id", "req_forecast");
if authenticated {
request = request.header(header::COOKIE, "cpr_admin_session=valid-session");
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = admin::router::<AdminTestState>()
.with_state(fixture.state())
@@ -0,0 +1,166 @@
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use gateway_api::admin;
use serde_json::{Value, json};
use tower::ServiceExt as _;
use super::super::{AdminTestFixture, AdminTestState};
async fn send(
fixture: &AdminTestFixture,
method: &str,
uri: &str,
body: Value,
authenticated: bool,
) -> (StatusCode, Value) {
let mut request = Request::builder()
.method(method)
.uri(uri)
.header("x-request-id", "import-task-contract")
.header(header::CONTENT_TYPE, "application/json");
if authenticated {
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = admin::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(request.body(Body::from(body.to_string())).unwrap())
.await
.unwrap();
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
let status = response.status();
let bytes = to_bytes(response.into_body(), 8 * 1024 * 1024)
.await
.unwrap();
let value: Value = serde_json::from_slice(&bytes).unwrap();
assert!(!value.to_string().contains("synthetic-sensitive-token"));
(status, value)
}
fn input() -> Value {
json!({"submissionId": "01994fcb-a333-7333-8000-000000000001", "items": [{"provider": "openai", "data": {"refreshToken": "synthetic-sensitive-token"}}]})
}
#[tokio::test]
async fn import_tasks_should_accept_list_restore_stop_and_deduplicate_without_echoing_credentials()
{
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let (status, accepted) = send(
&fixture,
"POST",
"/api/admin/accounts/import-tasks",
input(),
true,
)
.await;
assert_eq!(status, StatusCode::ACCEPTED);
let id = accepted["data"]["taskId"].as_str().unwrap();
let (_, retried) = send(
&fixture,
"POST",
"/api/admin/accounts/import-tasks",
input(),
true,
)
.await;
assert_eq!(retried["data"]["taskId"], id);
let (_, list) = send(
&fixture,
"GET",
"/api/admin/accounts/import-tasks",
Value::Null,
true,
)
.await;
assert_eq!(list["data"]["items"].as_array().unwrap().len(), 1);
let (_, detail) = send(
&fixture,
"GET",
&format!("/api/admin/accounts/import-tasks/detail?taskId={id}"),
Value::Null,
true,
)
.await;
assert_eq!(detail["data"]["items"][0]["status"], "pending");
let (_, stopped) = send(
&fixture,
"POST",
"/api/admin/accounts/import-tasks/stop",
json!({"taskId": id}),
true,
)
.await;
assert_eq!(stopped["data"]["counts"]["skipped"], 1);
assert!(stopped["data"]["finishedAt"].is_string());
let mut changed = input();
changed["items"][0]["data"]["refreshToken"] = json!("changed-synthetic-input");
assert_eq!(
send(
&fixture,
"POST",
"/api/admin/accounts/import-tasks",
changed,
true
)
.await
.0,
StatusCode::CONFLICT
);
}
#[tokio::test]
async fn import_tasks_should_require_admin_and_enforce_input_bounds() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (method, uri, body) in [
("POST", "/api/admin/accounts/import-tasks", input()),
("GET", "/api/admin/accounts/import-tasks", Value::Null),
(
"GET",
"/api/admin/accounts/import-tasks/detail?taskId=01994fcb-a333-7333-8000-000000000001",
Value::Null,
),
(
"POST",
"/api/admin/accounts/import-tasks/stop",
json!({"taskId": "01994fcb-a333-7333-8000-000000000001"}),
),
] {
assert_eq!(
send(&fixture, method, uri, body, false).await.0,
StatusCode::UNAUTHORIZED
);
}
for count in [0, 201] {
let mut body = input();
body["items"] = json!(vec![body["items"][0].clone(); count]);
assert_eq!(
send(
&fixture,
"POST",
"/api/admin/accounts/import-tasks",
body,
true
)
.await
.0,
StatusCode::BAD_REQUEST
);
}
let mut body = input();
body["items"][0]["data"]["refreshToken"] = json!("x".repeat(4 * 1024 * 1024));
assert_eq!(
send(
&fixture,
"POST",
"/api/admin/accounts/import-tasks",
body,
true
)
.await
.0,
StatusCode::PAYLOAD_TOO_LARGE
);
}
@@ -1,4 +1,5 @@
mod handlers;
mod import_tasks;
mod presenter;
mod personal_info {
@@ -319,31 +320,29 @@ mod batch_update {
}
#[test]
fn batch_update_should_require_enabled_and_reject_unknown_fields() {
fn batch_update_model_access_preserves_omitted_settings_and_rejects_unknown_fields() {
let request: BatchUpdateAccountsRequest = serde_json::from_value(json!({
"accountIds": ["acct_test"], "modelAccess": {"mode":"allowlist","models":["test-luna"]}
}))
.expect("policy-only update");
request.validate().expect("valid update");
assert!(request.enabled.is_none());
assert!(request.weight.is_none());
assert!(request.group_ids.is_none());
assert!(request.concurrency_limit.is_none());
assert!(
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
"accountIds": ["acct_test"],
"concurrencyLimit": null,
"weight": 1,
"groupIds": []
}))
.is_err()
);
assert!(
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
"accountIds": ["acct_test"],
"enabled": true,
"concurrencyLimit": null,
"weight": 1,
"groupIds": [],
"legacy": true
"accountIds": ["acct_test"], "enabled": true, "legacy": true
}))
.is_err()
);
let empty: BatchUpdateAccountsRequest =
serde_json::from_value(json!({"accountIds":["acct_test"]})).expect("empty mutation");
assert!(empty.validate().is_err());
}
#[test]
fn batch_update_should_reject_invalid_scheduling_bounds_and_missing_fields() {
fn batch_update_should_reject_invalid_scheduling_bounds_and_distinguish_clear_from_preserve() {
for (concurrency_limit, weight, field) in [
(json!(0), json!(1), "concurrencyLimit"),
(json!(4294967296_u64), json!(1), "concurrencyLimit"),
@@ -351,36 +350,20 @@ mod batch_update {
(json!(null), json!(101), "weight"),
] {
let request: BatchUpdateAccountsRequest = serde_json::from_value(json!({
"accountIds": ["acct_test"],
"enabled": true,
"concurrencyLimit": concurrency_limit,
"weight": weight,
"groupIds": []
}))
.expect("deserialize invalid scheduling");
"accountIds": ["acct_test"], "concurrencyLimit": concurrency_limit, "weight": weight,
})).expect("deserialize bounds");
assert_eq!(
request.validate().expect_err("reject scheduling").field(),
request.validate().expect_err("reject bounds").field(),
field
);
}
assert!(
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
"accountIds": ["acct_test"],
"enabled": true,
"weight": 1,
"groupIds": []
}))
.is_err()
);
assert!(
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
"accountIds": ["acct_test"],
"enabled": true,
"concurrencyLimit": null,
"groupIds": []
}))
.is_err()
);
let cleared: BatchUpdateAccountsRequest = serde_json::from_value(json!({
"accountIds": ["acct_test"], "concurrencyLimit": null
}))
.expect("clear concurrency override");
cleared.validate().expect("valid clear");
assert_eq!(cleared.concurrency_limit, Some(None));
assert!(cleared.weight.is_none());
}
#[test]
@@ -407,6 +390,43 @@ mod batch_update {
}
}
#[test]
fn single_update_should_accept_optional_unicode_and_multiline_notes() {
use gateway_api::admin::accounts::UpdateAccountRequest;
use serde_json::json;
for notes in [
json!(null),
json!(""),
json!("团队备用\n第二行\t说明"),
json!("备".repeat(500)),
] {
let request: UpdateAccountRequest = serde_json::from_value(json!({
"accountId": "acct_notes", "enabled": true, "concurrencyLimit": null,
"weight": 1, "groupIds": [], "notes": notes
}))
.unwrap();
request.validate().expect("accept bounded notes");
}
}
#[test]
fn single_update_should_reject_oversized_notes_and_control_characters() {
use gateway_api::admin::accounts::UpdateAccountRequest;
use serde_json::json;
for notes in [
"备".repeat(501),
"备注\0".to_owned(),
"备注\u{001b}".to_owned(),
] {
let request: UpdateAccountRequest = serde_json::from_value(json!({
"accountId": "acct_notes", "enabled": true, "concurrencyLimit": null,
"weight": 1, "groupIds": [], "notes": notes
}))
.unwrap();
assert_eq!(request.validate().unwrap_err().field(), "notes");
}
}
mod response {
use gateway_api::admin::accounts::AccountUsageView;
use gateway_api::admin::presenter::format_decimal_currency;
@@ -580,6 +600,44 @@ mod actions {
);
}
#[test]
fn api_key_rotation_settings_must_target_the_same_account_and_remain_valid() {
let mut request = json!({
"provider": "openai",
"accountId": "acct_api",
"baseUrl": "https://api.example.invalid/v1",
"transport": "http",
"settings": {
"accountId": "acct_api",
"enabled": true,
"concurrencyLimit": null,
"weight": 1,
"groupIds": []
}
});
serde_json::from_value::<RotateAccountRequest>(request.clone())
.expect("decode combined save")
.validate()
.expect("blank replacement key preserves the existing key");
request["settings"]["accountId"] = json!("acct_other");
assert_eq!(
serde_json::from_value::<RotateAccountRequest>(request.clone())
.unwrap()
.validate()
.unwrap_err()
.field(),
"settings.accountId"
);
request["settings"]["accountId"] = json!("acct_api");
request["settings"]["concurrencyLimit"] = json!(0);
assert!(
serde_json::from_value::<RotateAccountRequest>(request)
.unwrap()
.validate()
.is_err()
);
}
#[test]
fn credential_recovery_requests_should_not_accept_client_revision_fences() {
let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({
@@ -845,6 +903,9 @@ mod import_settings {
("weight", json!(0)),
("weight", json!(101)),
("groupIds", json!(["invalid-group"])),
("notes", json!("备".repeat(501))),
("notes", json!("备注\u{0000}")),
("notes", json!("备注\u{001b}")),
] {
let mut settings =
json!({"enabled": false, "concurrencyLimit": null, "weight": 1, "groupIds": []});
@@ -865,6 +926,25 @@ mod import_settings {
}
}
#[test]
fn import_and_oauth_accept_optional_unicode_and_multiline_notes() {
for notes in [
json!(null),
json!(""),
json!("团队备用\n下月续费\t"),
json!("备".repeat(500)),
] {
let settings = json!({"enabled": true, "concurrencyLimit": null, "weight": 1, "groupIds": [], "notes": notes});
let import: AccountImportRequest = serde_json::from_value(
json!({"provider": "openai", "data": {}, "settings": settings}),
)
.unwrap();
let oauth: CompleteAccountAuthorizationRequest = serde_json::from_value(json!({"provider": "xai", "flowId": "flow-test", "callbackUrl": "code", "settings": settings})).unwrap();
import.validate().expect("import notes");
oauth.validate().expect("OAuth notes");
}
}
#[test]
fn import_settings_require_a_complete_explicit_configuration() {
let settings =
+32 -33
View File
@@ -1,7 +1,5 @@
use gateway_admin::model::auth::{LoginCommand, LoginError};
use gateway_api::admin::auth::{
AdminLoginData, AdminLoginRequest, AdminLogoutData, AdminSessionStatusData,
};
use gateway_api::auth::LoginRequest;
use serde_json::json;
use super::AdminTestFixture;
@@ -9,18 +7,26 @@ use super::AdminTestFixture;
#[test]
fn login_request_should_deny_unknown_fields_and_redact_password_debug() {
let password = "admin-password-must-not-leak";
let request = serde_json::from_value::<AdminLoginRequest>(json!({
let request = serde_json::from_value::<LoginRequest>(json!({
"mode": "admin",
"username": "admin@example.invalid",
"password": password
}))
.expect("deserialize login request");
assert!(!format!("{request:?}").contains(password));
let (username, parsed_password) = request.into_parts();
let LoginCommand::Admin {
username,
password: parsed_password,
} = request.into()
else {
panic!("admin login expected")
};
assert_eq!(username.as_deref(), Some("admin@example.invalid"));
assert_eq!(parsed_password, password);
assert!(
serde_json::from_value::<AdminLoginRequest>(json!({
serde_json::from_value::<LoginRequest>(json!({
"mode": "admin",
"password": password,
"rememberMe": true
}))
@@ -28,39 +34,27 @@ fn login_request_should_deny_unknown_fields_and_redact_password_debug() {
);
}
#[test]
fn auth_responses_should_keep_stable_wire_shapes() {
assert_eq!(
serde_json::to_value(AdminLoginData::new("2026-07-18T08:00:00+08:00".to_owned()))
.expect("serialize login"),
json!({ "expiresAt": "2026-07-18T08:00:00+08:00" })
);
assert_eq!(
serde_json::to_value(AdminSessionStatusData::new(true)).expect("serialize status"),
json!({ "authenticated": true })
);
assert_eq!(
serde_json::to_value(AdminLogoutData::new()).expect("serialize logout"),
json!({ "message": "Logged out successfully" })
);
}
#[tokio::test]
async fn default_auth_service_should_initialize_login_validate_and_logout() {
let fixture = AdminTestFixture::new().await;
let service = fixture.services.auth();
let session = service
.login(LoginCommand {
username: Some("admin_1".to_owned()),
password: "strong-admin-password".to_owned(),
})
.login(
LoginCommand::Admin {
username: Some("admin_1".to_owned()),
password: "strong-admin-password".to_owned(),
},
std::net::Ipv4Addr::LOCALHOST.into(),
None,
)
.await
.expect("login succeeds");
assert!(
service
.validate_session(Some(&session.session_id))
.session(Some(&session.session_id))
.await
.expect("validate session")
.is_some()
);
assert_eq!(
service
@@ -76,9 +70,10 @@ async fn default_auth_service_should_initialize_login_validate_and_logout() {
.expect("logout session");
assert!(
!service
.validate_session(Some(&session.session_id))
.session(Some(&session.session_id))
.await
.expect("validate logged-out session")
.is_some()
);
assert_eq!(fixture.auth.audit_count(), 2);
}
@@ -124,10 +119,14 @@ async fn audit_failure_should_revoke_new_session_before_returning_it() {
fixture
.services
.auth()
.login(LoginCommand {
username: None,
password: "strong-admin-password".to_owned(),
})
.login(
LoginCommand::Admin {
username: None,
password: "strong-admin-password".to_owned(),
},
std::net::Ipv4Addr::LOCALHOST.into(),
None
)
.await
.expect_err("audit failure rejects login"),
LoginError::Unavailable
@@ -395,7 +395,7 @@ async fn reveal_route_should_use_query_id_and_no_store_response() {
.oneshot(
Request::builder()
.uri("/api/admin/client-keys/reveal?id=key_1")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_reveal")
.body(Body::empty())
.expect("reveal request"),
@@ -436,7 +436,7 @@ async fn list_route_should_accept_the_full_nonzero_u16_page_size() {
.oneshot(
Request::builder()
.uri("/api/admin/client-keys?limit=65535")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_client_keys_max_page")
.body(Body::empty())
.expect("client key list request"),
@@ -3,16 +3,18 @@ use axum::{
body::{Body, to_bytes},
http::{Method, Request, StatusCode, header},
};
use gateway_api::admin;
use gateway_api::auth::SessionState;
use serde_json::{Value, json};
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
const SESSION_COOKIE: &str = "cpr_admin_session=valid-session";
const SESSION_COOKIE: &str = "cpr_session=valid-session";
fn app(state: AdminTestState) -> Router {
admin::router::<AdminTestState>().with_state(state)
crate::openai::api_router_with_admin(state.admin_services().clone()).layer(axum::Extension(
axum::extract::ConnectInfo(std::net::SocketAddr::from(([127, 0, 0, 1], 41000))),
))
}
fn request(method: Method, uri: &str, body: Body) -> Request<Body> {
@@ -44,7 +46,7 @@ async fn malformed_login_json_should_use_the_admin_error_envelope() {
let fixture = AdminTestFixture::new().await;
let mut request = request(
Method::POST,
"/api/admin/auth/login",
"/api/auth/login",
Body::from(r#"{"password":"secret""#),
);
request
@@ -77,8 +79,10 @@ async fn invalid_login_json_data_should_not_echo_the_submitted_value() {
let submitted = "password-must-not-leak";
let mut request = request(
Method::POST,
"/api/admin/auth/login",
Body::from(json!({ "password": submitted, "rememberMe": true }).to_string()),
"/api/auth/login",
Body::from(
json!({ "mode": "admin", "password": submitted, "rememberMe": true }).to_string(),
),
);
request
.headers_mut()
@@ -111,8 +115,8 @@ async fn login_without_json_content_type_should_keep_415_with_an_admin_envelope(
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/auth/login",
Body::from(json!({ "password": "secret" }).to_string()),
"/api/auth/login",
Body::from(json!({ "mode": "admin", "password": "secret" }).to_string()),
))
.await
.expect("missing JSON content type response");
@@ -229,11 +233,7 @@ async fn unknown_admin_path_should_not_fall_through_to_the_spa() {
async fn unsupported_admin_method_should_keep_allow_and_return_an_envelope() {
let fixture = AdminTestFixture::new().await;
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/auth/status",
Body::empty(),
))
.oneshot(request(Method::POST, "/api/auth/status", Body::empty()))
.await
.expect("unsupported admin method response");
let allow = response
@@ -280,8 +280,8 @@ async fn admin_auth_failures_should_use_stable_chinese_contracts() {
.expect("invalid API key response");
let mut invalid_login = request(
Method::POST,
"/api/admin/auth/login",
Body::from(json!({ "password": "wrong-password" }).to_string()),
"/api/auth/login",
Body::from(json!({ "mode": "admin", "password": "wrong-password" }).to_string()),
);
invalid_login
.headers_mut()
@@ -300,15 +300,11 @@ async fn admin_auth_failures_should_use_stable_chinese_contracts() {
assert_eq!(
actual.map(|(status, _, body)| (status, body["code"].clone(), body["message"].clone())),
[
(
StatusCode::UNAUTHORIZED,
json!(40101),
json!("需要管理员登录")
),
(StatusCode::UNAUTHORIZED, json!(40101), json!("需要登录")),
(
StatusCode::UNAUTHORIZED,
json!(40102),
json!("管理员用户名或密码错误")
json!("登录凭据错误")
),
(
StatusCode::UNAUTHORIZED,
@@ -467,12 +463,14 @@ mod provider {
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::unknown(),
rate_limited_until: None,
cooldown: None,
last_error_reason: None,
last_error_message: None,
};
AccountPageItem {
account: AccountRecord {
notes: None,
model_access: Default::default(),
id: "acct_error_test".to_owned(),
provider_kind: ProviderKind::new(provider).unwrap(),
groups: Vec::new(),
+189 -31
View File
@@ -10,7 +10,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use futures::future::BoxFuture;
use gateway_admin::{
AdminConfig, AdminServices, InitialAdminPassword,
AdminConfig, AdminServices, ClientConfig, InitialAdminPassword,
model::{
MutationContext, Revision,
account_groups::{
@@ -24,7 +24,7 @@ use gateway_admin::{
AccountUpdateResult, AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult,
AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
},
auth::{AdminAuditEvent, AdminSession},
auth::{AdminAuditEvent, AuthSession},
client_distribution::{
ClientArchitecture, ClientDownloadPackage, ClientDownloadSource,
CodexDesktopWindowsDownloads,
@@ -66,10 +66,13 @@ use gateway_admin::{
},
},
};
use gateway_api::admin::AdminSessionState;
use gateway_api::auth::SessionState;
use gateway_core::{
account::{AccountStatusFacts, CredentialState, ProviderAccountId, QuotaState},
engine::probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
engine::{
execution::{ClientAuthenticationError, ClientKeyVerifier},
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
},
policy::{ClientApiKeyId, RateLimits},
routing::{ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId},
runtime::SnapshotControl,
@@ -88,6 +91,8 @@ mod wire;
pub(super) struct AdminTestFixture {
pub services: AdminServices,
pub client_key: Arc<Mutex<Option<ClientKeyRecord>>>,
pub observations: Arc<Mutex<MemoryObservations>>,
pub auth: Arc<MemoryAuthStore>,
pub settings: Arc<MemorySettingsStore>,
pub usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
@@ -106,10 +111,26 @@ impl AdminTestFixture {
}
pub async fn with_system(system: Arc<dyn SystemOperations>) -> Self {
Self::with_dependencies(system, None).await
}
pub async fn with_key_verifier(
verifier: Arc<dyn ClientKeyVerifier>,
system: Arc<dyn SystemOperations>,
) -> Self {
Self::with_dependencies(system, Some(verifier)).await
}
async fn with_dependencies(
system: Arc<dyn SystemOperations>,
verifier: Option<Arc<dyn ClientKeyVerifier>>,
) -> Self {
let api_key = Arc::new(Mutex::new(None));
let auth = Arc::new(MemoryAuthStore::new(api_key.clone()));
let settings = Arc::new(MemorySettingsStore::new(api_key));
let client_keys = Arc::new(MemoryClientKeyStore);
let client_key = Arc::new(Mutex::new(None));
let client_keys = Arc::new(MemoryClientKeyStore(client_key.clone()));
let observations = Arc::new(Mutex::new(MemoryObservations::default()));
let account_groups = Arc::new(MemoryAccountGroupStore::new());
let usage_records = Arc::new(Mutex::new(Vec::new()));
let usage_detail = Arc::new(Mutex::new(None));
@@ -120,6 +141,7 @@ impl AdminTestFixture {
let provider_error = Arc::new(Mutex::new(None));
let account = Arc::new(Mutex::new(None));
let unused = Arc::new(UnusedStore {
observations: observations.clone(),
usage_records: Arc::clone(&usage_records),
usage_detail: Arc::clone(&usage_detail),
diagnostics: Arc::clone(&diagnostics),
@@ -151,17 +173,24 @@ impl AdminTestFixture {
default_username: "admin_1".to_owned(),
default_password: InitialAdminPassword::new("strong-admin-password"),
},
ClientConfig::default(),
stores,
providers,
Arc::new(NoopSnapshot),
(Arc::new(NoopProbe), Arc::new(proxies::SuccessfulProbe)),
Arc::new(StaticClientDistribution),
system,
gateway_admin::AdminRuntimePorts {
providers,
snapshot: Arc::new(NoopSnapshot),
account_probe: Arc::new(NoopProbe),
proxy_probe: Arc::new(proxies::SuccessfulProbe),
client_distribution: Arc::new(StaticClientDistribution),
system,
client_key_verifier: verifier.unwrap_or_else(|| Arc::new(UnusedClientKeyVerifier)),
},
)
.await
.expect("initialize test admin services");
Self {
services: bundle.services(),
client_key,
observations,
auth,
settings,
usage_records,
@@ -207,15 +236,17 @@ impl ClientDistributionResolver for StaticClientDistribution {
#[derive(Clone)]
pub(super) struct AdminTestState(AdminServices);
impl AdminSessionState for AdminTestState {
impl SessionState for AdminTestState {
fn admin_services(&self) -> &AdminServices {
&self.0
}
}
pub(super) struct MemoryAuthStore {
pub(super) enabled: AtomicBool,
pub(super) unavailable: AtomicBool,
password_hash: Mutex<Option<String>>,
sessions: Mutex<BTreeMap<String, AdminSession>>,
sessions: Mutex<BTreeMap<String, AuthSession>>,
audits: Mutex<Vec<AdminAuditEvent>>,
api_key: Arc<Mutex<Option<AdminApiKey>>>,
fail_audit: AtomicBool,
@@ -224,6 +255,8 @@ pub(super) struct MemoryAuthStore {
impl MemoryAuthStore {
fn new(api_key: Arc<Mutex<Option<AdminApiKey>>>) -> Self {
Self {
enabled: AtomicBool::new(false),
unavailable: AtomicBool::new(false),
password_hash: Mutex::new(None),
sessions: Mutex::new(BTreeMap::new()),
audits: Mutex::new(Vec::new()),
@@ -235,8 +268,10 @@ impl MemoryAuthStore {
pub fn insert_session(&self, session_id: &str) {
self.sessions.lock().expect("sessions").insert(
session_id.to_owned(),
AdminSession {
admin_user_id: "admin_1".to_owned(),
AuthSession {
subject: gateway_admin::model::auth::SessionSubject::Admin {
admin_user_id: "admin_1".to_owned(),
},
expires_at: Utc::now() + Duration::hours(1),
},
);
@@ -282,7 +317,7 @@ impl AuthStore for MemoryAuthStore {
Ok(self.api_key.lock().expect("API key").clone())
}
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
Ok(self
.sessions
.lock()
@@ -291,11 +326,7 @@ impl AuthStore for MemoryAuthStore {
.cloned())
}
async fn store_session(
&self,
session_id: &str,
session: &AdminSession,
) -> AdminStoreResult<()> {
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()> {
self.sessions
.lock()
.expect("sessions")
@@ -303,10 +334,30 @@ impl AuthStore for MemoryAuthStore {
Ok(())
}
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
Ok(self.sessions.lock().expect("sessions").remove(session_id))
}
async fn client_key_enabled(
&self,
id: &gateway_core::policy::ClientApiKeyId,
) -> AdminStoreResult<bool> {
if self.unavailable.load(Ordering::SeqCst) {
return Err(unavailable("key status"));
}
Ok(id.as_str() == "key-42" && self.enabled.load(Ordering::SeqCst))
}
async fn consume_login_attempt(
&self,
_: std::net::IpAddr,
_: u32,
_: u32,
_: std::time::Duration,
) -> AdminStoreResult<Option<std::time::Duration>> {
Ok(None)
}
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
if self.fail_audit.load(Ordering::SeqCst) {
return Err(unavailable("auth audit"));
@@ -351,18 +402,30 @@ impl SettingsStore for MemorySettingsStore {
) -> AdminStoreResult<RuntimeSettings> {
let mut settings = self.settings.lock().expect("settings");
let updated = RuntimeSettings {
request_location_enabled: command.request_location_enabled,
request_location: command.request_location,
config_revision: next_revision(settings.config_revision),
model_mappings: command.model_mappings,
refresh_margin_seconds: command.refresh_margin_seconds,
refresh_concurrency: command.refresh_concurrency,
max_concurrent_per_account: command.max_concurrent_per_account,
request_interval_ms: command.request_interval_ms,
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,
rotation_strategy: command.rotation_strategy,
min_codex_desktop_version: command.min_codex_desktop_version,
min_codex_cli_version: command.min_codex_cli_version,
usage_retention_days: command.usage_retention_days,
ops_event_retention_days: command.ops_event_retention_days,
audit_retention_days: command.audit_retention_days,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
updated_at: Utc::now(),
};
*settings = updated.clone();
@@ -395,7 +458,17 @@ impl SettingsStore for MemorySettingsStore {
}
}
pub(super) struct MemoryClientKeyStore;
pub(super) struct MemoryClientKeyStore(Arc<Mutex<Option<ClientKeyRecord>>>);
#[derive(Default)]
pub(super) struct MemoryObservations {
pub summary: Option<UsageOverview>,
pub trend: Option<Vec<RequestMetricPoint>>,
pub summaries: Vec<(TimeRange, UsageFilter)>,
pub trends: Vec<(TimeRange, UsageFilter)>,
pub records: Vec<UsageQuery>,
pub errors: Vec<OpsErrorQuery>,
}
pub(super) const PRIMARY_GROUP_ID: &str = "grp_11111111111111111111111111111111";
pub(super) const SECONDARY_GROUP_ID: &str = "grp_22222222222222222222222222222222";
@@ -523,7 +596,7 @@ impl AccountGroupStore for MemoryAccountGroupStore {
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::default(),
rate_limited_until: None,
cooldown: None,
last_error_reason: None,
last_error_message: None,
},
@@ -537,7 +610,7 @@ impl AccountGroupStore for MemoryAccountGroupStore {
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::default(),
rate_limited_until: None,
cooldown: None,
last_error_reason: None,
last_error_message: None,
},
@@ -636,6 +709,18 @@ fn mutation(
#[async_trait]
impl ClientKeyStore for MemoryClientKeyStore {
async fn get_client_key(
&self,
id: &ClientApiKeyId,
) -> AdminStoreResult<Option<ClientKeyRecord>> {
Ok(self
.0
.lock()
.expect("client key")
.clone()
.filter(|key| &key.id == id))
}
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
Ok(ClientKeyPage {
config_revision: Revision::new(1).expect("revision"),
@@ -703,6 +788,7 @@ impl ClientKeyStore for MemoryClientKeyStore {
}
struct UnusedStore {
observations: Arc<Mutex<MemoryObservations>>,
usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
usage_detail: Arc<Mutex<Option<UsageDetail>>>,
diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
@@ -712,6 +798,14 @@ struct UnusedStore {
account: Arc<Mutex<Option<AccountPageItem>>>,
}
struct UnusedClientKeyVerifier;
impl ClientKeyVerifier for UnusedClientKeyVerifier {
fn verify_client_key(&self, _: &str) -> Result<ClientApiKeyId, ClientAuthenticationError> {
Err(ClientAuthenticationError::InvalidKey)
}
}
#[async_trait]
impl AccountStore for UnusedStore {
async fn list_accounts(
@@ -811,6 +905,15 @@ impl AccountStore for UnusedStore {
Err(unavailable("account enabled"))
}
async fn lower_concurrency_limit(
&self,
_: &gateway_core::account::ProviderAccountId,
_: gateway_core::account::AccountConcurrencyLimit,
_: &MutationContext,
) -> AdminStoreResult<Option<gateway_admin::model::accounts::AccountUpdateResult>> {
Ok(None)
}
async fn recover_account(
&self,
_: &ProviderAccountId,
@@ -848,17 +951,39 @@ impl AccountStore for UnusedStore {
impl AccountRuntimeStore for UnusedStore {
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot {
rate_limited_until: BTreeMap::new(),
cooldown: BTreeMap::new(),
in_flight: Some(BTreeMap::new()),
})
}
async fn account_runtime(&self, _: &[String]) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot {
rate_limited_until: BTreeMap::new(),
cooldown: BTreeMap::new(),
in_flight: Some(BTreeMap::new()),
})
}
async fn active_freezes(
&self,
) -> AdminStoreResult<BTreeMap<String, gateway_admin::model::accounts::AccountFreeze>> {
Ok(BTreeMap::new())
}
async fn capacity_peaks(
&self,
_account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>> {
Ok(BTreeMap::new())
}
async fn finish_freeze(
&self,
_account_id: &str,
_expected: &gateway_admin::model::accounts::AccountFreeze,
_postpone_until: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool> {
Ok(false)
}
}
#[async_trait]
@@ -885,10 +1010,12 @@ impl ObservabilityStore for UnusedStore {
async fn usage_trend(
&self,
_: TimeRange,
_: UsageFilter,
range: TimeRange,
filter: UsageFilter,
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
Err(unavailable("usage trend"))
let mut data = self.observations.lock().expect("observations");
data.trends.push((range, filter));
data.trend.clone().ok_or_else(|| unavailable("usage trend"))
}
fn usage_calculated_billing_facts(
@@ -902,6 +1029,11 @@ impl ObservabilityStore for UnusedStore {
}
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
self.observations
.lock()
.expect("observations")
.records
.push(query.clone());
let items = self.usage_records.lock().expect("usage records").clone();
Ok(UsagePage {
current_page: query.current_page,
@@ -919,8 +1051,17 @@ impl ObservabilityStore for UnusedStore {
.ok_or_else(|| unavailable("usage detail"))
}
async fn usage_summary(&self, _: TimeRange, _: UsageFilter) -> AdminStoreResult<UsageOverview> {
Err(unavailable("usage summary"))
async fn usage_summary(
&self,
range: TimeRange,
filter: UsageFilter,
) -> AdminStoreResult<UsageOverview> {
let mut data = self.observations.lock().expect("observations");
data.summaries.push((range, filter));
data.summary
.clone()
.map(|summary| UsageOverview { range, ..summary })
.ok_or_else(|| unavailable("usage summary"))
}
async fn usage_diagnostics(
@@ -933,6 +1074,11 @@ impl ObservabilityStore for UnusedStore {
}
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
self.observations
.lock()
.expect("observations")
.errors
.push(query.clone());
let items = self.ops_errors.lock().expect("ops errors").clone();
Ok(OpsErrorPage {
current_page: query.current_page,
@@ -1124,18 +1270,30 @@ fn test_runtime_settings() -> RuntimeSettings {
),
]);
RuntimeSettings {
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
model_mappings: mappings,
refresh_margin_seconds: 3_600,
refresh_concurrency: 2,
max_concurrent_per_account: 3,
request_interval_ms: 50,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
updated_at: Utc::now(),
}
}
@@ -245,7 +245,7 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
.oneshot(
Request::builder()
.uri("/api/admin/dashboard/summary")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_dashboard_account_summary")
.body(Body::empty())
.expect("dashboard request"),
@@ -326,7 +326,7 @@ async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
.oneshot(
Request::builder()
.uri("/api/admin/dashboard/summary")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_dashboard_lifetime_totals")
.body(Body::empty())
.expect("dashboard request"),
@@ -391,7 +391,7 @@ async fn dashboard_summary_should_default_to_current_china_day() {
.oneshot(
Request::builder()
.uri("/api/admin/dashboard/summary")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_dashboard_today_range")
.body(Body::empty())
.expect("dashboard request"),
@@ -480,7 +480,7 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
.oneshot(
Request::builder()
.uri("/api/admin/usage/records/detail?id=req_detail")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_usage_detail_snapshot")
.body(Body::empty())
.expect("usage detail request"),
@@ -575,7 +575,7 @@ async fn zero_attempt_failure_detail_keeps_missing_upstream_facts_and_preparatio
.oneshot(
Request::builder()
.uri("/api/admin/usage/records/detail?id=req_early")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_zero_attempt_detail")
.body(Body::empty())
.unwrap(),
@@ -688,7 +688,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
.oneshot(
Request::builder()
.uri("/api/admin/operations/errors")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_ops_errors_snapshot")
.body(Body::empty())
.expect("ops errors request"),
@@ -781,7 +781,7 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
.oneshot(
Request::builder()
.uri("/api/admin/usage/insights/diagnostics?dimension=account")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_diagnostics_snapshot")
.body(Body::empty())
.expect("diagnostics request"),
@@ -913,7 +913,7 @@ async fn usage_route_should_forward_a_bounded_unknown_outcome_filter() {
.oneshot(
Request::builder()
.uri("/api/admin/usage/records?outcome=provider_future_state")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_usage_other_outcome")
.body(Body::empty())
.expect("usage request"),
@@ -1033,7 +1033,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
.oneshot(
Request::builder()
.uri("/api/admin/usage/records")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_usage_endpoint")
.body(Body::empty())
.expect("usage request"),
@@ -19,6 +19,71 @@ use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
#[tokio::test]
async fn proxy_location_round_trips_preserves_omitted_and_clears_null() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let location =
json!({"country":"JP", "region":"Tokyo", "city":"Tokyo", "timezone":"Asia/Tokyo"});
let (status, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({
"name":"Tokyo", "proxyUrl":"http://proxy.example:8080", "location":location
})),
true,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(created["data"]["record"]["location"], location);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["items"][0]["location"], location);
for (revision, change, expected) in [
(1, None, location.clone()),
(2, Some(Value::Null), Value::Null),
(3, Some(location.clone()), location.clone()),
] {
let mut body = json!({"id":"proxy_test", "revision":revision, "name":"Renamed"});
if let Some(change) = change {
body["location"] = change;
}
let (status, changed) =
request(&fixture, "/api/admin/proxies/update", Some(body), true).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(changed["data"]["record"]["location"], expected);
}
}
#[tokio::test]
async fn proxy_location_rejects_invalid_and_incomplete_input() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (field, value) in [
("country", json!("jp")),
("city", json!(" ")),
("region", json!("Tokyo\n")),
("city", json!("x".repeat(129))),
("timezone", json!("Asia/Typo")),
] {
let mut invalid =
json!({"country":"JP", "region":"Tokyo", "city":"Tokyo", "timezone":"Asia/Tokyo"});
invalid[field] = value;
let (status, _) = request(&fixture, "/api/admin/proxies/create", Some(json!({"name":"Invalid", "proxyUrl":"http://proxy.example:8080", "location":invalid})), true).await;
assert!(status.is_client_error(), "invalid {field} accepted");
}
let (status, _) = request(&fixture, "/api/admin/proxies/create", Some(json!({"name":"Incomplete", "proxyUrl":"http://proxy.example:8080", "location":{"country":"JP"}})), true).await;
assert!(status.is_client_error());
let (status, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({"name":"Legacy", "proxyUrl":"http://proxy.example:8080"})),
true,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert!(created["data"]["record"]["location"].is_null());
}
#[derive(Default)]
pub(super) struct MemoryProxies(Mutex<Option<ProxyRecord>>);
@@ -98,6 +163,7 @@ impl ProxyStore for MemoryProxies {
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
let record = ProxyRecord {
location: command.location,
id: "proxy_test".to_owned(),
name: command.name,
proxy: command.proxy,
@@ -122,6 +188,9 @@ impl ProxyStore for MemoryProxies {
let mut stored = self.0.lock().unwrap();
let record = stored.as_mut().ok_or_else(missing)?;
record.name = command.name;
if let Some(location) = command.location {
record.location = location;
}
if let Some(proxy) = command.proxy {
record.proxy = proxy;
}
@@ -184,7 +253,7 @@ async fn request(
.uri(path)
.method(if body.is_some() { "POST" } else { "GET" });
if authenticated {
builder = builder.header(header::COOKIE, "cpr_admin_session=valid-session");
builder = builder.header(header::COOKIE, "cpr_session=valid-session");
}
let body = body.map_or_else(Body::empty, |body| Body::from(body.to_string()));
let response = gateway_api::admin::proxies::router::<AdminTestState>()
@@ -17,7 +17,7 @@ fn request(method: Method, path: &str, body: Option<Value>) -> Request<Body> {
let mut builder = Request::builder()
.method(method)
.uri(path)
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_admin_settings");
let body = if let Some(value) = body {
builder = builder.header(header::CONTENT_TYPE, "application/json");
@@ -37,6 +37,8 @@ async fn response_json(response: axum::response::Response) -> Value {
fn update_body() -> Value {
json!({
"requestLocationEnabled": false,
"requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"},
"modelMappings": {
"gpt-5.4": "gpt-5.5",
"grok-latest": "grok-4.5"
@@ -45,12 +47,22 @@ fn update_body() -> Value {
"refreshConcurrency": 4,
"maxConcurrentPerAccount": 5,
"requestIntervalMs": 25,
"maxWaitingPerKey": 0,
"maxWaitingPerAccount": 0,
"concurrencyWaitTimeoutSeconds": 30,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
"usageRetentionDays": 32,
"opsEventRetentionDays": 31,
"auditRetentionDays": 91
"auditRetentionDays": 91,
"accountAutoFreezeEnabled": true,
"accountAutoFreezeThreshold": 12,
"accountAutoFreezeWindowSeconds": 600,
"accountAutoFreezeDurationSeconds": 7200,
"accountAutoFreezeProbeEnabled": true,
"accountAutoFreezeProbeModel": null,
"accountAutoFreezeAdaptiveConcurrency": true
})
}
@@ -89,6 +101,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
use gateway_core::routing::{PublicModelId, UpstreamModelId};
let settings = RuntimeSettings {
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
model_mappings: BTreeMap::from_iter([
(
@@ -104,12 +118,22 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
refresh_concurrency: 4,
max_concurrent_per_account: 5,
request_interval_ms: 25,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
rotation_strategy: RotationStrategy::RoundRobin,
min_codex_desktop_version: Some("26.825.6671".to_owned()),
min_codex_cli_version: Some("0.40.0".to_owned()),
usage_retention_days: 32,
ops_event_retention_days: 31,
audit_retention_days: 91,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
updated_at: Utc
.with_ymd_and_hms(2026, 8, 2, 10, 30, 0)
.single()
@@ -120,6 +144,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
assert_eq!(
value,
json!({
"requestLocationEnabled": false,
"requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"},
"modelMappings": {
"gpt-5.4": "gpt-5.5",
"grok-latest": "grok-4.5"
@@ -128,12 +154,22 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"refreshConcurrency": 4,
"maxConcurrentPerAccount": 5,
"requestIntervalMs": 25,
"maxWaitingPerKey": 0,
"maxWaitingPerAccount": 0,
"concurrencyWaitTimeoutSeconds": 30,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
"usageRetentionDays": 32,
"opsEventRetentionDays": 31,
"auditRetentionDays": 91,
"accountAutoFreezeEnabled": true,
"accountAutoFreezeThreshold": 12,
"accountAutoFreezeWindowSeconds": 600,
"accountAutoFreezeDurationSeconds": 7200,
"accountAutoFreezeProbeEnabled": true,
"accountAutoFreezeProbeModel": null,
"accountAutoFreezeAdaptiveConcurrency": true,
"updatedAt": "2026-08-02T10:30:00Z"
})
);
@@ -160,6 +196,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
.cloned()
.collect();
let settings = RuntimeSettings {
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
model_mappings: request
.model_mappings
@@ -176,6 +214,9 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
refresh_concurrency: u32::try_from(request.refresh_concurrency).expect("u32"),
max_concurrent_per_account: u32::try_from(request.max_concurrent_per_account).expect("u32"),
request_interval_ms: request.request_interval_ms,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
.expect("fixture rotation strategy"),
min_codex_desktop_version: request.min_codex_desktop_version,
@@ -183,6 +224,13 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
usage_retention_days: u32::try_from(request.usage_retention_days).expect("u32"),
ops_event_retention_days: u32::try_from(request.ops_event_retention_days).expect("u32"),
audit_retention_days: u32::try_from(request.audit_retention_days).expect("u32"),
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
updated_at: chrono::Utc::now(),
};
@@ -379,7 +427,7 @@ async fn admin_auth_should_accept_a_configured_request_id_header_name() {
let unlabelled = Request::builder()
.method(Method::GET)
.uri("/api/admin/settings")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.body(Body::empty())
.expect("build settings request");
@@ -387,3 +435,143 @@ async fn admin_auth_should_accept_a_configured_request_id_header_name() {
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn concurrency_queue_fields_validate_bounds_and_accept_disabled_queues() {
for (field, invalid) in [
("maxWaitingPerKey", 1001),
("maxWaitingPerAccount", 1001),
("concurrencyWaitTimeoutSeconds", 0),
("concurrencyWaitTimeoutSeconds", 121),
] {
let mut body = update_body();
body[field] = json!(invalid);
let request: UpdateRuntimeSettingsRequest = serde_json::from_value(body).unwrap();
assert_eq!(request.validate().unwrap_err().field(), field);
}
let mut body = update_body();
body["maxWaitingPerKey"] = json!(0);
body["maxWaitingPerAccount"] = json!(1000);
body["concurrencyWaitTimeoutSeconds"] = json!(120);
serde_json::from_value::<UpdateRuntimeSettingsRequest>(body)
.unwrap()
.validate()
.unwrap();
}
#[tokio::test]
async fn request_location_should_normalize_toggle_and_round_trip() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let mut body = update_body();
body["requestLocationEnabled"] = json!(true);
body["requestLocation"] =
json!({"country":"JP", "region":" Tokyo ", "city":" Tokyo ", "timezone":"Asia/Tokyo"});
let expected =
json!({"country":"JP", "region":"Tokyo", "city":"Tokyo", "timezone":"Asia/Tokyo"});
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response_json(response).await["data"]["requestLocation"],
expected
);
let response = app(fixture.state())
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap();
assert_eq!(
response_json(response).await["data"]["requestLocation"],
expected
);
for enabled in [false, true] {
let mut body = update_body();
body["requestLocationEnabled"] = json!(enabled);
body["requestLocation"] = expected.clone();
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = app(fixture.state())
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap();
let data = response_json(response).await["data"].clone();
assert_eq!(data["requestLocationEnabled"], json!(enabled));
assert_eq!(data["requestLocation"], expected);
}
}
#[tokio::test]
async fn request_location_should_reject_invalid_or_missing_fields_without_replacing_settings() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let original = update_body()["requestLocation"].clone();
// JSON 结构或时区解析失败由 Axum 返回 422,业务校验失败返回 400。
let mut invalid = vec![
(Value::Null, StatusCode::UNPROCESSABLE_ENTITY),
(json!({}), StatusCode::UNPROCESSABLE_ENTITY),
(
json!({"timezone":"Asia/Tokyo"}),
StatusCode::UNPROCESSABLE_ENTITY,
),
];
for (field, value, status) in [
("country", "USA", StatusCode::BAD_REQUEST),
("country", "us", StatusCode::BAD_REQUEST),
("city", "", StatusCode::BAD_REQUEST),
("region", "\nTokyo", StatusCode::BAD_REQUEST),
(
"timezone",
"Not/A_Timezone",
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
let mut location = original.clone();
location[field] = json!(value);
invalid.push((location, status));
}
for (location, expected_status) in invalid {
let mut body = update_body();
body["requestLocation"] = location;
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), expected_status);
}
let mut body = update_body();
body.as_object_mut().unwrap().remove("requestLocation");
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let response = app(fixture.state())
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap();
assert_eq!(
response_json(response).await["data"]["requestLocation"],
original
);
}
@@ -45,7 +45,7 @@ async fn update_event_stream_should_preserve_event_id_and_download_progress_perc
.oneshot(
Request::builder()
.uri("/api/admin/system/update/events")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_system_update_events")
.body(Body::empty())
.expect("update event request"),
@@ -175,12 +175,12 @@ mod response {
40001,
),
(
AdminError::admin_session_required(),
AdminError::session_required(),
StatusCode::UNAUTHORIZED,
40101,
),
(
AdminError::invalid_admin_credentials(),
AdminError::invalid_credentials(),
StatusCode::UNAUTHORIZED,
40102,
),
@@ -36,6 +36,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/admin/account_groups.rs",
"src/admin/accounts/credentials.rs",
"src/admin/accounts/handlers.rs",
"src/admin/accounts/import_tasks.rs",
"src/admin/accounts/mod.rs",
"src/admin/accounts/presenter.rs",
"src/admin/accounts/wire.rs",
@@ -54,7 +55,11 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/admin/settings.rs",
"src/admin/system.rs",
"src/admin/wire.rs",
"src/auth.rs",
"src/health.rs",
"src/key_usage/mod.rs",
"src/key_usage/query.rs",
"src/key_usage/presenter.rs",
"src/lib.rs",
"src/openai/auth.rs",
"src/openai/error.rs",
@@ -76,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/session_cookie.rs",
];
expected.sort_unstable();
@@ -90,6 +96,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
let mut expected = vec![
"tests/admin/account_groups.rs",
"tests/admin/accounts/handlers.rs",
"tests/admin/accounts/import_tasks.rs",
"tests/admin/accounts/mod.rs",
"tests/admin/accounts/presenter.rs",
"tests/admin/auth.rs",
@@ -104,7 +111,11 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/admin/system.rs",
"tests/admin/wire.rs",
"tests/architecture.rs",
"tests/auth.rs",
"tests/support.rs",
"tests/health.rs",
"tests/key_usage/mod.rs",
"tests/key_usage/fixtures.rs",
"tests/main.rs",
"tests/openai/auth.rs",
"tests/openai/error.rs",
+368
View File
@@ -0,0 +1,368 @@
use std::sync::atomic::Ordering;
use axum::{
body::Body,
http::{Method, Request, StatusCode, header},
};
use serde_json::{Value, json};
use tower::ServiceExt as _;
use crate::support::{
RAW_KEY, auth_app, cookie_request, empty_request, json_request, response_json,
};
fn session_cookie(response: &axum::response::Response) -> String {
response.headers()[header::SET_COOKIE]
.to_str()
.expect("cookie")
.split(';')
.next()
.expect("cookie pair")
.to_owned()
}
async fn login(
app: &axum::Router,
body: Value,
previous: Option<&str>,
) -> axum::response::Response {
let mut request = json_request(Method::POST, "/api/auth/login", body);
if let Some(cookie) = previous {
request
.headers_mut()
.insert(header::COOKIE, cookie.parse().expect("cookie"));
}
app.clone().oneshot(request).await.expect("login response")
}
async fn get(app: &axum::Router, path: &str, cookie: &str) -> axum::response::Response {
app.clone()
.oneshot(cookie_request(Method::GET, path, cookie))
.await
.expect("GET response")
}
#[tokio::test]
async fn unified_login_returns_server_identity_and_rotates_the_previous_session() {
let (app, _) = auth_app().await;
let key = login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await;
assert_eq!(key.status(), StatusCode::OK);
let key_cookie = session_cookie(&key);
let key_data = response_json(key).await["data"].clone();
assert_eq!(key_data["role"], "key");
assert!(key_data["expiresAt"].is_string());
assert_eq!(key_data.as_object().expect("session object").len(), 2);
assert_eq!(
response_json(get(&app, "/api/auth/status", &key_cookie).await).await["data"],
json!({"authenticated": true, "session": key_data})
);
let admin = login(
&app,
json!({"mode": "admin", "username": "admin_1", "password": "strong-admin-password"}),
Some(&key_cookie),
)
.await;
assert_eq!(admin.status(), StatusCode::OK);
let admin_cookie = session_cookie(&admin);
let admin_data = response_json(admin).await["data"].clone();
assert_eq!(admin_data["role"], "admin");
assert!(admin_data["expiresAt"].is_string());
assert_eq!(admin_data.as_object().expect("session object").len(), 2);
assert_ne!(key_cookie, admin_cookie);
assert_eq!(
response_json(get(&app, "/api/auth/status", &admin_cookie).await).await["data"],
json!({"authenticated": true, "session": admin_data})
);
assert_eq!(
response_json(get(&app, "/api/auth/status", &key_cookie).await).await["data"],
json!({"authenticated": false, "session": null})
);
assert_eq!(
get(&app, "/api/admin/system/version", &admin_cookie)
.await
.status(),
StatusCode::OK
);
let key = login(
&app,
json!({"mode": "key", "apiKey": RAW_KEY}),
Some(&admin_cookie),
)
.await;
assert_eq!(key.status(), StatusCode::OK);
let renewed = session_cookie(&key);
assert_ne!(renewed, key_cookie);
assert_eq!(
get(&app, "/api/admin/system/version", &admin_cookie)
.await
.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
response_json(get(&app, "/api/auth/status", &renewed).await).await["data"]["authenticated"],
true
);
}
#[tokio::test]
async fn login_rejects_legacy_type_without_replacing_the_current_session() {
let (app, _) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
for body in [
json!({"type": "key", "apiKey": RAW_KEY}),
json!({"type": "admin", "password": "strong-admin-password"}),
json!({"mode": "key", "type": "key", "apiKey": RAW_KEY}),
json!({"mode": "admin", "type": "admin", "password": "strong-admin-password"}),
] {
let response = login(&app, body, Some(&cookie)).await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(!response.headers().contains_key(header::SET_COOKIE));
let error = response_json(response).await.to_string();
assert!(!error.contains(RAW_KEY));
assert!(!error.contains("strong-admin-password"));
}
assert_eq!(
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["session"]["role"],
"key"
);
}
#[tokio::test]
async fn valid_key_session_cannot_read_or_mutate_admin_resources() {
let (app, _) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
for path in [
"/api/admin/system/version",
"/api/admin/accounts",
"/api/admin/client-keys",
"/api/admin/settings",
] {
let response = get(&app, path, &cookie).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}");
assert_eq!(response_json(response).await["code"], 40301);
}
let mut request = json_request(
Method::POST,
"/api/admin/settings/admin-api-key/regenerate",
json!({}),
);
request
.headers_mut()
.insert(header::COOKIE, cookie.parse().unwrap());
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["authenticated"],
true
);
}
#[tokio::test]
async fn declared_roles_and_foreign_key_ids_cannot_override_server_authority() {
let (app, _) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
for body in [
json!({"mode": "admin", "apiKey": RAW_KEY}),
json!({"mode": "key", "apiKey": RAW_KEY, "role": "admin"}),
json!({"mode": "admin", "password": RAW_KEY, "role": "key"}),
json!({"role": "key", "apiKey": RAW_KEY}),
json!({"mode": "key", "apiKey": RAW_KEY, "adminUserId": "admin_1"}),
json!({"mode": "key", "apiKey": RAW_KEY, "clientKeyId": "other-key"}),
json!({"mode": "superadmin", "apiKey": RAW_KEY}),
json!({"apiKey": RAW_KEY}),
] {
let response = login(&app, body, Some(&cookie)).await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(!response_json(response).await.to_string().contains(RAW_KEY));
}
let invalid = login(
&app,
json!({"mode": "admin", "password": RAW_KEY}),
Some(&cookie),
)
.await;
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
assert!(!invalid.headers().contains_key(header::SET_COOKIE));
assert_eq!(response_json(invalid).await["code"], 40102);
assert_eq!(
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["session"]["role"],
"key"
);
}
#[tokio::test]
async fn data_plane_keys_and_browser_sessions_are_not_interchangeable() {
let (app, _) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
assert_eq!(
get(&app, "/v1/models", &cookie).await.status(),
StatusCode::UNAUTHORIZED
);
for (name, value) in [
(header::AUTHORIZATION.as_str(), format!("Bearer {RAW_KEY}")),
("x-api-key", RAW_KEY.to_owned()),
] {
let request = Request::get("/api/auth/status")
.header(name, value)
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(
response_json(response).await["data"]["authenticated"],
false
);
}
assert_eq!(
response_json(get(&app, "/api/auth/status", &format!("cpr_session={RAW_KEY}")).await).await
["data"]["authenticated"],
false
);
}
#[tokio::test]
async fn disabled_key_revokes_its_session_and_reenabling_does_not_restore_it() {
let (app, store) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
store.enabled.store(false, Ordering::SeqCst);
assert_eq!(
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"],
json!({"authenticated": false, "session": null})
);
assert_eq!(
login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None)
.await
.status(),
StatusCode::UNAUTHORIZED
);
store.enabled.store(true, Ordering::SeqCst);
assert_eq!(
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["authenticated"],
false
);
}
#[tokio::test]
async fn key_store_outage_is_unavailable_not_expired_or_authenticated() {
let (app, store) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
store.unavailable.store(true, Ordering::SeqCst);
for path in ["/api/auth/status", "/api/admin/system/version"] {
let response = get(&app, path, &cookie).await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{path}");
assert!(!response.headers().contains_key(header::SET_COOKIE));
}
store.unavailable.store(false, Ordering::SeqCst);
assert_eq!(
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["authenticated"],
true
);
}
#[tokio::test]
async fn removed_auth_routes_do_not_fall_back_to_spa_or_accept_old_cookies() {
let (app, _) = auth_app().await;
for path in ["/api/admin/auth/login", "/api/auth/unknown"] {
let response = app
.clone()
.oneshot(empty_request(Method::GET, path))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response_json(response).await["code"], 40401);
}
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
for old_name in ["cpr_admin_session", "cpr_client_session"] {
let old_cookie = cookie.replace("cpr_session", old_name);
assert_eq!(
response_json(get(&app, "/api/auth/status", &old_cookie).await).await["data"]["authenticated"],
false
);
}
}
#[test]
fn login_wire_never_prints_either_credential_in_debug() {
for body in [
json!({"mode": "key", "apiKey": RAW_KEY}),
json!({"mode": "admin", "password": RAW_KEY}),
] {
let request: gateway_api::auth::LoginRequest = serde_json::from_value(body).unwrap();
let debug = format!("{request:?}");
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains(RAW_KEY));
}
}
#[tokio::test]
async fn key_auth_should_issue_restore_and_clear_a_unified_cookie() {
let (app, _) = auth_app().await;
let login = app
.clone()
.oneshot(json_request(
Method::POST,
"/api/auth/login",
json!({ "mode": "key", "apiKey": RAW_KEY }),
))
.await
.expect("client login response");
assert_eq!(login.status(), StatusCode::OK);
assert_eq!(login.headers()[header::CACHE_CONTROL], "no-store");
let set_cookie = login.headers()[header::SET_COOKIE]
.to_str()
.expect("client session cookie")
.to_owned();
assert!(set_cookie.starts_with("cpr_session=session_"));
assert!(set_cookie.contains("Path=/;"));
assert!(set_cookie.contains("; Secure; HttpOnly; SameSite=Lax"));
assert!(set_cookie.contains("; Max-Age="));
assert!(set_cookie.contains("; Expires="));
assert!(!set_cookie.contains(RAW_KEY));
let cookie = set_cookie
.split(';')
.next()
.expect("cookie pair")
.to_owned();
let login_body = response_json(login).await;
assert_eq!(login_body["data"]["role"], "key");
assert!(login_body["data"].get("key").is_none());
assert!(!login_body.to_string().contains(RAW_KEY));
let status = app
.clone()
.oneshot(cookie_request(Method::GET, "/api/auth/status", &cookie))
.await
.expect("client status response");
assert_eq!(status.status(), StatusCode::OK);
let status_body = response_json(status).await;
assert_eq!(status_body["data"]["authenticated"], true);
assert_eq!(status_body["data"]["session"]["role"], "key");
let logout = app
.clone()
.oneshot(cookie_request(Method::POST, "/api/auth/logout", &cookie))
.await
.expect("client logout response");
assert_eq!(logout.status(), StatusCode::OK);
let cleared = logout.headers()[header::SET_COOKIE]
.to_str()
.expect("cleared cookie");
assert!(cleared.starts_with("cpr_session=;"));
assert!(cleared.contains("Path=/;"));
assert!(cleared.contains("Max-Age=0"));
let status = app
.oneshot(cookie_request(Method::GET, "/api/auth/status", &cookie))
.await
.expect("logged out");
assert_eq!(response_json(status).await["data"]["authenticated"], false);
}
@@ -0,0 +1,198 @@
use crate::{admin::AdminTestFixture, support::key_fixture};
use chrono::{Duration, Utc};
use gateway_admin::model::{
client_keys::ClientKeyRecord,
observability::{
AttemptMetrics, CostCoverage, CurrencyCost, DecimalAmount, Granularity, OpsError,
RequestMetricPoint, RequestMetrics, TimeRange, UsageBilling, UsageListRecord,
UsageOverview,
},
};
use gateway_core::{
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
policy::{ClientApiKeyId, RateLimits},
};
pub(super) async fn fixture() -> AdminTestFixture {
let fixture = key_fixture().await;
let now = Utc::now();
*fixture.client_key.lock().unwrap() = Some(ClientKeyRecord {
id: ClientApiKeyId::new("key-42").unwrap(),
name: "Development".to_owned(),
label: Some("private-sentinel".to_owned()),
prefix: "cpr_live".to_owned(),
groups: Vec::new(),
provider_kinds: Vec::new(),
enabled: true,
limits: RateLimits::unlimited(),
budget: ClientBudgetStatus {
limits: ClientBudgetLimits {
daily_usd: "1".parse().unwrap(),
weekly_usd: "5".parse().unwrap(),
},
daily_used_usd: "0.640001".parse().unwrap(),
weekly_used_usd: "2.35".parse().unwrap(),
daily_resets_at: Some((now + Duration::days(1)).into()),
weekly_resets_at: Some((now + Duration::days(7)).into()),
},
last_used_at: Some(now),
created_at: now,
updated_at: now,
});
let metrics = RequestMetrics {
request_count: 2,
success_count: 1,
failure_count: 1,
input_tokens: 1000,
output_tokens: 100,
cached_tokens: 900,
reasoning_tokens: 40,
total_tokens: 1100,
..Default::default()
};
let coverage = CostCoverage {
calculated_count: 1,
unavailable_count: 1,
..Default::default()
};
let costs = vec![CurrencyCost {
currency: "USD".to_owned(),
amount: "0.123456".parse::<DecimalAmount>().unwrap(),
}];
{
let mut observations = fixture.observations.lock().unwrap();
observations.summary = Some(UsageOverview {
range: TimeRange {
start: now - Duration::hours(1),
end: now,
},
requests: metrics.clone(),
attempts: AttemptMetrics {
costs: costs.clone(),
cost_coverage: coverage.clone(),
..Default::default()
},
providers: Vec::new(),
});
observations.trend = Some(vec![RequestMetricPoint {
bucket_start: now,
granularity: Granularity::FifteenMinutes,
metrics,
costs,
cost_coverage: coverage,
}]);
}
fixture.usage_records.lock().unwrap().push(usage_record());
fixture.ops_errors.lock().unwrap().push(error_record());
fixture
}
fn usage_record() -> UsageListRecord {
UsageListRecord {
id: "req-visible".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: Some("coding".to_owned()),
provider_kind: Some("private-sentinel".to_owned()),
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_authentication_kind: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
service_tier: Some("private-sentinel".to_owned()),
input_tokens: Some(1000),
output_tokens: Some(100),
cached_tokens: Some(900),
cache_write_tokens: Some(0),
reasoning_tokens: Some(40),
image_input_tokens: None,
image_output_tokens: None,
total_tokens: Some(1100),
cost_source: "calculated".to_owned(),
cost_amount: Some("0.123456".parse::<DecimalAmount>().unwrap()),
cost_currency: Some("USD".to_owned()),
billing: Some(UsageBilling::Total {
source: "provider_reported".to_owned(),
total: CurrencyCost {
currency: "USD".to_owned(),
amount: "0.123456".parse().unwrap(),
},
}),
transport_decision_wait_ms: None,
connect_ms: None,
headers_ms: None,
first_event_ms: Some(100),
first_reasoning_ms: Some(210),
first_text_ms: Some(600),
first_token_ms: Some(210),
provider_processing_ms: None,
latency_ms: Some(1250),
admission_decision_ms: None,
account_selection_wait_ms: None,
capacity_used_slots: Some(17),
capacity_total_slots: Some(50),
client_ip: Some("192.0.2.42".to_owned()),
user_agent: Some("key-usage-test/1.0".to_owned()),
reasoning_effort: Some("xhigh".to_owned()),
reasoning_preset: Some("private-sentinel".to_owned()),
subagent_kind: Some("private-sentinel".to_owned()),
compact: false,
started_at: Utc::now(),
}
}
fn error_record() -> OpsError {
OpsError {
source: "private-sentinel".to_owned(),
event_id: "error-visible".to_owned(),
request_id: Some("private-sentinel".to_owned()),
attempt_index: None,
client_api_key_ref: Some("private-sentinel".to_owned()),
component: "private-sentinel".to_owned(),
operation: "private-sentinel".to_owned(),
protocol: Some("private-sentinel".to_owned()),
client_transport: Some("http_sse".to_owned()),
requested_model_id: Some("coding".to_owned()),
service_tier: Some("private-sentinel".to_owned()),
endpoint: Some("/v1/responses".to_owned()),
provider_kind: Some("private-sentinel".to_owned()),
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_authentication_kind: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
failure_kind: "private-sentinel".to_owned(),
upstream_send_state: Some("private-sentinel".to_owned()),
client_status_code: Some(502),
upstream_status_code: Some(503),
provider_error_code: Some("private-sentinel".to_owned()),
client_response_id: Some("private-sentinel".to_owned()),
upstream_request_id: Some("private-sentinel".to_owned()),
latency_ms: Some(420),
message: "private-sentinel".to_owned(),
raw_upstream_error: Some("private-sentinel".to_owned()),
client_ip: Some("192.0.2.42".to_owned()),
user_agent: Some("key-usage-test/1.0".to_owned()),
reasoning_effort: Some("xhigh".to_owned()),
reasoning_preset: Some("private-sentinel".to_owned()),
request_kind: Some("private-sentinel".to_owned()),
subagent_kind: Some("private-sentinel".to_owned()),
compact: None,
continuation_affinity_hash: Some("private-sentinel".to_owned()),
continuation_previous_response_id_hash: Some("private-sentinel".to_owned()),
continuation_unavailable_reason: Some("private-sentinel".to_owned()),
upstream_connection_id: Some("private-sentinel".to_owned()),
upstream_connection_exit_reason: Some("private-sentinel".to_owned()),
upstream_connection_age_ms: None,
upstream_connection_idle_ms: None,
recovery_request_id: Some("private-sentinel".to_owned()),
recovered_at: None,
recovery_attempt_count: 0,
recovery_retry_delay_ms: None,
recovery_total_latency_ms: None,
occurred_at: Utc::now(),
stable_sort_id: "private-sentinel".to_owned(),
}
}
@@ -0,0 +1,365 @@
//! 通过真实路由验证会话隔离、范围收敛和对外字段白名单。
use std::sync::atomic::Ordering;
use axum::{
Router,
http::{Method, StatusCode, header},
response::Response,
};
use chrono::{Duration, Utc};
use gateway_admin::model::observability::{RequestMetrics, china_day_start};
use serde_json::{Value, json};
use tower::ServiceExt as _;
use crate::support::{RAW_KEY, cookie_request, empty_request, json_request, response_json};
mod fixtures;
const RANGE: &str = "startTime=2026-09-01T00:00:00Z&endTime=2026-09-02T00:00:00Z";
async fn login(app: &Router, mode: &str) -> String {
let body = if mode == "key" {
json!({"mode": "key", "apiKey": RAW_KEY})
} else {
json!({"mode": "admin", "username": "admin_1", "password": "strong-admin-password"})
};
let response = app
.clone()
.oneshot(json_request(Method::POST, "/api/auth/login", body))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response.headers()[header::SET_COOKIE]
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_owned()
}
async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Response {
let path = format!("/api/key-usage/{resource}?{RANGE}{suffix}");
let response = app
.clone()
.oneshot(cookie_request(Method::GET, &path, cookie))
.await
.unwrap();
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
response
}
fn assert_fields(value: &Value, expected: &[&str]) {
let mut actual: Vec<_> = value
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
actual.sort_unstable();
let mut expected = expected.to_vec();
expected.sort_unstable();
assert_eq!(actual, expected);
}
#[tokio::test]
async fn overview_scopes_every_query_and_projects_only_key_visible_fields() {
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, "overview", "&model=%20coding%20", &cookie).await;
assert_eq!(response.status(), StatusCode::OK);
let data = response_json(response).await["data"].clone();
assert_fields(
&data,
&[
"asOf",
"startTime",
"endTime",
"key",
"summary",
"trend",
"healthTimeline",
],
);
assert_fields(
&data["key"],
&[
"name",
"prefix",
"maxConcurrency",
"requestsPerMinute",
"dailyLimitUsd",
"dailyUsedUsd",
"dailyResetsAt",
"weeklyLimitUsd",
"weeklyUsedUsd",
"weeklyResetsAt",
],
);
assert_eq!(data["key"]["dailyUsedUsd"], "0.640001");
assert_eq!(data["key"]["maxConcurrency"], 0);
assert_eq!(data["summary"]["totalTokens"], 1100);
assert_eq!(data["summary"]["reasoningTokens"], 40);
assert_eq!(data["trend"][0]["reasoningTokens"], 40);
assert_eq!(data["summary"]["costUsd"], "0.123456");
assert_eq!(data["summary"]["costIncomplete"], true);
assert_eq!(data["trend"][0]["bucketSeconds"], 900);
assert_eq!(
data["healthTimeline"]["points"].as_array().unwrap().len(),
96
);
assert!(!data.to_string().contains("private-sentinel"));
let observations = fixture.observations.lock().unwrap();
assert_eq!(observations.summaries.len(), 1);
assert_eq!(observations.trends.len(), 2);
for (_, filter) in observations
.summaries
.iter()
.chain(observations.trends.iter())
{
assert_eq!(filter.client_api_key_ref.as_deref(), Some("key-42"));
assert!(filter.provider_account_ref.is_none());
assert!(filter.provider_kind.is_none());
}
assert_eq!(observations.summaries[0].1.model.as_deref(), Some("coding"));
let health = observations
.trends
.iter()
.find(|(_, filter)| filter.model.is_none())
.unwrap();
assert_eq!(health.0.start, china_day_start(health.0.end));
assert!((Utc::now() - health.0.end) < Duration::seconds(5));
}
#[tokio::test]
async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
let fixture = fixtures::fixture().await;
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
for kind in ["success", "error"] {
let response = get(
&app,
"records",
&format!("&kind={kind}&model=coding&currentPage=3&pageSize=10"),
&cookie,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let data = response_json(response).await["data"].clone();
assert_eq!(data["currentPage"], 3);
assert_eq!(data["pageSize"], 10);
assert_eq!(data["total"], 1);
let record = &data["items"][0];
assert_fields(
record,
&[
"id",
"createdAt",
"model",
"route",
"reasoningEffort",
"clientTransport",
"upstreamTransport",
"tokenDetails",
"billing",
"latencyMs",
"firstTokenLatencyMs",
"latencyDetails",
"clientIp",
"userAgent",
"status",
"statusCode",
],
);
assert_eq!(record["status"], kind);
assert_eq!(record["model"], "coding");
assert_eq!(record["reasoningEffort"], "xhigh");
assert_eq!(record["clientTransport"], "http_sse");
assert_eq!(record["upstreamTransport"], "websocket");
assert_eq!(record["clientIp"], "192.0.2.42");
assert_eq!(record["userAgent"], "key-usage-test/1.0");
assert!(!data.to_string().contains("private-sentinel"));
if kind == "success" {
assert_eq!(record["billing"]["totalAmountDisplay"], "$0.1235");
assert_eq!(record["tokenDetails"]["reasoningTokens"], 40);
assert_eq!(record["tokenDetails"]["totalTokens"], 1100);
assert_eq!(record["firstTokenLatencyMs"], 210);
assert_fields(
&record["latencyDetails"],
&["firstEventMs", "firstReasoningMs", "firstTextMs"],
);
assert_eq!(record["latencyDetails"]["firstEventMs"], 100);
assert!(record["statusCode"].is_null());
} else {
assert_eq!(record["statusCode"], 502);
assert!(record["billing"].is_null());
assert!(record["tokenDetails"].is_null());
assert_eq!(record["latencyDetails"], json!({}));
}
}
let data = fixture.observations.lock().unwrap();
assert_eq!(
data.records[0].filter.client_api_key_ref.as_deref(),
Some("key-42")
);
assert_eq!(
data.errors[0].filter.client_api_key_ref.as_deref(),
Some("key-42")
);
assert_eq!(data.records[0].filter.model.as_deref(), Some("coding"));
assert_eq!(data.errors[0].filter.model.as_deref(), Some("coding"));
}
#[tokio::test]
async fn missing_admin_and_revoked_sessions_cannot_read_key_usage() {
let fixture = fixtures::fixture().await;
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"] {
assert_eq!(
get(&app, resource, "", "").await.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
get(&app, resource, "", &admin).await.status(),
StatusCode::FORBIDDEN
);
fixture.auth.enabled.store(false, Ordering::SeqCst);
let response = get(&app, resource, "", &key).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(response_json(response).await["code"], 40101);
fixture.auth.enabled.store(true, Ordering::SeqCst);
}
assert!(fixture.observations.lock().unwrap().records.is_empty());
assert!(fixture.observations.lock().unwrap().summaries.is_empty());
}
#[tokio::test]
async fn unknown_scope_fields_and_unbounded_queries_are_rejected() {
let fixture = fixtures::fixture().await;
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
for resource in ["overview", "records"] {
for extra in [
"&clientApiKeyRef=other",
"&keyId=other",
"&account=other",
"&provider=openai",
"&model=%00",
"&startTime=duplicate",
] {
assert!(
get(&app, resource, extra, &cookie)
.await
.status()
.is_client_error()
);
}
}
for extra in [
"&currentPage=0",
"&pageSize=0",
"&pageSize=101",
"&kind=all",
"&currentPage=-1",
] {
assert!(
get(&app, "records", extra, &cookie)
.await
.status()
.is_client_error()
);
}
for range in [
"startTime=invalid&endTime=invalid",
"startTime=2026-01-01T00:00:00Z&endTime=2026-09-02T00:00:00Z",
"startTime=2026-09-02T00:00:00Z&endTime=2026-09-01T00:00:00Z",
] {
let response = app
.clone()
.oneshot(cookie_request(
Method::GET,
&format!("/api/key-usage/overview?{range}"),
&cookie,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
assert!(fixture.observations.lock().unwrap().summaries.is_empty());
assert!(fixture.observations.lock().unwrap().records.is_empty());
}
#[tokio::test]
async fn empty_usage_has_zero_cost_but_unknown_pricing_stays_unknown() {
let fixture = fixtures::fixture().await;
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
for requests in [0, 2] {
{
let mut data = fixture.observations.lock().unwrap();
let summary = data.summary.as_mut().unwrap();
summary.requests = RequestMetrics {
request_count: requests,
..Default::default()
};
summary.attempts.costs.clear();
summary.attempts.cost_coverage = Default::default();
summary.attempts.cost_coverage.unavailable_count = requests;
data.trend = Some(Vec::new());
}
let data = response_json(get(&app, "overview", "", &cookie).await).await["data"].clone();
assert_eq!(
data["summary"]["costUsd"],
if requests == 0 {
json!("0")
} else {
Value::Null
}
);
assert_eq!(data["summary"]["costIncomplete"], requests > 0);
}
}
#[tokio::test]
async fn overview_does_not_mask_missing_keys_or_unavailable_observations() {
let fixture = fixtures::fixture().await;
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
fixture.observations.lock().unwrap().summary = None;
assert_eq!(
get(&app, "overview", "", &cookie).await.status(),
StatusCode::SERVICE_UNAVAILABLE
);
*fixture.client_key.lock().unwrap() = None;
assert_eq!(
get(&app, "overview", "", &cookie).await.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn namespace_errors_remain_json_and_uncacheable() {
let fixture = fixtures::fixture().await;
let app = crate::openai::api_router_with_admin(fixture.services);
for (method, path, status) in [
(Method::GET, "/api/key-usage/missing", StatusCode::NOT_FOUND),
(
Method::POST,
"/api/key-usage/overview",
StatusCode::METHOD_NOT_ALLOWED,
),
] {
let response = app
.clone()
.oneshot(empty_request(method, path))
.await
.unwrap();
assert_eq!(response.status(), status);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(response_json(response).await["code"].is_number());
}
}
+55
View File
@@ -1,4 +1,59 @@
mod admin;
mod architecture;
mod auth;
mod health;
mod key_usage;
mod openai;
mod support;
#[tokio::test]
async fn api_should_serve_resolved_assets_with_relative_environment_override() {
use axum::{body::Body, http::Request};
use std::{fs, process::Command};
use tower::ServiceExt as _;
const CHILD_ENV: &str = "CPR_TEST_ASSET_DIRECTORY";
let Ok(directory) = std::env::var(CHILD_ENV) else {
let directory = std::env::temp_dir().join(format!("cpr-assets-{}", uuid::Uuid::now_v7()));
fs::create_dir_all(directory.join("deploy")).expect("config directory");
fs::create_dir_all(directory.join("web/dist")).expect("asset directory");
fs::write(directory.join("web/dist/index.html"), "resolved-web-assets").expect("index");
let output = Command::new(std::env::current_exe().expect("test executable"))
.args([
"--exact",
"api_should_serve_resolved_assets_with_relative_environment_override",
])
.current_dir(&directory)
.env(CHILD_ENV, &directory)
.env("CPR_WEB_DIST_DIR", "../web/dist")
.output()
.expect("isolated asset directory test");
fs::remove_dir_all(directory).expect("remove fixture");
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
};
let mut config = gateway_api::ApiConfig {
asset_directory: "unused".into(),
cors_allowed_origins: Vec::new(),
request_timeout_seconds: None,
request_id_header: "x-request-id".to_owned(),
};
config
.resolve_and_validate(&std::path::Path::new(&directory).join("deploy"))
.expect("resolved API config");
let admin = admin::AdminTestFixture::new().await;
let response = openai::api_router_with_config(admin.services, config)
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.expect("static response");
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 1024)
.await
.unwrap();
assert_eq!(body.as_ref(), b"resolved-web-assets");
}
@@ -110,6 +110,25 @@ fn rate_limited_error_should_map_to_openai_retryable_status() {
);
}
#[test]
fn queue_rejections_keep_distinct_retryable_http_codes() {
for (kind, code) in [
(
GatewayErrorKind::ConcurrencyQueueFull,
"concurrency_queue_full",
),
(
GatewayErrorKind::ConcurrencyQueueTimeout,
"concurrency_queue_timeout",
),
] {
assert_eq!(
gateway_error_contract(kind),
(StatusCode::TOO_MANY_REQUESTS, "rate_limit_error", code),
);
}
}
#[test]
fn upstream_unavailable_error_should_map_to_bad_gateway() {
assert_eq!(
@@ -49,6 +49,42 @@ pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Ro
api_router_with_origins(execution, Vec::new()).await
}
pub(super) fn api_router_with_admin(admin: gateway_admin::AdminServices) -> axum::Router {
api_router_with_config(
admin,
gateway_api::ApiConfig {
asset_directory: std::env::temp_dir(),
cors_allowed_origins: Vec::new(),
request_timeout_seconds: None,
request_id_header: "x-request-id".to_owned(),
},
)
}
pub(super) fn api_router_with_config(
admin: gateway_admin::AdminServices,
config: gateway_api::ApiConfig,
) -> axum::Router {
gateway_api::initialize(
config,
Arc::new(DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot("unused-client-route-key", "openai")),
Arc::new(UnusedExecutionStore),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(IgnoredClientApiKeyUsage),
)),
admin,
Vec::new(),
Arc::new(EmptyWorkerHealth),
Arc::new(TestLifecycle::default()),
)
.expect("API bundle")
.router()
}
pub(super) async fn api_router_with_worker_health(
execution: Arc<dyn ExecutionService>,
worker_health: Arc<dyn WorkerHealthSource>,
@@ -266,6 +302,14 @@ impl ExecutionStore for UnusedExecutionStore {
struct UnusedAdmissions;
impl ClientAdmissionPort for UnusedAdmissions {
fn abandon(
&self,
key: &gateway_core::policy::ClientApiKeyId,
request: &gateway_core::engine::ModelRequestId,
) {
let _ = futures::FutureExt::now_or_never(self.release(key, request));
}
fn admit(
&self,
_: ClientAdmissionRequest,
@@ -15,6 +15,7 @@ use axum::{
header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE},
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use bytes::Bytes;
use futures::StreamExt;
use futures::future::{BoxFuture, pending};
@@ -638,7 +639,11 @@ async fn request_context_should_resolve_forwarded_precedence_and_peer_fallback()
input: Some(json!("hello")),
client_metadata: None,
// 客户端 User-Agent 仅用于本地展示,不再透传给上游指纹上下文。
protocol_context: None,
protocol_context: Some(json!({"opaque_request_headers": [
["cf-connecting-ip", STANDARD.encode(b"198.51.100.1")],
["x-real-ip", STANDARD.encode(b"198.51.100.2")],
["x-forwarded-for", STANDARD.encode(b"10.0.0.2, 203.0.113.3")]
]})),
prompt_cache_key: None,
previous_response_id: None,
}
@@ -662,6 +667,26 @@ async fn request_context_should_resolve_forwarded_precedence_and_peer_fallback()
);
}
#[tokio::test]
async fn opaque_client_headers_should_not_change_local_client_observation() {
let mut headers = HeaderMap::new();
headers.insert("user-agent", "pi/synthetic".parse().unwrap());
headers.insert("x-stainless-runtime", "node".parse().unwrap());
headers.insert("origin", "https://synthetic.invalid".parse().unwrap());
let captured = captured_client_context(headers, "192.0.2.10:443".parse().unwrap()).await;
assert_eq!(captured.user_agent.as_deref(), Some("pi/synthetic"));
assert_eq!(captured.client_ip, Some("192.0.2.10".parse().unwrap()));
assert_eq!(
captured.protocol_context,
Some(json!({
"opaque_request_headers": [
["x-stainless-runtime", STANDARD.encode(b"node")],
["origin", STANDARD.encode(b"https://synthetic.invalid")]
]
}))
);
}
#[tokio::test]
async fn stale_model_catalog_should_not_block_a_new_model_request() {
let model = " gpt-future-codex ";
@@ -768,7 +793,7 @@ async fn subagent_header_should_not_replace_non_object_client_metadata() {
}
#[tokio::test]
async fn xai_private_headers_should_not_enter_openai_request_facts() {
async fn xai_private_headers_should_remain_opaque_without_projecting_request_facts() {
let peer = "192.0.2.10:443".parse().expect("peer address");
let mut headers = HeaderMap::new();
headers.insert("x-grok-turn-idx", "7".parse().expect("header"));
@@ -776,7 +801,15 @@ async fn xai_private_headers_should_not_enter_openai_request_facts() {
let captured = captured_client_context(headers, peer).await;
assert!(captured.protocol_context.is_none());
assert_eq!(
captured.protocol_context,
Some(json!({
"opaque_request_headers": [
["x-grok-turn-idx", STANDARD.encode(b"7")],
["x-grok-conv-id", STANDARD.encode(b"private-session")]
]
}))
);
assert!(captured.prompt_cache_key.is_none());
}
@@ -233,6 +233,14 @@ fn decoder_should_preserve_connection_metadata_outside_the_openai_wire_body() {
#[test]
fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
let mut headers = HeaderMap::new();
for name in [
"chatgpt-organization-id",
"chatgpt-org-id",
"x-openai-organization",
"x-openai-project",
] {
headers.insert(name, HeaderValue::from_static("unclassified-extension"));
}
headers.append(
"x-openai-future-mode",
HeaderValue::from_static("future-ascii"),
@@ -338,6 +346,18 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
values("openai-beta"),
vec![b"future_responses=v2".to_vec(), b"future_tools=v3".to_vec()]
);
for name in [
"chatgpt-organization-id",
"chatgpt-org-id",
"x-openai-organization",
"x-openai-project",
] {
assert_eq!(values(name), vec![b"unclassified-extension".to_vec()]);
}
assert_eq!(
values("x-codex-installation-id"),
vec![b"client-installation".to_vec()]
);
for preserved in [
"accept",
"content-type",
@@ -357,7 +377,6 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
"cookie",
"chatgpt-account-id",
"chatgpt-project-id",
"x-codex-installation-id",
// 上游指纹由运行时画像统一生成,客户端不得覆盖。
"user-agent",
"originator",
@@ -371,7 +390,7 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
}
#[test]
fn decoder_should_exclude_downstream_transport_headers_from_opaque_context() {
fn decoder_should_strip_http_transport_but_leave_source_headers_for_provider() {
let mut headers = HeaderMap::new();
for name in [
"cf-visitor",
@@ -407,10 +426,124 @@ fn decoder_should_exclude_downstream_transport_headers_from_opaque_context() {
assert_eq!(
openai_protocol_context(&decoded).get("opaque_request_headers"),
Some(&json!([["x-openai-future-mode", STANDARD.encode(b"keep")]])),
Some(&Value::Array(
headers
.iter()
.filter(|(name, _)| !matches!(
name.as_str(),
"accept-encoding" | "content-encoding"
))
.map(|(name, value)| json!([name.as_str(), STANDARD.encode(value.as_bytes())]))
.collect()
)),
);
}
#[test]
fn downstream_client_headers_should_remain_opaque_without_losing_session_semantics() {
for canonical in [None, Some("canonical-session")] {
let mut headers = HeaderMap::new();
for name in [
"X-Stainless-Runtime",
"x-stainless-future-field",
"Origin",
"Referer",
"Sec-Ch-Ua",
"sec-ch-ua-platform",
"Sec-Fetch-Site",
"session_id",
] {
headers.append(name, HeaderValue::from_static("alias-session"));
headers.append(name, HeaderValue::from_static("duplicate"));
}
if let Some(session) = canonical {
headers.insert("session-id", HeaderValue::from_static(session));
}
for name in [
"thread-id",
"x-client-request-id",
"traceparent",
"tracestate",
] {
headers.insert(name, HeaderValue::from_static("keep"));
}
headers.append("x-future-business", HeaderValue::from_static("first"));
headers.append(
"x-future-business",
HeaderValue::from_bytes(b"\x80\xff").unwrap(),
);
let original_headers = headers.clone();
let body = json!({
"model": "smart-code", "input": "中文 pi 内容不能被请求头过滤改写",
"prompt_cache_key": "synthetic-cache",
"client_metadata": {"session_id": "body-session"}
});
let mut frame = body.clone();
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_response_create_with_context(&frame.to_string(), &opening).unwrap(),
decode_response_create_with_context(&frame.to_string(), &opening).unwrap(),
] {
assert_eq!(openai_wire_body(&decoded), body.as_object().unwrap());
let context = openai_protocol_context(&decoded);
assert_eq!(context["session_id"], canonical.unwrap_or("alias-session"));
let entries = context["opaque_request_headers"].as_array().unwrap();
for name in [
"x-stainless-runtime",
"x-stainless-future-field",
"origin",
"referer",
"sec-ch-ua",
"sec-ch-ua-platform",
"sec-fetch-site",
"session_id",
] {
let values: Vec<_> = entries
.iter()
.filter(|entry| entry[0] == name)
.cloned()
.collect();
assert_eq!(
values,
vec![
json!([name, STANDARD.encode(b"alias-session")]),
json!([name, STANDARD.encode(b"duplicate")]),
],
"source header {name} belongs to the Provider"
);
}
for name in [
"thread-id",
"x-client-request-id",
"traceparent",
"tracestate",
] {
assert!(
entries
.iter()
.any(|entry| entry == &json!([name, STANDARD.encode(b"keep")]))
);
}
let future: Vec<_> = entries
.iter()
.filter(|entry| entry[0] == "x-future-business")
.cloned()
.collect();
assert_eq!(
future,
vec![
json!(["x-future-business", STANDARD.encode(b"first")]),
json!(["x-future-business", STANDARD.encode(b"\x80\xff")]),
]
);
}
// 解码不修改原始请求,CORS、鉴权和本地观测仍可读取原值。
assert_eq!(headers, original_headers);
}
}
#[test]
fn decoder_should_preserve_unknown_nested_values_without_debug_disclosure() {
let secret = "nested-private-value";
@@ -666,6 +799,87 @@ fn transparent_encoder_should_use_identical_json_for_sse_and_websocket() {
);
}
#[test]
fn transparent_encoder_should_translate_error_wire_to_response_failed_for_websocket() {
// codex 的 WS 端点只消费带 status 的包装错误帧;上游缺少 status 的裸
// `error` 帧会被静默忽略,客户端只能空等到 idle 超时。WS 边界与 SSE
// 边界一致投影成 `response.failed`,codex 将其映射为可重试错误并立即
// 重试,而不是把失败原因拖到流 EOF。
let response_id = "resp_ws_error";
let started = openai_wire_event(
Vec::new(),
"response.created",
json!({
"type": "response.created",
"response": {"id": response_id, "status": "in_progress"}
}),
);
let error = openai_wire_event(
Vec::new(),
"error",
json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later."
},
"sequence_number": 2
}),
);
let mut websocket_encoder = OpenAiResponsesEncoder::new();
websocket_encoder.push_websocket(&started);
let messages = websocket_encoder.push_websocket(&error);
assert_eq!(messages.len(), 1);
assert!(websocket_encoder.has_wire_failure());
let payload: Value = serde_json::from_str(&messages[0]).expect("response.failed is valid JSON");
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");
// 与 SSE 边界投影到同一份 data 负载,两条客户端通道行为一致。
let mut sse_encoder = OpenAiResponsesEncoder::new();
sse_encoder.push_sse(&started);
let frames = sse_encoder.push_sse(&error);
let sse_text = String::from_utf8(
frames
.iter()
.flat_map(|frame| frame.as_ref().iter().copied())
.collect(),
)
.expect("SSE is UTF-8");
let sse_data = parse_sse_events(&sse_text)
.expect("SSE parses")
.into_iter()
.map(|event| event.data)
.collect::<Vec<_>>();
assert_eq!(sse_data, messages);
// 带非 2xx status 的包装错误帧是 codex 可直接消费的形状,必须原样透传。
let mut passthrough_encoder = OpenAiResponsesEncoder::new();
passthrough_encoder.push_websocket(&started);
let wrapped = openai_wire_event(
Vec::new(),
"error",
json!({
"type": "error",
"status": 429,
"headers": {"x-request-id": "req_raw"},
"error": {"type": "custom_type", "code": "custom_code", "message": "raw marker"},
"future": {"keep": true}
}),
);
let wrapped_messages = passthrough_encoder.push_websocket(&wrapped);
assert_eq!(
wrapped_messages,
vec![wrapped.wire_event().expect("wire").data().to_string()]
);
assert!(passthrough_encoder.has_wire_failure());
}
#[test]
fn transparent_encoder_should_follow_wire_when_canonical_identity_changes() {
let mut encoder = OpenAiResponsesEncoder::new();
@@ -914,6 +914,14 @@ impl ExecutionStore for SettlementPorts {
}
impl ClientAdmissionPort for SettlementPorts {
fn abandon(
&self,
key: &gateway_core::policy::ClientApiKeyId,
request: &gateway_core::engine::ModelRequestId,
) {
let _ = futures::FutureExt::now_or_never(self.release(key, request));
}
fn admit(
&self,
_: ClientAdmissionRequest,
@@ -7,7 +7,7 @@ use serde_json::json;
use super::decode_response_create;
#[test]
fn response_create_should_exclude_cloudflare_headers_on_every_frame() {
fn response_create_should_carry_source_headers_to_provider_on_every_frame() {
let mut headers = HeaderMap::new();
for (name, value) in [
("CF-Visitor", r#"{"scheme":"https"}"#),
@@ -24,7 +24,7 @@ fn response_create_should_exclude_cloudflare_headers_on_every_frame() {
headers.append("x-openai-future-mode", HeaderValue::from_static("second"));
let request_headers = OpenAiRequestHeaders::from_headers(&headers);
// 连接级头会用于同一 WebSocket 的每一帧,后续帧也不能恢复下游链路元数据。
// 连接级头随每一帧传给 Provider,由相同的出站边界处理来源适配。
for input in ["hello", "continue"] {
let decoded = decode_response_create_with_context(
&json!({"type": "response.create", "model": "smart-code", "input": input}).to_string(),
@@ -39,10 +39,12 @@ fn response_create_should_exclude_cloudflare_headers_on_every_frame() {
.protocol_payload()
.context()
.get("opaque_request_headers"),
Some(&json!([
["x-openai-future-mode", STANDARD.encode(b"first")],
["x-openai-future-mode", STANDARD.encode(b"second")],
])),
Some(&serde_json::Value::Array(
headers
.iter()
.map(|(name, value)| json!([name.as_str(), STANDARD.encode(value.as_bytes())]))
.collect()
)),
);
assert_eq!(request.protocol_payload().body()["input"], input);
}

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