Compare commits

...
46 Commits
Author SHA1 Message Date
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
ZyyandCodex c8fdc2b5cd chore(release): prepare v3.7.2
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:55:09 +08:00
2han9wen71an dfabf0f9dd fix(openai): recover quota after same-window usage drops (#95)
OpenAI 周窗口的 used_percent 可以在同一窗口内回落(滚动计量),而
reset_at 固定到窗口边界才变化。恢复规则要求「reset 前进 + 用量低于
10%」,两个条件在滑动周窗口上都无法成立:耗尽账号被钉在旧 reset
日期(最长一周)无法参与调度,尽管 /usage 已显式报告 allowed=true /
limit_reached=false 且 5h 窗口全新。

新增第二条解除路径:同一窗口(reset 未滚动)内连续两次观测都未触顶
即解除。候选记录持久化在 _quota_recovery,跨 worker 周期与重启累积;
连续两次的要求保持对「耗尽后立刻拉到的旧快照」竞态的防护(单次旧
快照撑不过两次观测)。原有「reset 前进 + 用量<10%」快路径不变。
2026-09-15 15:38:46 +08:00
ZyyandCodex 817b0f2d6d chore(release): commit version and notes together
Allow staged or unstaged release notes and commit their final contents with the version update before tagging. Continue rejecting unrelated tracked changes and require notes to remain uncommitted.

Update the release skill, skip duplicate push CI for release metadata alone, and check the publish script syntax in CI. Validated publication behavior in temporary Git repositories, Bash syntax, and Actionlint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:14:18 +08:00
ZyyandCodex a975868046 fix(release): publish version-matched deployment assets
Publish the configuration template and Compose file with release checksums, pin the packaged Compose image to the release version, and include the template in platform archives.

Download deployment assets from one release tag, link versioned deployment documentation, and translate the release installation notes into Chinese.

Validated Actionlint, stable and prerelease packaging for all platforms, SHA-256 checksums, and resolved Docker Compose image versions.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:02:49 +08:00
ZyyandCodex cc2dfab19a fix(openai): preserve client location when unset
Accept omitted and null request locations without rewriting web search location or environment dates and timezones. Keep complete configured overrides validated and document the passthrough behavior.

Validated 626 OpenAI tests, 38 gateway tests including architecture checks, Rustfmt, and strict workspace Clippy.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:02:36 +08:00
ZyyandCodex 040a1e82a6 chore: bump version to 3.7.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:32:29 +08:00
ZyyandCodex 4f39bfc5a8 docs(release): prepare v3.7.1 notes
Document configurable request location, WebSocket SSE error delivery, relaxed Compose resource limits, and the required image/config version alignment.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:32:20 +08:00
ZyyandCodex 3e3616863d fix(deploy): remove fixed container resource limits
Allow the default Compose deployment to run on hosts with fewer than two CPUs and leave CPU and memory policy to the deployment environment.

Validated the rendered Docker Compose configuration.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:01:21 +08:00
1bbae67040 fix(gateway-api): translate upstream WebSocket error events into SSE response.failed (#90)
* fix(gateway-api): translate upstream WebSocket error events into SSE response.failed

WS 上游协议用 `error` 事件报告流中途失败(如 OpenAI 的 server_is_overloaded),
SSE 协议的对应形态是 `response.failed`。SSE 交付边界此前把 `error` 事件按
未知事件原样透传,而 codex 等 SSE 客户端会静默忽略未知事件类型:客户端只看到
无原因的流 EOF("stream closed before response.completed"),丢失了上游的
完整错误负载(message/code/type),也无法据此分类重试。

现在 SSE 编码器在交付边界把 `error` wire 事件翻译成 `response.failed`:
错误负载(type/code/message/param)原样保留,response id 沿用已交付的
response.created。WS 客户端交付不变(WS 协议原生支持 error 帧)。
原有的会话级失败兜底(finish_with_gateway_error 合成 response.failed)不变。

* fix(gateway-api): preserve upstream response failures

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-15 10:49:29 +08:00
ZyyandCodex fc07ac0803 fix(ci): scope rustls security waiver
Temporarily waive RUSTSEC-2026-0285 until 2026-09-21 UTC to preserve the pinned Codex TLS fingerprint. Keep all other audit failures blocking.

Validated cargo audit and waiver expiry behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex c7823a7821 feat(openai): make request location configurable
Group country, region, city, and timezone under openai.wire_profile.location while retaining the existing defaults. Share the configured location across web search and environment context, preserve epoch timestamps and release updates, and add validation, regression tests, and deployment documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex 61d1bcb405 refactor(api): standardize request parameters and trim unused options
Use data for POST bodies and params: data for GET queries. Remove request options that have no callers while retaining cancellation, timeout, and silent polling support. Update affected backup and client-download call sites and document the convention.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:25 +08:00
ZyyandCodex 21a436b76a chore: bump version to 3.7.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 17:33:59 +08:00
ZyyandCodex 849aa73a28 docs(release): prepare v3.7.0 notes
Summarize account personal information, reset credit fixes, OpenAI retry and transport changes, and service tier accounting. Document the admin API replacement and upgrade scope.

Co-authored-by: Codex <noreply@openai.com>
2026-09-14 17:33:52 +08:00
ZyyandCodex fb89bda177 docs: clarify documentation scope and required development skills
Centralize documentation update rules in CONTRIBUTING and reference them from the development and PR skills. Require frontend-design for frontend development and rust-best-practices for Rust development.

Co-authored-by: Codex <noreply@openai.com>
2026-09-14 17:28:07 +08:00
ZyyandCodex 9e6c8c9734 fix(openai): retry capacity rejections before rotating accounts
Retry explicit model capacity rejections on the same account up to three
times with bounded backoff, then reuse ordinary account rotation. Honor
the existing replay, delivery, cancellation and total request budgets.

Keep capacity rejections out of account health scores and provider
circuits, preserve original client errors, and extend the WebSocket
rejection boundary introduced by #89. Cover HTTP and WebSocket behavior
and document the policy.

Co-authored-by: Codex <noreply@openai.com>
2026-09-14 17:19:38 +08:00
2han9wen71an 3f69bc861c fix(openai): rotate accounts instead of same-socket retry on WebSocket account-level rejections (#89)
WebSocket 升级或交换被上游账号级拒绝(额度耗尽、限流、封禁等)时,恢复策略
仍会把错误交给同账号传输重试,且退避直接采用上游 retry-after(额度窗口通常
是小时级)。请求在 deadline 内无法恢复,错误原样返回客户端,Core 也失去了
换号机会——而同错误在 HTTP 路径上因回放安全会立即换号重试。

现在回放安全且带账号级失败记录的 WebSocket 拒绝不进入传输恢复:保留账号失败
记录(健康度与额度标记立即生效),把换号决策交还 Core,与 HTTP 路径语义一致。
会话 WebSocket 预算与禁用标记也不被账号级拒绝消耗。
2026-09-14 17:15:47 +08:00
ZyyandCodex cea4e1d8e9 fix(openai): use outbound service tier for usage and billing
Keep response-reported tiers as diagnostic metadata without overriding outbound-tier usage or cost estimates. Cover HTTP and WebSocket behavior and document the updated accounting policy.

Co-authored-by: Codex <noreply@openai.com>
2026-09-14 16:41:02 +08:00
539764f7eb feat(accounts): aggregate personal information on demand (#83)
Load profile statistics and OpenAI subscription information concurrently through one on-demand admin endpoint. Keep subscription queries out of quota refreshes, preserve partial results and account identity checks, and show subscriptions in the approved personal-information dialog layout.

Update the current API and architecture contracts while leaving README unchanged.

Co-authored-by: Yanshan Chen <16632048+chenyanshan@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 15:48:12 +08:00
c9be17778d fix(accounts): consume reset credits without card details (#81)
Allow count-only reset inventories to request upstream card selection without a credit ID. Preserve confirmation and idempotent retry guards, and place the fallback action in the shared modal footer with balanced inventory spacing.

Co-authored-by: Yanshan Chen <16632048+chenyanshan@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 15:47:32 +08:00
d8bc91efce fix(openai): align structured location metadata (#88)
Rewrite only Codex-generated environment context and web search location fields to Piketon, Ohio. Preserve user-authored text and absolute timestamps.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 14:14:08 +08:00
ZyyandZyy e8e2f18927 fix(openai): align WebSocket wire fingerprint (#87)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-09-14 12:52:06 +08:00
0977de3713 chore(deps): align Codex transport dependencies (#86)
Align HTTP and WebSocket TLS dependencies with the locally installed official Codex 0.154.0 client, preserve the hyper-util SOCKS IPv6 fix, and lock captured ClientHello behavior in tests.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 12:02:44 +08:00
dependabot[bot] eaa37d9faa chore(deps): bump the minor-and-patch group in /backend with 4 updates (#85)
Bumps the minor-and-patch group in /backend with 4 updates: [syn](https://github.com/dtolnay/syn), [uuid](https://github.com/uuid-rs/uuid), [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust) and [redis](https://github.com/redis-rs/redis-rs).


Updates `syn` from 3.0.4 to 3.0.5
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/3.0.4...3.0.5)

Updates `uuid` from 1.26.0 to 1.26.1
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.26.0...v1.26.1)

Updates `aws-sdk-s3` from 1.144.0 to 1.146.0
- [Release notes](https://github.com/awslabs/aws-sdk-rust/releases)
- [Commits](https://github.com/awslabs/aws-sdk-rust/commits)

Updates `redis` from 1.6.0 to 1.7.0
- [Release notes](https://github.com/redis-rs/redis-rs/releases)
- [Commits](https://github.com/redis-rs/redis-rs/compare/redis-1.6.0...redis-1.7.0)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 3.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: uuid
  dependency-version: 1.26.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.146.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: redis
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 11:48:32 +08:00
4e588ffa1d chore(deps): ignore Codex-aligned dependencies (#84)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-14 11:01:34 +08:00
dependabot[bot] b9e919f510 chore(deps): bump rust from 82150a5 to 9a73a50 in /deploy (#80)
Bumps rust from `82150a5` to `9a73a50`.

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.98-bookworm
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:58:58 +08:00
dependabot[bot] 32f9f3675d chore(deps): bump the minor-and-patch group in /frontend with 5 updates (#79)
Bumps the minor-and-patch group in /frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.41.0` | `1.45.0` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.14` | `3.4.15` |
| [marked](https://github.com/markedjs/marked) | `18.0.11` | `18.0.13` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.4.1` | `26.5.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.2` | `8.3.0` |


Updates `@lucide/vue` from 1.41.0 to 1.45.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.45.0/packages/vue)

Updates `dompurify` from 3.4.14 to 3.4.15
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.14...3.4.15)

Updates `marked` from 18.0.11 to 18.0.13
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.11...v18.0.13)

Updates `@types/node` from 26.4.1 to 26.5.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `vite` from 8.2.2 to 8.3.0
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.3.0/packages/vite)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:58:33 +08:00
dependabot[bot] 221d557d5e chore(deps): bump pnpm/action-setup in the minor-and-patch group (#76)
Bumps the minor-and-patch group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `pnpm/action-setup` from 6.0.10 to 6.1.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0977fd99725f1db4007ccb2928dbb4e90d06cc86...ea17c68df8912ef543352723c149a84f56e3d413)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:57:06 +08:00
ZyyandCodex 4105c1ece6 chore: bump version to 3.6.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-13 04:30:39 +08:00
ZyyandCodex 4d59687cbc docs(release): prepare v3.6.1 notes
Co-authored-by: Codex <noreply@openai.com>
2026-09-13 04:30:35 +08:00
ZyyandCodex 8621e24e7c fix: tolerate partial quota usage and remove retired model pricing
Keep quota capacity and remaining estimates based on recorded usage when some token or cost facts are missing. Preserve unknown values and explain potential underestimation without blocking usable forecasts.

Remove OpenAI pricing rules for shutdown models and the GPT-4-0314 alias while preserving historical costs and active regular prices. Document actual tier fallback and model-specific pricing behavior.

Validated with backend test targets, strict workspace Clippy, Rustfmt, frontend lint/typecheck/build, and mocked browser checks for partial, missing, complete, weekly/monthly, and mobile dark forecast states.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 04:27:49 +08:00
ZyyandCodex 3bebccac5d chore: bump version to 3.6.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-13 03:50:04 +08:00
ZyyandCodex 98287195ca docs(release): prepare v3.6.0 notes
Co-authored-by: Codex <noreply@openai.com>
2026-09-13 03:49:59 +08:00
ZyyandCodex fd1b159f71 docs: clarify document responsibilities and simplify setup
Keep documentation focused on current contracts, responsibilities, and required operations. Define when PRs need documentation updates and consolidate setup commands with explicit ownership and permissions.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 03:43:15 +08:00
Yanshan ChenandCodex 7b03fdc8ab feat(keys): support custom keys and search by name
Support optional custom client keys across creation, storage, and authentication. Search keys and request records by name, remove credential and prefix searches, and reject duplicate names during create and update using the existing control-plane transaction lock.

Simplify the request diagnostics panel and use the shared empty state.

Validated with 1,029 backend tests using dedicated PostgreSQL and Redis, strict workspace Clippy, frontend lint/typecheck/build, and browser checks for key creation, name validation, search, diagnostics refresh/export, and mobile layouts.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 03:21:42 +08:00
Yanshan ChenandCodex 851a50962d fix(admin): support HTTP login and clipboard without configuration
Support same-origin HTTP admin sessions without an additional configuration setting. Derive Cookie security from a validated browser Origin, retain Secure for HTTPS and unknown origins, and enable VueUse's existing clipboard fallback.

Validated with 312 API tests, 36 gateway tests, strict workspace Clippy, frontend lint/typecheck/build, and browser login/reload/logout over HTTP and HTTPS-to-HTTP proxying.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 02:41:05 +08:00
ZyyandCodex b6e17db0c6 fix(openai): preserve native Codex model catalogs
Forward complete upstream model objects and client versions without rebuilding metadata. Keep account-scoped catalogs in a bounded, coalesced cache with explicit invalidation and preserve model aliases without mixing fields.

Cover service tiers, native metadata, account isolation, cache behavior, and failure handling with regression tests; document the catalog contract.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 02:02:17 +08:00
ZyyandCodex b98c2f01a5 fix(accounts): fix quota forecasts and simplify capacity view
Exclude confirmed OpenAI prewarm requests from completed usage while
preserving audit records and unknown inference pricing.

Keep only the quota capacity card, trim unused Admin/API diagnostics,
and use the actual quota observation time without changing forecast gates.
Reduce warning noise and align capacity headings with the cards.

Co-authored-by: Codex <noreply@openai.com>
2026-09-13 00:45:26 +08:00
338 changed files with 16633 additions and 3672 deletions
+6 -3
View File
@@ -10,12 +10,14 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
## 按任务读取
| 任务 | 文档入口 |
| 任务 | 技能与文档入口 |
| --- | --- |
| 用户能力、快速开始 | [README](../../../README.md) |
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
| 管理端组件、主题 Token、视觉验证 | [管理端主题](../../../docs/theme.md) |
| 前端开发、管理端组件、主题 Token、视觉验证 | 加载并使用 `$frontend-design`,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
@@ -29,7 +31,8 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;同步受影响的说明。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
5. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
+12
View File
@@ -37,6 +37,11 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
区分已被主分支覆盖的实现、仍有价值的修复或回归测试,以及与目标无关的改动;不能仅凭标题或代码相似认定重复。
说明原问题发生版本和当前剩余差异,创建时据此收敛范围,审查时报告保留或拆分建议;这不授权自动关闭或修改他人的 PR。
## 文档职责与更新条件
按 [贡献与审查的文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否需要更新文档。
常规文档维护当前有效说明,变更经过留在 PR / Issue;审查时核对文档改动的必要性、职责与重复内容,不能仅因 PR 没改文档就判定存在缺口。
## 创建 PR
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
@@ -46,6 +51,8 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
实现应职责清晰、表达直接、复用合理;提交前主动清理本次变更引入的重复逻辑、冗余状态、死代码、
多余抽象及无依据的兼容分支。功能可用和检查通过不能替代可维护性自查。
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
@@ -76,8 +83,13 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
按上述核对流程收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
+4 -3
View File
@@ -53,8 +53,9 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
## 执行正式发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
当前脚本要求先提交 `release/notes.md`,再由脚本更新 `release/version.yaml` 并创建版本提交,
不要提前把版本文件改成目标版本,否则脚本会判定该版本已设置。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): prepare vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权正式发布后,将 `release_version` 设为已确定的版本,执行:
@@ -63,7 +64,7 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
release/publish "$release_version"
```
该命令会创建版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
## 恢复与验收
+17 -1
View File
@@ -21,11 +21,27 @@ updates:
day: monday
time: '09:10'
timezone: Asia/Shanghai
# 固定官方 Codex Desktop 的 TLS 指纹;升级前必须抓取握手并比对扩展顺序。
# 按官方 Codex 锁文件、协议指纹或已审计 fork 固定;完成对应核对与实测后再手动升级。
ignore:
- dependency-name: aws-lc-rs
- dependency-name: hyper
- dependency-name: hyper-rustls
- dependency-name: hyper-tls
- dependency-name: hyper-util
- dependency-name: native-tls
- dependency-name: openssl
- dependency-name: openssl-src
- dependency-name: openssl-sys
- dependency-name: reqwest
- dependency-name: rustls
- dependency-name: rustls-native-certs
- dependency-name: rustls-pki-types
- dependency-name: tokio
- dependency-name: tokio-native-tls
- dependency-name: tokio-rustls
- dependency-name: tokio-tungstenite
- dependency-name: tungstenite
- dependency-name: zstd
groups:
minor-and-patch:
update-types:
+4 -1
View File
@@ -159,7 +159,7 @@ jobs:
ref: ${{ inputs.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 11.7.0
@@ -205,3 +205,6 @@ jobs:
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
-color
.github/workflows/*.yml
- name: Check release script syntax
run: bash -n release/publish
+1
View File
@@ -6,6 +6,7 @@ on:
- main
paths-ignore:
- 'release/version.yaml'
- 'release/notes.md'
pull_request:
branches:
- main
+31 -14
View File
@@ -315,6 +315,7 @@ jobs:
shell: bash
env:
VERSION: ${{ needs.release-info.outputs.version }}
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
run: |
set -euo pipefail
ruby <<'RUBY' > release-platforms.json
@@ -349,7 +350,18 @@ jobs:
shutil.rmtree(release_dir, ignore_errors=True)
release_dir.mkdir(parents=True)
archives = []
config_template = pathlib.Path("deploy/config.example.yaml")
shutil.copy2(config_template, release_dir / config_template.name)
compose = pathlib.Path("deploy/compose.yaml").read_text()
image = f"{os.environ['REGISTRY']}/{os.environ['IMAGE_NAME']}"
default_image = "${CPR_IMAGE:-" + image + ":latest}"
if compose.count(default_image) != 1:
raise SystemExit("Expected exactly one default application image in deploy/compose.yaml")
# 发布附件固定镜像版本,防止后续 pull 与随包配置模板脱节。
versioned_image = "${CPR_IMAGE:-" + f"{image}:{os.environ['DOCKER_VERSION']}" + "}"
compose = compose.replace(default_image, versioned_image)
(release_dir / "compose.yaml").write_text(compose)
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
for platform in platforms:
platform_name = f"{platform['os']}-{platform['arch']}"
@@ -367,6 +379,9 @@ jobs:
shutil.copy2(binary_src, binary_dest)
binary_dest.chmod(0o755)
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
deploy_dir = package_root / "deploy"
deploy_dir.mkdir()
shutil.copy2(config_template, deploy_dir / config_template.name)
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
archive_path = release_dir / archive_name
@@ -375,12 +390,12 @@ jobs:
archive.add(package_root, arcname=APP_NAME)
else:
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
archives.append(archive_path)
assets.append(archive_path)
checksum_path = release_dir / "checksums.txt"
with checksum_path.open("w", encoding="utf-8") as file:
for archive in archives:
file.write(f"{sha256(archive)} {archive.name}\n")
for asset in assets:
file.write(f"{sha256(asset)} {asset.name}\n")
PY
- name: Generate release asset provenance
@@ -655,25 +670,27 @@ jobs:
env:
INSTALLATION_NOTES: |
> [!WARNING]
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
> **跨大版本升级:** 不支持跨大版本在线升级。请使用全新的 `.runtime/` 数据目录重新部署,再重新导入或重新授权服务商账号,并重新导入客户端密钥。详见[升级说明](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md#镜像升级与源码构建)。
## 📥 Installation
## 📥 安装
**Docker:**
**Docker 部署:**
```bash
# GitHub Container Registry
# 从 GitHub 容器镜像仓库拉取
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
```
**Manual download:**
Download the appropriate archive for your platform from the assets below.
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
Release archives are covered by GitHub artifact attestations. Docker images are published with SBOM/provenance attestations and keyless cosign signatures.
**手动下载:**
请从下方附件中下载适合当前平台的归档包。
## 📚 Documentation
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
- [GitHub Repository](https://github.com/${{ github.repository }})
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
## 📚 文档
- [GitHub 仓库](https://github.com/${{ github.repository }})
- [部署指南](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md)
run: |
python3 <<'PY'
import os
+11 -2
View File
@@ -40,7 +40,16 @@ jobs:
- name: Run cargo audit
working-directory: backend
run: cargo audit
shell: bash
run: |
# 临时接受此中危风险以保持 Codex 0.154.0 的 TLS 指纹;直接升级会新增 ML-DSA 签名算法。
# 仅豁免本条公告,2026-09-21 UTC 起恢复阻断;兼容升级验证完成后应提前删除豁免。
# https://github.com/rustls/rustls/security/advisories/GHSA-2mjx-qc3c-rqvc
audit_advisory_args=()
if [[ "$(date -u +%F)" < "2026-09-21" ]]; then
audit_advisory_args+=(--ignore RUSTSEC-2026-0285)
fi
cargo audit "${audit_advisory_args[@]}"
frontend-security:
runs-on: ubuntu-latest
@@ -52,7 +61,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 11.7.0
+20 -3
View File
@@ -76,15 +76,32 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
## 项目约定
- Rust 模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式。
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。行为或合同变更同步到架构文档指定的文档位置,发布说明按维护者的发布安排更新。
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。
## 文档职责与更新条件
常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
- 按 [开发指南的文档入口](.agents/skills/dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue 或任务报告。版本变更与升级影响按维护者的发版安排维护
`release/notes.md`,不为普通开发任务或 PR 自动追加发布说明。
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
## 界面验证
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
@@ -113,7 +130,7 @@ pnpm --dir frontend build
git diff --check
```
`build` 已包含类型检查;按当前架构约定,不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security-scan.yml)。
+16 -16
View File
@@ -5,7 +5,7 @@
# Codex Proxy RS
面向 Codex 的自托管多账号 AI 网关。
面向 Codex 的自托管多账号 AI 网关
[![CI](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/zyycn/codex-proxy-rs?display_name=tag&sort=semver&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/releases)
@@ -21,30 +21,31 @@
## 快速开始
使用 Docker Compose 部署发布镜像 `ghcr.io/zyycn/codex-proxy-rs:latest`,同时启动 PostgreSQL 和 Redis。
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
### 1. 下载部署文件并配置
```bash
mkdir -p codex-proxy-rs/deploy
cd codex-proxy-rs
mkdir -p codex-proxy-rs/deploy && cd codex-proxy-rs
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/compose.yaml \
# 只解析一次最新正式版本,确保两个文件来自同一 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://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/config.example.yaml \
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/config.example.yaml" \
-o deploy/config.example.yaml
mkdir -p .runtime/data .runtime/logs
install -d -m 0750 .runtime/postgres .runtime/redis
cp deploy/config.example.yaml deploy/config.yaml
sudo chown "$(id -u):10001" deploy/config.yaml
chmod 0640 deploy/config.yaml
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
chmod 0770 .runtime/data .runtime/logs
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
```
也可将 `CPR_RELEASE_TAG` 设置为指定的发布标签。Release 附带的 `compose.yaml` 默认使用该版本镜像,
配置模板和部署文件均包含在 `checksums.txt` 中;不要混用 `main` 分支模板与已发布镜像。
分别生成数据库和 Redis 密码:
```bash
@@ -60,8 +61,6 @@ openssl rand -hex 24
| `store.redis.password` | 第二个生成的 48 位十六进制密码 |
| `admin.default_password` | 管理员初始密码,至少 12 位,不能包含 `$` |
不需要创建 `.env`。管理员初始密码只在首次创建账号时使用。
### 2. 启动服务
```bash
@@ -73,13 +72,14 @@ curl -i http://127.0.0.1:8080/healthz
健康检查返回 `204 No Content` 后,打开 `http://127.0.0.1:8080`,
使用 `admin@cpr.local` 和刚设置的管理员密码登录。
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)。
### 3. 添加账号与客户端密钥
1. 在「账号」中添加 OpenAI 或 xAI 账号,完成授权或导入。
1. 在「账号」中添加账号,完成授权或导入。
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
3. 打开密钥的「使用密钥」,复制客户端配置。
@@ -93,7 +93,7 @@ curl -i http://127.0.0.1:8080/healthz
| 配置 | 值 |
| --- | --- |
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
| API Key | 管理端创建的 `sk_...` 客户端密钥 |
| API Key | 管理端创建的客户端密钥 |
可用模型以该密钥查询到的模型列表为准:
+215 -37
View File
@@ -116,7 +116,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -154,9 +154,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.18.1"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -165,22 +165,21 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.45.0"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "aws-runtime"
version = "1.9.1"
version = "1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb"
checksum = "ef47857a1d4488b528f4a5d5715fa7c3300820897824152234d3fa22b1426657"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -206,9 +205,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.144.0"
version = "1.146.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30dc8bf6baaf7d46336a0ca2c69f223d9b90d7a801fb3e28f7ea17b00dc6b1de"
checksum = "37b5ffaae346b9bd486ebdc3eb7053ec8ab8a1ad0f19a332eefeff036ed559e6"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -479,9 +478,9 @@ dependencies = [
[[package]]
name = "aws-types"
version = "1.5.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94"
checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
@@ -709,6 +708,7 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
"serde",
]
[[package]]
@@ -740,7 +740,7 @@ dependencies = [
"provider-xai",
"serde",
"serde_json",
"syn 3.0.4",
"syn 3.0.5",
"tempfile",
"thiserror",
"tokio",
@@ -749,9 +749,9 @@ dependencies = [
[[package]]
name = "combine"
version = "4.6.7"
version = "4.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"bytes",
"futures-core",
@@ -808,6 +808,16 @@ dependencies = [
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -1192,7 +1202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1291,6 +1301,21 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1383,7 +1408,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -1879,6 +1904,22 @@ dependencies = [
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1896,9 +1937,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2236,6 +2279,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe 0.1.6",
"openssl-sys",
"schannel",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@@ -2291,12 +2351,66 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-src"
version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
[[package]]
name = "outref"
version = "0.5.2"
@@ -2604,6 +2718,7 @@ dependencies = [
"base64 0.23.1",
"bytes",
"chrono",
"chrono-tz",
"cookie",
"flate2",
"futures",
@@ -2615,6 +2730,9 @@ dependencies = [
"hyper",
"hyper-rustls",
"hyper-util",
"native-tls",
"openssl",
"openssl-sys",
"reqwest",
"roxmltree",
"rustls",
@@ -2741,7 +2859,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2840,9 +2958,9 @@ dependencies = [
[[package]]
name = "redis"
version = "1.6.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f"
checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed"
dependencies = [
"arc-swap",
"arcstr",
@@ -2919,6 +3037,7 @@ dependencies = [
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
@@ -2927,9 +3046,12 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -2941,6 +3063,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -3011,7 +3134,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3032,21 +3155,21 @@ dependencies = [
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"openssl-probe 0.2.1",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.7.0",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
@@ -3130,6 +3253,19 @@ dependencies = [
"zeroize",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -3137,7 +3273,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -3205,7 +3341,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -3663,9 +3799,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.4"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
@@ -3692,6 +3828,27 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tar"
version = "0.4.46"
@@ -3713,7 +3870,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3742,7 +3899,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.5",
]
[[package]]
@@ -3811,9 +3968,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.53.1"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
@@ -3836,6 +3993,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -4184,9 +4351,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.26.0"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -4419,6 +4586,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
+16 -8
View File
@@ -14,6 +14,8 @@ pub struct GatewayConfig {
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,
@@ -34,6 +36,9 @@ impl LoadableConfig for GatewayConfig {
self.admin
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("admin"))?;
self.client
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("client"))?;
self.api
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
@@ -55,6 +60,7 @@ pub async fn run() -> Result<(), BootstrapError> {
host,
store,
admin,
client,
api,
openai,
xai,
@@ -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",
&[
+76
View File
@@ -278,6 +278,37 @@ fn config_loader_should_reject_admin_password_with_compose_interpolation() {
assert_rejected(valid_config().replace(ADMIN_PASSWORD, "unsafe$password"));
}
#[test]
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]
fn config_loader_should_reject_removed_fingerprint_section() {
assert_rejected(valid_config().replace(
@@ -310,6 +341,39 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replace("desktop_build: '8109'", "desktop_build: 'build'"));
}
#[test]
fn config_loader_should_accept_empty_and_custom_request_locations() {
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");
}
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) {
assert!(parse_config(&config).is_err());
}
@@ -332,6 +396,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");
+72 -6
View File
@@ -5,6 +5,7 @@
use std::{fmt, path::Path, sync::Arc, time::Duration};
use gateway_core::{
engine::execution::ClientKeyVerifier,
engine::probe::AccountProbe,
routing::ProviderKind,
runtime::SnapshotControl,
@@ -21,6 +22,8 @@ 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,
@@ -58,6 +61,7 @@ const WEAK_INITIAL_PASSWORDS: &[&str] = &[
];
const BACKUP_WORKER_OWNER: &str = "backup";
const DEFAULT_CLIENT_SESSION_TTL_MINUTES: u64 = 24 * 60;
/// 只用于首次幂等创建默认管理员的启动密码。
#[derive(Clone, Deserialize)]
@@ -98,6 +102,35 @@ pub struct AdminConfig {
pub default_password: InitialAdminPassword,
}
/// Client 登录域的通用启动配置。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
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>,
@@ -164,6 +198,11 @@ pub struct AdminServices {
}
impl AdminServices {
#[must_use]
pub fn key_usage(&self) -> &dyn KeyUsageService {
self.key_usage.as_ref()
}
#[must_use]
pub fn proxies(&self) -> &dyn ProxiesService {
self.proxies.as_ref()
@@ -243,6 +282,17 @@ impl AdminBundle {
}
}
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
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 +300,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 +330,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 +356,13 @@ 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 services = AdminServices {
key_usage,
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
store.proxies(),
proxy_probe,
@@ -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>,
}
@@ -86,6 +88,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 +101,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 +221,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 +238,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>,
}
@@ -6,7 +6,7 @@ use chrono::{DateTime, Utc};
use gateway_core::{
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
policy::{ClientApiKeyId, RateLimits},
policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits},
routing::{AccountGroupId, ProviderKind},
};
@@ -144,6 +144,7 @@ impl fmt::Debug for ClientKeySecret {
/// API 提交的 Client Key 创建命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub custom_key: Option<PlaintextClientApiKey>,
pub name: String,
pub label: Option<String>,
pub group_ids: Vec<AccountGroupId>,
@@ -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,7 @@ pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod key_usage;
pub mod observability;
pub mod provider_credentials;
pub mod proxies;
@@ -21,6 +22,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,
@@ -757,6 +758,24 @@ pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
})
}
/// 按需查询的订阅周期,不参与额度或账号可用性判断。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderSubscription {
pub starts_at: Option<DateTime<Utc>>,
pub expires_at: DateTime<Utc>,
pub will_renew: Option<bool>,
pub billing_period: Option<String>,
pub billing_currency: Option<String>,
pub observed_at: DateTime<Utc>,
}
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果。
#[derive(Debug, Clone, PartialEq)]
pub struct AccountPersonalInfo {
pub profile: Result<ProviderProfileStatistics, AdminError>,
pub subscription: Option<ProviderSubscription>,
}
/// Provider 官方个人资料中的累计摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileStatisticsSummary {
@@ -26,7 +26,6 @@ pub struct AccountQuotaForecast {
pub low_sample: bool,
pub incomplete_cost: bool,
pub incomplete_tokens: bool,
pub method: QuotaForecastMethod,
pub estimated_tokens: Option<u64>,
pub estimated_usd: Option<f64>,
/// 剩余估算始终属于源窗口,不随目标周期折算。
@@ -36,30 +35,12 @@ pub struct AccountQuotaForecast {
#[derive(Debug, Clone, PartialEq)]
pub struct QuotaForecastSource {
pub key: String,
pub label: String,
pub window_seconds: u64,
pub used_percent: Option<f64>,
pub observed_at: Option<DateTime<Utc>>,
pub start_at: Option<DateTime<Utc>>,
pub reset_at: DateTime<Utc>,
pub request_count: u64,
pub tokens: Option<u64>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub known_cost_count: u64,
pub partial_cost_count: u64,
pub unavailable_cost_count: u64,
pub usd: Option<f64>,
pub sample_start_at: Option<DateTime<Utc>>,
pub baseline_percent: f64,
pub sampled_percent: Option<f64>,
pub block_count: usize,
pub observation_count: usize,
pub missing_token_count: u64,
pub excluded_request_count: u64,
pub pending_request_count: u64,
}
/// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算。
@@ -88,7 +69,6 @@ pub fn account_quota_forecasts(
low_sample: false,
incomplete_cost: false,
incomplete_tokens: false,
method: QuotaForecastMethod::Cumulative,
estimated_tokens: None,
estimated_usd: None,
remaining_tokens: None,
@@ -138,30 +118,12 @@ impl AccountQuotaForecast {
.and_then(Duration::try_seconds)
.and_then(|duration| reset_at.checked_sub_signed(duration));
self.source = Some(QuotaForecastSource {
key: window.key.clone(),
label: window.label.clone(),
window_seconds: seconds,
used_percent: percent,
observed_at,
start_at: start,
reset_at,
request_count: usage.map_or(0, |usage| usage.request_count),
tokens: usage.map(|usage| usage.tokens),
input_tokens: usage.map(|usage| usage.input_tokens),
output_tokens: usage.map(|usage| usage.output_tokens),
cached_tokens: usage.map(|usage| usage.cached_tokens),
known_cost_count: usage.map_or(0, |usage| usage.known_cost_count),
partial_cost_count: 0,
unavailable_cost_count: usage.map_or(0, |usage| usage.unavailable_cost_count),
usd,
sample_start_at: sample.map(|sample| sample.start_at),
baseline_percent: sample.map_or(0.0, |sample| sample.baseline_percent),
sampled_percent: sample.map(|sample| sample.sampled_percent),
block_count: sample.map_or(0, |sample| sample.block_count),
observation_count: sample.map_or(0, |sample| sample.observation_count),
missing_token_count: usage.map_or(0, |usage| usage.missing_token_count),
excluded_request_count: usage.map_or(0, |usage| usage.excluded_request_count),
pending_request_count: sample.map_or(0, |sample| sample.pending_request_count),
});
self.incomplete_cost = usage.is_none_or(|usage| {
usage.unavailable_cost_count > 0
@@ -169,7 +131,7 @@ impl AccountQuotaForecast {
|| usage.known_cost_count != usage.request_count
});
self.incomplete_tokens = usage.is_some_and(|usage| usage.missing_token_count > 0);
self.method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method);
let method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method);
let Some(start) = start.filter(|start| *start <= now && now < reset_at) else {
self.unavailable_reason = Some("额度窗口已过期或边界无效,请刷新账号额度后重试。");
return;
@@ -183,7 +145,7 @@ impl AccountQuotaForecast {
Some("额度观测出现回落或累计记录不连续,正在重新积累配对样本。");
return;
}
if account_added_at > start && self.method == QuotaForecastMethod::Cumulative {
if account_added_at > start && method == QuotaForecastMethod::Cumulative {
self.unavailable_reason = Some(
"本周期开始时的记录不完整,正在积累至少 5 个百分点的配对观测,无需等待下次重置。",
);
@@ -210,23 +172,20 @@ impl AccountQuotaForecast {
return;
};
self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT
|| (self.method == QuotaForecastMethod::Incremental && sample.block_count < 2);
|| (method == QuotaForecastMethod::Incremental && sample.block_count < 2);
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗。
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。
let capacity_factor = 100.0 / sample.sampled_percent;
let factor = capacity_factor * self.target_seconds as f64 / seconds as f64;
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0 && !self.incomplete_tokens);
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0);
self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor));
self.estimated_usd = usd
.filter(|_| !self.incomplete_cost)
.and_then(|value| estimate(value, factor));
self.estimated_usd = usd.and_then(|value| estimate(value, factor));
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
self.remaining_tokens = tokens.and_then(|value| estimate_tokens(value, remaining_factor));
self.remaining_usd = usd
.filter(|_| !self.incomplete_cost)
.and_then(|value| estimate(value, remaining_factor));
self.remaining_usd = usd.and_then(|value| estimate(value, remaining_factor));
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
{
Some("本周期缺少可用的 Token 或完整费用记录,暂时无法预测额度。")
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
} else {
None
};
@@ -89,16 +89,6 @@ pub enum QuotaForecastMethod {
Incremental,
}
impl QuotaForecastMethod {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Cumulative => "cumulative",
Self::Incremental => "incremental",
}
}
}
#[derive(Debug, Clone)]
pub struct QuotaForecastSample {
pub key: String,
@@ -23,6 +23,9 @@ pub struct RuntimeSettings {
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>,
@@ -40,6 +43,9 @@ pub struct ReplaceRuntimeSettings {
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>,
@@ -19,7 +19,7 @@ use crate::model::provider_credentials::{
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderResetCreditResult, ProviderResetCredits, explicit_plan_type,
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
};
use crate::model::{
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
@@ -185,6 +185,14 @@ pub trait ProviderAdmin: Send + Sync {
}
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
/// 只由个人信息显式查询,不在额度刷新或后台任务中预取。
async fn subscription(
&self,
_account_id: &ProviderAccountId,
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
async fn profile_statistics(
&self,
_account_id: &ProviderAccountId,
@@ -2,7 +2,7 @@
//!
//! 端口按业务资源拆分,方法使用领域模型,不暴露连接池、事务或 Redis client。
use std::sync::Arc;
use std::{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,
@@ -44,6 +44,7 @@ pub enum AdminStoreErrorKind {
Invalid,
NotFound,
StaleRevision,
DuplicateName,
Conflict,
Unavailable,
}
@@ -194,7 +195,7 @@ pub trait AccountRuntimeStore: Send + Sync {
) -> AdminStoreResult<AccountRuntimeSnapshot>;
}
/// 管理员密码、会话和安全审计。
/// 控制面凭据、统一会话、登录限流与管理员安全审计。
#[async_trait]
pub trait AuthStore: Send + Sync {
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
@@ -207,19 +208,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(
@@ -22,9 +22,9 @@ use crate::{
},
observability::TimeRange,
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
AccountRefreshResult, ConsumeProviderResetCredit, PrepareCredentialRefresh,
ProviderModels, ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
QuotaLocalUsageAttribution,
},
@@ -90,11 +90,11 @@ pub trait AccountsService: Send + Sync {
account_id: &ProviderAccountId,
) -> Result<AccountQuotaForecastReport, AdminError>;
async fn profile_statistics(
async fn personal_info(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持账号统计"))
) -> Result<AccountPersonalInfo, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持个人信息"))
}
async fn profile_avatar(
@@ -570,7 +570,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;
}
@@ -680,15 +680,36 @@ impl AccountsService for DefaultAccountsService {
})
}
async fn profile_statistics(
async fn personal_info(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.profile_statistics(account_id)
.await
.map_err(|error| map_provider_error(error, "provider profile statistics"))
) -> Result<AccountPersonalInfo, AdminError> {
let (initial, provider) = self.provider_for_account(account_id).await?;
// 两项读取相互独立;不因其中一项失败而取消另一项,也不触发凭据或额度刷新。
let (profile, subscription) = futures::join!(
provider.profile_statistics(account_id),
provider.subscription(account_id),
);
let profile =
profile.map_err(|error| map_provider_error(error, "provider profile statistics"));
let subscription = subscription
.map_err(|error| map_provider_error(error, "provider subscription"))
.ok()
.flatten();
// 汇聚等待期间发生重新授权、换绑或删除时,不返回混合身份的数据。
let current = self.load_account(account_id).await?;
if current.account.provider_kind != initial.account.provider_kind
|| current.account.credential_revision != initial.account.credential_revision
|| current.account.upstream_user_id != initial.account.upstream_user_id
|| current.account.upstream_account_id != initial.account.upstream_account_id
{
return Err(AdminError::conflict("账号身份已变化,请刷新信息后重试"));
}
Ok(AccountPersonalInfo {
profile,
subscription,
})
}
async fn profile_avatar(
+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())
@@ -18,7 +18,7 @@ use crate::{
DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey,
},
},
ports::store::ClientKeyStore,
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
};
use super::{map_store_error, publish_committed};
@@ -87,9 +87,13 @@ impl ClientKeyService for DefaultClientKeyService {
) -> Result<CreatedClientKey, AdminError> {
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes));
let plaintext = if let Some(key) = command.custom_key {
key.expose_for_auth().to_owned()
} else {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
};
let (config_revision, record) = self
.store
.create_client_key(
@@ -105,7 +109,7 @@ impl ClientKeyService for DefaultClientKeyService {
context,
)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
.map_err(map_client_key_write_error)?;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
Ok(CreatedClientKey {
config_revision,
@@ -123,7 +127,7 @@ impl ClientKeyService for DefaultClientKeyService {
.store
.update_client_key(command, context)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
.map_err(map_client_key_write_error)?;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
Ok(ClientKeyMutation {
config_revision,
@@ -171,6 +175,14 @@ impl ClientKeyService for DefaultClientKeyService {
}
}
fn map_client_key_write_error(error: AdminStoreError) -> AdminError {
match error.kind() {
AdminStoreErrorKind::DuplicateName => AdminError::conflict("名称已存在"),
AdminStoreErrorKind::Conflict => AdminError::conflict("API Key 已存在,请使用其他密钥"),
_ => map_store_error(error, "client API key"),
}
}
fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
let Some(cursor) = &query.cursor else {
return 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,7 @@ pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod key_usage;
pub mod observability;
pub mod openai;
pub mod proxies;
@@ -42,9 +43,9 @@ fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError
let kind = match error.kind() {
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
AdminStoreErrorKind::StaleRevision | AdminStoreErrorKind::Conflict => {
AdminErrorKind::Conflict
}
AdminStoreErrorKind::StaleRevision
| AdminStoreErrorKind::DuplicateName
| AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict,
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
};
tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed");
@@ -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)
@@ -117,6 +117,9 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
let valid = 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
@@ -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(),
};
@@ -129,8 +129,8 @@ fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() {
#[test]
fn corresponding_actual_window_takes_priority_and_retains_its_duration() {
let [week, month] = forecast(&quota(vec![window("month", 31), window("week", 7)]));
assert_eq!(week.source.unwrap().key, "week");
assert_eq!(month.source.unwrap().key, "month");
assert_eq!(week.source.unwrap().label, "week");
assert_eq!(month.source.unwrap().label, "month");
assert!(!month.extrapolated);
assert_eq!(month.target_seconds, 31 * 86_400);
assert_eq!(month.estimated_tokens, Some(5_000));
@@ -241,21 +241,22 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
assert_eq!(week.estimated_tokens, Some(10_000));
assert_eq!(week.remaining_tokens, Some(8_000));
assert_eq!(month.remaining_tokens, Some(8_000));
assert_eq!(week.method, QuotaForecastMethod::Incremental);
assert!(week.unavailable_reason.is_none());
assert!(!week.low_sample);
}
#[test]
fn missing_tokens_suppress_token_estimates_without_discarding_complete_costs() {
fn missing_tokens_keep_estimates_from_recorded_usage() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
sample.usage.missing_token_count = 1;
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.incomplete_tokens);
assert_eq!(week.estimated_tokens, None);
assert_eq!(week.remaining_tokens, None);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.source.unwrap().missing_token_count, 1);
assert_eq!(week.source.unwrap().tokens, Some(1_000));
}
#[test]
@@ -269,7 +270,7 @@ fn discontinuous_samples_never_fall_back_to_cumulative_predictions() {
}
#[test]
fn partial_or_unknown_costs_suppress_only_money_estimates() {
fn partial_costs_keep_estimates_from_known_amounts() {
for coverage in [
CostCoverage {
calculated_count: 9,
@@ -281,19 +282,77 @@ fn partial_or_unknown_costs_suppress_only_money_estimates() {
unavailable_count: 1,
..Default::default()
},
CostCoverage::default(),
] {
let mut sample = window("week", 7);
sample.local_usage.as_mut().unwrap().cost_coverage = coverage;
let [week, _] = forecast(&quota(vec![sample]));
assert!(week.incomplete_cost);
assert!(week.unavailable_reason.is_none());
assert!(week.estimated_usd.is_none());
assert!(week.remaining_usd.is_none());
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_usd, Some(8.0));
assert_eq!(week.estimated_tokens, Some(5_000));
}
}
#[test]
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
sample.usage.request_count += 2;
sample.usage.missing_token_count = 2;
sample.usage.unavailable_cost_count = 2;
sample.usage.excluded_request_count = 1;
sample.pending_request_count = 1;
let [week, month] =
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.incomplete_tokens);
assert!(week.incomplete_cost);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.remaining_usd, Some(8.0));
assert_eq!(month.estimated_tokens, Some(21_429));
assert_eq!(month.remaining_tokens, week.remaining_tokens);
assert_eq!(month.remaining_usd, week.remaining_usd);
}
#[test]
fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
let mut sample = window("week", 7);
let usage = sample.local_usage.as_mut().unwrap();
usage.cost_coverage = CostCoverage {
unavailable_count: usage.request_count,
..Default::default()
};
usage.costs.clear();
let [week, _] = forecast(&quota(vec![sample]));
assert!(week.incomplete_cost);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, None);
assert_eq!(week.remaining_usd, None);
}
#[test]
fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
sample.usage = QuotaForecastUsage {
request_count: 2,
missing_token_count: 2,
unavailable_cost_count: 2,
..Default::default()
};
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert!(week.unavailable_reason.is_some());
assert_eq!(week.estimated_tokens, None);
assert_eq!(week.estimated_usd, None);
assert_eq!(week.remaining_tokens, None);
assert_eq!(week.remaining_usd, None);
}
#[test]
fn zero_known_cost_is_valid_but_missing_usd_is_not_invented() {
let mut sample = window("week", 7);
@@ -38,8 +38,10 @@ use gateway_admin::{
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileStatistics,
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow, ProviderResetCreditResult, ProviderSubscription,
QuotaLocalUsageAttribution,
},
quota_forecast_sampling::{QuotaForecastHistory, QuotaForecastUsage},
@@ -77,6 +79,9 @@ pub(super) struct FakeProviderAdmin {
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
current_credential_revision: Mutex<Revision>,
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
profile_result: Mutex<Result<ProviderProfileStatistics, ProviderAdminErrorKind>>,
subscription_result: Mutex<Result<Option<ProviderSubscription>, ProviderAdminErrorKind>>,
personal_info_barrier: Mutex<Option<Arc<tokio::sync::Barrier>>>,
}
impl FakeProviderAdmin {
@@ -97,6 +102,9 @@ impl FakeProviderAdmin {
quota_refresh_account: Mutex::new(None),
current_credential_revision: Mutex::new(revision(1)),
reset_credit_commands: Mutex::new(Vec::new()),
profile_result: Mutex::new(Ok(empty_profile_statistics())),
subscription_result: Mutex::new(Ok(None)),
personal_info_barrier: Mutex::new(None),
})
}
@@ -104,6 +112,14 @@ impl FakeProviderAdmin {
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
}
async fn wait_personal_info_queries(&self) {
let barrier = self.personal_info_barrier.lock().unwrap().clone();
if let Some(barrier) = barrier {
barrier.wait().await;
barrier.wait().await;
}
}
fn fail_next_with_message(&self, kind: ProviderAdminErrorKind, message: &str) {
*self.failure.lock().expect("provider failure") =
Some(ProviderAdminError::new(kind).with_message(message));
@@ -237,6 +253,32 @@ impl ProviderAdmin for FakeProviderAdmin {
&self.kind
}
async fn profile_statistics(
&self,
_: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
self.record("provider.profile_statistics");
self.wait_personal_info_queries().await;
self.profile_result
.lock()
.unwrap()
.clone()
.map_err(ProviderAdminError::new)
}
async fn subscription(
&self,
_: &ProviderAccountId,
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
self.record("provider.subscription");
self.wait_personal_info_queries().await;
self.subscription_result
.lock()
.unwrap()
.clone()
.map_err(ProviderAdminError::new)
}
fn plan_type_display(&self, plan_type: &str) -> String {
format!("{} display: {plan_type}", self.kind)
}
@@ -880,6 +922,9 @@ impl SettingsStore for StaticSettingsStore {
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,
@@ -1288,6 +1333,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,
@@ -1325,6 +1372,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,
@@ -1367,12 +1416,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
@@ -1825,9 +1875,7 @@ async fn quota_forecast_does_not_query_usage_without_a_current_snapshot() {
#[tokio::test]
async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_changed_bucket() {
use gateway_admin::model::quota_forecast_sampling::{
QuotaForecastHistoryPoint, QuotaForecastMethod,
};
use gateway_admin::model::quota_forecast_sampling::QuotaForecastHistoryPoint;
let now = Utc::now();
let observed = now - TimeDelta::minutes(1);
let reset = now + TimeDelta::days(1);
@@ -1884,7 +1932,7 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
assert_eq!(result.forecasts[0].method, QuotaForecastMethod::Incremental);
assert!(result.forecasts[0].unavailable_reason.is_none());
assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000));
assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000));
assert_eq!(store.quota_window_queries()[0].range.start, added);
@@ -2360,6 +2408,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"),
@@ -2402,6 +2452,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,
@@ -2431,6 +2482,167 @@ fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResul
}
}
fn empty_profile_statistics() -> ProviderProfileStatistics {
ProviderProfileStatistics {
display_name: Some("Preview user".to_owned()),
username: None,
image_url: None,
has_stats_error: false,
summary: ProviderProfileStatisticsSummary {
total_text_tokens: Some(123),
peak_tokens: None,
longest_task_duration_ms: None,
current_streak_days: None,
longest_streak_days: None,
},
daily_usage: None,
activity_insights: ProviderProfileActivityInsights {
fast_mode_percent: None,
reasoning_effort: None,
reasoning_effort_percent: None,
skills_explored: None,
total_skills_used: None,
total_threads: None,
invocations: None,
},
}
}
fn personal_info_subscription() -> ProviderSubscription {
ProviderSubscription {
starts_at: None,
expires_at: Utc::now(),
will_renew: Some(true),
billing_period: Some("monthly".to_owned()),
billing_currency: Some("USD".to_owned()),
observed_at: Utc::now(),
}
}
#[tokio::test]
async fn personal_info_should_query_both_parts_concurrently_once_on_every_request() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let subscription = personal_info_subscription();
*provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone()));
*provider.personal_info_barrier.lock().unwrap() = Some(Arc::new(tokio::sync::Barrier::new(2)));
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider, store).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
for _ in 0..2 {
let info = tokio::time::timeout(
std::time::Duration::from_secs(1),
services.accounts().personal_info(&account_id),
)
.await
.expect("两项查询应并发执行")
.unwrap();
assert_eq!(info.profile.unwrap(), empty_profile_statistics());
assert_eq!(info.subscription, Some(subscription.clone()));
}
assert_eq!(
recorded(&events),
[
"store.load_account",
"provider.profile_statistics",
"provider.subscription",
"store.load_account",
"store.load_account",
"provider.profile_statistics",
"provider.subscription",
"store.load_account",
]
);
}
#[tokio::test]
async fn personal_info_should_keep_profile_when_subscription_is_missing_or_fails() {
for subscription in [Ok(None), Err(ProviderAdminErrorKind::Unavailable)] {
let provider = FakeProviderAdmin::new("openai", events());
*provider.subscription_result.lock().unwrap() = subscription;
let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await;
let info = services
.accounts()
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
assert_eq!(info.profile.unwrap(), empty_profile_statistics());
assert_eq!(info.subscription, None);
}
}
#[tokio::test]
async fn personal_info_should_keep_subscription_when_profile_fails() {
let provider = FakeProviderAdmin::new("openai", events());
*provider.profile_result.lock().unwrap() = Err(ProviderAdminErrorKind::BadGateway);
let subscription = personal_info_subscription();
*provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone()));
let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await;
let info = services
.accounts()
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
assert_eq!(
info.profile.unwrap_err().kind(),
gateway_admin::model::AdminErrorKind::BadGateway
);
assert_eq!(info.subscription, Some(subscription));
}
#[tokio::test]
async fn personal_info_should_reject_missing_account_before_querying_provider() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
store.set_accounts(Vec::new());
let services = accounts_service(provider, store).await;
let error = services
.accounts()
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap_err();
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::NotFound);
assert_eq!(recorded(&events), ["store.load_account"]);
}
#[tokio::test]
async fn personal_info_should_discard_results_when_account_changes_during_query() {
for change in 0..5 {
let provider = FakeProviderAdmin::new("openai", events());
let barrier = Arc::new(tokio::sync::Barrier::new(3));
*provider.personal_info_barrier.lock().unwrap() = Some(barrier.clone());
let store = FakeAccountStore::new("openai", events());
let services = accounts_service(provider, store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
let mutate = async {
barrier.wait().await;
let mut accounts = store.accounts.lock().unwrap().clone();
match change {
0 => accounts[0].credential_revision = revision(99),
1 => accounts[0].upstream_account_id = Some("changed-account".to_owned()),
2 => accounts[0].upstream_user_id = Some("changed-user".to_owned()),
3 => accounts[0].provider_kind = ProviderKind::new("xai").unwrap(),
_ => accounts.clear(),
}
store.set_accounts(accounts);
barrier.wait().await;
};
let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(1), async {
tokio::join!(services.accounts().personal_info(&account_id), mutate)
})
.await
.unwrap();
let expected = if change == 4 {
gateway_admin::model::AdminErrorKind::NotFound
} else {
gateway_admin::model::AdminErrorKind::Conflict
};
assert_eq!(result.unwrap_err().kind(), expected);
}
}
async fn accounts_service(
provider: Arc<FakeProviderAdmin>,
store: Arc<FakeAccountStore>,
@@ -2532,6 +2744,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"))
}
}
@@ -1,22 +1,38 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use gateway_core::policy::ClientApiKeyId;
use chrono::Utc;
use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits};
use gateway_admin::{
model::{
AdminErrorKind, MutationContext, Revision,
AdminErrorKind, MutationActor, MutationContext, Revision,
client_keys::{
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPage,
ClientKeyPageSize, ClientKeyRecord, ClientKeySecret, ClientKeySort, ClientKeySortField,
DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
CreateClientKey, DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection,
UpdateClientKey,
},
},
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ClientKeyStore},
};
struct UnusedClientKeyStore;
#[derive(Default)]
struct TestClientKeyStore {
plaintexts: Mutex<Vec<String>>,
create_error: Option<AdminStoreErrorKind>,
update_error: Option<AdminStoreErrorKind>,
}
#[async_trait]
impl ClientKeyStore for UnusedClientKeyStore {
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 {
@@ -36,10 +52,32 @@ impl ClientKeyStore for UnusedClientKeyStore {
async fn create_client_key(
&self,
_: NewClientKey,
key: NewClientKey,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
Err(unused())
if let Some(kind) = self.create_error {
return Err(AdminStoreError::new(
kind,
"client key",
"test create failure",
));
}
self.plaintexts.lock().unwrap().push(key.plaintext);
let record = ClientKeyRecord {
id: key.id,
name: key.name,
label: key.label,
groups: Vec::new(),
provider_kinds: Vec::new(),
prefix: String::new(),
enabled: true,
limits: key.limits,
budget: Default::default(),
last_used_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
Ok((Revision::new(2).unwrap(), record))
}
async fn update_client_key(
@@ -47,6 +85,13 @@ impl ClientKeyStore for UnusedClientKeyStore {
_: UpdateClientKey,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
if let Some(kind) = self.update_error {
return Err(AdminStoreError::new(
kind,
"client key",
"test update failure",
));
}
Err(unused())
}
@@ -70,7 +115,7 @@ impl ClientKeyStore for UnusedClientKeyStore {
#[tokio::test]
async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
let services = super::AdminHarness::new()
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
.client_keys(Arc::new(TestClientKeyStore::default()))
.build()
.await;
let sort = ClientKeySort {
@@ -98,7 +143,7 @@ async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
#[tokio::test]
async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
let services = super::AdminHarness::new()
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
.client_keys(Arc::new(TestClientKeyStore::default()))
.build()
.await;
let page = services
@@ -118,6 +163,105 @@ async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
assert_eq!(page.total, 0);
}
#[tokio::test]
async fn create_preserves_migrated_keys_and_keeps_default_generation() {
let store = Arc::new(TestClientKeyStore::default());
let services = super::AdminHarness::new()
.client_keys(store.clone())
.build()
.await;
for value in [Some("q".to_owned()), Some("legacy+/=:!".repeat(1024)), None] {
let created = services
.client_keys()
.create(&mutation_context(), create_command(value.as_deref()))
.await
.unwrap();
let plaintext = created.secret.expose_for_response();
assert_eq!(store.plaintexts.lock().unwrap().last().unwrap(), plaintext);
if let Some(value) = value {
assert_eq!(plaintext, value);
} else {
assert!(plaintext.starts_with("sk_"));
assert_eq!(plaintext.len(), 46);
}
}
}
#[tokio::test]
async fn duplicate_keys_return_actionable_conflicts_without_disclosing_the_key() {
let services = super::AdminHarness::new()
.client_keys(Arc::new(TestClientKeyStore {
create_error: Some(AdminStoreErrorKind::Conflict),
..Default::default()
}))
.build()
.await;
let key = "legacy-duplicate-must-stay-private";
let error = services
.client_keys()
.create(&mutation_context(), create_command(Some(key)))
.await
.unwrap_err();
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert_eq!(error.message(), "API Key 已存在,请使用其他密钥");
assert!(!format!("{error:?}").contains(key));
}
#[tokio::test]
async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_update() {
let services = super::AdminHarness::new()
.client_keys(Arc::new(TestClientKeyStore {
create_error: Some(AdminStoreErrorKind::DuplicateName),
update_error: Some(AdminStoreErrorKind::DuplicateName),
..Default::default()
}))
.build()
.await;
let created = services
.client_keys()
.create(&mutation_context(), create_command(None))
.await
.unwrap_err();
let updated = services
.client_keys()
.update(
&mutation_context(),
UpdateClientKey {
id: ClientApiKeyId::new("key_existing").unwrap(),
name: "Migration".to_owned(),
label: None,
group_ids: Vec::new(),
limits: RateLimits::unlimited(),
daily_limit_usd: None,
weekly_limit_usd: None,
},
)
.await
.unwrap_err();
for error in [created, updated] {
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert_eq!(error.message(), "名称已存在");
}
}
fn create_command(key: Option<&str>) -> CreateClientKey {
CreateClientKey {
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
name: "Migration".to_owned(),
label: None,
group_ids: Vec::new(),
limits: RateLimits::unlimited(),
budget: Default::default(),
}
}
fn mutation_context() -> MutationContext {
MutationContext {
actor: MutationActor::System,
request_id: "custom-key-test".to_owned(),
}
}
fn unused() -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
@@ -1,6 +1,7 @@
mod account_groups;
mod accounts;
mod auth;
mod auth_key;
mod backup;
mod client_keys;
mod observability;
@@ -19,7 +20,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 +32,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,7 +72,10 @@ 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},
@@ -81,6 +85,7 @@ use gateway_core::{
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 +98,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 +107,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 +123,7 @@ impl AdminHarness {
],
probe: Arc::new(UnavailableProbe),
system: Arc::new(UnavailableSystem),
client_key_verifier: Arc::new(UnavailableClientKeyVerifier),
}
}
@@ -154,6 +162,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
@@ -201,6 +219,9 @@ impl AdminHarness {
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,11 +235,15 @@ 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")
@@ -268,18 +293,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 +329,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]
@@ -480,6 +530,13 @@ impl AccountRuntimeStore for UnavailableStore {
#[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"))
}
@@ -759,6 +759,9 @@ impl SettingsStore for FixtureSettingsStore {
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,
@@ -66,6 +66,9 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
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,
@@ -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)]
#[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,9 +56,11 @@ 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)?,
})
}
@@ -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)?,
})
}
@@ -1,11 +1,13 @@
//! 账号管理路由与 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()
.route("/api/admin/accounts", get(list_accounts::<S>))
@@ -23,12 +25,12 @@ where
)
.route("/api/admin/accounts/quota", get(account_quota::<S>))
.route(
"/api/admin/accounts/quota-forecast",
get(account_quota_forecast::<S>),
"/api/admin/accounts/personal-info",
get(account_personal_info::<S>),
)
.route(
"/api/admin/accounts/profile-statistics",
get(account_profile_statistics::<S>),
"/api/admin/accounts/quota-forecast",
get(account_quota_forecast::<S>),
)
.route(
"/api/admin/accounts/profile-avatar",
@@ -67,7 +69,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 +90,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 +111,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
@@ -130,7 +132,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 +155,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 +183,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 +217,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 +251,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 +274,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 +295,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 +317,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 +336,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 +355,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 +376,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
@@ -389,24 +391,24 @@ where
))
}
async fn account_profile_statistics<S>(
async fn account_personal_info<S>(
_auth: AdminAuth,
State(state): State<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
.admin_services()
.accounts()
.profile_statistics(&account_id)
.personal_info(&account_id)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountProfileStatisticsData::from(result)),
AdminEnvelope::ok(AccountPersonalInfoData::from(result)),
))
}
@@ -416,7 +418,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 +481,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 +502,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 +523,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 +544,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 +563,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 +582,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
@@ -25,14 +25,15 @@ use gateway_admin::model::{
AccountWeight, AccountsUpdateResult, BatchUpdateAccounts, SortDirection, UpdateAccount,
},
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
AccountRefreshResult, AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult,
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
ProviderDocument, ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar,
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaWindow, ProviderResetCredit,
ProviderResetCreditResult, ProviderResetCredits, RotateCredential, StartAuthorization,
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, RotateCredential,
StartAuthorization,
},
};
use gateway_core::{
@@ -45,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;
@@ -74,8 +75,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;
@@ -2,7 +2,6 @@
use super::*;
use gateway_admin::model::quota_forecast::{AccountQuotaForecast, AccountQuotaForecastReport};
use gateway_admin::model::quota_forecast_sampling::QuotaForecastMethod;
pub(super) fn account_page_data(
result: AccountDirectoryPage,
@@ -76,6 +75,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
AccountView {
id: account.id.clone(),
name: account.name,
notes: account.notes,
provider: account.provider_kind.to_string(),
groups: account
.groups
@@ -104,6 +104,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()
@@ -129,7 +130,6 @@ impl From<AccountQuotaForecastReport> for AccountQuotaForecastData {
Self {
account_id: report.account_id,
generated_at: china_rfc3339(&report.generated_at),
generated_at_display: china_datetime(&report.generated_at),
forecasts: report
.forecasts
.into_iter()
@@ -148,9 +148,7 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
target_days: forecast.target_seconds as f64 / 86_400.0,
extrapolated: forecast.extrapolated,
source: forecast.source.map(|source| QuotaForecastSourceView {
key: source.key,
label: source.label,
window_days: source.window_seconds as f64 / 86_400.0,
used_percent: source.used_percent,
used_percent_display: source
.used_percent
@@ -159,48 +157,14 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
observed_at_display: source
.observed_at
.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
start_at: source.start_at.map(|value| china_rfc3339(&value)),
start_at_display: source
.start_at
.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
reset_at: china_rfc3339(&source.reset_at),
reset_at_display: china_datetime(&source.reset_at),
request_count_display: format_number(source.request_count),
tokens_display: display_optional_tokens(source.tokens),
input_tokens_display: display_optional_tokens(source.input_tokens),
output_tokens_display: display_optional_tokens(source.output_tokens),
cached_tokens_display: display_optional_tokens(source.cached_tokens),
known_cost_count: source.known_cost_count,
known_cost_count_display: format_number(source.known_cost_count),
partial_cost_count: source.partial_cost_count,
partial_cost_count_display: format_number(source.partial_cost_count),
unavailable_cost_count: source.unavailable_cost_count,
unavailable_cost_count_display: format_number(source.unavailable_cost_count),
usd_display: forecast_usd_display(source.usd),
sample_start_at: source.sample_start_at.map(|value| china_rfc3339(&value)),
sample_start_at_display: source
.sample_start_at
.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
baseline_percent: source.baseline_percent,
sampled_percent: source.sampled_percent,
sampled_percent_display: source
.sampled_percent
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1} 个百分点")),
block_count: source.block_count,
observation_count: source.observation_count,
missing_token_count: source.missing_token_count,
excluded_request_count: source.excluded_request_count,
pending_request_count: source.pending_request_count,
}),
unavailable_reason: forecast.unavailable_reason,
low_sample: forecast.low_sample,
incomplete_cost: forecast.incomplete_cost,
incomplete_tokens: forecast.incomplete_tokens,
method: forecast.method.as_str(),
method_display: match forecast.method {
QuotaForecastMethod::Cumulative => "窗口累计估算",
QuotaForecastMethod::Incremental => "近期分段估算",
},
estimated_tokens: forecast.estimated_tokens,
estimated_tokens_display: display_optional_tokens(forecast.estimated_tokens),
estimated_usd: forecast.estimated_usd,
@@ -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>,
@@ -278,7 +309,6 @@ pub struct AccountView {
pub struct AccountQuotaForecastData {
pub account_id: String,
pub generated_at: String,
pub generated_at_display: String,
pub forecasts: Vec<AccountQuotaForecastView>,
}
@@ -293,8 +323,6 @@ pub struct AccountQuotaForecastView {
pub low_sample: bool,
pub incomplete_cost: bool,
pub incomplete_tokens: bool,
pub method: &'static str,
pub method_display: &'static str,
pub estimated_tokens: Option<u64>,
pub estimated_tokens_display: String,
pub estimated_usd: Option<f64>,
@@ -308,39 +336,14 @@ pub struct AccountQuotaForecastView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QuotaForecastSourceView {
pub key: String,
pub label: String,
pub window_days: f64,
pub used_percent: Option<f64>,
pub used_percent_display: String,
pub observed_at: Option<String>,
pub observed_at_display: String,
pub start_at: Option<String>,
pub start_at_display: String,
pub reset_at: String,
pub reset_at_display: String,
pub request_count_display: String,
pub tokens_display: String,
pub input_tokens_display: String,
pub output_tokens_display: String,
pub cached_tokens_display: String,
pub known_cost_count: u64,
pub known_cost_count_display: String,
pub partial_cost_count: u64,
pub partial_cost_count_display: String,
pub unavailable_cost_count: u64,
pub unavailable_cost_count_display: String,
pub usd_display: String,
pub sample_start_at: Option<String>,
pub sample_start_at_display: String,
pub baseline_percent: f64,
pub sampled_percent: Option<f64>,
pub sampled_percent_display: String,
pub block_count: usize,
pub observation_count: usize,
pub missing_token_count: u64,
pub excluded_request_count: u64,
pub pending_request_count: u64,
}
#[derive(Debug, Clone, Serialize)]
@@ -681,6 +684,54 @@ pub struct AccountQuotaData {
pub account: AccountView,
}
/// 个人资料、累计统计与订阅的统一响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountPersonalInfoData {
pub profile: Option<AccountProfileStatisticsData>,
pub profile_error: Option<String>,
pub subscription: Option<AccountSubscriptionData>,
}
impl From<AccountPersonalInfo> for AccountPersonalInfoData {
fn from(info: AccountPersonalInfo) -> Self {
let (profile, profile_error) = match info.profile {
Ok(profile) => (Some(AccountProfileStatisticsData::from(profile)), None),
Err(error) => (None, Some(error.message().to_owned())),
};
Self {
profile,
profile_error,
subscription: info.subscription.map(AccountSubscriptionData::from),
}
}
}
/// 按需读取的订阅安全字段,不暴露原始上游响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountSubscriptionData {
pub starts_at: Option<String>,
pub expires_at: String,
pub will_renew: Option<bool>,
pub billing_period: Option<String>,
pub billing_currency: Option<String>,
pub observed_at: String,
}
impl From<ProviderSubscription> for AccountSubscriptionData {
fn from(subscription: ProviderSubscription) -> Self {
Self {
starts_at: subscription.starts_at.map(|value| value.to_rfc3339()),
expires_at: subscription.expires_at.to_rfc3339(),
will_renew: subscription.will_renew,
billing_period: subscription.billing_period,
billing_currency: subscription.billing_currency,
observed_at: subscription.observed_at.to_rfc3339(),
}
}
}
/// Provider 官方个人资料统计响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
+14 -196
View File
@@ -1,31 +1,17 @@
//! 管理员登录、会话与管理请求鉴权接线。
use std::fmt;
//! 管理端权限校验与请求审计上下文。
use axum::{
Router,
extract::{FromRequestParts, State},
http::{HeaderMap, HeaderValue, StatusCode, header::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 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";
const ADMIN_SESSION_COOKIE_ATTRS: &str = "Path=/; Secure; HttpOnly; SameSite=Lax";
/// 所有管理 HTTP 模块从 state 消费同一个认证用例端口。
pub trait AdminSessionState {
fn admin_services(&self) -> &AdminServices;
}
/// 已通过管理员会话或部署级管理 API Key 鉴权的请求。
pub struct AdminAuth {
@@ -41,7 +27,7 @@ impl AdminAuth {
impl<S> FromRequestParts<S> for AdminAuth
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
type Rejection = AdminError;
@@ -74,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
@@ -101,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)),
};
}
@@ -114,171 +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>,
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}={}; {ADMIN_SESSION_COOKIE_ATTRS}",
session.session_id
);
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}=; {ADMIN_SESSION_COOKIE_ATTRS}; Max-Age=0");
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};
@@ -13,7 +15,7 @@ use gateway_admin::model::client_keys::{
use gateway_core::{
engine::budget::ClientBudgetLimits,
metering::Decimal,
policy::{ClientApiKeyId, RateLimits},
policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits},
routing::AccountGroupId,
};
use serde::{Deserialize, Serialize};
@@ -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,
};
@@ -70,9 +72,7 @@ impl ListClientKeysQuery {
}
let search = self.search.map(|search| search.trim().to_owned());
if search.as_deref().is_some_and(|search| {
search.len() > MAX_SEARCH_BYTES
|| search.chars().any(char::is_control)
|| contains_client_key_material(search)
search.len() > MAX_SEARCH_BYTES || search.chars().any(char::is_control)
}) {
return Err(WireValidationError::new("search"));
}
@@ -143,9 +143,10 @@ impl ClientKeySort {
}
/// 创建 Client Key 请求。
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[derive(Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateClientKeyRequest {
custom_key: Option<String>,
name: String,
label: Option<String>,
group_ids: Vec<String>,
@@ -155,6 +156,16 @@ pub struct CreateClientKeyRequest {
weekly_limit_usd: Option<String>,
}
impl fmt::Debug for CreateClientKeyRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CreateClientKeyRequest")
.field("name", &self.name)
.field("custom_key", &"[REDACTED]")
.finish_non_exhaustive()
}
}
impl CreateClientKeyRequest {
/// 校验 wire 边界并直接构造管理用例命令。
pub fn into_command(self) -> Result<CreateClientKey, WireValidationError> {
@@ -164,6 +175,12 @@ impl CreateClientKeyRequest {
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(CreateClientKey {
custom_key: self
.custom_key
.filter(|key| !key.is_empty())
.map(PlaintextClientApiKey::new)
.transpose()
.map_err(|_| WireValidationError::new("customKey"))?,
name: self.name,
label: self.label,
group_ids,
@@ -661,19 +678,10 @@ fn validate_optional_text(
Ok(())
}
fn contains_client_key_material(value: &str) -> bool {
value.as_bytes().windows(46).any(|window| {
&window[..3] == b"sk_"
&& window[3..]
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
})
}
/// 构造固定 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>))
@@ -706,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()
@@ -724,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
@@ -745,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
@@ -771,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(
@@ -789,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(
@@ -810,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(
@@ -831,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
+12 -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},
};
@@ -170,7 +171,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 +204,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 +238,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 +296,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
@@ -325,7 +326,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 +350,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
@@ -385,7 +386,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 +410,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 +434,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,
};
@@ -37,6 +39,9 @@ pub struct RuntimeSettingsView {
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>,
@@ -55,6 +60,9 @@ pub struct UpdateRuntimeSettingsRequest {
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>,
@@ -67,6 +75,17 @@ impl UpdateRuntimeSettingsRequest {
/// 校验公共运行参数。
pub fn validate(&self) -> Result<(), WireValidationError> {
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"),
@@ -104,6 +123,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,
@@ -126,6 +148,9 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
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,
@@ -228,7 +253,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 +282,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 +300,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 +320,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 +340,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 +359,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 +380,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()
+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, "每页数量不合法")
})?,
})
}
}
+6 -1
View File
@@ -29,8 +29,11 @@ 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)]
@@ -136,6 +139,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 +230,7 @@ impl ApiState {
}
}
impl admin::AdminSessionState for ApiState {
impl auth::SessionState for ApiState {
fn admin_services(&self) -> &AdminServices {
&self.admin
}
@@ -86,7 +86,7 @@ impl ClientApiKeyAuthError {
///
/// # Errors
///
/// Header 缺失、Bearer 语法错误或不是网关 Client Key 前缀时返回稳定错误。
/// Header 缺失、Bearer 语法错误或 Key 不能作为 HTTP Bearer 值时返回稳定错误。
pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAuthError> {
let raw = headers
.get(AUTHORIZATION)
@@ -100,7 +100,7 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu
if token.is_empty() {
return Err(ClientApiKeyAuthError::MalformedAuthorization);
}
if !token.starts_with("sk_") {
if gateway_core::policy::PlaintextClientApiKey::validate(token).is_err() {
return Err(ClientApiKeyAuthError::InvalidKeyFormat);
}
Ok(token)
@@ -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",
@@ -6,7 +6,7 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use gateway_core::routing::{PublicModelId, PublicModelProfile};
use gateway_core::routing::{PublicModelDescriptor, PublicModelId, PublicModelProfile};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -14,7 +14,7 @@ use crate::ApiState;
use super::{
auth::{authenticate_client, client_access_error_response},
error::model_not_found_response,
error::{model_not_found_response, openai_error_response},
};
const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000;
@@ -37,19 +37,53 @@ pub(crate) async fn models(
Err(error) => return client_access_error_response(error),
};
if query
if let Some(version) = query
.client_version
.as_deref()
.is_some_and(|version| !version.trim().is_empty())
.filter(|version| !version.trim().is_empty())
{
let models = service
.public_model_profiles(&client)
if version.len() > 64
|| !version
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b".-+".contains(&byte))
{
return openai_error_response(
StatusCode::BAD_REQUEST,
"Invalid client_version",
"invalid_request_error",
"invalid_client_version",
)
.into_response();
}
let catalog = match service.client_model_catalog(&client, version).await {
Ok(catalog) => catalog,
Err(_) => return catalog_unavailable_response(),
};
let models = catalog
.iter()
.enumerate()
.map(|(index, profile)| codex_model_json(profile, index))
.collect::<Vec<_>>();
.map(|(index, entry)| match entry {
PublicModelDescriptor::Adapted(profile) => Ok(codex_model_json(profile, index)),
PublicModelDescriptor::Native { model, payload } => {
if payload.protocol() != "codex" {
return Err(());
}
let mut value: Value =
serde_json::from_slice(payload.body()).map_err(|_| ())?;
let object = value.as_object_mut().ok_or(())?;
// 别名只改变请求标识,能力、提示词、顺序等仍由上游目录决定。
object.insert("slug".to_owned(), Value::String(model.as_str().to_owned()));
Ok(value)
}
})
.collect::<Result<Vec<_>, ()>>();
let Ok(models) = models else {
return catalog_unavailable_response();
};
return (
StatusCode::OK,
// 目录经过账号选择/别名/多 Provider 聚合,不能借用上游 ETag 表示改写后的正文。
[("cache-control", "private, no-store")],
Json(json!({
"models": models,
})),
@@ -72,6 +106,16 @@ pub(crate) async fn models(
.into_response()
}
fn catalog_unavailable_response() -> Response {
openai_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Model catalog is temporarily unavailable",
"server_error",
"model_catalog_unavailable",
)
.into_response()
}
/// `GET /v1/models/{model_id}`。
pub(crate) async fn model_detail(
State(state): State<ApiState>,
@@ -2,7 +2,9 @@
use bytes::Bytes;
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent};
use gateway_protocol::openai::sse::encode_sse_event_with_metadata;
use gateway_protocol::openai::sse::{
encode_sse_event_with_metadata, response_failed_sse_data_from_error_event,
};
use serde_json::Value;
use super::error::ResponseEncodeError;
@@ -17,6 +19,7 @@ const OPENAI_PROTOCOL: &str = "openai";
#[derive(Debug, Default)]
pub struct OpenAiResponsesEncoder {
response_id: Option<String>,
response_snapshot: Option<Value>,
wire_terminal: Option<Value>,
wire_failure: bool,
}
@@ -27,6 +30,7 @@ impl OpenAiResponsesEncoder {
pub const fn new() -> Self {
Self {
response_id: None,
response_snapshot: None,
wire_terminal: None,
wire_failure: false,
}
@@ -39,6 +43,22 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因。
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`。
if wire.event_type() == Some("error")
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
)
{
return vec![Bytes::from(encode_sse_event_with_metadata(
"response.failed",
&data.to_string(),
wire.sse_id(),
wire.sse_retry(),
))];
}
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
return vec![raw_sse_frame.clone()];
}
@@ -57,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()]
}
@@ -110,6 +145,16 @@ impl OpenAiResponsesEncoder {
let effective_type = wire
.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str));
if matches!(
effective_type,
Some("response.created" | "response.in_progress" | "response.queued")
) && let Some(response) = wire
.data()
.get("response")
.filter(|value| value.is_object())
{
self.response_snapshot = Some(response.clone());
}
if matches!(
effective_type,
Some("response.completed" | "response.incomplete")
@@ -126,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))
}
@@ -10,7 +10,7 @@ use gateway_core::engine::execution::{
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
use gateway_core::routing::{PublicModelId, PublicModelProfile};
use gateway_core::routing::{ProviderCatalogUnavailable, PublicModelDescriptor, PublicModelId};
use uuid::Uuid;
use super::auth::ClientApiKeyAuthError;
@@ -52,11 +52,14 @@ impl OpenAiService {
.collect()
}
pub(crate) fn public_model_profiles(
pub(crate) async fn client_model_catalog(
&self,
client: &AuthenticatedClient,
) -> Vec<PublicModelProfile> {
self.execution.public_model_profiles(client)
client_version: &str,
) -> Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable> {
self.execution
.client_model_catalog(client, "codex", client_version)
.await
}
pub(crate) fn contains_public_model(
@@ -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() {
@@ -7,6 +7,42 @@ use tower::ServiceExt as _;
use super::super::{AdminTestFixture, AdminTestState};
#[tokio::test]
async fn personal_info_requires_admin_and_a_valid_account_query() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (query, authenticated, expected) in [
("?accountId=acct_test", false, StatusCode::UNAUTHORIZED),
("", true, StatusCode::BAD_REQUEST),
("?accountId=bad", true, StatusCode::BAD_REQUEST),
(
"?accountId=acct_test&refresh=true",
true,
StatusCode::BAD_REQUEST,
),
// 此夹具未提供账号 Store,合法查询应透传服务不可用,而非绕过查询。
(
"?accountId=acct_test",
true,
StatusCode::SERVICE_UNAVAILABLE,
),
] {
let mut request = Request::builder()
.uri(format!("/api/admin/accounts/personal-info{query}"))
.header("x-request-id", "req_personal_info");
if authenticated {
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = admin::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), expected, "{query}");
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
}
}
#[tokio::test]
async fn quota_forecast_requires_admin_and_valid_account_query() {
let fixture = AdminTestFixture::new().await;
@@ -37,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())
@@ -1,6 +1,77 @@
mod handlers;
mod presenter;
mod personal_info {
use chrono::{TimeZone as _, Utc};
use gateway_admin::model::{
AdminError,
provider_credentials::{AccountPersonalInfo, ProviderSubscription},
};
use gateway_api::admin::accounts::{AccountPersonalInfoData, AccountSubscriptionData};
use serde_json::json;
#[test]
fn subscription_response_exposes_only_display_fields_and_preserves_unknowns() {
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
let response = AccountSubscriptionData::from(ProviderSubscription {
starts_at: None,
expires_at: observed,
will_renew: None,
billing_period: None,
billing_currency: Some("USD".to_owned()),
observed_at: observed,
});
assert_eq!(
serde_json::to_value(response).unwrap(),
json!({
"startsAt": null, "expiresAt": "2026-09-14T00:00:00+00:00",
"willRenew": null, "billingPeriod": null, "billingCurrency": "USD",
"observedAt": "2026-09-14T00:00:00+00:00"
})
);
}
#[test]
fn personal_info_response_preserves_subscription_when_profile_fails() {
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
profile: Err(AdminError::bad_gateway("上游服务请求失败")),
subscription: Some(ProviderSubscription {
starts_at: None,
expires_at: observed,
will_renew: None,
billing_period: None,
billing_currency: None,
observed_at: observed,
}),
});
let value = serde_json::to_value(response).unwrap();
assert!(value["profile"].is_null());
assert_eq!(value["profileError"], "上游服务请求失败");
assert_eq!(
value["subscription"]["expiresAt"],
"2026-09-14T00:00:00+00:00"
);
assert_eq!(value.as_object().unwrap().len(), 3);
}
#[test]
fn personal_info_response_preserves_unknown_subscription_and_profile_error() {
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
profile: Err(AdminError::unavailable("Provider 服务暂不可用")),
subscription: None,
});
assert_eq!(
serde_json::to_value(response).unwrap(),
json!({
"profile": null,
"profileError": "Provider 服务暂不可用",
"subscription": null,
})
);
}
}
mod query {
use gateway_admin::model::accounts::{AccountSortField, AccountStatus, SortDirection};
use gateway_api::admin::accounts::ListQuery;
@@ -248,31 +319,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"),
@@ -280,36 +349,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]
@@ -336,6 +389,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;
@@ -774,6 +864,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": []});
@@ -794,6 +887,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 =
@@ -1,48 +1,28 @@
use gateway_admin::model::{
provider_credentials::AccountUsagePeriod,
quota_forecast::{AccountQuotaForecast, AccountQuotaForecastReport, QuotaForecastSource},
quota_forecast_sampling::QuotaForecastMethod,
};
use gateway_api::admin::accounts::AccountQuotaForecastData;
#[test]
fn quota_forecast_projection_preserves_null_zero_and_sample_metadata() {
fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
let now = "2026-09-12T00:00:00Z".parse().unwrap();
let forecast = AccountQuotaForecast {
period: AccountUsagePeriod::Weekly,
target_seconds: 7 * 86_400,
extrapolated: false,
source: Some(QuotaForecastSource {
key: "week".to_owned(),
label: "周额度".to_owned(),
window_seconds: 7 * 86_400,
used_percent: Some(100.0),
observed_at: Some(now),
start_at: Some(now),
reset_at: now,
request_count: 1_024,
tokens: Some(1_000_000),
input_tokens: Some(900_000),
output_tokens: Some(100_000),
cached_tokens: Some(800_000),
known_cost_count: 1_000,
partial_cost_count: 24,
unavailable_cost_count: 0,
usd: Some(0.1234),
sample_start_at: Some(now),
baseline_percent: 85.0,
sampled_percent: Some(15.0),
block_count: 3,
observation_count: 96,
missing_token_count: 0,
excluded_request_count: 4,
pending_request_count: 2,
}),
unavailable_reason: None,
low_sample: false,
incomplete_cost: true,
incomplete_tokens: false,
method: QuotaForecastMethod::Incremental,
estimated_tokens: Some(1_000_000),
estimated_usd: None,
remaining_tokens: Some(0),
@@ -66,13 +46,22 @@ fn quota_forecast_projection_preserves_null_zero_and_sample_metadata() {
assert!(week["estimatedUsd"].is_null());
assert_eq!(week["estimatedUsdDisplay"], "—");
assert_eq!(week["remainingTokensDisplay"], "0");
assert_eq!(week["source"]["requestCountDisplay"], "1,024");
assert_eq!(week["source"]["usdDisplay"], "$0.1234");
assert_eq!(week["source"]["partialCostCount"], 24);
assert_eq!(week["method"], "incremental");
assert_eq!(week["source"]["sampledPercentDisplay"], "15.0 个百分点");
assert_eq!(week["source"]["pendingRequestCount"], 2);
assert!(week["source"].get("providerData").is_none());
assert_eq!(
week["source"],
serde_json::json!({
"label": "周额度",
"usedPercent": 100.0,
"usedPercentDisplay": "100.0%",
"observedAt": "2026-09-12T08:00:00+08:00",
"observedAtDisplay": "2026-09-12 08:00:00",
"resetAt": "2026-09-12T08:00:00+08:00",
"tokensDisplay": "1M",
"usdDisplay": "$0.1234"
})
);
assert!(week.get("method").is_none());
assert!(week.get("methodDisplay").is_none());
assert!(value.get("generatedAtDisplay").is_none());
assert_eq!(value["forecasts"][1]["period"], "monthly");
assert_eq!(value["forecasts"][1]["targetDays"], 30.0);
assert_eq!(value["forecasts"][1]["extrapolated"], true);
+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
@@ -10,6 +10,71 @@ use serde_json::json;
use super::{AdminTestFixture, AdminTestState};
#[test]
fn custom_client_keys_preserve_migrated_values_and_redact_debug() {
for value in [
"q".to_owned(),
"sk-old!@/key+=value".to_owned(),
"x".repeat(8192),
] {
let request: CreateClientKeyRequest = serde_json::from_value(json!({
"name": "migration", "customKey": value, "groupIds": [],
"maxConcurrency": 2, "requestsPerMinute": 0
}))
.unwrap();
let debug = format!("{request:?}");
assert!(debug.contains("[REDACTED]"));
let command = request.into_command().unwrap();
assert_eq!(command.custom_key.unwrap().expose_for_auth(), value);
}
let secret = "legacy-key-should-not-leak";
let request: CreateClientKeyRequest = serde_json::from_value(json!({
"name": "migration", "customKey": secret, "groupIds": [],
"maxConcurrency": 0, "requestsPerMinute": 0
}))
.unwrap();
assert!(!format!("{request:?}").contains(secret));
assert!(!format!("{:?}", request.into_command().unwrap()).contains(secret));
}
#[test]
fn custom_client_key_is_optional_and_only_rejects_untransportable_values() {
let payload = json!({"name": "migration", "groupIds": [], "maxConcurrency": 0,
"requestsPerMinute": 0});
for value in [None, Some(json!(null)), Some(json!(""))] {
let mut payload = payload.clone();
if let Some(value) = value {
payload["customKey"] = value;
}
let command = serde_json::from_value::<CreateClientKeyRequest>(payload)
.unwrap()
.into_command()
.unwrap();
assert!(command.custom_key.is_none());
}
for value in [
" key",
"key ",
"key with spaces",
"key\n",
"key\tvalue",
"密钥",
] {
let mut payload = payload.clone();
payload["customKey"] = json!(value);
let error = serde_json::from_value::<CreateClientKeyRequest>(payload)
.unwrap()
.into_command()
.unwrap_err();
assert_eq!(error.field(), "customKey");
assert!(!format!("{error:?}").contains(value));
}
let mut update = payload;
update["id"] = json!("key_existing");
update["customKey"] = json!("replacement-credential");
assert!(serde_json::from_value::<UpdateClientKeyRequest>(update).is_err());
}
#[test]
fn budget_inputs_preserve_decimal_precision_and_omitted_updates() {
let payload = json!({"name": "budget", "groupIds": [], "maxConcurrency": 3,
@@ -63,10 +128,6 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
"search": "a".repeat(257)
}))
.expect("deserialize oversized search");
let secret_search = serde_json::from_value::<ListClientKeysQuery>(json!({
"search": format!("sk_{}", "a".repeat(43))
}))
.expect("deserialize secret search");
let invalid_sort = serde_json::from_value::<ListClientKeysQuery>(json!({
"sortBy": "plaintextKey",
"sortDirection": "asc"
@@ -92,13 +153,6 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
.field(),
"search"
);
assert_eq!(
secret_search
.into_command()
.expect_err("reject full key in search")
.field(),
"search"
);
assert_eq!(
invalid_sort
.into_command()
@@ -140,6 +194,16 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
);
}
#[test]
fn client_key_name_search_does_not_interpret_names_as_credentials() {
let name = format!("sk_{}", "a".repeat(43));
let command = serde_json::from_value::<ListClientKeysQuery>(json!({ "search": name }))
.unwrap()
.into_command()
.unwrap();
assert_eq!(command.search.as_deref(), Some(name.as_str()));
}
#[test]
fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
let valid = serde_json::from_value::<CreateClientKeyRequest>(json!({
@@ -331,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"),
@@ -372,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,
@@ -473,6 +469,8 @@ mod provider {
};
AccountPageItem {
account: AccountRecord {
notes: None,
model_access: Default::default(),
id: "acct_error_test".to_owned(),
provider_kind: ProviderKind::new(provider).unwrap(),
groups: Vec::new(),
+136 -27
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"));
@@ -357,6 +408,9 @@ impl SettingsStore for MemorySettingsStore {
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,
@@ -395,7 +449,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";
@@ -636,6 +700,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 +779,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 +789,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(
@@ -885,10 +970,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 +989,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 +1011,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 +1034,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,
@@ -1130,6 +1236,9 @@ fn test_runtime_settings() -> RuntimeSettings {
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,
@@ -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"),
@@ -184,7 +184,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");
@@ -45,6 +45,9 @@ 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",
@@ -104,6 +107,9 @@ 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()),
@@ -128,6 +134,9 @@ 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",
@@ -176,6 +185,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,
@@ -379,7 +391,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 +399,26 @@ 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();
}
@@ -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,
),
@@ -54,7 +54,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 +80,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();
@@ -104,7 +109,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());
}
}
+3
View File
@@ -1,4 +1,7 @@
mod admin;
mod architecture;
mod auth;
mod health;
mod key_usage;
mod openai;
mod support;
@@ -76,10 +76,28 @@ fn bearer_client_api_key_should_reject_empty_bearer_token() {
}
#[test]
fn bearer_client_api_key_should_reject_non_gateway_key_prefix() {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer xai-secret"));
fn bearer_client_api_key_should_accept_migrated_formats_without_rewriting() {
for key in [
"q".to_owned(),
"sk-old/key+value=:!@".to_owned(),
"x".repeat(8192),
] {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {key}")).unwrap(),
);
assert_eq!(bearer_client_api_key(&headers), Ok(key.as_str()));
}
}
#[test]
fn bearer_client_api_key_should_reject_whitespace_inside_tokens() {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer key with spaces"),
);
assert_eq!(
bearer_client_api_key(&headers),
Err(ClientApiKeyAuthError::InvalidKeyFormat)
@@ -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,32 @@ 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 {
gateway_api::initialize(
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(),
},
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 +292,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,
+144 -12
View File
@@ -1,4 +1,4 @@
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use axum::{
body::{Body, to_bytes},
@@ -11,7 +11,8 @@ use gateway_core::engine::execution::{
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::routing::{
ModelPresentation, ModelServiceTier, PublicModelId, PublicModelProfile,
ModelPresentation, ModelServiceTier, ProviderCatalogUnavailable, PublicModelDescriptor,
PublicModelId, PublicModelProfile,
};
use tower::ServiceExt;
@@ -20,6 +21,8 @@ use super::{api_router, authenticated_client, authenticated_client_with_min_vers
pub(super) struct ModelsExecution {
client: AuthenticatedClient,
presentation: Option<ModelPresentation>,
native: Option<Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>>,
requested_versions: Mutex<Vec<String>>,
}
impl ModelsExecution {
@@ -27,6 +30,8 @@ impl ModelsExecution {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
presentation: None,
native: None,
requested_versions: Mutex::default(),
})
}
@@ -61,6 +66,8 @@ impl ModelsExecution {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
presentation: Some(presentation),
native: None,
requested_versions: Mutex::default(),
})
}
@@ -68,6 +75,8 @@ impl ModelsExecution {
Arc::new(Self {
client: authenticated_client_with_min_versions("sk_models_test", None, Some("0.40.0")),
presentation: None,
native: None,
requested_versions: Mutex::default(),
})
}
@@ -79,11 +88,48 @@ impl ModelsExecution {
None,
),
presentation: None,
native: None,
requested_versions: Mutex::default(),
})
}
fn profiles(&self) -> Vec<PublicModelProfile> {
self.presentation
.as_ref()
.map(|presentation| {
PublicModelProfile::new(
PublicModelId::new("grok-4.5").expect("model"),
presentation.clone(),
)
})
.into_iter()
.collect()
}
}
impl ExecutionService for ModelsExecution {
fn client_model_catalog<'a>(
&'a self,
_client: &'a AuthenticatedClient,
protocol: &'a str,
version: &'a str,
) -> BoxFuture<'a, Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>> {
Box::pin(async move {
assert_eq!(protocol, "codex");
self.requested_versions
.lock()
.expect("versions")
.push(version.to_owned());
self.native.clone().unwrap_or_else(|| {
Ok(self
.profiles()
.into_iter()
.map(PublicModelDescriptor::Adapted)
.collect())
})
})
}
fn authenticate(
&self,
plaintext: &str,
@@ -102,16 +148,6 @@ impl ExecutionService for ModelsExecution {
.collect()
}
fn public_model_profiles(&self, _: &AuthenticatedClient) -> Vec<PublicModelProfile> {
let Some(presentation) = &self.presentation else {
return Vec::new();
};
vec![PublicModelProfile::new(
PublicModelId::new("grok-4.5").expect("model"),
presentation.clone(),
)]
}
fn contains_public_model(&self, _: &AuthenticatedClient, model: &PublicModelId) -> bool {
matches!(model.as_str(), "model-a" | "model-b")
}
@@ -145,6 +181,102 @@ fn authorized_request(path: &str) -> Request<Body> {
.expect("build models request")
}
#[tokio::test]
async fn native_catalog_preserves_complete_objects_and_only_rewrites_alias_slug() {
let original = serde_json::json!({
"slug": "gpt-native", "display_name": "Official name", "priority": 47,
"base_instructions": "Official instructions\nDo not replace.",
"model_messages": {"token_budget": "upstream template", "future_section": [null, {"a": true}]},
"service_tiers": [{"id": "priority", "name": "Fast", "description": "Official speed tier"}],
"tool_mode": "code_mode_only", "use_responses_lite": true,
"future_field": {"nested": [1, null, "value"]}, "explicit_null": null
});
let entry = |model: &str| PublicModelDescriptor::Native {
model: PublicModelId::new(model).expect("model"),
payload: gateway_core::operation::RawJsonPayload::new(
"codex",
serde_json::to_vec(&original).expect("JSON").into(),
)
.expect("payload"),
};
let execution = Arc::new(ModelsExecution {
client: authenticated_client("sk_models_test"),
presentation: None,
native: Some(Ok(vec![entry("gpt-native"), entry("my-alias")])),
requested_versions: Mutex::default(),
});
let response = api_router(execution.clone())
.await
.oneshot(authorized_request(
"/v1/models?client_version=0.154.0-alpha.3",
))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["cache-control"], "private, no-store");
assert!(response.headers().get("etag").is_none());
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
assert_eq!(value["models"][0], original);
let mut alias = original;
alias["slug"] = serde_json::json!("my-alias");
assert_eq!(value["models"][1], alias);
assert_eq!(
*execution.requested_versions.lock().expect("versions"),
["0.154.0-alpha.3"]
);
}
#[tokio::test]
async fn native_catalog_failure_does_not_publish_a_synthetic_or_empty_success() {
let execution = Arc::new(ModelsExecution {
client: authenticated_client("sk_models_test"),
presentation: None,
native: Some(Err(ProviderCatalogUnavailable)),
requested_versions: Mutex::default(),
});
let response = api_router(execution)
.await
.oneshot(authorized_request("/v1/models?client_version=0.154.0"))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
assert_eq!(value["error"]["code"], "model_catalog_unavailable");
assert!(value.get("models").is_none());
}
#[tokio::test]
async fn catalog_rejects_unbounded_or_control_character_versions_before_fetching() {
for version in [
"x".repeat(65),
"0.154.0%0A".to_owned(),
"0.154.0%20".to_owned(),
] {
let execution = ModelsExecution::new();
let response = api_router(execution.clone())
.await
.oneshot(authorized_request(&format!(
"/v1/models?client_version={version}"
)))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(
execution
.requested_versions
.lock()
.expect("versions")
.is_empty()
);
}
}
#[tokio::test]
async fn models_should_encode_the_service_visible_catalog() {
let response = api_router(ModelsExecution::new())
@@ -34,6 +34,7 @@ use gateway_core::event::{
use gateway_core::operation::{Operation, OperationKind};
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::{OpaqueUpstreamValue, UpstreamSendState};
use gateway_protocol::openai::sse::{encode_sse_event, parse_sse_events};
use serde_json::{Value, json};
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
@@ -1572,6 +1573,113 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
);
}
#[tokio::test]
async fn streaming_upstream_error_event_should_be_translated_to_response_failed_for_sse() {
let trace = Arc::new(Trace::default());
let error_data = json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
let error_wire = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some("error".to_owned()),
error_data,
projected_error_frame,
None,
None,
)
.expect("valid upstream error wire"),
);
let started_wire = ProviderEvent::canonical_with_wire(
vec![started()],
ProtocolWireEvent::json(
"openai",
Some("response.created".to_owned()),
json!({
"type": "response.created",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "in_progress",
"future_response_field": {"keep": true}
}
}),
)
.expect("valid upstream started wire"),
);
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(CoordinatedEvent::single(
started_wire,
CommitRequirement::CommitBeforeDelivery,
)),
NextStep::Event(CoordinatedEvent::single(
error_wire,
CommitRequirement::AlreadyCommitted,
)),
NextStep::Error(EngineError::Provider(ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
))),
],
);
let response = stream_execution_response(Box::new(session), None).await;
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read SSE body");
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
let events = parse_sse_events(&body).expect("translated SSE should parse");
let failed = events
.iter()
.find(|event| event.event.as_deref() == Some("response.failed"))
.expect("translated response.failed event");
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
assert_eq!(
(
failed,
body.matches("event: response.failed").count(),
body.contains("event: error\n"),
body.ends_with("data: [DONE]\n\n"),
),
(
json!({
"type": "response.failed",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"future_response_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
}),
1,
false,
true,
)
);
}
#[tokio::test]
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
let trace = Arc::new(Trace::default());
@@ -666,6 +666,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,
@@ -9,6 +9,110 @@ use super::models::ModelsExecution;
const REMOVED_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
#[tokio::test]
async fn browser_origin_controls_http_admin_sessions_without_configuration() {
use axum::body::to_bytes;
use serde_json::{Value, json};
for (origins, secure) in [
(vec!["http://admin.example.test"], false),
(vec!["http://192.0.2.1:8080"], false),
(vec!["http://[::1]:8080"], false),
(vec!["https://admin.example.test"], true),
(vec![], true),
(vec!["null"], true),
(vec!["invalid-origin"], true),
(vec!["http://admin.example.test/path"], true),
(vec!["http://admin.example.test?https"], true),
(vec!["http://user@admin.example.test"], true),
(
vec!["http://admin.example.test", "https://admin.example.test"],
true,
),
] {
let app = api_router_with_origins(ModelsExecution::new(), Vec::new())
.await
.layer(axum::Extension(axum::extract::ConnectInfo(
std::net::SocketAddr::from(([127, 0, 0, 1], 41000)),
)));
let request = |path: &str| {
let mut builder = Request::post(path)
.header("content-type", "application/json")
.header("x-forwarded-proto", "http")
.header("forwarded", "proto=http");
for origin in &origins {
builder = builder.header("origin", *origin);
}
builder
};
let response = app
.clone()
.oneshot(
request("/api/auth/login")
.body(Body::from(
json!({"mode": "admin", "username": "admin_1", "password": "strong-admin-password"})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let cookie = response.headers()["set-cookie"].to_str().unwrap();
let session = cookie.split(';').next().unwrap().to_owned();
let attrs: Vec<_> = cookie.split(';').map(str::trim).collect();
assert_eq!(attrs.contains(&"Secure"), secure);
for attribute in ["Path=/", "HttpOnly", "SameSite=Lax"] {
assert!(attrs.contains(&attribute));
}
let response = app
.clone()
.oneshot(
Request::get("/api/auth/status")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body: Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap();
assert_eq!(body["data"]["authenticated"], true);
let response = app
.clone()
.oneshot(
request("/api/auth/logout")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let cookie = response.headers()["set-cookie"].to_str().unwrap();
assert!(cookie.starts_with("cpr_session=;"));
let attrs: Vec<_> = cookie.split(';').map(str::trim).collect();
assert_eq!(attrs.contains(&"Secure"), secure);
for attribute in ["Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0"] {
assert!(attrs.contains(&attribute));
}
let response = app
.oneshot(
Request::get("/api/auth/status")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body: Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap();
assert_eq!(body["data"]["authenticated"], false);
}
}
#[tokio::test]
async fn removed_responses_review_route_should_not_reach_the_responses_handler() {
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
+146
View File
@@ -0,0 +1,146 @@
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
sync::{Arc, atomic::Ordering},
};
use async_trait::async_trait;
use axum::{
body::{Body, to_bytes},
extract::ConnectInfo,
http::{Method, Request, header},
};
use gateway_admin::{
model::system::{
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion,
},
ports::system::{SystemOperationError, SystemOperations, SystemUpdateEventStream},
};
use gateway_core::{
engine::execution::{ClientAuthenticationError, ClientKeyVerifier},
policy::ClientApiKeyId,
};
use serde_json::Value;
pub(crate) const RAW_KEY: &str = "cpr_live_candidate_must_not_leak";
pub(crate) async fn auth_app() -> (axum::Router, Arc<crate::admin::MemoryAuthStore>) {
let fixture = key_fixture().await;
(
crate::openai::api_router_with_admin(fixture.services),
fixture.auth,
)
}
pub(crate) async fn key_fixture() -> crate::admin::AdminTestFixture {
let fixture = crate::admin::AdminTestFixture::with_key_verifier(
Arc::new(AcceptingVerifier {
key_id: ClientApiKeyId::new("key-42").expect("key ID"),
}),
Arc::new(VersionSystem),
)
.await;
fixture.auth.enabled.store(true, Ordering::SeqCst);
fixture
}
pub(crate) fn json_request(method: Method, uri: &str, body: Value) -> Request<Body> {
let mut request = Request::builder()
.method(method)
.uri(uri)
.header(header::CONTENT_TYPE, "application/json")
.header(header::ORIGIN, "https://console.example.test")
.body(Body::from(body.to_string()))
.expect("client JSON request");
request.extensions_mut().insert(ConnectInfo(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10)),
41_000,
)));
request
}
pub(crate) fn empty_request(method: Method, uri: &str) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.body(Body::empty())
.expect("client empty request")
}
pub(crate) fn cookie_request(method: Method, uri: &str, cookie: &str) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header(header::COOKIE, cookie)
.body(Body::empty())
.expect("client cookie request")
}
pub(crate) async fn response_json(response: axum::response::Response) -> Value {
let body = to_bytes(response.into_body(), 64 * 1024)
.await
.expect("read client response body");
serde_json::from_slice(&body).expect("parse client response JSON")
}
struct VersionSystem;
#[async_trait]
impl SystemOperations for VersionSystem {
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
Ok(SystemVersion {
version: "3.7.0".to_owned(),
git_sha: "internal-revision".to_owned(),
build_time: "internal-build-time".to_owned(),
deployment_mode: "binary".to_owned(),
update_channel: "release".to_owned(),
latest_version: "3.8.0".to_owned(),
has_update: true,
update_cached: true,
update_warning: Some("internal-update-warning".to_owned()),
})
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
unreachable!("client route must not request update details")
}
fn update_events(&self) -> SystemUpdateEventStream {
unreachable!("client route must not subscribe to updates")
}
async fn perform_update(
&self,
_: Option<String>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
unreachable!("client route must not perform updates")
}
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
unreachable!("client route must not request update status")
}
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
unreachable!("client route must not roll back")
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
unreachable!("client route must not restart")
}
}
struct AcceptingVerifier {
key_id: ClientApiKeyId,
}
impl ClientKeyVerifier for AcceptingVerifier {
fn verify_client_key(
&self,
candidate: &str,
) -> Result<ClientApiKeyId, ClientAuthenticationError> {
if candidate == RAW_KEY {
Ok(self.key_id.clone())
} else {
Err(ClientAuthenticationError::InvalidKey)
}
}
}
@@ -2,6 +2,11 @@
mod error;
mod model;
mod model_access;
pub use model_access::{
AccountModelAccess, AccountModelAccessMode, InvalidAccountModelAccess,
MAX_ACCOUNT_ACCESS_MODELS,
};
mod proxy;
pub use proxy::{InvalidOutboundProxy, OutboundProxy};
pub mod scope;
@@ -669,6 +669,7 @@ pub struct ProviderAccount {
enabled: bool,
concurrency_limit: Option<AccountConcurrencyLimit>,
weight: AccountWeight,
model_access: super::AccountModelAccess,
credential_state: CredentialState,
quota: QuotaState,
last_error_reason: Option<AccountErrorReason>,
@@ -704,6 +705,7 @@ impl ProviderAccount {
enabled: true,
concurrency_limit: None,
weight: AccountWeight::DEFAULT,
model_access: super::AccountModelAccess::all(),
credential_state: CredentialState::Unknown,
quota: QuotaState::unknown(),
last_error_reason: None,
@@ -728,6 +730,17 @@ impl ProviderAccount {
self
}
#[must_use]
pub fn with_model_access(mut self, model_access: super::AccountModelAccess) -> Self {
self.model_access = model_access;
self
}
#[must_use]
pub const fn model_access(&self) -> &super::AccountModelAccess {
&self.model_access
}
#[must_use]
pub fn with_outbound_proxy(mut self, proxy: Option<super::OutboundProxy>) -> Self {
self.outbound_proxy = proxy;
@@ -1007,6 +1020,8 @@ impl ProviderRefreshQuery {
#[derive(Clone, PartialEq)]
pub struct NewProviderAccount {
pub account: ProviderAccount,
/// 导入时显式提供的政策;省略时保留已有账号设置。
pub model_access: Option<super::AccountModelAccess>,
pub credential: PlaintextCredential,
}
@@ -0,0 +1,105 @@
//! 管理员配置的账号模型政策,与上游模型权限分开保存和判断。
use std::collections::BTreeSet;
use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;
use crate::validation::validate_text;
/// 一个账号最多配置的精确模型 ID 数量。
pub const MAX_ACCOUNT_ACCESS_MODELS: usize = 256;
/// 账号模型政策的匹配方式。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountModelAccessMode {
#[default]
All,
Allowlist,
Denylist,
}
/// 已校验的账号模型政策;私有字段保证所有边界使用相同的匹配规则。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AccountModelAccess {
mode: AccountModelAccessMode,
models: BTreeSet<String>,
}
/// 不携带原始输入,避免在错误边界回显配置正文。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error(
"modelAccess requires all with no models, or allowlist/denylist with 1–256 exact model IDs (at most 256 bytes each)"
)]
pub struct InvalidAccountModelAccess;
impl Default for AccountModelAccess {
fn default() -> Self {
Self::all()
}
}
impl AccountModelAccess {
#[must_use]
pub const fn all() -> Self {
Self {
mode: AccountModelAccessMode::All,
models: BTreeSet::new(),
}
}
pub fn new(
mode: AccountModelAccessMode,
models: Vec<String>,
) -> Result<Self, InvalidAccountModelAccess> {
if models.len() > MAX_ACCOUNT_ACCESS_MODELS
|| (mode == AccountModelAccessMode::All) != models.is_empty()
|| models.iter().any(|model| {
model.trim() != model
|| model.trim().is_empty()
|| model.contains('*')
|| validate_text(model, 256, true, None).is_err()
})
{
return Err(InvalidAccountModelAccess);
}
Ok(Self {
mode,
models: models.into_iter().collect(),
})
}
#[must_use]
pub const fn mode(&self) -> AccountModelAccessMode {
self.mode
}
#[must_use]
pub fn models(&self) -> &BTreeSet<String> {
&self.models
}
#[must_use]
pub fn allows(&self, upstream_model: &str) -> bool {
match self.mode {
AccountModelAccessMode::All => true,
AccountModelAccessMode::Allowlist => self.models.contains(upstream_model),
AccountModelAccessMode::Denylist => !self.models.contains(upstream_model),
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ModelAccessDocument {
mode: AccountModelAccessMode,
models: Vec<String>,
}
impl<'de> Deserialize<'de> for AccountModelAccess {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let document = ModelAccessDocument::deserialize(deserializer)?;
Self::new(document.mode, document.models).map_err(serde::de::Error::custom)
}
}

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