Compare commits

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(xai): keep catalog credential revision aligned

---------

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

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

Refs #139

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

* test(openai): make SOCKS negotiation coverage deterministic

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

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

---------

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

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

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

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

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

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

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

---------

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

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

Refs #129

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

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

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

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

---------

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

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

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 05:15:31 +08:00
ZyyandCodex 42c2b249c2 chore(release): v3.9.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 04:08:44 +08:00
ZyyandCodex 0b3ebc23f5 chore(workflow): refine documentation and release checks
Require documentation diff review by purpose, distinguish release migration baselines, and use the version tag directly in release commit titles.

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 03:30:54 +08:00
499b43043e feat(accounts): add opt-in capacity cooldown and recovery (#126)
Add opt-in account capacity cooldown, recovery probes, and atomic adaptive concurrency updates. Include overload and slow_down in Smart scheduling feedback, expose rate-limit reasons, and keep probe results fenced by cooldown generation.

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

Closes #120

Co-authored-by: zhangwentian <2han9wen71an@gmail.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 03:23:38 +08:00
ZyyandCodex ae2da31380 fix(accounts): improve provider selection and forecast hints
Expand provider choices to full-width labeled options and move the forecast caveat into an icon-only explanation popover.

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:24:34 +08:00
866f71e3fb fix(openai): sync account plans with quota updates (#112)
* fix(openai): sync account plans with quota updates

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

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

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:22:01 +08:00
yertandCodex 47ab48b4cf feat(deploy): add one-click installer (#116)
Automate version-matched Compose deployment, credential generation, bind-mount permissions, and startup health validation while preserving existing deployments.

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

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

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

Closes #97

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

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

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

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

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

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

* feat(console): refine key workspace experience

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

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

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

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

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

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

Share one wait deadline across client admission and account selection.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 02:00:26 +08:00
84382761fc feat(auth): add focused API key usage page (#102)
* feat(auth): add API key workspace

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

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

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

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

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

* feat(console): refine key workspace experience

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

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

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

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

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 01:49:37 +08:00
0a09c1572f feat(accounts): add notes and refine account interactions (#100)
Persist optional account notes across creation, editing, imports, and credential refreshes. Show notes in compact account rows with overflow-only popovers.

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

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

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

#90 已在 SSE 边界做了同样的投影,但客户端 WS 边界(push_websocket)仍原
样转发;使用 WS transport 的 codex 桌面端不受 #90 保护。本提交在该边界
对不可消费的错误帧投影成 response.failed(消息 JSON 自带 type 字段,经
process_responses_event 的 response.failed 分支映射为可重试错误并立即重
试);带非 2xx status 或特殊错误码的可消费形状原样透传,保持
deliverable_business_failures_are_not_rewritten... 既有契约。
2026-09-15 16:31:07 +08:00
534 changed files with 38293 additions and 5214 deletions
+4 -1
View File
@@ -33,6 +33,9 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
5. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
5. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
6. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
+4 -3
View File
@@ -5,8 +5,9 @@ description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用
# 整理与提交 Issue
本仓库流程见 [贡献与审查](../../../CONTRIBUTING.md),问题反馈使用当前的
[YAML Issue Form](../../../.github/ISSUE_TEMPLATE/bug_report.yml)。从表单读取字段与必填项,不在技能中复制一份字段清单。
本仓库流程见 [贡献与审查](../../../CONTRIBUTING.md),按场景选择当前的
[接口问题反馈](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)
或 [功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml) 表单。从表单读取字段与必填项,不在技能中复制一份字段清单。
用户指定其他仓库时,使用该仓库当前的贡献规则与 Issue 模板,不能把这里的表单套过去。
## 确定目标和动作
@@ -21,7 +22,7 @@ description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用
## 证据与去重
1. 读取目标仓库实际 Issue 模板和配置,区分缺陷、功能建议与协作规则提案。
本仓库后两类允许普通 Issue;其他仓库按其配置选择入口。
本仓库功能建议有独立表单,协作规则提案允许普通 Issue;其他仓库按其配置选择入口。
2. 先搜索 open 和 closed Issues,结合关键词、错误信息与触发条件检查重复,而不只比较标题。
同时核对相关 PR 与发布记录,关联已有修复及其发布版本;仅主分支已修复时明确尚未发布。
同一问题已有记录且没有新增证据时返回已有链接;用户仍明确要求新建时按其要求处理。
+9
View File
@@ -42,6 +42,10 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
按 [贡献与审查的文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否需要更新文档。
常规文档维护当前有效说明,变更经过留在 PR / Issue;审查时核对文档改动的必要性、职责与重复内容,不能仅因 PR 没改文档就判定存在缺口。
创建前和审查时逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
是否重复已有说明,脱离本次变更背景能否独立理解。仅说明实现经过、前后对比或验证结果的段落归入
PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断依据是内容用途,不按“新增”“不再”等词机械判错。
## 创建 PR
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
@@ -56,6 +60,7 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
按上述文档复核步骤检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
前端改动提供实际界面证据或预览步骤;缺少证据时写明,不用构建成功替代视觉验收。
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
再给必要的设计取舍、验证与遗留事项。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
@@ -83,6 +88,9 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
按上述核对流程收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
@@ -90,6 +98,7 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
按上述文档复核步骤检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
工具不可用、集成测试跳过或环境不足时具体说明影响范围。
7. 按 [审查标准](../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
再列出实现问题与验证缺口,结论附具体依据。
+19 -7
View File
@@ -1,6 +1,6 @@
---
name: release
description: 准备或执行当前仓库发版,维护中文发布说明。用于“发版”“发个新版本”“准备 vX.Y.Z”“推荐版本号”“整理更新日志/发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing a repository release; not for deploying/restarting an instance, publishing application data, or authoring a release skill.
description: 准备或执行当前仓库的稳定版、实验版发版,维护中文发布说明。用于“发版”“发布实验版/exp 版本”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable or experimental releases; not for deploying/restarting an instance or authoring a release skill.
---
# 发版
@@ -12,12 +12,17 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
| 发布实验版、exp 版本 | 从目标实验分支发布,按下方实验版流程确定版本、文案和渠道隔离 |
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
沿用当前会话明确的版本、范围与授权;用户已要求正式发版且条件满足时直接执行,不重复确认。
沿用当前会话明确的版本、范围与授权;用户已授权发布目标版本且条件满足时直接执行,不重复确认。
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
稳定版从 `main` 发布。实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`,
展示标题包含分支用途名;先读 [实验版发布](references/experimental.md) 确定标题与编号,再复用本文的准备、执行与验收步骤。
实验功能是否合入 `main` 是单独的维护决策,发布实验版不包含合并功能的授权。
## 读取当前流程
先读 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),再按任务检查:
@@ -35,7 +40,7 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
## 准备版本与说明
1. 确认发布分支、upstream、目标仓库、工作区和完整待推送范围。本仓库沿用 `main`;
1. 确认发布类型、对应分支、upstream、目标仓库、工作区和完整待推送范围;
未提交或未跟踪内容分别核对,不因脚本只检查已跟踪文件就认定所有内容都已纳入发布。
2. 读取版本文件、已有 tag 和实际发行记录,确认上一发布基线与本次目标提交。
根据基线到目标的提交和最终差异归纳变更,剔除回退、重复和未进入发布范围的内容。
@@ -48,17 +53,21 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
只修改说明时无需重建前后端;正式发版仍要跟踪发布工作流针对目标提交的检查与产物。
涉及数据库迁移时,对照同一大版本的上一正式 Release 检查已发布迁移是否保持不变,并验证升级路径;
跨大版本按项目部署约定判断,不默认支持在线升级。区分未发布迁移的整理、已执行这些迁移的源码部署实例,
以及 CI 实际采用的冻结基线。未发版不代表迁移从未被执行,CI 冻结检查失败也不等于正式版数据库升级失败;
按各自证据报告兼容性与检查结果,不因未发版而自动放宽现有冻结规则。
只修改说明时无需重建前后端;实际发版仍要跟踪发布工作流针对目标提交的检查与产物。
## 执行正式发版
## 执行发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): prepare vX.Y.Z`。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权正式发布后,将 `release_version` 设为已确定的版本,执行:
在准备完成且用户已授权发布后,将 `release_version` 设为已确定的版本,执行:
```bash
release/publish "$release_version"
@@ -67,6 +76,9 @@ release/publish "$release_version"
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
## 恢复与验收
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
+2 -2
View File
@@ -1,4 +1,4 @@
interface:
display_name: "release"
short_description: "按仓库流程整理中文发布说明、核对版本并完成发行与验证"
default_prompt: "使用 $release 准备下一个版本的中文发布说明,并核对发版条件。"
short_description: "按仓库流程准备稳定版或实验版的中文说明,完成发行与渠道验证"
default_prompt: "使用 $release 按指定的稳定版或实验版渠道准备中文发布说明,并核对发版条件。"
@@ -0,0 +1,64 @@
# 实验版发布
适用于独立于主版本维护、供用户主动选择体验的实验功能。发布入口仍是仓库的 `release/publish`,
构建复用目标实验分支的 `.github/workflows/release.yml` 与 `release/platforms.yaml`。
## 分支与版本
- 从用户指定或会话已确定的 `experimental/<用途>` 分支发布,核对它的 upstream 和实验功能范围。
使用独立 worktree 时保留其他工作区内容;不把实验代码合入 `main`,也不顺带升级运行实例。
- 版本采用 `vX.Y.Z-exp.N`:`X.Y.Z` 表示本轮采用的正式版基线,`N` 从 1 开始递增。
编号在仓库内唯一;多个实验分支共享 Git tag 命名空间,不能分别占用相同版本号。
后续只有实际同步了新的正式版基线,才调整 `X.Y.Z`。
- 核对已有 tag、Release 与目标分支源码,先区分重试和新发行。已发布的旧命名版本保留,
不为统一名称移动 tag 或替换原有产物。
- GitHub Release 展示标题采用 `vX.Y.Z-<用途>.M`,`<用途>` 取实验分支名去掉 `experimental/` 前缀后的部分。
发布前通过 `gh api` 核对相同正式版基线与用途的已有 Release 标题和旧命名 tag,新发行取已有最大编号加 1,首次从 1 开始。
展示编号 `M` 独立于实际 tag 的 `exp.N`,旧命名发行也计入,不能因改用 `exp.N` 而重置;同一发行重试沿用已确定编号。
例如已有 `v3.10.0-codex-anti-degradation.1`,下一次 tag 即使是 `v3.10.0-exp.1`,标题也应为 `v3.10.0-codex-anti-degradation.2`。
- tag 和产物继续使用 `exp.N`:镜像版本去掉 tag 开头的 `v`,安装包沿用项目的版本、系统和架构命名。
发布说明首行仍匹配实际 tag;关联 Issue 和试验目的写在正文中。新标题规则不自动改名已有 Release。
## 先核对发布能力
在目标实验分支读取实际工作流,确认以下行为;新实验分支若缺少支持,先在既有流程中补齐并验证:
- `exp.N` 产生 `experimental` 构建类型,所有平台的二进制与镜像使用同一版本和源码提交。
本项目实验构建禁用应用内一键更新,用户按部署说明手动升级。
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
默认 `stable` 更新渠道忽略实验版;主动配置为 `preview` 的用户可能看到预发行。
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
核对发布附件确实包含它,不能只在文案中承诺。
- 实验版正文使用下方完整文案,不再由工作流重复追加安装章节或自动生成混入正式版基线的提交列表。
- Release 的 `name` 使用上述展示标题。工作流若默认以 tag 为标题,发布成功后用 `gh release edit --title` 设置标题,
再通过 `gh api` 回读核对;仅修改 `name`,保留 tag、产物、正文和预发行状态。
## 中文发布说明
在目标分支的 `release/notes.md` 编写,首行为 `# vX.Y.Z-exp.N`。后续内容按实际需要组织:
1. **实验说明**:开头简述正式版基线、验证的问题与适用人群。效果尚未验证时明确说明,
不把假设写成已解决的问题,不承诺实验功能一定进入主版本。
2. **本次变化**:首次发行概括相对正式版基线新增的实验能力,关联 PR;后续发行只列相对上个实验版的变化。
同步主分支修复时说明它对实验用户的影响,不复制整份正式版更新记录。
3. **安装与使用**:给出本版本真实镜像命令、支持平台、部署文件及实验部署文档链接。
交代功能是否默认启用和必要配置;保留工作流实际提供的校验与证明说明。
4. **使用须知**:只写本次适用的已知限制、数据隔离、迁移与回退边界,以及手动更新方式。
5. **反馈与致谢**:链接关联 Issue 或已有反馈入口,感谢实际贡献者。没有相应内容的章节可以省略。
用户要求先审核文案时,先展示完整正文;已批准的同一版本文案无需重复确认。
正常新发行把说明纳入版本提交并从 tag 构建,不先创建公开空 Release 再回填附件。
## 执行与验收
按主技能准备说明和其他变更后,在实验分支执行 `release/publish <X.Y.Z-exp.N>`。
入口创建版本提交和 tag,并从该分支 dispatch 发布工作流;不要从 `main` 发布实验代码。
发布前记录 GitHub Latest 的版本与 Docker `latest` 摘要;发布后核对二者仍对应稳定版。
检查展示标题的分支用途与历史续号、它所对应的实际 tag,以及目标 tag 的源码提交、完整产物、校验和、多架构平台和 Pre-release 标记。
同基线的 `X.Y.Z-exp.N` 按 SemVer 排序低于正式 `X.Y.Z`;渠道隔离仍以更新器过滤和发布标签规则为准。
失败时按主技能从未完成步骤恢复,不通过换版本号或移动 tag 掩盖失败。
仅维护已有页面文案时不重新构建;报告实际完成状态、发布链接及验证缺口。
@@ -0,0 +1,33 @@
body:
- type: markdown
attributes:
value: |
欢迎提问。提交前可以搜索已有讨论,并查看[部署文档](https://github.com/zyycn/codex-proxy-rs/blob/main/deploy/README.md)。
明确的程序故障请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose);不确定是不是 Bug,也可以先在这里交流。
请移除密钥、令牌、Cookie、代理认证和私人内容,不要上传完整配置或请求转储。解决后请标记帮助到你的答案。
- type: textarea
id: question
attributes:
label: 想解决什么问题
description: 说明希望完成的任务、当前遇到的困难,标题尽量具体。
validations:
required: true
- type: input
id: version
attributes:
label: 版本与部署方式
description: 涉及具体实例时,填写完整版本号或提交号,以及 Docker、二进制或源码部署方式。
- type: textarea
id: attempts
attributes:
label: 已尝试的方法与结果
description: 可以附看过的文档、相关讨论、脱敏错误片段或截图;未尝试可留空。
- type: textarea
id: environment
attributes:
label: 相关环境与补充信息
description: 按问题需要补充操作系统、客户端、反向代理或中转链路,无需提供无关信息。
@@ -0,0 +1,44 @@
body:
- type: markdown
attributes:
value: |
欢迎分享实验版本的使用体验,包括有效、无明显变化或遇到限制的情况。
请先查看对应[预发行页面](https://github.com/zyycn/codex-proxy-rs/releases)的部署与数据隔离说明,并搜索已有反馈。
明确的程序缺陷请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose),在两处互相附链接即可,无需重复粘贴。
请移除密钥、令牌、Cookie、代理认证和私人内容,不要上传完整配置或请求转储。
- type: input
id: version
attributes:
label: 实验版本或提交号
description: 填写实际运行的完整版本号或提交号,不要只写 exp 或 latest。
validations:
required: true
- type: textarea
id: scenario
attributes:
label: 使用场景与环境
description: 说明启用的实验功能、部署方式、客户端及与结果相关的条件,不需要提供账号身份信息。
validations:
required: true
- type: textarea
id: observation
attributes:
label: 观察到的效果或限制
description: 描述实际现象、出现频率和观察时长;请区分观察结果与原因推测。
validations:
required: true
- type: textarea
id: comparison
attributes:
label: 对照与复现信息
description: 如已对比启用前后或不同配置,请说明条件和结果;没有对照也可以反馈,无需额外测试。
- type: textarea
id: references
attributes:
label: 相关链接与补充材料
description: 可附已有 Issue、讨论、脱敏日志片段或截图。
@@ -0,0 +1,29 @@
body:
- type: markdown
attributes:
value: |
这里适合交流尚在探索的想法、方案取舍和使用场景,不要求先给出完整实现。
已有明确场景与预期行为时,可以直接提交[功能建议 Issue](https://github.com/zyycn/codex-proxy-rs/issues/new?template=feature-request.yml),无需先开讨论。
请先搜索相关讨论,每个主题围绕一个问题展开。
- type: textarea
id: use-case
attributes:
label: 使用场景与当前不便
description: 希望完成什么任务,目前的做法有什么不足?
validations:
required: true
- type: textarea
id: idea
attributes:
label: 想法与希望讨论的方向
description: 可以说明期望体验、考虑过的替代方法,以及还不确定的地方。
validations:
required: true
- type: textarea
id: references
attributes:
label: 参考资料
description: 可附相关 Issue、讨论或其他项目的具体行为;示例和截图请先脱敏。
+174
View File
@@ -0,0 +1,174 @@
name: 接口问题反馈
description: 客户端调用模型、搜索等 API 时的报错、断流、超时或响应异常
body:
- type: markdown
attributes:
value: |
管理端页面、账号导入或服务部署的问题请选择“常规问题反馈”;新增能力请选择“功能建议”。
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: textarea
id: description
attributes:
label: 问题描述
description: 说明发生了什么、实际结果以及出现的频率;已有正常账号、模型或客户端的对照结果也可说明。
validations:
required: true
- type: input
id: version
attributes:
label: 服务版本
description: 填写实际运行的完整版本号,不要只写“最新”或 latest;源码运行填写提交号。
placeholder: 完整版本号或提交号
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
options:
- Docker Compose
- 其他容器部署
- 二进制运行
- 源码运行
- 其他方式(请在环境信息中说明)
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 填写服务端与客户端的操作系统及版本;性能或超时问题可补充 CPU、内存和容器资源限制。
placeholder: |
服务端操作系统与版本:
客户端操作系统与版本:
相关资源限制(如有):
validations:
required: true
- type: input
id: client-name
attributes:
label: 客户端名称
description: 区分 CLI、桌面端、编辑器扩展、SDK 或自编脚本。
placeholder: 例如 Codex CLI、Codex Desktop 或某个编辑器扩展
validations:
required: true
- type: input
id: client-version
attributes:
label: 客户端版本
description: 填写完整版本号;Codex CLI 可填写 codex --version 的输出。无法取得时说明原因。
placeholder: 完整版本号或相关 SDK 版本
validations:
required: true
- type: input
id: client-source
attributes:
label: 客户端安装来源与启动方式
description: 说明安装来源,以及是否通过包装脚本、启动器或第三方修改版运行;不确定时填写已知部分。
placeholder: 例如通过 npm 安装 @openai/codex,直接执行 codex
validations:
required: true
- type: input
id: endpoint
attributes:
label: 请求方法与端点
description: 填写发生异常的请求方法与路径,无需真实域名;不清楚端点时说明客户端当时执行的具体操作。
placeholder: 例如 POST /v1/responses、POST /v1/alpha/search 或 WebSocket /v1/responses
validations:
required: true
- type: dropdown
id: intermediary
attributes:
label: 是否经过中转或反向代理
description: |
指客户端到本服务之间的链路。API 中转包括 New API、One API、CCH 等;反向代理或 CDN 包括 Nginx、Caddy、Cloudflare Tunnel 等。
如有中转,请在下方填写链路详情;本服务到上游的出站代理请在补充信息中说明。
options:
- 否,直接连接本服务
- 是,仅经过 API 中转服务
- 是,仅经过反向代理或 CDN
- 是,同时经过 API 中转和反向代理或 CDN
- 不确定
validations:
required: true
- type: textarea
id: request-chain
attributes:
label: 请求链路
description: |
按调用顺序填写中间服务的名称与版本,例如:Codex → New API → Nginx → 本服务。不确定时填写已知部分。
如果已测试过直连本服务,请说明是否仍能复现;未测试可注明,不要求先完成对照测试。
placeholder: |
调用顺序与各中间服务版本:
是否启用请求参数或请求头改写(如已知):
直连本服务的对照结果(如已测试):
- type: textarea
id: account-model
attributes:
label: 平台、账号类型与模型
description: 填写与失败请求相关的信息,不要提供账号凭据;不涉及模型或无法确认的信息请注明。
placeholder: |
平台与认证类型:
账号套餐(如相关):
请求模型与映射后的上游模型(如已知):
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 描述触发问题的操作;无法稳定复现时,说明已知触发条件。
placeholder: |
1. 使用什么配置或输入
2. 执行什么操作
3. 出现什么结果
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
- type: textarea
id: request-response
attributes:
label: 请求、响应与诊断附件
description: |
提供脱敏后的最小请求、网关实际状态/响应片段和客户端显示,区分上游、中转、网关与客户端的错误来源。
可在此直接上传管理端默认诊断包并检查其缺失说明;原始错误与日志片段需另行审阅脱敏,粘贴时请使用 Markdown 代码块。
按部署文档的“请求错误排查”检查关联 ID、错误详情与 application 日志,不默认开启完整 dump;无法取得的信息请注明。
placeholder: |
发生时间与时区:
传输(HTTP JSON / SSE / WebSocket):
脱敏后的最小请求:
网关关联 ID(x-gateway-request-id;配置的入口头请单独注明):
上游关联 ID(x-request-id / x-oai-request-id,注明来源):
网关实际状态、Content-Type 与响应片段:
中转服务的状态与错误(如有):
客户端显示的错误:
诊断包或无法取得的信息:
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 最近有升级时填写升级前后版本及前后端是否同步更新;网络问题补充出站代理、系统代理、TUN 和自定义证书等相关环境。
placeholder: |
服务或客户端升级前版本 → 当前版本(如有):
服务前后端是否同步更新(如有升级):
最近的配置变更:
其他相关环境:
-120
View File
@@ -1,120 +0,0 @@
name: 问题反馈
description: 提交可复现的问题,帮助定位和修复
body:
- type: markdown
attributes:
value: |
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: dropdown
id: issue-type
attributes:
label: 问题类型
options:
- 接口请求或响应异常
- 页面或交互异常
- 配置或部署异常
- 其他问题
validations:
required: true
- type: textarea
id: description
attributes:
label: 问题描述
description: 说明发生了什么、实际结果以及出现的频率。
validations:
required: true
- type: input
id: version
attributes:
label: 服务版本
description: 填写具体版本号;从源码运行时填写提交号。
placeholder: 版本号或提交号
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
options:
- Docker Compose
- 其他容器部署
- 二进制运行
- 源码运行
- 其他方式(请在环境信息中说明)
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 填写操作系统与版本;页面问题同时填写浏览器与版本。
placeholder: |
操作系统与版本:
浏览器与版本(页面问题):
validations:
required: true
- type: textarea
id: account-client
attributes:
label: 账号与客户端
description: 接口问题请填写平台、认证类型、模型和客户端信息,不要提供账号凭据。
placeholder: |
平台与认证类型:
模型名称:
客户端名称与版本:
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 描述触发问题的操作;无法稳定复现时,说明已知触发条件。
placeholder: |
1. 使用什么配置或输入
2. 执行什么操作
3. 出现什么结果
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
- type: textarea
id: request-response
attributes:
label: 请求与响应
description: |
接口问题请提供脱敏后的最小请求、网关实际状态/响应片段和客户端终端显示,区分两者。
优先附管理端默认诊断包并检查其缺失说明;原始错误与日志片段需另行审阅脱敏。
先按部署文档的“请求错误排查”检查关联 ID、错误详情与 application 日志,不默认开启完整 dump。
placeholder: |
发生时间与时区:
传输(HTTP JSON / SSE / WebSocket):
网关关联 ID(x-gateway-request-id / 配置的入口头):
上游关联 ID(x-request-id / x-oai-request-id,注明来源):
网关实际状态与响应片段:
客户端终端显示:
诊断包或无法取得的信息:
render: text
- type: textarea
id: screenshots
attributes:
label: 截图或录屏
description: 页面或交互问题可在此拖入相关截图、录屏,并说明对应的操作。
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 最近的升级或配置变更;网络问题补充系统代理、TUN 和自定义证书等相关环境。
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: 使用问答
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答
about: 安装部署、配置和日常使用求助;明确的 Bug 请选择上方问题反馈模板。
- name: 想法与方案讨论
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论
about: 探索尚未明确的需求与方案;具体功能需求也可以直接提交功能建议。
- name: 实验版体验反馈
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈
about: 分享实验版本的使用效果、适用场景和限制。
@@ -0,0 +1,39 @@
name: 功能建议
description: 说明使用场景、当前不便和期望行为,提出新能力或体验改进
body:
- type: markdown
attributes:
value: |
如果已有功能无法正常使用,请选择对应的故障模板。提交前可搜索已有 Issues,相关讨论请附链接。
每个 Issue 尽量围绕一个使用场景;截图和示例请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: textarea
id: use-case
attributes:
label: 使用场景与当前不便
description: 说明你希望完成什么任务、目前如何操作,以及现有能力有什么不足。
placeholder: |
希望完成的任务:
当前做法与不便:
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 期望行为
description: 描述改进后希望如何使用,最好给出一个具体例子;不要求先设计技术方案。
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 已尝试的替代方案
description: 如果已有配置、操作方式或其他工具能部分满足需求,说明仍缺少什么;未尝试可留空。
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 可附当前使用的具体版本、相关 Issue、截图或参考链接;引用其他项目时说明希望借鉴的具体行为。
@@ -0,0 +1,143 @@
name: 常规问题反馈
description: 管理端页面、账号导入、安装部署、升级或其他非客户端 API 问题
body:
- type: markdown
attributes:
value: |
客户端调用模型、搜索等 API 的问题请选择“接口问题反馈”;新增能力或体验改进请选择“功能建议”。
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: dropdown
id: issue-type
attributes:
label: 问题类型
options:
- 页面或交互异常
- 配置或部署异常
- 升级异常
- 其他问题
validations:
required: true
- type: textarea
id: description
attributes:
label: 问题描述
description: 说明发生了什么、实际结果以及出现的频率。
validations:
required: true
- type: input
id: version
attributes:
label: 服务版本
description: 填写实际运行的完整版本号,不要只写“最新”或 latest;源码运行填写提交号。服务无法启动时填写准备运行的版本。
placeholder: 完整版本号或提交号
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
options:
- Docker Compose
- 其他容器部署
- 二进制运行
- 源码运行
- 其他方式(请在环境信息中说明)
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 填写操作系统与版本,并补充与问题相关的浏览器、访问方式或资源信息。
placeholder: |
操作系统与版本:
浏览器与版本(页面问题):
页面访问方式(HTTP / HTTPS,localhost / IP / 域名,无需真实地址):
CPU 核数与内存、容器资源限制(部署或性能问题):
Docker / Compose 版本(容器部署问题):
validations:
required: true
- type: dropdown
id: intermediary
attributes:
label: 是否经过中转或反向代理
description: |
指客户端或浏览器到本服务之间的链路。API 中转包括 New API、One API、CCH 等;反向代理或 CDN 包括 Nginx、Caddy、Cloudflare Tunnel 等。
如有中转,请在下方填写链路详情;本服务到上游的出站代理请在补充信息中说明。
options:
- 否,直接连接本服务
- 是,仅经过 API 中转服务
- 是,仅经过反向代理或 CDN
- 是,同时经过 API 中转和反向代理或 CDN
- 不确定
- 不适用(例如服务尚未启动)
validations:
required: true
- type: textarea
id: request-chain
attributes:
label: 请求链路
description: |
按调用顺序填写中间服务的名称与版本,例如:浏览器 → Cloudflare → Nginx → 本服务。不确定时填写已知部分。
如果已测试过直连本服务,请说明是否仍能复现;未测试可注明,不要求先完成对照测试。
placeholder: |
调用顺序与各中间服务版本:
是否启用请求参数或请求头改写(如已知):
直连本服务的对照结果(如已测试):
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 描述触发问题的操作;批量操作补充数量,升级问题说明升级方式。无法稳定复现时,说明已知触发条件。
placeholder: |
1. 使用什么配置或输入
2. 执行什么操作
3. 出现什么结果
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
- type: textarea
id: logs
attributes:
label: 错误信息与日志
description: |
提供脱敏后的页面提示、浏览器控制台错误或相关启动、升级日志;如有失败请求,补充状态码与响应片段。
可以在此上传诊断包或日志附件;粘贴日志时请使用 Markdown 代码块。不要上传完整配置或请求转储。
placeholder: |
发生时间与时区:
页面提示或终端错误:
浏览器控制台 / 服务日志:
失败请求的状态码与响应片段(如有):
- type: textarea
id: screenshots
attributes:
label: 截图或录屏
description: 页面或交互问题可在此拖入相关截图、录屏,并说明对应的操作。
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 最近有升级时填写升级前后版本及前后端是否同步更新;网络问题补充出站代理、系统代理、TUN 和自定义证书等相关环境。
placeholder: |
升级前版本 → 当前版本(如有):
前后端是否同步更新(如有升级):
最近的配置变更:
其他相关环境:
-3
View File
@@ -669,9 +669,6 @@ jobs:
shell: bash
env:
INSTALLATION_NOTES: |
> [!WARNING]
> **跨大版本升级:** 不支持跨大版本在线升级。请使用全新的 `.runtime/` 数据目录重新部署,再重新导入或重新授权服务商账号,并重新导入客户端密钥。详见[升级说明](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md#镜像升级与源码构建)。
## 📥 安装
**Docker 部署:**
+12 -3
View File
@@ -4,11 +4,20 @@
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
## 提交 Issue
## 交流与反馈
问题反馈使用仓库的 [Issue 表单](.github/ISSUE_TEMPLATE/bug_report.yml),填写问题类型、描述、版本与环境、复现步骤和预期结果。接口问题补充平台与认证类型、模型、客户端,以及脱敏后的请求、响应和请求 ID;页面问题附相关截图或录屏。
[讨论区](https://github.com/zyycn/codex-proxy-rs/discussions) 用于使用问答、想法讨论、实验反馈和经验分享。
提问时按需补充版本、部署方式、已尝试的方法及脱敏信息;问题解决后,请在「使用问答」中标记有帮助的答案。
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议与协作规则调整可直接提交 Issue,说明场景、原因和建议方案。
明确的 Bug、具体功能需求或协作规则建议使用 Issue,无需先开讨论。
讨论形成可执行事项后,可以新建 Issue 并互相附链接;已有 Issue 的后续信息继续补充在原帖,避免重复记录。
围绕问题友善交流,欢迎不同使用场景和方案观点。
### 提交 Issue
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏。
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案。
## 问题与方案依据
+48 -36
View File
@@ -12,71 +12,75 @@
[![GHCR](https://img.shields.io/badge/GHCR-codex--proxy--rs-2496ED?logo=docker&logoColor=white&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square)](LICENSE)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
[快速预览](#快速预览) · [快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
</div>
> [!NOTE]
> 本项目提供 Responses API,不支持 `/v1/chat/completions`。接入前请确认客户端支持 Responses 协议。
## 快速预览
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计。
| 登录信息 | 值 |
| --- | --- |
| 地址 | <https://codex-proxy-rs.ainz.cc> |
| 登录身份 | 管理员 |
| 账号 | `admin@cpr.local` |
| 密码 | `039c18de2aeac46d23ead7766bb07bbbe3747233c66a128e` |
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据。
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息。
## 快速开始
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
### 1. 下载部署文件并配置
### 一键安装
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限。
```bash
mkdir -p codex-proxy-rs/deploy && cd codex-proxy-rs
# 只解析一次最新正式版本,确保两个文件来自同一 Release。
CPR_RELEASE_URL="$(curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/zyycn/codex-proxy-rs/releases/latest)"
CPR_RELEASE_TAG="${CPR_RELEASE_URL##*/}"
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/compose.yaml" \
-o deploy/compose.yaml
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/config.example.yaml" \
-o deploy/config.example.yaml
install -d -m 0750 .runtime/postgres .runtime/redis
sudo install -d -m 0770 -o "$(id -u)" -g 10001 .runtime/data .runtime/logs
sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/install.sh -o install.sh && bash install.sh
```
也可将 `CPR_RELEASE_TAG` 设置为指定的发布标签。Release 附带的 `compose.yaml` 默认使用该版本镜像,
配置模板和部署文件均包含在 `checksums.txt` 中;不要混用 `main` 分支模板与已发布镜像。
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
自动生成密码、设置目录权限并启动服务。完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
[添加账号与客户端密钥](#添加账号与客户端密钥)。
分别生成数据库和 Redis 密码:
可在执行 `bash install.sh` 时传入环境变量:
| 变量 | 用途 | 默认值 |
| --- | --- | --- |
| `INSTALL_DIR` | 安装目录,建议使用绝对路径 | 当前目录下的 `codex-proxy-rs/` |
| `CPR_RELEASE_TAG` | 指定发布标签 | 最新正式版本 |
| `ADMIN_PASSWORD` | 管理员初始密码,至少 12 位,不能包含 `$`,不能使用常见弱口令 | 随机生成 |
例如,自定义安装目录:
```bash
openssl rand -hex 24
openssl rand -hex 24
INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
```
编辑 `deploy/config.yaml`,填好以下三项:
重复运行时请使用同一安装目录。检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
忽略传入的管理员密码,也不执行版本升级。
| 配置项 | 填写内容 |
| --- | --- |
| `store.database.password` | 第一个生成的 48 位十六进制密码 |
| `store.redis.password` | 第二个生成的 48 位十六进制密码 |
| `admin.default_password` | 管理员初始密码,至少 12 位,不能包含 `$` |
### 手动安装
### 2. 启动服务
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)。
```bash
docker compose -f deploy/compose.yaml config --quiet
docker compose -f deploy/compose.yaml pull
docker compose -f deploy/compose.yaml up -d --no-build --wait
curl -i http://127.0.0.1:8080/healthz
```
### 登录管理端
健康检查返回 `204 No Content` 后,打开 `http://127.0.0.1:8080`,
使用 `admin@cpr.local` 和刚设置的管理员密码登录。
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)。
### 3. 添加账号与客户端密钥
### 添加账号与客户端密钥
1. 在「账号」中添加账号,完成授权或导入。
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
@@ -106,6 +110,7 @@ curl http://127.0.0.1:8080/v1/models \
- [客户端接入与生图](deploy/README.md#客户端配置)
- [部署、备份与恢复](deploy/README.md)
- [API 参考](docs/api.md)
- [模型定价与手动同步](docs/api.md#模型定价)
- [系统架构](docs/architecture.md)
- [管理端主题](docs/theme.md)
- [数据库迁移](backend/migrations/README.md)
@@ -113,6 +118,13 @@ curl http://127.0.0.1:8080/v1/models \
## 社区
欢迎到 [Discussions 讨论区](https://github.com/zyycn/codex-proxy-rs/discussions)交流:
[使用问答](https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答)、
[想法讨论](https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论)、
[实验反馈](https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈)与
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)。
明确的 Bug 或功能需求请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose),无需先发讨论。
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台。
## 许可证
+56
View File
@@ -1491,6 +1491,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror",
"tokio",
"tokio-tungstenite 0.29.0",
@@ -1509,6 +1510,7 @@ dependencies = [
"async-trait",
"base64 0.23.1",
"bytes",
"chrono-tz",
"futures",
"futures-timer",
"semver",
@@ -1539,6 +1541,7 @@ dependencies = [
"scraper",
"semver",
"serde",
"serde_ignored",
"serde_json",
"sha2 0.11.0",
"tar",
@@ -2200,6 +2203,17 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "markup5ever"
version = "0.39.0"
@@ -2720,6 +2734,7 @@ dependencies = [
"chrono",
"chrono-tz",
"cookie",
"crc32fast",
"flate2",
"futures",
"gateway-admin",
@@ -2744,6 +2759,7 @@ dependencies = [
"serde_json",
"sha2 0.11.0",
"subtle",
"tar",
"tempfile",
"thiserror",
"tokio",
@@ -2757,6 +2773,8 @@ dependencies = [
"url",
"uuid",
"wiremock",
"xz2",
"zip",
"zstd",
]
@@ -3344,6 +3362,16 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.151"
@@ -4268,6 +4296,12 @@ dependencies = [
"thiserror",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -4783,6 +4817,15 @@ version = "0.8.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576"
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yaml-rust2"
version = "0.11.0"
@@ -4911,6 +4954,19 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
]
[[package]]
name = "zlib-rs"
version = "0.6.6"
+24 -15
View File
@@ -7,26 +7,32 @@ use serde::Deserialize;
const CONFIG_SCHEMA_VERSION: u32 = 1;
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GatewayConfig {
schema_version: u32,
host: HostConfig,
store: gateway_store::StoreConfig,
admin: gateway_admin::AdminConfig,
#[serde(default)]
client: gateway_admin::ClientConfig,
api: gateway_api::ApiConfig,
openai: provider_openai::OpenAiConfig,
xai: provider_xai::XaiConfig,
#[serde(default, rename = "services")]
_compose_services: Option<serde::de::IgnoredAny>,
}
impl LoadableConfig for GatewayConfig {
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
if self.schema_version != CONFIG_SCHEMA_VERSION {
return Err(ConfigError::InvalidField("schema_version"));
}
self.host.resolve_and_validate(source_dir)?;
self.api
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.host
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(&runtime_data_dir)
@@ -34,9 +40,9 @@ impl LoadableConfig for GatewayConfig {
self.admin
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("admin"))?;
self.api
self.client
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
.map_err(|_| ConfigError::InvalidField("client"))?;
self.openai
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
@@ -55,10 +61,10 @@ pub async fn run() -> Result<(), BootstrapError> {
host,
store,
admin,
client,
api,
openai,
xai,
_compose_services: _,
} = config;
let host = gateway_host::initialize(host).await?;
@@ -75,15 +81,18 @@ 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 {
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
providers: vec![openai.admin_provider(), xai.admin_provider()],
snapshot: core.snapshot_control(),
account_probe: core.account_probe(),
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");
+4 -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,12 +178,13 @@ 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",
&[
"client_distribution",
"config",
"pricing",
"proxy_probe",
"serve",
"system_update",
+206 -42
View File
@@ -168,6 +168,50 @@ fn config_loader_should_resolve_paths_relative_to_config_file() {
assert!(debug.contains("frontend/dist"));
}
#[test]
fn config_loader_should_share_resolved_assets_with_system_update() {
const CHILD_ENV: &str = "CPR_TEST_UPDATE_ASSETS_CHILD";
let Ok(case) = std::env::var(CHILD_ENV) else {
// 使用子进程覆盖无环境变量、Docker 路径和相对路径,避免污染并行测试。
for (case, web_dist) in [
("binary", None),
("docker", Some("/app/web/dist")),
("relative", Some("../custom/dist")),
] {
let mut child = Command::new(std::env::current_exe().expect("test executable"));
child
.args([
"--exact",
"bootstrap::config_loader_should_share_resolved_assets_with_system_update",
])
.env(CHILD_ENV, case)
.env_remove("CPR_WEB_DIST_DIR");
if let Some(web_dist) = web_dist {
child.env("CPR_WEB_DIST_DIR", web_dist);
}
let output = child.output().expect("isolated configuration test");
assert!(
output.status.success(),
"{case}: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
return;
};
let mut document = valid_config_document();
document["api"]["asset_directory"] = serde_json::json!("../web/dist");
let (config, directory) = parse_config(&document.to_string()).expect("binary configuration");
let assets = match case.as_str() {
"docker" => std::path::PathBuf::from("/app/web/dist"),
"relative" => directory.path().join("deploy/../custom/dist"),
_ => directory.path().join("deploy/../web/dist"),
};
let debug = format!("{config:?}");
assert!(debug.contains(&format!("asset_directory: {assets:?}")));
assert!(debug.contains(&format!("web_dist_dir: Some({assets:?})")));
}
#[test]
fn config_loader_should_reject_missing_runtime_data_dir() {
let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", "");
@@ -228,16 +272,107 @@ fn bootstrap_config_debug_should_redact_all_passwords() {
}
#[test]
fn config_loader_should_reject_unknown_fields() {
assert_rejected(format!(
"{}\nunknown_terminal_field: true\n",
valid_config()
));
fn config_loader_should_ignore_unknown_fields_in_configuration_sections() {
let mut document = valid_config_document();
document["host"]["system_update"] = serde_json::json!({});
document["store"]["pool"] = serde_json::json!({});
for path in [
"",
"/host",
"/host/listen",
"/host/logging",
"/host/logging/file",
"/host/system_update",
"/store",
"/store/database",
"/store/redis",
"/store/pool",
"/admin",
"/client",
"/api",
"/openai",
"/openai/wire_profile",
"/xai",
"/xai/wire_profile",
] {
let mut extended = document.clone();
extended.pointer_mut(path).expect("configuration section")["unused_setting"] =
serde_json::json!({"nested": [true, null, "ignored"]});
parse_config(&extended.to_string())
.unwrap_or_else(|error| panic!("unknown field in {path}: {error}"));
}
}
#[test]
fn config_loader_should_reject_removed_tls_section() {
assert_rejected(valid_config().replace("openai:\n", "openai:\n tls: {}\n"));
fn config_loader_should_ignore_removed_tls_and_fingerprint_sections() {
let mut document = valid_config_document();
document["openai"]["tls"] = serde_json::json!({});
document["openai"]["fingerprint"] = serde_json::json!({"browser": "removed"});
parse_config(&document.to_string()).expect("removed provider settings are ignored");
}
#[test]
fn config_loader_should_reject_invalid_known_fields_alongside_unknown_fields() {
for port in [serde_json::json!(0), serde_json::json!("not-a-port")] {
let mut document = valid_config_document();
document["host"]["listen"]["unused_setting"] = serde_json::json!(true);
document["host"]["listen"]["port"] = port;
assert_rejected(document.to_string());
}
}
#[test]
fn config_loader_should_report_startup_configuration_diagnostics() {
const CHILD_ENV: &str = "CPR_TEST_CONFIG_DIAGNOSTICS_CHILD";
const UNUSED_SECRET: &str = "unused-value-must-not-appear-in-diagnostics";
if std::env::var_os(CHILD_ENV).is_some() {
gateway_host::load_config::<GatewayConfig>().expect("startup configuration");
return;
}
for case in ["normal", "unused", "missing"] {
let mut document = valid_config_document();
if case == "unused" {
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
} else if case == "missing" {
document["host"]["listen"]
.as_object_mut()
.unwrap()
.remove("port");
}
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("deploy")).unwrap();
fs::write(
directory.path().join("deploy/config.yaml"),
document.to_string(),
)
.unwrap();
let output = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"bootstrap::config_loader_should_report_startup_configuration_diagnostics",
"--nocapture",
])
.env(CHILD_ENV, "1")
.current_dir(directory.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.success(),
case != "missing",
"{case}: {stderr}"
);
match case {
"unused" => assert!(
stderr.contains("openai.wire_profile.location") && stderr.contains("已忽略"),
"{stderr}"
),
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
_ => assert!(!stderr.contains("警告"), "{stderr}"),
}
assert!(!stderr.contains(UNUSED_SECRET), "{stderr}");
assert!(!stderr.contains("services"), "{stderr}");
}
}
#[test]
@@ -279,16 +414,43 @@ fn config_loader_should_reject_admin_password_with_compose_interpolation() {
}
#[test]
fn config_loader_should_reject_removed_fingerprint_section() {
assert_rejected(valid_config().replace(
"openai:\n",
"openai:\n fingerprint:\n browser: removed\n",
));
fn config_loader_should_reject_zero_client_session_ttl() {
let mut config = valid_config_document();
*config
.pointer_mut("/client/session_ttl_minutes")
.expect("example client session TTL") = serde_json::json!(0);
assert_rejected(config.to_string());
}
#[test]
fn config_loader_should_default_missing_client_section() {
let mut config = valid_config_document();
config
.as_object_mut()
.expect("example config mapping")
.remove("client")
.expect("example client section");
parse_config(&config.to_string()).expect("client defaults when the section is omitted");
}
#[test]
fn config_loader_should_reject_missing_client_session_ttl() {
let mut config = valid_config_document();
config["client"]
.as_object_mut()
.expect("example client mapping")
.remove("session_ttl_minutes")
.expect("example client session TTL");
assert_rejected(config.to_string());
}
#[test]
fn config_loader_should_reject_invalid_codex_cli_version() {
assert_rejected(valid_config().replace("codex_version: '0.153.4'", "codex_version: 'latest'"));
assert_rejected(valid_config().replacen(
" residency: us",
" residency: us\n codex_version: 'latest'",
1,
));
}
#[test]
@@ -307,40 +469,30 @@ fn config_loader_should_reject_zero_server_port() {
#[test]
fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replace("desktop_build: '8109'", "desktop_build: 'build'"));
assert_rejected(valid_config().replacen(
" residency: us",
" residency: us\n desktop_build: 'build'",
1,
));
}
#[test]
fn config_loader_should_accept_empty_and_custom_request_locations() {
fn config_loader_should_ignore_removed_request_location_setting() {
let original = valid_config();
let omitted = original
.lines()
.filter(|line| !line.trim_start().starts_with("location:"))
.collect::<Vec<_>>()
.join("\n");
assert_ne!(original, omitted);
parse_config(&omitted).expect("location passthrough when omitted");
let location_line = original
.lines()
.find(|line| line.trim_start().starts_with("location:"))
.expect("example location");
for empty in [" location:", " location: null", " location: ~"] {
parse_config(&original.replace(location_line, empty)).expect("empty YAML location");
parse_config(&original).expect("location belongs to runtime settings");
for value in [
"null",
"{}",
"{ country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
] {
let legacy = original.replacen(
" residency: us",
&format!(" residency: us\n location: {value}"),
1,
);
assert_ne!(legacy, original);
parse_config(&legacy).expect("removed request location does not prevent startup");
}
let custom = original.replace(
"location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
"location: { country: 'NZ', region: 'Auckland', city: 'Auckland', timezone: 'Pacific/Auckland' }",
);
assert_ne!(original, custom);
parse_config(&custom).expect("custom location from YAML");
}
#[test]
fn config_loader_should_reject_invalid_request_location_timezones() {
let original = valid_config();
let invalid = original.replace("timezone: 'America/New_York'", "timezone: 'Not/A_Timezone'");
assert_ne!(original, invalid);
assert_rejected(invalid);
}
fn assert_rejected(config: String) {
@@ -365,6 +517,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");
+2 -2
View File
@@ -25,6 +25,7 @@ rand_core = { version = "0.6.4", features = ["getrandom"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
secrecy = { version = "0.10.3", features = ["serde"] }
sha2 = "0.11"
subtle = "2.6.1"
thiserror = "2.0.18"
tracing = "0.1.44"
@@ -34,9 +35,8 @@ chrono-tz = "0.10"
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
[dev-dependencies]
sha2 = "0.11"
tempfile = "3"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] }
[lints]
workspace = true
@@ -0,0 +1,304 @@
//! 容量熔断冻结的恢复编排:到期探测、失败顺延与自适应并发下调。
//!
//! 冻结事实保存在可丢失的 Redis 冷却中,本服务只组合 Admin 端口:
//! 观测(`AccountRuntimeStore`)、探测(`AccountsService`)与写回
//! (原子降低并发 / 按冻结代次结束探测)。旧观测不会覆盖管理员操作。
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use chrono::{DateTime, Utc};
use futures::StreamExt as _;
use gateway_core::account::{AccountConcurrencyLimit, ProviderAccountId};
use gateway_core::routing::UpstreamModelId;
use gateway_core::task::{ScheduledTask, WorkerCycleContext, WorkerTaskError};
use tracing::warn;
use crate::model::accounts::{
AccountConnectionTestEvent, AccountFreeze, AccountPageItem, AccountRuntimeSnapshot,
};
use crate::model::{MutationActor, MutationContext};
use crate::ports::store::{AccountRuntimeStore, AccountStore, SettingsStore};
use crate::use_case::accounts::AccountsService;
/// 自适应并发下调保留系数:下调到观测在途峰值的 80%。
const ADAPTIVE_CONCURRENCY_FACTOR: f64 = 0.8;
/// 自适应并发下限:过低的并发让账号几乎不可用,宁可保持冻结。
const ADAPTIVE_CONCURRENCY_FLOOR: u32 = 2;
/// worker 周期;冷却观测与探测都依赖该节奏。
pub const FREEZE_RECOVERY_INTERVAL: Duration = Duration::from_secs(30);
pub const FREEZE_RECOVERY_WORKER_OWNER: &str = "account-freeze-recovery";
pub const WORKER_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
pub const WORKER_MAXIMUM_BACKOFF: Duration = Duration::from_secs(60);
pub const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
pub const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
/// 系统审计上下文的固定请求 ID;账号并发更新审计由此溯源到自动冻结编排。
const SYSTEM_REQUEST_ID: &str = "account-freeze-recovery";
/// 冻结恢复所需的管理端口组合。
#[derive(Clone)]
pub struct FreezeRecoveryDeps {
pub accounts: Arc<dyn AccountsService>,
pub store: Arc<dyn AccountStore>,
pub runtime: Arc<dyn AccountRuntimeStore>,
pub settings: Arc<dyn SettingsStore>,
}
pub struct FreezeRecoveryTask {
deps: FreezeRecoveryDeps,
}
impl FreezeRecoveryTask {
pub fn new(deps: FreezeRecoveryDeps) -> Self {
Self { deps }
}
fn system_context() -> MutationContext {
MutationContext {
actor: MutationActor::System,
request_id: SYSTEM_REQUEST_ID.to_owned(),
}
}
/// 读取冻结策略;读取或校验失败跳过本轮,不能把未知配置当作允许解冻。
async fn freeze_policy(&self) -> Option<gateway_core::provider_ports::ProviderFreezePolicy> {
let settings = self.deps.settings.load_runtime_settings().await.ok()?;
gateway_core::provider_ports::ProviderFreezePolicy::try_new(
settings.account_auto_freeze_enabled,
settings.account_auto_freeze_threshold,
settings.account_auto_freeze_window_seconds,
settings.account_auto_freeze_duration_seconds,
settings.account_auto_freeze_probe_enabled,
settings.account_auto_freeze_probe_model,
settings.account_auto_freeze_adaptive_concurrency,
)
.ok()
}
async fn active_freezes(&self) -> BTreeMap<String, AccountFreeze> {
self.deps.runtime.active_freezes().await.unwrap_or_default()
}
async fn run_cycle_inner(&self) {
let Some(policy) = self.freeze_policy().await else {
return;
};
let freezes = self.active_freezes().await;
if freezes.is_empty() {
return;
}
if policy.enabled() && policy.adaptive_concurrency() {
self.adapt_concurrency_limits(&freezes, &policy).await;
}
self.recover_due_freezes(&freezes, &policy).await;
}
/// 对冻结中的账号执行自适应并发下调:目标为观测在途峰值的 80%(下限 2),
/// 只降不升;未观测到峰值证据的账号保持现状。
async fn adapt_concurrency_limits(
&self,
freezes: &BTreeMap<String, AccountFreeze>,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) {
let account_ids = freezes.keys().cloned().collect::<Vec<_>>();
let Ok(peaks) = self.deps.runtime.capacity_peaks(&account_ids).await else {
return;
};
for (account_id, peak) in peaks {
let Some(target) = adaptive_target(peak) else {
continue;
};
let Ok(account) = ProviderAccountId::new(account_id.clone()) else {
continue;
};
let Some(limit) = AccountConcurrencyLimit::new(target) else {
continue;
};
match self
.deps
.accounts
.lower_concurrency_limit(&Self::system_context(), account, limit)
.await
{
Ok(Some(_)) => {
tracing::info!(
account_id,
adapted_limit = target,
freeze_seconds = policy.freeze_duration().as_secs(),
"容量熔断:按观测在途峰值下调账号并发上限",
);
}
Ok(None) => {}
Err(error) => {
warn!(account_id, error = %error, "自适应并发下调失败");
}
}
}
}
async fn load_account(&self, account_id: &str) -> Option<Option<AccountPageItem>> {
let account_id = ProviderAccountId::new(account_id.to_owned()).ok()?;
self.deps
.store
.load_account(account_id.as_str(), AccountRuntimeSnapshot::default())
.await
.ok()
}
/// 到达探测时间后才恢复;关闭自动冻结或探测时,已有冻结仍等待原冷却结束。
async fn recover_due_freezes(
&self,
freezes: &BTreeMap<String, AccountFreeze>,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) {
for (account_id, freeze) in freezes {
if freeze.until > Utc::now() {
continue;
}
if policy.enabled() && policy.probe_enabled() && freeze.requires_probe {
self.probe_and_recover(account_id, freeze, policy).await;
} else if let Err(error) = self
.deps
.runtime
.finish_freeze(account_id, freeze, None)
.await
{
warn!(account_id, error = %error, "解除到期冻结失败");
}
}
}
async fn probe_and_recover(
&self,
account_id: &str,
freeze: &AccountFreeze,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) {
let Some(Some(item)) = self.load_account(account_id).await else {
return;
};
if !item.account.enabled {
// 停用账号没有自动恢复意义;解冻交给管理员手动恢复。
return;
}
let Ok(account) = ProviderAccountId::new(account_id.to_owned()) else {
return;
};
let Some(model) = self.resolve_probe_model(&account, policy).await else {
// 没有可用探测模型时按失败处理,顺延冻结等待下一轮。
self.postpone(&account, freeze, policy).await;
return;
};
let probe_succeeded = match self
.deps
.accounts
.test_connection(account.clone(), model)
.await
{
Ok(events) => drain_probe(events).await,
Err(error) => {
tracing::debug!(account_id, error = %error, "冻结恢复探测发起失败");
false
}
};
if probe_succeeded {
match self
.deps
.runtime
.finish_freeze(account_id, freeze, None)
.await
{
Ok(true) => tracing::info!(account_id, "冻结恢复探测成功:账号已解冻"),
Ok(false) => {}
Err(error) => warn!(account_id, error = %error, "解除冻结失败"),
}
} else {
self.postpone(&account, freeze, policy).await;
}
}
/// 探测失败只顺延本次仍存在的冻结,旧探测不能重新冻结已手动恢复的账号。
async fn postpone(
&self,
account: &ProviderAccountId,
freeze: &AccountFreeze,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) {
let Some(until) = SystemTime::now().checked_add(policy.freeze_duration()) else {
return;
};
let until = DateTime::<Utc>::from(until);
match self
.deps
.runtime
.finish_freeze(account.as_str(), freeze, Some(until))
.await
{
Ok(true) => {
tracing::info!(
account_id = account.as_str(),
postpone_seconds = policy.freeze_duration().as_secs(),
"冻结恢复探测失败:账号冻结顺延",
);
}
Ok(false) => {}
Err(error) => {
warn!(
account_id = account.as_str(),
error = %error,
"冻结顺延失败"
);
}
}
}
async fn resolve_probe_model(
&self,
account: &ProviderAccountId,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) -> Option<UpstreamModelId> {
if let Some(model) = policy.probe_model() {
return UpstreamModelId::new(model.to_owned()).ok();
}
let models = self.deps.accounts.models(account, false).await.ok()?;
models.models.into_iter().next().map(|model| model.id)
}
}
impl ScheduledTask for FreezeRecoveryTask {
fn run_cycle(
&self,
context: WorkerCycleContext,
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
if context.cancellation().is_cancelled() {
return Ok(());
}
self.run_cycle_inner().await;
Ok(())
})
}
}
/// 自适应目标并发:观测峰值 × 0.8 向下取整,下限 2;无证据(峰值为 0)不调整。
fn adaptive_target(peak_in_flight: u32) -> Option<u32> {
if peak_in_flight == 0 {
return None;
}
let scaled = (f64::from(peak_in_flight) * ADAPTIVE_CONCURRENCY_FACTOR).floor() as u32;
Some(scaled.max(ADAPTIVE_CONCURRENCY_FLOOR))
}
/// 消耗连接测试事件流并返回是否以 `Completed` 终止。
async fn drain_probe(mut events: crate::model::accounts::AccountConnectionTestEventStream) -> bool {
let mut completed = false;
while let Some(event) = events.next().await {
match event {
AccountConnectionTestEvent::Completed => completed = true,
AccountConnectionTestEvent::Failed { .. } => return false,
_ => {}
}
}
completed
}
+172 -29
View File
@@ -5,27 +5,32 @@
use std::{fmt, path::Path, sync::Arc, time::Duration};
use gateway_core::{
engine::execution::ClientKeyVerifier,
engine::probe::AccountProbe,
routing::ProviderKind,
runtime::SnapshotControl,
task::{
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerRegistration,
WorkerRunnable,
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerLeaseRequest,
WorkerRegistration, WorkerRunnable, WorkerSchedule,
},
};
use secrecy::{ExposeSecret as _, SecretString};
use serde::Deserialize;
pub mod backup;
pub mod freeze_recovery;
pub mod model;
pub mod ports;
mod use_case;
pub use use_case::key_usage::KeyUsageService;
pub use use_case::{
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
backup::BackupService, client_distribution::ClientDistributionService,
client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService,
proxies::ProxiesService, settings::SettingsService, system::SystemService, xai::XaiService,
client_keys::ClientKeyService, import_tasks::ImportTasksService,
observability::ObservabilityService, openai::OpenAiService, proxies::ProxiesService,
settings::SettingsService, system::SystemService, xai::XaiService,
};
use model::{AdminError, AdminErrorKind};
@@ -46,7 +51,7 @@ use use_case::{
const OPENAI_PROVIDER_KIND: &str = "openai";
const XAI_PROVIDER_KIND: &str = "xai";
const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12;
const WEAK_INITIAL_PASSWORDS: &[&str] = &[
const WEAK_ADMIN_PASSWORDS: &[&str] = &[
"",
"admin",
"123456",
@@ -58,6 +63,7 @@ const WEAK_INITIAL_PASSWORDS: &[&str] = &[
];
const BACKUP_WORKER_OWNER: &str = "backup";
const DEFAULT_CLIENT_SESSION_TTL_MINUTES: u64 = 24 * 60;
/// 只用于首次幂等创建默认管理员的启动密码。
#[derive(Clone, Deserialize)]
@@ -91,13 +97,40 @@ impl fmt::Debug for InitialAdminPassword {
/// 管理控制面的启动配置。
#[derive(Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AdminConfig {
pub session_ttl_minutes: u64,
pub default_username: String,
pub default_password: InitialAdminPassword,
}
/// Client 登录域的通用启动配置。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ClientConfig {
pub session_ttl_minutes: u64,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
session_ttl_minutes: DEFAULT_CLIENT_SESSION_TTL_MINUTES,
}
}
}
impl ClientConfig {
/// 校验 Client session TTL;当前配置不含相对路径。
///
/// # Errors
///
/// 会话有效期为零或无法安全换算时返回错误。
pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), AdminConfigError> {
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
return Err(AdminConfigError::InvalidField("client.session_ttl_minutes"));
}
Ok(())
}
}
impl AdminConfig {
/// 校验 Admin-owned 字段;当前配置不含相对路径。
///
@@ -116,7 +149,7 @@ impl AdminConfig {
let password = self.default_password.expose().trim();
if password.len() < MINIMUM_INITIAL_PASSWORD_BYTES
|| password.contains('$')
|| WEAK_INITIAL_PASSWORDS.contains(&password.to_ascii_lowercase().as_str())
|| WEAK_ADMIN_PASSWORDS.contains(&password.to_ascii_lowercase().as_str())
{
return Err(AdminConfigError::WeakInitialPassword);
}
@@ -151,6 +184,7 @@ pub enum AdminConfigError {
pub struct AdminServices {
proxies: Arc<dyn ProxiesService>,
auth: Arc<dyn AuthService>,
key_usage: Arc<dyn KeyUsageService>,
accounts: Arc<dyn AccountsService>,
account_groups: Arc<dyn AccountGroupService>,
client_keys: Arc<dyn ClientKeyService>,
@@ -161,9 +195,26 @@ pub struct AdminServices {
openai: Arc<dyn OpenAiService>,
xai: Arc<dyn XaiService>,
backups: Arc<dyn BackupService>,
import_tasks: Arc<dyn ImportTasksService>,
}
impl AdminServices {
#[must_use]
pub fn import_tasks(&self) -> &dyn ImportTasksService {
self.import_tasks.as_ref()
}
#[must_use]
pub fn key_usage(&self) -> &dyn KeyUsageService {
self.key_usage.as_ref()
}
/// 取得账号服务的共享句柄;后台编排(冻结恢复 worker)需要持有 Arc。
#[must_use]
pub fn accounts_handle(&self) -> Arc<dyn AccountsService> {
Arc::clone(&self.accounts)
}
#[must_use]
pub fn proxies(&self) -> &dyn ProxiesService {
self.proxies.as_ref()
@@ -237,12 +288,24 @@ impl AdminBundle {
self.services.clone()
}
/// 取出 Backup Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host。
/// 取出 Admin Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host。
pub fn take_worker_contributions(&mut self) -> Vec<WorkerContribution> {
std::mem::take(&mut self.worker_contributions)
}
}
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
pub struct AdminRuntimePorts {
pub pricing_source: Arc<dyn ports::pricing::PricingSource>,
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 +313,26 @@ 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 {
pricing_source,
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 +344,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 +370,29 @@ 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 profile_provider = Arc::clone(&openai);
let openai = Arc::new(DefaultOpenAiService::new(
openai,
store.accounts(),
store.proxies(),
snapshot.clone(),
));
let xai = Arc::new(DefaultXaiService::new(
xai,
store.accounts(),
store.proxies(),
snapshot.clone(),
));
let import_tasks =
use_case::import_tasks::DefaultImportTasksService::new(openai.clone(), xai.clone());
let import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone());
let services = AdminServices {
key_usage,
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
store.proxies(),
proxy_probe,
@@ -304,7 +400,7 @@ pub async fn initialize(
registry.clone(),
)),
auth,
accounts,
accounts: accounts.clone(),
account_groups: Arc::new(DefaultAccountGroupService::new(
store.account_groups(),
store.account_runtime(),
@@ -313,34 +409,50 @@ pub async fn initialize(
client_keys: Arc::new(DefaultClientKeyService::new(
store.client_keys(),
snapshot.clone(),
Arc::clone(&profile_provider),
)),
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
observability: Arc::new(DefaultObservabilityService::new(
store.observability(),
store.accounts(),
store.settings(),
registry,
registry.clone(),
)),
settings: Arc::new(DefaultSettingsService::new(
store.settings(),
snapshot.clone(),
profile_provider,
registry,
pricing_source,
)),
system: Arc::new(DefaultSystemService::new(system)),
openai: Arc::new(DefaultOpenAiService::new(
openai,
store.accounts(),
store.proxies(),
snapshot.clone(),
)),
xai: Arc::new(DefaultXaiService::new(
xai,
store.accounts(),
store.proxies(),
snapshot.clone(),
)),
openai,
xai,
import_tasks,
backups,
};
let worker_contributions = backup_worker_contribution(backup_task)?;
let freeze_recovery =
freeze_recovery::FreezeRecoveryTask::new(freeze_recovery::FreezeRecoveryDeps {
accounts: Arc::clone(&accounts) as Arc<dyn AccountsService>,
store: store.accounts(),
runtime: store.account_runtime(),
settings: store.settings(),
});
let mut worker_contributions = backup_worker_contribution(backup_task)?;
let id = WorkerId::try_new(WorkerKind::AccountImport, "admin")
.map_err(|_| AdminError::internal("导入 Worker ID 不合法"))?;
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
.map_err(|_| AdminError::internal("导入 Worker 重启策略不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Daemon {
restart,
task: Box::new(import_task),
},
)
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
worker_contributions.push(WorkerContribution::Registration(registration));
worker_contributions.extend(freeze_recovery_worker_contribution(freeze_recovery)?);
Ok(AdminBundle {
services,
worker_contributions,
@@ -366,6 +478,37 @@ fn backup_worker_contribution(
Ok(vec![WorkerContribution::Registration(registration)])
}
/// 冻结恢复 Worker 注册:按固定周期扫描活跃冻结,owner 固定。
fn freeze_recovery_worker_contribution(
task: freeze_recovery::FreezeRecoveryTask,
) -> Result<Vec<WorkerContribution>, AdminError> {
let id = WorkerId::try_new(
WorkerKind::AccountFreezeRecovery,
freeze_recovery::FREEZE_RECOVERY_WORKER_OWNER,
)
.map_err(|_| AdminError::internal("冻结恢复 Worker ID 不合法"))?;
let schedule = WorkerSchedule::try_new(
freeze_recovery::FREEZE_RECOVERY_INTERVAL,
freeze_recovery::WORKER_INITIAL_BACKOFF,
freeze_recovery::WORKER_MAXIMUM_BACKOFF,
freeze_recovery::WORKER_LEASE_TTL,
freeze_recovery::WORKER_LEASE_RENEWAL,
)
.map_err(|_| AdminError::internal("冻结恢复 Worker 调度配置不合法"))?;
let lease = WorkerLeaseRequest::try_new(id.clone(), freeze_recovery::WORKER_LEASE_TTL)
.map_err(|_| AdminError::internal("冻结恢复 Worker 租约配置不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task: Box::new(task),
},
)
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
Ok(vec![WorkerContribution::Registration(registration)])
}
fn provider_kind(value: &'static str) -> Result<ProviderKind, AdminError> {
ProviderKind::new(value).map_err(|_| AdminError::internal("内置 Provider 类型不合法"))
}
@@ -79,6 +79,7 @@ pub struct AccountGroupListQuery {
/// Complete account group summary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupRecord {
pub disable_fast: bool,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
@@ -107,6 +108,7 @@ pub struct AccountGroupPage {
/// Create an account group.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAccountGroup {
pub disable_fast: bool,
pub name: String,
pub description: Option<String>,
pub color: AccountGroupColor,
@@ -115,6 +117,7 @@ pub struct CreateAccountGroup {
/// Store-ready create command with a generated stable ID.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewAccountGroup {
pub disable_fast: bool,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
@@ -124,6 +127,7 @@ pub struct NewAccountGroup {
/// Update an account group's descriptive fields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccountGroup {
pub disable_fast: Option<bool>,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
@@ -18,12 +18,14 @@ pub use gateway_core::account::{
QuotaState, resolve_account_status,
};
/// 导入时统一应用的账号调度与分组设置;缺省时保留原有导入语义。
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountImportSettings {
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
}
@@ -67,11 +69,20 @@ pub struct AccountListQuery {
/// Admin query service 从运行态存储取得的当前账号冷却快照。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AccountRuntimeSnapshot {
pub rate_limited_until: BTreeMap<String, DateTime<Utc>>,
pub cooldown: BTreeMap<String, gateway_core::account::AccountCooldown>,
/// `None` 表示实时 lease 存储不可用;`Some` 中未出现的账号当前使用量为零。
pub in_flight: Option<BTreeMap<String, u64>>,
}
/// 恢复任务读取的冻结快照;generation 将异步结果绑定到本次冻结。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountFreeze {
pub credential_revision: Revision,
pub until: DateTime<Utc>,
pub generation: String,
pub requires_probe: bool,
}
/// Optional account membership filter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountGroupFilter {
@@ -86,6 +97,7 @@ pub struct AccountRecord {
pub provider_kind: ProviderKind,
pub groups: Vec<AccountGroupRef>,
pub name: String,
pub notes: Option<String>,
pub email: Option<String>,
pub upstream_user_id: Option<String>,
pub upstream_account_id: Option<String>,
@@ -98,6 +110,7 @@ pub struct AccountRecord {
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub model_access: gateway_core::account::AccountModelAccess,
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub credential_state: CredentialState,
pub credential_observed_at: DateTime<Utc>,
@@ -217,9 +230,12 @@ pub struct AccountSummary {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccount {
pub account_id: String,
/// 缺省保留备注;空字符串清空备注。
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
@@ -231,14 +247,15 @@ pub struct AccountUpdateResult {
pub account_id: gateway_core::account::ProviderAccountId,
}
/// 一批账号可编辑事实的一次性替换命令。
/// 仅修改显式字段的批量账号设置命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchUpdateAccounts {
pub account_ids: Vec<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
pub enabled: Option<bool>,
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
pub weight: Option<AccountWeight>,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Option<Vec<gateway_core::routing::AccountGroupId>>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
+74 -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,99 @@ 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,
credential_fingerprint: String,
},
Key {
client_key_id: ClientApiKeyId,
},
}
/// 仅已登录管理员可以修改自己的密码。
pub struct ChangePassword {
pub current_password: String,
pub new_password: String,
}
impl std::fmt::Debug for ChangePassword {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("ChangePassword([REDACTED])")
}
}
/// 两种登录方式共用的固定有效期会话。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthSession {
pub subject: SessionSubject,
pub expires_at: DateTime<Utc>,
}
@@ -86,6 +86,7 @@ pub struct ClientKeyListQuery {
/// 不含完整明文 Key 的管理投影。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyRecord {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -144,6 +145,7 @@ impl fmt::Debug for ClientKeySecret {
/// API 提交的 Client Key 创建命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub custom_key: Option<PlaintextClientApiKey>,
pub name: String,
pub label: Option<String>,
@@ -155,6 +157,7 @@ pub struct CreateClientKey {
/// 管理用例生成 ID 与明文后的持久化命令。
#[derive(Clone, PartialEq, Eq)]
pub struct NewClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -179,6 +182,7 @@ impl fmt::Debug for NewClientKey {
/// 修改 Client Key 的公开策略字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKey {
pub openai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -195,6 +199,21 @@ pub struct SetClientKeyEnabled {
pub enabled: bool,
}
/// 管理员选择的金额重置范围。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientKeyBudgetPeriod {
Daily,
Weekly,
All,
}
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResetClientKeyBudget {
pub id: ClientApiKeyId,
pub period: ClientKeyBudgetPeriod,
}
/// 删除 Client Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteClientKey {
@@ -0,0 +1,84 @@
//! 进程内账号导入任务;只保存执行所需输入与可公开的结果。
use chrono::{DateTime, Utc};
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
use uuid::Uuid;
use super::{MutationContext, provider_credentials::ImportCredentials};
pub const MAX_IMPORT_TASK_ITEMS: usize = 200;
#[derive(Debug)]
pub struct ImportTaskInput {
pub provider: ProviderKind,
pub command: ImportCredentials,
}
#[derive(Debug)]
pub struct SubmitImportTask {
pub submission_id: Uuid,
/// API 对完整输入计算摘要;用于区分丢失响应后的重试与修改后的新提交。
pub fingerprint: [u8; 32],
pub context: MutationContext,
pub items: Vec<ImportTaskInput>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportItemStatus {
Pending,
Running,
Succeeded,
Failed,
Unknown,
Skipped,
}
impl ImportItemStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Running => "running",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::Unknown => "unknown",
Self::Skipped => "skipped",
}
}
}
#[derive(Debug, Clone)]
pub struct ImportTaskItem {
pub index: usize,
pub provider: ProviderKind,
pub status: ImportItemStatus,
pub account_ids: Vec<ProviderAccountId>,
pub message: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ImportTaskCounts {
pub pending: usize,
pub running: usize,
pub succeeded: usize,
pub failed: usize,
pub unknown: usize,
pub skipped: usize,
pub imported_accounts: usize,
}
#[derive(Debug, Clone)]
pub struct ImportTaskSummary {
pub task_id: Uuid,
pub created_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub stop_requested: bool,
pub total: usize,
pub counts: ImportTaskCounts,
}
#[derive(Debug, Clone)]
pub struct ImportTaskDetail {
pub summary: ImportTaskSummary,
pub items: Vec<ImportTaskItem>,
}
@@ -0,0 +1,41 @@
//! 单页 Key 用量的查询合同;身份范围不由调用方提供。
use super::{
PageSize,
client_keys::ClientKeyRecord,
observability::{
HealthTimeline, OpsErrorPage, RequestMetricPoint, TimeRange, UsageOverview, UsagePage,
},
};
#[derive(Debug, Clone)]
pub struct KeyUsageQuery {
pub range: TimeRange,
pub model: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub enum KeyUsageRecordKind {
Success,
Error,
}
#[derive(Debug, Clone)]
pub struct KeyUsageRecordsQuery {
pub usage: KeyUsageQuery,
pub kind: KeyUsageRecordKind,
pub current_page: u32,
pub page_size: PageSize,
}
pub struct KeyUsageOverview {
pub key: ClientKeyRecord,
pub overview: UsageOverview,
pub trend: Vec<RequestMetricPoint>,
pub health_timeline: HealthTimeline,
}
pub enum KeyUsageRecords {
Success(UsagePage),
Error(OpsErrorPage),
}
@@ -8,7 +8,10 @@ pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod pricing;
pub mod provider_credentials;
pub mod proxies;
pub mod quota_forecast;
@@ -21,6 +24,7 @@ pub mod system;
pub enum AdminErrorKind {
Invalid,
Unauthorized,
Forbidden,
NotFound,
Conflict,
RateLimited,
@@ -297,6 +297,7 @@ pub struct ProviderBillingInput {
/// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageCalculatedBillingFact {
pub breakdown: Option<CalculatedBillingBreakdown>,
pub bucket_start: DateTime<Utc>,
pub provider_kind: String,
pub upstream_model_id: String,
@@ -311,6 +312,8 @@ pub struct UsageCalculatedBillingFact {
/// Provider 已确认的逐项费用与单价。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedBillingBreakdown {
pub image: Option<ImageBillingBreakdown>,
pub custom_multiplier_bps: u32,
pub input_amount: CurrencyCost,
pub output_amount: CurrencyCost,
pub cache_read_amount: CurrencyCost,
@@ -325,6 +328,16 @@ pub struct CalculatedBillingBreakdown {
pub multiplier_percent: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageBillingBreakdown {
pub input_tokens: u64,
pub cached_tokens: u64,
pub input_amount: CurrencyCost,
pub cache_read_amount: CurrencyCost,
pub input_price_per_million: CurrencyCost,
pub cache_read_price_per_million: CurrencyCost,
}
/// 单次请求的费用语义。
///
/// Provider 上报费用或无法恢复逐项价格时保留总额;Provider 验证成功后升级为完整分解。
@@ -544,6 +557,7 @@ pub struct UsageListRecord {
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
@@ -602,6 +616,7 @@ pub struct UsageRecord {
pub upstream_transport: Option<String>,
pub http_version: Option<String>,
pub websocket_pool: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明。
pub provider_metadata_json: Option<String>,
@@ -0,0 +1,52 @@
//! 全局模型定价的管理合同。
use gateway_core::metering::{ModelPriceOverride, PricingOverrides};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PricingCatalog {
pub defaults: PricingOverrides,
pub overrides: PricingOverrides,
pub synced: PricingOverrides,
pub synced_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StoredPricing {
pub overrides: PricingOverrides,
pub synced: PricingOverrides,
pub synced_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PricingSyncPreview {
pub prices: PricingOverrides,
pub skipped: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SyncPricing {
pub preview: PricingSyncPreview,
pub models: BTreeMap<String, BTreeSet<String>>,
}
pub type PricingSyncChanges = BTreeMap<String, BTreeMap<String, Option<ModelPriceOverride>>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PricingChange {
Replace(ModelPriceOverride),
Multiplier(u32),
Reset,
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdatePricing {
pub provider: String,
pub models: Vec<String>,
pub change: PricingChange,
}
pub type ProviderPricingCatalog = BTreeMap<String, ModelPriceOverride>;
@@ -99,6 +99,7 @@ impl fmt::Debug for PrepareCredentialImport {
/// Provider 已验证、可由 Store 原子创建的一份 credential。
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialCreate {
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub account_id: ProviderAccountId,
pub provider_kind: ProviderKind,
@@ -584,6 +585,7 @@ pub struct CredentialDeletionResult {
pub struct RotateCredential {
pub mutation: CredentialMutation,
pub provider_material: ProviderDocument,
pub settings: Option<super::accounts::UpdateAccount>,
}
/// Provider 校验手工轮换材料时所需的非事务输入。
@@ -612,6 +614,8 @@ pub struct PreparedCredentialRotationFacts {
pub name: String,
pub email: Option<String>,
pub plan_type: Option<String>,
/// Token 刷新保留提交时的资料,不以准备阶段的副本覆盖新套餐。
pub preserve_profile: bool,
pub provider_material: ProviderDocument,
pub has_refresh_token: bool,
pub access_token_expires_at: Option<DateTime<Utc>>,
@@ -663,6 +667,7 @@ impl fmt::Debug for PreparedCredentialRotation {
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialRotationCommit {
pub prepared: PreparedCredentialRotationFacts,
pub settings: Option<super::accounts::UpdateAccount>,
}
impl fmt::Debug for RotateCredential {
@@ -671,6 +676,7 @@ impl fmt::Debug for RotateCredential {
.debug_struct("RotateCredential")
.field("mutation", &self.mutation)
.field("provider_material", &self.provider_material)
.field("settings", &self.settings)
.finish()
}
}
@@ -60,11 +60,14 @@ pub struct ProxyTestResult {
pub success: bool,
pub latency_ms: u64,
pub exit_ip: Option<std::net::IpAddr>,
pub exit_ipv4: Option<std::net::Ipv4Addr>,
pub exit_ipv6: Option<std::net::Ipv6Addr>,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct ProxyRecord {
pub location: Option<gateway_core::account::RequestLocation>,
pub id: String,
pub name: String,
pub proxy: OutboundProxy,
@@ -86,12 +89,15 @@ pub struct ProxyPage {
#[derive(Debug, Clone)]
pub struct NewProxy {
pub location: Option<gateway_core::account::RequestLocation>,
pub name: String,
pub proxy: OutboundProxy,
}
#[derive(Debug, Clone)]
pub struct UpdateProxy {
/// 外层为空保留配置,内层为空恢复全局继承。
pub location: Option<Option<gateway_core::account::RequestLocation>>,
pub id: String,
pub revision: Revision,
pub name: String,
@@ -17,35 +17,63 @@ pub use gateway_core::account::RotationStrategy;
/// 完整运行设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeSettings {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub config_revision: Revision,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u32,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub updated_at: DateTime<Utc>,
}
/// 原子替换运行设置的命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplaceRuntimeSettings {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u32,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
}
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
@@ -2,6 +2,7 @@
pub mod backup;
pub mod client_distribution;
pub mod pricing;
pub mod provider;
pub mod proxy;
pub mod store;
@@ -0,0 +1,9 @@
//! 手动同步的外部价目来源;网络和外部文档解析由 Host 实现。
use crate::model::{AdminError, pricing::PricingSyncPreview};
use async_trait::async_trait;
#[async_trait]
pub trait PricingSource: Send + Sync {
async fn fetch(&self) -> Result<PricingSyncPreview, AdminError>;
}
@@ -110,8 +110,28 @@ impl ProviderAdminError {
/// 全部由 [`crate::ports::store::AccountStore`] 提交。运行时资源通知只在事务成功后发生。
#[async_trait]
pub trait ProviderAdmin: Send + Sync {
/// 只读内置价目;没有本地计价能力的 Provider 返回空目录。
fn pricing_catalog(&self) -> crate::model::pricing::ProviderPricingCatalog {
Default::default()
}
fn provider_kind(&self) -> &ProviderKind;
/// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段。
fn client_profile_options(
&self,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 校验并投影客户端身份,结果不含认证或账号材料。
fn preview_client_profile(
&self,
_configuration: &gateway_core::account::OpaqueProviderData,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 将原始套餐值投影为展示名称;默认保留未知 Provider 的原始名称。
fn plan_type_display(&self, plan_type: &str) -> String {
plan_type.to_owned()
@@ -139,6 +159,13 @@ pub trait ProviderAdmin: Send + Sync {
/// 返回该 Provider 实际持有的 Dashboard 上游身份画像。
fn dashboard_wire_profile(&self) -> Option<DashboardWireProfile>;
fn configured_wire_profile(
&self,
_configuration: &gateway_core::account::OpaqueProviderData,
) -> Option<DashboardWireProfile> {
self.dashboard_wire_profile()
}
/// 使用 Provider-owned 价格规则恢复持久请求的逐项费用。
fn calculated_billing(
&self,
@@ -170,6 +197,14 @@ pub trait ProviderAdmin: Send + Sync {
command: PrepareCredentialRefresh,
) -> Result<PreparedCredentialRotation, ProviderAdminError>;
/// 可公开的账号连接设置;实现只能显式投影非敏感字段,不能返回凭据原文。
async fn account_configuration(
&self,
_account_id: &ProviderAccountId,
) -> Result<Option<ProviderDocument>, ProviderAdminError> {
Ok(None)
}
async fn quota(
&self,
request: ProviderQuotaRequest,
@@ -243,6 +278,14 @@ pub struct ProviderAdminRegistry {
}
impl ProviderAdminRegistry {
#[must_use]
pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides {
self.providers
.iter()
.map(|(kind, provider)| (kind.as_str().to_owned(), provider.pricing_catalog()))
.collect()
}
/// 创建无重复 ProviderKind 的注册表。
///
/// # Errors
@@ -305,10 +348,19 @@ impl ProviderAdminRegistry {
}
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
pub fn dashboard_wire_profiles(&self) -> Vec<DashboardWireProfile> {
pub fn dashboard_wire_profiles(
&self,
configurations: &std::collections::BTreeMap<
ProviderKind,
gateway_core::account::OpaqueProviderData,
>,
) -> Vec<DashboardWireProfile> {
self.providers
.values()
.filter_map(|provider| provider.dashboard_wire_profile())
.iter()
.filter_map(|(kind, provider)| match configurations.get(kind) {
Some(configuration) => provider.configured_wire_profile(configuration),
None => provider.dashboard_wire_profile(),
})
.collect()
}
@@ -2,7 +2,7 @@
//!
//! 端口按业务资源拆分,方法使用领域模型,不暴露连接池、事务或 Redis client。
use std::sync::Arc;
use std::{collections::BTreeMap, net::IpAddr, sync::Arc, time::Duration};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
@@ -20,10 +20,10 @@ 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,
NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled, UpdateClientKey,
},
observability::{
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
@@ -159,6 +159,14 @@ pub trait AccountStore: Send + Sync {
context: &MutationContext,
) -> AdminStoreResult<AccountUpdateResult>;
/// 在事务内按最新启用状态、账号上限和全局默认值判断,只降低并发上限。
async fn lower_concurrency_limit(
&self,
account_id: &gateway_core::account::ProviderAccountId,
limit: gateway_core::account::AccountConcurrencyLimit,
context: &MutationContext,
) -> AdminStoreResult<Option<AccountUpdateResult>>;
async fn recover_account(
&self,
account_id: &gateway_core::account::ProviderAccountId,
@@ -193,13 +201,41 @@ pub trait AccountRuntimeStore: Send + Sync {
&self,
account_ids: &[String],
) -> AdminStoreResult<AccountRuntimeSnapshot>;
/// 容量熔断自动冻结中的账号与其冻结截止时间;429 临时限流不包含在内。
async fn active_freezes(
&self,
) -> AdminStoreResult<BTreeMap<String, crate::model::accounts::AccountFreeze>>;
/// 读取容量失败窗口内观测到的在途并发峰值(自适应并发下调的证据)。
async fn capacity_peaks(
&self,
account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>>;
/// 仅当冻结快照仍匹配时解除或顺延;旧探测不得覆盖手动恢复或新一轮冻结。
async fn finish_freeze(
&self,
account_id: &str,
expected: &crate::model::accounts::AccountFreeze,
postpone_until: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool>;
}
/// 管理员密码、会话和安全审计。
/// 控制面凭据、统一会话、登录限流与管理员安全审计。
#[async_trait]
pub trait AuthStore: Send + Sync {
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
/// 密码更新与审计必须在同一事务提交;旧哈希不匹配时不写入。
async fn change_password(
&self,
admin_user_id: &str,
expected_hash: &str,
password_hash: &str,
audit: AdminAuditEvent,
) -> AdminStoreResult<bool>;
async fn create_password_hash_if_absent(
&self,
admin_user_id: &str,
@@ -208,19 +244,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(
@@ -251,6 +306,13 @@ pub trait ClientKeyStore: Send + Sync {
command: DeleteClientKey,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
/// 仅修改运行时账本并原子记录审计,不推进配置版本。
async fn reset_client_key_budget(
&self,
command: ResetClientKeyBudget,
context: &MutationContext,
) -> AdminStoreResult<()>;
}
/// Provider-neutral account group management transactions.
@@ -355,6 +417,18 @@ pub trait ObservabilityStore: Send + Sync {
/// Runtime settings 与管理员 API Key 写入。
#[async_trait]
pub trait SettingsStore: Send + Sync {
async fn load_pricing(&self) -> AdminStoreResult<crate::model::pricing::StoredPricing>;
async fn sync_pricing(
&self,
changes: crate::model::pricing::PricingSyncChanges,
context: &MutationContext,
) -> AdminStoreResult<crate::model::Revision>;
async fn update_pricing(
&self,
command: crate::model::pricing::UpdatePricing,
context: &MutationContext,
) -> AdminStoreResult<crate::model::Revision>;
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings>;
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool>;
@@ -144,6 +144,7 @@ impl AccountGroupService for DefaultAccountGroupService {
id,
name: command.name,
description: command.description,
disable_fast: command.disable_fast,
color: command.color,
},
context,
@@ -192,11 +193,7 @@ fn project_group_runtime(
let mut accounts_by_group = BTreeMap::<&str, Vec<&str>>::new();
for member in members {
let mut status = member.status.clone();
status.rate_limited_until = runtime
.rate_limited_until
.get(&member.account_id)
.copied()
.map(Into::into);
status.cooldown = runtime.cooldown.get(&member.account_id).copied();
status_by_account
.entry(member.account_id.as_str())
.or_insert_with(|| resolve_account_status(&status, now).status);
@@ -17,8 +17,8 @@ use crate::{
AdminError, MutationContext,
accounts::{
AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery,
AccountPageItem, AccountUpdateResult, AccountUsageWindowQuery, AccountsUpdateResult,
BatchUpdateAccounts, UpdateAccount,
AccountPageItem, AccountUpdateResult, AccountUsage, AccountUsageWindowQuery,
AccountsUpdateResult, BatchUpdateAccounts, UpdateAccount,
},
observability::TimeRange,
provider_credentials::{
@@ -73,12 +73,26 @@ pub trait AccountsService: Send + Sync {
command: UpdateAccount,
) -> Result<AccountUpdateResult, AdminError>;
async fn lower_concurrency_limit(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
limit: gateway_core::account::AccountConcurrencyLimit,
) -> Result<Option<AccountUpdateResult>, AdminError>;
async fn batch_update(
&self,
context: &MutationContext,
command: BatchUpdateAccounts,
) -> Result<AccountsUpdateResult, AdminError>;
async fn account_configuration(
&self,
_account_id: &ProviderAccountId,
) -> Result<Option<crate::model::provider_credentials::ProviderDocument>, AdminError> {
Ok(None)
}
async fn quota(
&self,
account_id: &ProviderAccountId,
@@ -250,6 +264,37 @@ impl DefaultAccountsService {
Ok(())
}
async fn load_api_key_usage(
&self,
accounts: &[AccountPageItem],
) -> Result<BTreeMap<String, AccountUsage>, AdminError> {
let now = Utc::now();
// API Key 没有套餐周期;本地累计直接查询账号创建后仍保留的请求记录。
let windows = accounts
.iter()
.filter(|item| item.account.authentication_kind == "api_key")
.map(|item| AccountUsageWindowQuery {
account_id: item.account.id.clone(),
key: "account-lifetime".to_owned(),
range: TimeRange {
start: item.account.created_at,
end: now,
},
})
.collect::<Vec<_>>();
if windows.is_empty() {
return Ok(BTreeMap::new());
}
Ok(self
.accounts
.load_account_usage_by_windows(&windows)
.await
.map_err(|error| map_store_error(error, "API Key account usage"))?
.into_iter()
.map(|result| (result.account_id, result.usage))
.collect())
}
async fn load_directory_item(
&self,
account_id: &ProviderAccountId,
@@ -287,9 +332,15 @@ impl DefaultAccountsService {
std::slice::from_mut(&mut quota),
)
.await?;
let usage = quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone());
let usage = self
.load_api_key_usage(std::slice::from_ref(&stored))
.await?
.remove(&stored.account.id)
.or_else(|| {
quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone())
});
Ok(AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
stored.account.provider_kind.as_str(),
@@ -377,14 +428,17 @@ impl AccountsService for DefaultAccountsService {
.collect::<Result<Vec<_>, AdminError>>()?;
self.attach_quota_local_usage(&page.items, &mut quotas)
.await?;
let mut api_key_usage = self.load_api_key_usage(&page.items).await?;
let items = page
.items
.into_iter()
.zip(quotas)
.map(|(mut item, quota)| {
let usage = quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone());
let usage = api_key_usage.remove(&item.account.id).or_else(|| {
quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone())
});
AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
item.account.provider_kind.as_str(),
@@ -535,6 +589,27 @@ impl AccountsService for DefaultAccountsService {
Ok(result)
}
async fn lower_concurrency_limit(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
limit: gateway_core::account::AccountConcurrencyLimit,
) -> Result<Option<AccountUpdateResult>, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
let result = self
.accounts
.lower_concurrency_limit(&account_id, limit, context)
.await
.map_err(|error| map_store_error(error, "provider account"))?;
if let Some(result) = &result {
provider
.account_facts_changed(std::slice::from_ref(&account_id))
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
}
Ok(result)
}
async fn batch_update(
&self,
context: &MutationContext,
@@ -570,7 +645,7 @@ impl AccountsService for DefaultAccountsService {
.await
.map_err(|error| map_store_error(error, "provider accounts"))?;
for (provider, provider_ids) in providers.values() {
if !enabled {
if enabled == Some(false) {
for account_id in provider_ids {
provider.account_unavailable(account_id).await;
}
@@ -581,6 +656,17 @@ impl AccountsService for DefaultAccountsService {
Ok(result)
}
async fn account_configuration(
&self,
account_id: &ProviderAccountId,
) -> Result<Option<crate::model::provider_credentials::ProviderDocument>, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.account_configuration(account_id)
.await
.map_err(|error| map_provider_error(error, "provider account configuration"))
}
async fn quota(
&self,
account_id: &ProviderAccountId,
+286 -77
View File
@@ -1,20 +1,23 @@
//! 管理员认证状态机。
//! 控制面登录、会话恢复与身份校验的唯一 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 sha2::{Digest, Sha256};
use subtle::ConstantTimeEq as _;
use uuid::Uuid;
use crate::{
model::{
AdminError,
AdminError, AdminErrorKind,
auth::{
AdminAuditEvent, AdminSession, AuditActorKind, LoginCommand, LoginError, LoginResult,
AdminAuditEvent, AuditActorKind, AuthSession, ChangePassword, LoginCommand, LoginError,
LoginResult, SessionSubject,
},
},
ports::store::AuthStore,
@@ -22,67 +25,193 @@ use crate::{
use super::map_store_error;
/// API 鉴权与管理员登录消费的统一服务。
/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定。
#[async_trait]
pub trait AuthService: Send + Sync {
async fn change_password(
&self,
session_id: Option<&str>,
command: ChangePassword,
source_ip: IpAddr,
) -> Result<(), AdminError>;
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError>;
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError>;
async fn resolve_admin_user_id(
&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 password_hash = self
.store
.load_password_hash(&self.default_admin_user_id)
.await
.map_err(|_| LoginError::Unavailable)?
.ok_or(LoginError::InvalidCredentials)?;
if password.len() > 4096
|| !verify_admin_password(&password, &password_hash)
.map_err(|_| LoginError::Unavailable)?
{
return Err(LoginError::InvalidCredentials);
}
Ok(SessionSubject::Admin {
admin_user_id: self.default_admin_user_id.clone(),
credential_fingerprint: password_fingerprint(&password_hash),
})
}
LoginCommand::Key { api_key } => {
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 })
}
}
}
}
#[async_trait]
impl AuthService for DefaultAuthService {
async fn change_password(
&self,
session_id: Option<&str>,
command: ChangePassword,
source_ip: IpAddr,
) -> Result<(), AdminError> {
let session = self
.session(session_id)
.await?
.ok_or_else(|| AdminError::new(AdminErrorKind::Unauthorized, "请先登录"))?;
let SessionSubject::Admin {
admin_user_id,
credential_fingerprint,
} = session.subject
else {
return Err(AdminError::new(
AdminErrorKind::Forbidden,
"仅管理员可以修改密码",
));
};
if self
.store
.consume_login_attempt(
source_ip,
LOGIN_ATTEMPTS_PER_SOURCE,
LOGIN_ATTEMPTS_GLOBAL,
LOGIN_WINDOW,
)
.await
.map_err(|error| map_store_error(error, "password change limit"))?
.is_some()
{
return Err(AdminError::new(
AdminErrorKind::RateLimited,
"尝试过于频繁,请稍后再试",
));
}
validate_new_password(&command.new_password)?;
let password_hash = self
.store
.load_password_hash(&admin_user_id)
.await
.map_err(|error| map_store_error(error, "administrator"))?
.filter(|hash| password_fingerprint(hash) == credential_fingerprint)
.ok_or_else(|| AdminError::conflict("密码已变更,请重新登录"))?;
if command.current_password.len() > 4096
|| !verify_admin_password(&command.current_password, &password_hash)?
{
return Err(AdminError::invalid("当前密码不正确"));
}
if command.new_password == command.current_password {
return Err(AdminError::invalid("新密码不能与当前密码相同"));
}
let hash = hash_admin_password(&command.new_password)?;
let mut audit = self.auth_audit("admin.password_changed", &admin_user_id);
audit.entity_kind = "admin_user".to_owned();
audit.changed_fields = vec!["password".to_owned()];
if !self
.store
.change_password(&admin_user_id, &password_hash, &hash, audit)
.await
.map_err(|error| map_store_error(error, "administrator password"))?
{
return Err(AdminError::conflict("密码已变更,请重新登录"));
}
// 密码事务提交后旧指纹不再匹配,会话撤销不依赖 Redis 删除成功。
Ok(())
}
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError> {
let hash = hash_admin_password(password)?;
self.store
@@ -91,22 +220,68 @@ 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::Admin {
admin_user_id,
credential_fingerprint,
} = &session.subject
{
let password_hash = self
.store
.load_password_hash(admin_user_id)
.await
.map_err(|error| map_store_error(error, "administrator session"))?;
if password_hash
.is_none_or(|hash| password_fingerprint(&hash) != *credential_fingerprint)
{
let _ = self.store.delete_session(session_id).await;
return Ok(None);
}
}
if let SessionSubject::Key { client_key_id } = &session.subject
&& !self
.store
.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 +299,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 +377,27 @@ 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 validate_new_password(password: &str) -> Result<(), AdminError> {
if password.trim().chars().count() < 12
|| password.len() > 1024
|| password.chars().any(char::is_control)
|| crate::WEAK_ADMIN_PASSWORDS.contains(&password.trim().to_ascii_lowercase().as_str())
{
return Err(AdminError::invalid(
"新密码至少需要 12 个字符,最多 1024 字节,不能使用常见弱口令或控制字符",
));
}
Ok(())
}
fn hash_admin_password(password: &str) -> Result<String, AdminError> {
Argon2::default()
.hash_password(password.as_bytes())
@@ -220,3 +424,8 @@ fn valid_admin_api_key_shape(value: &str) -> bool {
&& value.starts_with("admin-")
&& value[6..].bytes().all(|byte| byte.is_ascii_hexdigit())
}
// 指纹只绑定已加盐的密码哈希,不把密码或原始哈希复制到 Redis 会话。
fn password_fingerprint(password_hash: &str) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(password_hash.as_bytes()))
}
@@ -15,7 +15,8 @@ use crate::{
client_keys::{
ClientKeyCursorValue, ClientKeyListQuery, ClientKeyMutation, ClientKeyPage,
ClientKeySecret, ClientKeySortField, CreateClientKey, CreatedClientKey,
DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey,
DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
UpdateClientKey,
},
},
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
@@ -48,22 +49,49 @@ pub trait ClientKeyService: Send + Sync {
context: &MutationContext,
command: DeleteClientKey,
) -> Result<ClientKeyMutation, AdminError>;
async fn reset_budget(
&self,
context: &MutationContext,
command: ResetClientKeyBudget,
) -> Result<ClientApiKeyId, AdminError>;
}
pub(crate) struct DefaultClientKeyService {
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
store: Arc<dyn ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultClientKeyService {
#[must_use]
pub(crate) fn new(store: Arc<dyn ClientKeyStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
Self { store, snapshot }
pub(crate) fn new(
store: Arc<dyn ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
) -> Self {
Self {
store,
snapshot,
profile_provider,
}
}
}
#[async_trait]
impl ClientKeyService for DefaultClientKeyService {
async fn reset_budget(
&self,
context: &MutationContext,
command: ResetClientKeyBudget,
) -> Result<ClientApiKeyId, AdminError> {
let id = command.id.clone();
self.store
.reset_client_key_budget(command, context)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
Ok(id)
}
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError> {
validate_cursor(&query)?;
self.store
@@ -85,6 +113,11 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError> {
if let Some(profile) = &command.openai_client_profile_override {
self.profile_provider
.preview_client_profile(profile)
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
let plaintext = if let Some(key) = command.custom_key {
@@ -98,6 +131,7 @@ impl ClientKeyService for DefaultClientKeyService {
.store
.create_client_key(
NewClientKey {
openai_client_profile_override: command.openai_client_profile_override,
id,
name: command.name,
label: command.label,
@@ -122,6 +156,11 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: UpdateClientKey,
) -> Result<ClientKeyMutation, AdminError> {
if let Some(Some(profile)) = &command.openai_client_profile_override {
self.profile_provider
.preview_client_profile(profile)
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
let id = command.id.clone();
let (config_revision, record) = self
.store
@@ -0,0 +1,372 @@
//! 导入队列由 Host 管理生命周期,HTTP 只负责接受输入和读取快照。
use std::{
collections::VecDeque,
panic::AssertUnwindSafe,
sync::{Arc, Mutex, MutexGuard},
time::Duration,
};
use chrono::Utc;
use futures::{FutureExt as _, StreamExt as _, future::BoxFuture, stream::FuturesUnordered};
use gateway_core::{
lifecycle::CancellationToken,
task::{DaemonTask, WorkerTaskError},
};
use tokio::{sync::Notify, time::Instant};
use uuid::Uuid;
use super::{openai::OpenAiService, xai::XaiService};
use crate::model::{
AdminError, AdminErrorKind, MutationActor, MutationContext,
import_tasks::{
ImportItemStatus, ImportTaskCounts, ImportTaskDetail, ImportTaskInput, ImportTaskItem,
ImportTaskSummary, MAX_IMPORT_TASK_ITEMS, SubmitImportTask,
},
};
const CONCURRENCY: usize = 3;
const MAX_ACTIVE_TASKS: usize = 8;
const MAX_RETAINED_TASKS: usize = 100;
const RETENTION: Duration = Duration::from_secs(3600);
pub trait ImportTasksService: Send + Sync {
fn submit(&self, command: SubmitImportTask) -> Result<ImportTaskSummary, AdminError>;
fn list(&self, context: &MutationContext) -> Vec<ImportTaskSummary>;
fn detail(
&self,
context: &MutationContext,
task_id: Uuid,
) -> Result<ImportTaskDetail, AdminError>;
fn stop(
&self,
context: &MutationContext,
task_id: Uuid,
) -> Result<ImportTaskDetail, AdminError>;
}
struct Entry {
task_id: Uuid,
submission_id: Uuid,
fingerprint: [u8; 32],
owner: MutationActor,
created_at: chrono::DateTime<Utc>,
finished_at: Option<chrono::DateTime<Utc>>,
finished_instant: Option<Instant>,
stop_requested: bool,
items: Vec<ImportTaskItem>,
inputs: Vec<Option<ImportTaskInput>>,
}
impl Entry {
fn summary(&self) -> ImportTaskSummary {
let mut counts = ImportTaskCounts::default();
for item in &self.items {
match item.status {
ImportItemStatus::Pending => counts.pending += 1,
ImportItemStatus::Running => counts.running += 1,
ImportItemStatus::Succeeded => counts.succeeded += 1,
ImportItemStatus::Failed => counts.failed += 1,
ImportItemStatus::Unknown => counts.unknown += 1,
ImportItemStatus::Skipped => counts.skipped += 1,
}
counts.imported_accounts += item.account_ids.len();
}
ImportTaskSummary {
task_id: self.task_id,
created_at: self.created_at,
finished_at: self.finished_at,
stop_requested: self.stop_requested,
total: self.items.len(),
counts,
}
}
fn detail(&self) -> ImportTaskDetail {
ImportTaskDetail {
summary: self.summary(),
items: self.items.clone(),
}
}
fn finish_if_done(&mut self) {
if self.finished_at.is_none()
&& self.items.iter().all(|item| {
!matches!(
item.status,
ImportItemStatus::Pending | ImportItemStatus::Running
)
})
{
self.finished_at = Some(Utc::now());
self.finished_instant = Some(Instant::now());
// 终态仅保留结果,连已清空的输入槽位分配也一并释放。
self.inputs = Vec::new();
}
}
fn stop(&mut self) {
if self.finished_at.is_some() {
return;
}
self.stop_requested = true;
for (item, input) in self.items.iter_mut().zip(&mut self.inputs) {
if item.status == ImportItemStatus::Pending {
item.status = ImportItemStatus::Skipped;
*input = None;
}
}
self.finish_if_done();
}
}
#[derive(Default)]
struct Registry {
tasks: VecDeque<Entry>,
shutting_down: bool,
}
pub(crate) struct DefaultImportTasksService {
registry: Mutex<Registry>,
notify: Notify,
openai: Arc<dyn OpenAiService>,
xai: Arc<dyn XaiService>,
}
impl DefaultImportTasksService {
pub(crate) fn new(openai: Arc<dyn OpenAiService>, xai: Arc<dyn XaiService>) -> Arc<Self> {
Arc::new(Self {
registry: Mutex::default(),
notify: Notify::new(),
openai,
xai,
})
}
fn registry(&self) -> MutexGuard<'_, Registry> {
let mut registry = self
.registry
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
registry.tasks.retain(|entry| {
entry
.finished_instant
.is_none_or(|at| at.elapsed() < RETENTION)
});
registry
}
fn take_next(&self) -> Option<(Uuid, usize, ImportTaskInput)> {
let mut registry = self.registry();
if registry.shutting_down {
return None;
}
let task_index = registry
.tasks
.iter()
.position(|entry| entry.inputs.iter().any(Option::is_some))?;
// 轮转任务,避免大批次独占后续全部执行机会。
let mut entry = registry.tasks.remove(task_index)?;
let index = entry.inputs.iter().position(Option::is_some)?;
let input = entry.inputs[index].take()?;
entry.items[index].status = ImportItemStatus::Running;
let id = entry.task_id;
registry.tasks.push_back(entry);
Some((id, index, input))
}
async fn execute(&self, id: Uuid, index: usize, input: ImportTaskInput) {
let operation = async {
match input.provider.as_str() {
"openai" => self.openai.import_document(input.command).await,
"xai" => self.xai.import_document(input.command).await,
_ => Err(AdminError::invalid("不支持的导入平台")),
}
};
let result = AssertUnwindSafe(operation).catch_unwind().await;
let mut registry = self.registry();
let Some(entry) = registry.tasks.iter_mut().find(|entry| entry.task_id == id) else {
return;
};
let item = &mut entry.items[index];
match result {
Ok(Ok(result)) => {
item.status = ImportItemStatus::Succeeded;
item.account_ids = result.credential_ids;
}
Ok(Err(error)) => {
// 存储或发布异常也可能发生在提交之后;不自动重放可能轮换 RT 的操作。
item.status = if matches!(
error.kind(),
AdminErrorKind::UpstreamResultUnknown
| AdminErrorKind::Internal
| AdminErrorKind::Unavailable
) {
ImportItemStatus::Unknown
} else {
ImportItemStatus::Failed
};
item.message = Some(error.message().to_owned());
}
Err(_) => {
item.status = ImportItemStatus::Unknown;
item.message = Some("执行中断,请先核对账号列表再决定是否重新导入".to_owned());
}
}
entry.finish_if_done();
}
}
impl ImportTasksService for DefaultImportTasksService {
fn submit(&self, command: SubmitImportTask) -> Result<ImportTaskSummary, AdminError> {
if command.items.is_empty()
|| command.items.len() > MAX_IMPORT_TASK_ITEMS
|| command.items.iter().any(|item| {
!matches!(item.provider.as_str(), "openai" | "xai")
|| item.command.context.actor != command.context.actor
})
{
return Err(AdminError::invalid("导入任务需包含 1–200 条有效输入"));
}
let mut registry = self.registry();
if let Some(existing) = registry.tasks.iter().find(|entry| {
entry.owner == command.context.actor && entry.submission_id == command.submission_id
}) {
return if existing.fingerprint == command.fingerprint {
Ok(existing.summary())
} else {
Err(AdminError::new(
AdminErrorKind::Conflict,
"提交标识已用于其他导入内容",
))
};
}
if registry.shutting_down {
return Err(AdminError::new(
AdminErrorKind::Unavailable,
"服务正在关闭,暂不接受导入任务",
));
}
if registry
.tasks
.iter()
.filter(|entry| entry.finished_at.is_none())
.count()
>= MAX_ACTIVE_TASKS
|| registry.tasks.len() >= MAX_RETAINED_TASKS
{
return Err(AdminError::new(
AdminErrorKind::RateLimited,
"导入任务已满,请稍后再试",
));
}
let items = command
.items
.iter()
.enumerate()
.map(|(index, input)| ImportTaskItem {
index: index + 1,
provider: input.provider.clone(),
status: ImportItemStatus::Pending,
account_ids: Vec::new(),
message: None,
})
.collect();
let entry = Entry {
task_id: Uuid::now_v7(),
submission_id: command.submission_id,
fingerprint: command.fingerprint,
owner: command.context.actor,
created_at: Utc::now(),
finished_at: None,
finished_instant: None,
stop_requested: false,
items,
inputs: command.items.into_iter().map(Some).collect(),
};
let summary = entry.summary();
registry.tasks.push_back(entry);
drop(registry);
self.notify.notify_one();
Ok(summary)
}
fn list(&self, context: &MutationContext) -> Vec<ImportTaskSummary> {
let mut tasks: Vec<_> = self
.registry()
.tasks
.iter()
.filter(|entry| entry.owner == context.actor)
.map(Entry::summary)
.collect();
tasks.sort_by_key(|entry| std::cmp::Reverse(entry.task_id));
tasks
}
fn detail(
&self,
context: &MutationContext,
task_id: Uuid,
) -> Result<ImportTaskDetail, AdminError> {
self.registry()
.tasks
.iter()
.find(|entry| entry.task_id == task_id && entry.owner == context.actor)
.map(Entry::detail)
.ok_or_else(|| AdminError::not_found("导入任务不存在或已过期"))
}
fn stop(
&self,
context: &MutationContext,
task_id: Uuid,
) -> Result<ImportTaskDetail, AdminError> {
let mut registry = self.registry();
let entry = registry
.tasks
.iter_mut()
.find(|entry| entry.task_id == task_id && entry.owner == context.actor)
.ok_or_else(|| AdminError::not_found("导入任务不存在或已过期"))?;
entry.stop();
Ok(entry.detail())
}
}
pub(crate) struct ImportTaskWorker(pub Arc<DefaultImportTasksService>);
impl DaemonTask for ImportTaskWorker {
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let mut active = FuturesUnordered::new();
// 固定一个取消 future,避免循环反复注册中立 CancellationToken 的 waiter。
let cancelled = cancellation.cancelled();
tokio::pin!(cancelled);
let mut cleanup = tokio::time::interval(Duration::from_secs(60));
loop {
if cancellation.is_cancelled() {
let mut registry = self.0.registry();
registry.shutting_down = true;
for entry in &mut registry.tasks {
entry.stop();
}
break;
}
while active.len() < CONCURRENCY {
let Some((id, index, input)) = self.0.take_next() else {
break;
};
active.push(self.0.execute(id, index, input));
}
tokio::select! {
() = &mut cancelled => {},
_ = active.next(), if !active.is_empty() => {},
() = self.0.notify.notified() => {},
_ = cleanup.tick() => { drop(self.0.registry()); },
}
}
// Host 提供总关闭预算;此处让已发出的交换与入库尽量完成。
while active.next().await.is_some() {}
Ok(())
})
}
}
@@ -0,0 +1,165 @@
//! Key 自助查询只从统一会话取得范围,复用现有观测和额度账本。
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use crate::{
AuthService,
model::{
AdminError, AdminErrorKind,
auth::SessionSubject,
key_usage::{
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
KeyUsageRecordsQuery,
},
observability::{
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
},
},
ports::store::{ClientKeyStore, ObservabilityStore},
};
use gateway_core::policy::ClientApiKeyId;
use super::{map_store_error, observability::health_timeline_at};
#[async_trait]
pub trait KeyUsageService: Send + Sync {
async fn overview(
&self,
session_id: Option<&str>,
query: KeyUsageQuery,
) -> Result<Option<KeyUsageOverview>, AdminError>;
async fn records(
&self,
session_id: Option<&str>,
query: KeyUsageRecordsQuery,
) -> Result<Option<KeyUsageRecords>, AdminError>;
}
pub(crate) struct DefaultKeyUsageService {
auth: Arc<dyn AuthService>,
keys: Arc<dyn ClientKeyStore>,
observations: Arc<dyn ObservabilityStore>,
}
impl DefaultKeyUsageService {
pub(crate) fn new(
auth: Arc<dyn AuthService>,
keys: Arc<dyn ClientKeyStore>,
observations: Arc<dyn ObservabilityStore>,
) -> Self {
Self {
auth,
keys,
observations,
}
}
async fn key_id(&self, session_id: Option<&str>) -> Result<Option<ClientApiKeyId>, AdminError> {
match self
.auth
.session(session_id)
.await?
.map(|session| session.subject)
{
Some(SessionSubject::Key { client_key_id }) => Ok(Some(client_key_id)),
Some(SessionSubject::Admin { .. }) => Err(AdminError::new(
AdminErrorKind::Forbidden,
"当前身份无权访问密钥用量接口",
)),
None => Ok(None),
}
}
}
fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
UsageFilter {
client_api_key_ref: Some(id.as_str().to_owned()),
model,
..UsageFilter::default()
}
}
#[async_trait]
impl KeyUsageService for DefaultKeyUsageService {
async fn overview(
&self,
session_id: Option<&str>,
query: KeyUsageQuery,
) -> Result<Option<KeyUsageOverview>, AdminError> {
let Some(id) = self.key_id(session_id).await? else {
return Ok(None);
};
let Some(key) = self
.keys
.get_client_key(&id)
.await
.map_err(|error| map_store_error(error, "key usage profile"))?
.filter(|key| key.enabled)
else {
return Ok(None);
};
let filter = usage_filter(&id, query.model);
let now = Utc::now();
// 健康条始终展示北京时间今日,不随历史范围或模型筛选改变。
let today = TimeRange {
start: china_day_start(now),
end: now,
};
let (overview, trend, health_points) = futures::try_join!(
self.observations.usage_summary(query.range, filter.clone()),
self.observations.usage_trend(query.range, filter),
self.observations
.usage_trend(today, usage_filter(&id, None)),
)
.map_err(|error| map_store_error(error, "key usage overview"))?;
Ok(Some(KeyUsageOverview {
key,
overview,
trend,
health_timeline: health_timeline_at(&health_points, now),
}))
}
async fn records(
&self,
session_id: Option<&str>,
query: KeyUsageRecordsQuery,
) -> Result<Option<KeyUsageRecords>, AdminError> {
let Some(id) = self.key_id(session_id).await? else {
return Ok(None);
};
let result = match query.kind {
KeyUsageRecordKind::Success => KeyUsageRecords::Success(
self.observations
.list_usage_records(UsageQuery {
range: query.usage.range,
filter: usage_filter(&id, query.usage.model),
current_page: query.current_page,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage records"))?,
),
KeyUsageRecordKind::Error => KeyUsageRecords::Error(
self.observations
.list_ops_errors(OpsErrorQuery {
range: query.usage.range,
filter: OpsErrorFilter {
client_api_key_ref: Some(id.as_str().to_owned()),
model: query.usage.model,
..OpsErrorFilter::default()
},
current_page: query.current_page,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage errors"))?,
),
};
Ok(Some(result))
}
}
@@ -6,6 +6,8 @@ pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod openai;
pub mod proxies;
@@ -189,9 +191,6 @@ async fn pending_authorization(
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
if !record.last_test.is_some_and(|test| test.success) {
return Err(AdminError::conflict("请先测试代理连接"));
}
proxy_id = Some(record.id);
Some(record.proxy)
}
@@ -344,12 +343,19 @@ async fn commit_authorization(
async fn commit_credential_rotation(
accounts: &dyn AccountStore,
prepared: PreparedCredentialRotation,
settings: Option<crate::model::accounts::UpdateAccount>,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_rotation(CredentialRotationCommit { prepared: facts }, context)
.commit_credential_rotation(
CredentialRotationCommit {
prepared: facts,
settings,
},
context,
)
.await
{
Ok(result) => {
@@ -371,7 +377,13 @@ async fn commit_credential_refresh(
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_refresh(CredentialRotationCommit { prepared: facts }, context)
.commit_credential_refresh(
CredentialRotationCommit {
prepared: facts,
settings: None,
},
context,
)
.await
{
Ok(result) => {
@@ -192,7 +192,14 @@ impl DefaultObservabilityService {
average(first_token_latency_sum_ms, first_token_latency_count);
let trend = trend(TrendKind::Usage, observation.trend.clone())?;
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
let wire_profiles = self.providers.dashboard_wire_profiles();
let mut configurations = std::collections::BTreeMap::new();
if let Some(configuration) = &settings.openai_client_profile {
configurations.insert(
gateway_core::routing::ProviderKind::new("openai").expect("static provider kind"),
configuration.clone(),
);
}
let wire_profiles = self.providers.dashboard_wire_profiles(&configurations);
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
let total_slots = runtime_slots.as_ref().map_or_else(
|| {
@@ -612,7 +619,7 @@ async fn recover_standard_costs(
mut facts: UsageCalculatedBillingStream<'_>,
) -> Result<UsageCostScenarios, AdminError> {
let mut scenarios = UsageCostScenarios::default();
while let Some(fact) = facts
while let Some(mut fact) = facts
.try_next()
.await
.map_err(|error| map_store_error(error, "usage billing facts"))?
@@ -629,7 +636,14 @@ async fn recover_standard_costs(
cache_write_tokens: fact.cache_write_tokens,
total: fact.total.clone(),
};
let breakdown = match providers.calculated_billing(&provider_kind, &input) {
let breakdown = match fact
.breakdown
.take()
.filter(|b| b.total_amount == input.total)
.map_or_else(
|| providers.calculated_billing(&provider_kind, &input),
|b| Ok(Some(b)),
) {
Ok(breakdown) => breakdown,
Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => continue,
Err(error) => return Err(map_provider_error(error, "usage billing")),
@@ -658,8 +672,15 @@ fn no_cache_cost(
fact: &UsageCalculatedBillingFact,
breakdown: &crate::model::observability::CalculatedBillingBreakdown,
) -> Option<DecimalAmount> {
let input_tokens = fact.input_tokens?;
let cached_tokens = fact.cached_tokens.unwrap_or_default().min(input_tokens);
let image = breakdown.image.as_ref();
let input_tokens = fact
.input_tokens?
.checked_sub(image.map_or(0, |i| i.input_tokens))?;
let cached_tokens = fact
.cached_tokens
.unwrap_or_default()
.checked_sub(image.map_or(0, |i| i.cached_tokens))?
.min(input_tokens);
let cache_write_tokens = fact
.cache_write_tokens
.unwrap_or_default()
@@ -669,11 +690,20 @@ fn no_cache_cost(
.scaled()
.checked_mul(u128::from(cached_tokens.saturating_add(cache_write_tokens)))?
.checked_div(1_000_000)?;
let total = decimal(&breakdown.total_amount.amount)?
let mut total = decimal(&breakdown.total_amount.amount)?
.scaled()
.checked_sub(decimal(&breakdown.cache_read_amount.amount)?.scaled())?
.checked_sub(decimal(&breakdown.cache_write_amount.amount)?.scaled())?
.checked_add(replaced_input_amount)?;
if let Some(image) = image {
let image_no_cache = decimal(&image.input_price_per_million.amount)?
.scaled()
.checked_mul(u128::from(image.cached_tokens))?
.checked_div(1_000_000)?;
total = total
.checked_sub(decimal(&image.cache_read_amount.amount)?.scaled())?
.checked_add(image_no_cache)?;
}
DecimalAmount::from_str(
&gateway_core::metering::Decimal::from_scaled(total)
.ok()?
@@ -852,7 +882,10 @@ impl DefaultObservabilityService {
/// 按指定时刻计算中国自然日的 96 个 15 分钟健康桶。
#[must_use]
fn health_timeline_at(records: &[RequestMetricPoint], now: DateTime<Utc>) -> HealthTimeline {
pub(super) fn health_timeline_at(
records: &[RequestMetricPoint],
now: DateTime<Utc>,
) -> HealthTimeline {
let current_slot = quarter_hour_start(now);
let start = china_day_start(now);
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
@@ -189,6 +189,17 @@ impl OpenAiService for DefaultOpenAiService {
) -> Result<CredentialMutationResult, AdminError> {
let context = command.mutation.context;
let account_id = command.mutation.account_id;
if command
.settings
.as_ref()
.is_some_and(|settings| settings.account_id != account_id.as_str())
{
return Err(AdminError::invalid("凭据和账号设置的目标不一致"));
}
let disable_account = command
.settings
.as_ref()
.is_some_and(|settings| !settings.enabled);
let details = required_credential(
self.accounts.as_ref(),
self.provider.provider_kind(),
@@ -209,10 +220,14 @@ impl OpenAiService for DefaultOpenAiService {
let result = commit_credential_rotation(
self.accounts.as_ref(),
prepared,
command.settings,
&context,
"OpenAI credential rotation",
)
.await?;
if disable_account {
self.provider.account_unavailable(&account_id).await;
}
self.provider
.account_facts_changed(std::slice::from_ref(&result.account_id))
.await;
@@ -181,6 +181,11 @@ impl ProxiesService for DefaultProxiesService {
context: &MutationContext,
) -> Result<ProxyMutation, AdminError> {
command.name = validate_name(&command.name)?;
command.location = command
.location
.map(|location| location.normalized())
.transpose()
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
let result = self
.store
.create(command, context)
@@ -196,6 +201,11 @@ impl ProxiesService for DefaultProxiesService {
context: &MutationContext,
) -> Result<ProxyMutation, AdminError> {
command.name = validate_name(&command.name)?;
command.location = command
.location
.map(|location| location.map(|value| value.normalized()).transpose())
.transpose()
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
let result = self
.store
.update(command, context)
@@ -23,6 +23,31 @@ use super::{map_store_error, publish_committed};
/// API 消费的 Runtime settings 管理服务。
#[async_trait]
pub trait SettingsService: Send + Sync {
async fn preview_pricing_sync(
&self,
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError>;
async fn sync_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::SyncPricing,
) -> Result<(), AdminError>;
async fn pricing(&self) -> Result<crate::model::pricing::PricingCatalog, AdminError>;
async fn update_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::UpdatePricing,
) -> Result<(), AdminError>;
async fn client_profile_options(
&self,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
}
async fn preview_client_profile(
&self,
_configuration: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
}
async fn load(&self) -> Result<RuntimeSettings, AdminError>;
async fn replace(
&self,
@@ -41,19 +66,232 @@ pub trait SettingsService: Send + Sync {
}
pub(crate) struct DefaultSettingsService {
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
store: Arc<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultSettingsService {
#[must_use]
pub(crate) fn new(store: Arc<dyn SettingsStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
Self { store, snapshot }
pub(crate) fn new(
store: Arc<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
providers: crate::ports::provider::ProviderAdminRegistry,
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
) -> Self {
Self {
providers,
pricing_source,
store,
snapshot,
profile_provider,
}
}
}
#[async_trait]
impl SettingsService for DefaultSettingsService {
async fn preview_pricing_sync(
&self,
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError> {
self.pricing_source.fetch().await
}
async fn sync_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::SyncPricing,
) -> Result<(), AdminError> {
let count = command
.models
.values()
.map(std::collections::BTreeSet::len)
.sum::<usize>();
if count == 0
|| count > 10_000
|| command
.models
.values()
.any(std::collections::BTreeSet::is_empty)
{
return Err(AdminError::invalid("请选择 1 至 10000 个模型"));
}
let mut current = self.pricing_source.fetch().await?;
if current != command.preview {
return Err(AdminError::invalid(
"models.dev 价目已变化,请重新预览后确认",
));
}
let stored = self
.store
.load_pricing()
.await
.map_err(|error| map_store_error(error, "model pricing"))?;
let mut changes = crate::model::pricing::PricingSyncChanges::new();
for (provider, models) in command.models {
let selected = changes.entry(provider.clone()).or_default();
for model in models {
let price = current
.prices
.get_mut(&provider)
.and_then(|prices| prices.remove(&model));
if price.is_none()
&& !stored
.synced
.get(&provider)
.is_some_and(|prices| prices.contains_key(&model))
{
return Err(AdminError::invalid("所选模型不在来源价目中,请重新预览"));
}
selected.insert(model, price);
}
}
let revision = self
.store
.sync_pricing(changes, context)
.await
.map_err(|error| map_store_error(error, "model pricing sync"))?;
publish_committed(self.snapshot.as_ref(), revision).await
}
async fn pricing(&self) -> Result<crate::model::pricing::PricingCatalog, AdminError> {
let stored = self
.store
.load_pricing()
.await
.map_err(|error| map_store_error(error, "model pricing"))?;
Ok(crate::model::pricing::PricingCatalog {
defaults: self.providers.pricing_catalog(),
overrides: stored.overrides,
synced: stored.synced,
synced_at: stored.synced_at,
})
}
async fn update_pricing(
&self,
context: &MutationContext,
command: crate::model::pricing::UpdatePricing,
) -> Result<(), AdminError> {
use crate::model::pricing::PricingChange;
let catalog = self.pricing().await?;
if !catalog.defaults.contains_key(&command.provider)
|| command.models.is_empty()
|| command.models.len() > 500
|| command.models.iter().any(|model| {
model.is_empty()
|| model.len() > 128
|| model.trim() != model
|| model.chars().any(char::is_whitespace)
|| model.chars().any(char::is_control)
})
{
return Err(AdminError::invalid("Provider、模型 ID 或批量数量不合法"));
}
if command.change == PricingChange::Delete
&& command.models.iter().any(|model| {
catalog
.defaults
.get(&command.provider)
.is_some_and(|models| models.contains_key(model))
})
{
return Err(AdminError::invalid("内置价目不能删除,仅支持人工覆盖"));
}
let defaults = gateway_core::metering::merge_pricing(catalog.defaults, &catalog.synced);
let defaults = defaults.get(&command.provider);
match &command.change {
PricingChange::Replace(pricing) => {
pricing.validate().map_err(AdminError::invalid)?;
if command.provider == "xai"
&& pricing
.bands
.keys()
.any(|band| matches!(band.as_str(), "image" | "flex" | "long_flex"))
{
return Err(AdminError::invalid("xAI 不支持此价格档位"));
}
if !pricing.bands.contains_key("standard")
&& command
.models
.iter()
.any(|model| !defaults.is_some_and(|p| p.contains_key(model)))
{
return Err(AdminError::invalid("未知模型必须提供标准档价格"));
}
}
PricingChange::Multiplier(bps) if *bps > 1_000_000 => {
return Err(AdminError::invalid("倍率必须在 0 至 100 倍之间"));
}
PricingChange::Multiplier(_)
if command.models.iter().any(|model| {
!defaults.is_some_and(|p| p.contains_key(model))
&& !catalog
.overrides
.get(&command.provider)
.and_then(|p| p.get(model))
.is_some_and(|p| p.bands.contains_key("standard"))
}) =>
{
return Err(AdminError::invalid("请先为未知模型配置标准档价格"));
}
_ => {}
}
let revision = self
.store
.update_pricing(command, context)
.await
.map_err(|error| map_store_error(error, "model pricing"))?;
publish_committed(self.snapshot.as_ref(), revision).await
}
async fn client_profile_options(
&self,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
let mut options = self
.profile_provider
.client_profile_options()
.map_err(|error| super::map_provider_error(error, "client profile"))?
.into_inner();
let configuration = self
.load()
.await?
.openai_client_profile
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
options.insert(
"globalConfiguration".to_owned(),
serde_json::Value::Object(configuration.into_inner()),
);
Ok(gateway_core::account::OpaqueProviderData::new(options))
}
async fn preview_client_profile(
&self,
configuration: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
let global;
let (configuration, source) = if let Some(configuration) = configuration {
(configuration, "override")
} else {
global = self
.load()
.await?
.openai_client_profile
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
(&global, "global")
};
let mut preview = self
.profile_provider
.preview_client_profile(configuration)
.map_err(|error| super::map_provider_error(error, "client profile"))?
.into_inner();
preview.insert("source".to_owned(), serde_json::Value::from(source));
Ok(gateway_core::account::OpaqueProviderData::new(preview))
}
async fn load(&self) -> Result<RuntimeSettings, AdminError> {
self.store
.load_runtime_settings()
@@ -67,6 +305,11 @@ impl SettingsService for DefaultSettingsService {
command: ReplaceRuntimeSettings,
) -> Result<RuntimeSettings, AdminError> {
validate_settings(&command)?;
if let Some(profile) = &command.openai_client_profile {
self.profile_provider
.preview_client_profile(profile)
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
let settings = self
.store
.replace_runtime_settings(command, context)
@@ -114,15 +357,25 @@ impl SettingsService for DefaultSettingsService {
}
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
let valid = command.refresh_margin_seconds > 0
let valid = command.request_location.validate().is_ok()
&& command.responses_max_decompressed_body_bytes > 0
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
&& command.refresh_margin_seconds > 0
&& command.refresh_concurrency > 0
&& command.max_concurrent_per_account > 0
&& command.max_waiting_per_key <= 1_000
&& command.max_waiting_per_account <= 1_000
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
&& command.usage_retention_days >= 31
&& command.ops_event_retention_days > 0
&& command.audit_retention_days > 0
&& valid_client_version(command.min_codex_desktop_version.as_deref())
&& valid_client_version(command.min_codex_cli_version.as_deref())
&& i64::try_from(command.request_interval_ms).is_ok();
&& valid_probe_model(command.account_auto_freeze_probe_model.as_deref())
&& i64::try_from(command.request_interval_ms).is_ok()
&& (2..=1_000).contains(&command.account_auto_freeze_threshold)
&& (60..=3_600).contains(&command.account_auto_freeze_window_seconds)
&& (300..=604_800).contains(&command.account_auto_freeze_duration_seconds);
if valid {
Ok(())
} else {
@@ -133,3 +386,12 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
fn valid_client_version(value: Option<&str>) -> bool {
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
}
fn valid_probe_model(value: Option<&str>) -> bool {
value.is_none_or(|value| {
!value.is_empty()
&& value.len() <= 128
&& value == value.trim()
&& !value.bytes().any(|byte| byte.is_ascii_control())
})
}
@@ -618,6 +618,16 @@ impl AuthStore for FakeAuthStore {
async fn load_password_hash(&self, _admin_user_id: &str) -> AdminStoreResult<Option<String>> {
Ok(None)
}
async fn change_password(
&self,
_: &str,
_: &str,
_: &str,
_: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn create_password_hash_if_absent(
&self,
_admin_user_id: &str,
@@ -633,22 +643,39 @@ impl AuthStore for FakeAuthStore {
async fn load_session(
&self,
_session_id: &str,
) -> AdminStoreResult<Option<gateway_admin::model::auth::AdminSession>> {
) -> AdminStoreResult<Option<gateway_admin::model::auth::AuthSession>> {
Ok(None)
}
async fn store_session(
&self,
_session_id: &str,
_session: &gateway_admin::model::auth::AdminSession,
_session: &gateway_admin::model::auth::AuthSession,
) -> AdminStoreResult<()> {
Ok(())
}
async fn delete_session(
&self,
_session_id: &str,
) -> AdminStoreResult<Option<gateway_admin::model::auth::AdminSession>> {
) -> AdminStoreResult<Option<gateway_admin::model::auth::AuthSession>> {
Ok(None)
}
async fn client_key_enabled(
&self,
_: &gateway_core::policy::ClientApiKeyId,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn consume_login_attempt(
&self,
_: std::net::IpAddr,
_: u32,
_: u32,
_: std::time::Duration,
) -> AdminStoreResult<Option<std::time::Duration>> {
Ok(None)
}
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
self.audit.lock().expect("audit").push(event);
Ok(())
+9 -1
View File
@@ -23,6 +23,14 @@ mod backup;
mod model;
mod use_case;
#[test]
fn client_config_should_default_to_a_one_day_session() {
assert_eq!(
gateway_admin::ClientConfig::default().session_ttl_minutes,
1440
);
}
#[test]
fn revision_should_reject_zero() {
assert!(Revision::new(0).is_err());
@@ -88,7 +96,7 @@ fn admin_api_key_debug_should_redact_plaintext() {
#[test]
fn login_command_debug_should_redact_password() {
let command = LoginCommand {
let command = LoginCommand::Admin {
username: None,
password: "secret-password".to_owned(),
};
@@ -5,7 +5,7 @@ use std::{
};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use chrono::{DateTime, Duration, Utc};
use gateway_admin::{
model::{
MutationContext, PageSize, Revision,
@@ -168,18 +168,41 @@ impl AccountRuntimeStore for FakeRuntimeStore {
) -> AdminStoreResult<AccountRuntimeSnapshot> {
*self.requested_accounts.lock().expect("requested accounts") = account_ids.to_vec();
Ok(AccountRuntimeSnapshot {
rate_limited_until: BTreeMap::from([(
cooldown: BTreeMap::from([(
"acct_limited".to_owned(),
Utc::now() + Duration::minutes(5),
std::time::SystemTime::from(Utc::now() + Duration::minutes(5)).into(),
)]),
in_flight: Some(BTreeMap::from([("acct_available".to_owned(), 2)])),
})
}
async fn active_freezes(
&self,
) -> AdminStoreResult<BTreeMap<String, gateway_admin::model::accounts::AccountFreeze>> {
Ok(BTreeMap::new())
}
async fn capacity_peaks(
&self,
_account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>> {
Ok(BTreeMap::new())
}
async fn finish_freeze(
&self,
_account_id: &str,
_expected: &gateway_admin::model::accounts::AccountFreeze,
_postpone_until: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool> {
Ok(false)
}
}
fn group_record() -> AccountGroupRecord {
let now = Utc::now();
AccountGroupRecord {
disable_fast: false,
id: group_id(),
name: "Primary".to_owned(),
description: None,
@@ -215,7 +238,7 @@ fn member(account_id: &str, total_slots: u64) -> AccountGroupMemberFact {
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::default(),
rate_limited_until: None,
cooldown: None,
last_error_reason: None,
last_error_message: None,
},
@@ -67,6 +67,7 @@ pub(super) struct FakeProviderAdmin {
kind: ProviderKind,
events: EventLog,
failure: Mutex<Option<ProviderAdminError>>,
import_gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
quota_failure: Mutex<Option<ProviderAdminErrorKind>>,
pending: Arc<Mutex<Option<PendingAuthorizationMutation>>>,
retry_authorization_after_abort: Mutex<bool>,
@@ -90,6 +91,7 @@ impl FakeProviderAdmin {
kind: ProviderKind::new(kind).expect("provider kind"),
events,
failure: Mutex::new(None),
import_gate: Mutex::new(None),
quota_failure: Mutex::new(None),
pending: Arc::new(Mutex::new(None)),
retry_authorization_after_abort: Mutex::new(false),
@@ -108,6 +110,12 @@ impl FakeProviderAdmin {
})
}
pub(super) fn block_imports(&self) -> Arc<tokio::sync::Semaphore> {
let gate = Arc::new(tokio::sync::Semaphore::new(0));
*self.import_gate.lock().expect("import gate") = Some(gate.clone());
gate
}
pub(super) fn fail_next(&self, kind: ProviderAdminErrorKind) {
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
}
@@ -235,6 +243,7 @@ impl FakeProviderAdmin {
name: account.name.clone(),
email: account.email.clone(),
plan_type: account.plan_type.clone(),
preserve_profile: false,
provider_material: document(),
has_refresh_token: account.has_refresh_token,
access_token_expires_at: account
@@ -332,6 +341,10 @@ impl ProviderAdmin for FakeProviderAdmin {
_command: PrepareCredentialImport,
) -> Result<PreparedCredentialImport, ProviderAdminError> {
self.record("provider.prepare_import");
let gate = self.import_gate.lock().expect("import gate").clone();
if let Some(gate) = gate {
gate.acquire().await.expect("import permit").forget();
}
self.require_available()?;
let account_ids = self
.import_account_ids
@@ -531,6 +544,8 @@ pub(super) struct FakeAccountStore {
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
quota_window_queries: Mutex<Vec<AccountUsageWindowQuery>>,
quota_forecast_history: Mutex<QuotaForecastHistory>,
update_commands: Mutex<Vec<UpdateAccount>>,
pub(super) lowered_limits: Mutex<Vec<(String, u32)>>,
}
impl FakeAccountStore {
@@ -538,7 +553,7 @@ impl FakeAccountStore {
Self::with_account(account_record(kind), events)
}
fn with_account(account: AccountRecord, events: EventLog) -> Arc<Self> {
pub(super) fn with_account(account: AccountRecord, events: EventLog) -> Arc<Self> {
Arc::new(Self {
events,
accounts: Mutex::new(vec![account]),
@@ -549,9 +564,18 @@ impl FakeAccountStore {
quota_window_usage: Mutex::new(Vec::new()),
quota_window_queries: Mutex::new(Vec::new()),
quota_forecast_history: Mutex::new(QuotaForecastHistory::default()),
update_commands: Mutex::new(Vec::new()),
lowered_limits: Mutex::new(Vec::new()),
})
}
pub(super) fn update_commands(&self) -> Vec<UpdateAccount> {
self.update_commands
.lock()
.expect("update commands")
.clone()
}
pub(super) fn fail_next_commit(&self) {
*self.fail_commit.lock().expect("store failure") = true;
}
@@ -617,7 +641,7 @@ impl FakeAccountStore {
credential_state: account.credential_state,
access_token_expires_at: account.access_token_expires_at.map(Into::into),
quota: account.quota,
rate_limited_until: None,
cooldown: None,
last_error_reason: account.last_error_reason,
last_error_message: account.last_error_message.clone(),
};
@@ -839,12 +863,45 @@ impl AccountStore for FakeAccountStore {
self.record("store.update_account");
self.record_context(context);
self.require_commit()?;
self.update_commands
.lock()
.expect("update commands")
.push(command.clone());
Ok(AccountUpdateResult {
config_revision: revision(2),
account_id: ProviderAccountId::new(command.account_id).expect("account ID"),
})
}
async fn lower_concurrency_limit(
&self,
account_id: &ProviderAccountId,
limit: gateway_core::account::AccountConcurrencyLimit,
_: &MutationContext,
) -> AdminStoreResult<Option<AccountUpdateResult>> {
let mut accounts = self.accounts.lock().expect("accounts");
let Some(account) = accounts
.iter_mut()
.find(|account| account.id == account_id.as_str())
else {
return Ok(None);
};
if !account.enabled
|| account.concurrency_limit.map_or(5, |value| value.get()) <= limit.get()
{
return Ok(None);
}
account.concurrency_limit = Some(limit);
self.lowered_limits
.lock()
.expect("lowered limits")
.push((account.id.clone(), limit.get()));
Ok(Some(AccountUpdateResult {
config_revision: revision(2),
account_id: account_id.clone(),
}))
}
async fn recover_account(
&self,
account_id: &ProviderAccountId,
@@ -914,20 +971,51 @@ struct StaticSettingsStore;
#[async_trait]
impl SettingsStore for StaticSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
openai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
model_mappings: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: 1,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 30,
ops_event_retention_days: 30,
audit_retention_days: 30,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
updated_at: Utc::now(),
})
}
@@ -1330,6 +1418,8 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
.update(
&context("update-request"),
UpdateAccount {
notes: None,
model_access: Default::default(),
outbound_proxy: None,
account_id: "acct_test".to_owned(),
enabled: false,
@@ -1367,6 +1457,8 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
.update(
&context("update-failure"),
UpdateAccount {
notes: None,
model_access: Default::default(),
outbound_proxy: None,
account_id: "acct_test".to_owned(),
enabled: false,
@@ -1409,12 +1501,13 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
.batch_update(
&context("batch-update-request"),
BatchUpdateAccounts {
model_access: Default::default(),
outbound_proxy: None,
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
enabled: false,
concurrency_limit: None,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: Vec::new(),
enabled: Some(false),
concurrency_limit: Some(None),
weight: Some(gateway_core::account::AccountWeight::DEFAULT),
group_ids: Some(Vec::new()),
},
)
.await
@@ -1948,6 +2041,52 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
);
}
#[tokio::test]
async fn api_key_list_and_detail_should_accumulate_local_usage_without_subscription_windows() {
let provider = FakeProviderAdmin::new("openai", events());
let mut account = account_record("openai");
account.authentication_kind = "api_key".to_owned();
account.created_at = Utc::now() - TimeDelta::days(60);
let added_at = account.created_at;
let store = FakeAccountStore::with_account(account, events());
store.set_quota_window_usage(vec![AccountUsageWindowResult {
account_id: "acct_test".to_owned(),
key: "account-lifetime".to_owned(),
usage: quota_local_usage("acct_test", 4_330_000),
}]);
let services = accounts_service(provider, store.clone()).await;
let page = services
.accounts()
.list(AccountListQuery {
page: 1,
page_size: gateway_admin::model::PageSize::new(20).unwrap(),
provider_kind: None,
group_filter: None,
search: None,
status: None,
sort: None,
})
.await
.unwrap();
assert!(page.items[0].quota.windows.is_empty());
assert_eq!(
page.items[0].usage.as_ref().unwrap().total_tokens,
Some(4_330_000)
);
let queries = store.quota_window_queries();
assert_eq!(queries.len(), 1);
assert_eq!(queries[0].range.start, added_at);
assert!(queries[0].range.end > added_at + TimeDelta::days(59));
let detail = services
.accounts()
.quota(&ProviderAccountId::new("acct_test").unwrap(), false)
.await
.unwrap();
assert_eq!(detail.usage, page.items[0].usage);
assert!(detail.quota.windows.is_empty());
assert_eq!(store.quota_window_queries()[0].range.start, added_at);
}
#[tokio::test]
async fn accounts_list_should_attach_local_usage_to_quota_windows() {
let provider = FakeProviderAdmin::new("openai", events());
@@ -2400,6 +2539,8 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
pub(super) fn account_record(kind: &str) -> AccountRecord {
let now = Utc::now();
AccountRecord {
notes: None,
model_access: Default::default(),
outbound_proxy: None,
id: "acct_test".to_owned(),
provider_kind: ProviderKind::new(kind).expect("provider kind"),
@@ -2442,6 +2583,7 @@ fn prepared_create_with_id(
) -> PreparedCredentialCreate {
let now = Utc::now();
PreparedCredentialCreate {
model_access: Default::default(),
outbound_proxy: None,
account_id: ProviderAccountId::new(account_id).expect("prepared account ID"),
provider_kind,
@@ -2733,6 +2875,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,17 +13,91 @@ 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>>,
}
#[tokio::test]
async fn password_change_obeys_rate_limit_and_rejects_unbound_legacy_sessions() {
use gateway_admin::model::{
AdminErrorKind,
auth::{ChangePassword, SessionSubject},
};
let store = std::sync::Arc::new(MemoryAuthStore::default());
let services = super::AdminHarness::new().auth(store.clone()).build().await;
let auth = services.auth();
let login = auth
.login(
LoginCommand::Admin {
username: None,
password: "strong-test-password".into(),
},
std::net::Ipv4Addr::LOCALHOST.into(),
None,
)
.await
.unwrap();
*store.retry_after.lock().unwrap() = Some(std::time::Duration::from_secs(30));
let error = auth
.change_password(
Some(&login.session_id),
ChangePassword {
current_password: "strong-test-password".into(),
new_password: "new-strong-password".into(),
},
std::net::Ipv4Addr::LOCALHOST.into(),
)
.await
.unwrap_err();
assert_eq!(error.kind(), AdminErrorKind::RateLimited);
assert!(
auth.session(Some(&login.session_id))
.await
.unwrap()
.is_some()
);
store.sessions.lock().unwrap().insert(
"legacy".into(),
AuthSession {
subject: SessionSubject::Admin {
admin_user_id: "admin".into(),
credential_fingerprint: String::new(),
},
expires_at: Utc::now() + TimeDelta::hours(1),
},
);
assert!(auth.session(Some("legacy")).await.unwrap().is_none());
}
#[async_trait]
impl AuthStore for MemoryAuthStore {
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn change_password(
&self,
_: &str,
expected_hash: &str,
password_hash: &str,
audit: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
let mut stored = self.password_hash.lock().unwrap();
let Some(credentials) = stored
.as_mut()
.filter(|value| value.as_str() == expected_hash)
else {
return Ok(false);
};
*credentials = password_hash.to_owned();
self.audits.lock().unwrap().push(audit);
Ok(true)
}
async fn create_password_hash_if_absent(
&self,
_: &str,
@@ -41,7 +115,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 +127,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 +135,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 +172,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 +211,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 +236,135 @@ 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 {
credential_fingerprint: String::new(),
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,303 @@
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 change_password(
&self,
_: &str,
_: &str,
_: &str,
_: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn create_password_hash_if_absent(&self, _: &str, _: &str) -> AdminStoreResult<bool> {
Ok(true)
}
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"))
}
}
@@ -22,10 +22,27 @@ struct TestClientKeyStore {
plaintexts: Mutex<Vec<String>>,
create_error: Option<AdminStoreErrorKind>,
update_error: Option<AdminStoreErrorKind>,
resets: Mutex<Vec<gateway_admin::model::client_keys::ResetClientKeyBudget>>,
}
#[async_trait]
impl ClientKeyStore for TestClientKeyStore {
async fn reset_client_key_budget(
&self,
command: gateway_admin::model::client_keys::ResetClientKeyBudget,
_: &MutationContext,
) -> AdminStoreResult<()> {
self.resets.lock().unwrap().push(command);
Ok(())
}
async fn get_client_key(
&self,
_: &ClientApiKeyId,
) -> 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 {
@@ -57,6 +74,7 @@ impl ClientKeyStore for TestClientKeyStore {
}
self.plaintexts.lock().unwrap().push(key.plaintext);
let record = ClientKeyRecord {
openai_client_profile_override: None,
id: key.id,
name: key.name,
label: key.label,
@@ -105,6 +123,27 @@ impl ClientKeyStore for TestClientKeyStore {
}
}
#[tokio::test]
async fn reset_budget_forwards_scope_and_returns_only_key_identity() {
use gateway_admin::model::client_keys::{ClientKeyBudgetPeriod, ResetClientKeyBudget};
let store = Arc::new(TestClientKeyStore::default());
let services = super::AdminHarness::new()
.client_keys(store.clone())
.build()
.await;
let command = ResetClientKeyBudget {
id: ClientApiKeyId::new("key_reset").unwrap(),
period: ClientKeyBudgetPeriod::Weekly,
};
let result = services
.client_keys()
.reset_budget(&mutation_context(), command.clone())
.await
.unwrap();
assert_eq!(result, command.id);
assert_eq!(*store.resets.lock().unwrap(), vec![command]);
}
#[tokio::test]
async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
let services = super::AdminHarness::new()
@@ -220,6 +259,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
.update(
&mutation_context(),
UpdateClientKey {
openai_client_profile_override: None,
id: ClientApiKeyId::new("key_existing").unwrap(),
name: "Migration".to_owned(),
label: None,
@@ -239,6 +279,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
fn create_command(key: Option<&str>) -> CreateClientKey {
CreateClientKey {
openai_client_profile_override: None,
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
name: "Migration".to_owned(),
label: None,
@@ -0,0 +1,446 @@
//! 容量熔断冻结恢复编排回归:到期探测、失败顺延与自适应并发下调。
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use chrono::{DateTime, Duration as TimeDelta, Utc};
use gateway_admin::freeze_recovery::{FreezeRecoveryDeps, FreezeRecoveryTask};
use gateway_admin::model::MutationContext;
use gateway_admin::model::accounts::{AccountFreeze, AccountRuntimeSnapshot};
use gateway_admin::model::settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings,
};
use gateway_admin::ports::store::{
AccountRuntimeStore, AccountStore, AdminStoreError, AdminStoreResult, SettingsStore,
};
use gateway_core::engine::probe::{
AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult,
};
use gateway_core::lifecycle::CancellationToken;
use gateway_core::task::{ScheduledTask as _, WorkerCycleContext, WorkerId, WorkerKind};
use super::AdminHarness;
use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, events, revision};
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
RuntimeSettings {
openai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
model_mappings: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: 5,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
account_auto_freeze_enabled: enabled,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: probe_enabled,
account_auto_freeze_probe_model: Some("gpt-5.5".to_owned()),
account_auto_freeze_adaptive_concurrency: adaptive,
updated_at: Utc::now(),
}
}
struct FreezeSettingsStore {
settings: RuntimeSettings,
}
#[async_trait]
impl SettingsStore for FreezeSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(self.settings.clone())
}
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
Ok(false)
}
async fn replace_runtime_settings(
&self,
_: ReplaceRuntimeSettings,
_: &MutationContext,
) -> AdminStoreResult<RuntimeSettings> {
Err(store_unavailable())
}
async fn replace_admin_api_key(
&self,
_: AdminApiKey,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(store_unavailable())
}
async fn delete_admin_api_key(
&self,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(store_unavailable())
}
}
fn store_unavailable() -> AdminStoreError {
AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"freeze recovery test",
"unused",
)
}
/// 记录解冻/顺延调用的运行态 fake;冻结与峰值证据由测试预置。
struct FreezeRuntimeStore {
freezes: BTreeMap<String, AccountFreeze>,
peaks: BTreeMap<String, u32>,
cleared: Mutex<Vec<String>>,
extended: Mutex<Vec<(String, DateTime<Utc>)>>,
}
impl FreezeRuntimeStore {
fn new(freezes: BTreeMap<String, AccountFreeze>, peaks: BTreeMap<String, u32>) -> Arc<Self> {
Arc::new(Self {
freezes,
peaks,
cleared: Mutex::new(Vec::new()),
extended: Mutex::new(Vec::new()),
})
}
fn cleared(&self) -> Vec<String> {
self.cleared.lock().expect("cleared lock").clone()
}
fn extended(&self) -> Vec<(String, DateTime<Utc>)> {
self.extended.lock().expect("extended lock").clone()
}
}
#[async_trait]
impl AccountRuntimeStore for FreezeRuntimeStore {
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
async fn account_runtime(
&self,
_account_ids: &[String],
) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
async fn active_freezes(&self) -> AdminStoreResult<BTreeMap<String, AccountFreeze>> {
Ok(self.freezes.clone())
}
async fn capacity_peaks(
&self,
account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>> {
Ok(account_ids
.iter()
.filter_map(|id| self.peaks.get(id).map(|peak| (id.clone(), *peak)))
.collect())
}
async fn finish_freeze(
&self,
account_id: &str,
expected: &AccountFreeze,
postpone_until: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool> {
assert_eq!(self.freezes.get(account_id), Some(expected));
if let Some(until) = postpone_until {
self.extended
.lock()
.expect("extended")
.push((account_id.to_owned(), until));
} else {
self.cleared
.lock()
.expect("cleared")
.push(account_id.to_owned());
}
Ok(true)
}
}
struct SuccessfulProbe;
impl AccountProbe for SuccessfulProbe {
fn probe(
&self,
_: AccountProbeRequest,
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async {
Ok(AccountProbeResult {
text: vec!["OK".to_owned()],
})
})
}
}
struct FailingProbe;
impl AccountProbe for FailingProbe {
fn probe(
&self,
_: AccountProbeRequest,
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async {
Err(AccountProbeError::new(
gateway_core::error::GatewayError::new(
gateway_core::error::GatewayErrorKind::RateLimited,
"still overloaded",
),
gateway_core::engine::probe::AccountProbeErrorSource::Upstream,
Some(gateway_core::upstream::UpstreamSendState::NotSent),
None,
))
})
}
}
async fn recovery_task(
settings: RuntimeSettings,
runtime: Arc<FreezeRuntimeStore>,
probe: Arc<dyn AccountProbe>,
) -> (FreezeRecoveryTask, Arc<FakeAccountStore>) {
recovery_task_with_store(settings, runtime, probe).await
}
async fn recovery_task_with_store(
settings: RuntimeSettings,
runtime: Arc<FreezeRuntimeStore>,
probe: Arc<dyn AccountProbe>,
) -> (FreezeRecoveryTask, Arc<FakeAccountStore>) {
let store = FakeAccountStore::new("openai", events());
let services = AdminHarness::new()
.accounts(Arc::clone(&store) as Arc<dyn AccountStore>)
.account_runtime(Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>)
.settings(Arc::new(FreezeSettingsStore {
settings: settings.clone(),
}))
.provider(FakeProviderAdmin::new("openai", events()))
.probe(probe)
.build()
.await;
let task = FreezeRecoveryTask::new(FreezeRecoveryDeps {
accounts: services.accounts_handle(),
store: store.clone(),
runtime,
settings: Arc::new(FreezeSettingsStore {
settings: settings.clone(),
}),
});
(task, store)
}
async fn run_cycle(task: &FreezeRecoveryTask) {
let worker = WorkerId::try_new(WorkerKind::AccountFreezeRecovery, "test").expect("worker id");
let context = WorkerCycleContext::new(worker, None, CancellationToken::new());
task.run_cycle(context)
.await
.expect("freeze recovery cycle");
}
fn freeze_until(until: DateTime<Utc>) -> BTreeMap<String, AccountFreeze> {
BTreeMap::from([(
"acct_test".to_owned(),
AccountFreeze {
credential_revision: revision(1),
until,
generation: "freeze-generation".to_owned(),
requires_probe: true,
},
)])
}
fn freeze_due() -> BTreeMap<String, AccountFreeze> {
freeze_until(Utc::now() - TimeDelta::seconds(1))
}
#[tokio::test]
async fn probe_success_clears_freeze() {
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
let (task, _store) = recovery_task(
runtime_settings(true, true, false),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
.await;
run_cycle(&task).await;
assert_eq!(runtime.cleared(), vec!["acct_test".to_owned()]);
assert!(runtime.extended().is_empty());
}
#[tokio::test]
async fn probe_failure_postpones_freeze() {
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
let (task, _store) = recovery_task(
runtime_settings(true, true, false),
Arc::clone(&runtime),
Arc::new(FailingProbe),
)
.await;
run_cycle(&task).await;
assert!(runtime.cleared().is_empty());
let extended = runtime.extended();
assert_eq!(extended.len(), 1);
let (account_id, until) = &extended[0];
assert_eq!(account_id, "acct_test");
let remaining = until.timestamp_millis() - Utc::now().timestamp_millis();
assert!(
remaining > 7_000_000 && remaining <= 7_200_000,
"postponed freeze should be about 2 hours, got {remaining}ms"
);
}
#[tokio::test]
async fn disabled_policy_releases_due_freeze_without_probing() {
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
let (task, _store) = recovery_task(
runtime_settings(false, true, false),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
.await;
run_cycle(&task).await;
assert_eq!(runtime.cleared(), vec!["acct_test".to_owned()]);
assert!(runtime.extended().is_empty());
}
#[tokio::test]
async fn adaptive_concurrency_lowers_limit_to_observed_peak() {
let runtime = FreezeRuntimeStore::new(
freeze_until(Utc::now() + TimeDelta::hours(1)),
BTreeMap::from([("acct_test".to_owned(), 4_u32)]),
);
let (task, store) = recovery_task(
runtime_settings(true, false, true),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
.await;
run_cycle(&task).await;
// 峰值 4 × 0.8 = 3(不低于下限 2),低于全局默认 5,应下调到 3。
assert_eq!(
*store.lowered_limits.lock().expect("lowered limits"),
vec![("acct_test".to_owned(), 3)]
);
assert!(store.update_commands().is_empty());
assert!(runtime.extended().is_empty());
assert!(runtime.cleared().is_empty());
}
#[tokio::test]
async fn adaptive_concurrency_never_raises_limit() {
let runtime = FreezeRuntimeStore::new(
freeze_until(Utc::now() + TimeDelta::hours(1)),
BTreeMap::from([("acct_test".to_owned(), 40_u32)]),
);
let (task, store) = recovery_task(
runtime_settings(true, false, true),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
.await;
run_cycle(&task).await;
// 峰值 40 的目标 32 高于全局默认 5;只降不升,不下发任何更新。
assert!(store.update_commands().is_empty());
}
/// 到期时间还早(超过探测提前量)的冻结不发起探测。
#[tokio::test]
async fn probe_skips_freezes_far_from_expiry() {
let runtime = FreezeRuntimeStore::new(
freeze_until(Utc::now() + TimeDelta::hours(1)),
BTreeMap::new(),
);
let (task, _store) = recovery_task(
runtime_settings(true, true, false),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
.await;
run_cycle(&task).await;
assert!(runtime.cleared().is_empty());
assert!(runtime.extended().is_empty());
}
/// 停用账号不参与探测恢复;并发下调同样跳过。
#[tokio::test]
async fn disabled_accounts_are_skipped() {
let runtime = FreezeRuntimeStore::new(
freeze_due(),
BTreeMap::from([("acct_test".to_owned(), 4_u32)]),
);
let mut record = account_record("openai");
record.enabled = false;
let store = FakeAccountStore::with_account(record, events());
let services = AdminHarness::new()
.accounts(Arc::clone(&store) as Arc<dyn AccountStore>)
.account_runtime(Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>)
.settings(Arc::new(FreezeSettingsStore {
settings: runtime_settings(true, true, true),
}))
.provider(FakeProviderAdmin::new("openai", events()))
.probe(Arc::new(SuccessfulProbe))
.build()
.await;
let task = FreezeRecoveryTask::new(FreezeRecoveryDeps {
accounts: services.accounts_handle(),
store: store.clone(),
runtime: Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>,
settings: Arc::new(FreezeSettingsStore {
settings: runtime_settings(true, true, true),
}),
});
run_cycle(&task).await;
assert!(runtime.cleared().is_empty());
assert!(runtime.extended().is_empty());
assert!(store.update_commands().is_empty());
}
@@ -0,0 +1,370 @@
use std::{sync::Arc, time::Duration};
use gateway_admin::{
AdminBundle, AdminServices,
model::{
AdminErrorKind, MutationActor, MutationContext,
import_tasks::{ImportTaskInput, SubmitImportTask},
provider_credentials::ImportCredentials,
},
ports::provider::ProviderAdminErrorKind,
};
use gateway_core::{
lifecycle::CancellationToken,
routing::ProviderKind,
task::{WorkerContribution, WorkerKind, WorkerRunnable},
};
use uuid::Uuid;
use super::{
AdminHarness,
accounts::{
EventLog, FakeAccountStore, FakeProviderAdmin, context, document, events, recorded,
},
};
fn command(id: Uuid, count: usize) -> SubmitImportTask {
let context = context("task-submit");
SubmitImportTask {
submission_id: id,
fingerprint: [1; 32],
context: context.clone(),
items: (0..count)
.map(|_| ImportTaskInput {
provider: ProviderKind::new("openai").unwrap(),
command: ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context.clone(),
document: document(),
},
})
.collect(),
}
}
async fn harness() -> (AdminBundle, Arc<FakeProviderAdmin>, EventLog) {
let log = events();
let provider = FakeProviderAdmin::new("openai", log.clone());
let bundle = AdminHarness::new()
.provider(provider.clone())
.accounts(FakeAccountStore::new("openai", log.clone()))
.build_bundle()
.await;
(bundle, provider, log)
}
fn start(bundle: &mut AdminBundle) -> (CancellationToken, tokio::task::JoinHandle<()>) {
let registration = bundle
.take_worker_contributions()
.into_iter()
.find_map(|contribution| match contribution {
WorkerContribution::Registration(registration)
if registration.id.kind() == WorkerKind::AccountImport =>
{
Some(registration)
}
_ => None,
})
.expect("import registration");
let WorkerRunnable::Daemon { task, .. } = registration.runnable else {
panic!("import daemon")
};
let cancellation = CancellationToken::new();
let token = cancellation.clone();
let handle = tokio::spawn(async move {
task.run(token).await.expect("import daemon exits cleanly");
});
(cancellation, handle)
}
async fn wait_started(log: &EventLog, count: usize) {
tokio::time::timeout(Duration::from_secs(5), async {
while recorded(log)
.iter()
.filter(|event| **event == "provider.prepare_import")
.count()
< count
{
tokio::task::yield_now().await;
}
})
.await
.expect("imports started");
}
async fn wait_finished(services: &AdminServices, id: Uuid) {
tokio::time::timeout(Duration::from_secs(5), async {
while services
.import_tasks()
.detail(&context("poll"), id)
.unwrap()
.summary
.finished_at
.is_none()
{
tokio::task::yield_now().await;
}
})
.await
.expect("imports finished");
}
#[tokio::test]
async fn retry_should_return_the_same_task_but_reject_changed_content() {
let (bundle, _, _) = harness().await;
let services = bundle.services();
let id = Uuid::now_v7();
let first = services.import_tasks().submit(command(id, 2)).unwrap();
let retry = services.import_tasks().submit(command(id, 2)).unwrap();
assert_eq!(first.task_id, retry.task_id);
assert_eq!(
services
.import_tasks()
.list(&context("reopened-page"))
.len(),
1
);
let mut changed = command(id, 2);
changed.fingerprint = [2; 32];
assert_eq!(
services.import_tasks().submit(changed).unwrap_err().kind(),
AdminErrorKind::Conflict
);
}
#[tokio::test]
async fn task_reads_and_stop_should_be_isolated_by_owner() {
let (bundle, _, _) = harness().await;
let services = bundle.services();
let task = services
.import_tasks()
.submit(command(Uuid::now_v7(), 1))
.unwrap();
let other = MutationContext {
actor: MutationActor::AdminSession {
admin_user_id: "another-admin".to_owned(),
},
request_id: "other-session".to_owned(),
};
assert!(services.import_tasks().list(&other).is_empty());
assert_eq!(
services
.import_tasks()
.detail(&other, task.task_id)
.unwrap_err()
.kind(),
AdminErrorKind::NotFound
);
assert_eq!(
services
.import_tasks()
.stop(&other, task.task_id)
.unwrap_err()
.kind(),
AdminErrorKind::NotFound
);
}
#[tokio::test]
async fn stopping_should_skip_pending_items_and_allow_three_in_flight_to_commit() {
let (mut bundle, provider, log) = harness().await;
let gate = provider.block_imports();
let services = bundle.services();
let task = services
.import_tasks()
.submit(command(Uuid::now_v7(), 8))
.unwrap();
let (cancel, worker) = start(&mut bundle);
wait_started(&log, 3).await;
let stopped = services
.import_tasks()
.stop(&context("stop"), task.task_id)
.unwrap();
assert_eq!(stopped.summary.counts.running, 3);
assert_eq!(stopped.summary.counts.skipped, 5);
gate.add_permits(3);
wait_finished(&services, task.task_id).await;
let finished = services
.import_tasks()
.detail(&context("new-page"), task.task_id)
.unwrap();
assert_eq!(finished.summary.counts.imported_accounts, 3);
assert_eq!(
recorded(&log)
.iter()
.filter(|event| **event == "provider.prepare_import")
.count(),
3
);
cancel.cancel();
worker.await.unwrap();
}
#[tokio::test]
async fn concurrency_limit_should_be_shared_across_tasks() {
let (mut bundle, provider, log) = harness().await;
let gate = provider.block_imports();
let services = bundle.services();
let first = services
.import_tasks()
.submit(command(Uuid::now_v7(), 6))
.unwrap();
let second = services
.import_tasks()
.submit(command(Uuid::now_v7(), 6))
.unwrap();
let (cancel, worker) = start(&mut bundle);
wait_started(&log, 3).await;
let summaries = services.import_tasks().list(&context("poll"));
assert_eq!(
summaries
.iter()
.map(|task| task.counts.running)
.sum::<usize>(),
3
);
assert!(summaries.iter().all(|task| task.counts.running > 0));
gate.add_permits(12);
wait_finished(&services, first.task_id).await;
wait_finished(&services, second.task_id).await;
cancel.cancel();
worker.await.unwrap();
}
#[tokio::test]
async fn failed_and_unknown_items_should_not_abort_the_batch_or_retry_credentials() {
for (kind, failed, unknown) in [
(ProviderAdminErrorKind::Invalid, 1, 0),
(ProviderAdminErrorKind::Ambiguous, 0, 1),
] {
let (mut bundle, provider, log) = harness().await;
provider.fail_next(kind);
let services = bundle.services();
let task = services
.import_tasks()
.submit(command(Uuid::now_v7(), 4))
.unwrap();
let (cancel, worker) = start(&mut bundle);
wait_finished(&services, task.task_id).await;
let result = services
.import_tasks()
.detail(&context("poll"), task.task_id)
.unwrap();
assert_eq!(
(
result.summary.counts.succeeded,
result.summary.counts.failed,
result.summary.counts.unknown
),
(3, failed, unknown)
);
assert_eq!(
recorded(&log)
.iter()
.filter(|event| **event == "provider.prepare_import")
.count(),
4
);
cancel.cancel();
worker.await.unwrap();
}
}
#[tokio::test]
async fn shutdown_should_drain_running_items_and_reject_new_work() {
let (mut bundle, provider, log) = harness().await;
let gate = provider.block_imports();
let services = bundle.services();
let task = services
.import_tasks()
.submit(command(Uuid::now_v7(), 7))
.unwrap();
let (cancel, worker) = start(&mut bundle);
wait_started(&log, 3).await;
cancel.cancel();
tokio::task::yield_now().await;
assert_eq!(
services
.import_tasks()
.submit(command(Uuid::now_v7(), 1))
.unwrap_err()
.kind(),
AdminErrorKind::Unavailable
);
assert!(!worker.is_finished());
gate.add_permits(3);
worker.await.unwrap();
let result = services
.import_tasks()
.detail(&context("poll"), task.task_id)
.unwrap();
assert_eq!(
(
result.summary.counts.succeeded,
result.summary.counts.skipped
),
(3, 4)
);
}
#[tokio::test(start_paused = true)]
async fn completed_records_should_expire_and_new_process_should_start_empty() {
let (bundle, _, _) = harness().await;
let services = bundle.services();
let task = services
.import_tasks()
.submit(command(Uuid::now_v7(), 2))
.unwrap();
services
.import_tasks()
.stop(&context("stop"), task.task_id)
.unwrap();
tokio::time::advance(Duration::from_secs(3601)).await;
assert!(services.import_tasks().list(&context("poll")).is_empty());
assert_eq!(
services
.import_tasks()
.detail(&context("poll"), task.task_id)
.unwrap_err()
.kind(),
AdminErrorKind::NotFound
);
let (fresh, _, _) = harness().await;
assert!(
fresh
.services()
.import_tasks()
.list(&context("poll"))
.is_empty()
);
}
#[tokio::test]
async fn queue_should_reject_excess_work_before_starting_any_imports() {
let (bundle, _, log) = harness().await;
let services = bundle.services();
assert_eq!(
services
.import_tasks()
.submit(command(Uuid::now_v7(), 201))
.unwrap_err()
.kind(),
AdminErrorKind::Invalid
);
for _ in 0..8 {
services
.import_tasks()
.submit(command(Uuid::now_v7(), 1))
.unwrap();
}
assert_eq!(
services
.import_tasks()
.submit(command(Uuid::now_v7(), 1))
.unwrap_err()
.kind(),
AdminErrorKind::RateLimited
);
assert!(recorded(&log).is_empty());
}
@@ -1,8 +1,11 @@
mod account_groups;
mod accounts;
mod auth;
mod auth_key;
mod backup;
mod client_keys;
mod freeze_recovery;
mod import_tasks;
mod observability;
mod openai;
mod proxies;
@@ -19,7 +22,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::future::BoxFuture;
use gateway_admin::{
AdminConfig, AdminServices, InitialAdminPassword,
AdminConfig, AdminServices, ClientConfig, InitialAdminPassword,
model::{
MutationContext, Revision,
account_groups::{
@@ -31,7 +34,7 @@ use gateway_admin::{
AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult, AccountsUpdateResult,
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
},
auth::{AdminAuditEvent, AdminSession},
auth::{AdminAuditEvent, AuthSession},
client_distribution::CodexDesktopWindowsDownloads,
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
@@ -71,16 +74,21 @@ use gateway_admin::{
};
use gateway_core::{
account::ProviderAccountId,
engine::probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
engine::{
execution::{ClientAuthenticationError, ClientKeyVerifier},
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
},
error::{GatewayError, GatewayErrorKind},
policy::ClientApiKeyId,
routing::{ConfigRevision, ProviderKind},
runtime::SnapshotControl,
};
use std::collections::BTreeMap;
pub(super) struct AdminHarness {
default_password: String,
session_ttl_minutes: u64,
client_session_ttl_minutes: u64,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
@@ -93,6 +101,7 @@ pub(super) struct AdminHarness {
providers: Vec<Arc<dyn ProviderAdmin>>,
probe: Arc<dyn AccountProbe>,
system: Arc<dyn SystemOperations>,
client_key_verifier: Arc<dyn ClientKeyVerifier>,
}
impl AdminHarness {
@@ -101,6 +110,7 @@ impl AdminHarness {
Self {
default_password: "strong-test-password".to_owned(),
session_ttl_minutes: 60,
client_session_ttl_minutes: 1_440,
accounts: unavailable.clone(),
proxies: Arc::new(proxies::TestProxies::default()),
account_runtime: unavailable.clone(),
@@ -116,6 +126,7 @@ impl AdminHarness {
],
probe: Arc::new(UnavailableProbe),
system: Arc::new(UnavailableSystem),
client_key_verifier: Arc::new(UnavailableClientKeyVerifier),
}
}
@@ -154,6 +165,16 @@ impl AdminHarness {
self
}
pub(super) fn client_session_ttl_minutes(mut self, minutes: u64) -> Self {
self.client_session_ttl_minutes = minutes;
self
}
pub(super) fn client_key_verifier(mut self, verifier: Arc<dyn ClientKeyVerifier>) -> Self {
self.client_key_verifier = verifier;
self
}
pub(super) fn observability(mut self, store: Arc<dyn ObservabilityStore>) -> Self {
self.observability = store;
self
@@ -195,12 +216,19 @@ impl AdminHarness {
}
pub(super) async fn build(self) -> AdminServices {
self.build_bundle().await.services()
}
pub(super) async fn build_bundle(self) -> gateway_admin::AdminBundle {
gateway_admin::initialize(
AdminConfig {
session_ttl_minutes: self.session_ttl_minutes,
default_username: "admin".to_owned(),
default_password: InitialAdminPassword::new(self.default_password),
},
ClientConfig {
session_ttl_minutes: self.client_session_ttl_minutes,
},
AdminStorePorts::new(
AdminAccountStorePorts::new(
self.accounts,
@@ -214,15 +242,19 @@ 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 {
pricing_source: Arc::new(UnavailablePricingSource),
providers: self.providers,
snapshot: Arc::new(NoopSnapshot),
account_probe: self.probe,
proxy_probe: Arc::new(proxies::TestProxies::default()),
client_distribution: Arc::new(NoopClientDistribution),
system: self.system,
client_key_verifier: self.client_key_verifier,
},
)
.await
.expect("initialize admin test harness")
.services()
}
}
@@ -251,6 +283,25 @@ impl AuthStore for BootstrapAuthStore {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn change_password(
&self,
_: &str,
expected_hash: &str,
password_hash: &str,
audit: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
let mut stored = self.password_hash.lock().unwrap();
let Some(credentials) = stored
.as_mut()
.filter(|value| value.as_str() == expected_hash)
else {
return Ok(false);
};
*credentials = password_hash.to_owned();
let _ = audit;
Ok(true)
}
async fn create_password_hash_if_absent(
&self,
_: &str,
@@ -268,18 +319,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 +355,14 @@ impl AuthStore for BootstrapAuthStore {
struct UnavailableStore;
struct UnavailableClientKeyVerifier;
impl ClientKeyVerifier for UnavailableClientKeyVerifier {
fn verify_client_key(&self, _: &str) -> Result<ClientApiKeyId, ClientAuthenticationError> {
Err(ClientAuthenticationError::InvalidKey)
}
}
struct UnavailableAccountGroupStore;
#[async_trait]
@@ -434,6 +510,15 @@ impl AccountStore for UnavailableStore {
Err(unavailable("account enabled"))
}
async fn lower_concurrency_limit(
&self,
_: &gateway_core::account::ProviderAccountId,
_: gateway_core::account::AccountConcurrencyLimit,
_: &MutationContext,
) -> AdminStoreResult<Option<gateway_admin::model::accounts::AccountUpdateResult>> {
Ok(None)
}
async fn recover_account(
&self,
_: &ProviderAccountId,
@@ -476,10 +561,47 @@ impl AccountRuntimeStore for UnavailableStore {
async fn account_runtime(&self, _: &[String]) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
async fn active_freezes(
&self,
) -> AdminStoreResult<BTreeMap<String, gateway_admin::model::accounts::AccountFreeze>> {
Ok(BTreeMap::new())
}
async fn capacity_peaks(
&self,
_account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>> {
Ok(BTreeMap::new())
}
async fn finish_freeze(
&self,
_account_id: &str,
_expected: &gateway_admin::model::accounts::AccountFreeze,
_postpone_until: Option<DateTime<Utc>>,
) -> AdminStoreResult<bool> {
Ok(false)
}
}
#[async_trait]
impl ClientKeyStore for UnavailableStore {
async fn reset_client_key_budget(
&self,
_: gateway_admin::model::client_keys::ResetClientKeyBudget,
_: &MutationContext,
) -> AdminStoreResult<()> {
Err(unavailable("client key budget reset"))
}
async fn get_client_key(
&self,
_: &ClientApiKeyId,
) -> AdminStoreResult<Option<ClientKeyRecord>> {
Err(unavailable("client key"))
}
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
Err(unavailable("client key list"))
}
@@ -584,6 +706,23 @@ impl ObservabilityStore for UnavailableStore {
#[async_trait]
impl SettingsStore for UnavailableStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unavailable("settings"))
}
@@ -622,6 +761,19 @@ struct UnavailableProvider {
calculated_billing: Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
}
struct UnavailablePricingSource;
#[async_trait]
impl gateway_admin::ports::pricing::PricingSource for UnavailablePricingSource {
async fn fetch(
&self,
) -> Result<gateway_admin::model::pricing::PricingSyncPreview, gateway_admin::model::AdminError>
{
Err(gateway_admin::model::AdminError::internal(
"pricing source unavailable",
))
}
}
impl UnavailableProvider {
fn new(kind: &str) -> Self {
Self {
@@ -769,6 +921,8 @@ pub(super) fn calculated_billing_provider() -> Arc<dyn ProviderAdmin> {
dashboard_profile: None,
calculated_billing: Some(
gateway_admin::model::observability::CalculatedBillingBreakdown {
custom_multiplier_bps: 10_000,
image: None,
input_amount: amount("0.8"),
output_amount: amount("0.2"),
cache_read_amount: amount("0"),
@@ -372,6 +372,7 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
}],
}]);
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
breakdown: None,
bucket_start: quarter_hour_start(now),
provider_kind: "openai".to_owned(),
upstream_model_id: "gpt-5.5".to_owned(),
@@ -543,6 +544,7 @@ async fn usage_insights_should_reject_partial_costs_when_billing_stream_fails()
let range = observation_range(now);
let store = Arc::new(FixtureObservabilityStore::new(range));
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
breakdown: None,
bucket_start: quarter_hour_start(now),
provider_kind: "openai".to_owned(),
upstream_model_id: "gpt-5.5".to_owned(),
@@ -751,14 +753,38 @@ struct FixtureSettingsStore;
#[async_trait]
impl SettingsStore for FixtureSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
openai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(1).expect("revision"),
model_mappings: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: 1,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -766,6 +792,13 @@ impl SettingsStore for FixtureSettingsStore {
ops_event_retention_days: 30,
audit_retention_days: 30,
updated_at: Utc::now(),
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
})
}
@@ -848,6 +881,7 @@ fn total_record(
provider_account_authentication_kind: None,
upstream_model_id: Some("gpt-5.5".to_owned()),
upstream_transport: None,
upstream_response_model: None,
service_tier: None,
input_tokens: Some(800),
output_tokens: Some(200),
@@ -12,6 +12,7 @@ use gateway_core::account::{OutboundProxy, ProviderAccountId};
pub(super) struct TestProxies {
pub events: Option<super::accounts::EventLog>,
pub accounts: Option<Vec<ProxyAccountRef>>,
pub record: Option<ProxyRecord>,
}
struct ImportGuard(super::accounts::EventLog);
@@ -70,8 +71,11 @@ impl ProxyStore for TestProxies {
page_size: query.page_size.get(),
})
}
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
Err(super::unavailable("proxy"))
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord> {
self.record
.clone()
.filter(|record| record.id == id)
.ok_or_else(|| super::unavailable("proxy"))
}
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
Err(super::unavailable("proxy"))
@@ -105,6 +109,60 @@ impl ProxyProbe for TestProxies {
}
}
#[tokio::test]
async fn authorization_uses_selected_proxy_regardless_of_probe_status() {
use super::accounts::{FakeAccountStore, FakeProviderAdmin, context, events, recorded};
use gateway_admin::model::provider_credentials::StartAuthorization;
use std::sync::Arc;
for kind in ["openai", "xai"] {
for probe_success in [None, Some(false), Some(true)] {
let events = events();
let now = chrono::Utc::now();
let services = super::AdminHarness::new()
.provider(FakeProviderAdmin::new(kind, events.clone()))
.accounts(FakeAccountStore::new(kind, events.clone()))
.proxies(Arc::new(TestProxies {
record: Some(ProxyRecord {
location: None,
id: "proxy_oauth".to_owned(),
name: "授权出口".to_owned(),
proxy: OutboundProxy::parse("http://127.0.0.1:8080").unwrap(),
revision: Revision::new(1).unwrap(),
account_count: 0,
last_test_at: probe_success.map(|_| now),
last_test: probe_success.map(|success| ProxyTestResult {
success,
latency_ms: 10,
exit_ip: None,
exit_ipv4: None,
exit_ipv6: None,
message: "出口探测结果".to_owned(),
}),
created_at: now,
updated_at: now,
}),
..Default::default()
}))
.build()
.await;
let command = StartAuthorization {
outbound_proxy: Some(AccountProxySelection::Saved("proxy_oauth".to_owned())),
context: context("oauth-proxy-status"),
name: "授权账号".to_owned(),
reauthorization: None,
};
let result = if kind == "openai" {
services.openai().start_authorization(command).await
} else {
services.xai().start_authorization(command).await
};
assert!(result.is_ok(), "{kind}, {probe_success:?}: {result:?}");
assert_eq!(recorded(&events), ["provider.start_authorization"]);
}
}
}
#[tokio::test]
async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
use super::accounts::{FakeProviderAdmin, events};
@@ -15,6 +15,23 @@ struct UnusedSettingsStore;
#[async_trait]
impl SettingsStore for UnusedSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
Ok(Default::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
_: gateway_admin::model::pricing::UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
panic!("unexpected pricing update")
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unused())
}
@@ -61,17 +78,31 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
request_id: "request-settings".to_owned(),
},
ReplaceRuntimeSettings {
openai_client_profile: None,
request_location_enabled: false,
request_location: Default::default(),
model_mappings: Default::default(),
refresh_margin_seconds: 0,
refresh_concurrency: 1,
max_concurrent_per_account: 1,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
},
)
.await
+1
View File
@@ -24,6 +24,7 @@ gateway-protocol = { path = "../gateway-protocol" }
secrecy = "0.10"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
sha2 = "0.11.0"
thiserror = "2.0.18"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "sync", "time"] }
tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeout", "trace"] }
@@ -1,5 +1,7 @@
//! Account group HTTP wire and fixed routes.
use crate::auth::SessionState;
use std::collections::BTreeMap;
use axum::{
@@ -22,8 +24,8 @@ use gateway_core::routing::AccountGroupId;
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta, WireValidationError, wire::map_admin_service_error,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
WireValidationError, wire::map_admin_service_error,
};
const DEFAULT_PAGE_SIZE: u32 = 50;
@@ -70,6 +72,8 @@ impl ListAccountGroupsQuery {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateAccountGroupRequest {
#[serde(default)]
disable_fast: bool,
name: String,
description: Option<String>,
color: String,
@@ -78,6 +82,7 @@ struct CreateAccountGroupRequest {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct UpdateAccountGroupRequest {
disable_fast: Option<bool>,
id: String,
name: String,
description: Option<String>,
@@ -93,6 +98,7 @@ struct AccountGroupIdRequest {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountGroupView {
disable_fast: bool,
id: String,
name: String,
description: Option<String>,
@@ -136,6 +142,7 @@ impl From<AccountGroupRecord> for AccountGroupView {
id: record.id.to_string(),
name: record.name,
description: record.description,
disable_fast: record.disable_fast,
color: record.color.as_str().to_owned(),
enabled: record.enabled,
member_count: record.member_count,
@@ -221,7 +228,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 +245,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 +265,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(
@@ -271,6 +278,7 @@ where
CreateAccountGroup {
name: request.name,
description: request.description,
disable_fast: request.disable_fast,
color: group_color(&request.color)?,
},
)
@@ -284,7 +292,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(
@@ -298,6 +306,7 @@ where
id: group_id(request.id)?,
name: request.name,
description: request.description,
disable_fast: request.disable_fast,
color: group_color(&request.color)?,
},
)
@@ -311,7 +320,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 +331,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 +343,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 +367,7 @@ async fn delete<S>(
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
mutation_response(
StatusCode::OK,
@@ -2,6 +2,18 @@
use super::*;
fn validate_account_notes(notes: Option<&str>) -> Result<(), WireValidationError> {
if notes.is_some_and(|notes| {
notes.chars().count() > 500
|| notes
.chars()
.any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))
}) {
return Err(WireValidationError::new("notes"));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum AccountProvider {
OpenAi,
@@ -18,19 +30,22 @@ impl AccountProvider {
}
}
/// 导入统一设置,复用编辑账号的调度和分组约束。
#[derive(Debug, Clone, Deserialize)]
/// 导入统一设置,复用编辑账号的备注、调度和分组约束。
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportSettingsRequest {
pub notes: Option<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
pub weight: u64,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Vec<String>,
}
impl AccountImportSettingsRequest {
fn validate(&self) -> Result<(), WireValidationError> {
validate_account_notes(self.notes.as_deref())?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
validate_wire_group_ids(&self.group_ids)?;
@@ -41,16 +56,18 @@ impl AccountImportSettingsRequest {
self,
) -> Result<gateway_admin::model::accounts::AccountImportSettings, WireValidationError> {
Ok(gateway_admin::model::accounts::AccountImportSettings {
notes: self.notes,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
model_access: self.model_access,
group_ids: validate_wire_group_ids(&self.group_ids)?,
})
}
}
/// Provider-owned 账号导入请求;公共 API 不解释 `data` 内部字段。
#[derive(Deserialize)]
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportRequest {
pub outbound_proxy_id: Option<String>,
@@ -200,16 +217,19 @@ pub struct UpdateAccountRequest {
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
pub account_id: String,
pub notes: Option<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
pub weight: u64,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Vec<String>,
}
impl UpdateAccountRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
validate_account_notes(self.notes.as_deref())?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
validate_wire_group_ids(&self.group_ids)?;
@@ -224,9 +244,11 @@ impl UpdateAccountRequest {
self.outbound_proxy_url,
)?,
account_id: self.account_id,
notes: self.notes,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
model_access: self.model_access,
group_ids: validate_wire_group_ids(&self.group_ids)?,
})
}
@@ -297,9 +319,13 @@ impl AccountDeletionRequest {
pub struct RotateAccountRequest {
pub provider: String,
pub account_id: String,
pub access_token: String,
pub access_token: Option<String>,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
pub transport: Option<String>,
pub settings: Option<UpdateAccountRequest>,
}
impl RotateAccountRequest {
@@ -308,11 +334,40 @@ impl RotateAccountRequest {
return Err(WireValidationError::new("provider"));
}
require_account_id(&self.account_id, "accountId")?;
validate_oauth_material(
&self.access_token,
self.refresh_token.as_deref(),
self.id_token.as_deref(),
)
if let Some(settings) = &self.settings {
settings.validate()?;
if settings.account_id != self.account_id {
return Err(WireValidationError::new("settings.accountId"));
}
}
if let Some(base_url) = &self.base_url {
if self.access_token.is_some()
|| self.refresh_token.is_some()
|| self.id_token.is_some()
|| base_url.is_empty()
|| base_url.len() > 2048
|| !matches!(self.transport.as_deref(), Some("http" | "prefer_websocket"))
|| self.api_key.as_ref().is_some_and(|key| {
key.is_empty()
|| key.len() > 16 * 1024
|| !key.bytes().all(|byte| byte.is_ascii_graphic())
})
{
return Err(WireValidationError::new("credential"));
}
Ok(())
} else {
if self.api_key.is_some() || self.transport.is_some() {
return Err(WireValidationError::new("credential"));
}
validate_oauth_material(
self.access_token
.as_deref()
.ok_or_else(|| WireValidationError::new("accessToken"))?,
self.refresh_token.as_deref(),
self.id_token.as_deref(),
)
}
}
pub(super) fn into_command(
@@ -321,16 +376,34 @@ impl RotateAccountRequest {
) -> Result<RotateCredential, WireValidationError> {
self.validate()?;
let mut material = Map::new();
material.insert("access_token".to_owned(), Value::String(self.access_token));
material.insert(
"refresh_token".to_owned(),
self.refresh_token.map_or(Value::Null, Value::String),
);
material.insert(
"id_token".to_owned(),
self.id_token.map_or(Value::Null, Value::String),
);
if let Some(base_url) = self.base_url {
material.insert("base_url".to_owned(), Value::String(base_url));
material.insert(
"transport".to_owned(),
self.transport.map_or(Value::Null, Value::String),
);
if let Some(key) = self.api_key {
material.insert("api_key".to_owned(), Value::String(key));
}
} else {
material.insert(
"access_token".to_owned(),
self.access_token.map_or(Value::Null, Value::String),
);
material.insert(
"refresh_token".to_owned(),
self.refresh_token.map_or(Value::Null, Value::String),
);
material.insert(
"id_token".to_owned(),
self.id_token.map_or(Value::Null, Value::String),
);
}
Ok(RotateCredential {
settings: self
.settings
.map(UpdateAccountRequest::into_command)
.transpose()?,
mutation: CredentialMutation {
context,
account_id: ProviderAccountId::new(self.account_id)
@@ -1,13 +1,16 @@
//! 账号管理路由与 HTTP handler 编排。
use crate::auth::SessionState;
use super::*;
/// 构造统一账号管理路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.merge(super::import_tasks::router::<S>())
.route("/api/admin/accounts", get(list_accounts::<S>))
.route("/api/admin/accounts/detail", get(account_detail::<S>))
.route("/api/admin/accounts/export", get(export_accounts::<S>))
@@ -67,7 +70,7 @@ async fn batch_update_accounts<S>(
AdminJson(request): AdminJson<BatchUpdateAccountsRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
@@ -88,7 +91,7 @@ async fn list_accounts<S>(
AdminQuery(query): AdminQuery<ListQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = query.validate().map_err(map_wire_error)?;
let page = command.page;
@@ -109,7 +112,7 @@ async fn account_detail<S>(
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
@@ -118,8 +121,15 @@ where
.quota(&account_id, false)
.await
.map_err(map_service_error)?;
let data = AccountQuotaData {
let configuration = state
.admin_services()
.accounts()
.account_configuration(&account_id)
.await
.map_err(map_service_error)?;
let data = AccountDetailData {
account: account_view(result, Utc::now()),
credential_configuration: configuration.map(provider_document_value),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -130,7 +140,7 @@ async fn export_accounts<S>(
AdminQuery(query): AdminQuery<AccountExportQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let ids = query.into_ids().map_err(map_wire_error)?;
let result = state
@@ -153,7 +163,7 @@ async fn import_accounts<S>(
AdminJson(request): AdminJson<AccountImportRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
@@ -181,7 +191,7 @@ async fn start_account_authorization<S>(
AdminJson(request): AdminJson<StartAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
@@ -215,7 +225,7 @@ async fn complete_account_authorization<S>(
AdminJson(request): AdminJson<CompleteAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
@@ -249,7 +259,7 @@ async fn rotate_account<S>(
AdminJson(request): AdminJson<RotateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
@@ -272,7 +282,7 @@ async fn update_account<S>(
AdminJson(request): AdminJson<UpdateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
@@ -293,7 +303,7 @@ async fn delete_accounts<S>(
AdminJson(request): AdminJson<AccountDeletionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
@@ -315,7 +325,7 @@ async fn refresh_account<S>(
AdminJson(request): AdminJson<AccountRefreshRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = request.into_command().map_err(map_wire_error)?;
let result = state
@@ -334,7 +344,7 @@ async fn recover_account<S>(
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
@@ -353,7 +363,7 @@ async fn account_quota<S>(
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
@@ -374,7 +384,7 @@ async fn account_quota_forecast<S>(
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
@@ -395,7 +405,7 @@ async fn account_personal_info<S>(
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
@@ -416,7 +426,7 @@ async fn account_profile_avatar<S>(
AdminQuery(query): AdminQuery<AccountProfileAvatarQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let avatar = state
@@ -479,7 +489,7 @@ async fn refresh_account_quota<S>(
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
@@ -500,7 +510,7 @@ async fn account_reset_credits<S>(
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
@@ -521,7 +531,7 @@ async fn consume_account_reset_credit<S>(
AdminJson(request): AdminJson<AccountResetCreditConsumeRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
@@ -542,7 +552,7 @@ async fn account_models<S>(
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
@@ -561,7 +571,7 @@ async fn refresh_account_models<S>(
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
@@ -580,7 +590,7 @@ async fn test_account_connection<S>(
AdminQuery(query): AdminQuery<AccountTestQuery>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let (account_id, upstream_model) = query.into_command().map_err(map_wire_error)?;
let stream = state
@@ -0,0 +1,240 @@
//! 后台导入任务的有界请求与安全结果投影。
use axum::extract::DefaultBodyLimit;
use gateway_admin::model::import_tasks::{
ImportTaskDetail, ImportTaskInput, ImportTaskSummary, MAX_IMPORT_TASK_ITEMS, SubmitImportTask,
};
use sha2::{Digest as _, Sha256};
use super::*;
use crate::auth::SessionState;
pub(super) fn router<S>() -> Router<S>
where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route(
"/api/admin/accounts/import-tasks",
get(list::<S>).post(submit::<S>),
)
.route("/api/admin/accounts/import-tasks/detail", get(detail::<S>))
.route("/api/admin/accounts/import-tasks/stop", post(stop::<S>))
.layer(DefaultBodyLimit::max(4 * 1024 * 1024))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct SubmitRequest {
submission_id: String,
items: Vec<AccountImportRequest>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct TaskId {
task_id: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SummaryView {
task_id: String,
created_at: String,
finished_at: Option<String>,
stop_requested: bool,
total: usize,
counts: CountsView,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CountsView {
pending: usize,
running: usize,
succeeded: usize,
failed: usize,
unknown: usize,
skipped: usize,
imported_accounts: usize,
}
impl From<ImportTaskSummary> for SummaryView {
fn from(task: ImportTaskSummary) -> Self {
let counts = task.counts;
Self {
task_id: task.task_id.to_string(),
created_at: task.created_at.to_rfc3339(),
finished_at: task.finished_at.map(|at| at.to_rfc3339()),
stop_requested: task.stop_requested,
total: task.total,
counts: CountsView {
pending: counts.pending,
running: counts.running,
succeeded: counts.succeeded,
failed: counts.failed,
unknown: counts.unknown,
skipped: counts.skipped,
imported_accounts: counts.imported_accounts,
},
}
}
}
#[derive(Serialize)]
struct DetailView {
#[serde(flatten)]
summary: SummaryView,
items: Vec<ItemView>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ItemView {
index: usize,
provider: String,
status: &'static str,
account_ids: Vec<String>,
message: Option<String>,
}
impl From<ImportTaskDetail> for DetailView {
fn from(task: ImportTaskDetail) -> Self {
Self {
summary: task.summary.into(),
items: task
.items
.into_iter()
.map(|item| ItemView {
index: item.index,
provider: item.provider.to_string(),
status: item.status.as_str(),
account_ids: item
.account_ids
.into_iter()
.map(|id| id.to_string())
.collect(),
message: item.message,
})
.collect(),
}
}
}
#[derive(Serialize)]
struct ListView {
items: Vec<SummaryView>,
}
async fn submit<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<SubmitRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let submission_id = Uuid::parse_str(&request.submission_id)
.map_err(|_| map_wire_error(WireValidationError::new("submissionId")))?;
if submission_id.is_nil() {
return Err(map_wire_error(WireValidationError::new("submissionId")));
}
if request.items.is_empty() || request.items.len() > MAX_IMPORT_TASK_ITEMS {
return Err(map_wire_error(WireValidationError::new("items")));
}
let encoded = serde_json::to_vec(&request.items)
.map_err(|_| map_wire_error(WireValidationError::new("items")))?;
let fingerprint = Sha256::digest(&encoded).into();
drop(encoded);
let context = auth.context().mutation_context();
let items = request
.items
.into_iter()
.map(|item| {
let (provider, command) = item.into_command(context.clone())?;
let provider = ProviderKind::new(match provider {
AccountProvider::OpenAi => "openai",
AccountProvider::Xai => "xai",
})
.map_err(|_| WireValidationError::new("provider"))?;
Ok(ImportTaskInput { provider, command })
})
.collect::<Result<Vec<_>, WireValidationError>>()
.map_err(map_wire_error)?;
let task = state
.admin_services()
.import_tasks()
.submit(SubmitImportTask {
submission_id,
fingerprint,
context,
items,
})
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::ACCEPTED,
AdminEnvelope::ok(SummaryView::from(task)),
))
}
async fn list<S>(auth: AdminAuth, State(state): State<S>) -> impl IntoResponse
where
S: SessionState + Send + Sync,
{
let items = state
.admin_services()
.import_tasks()
.list(&auth.context().mutation_context())
.into_iter()
.map(SummaryView::from)
.collect();
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(ListView { items }))
}
async fn detail<S>(
auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<TaskId>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let task = state
.admin_services()
.import_tasks()
.detail(
&auth.context().mutation_context(),
parse_task_id(&query.task_id)?,
)
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(DetailView::from(task)),
))
}
async fn stop<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<TaskId>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let task = state
.admin_services()
.import_tasks()
.stop(
&auth.context().mutation_context(),
parse_task_id(&request.task_id)?,
)
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(DetailView::from(task)),
))
}
fn parse_task_id(value: &str) -> Result<Uuid, AdminError> {
Uuid::parse_str(value).map_err(|_| map_wire_error(WireValidationError::new("taskId")))
}
@@ -46,8 +46,8 @@ use uuid::{Uuid, Version};
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta, WireValidationError,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
WireValidationError,
};
const DEFAULT_PAGE_SIZE: u32 = 50;
@@ -66,6 +66,7 @@ const MAX_AVATAR_VERSION_BYTES: usize = 32;
mod credentials;
mod handlers;
mod import_tasks;
mod presenter;
mod wire;
@@ -75,8 +76,8 @@ pub(super) use presenter::quota_window_view;
pub use wire::*;
use credentials::{
AccountProvider, deserialize_required_nullable, parse_account_weight, parse_concurrency_limit,
provider_document_value, require_account_id, validate_wire_group_ids,
AccountProvider, parse_account_weight, parse_concurrency_limit, provider_document_value,
require_account_id, validate_wire_group_ids,
};
use presenter::*;
use wire::BatchUpdatedAccountsData;
@@ -66,15 +66,20 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
quota,
} = item;
let status = projection.status.as_str().to_owned();
let rate_limited_until = projection.rate_limited_until.map(DateTime::<Utc>::from);
let cooldown = projection.cooldown;
let expires_at = account.access_token_expires_at.as_ref().map(china_rfc3339);
let added_at = china_rfc3339(&account.created_at);
let updated_at = china_rfc3339(&account.updated_at);
let usage_period = quota.usage_window().map(|(_, period)| period);
let (quota, refresh_token_expires_at) = account_quota_view(quota, rate_limited_until, now);
let mut usage = account_usage_view(usage, usage_period, now);
if account.authentication_kind == "api_key" {
usage.window_label_display = "通用额度".to_owned();
}
let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now);
AccountView {
id: account.id.clone(),
name: account.name,
notes: account.notes,
provider: account.provider_kind.to_string(),
groups: account
.groups
@@ -103,6 +108,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
enabled: account.enabled,
concurrency_limit: account.concurrency_limit.map(|limit| limit.get()),
weight: account.weight.get(),
model_access: account.model_access,
outbound_proxy_endpoint: account
.outbound_proxy
.as_ref()
@@ -119,7 +125,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
updated_at,
updated_at_display: china_datetime(&account.updated_at),
quota,
usage: account_usage_view(usage, usage_period, now),
usage,
}
}
@@ -183,7 +189,7 @@ fn forecast_usd_display(value: Option<f64>) -> String {
pub(super) fn account_quota_view(
mut quota: ProviderQuota,
rate_limited_until: Option<DateTime<Utc>>,
cooldown: Option<gateway_core::account::AccountCooldown>,
now: DateTime<Utc>,
) -> (AccountQuotaView, Option<String>) {
quota.apply_limit_reached_display();
@@ -194,12 +200,22 @@ pub(super) fn account_quota_view(
.observed_at
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now));
let windows = quota.windows.into_iter().map(quota_window_view).collect();
let rate_limited_until = rate_limited_until.map(|until| china_datetime(&until));
let rate_limited_until = cooldown.map(|value| china_datetime(&value.until.into()));
let rate_limit_reason = cooldown.map(|value| {
if value.kind.is_capacity_freeze() {
"capacity_freeze".to_owned()
} else {
"upstream_rate_limit".to_owned()
}
});
let recovery_probe_required = cooldown.is_some_and(|value| value.kind.requires_probe());
(
AccountQuotaView {
refreshed_at_display,
limit_reached: quota.limit_reached,
rate_limited_until,
rate_limit_reason,
recovery_probe_required,
windows,
},
refresh_token_expires_at,
@@ -64,11 +64,12 @@ pub struct BatchUpdateAccountsRequest {
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<AccountProxyUpdate>,
pub account_ids: Vec<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
pub weight: u64,
pub group_ids: Vec<String>,
pub enabled: Option<bool>,
#[serde(default, deserialize_with = "deserialize_optional_nullable_limit")]
pub concurrency_limit: Option<Option<u64>>,
pub weight: Option<u64>,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize)]
@@ -91,6 +92,12 @@ impl From<AccountsUpdateResult> for BatchUpdatedAccountsData {
}
}
fn deserialize_optional_nullable_limit<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Option<u64>>, D::Error> {
Option::<u64>::deserialize(deserializer).map(Some)
}
impl BatchUpdateAccountsRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if self.account_ids.is_empty()
@@ -103,9 +110,23 @@ impl BatchUpdateAccountsRequest {
{
return Err(WireValidationError::new("accountIds"));
}
validate_wire_group_ids(&self.group_ids)?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
if let Some(group_ids) = &self.group_ids {
validate_wire_group_ids(group_ids)?;
}
self.concurrency_limit
.map(parse_concurrency_limit)
.transpose()?;
self.weight.map(parse_account_weight).transpose()?;
if self.enabled.is_none()
&& self.concurrency_limit.is_none()
&& self.weight.is_none()
&& self.group_ids.is_none()
&& self.model_access.is_none()
&& self.outbound_proxy_id.is_none()
&& self.outbound_proxy_url.is_none()
{
return Err(WireValidationError::new("accountSettings"));
}
Ok(())
}
@@ -115,9 +136,17 @@ impl BatchUpdateAccountsRequest {
outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?,
account_ids: self.account_ids,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
group_ids: validate_wire_group_ids(&self.group_ids)?,
concurrency_limit: self
.concurrency_limit
.map(parse_concurrency_limit)
.transpose()?,
weight: self.weight.map(parse_account_weight).transpose()?,
model_access: self.model_access,
group_ids: self
.group_ids
.as_deref()
.map(validate_wire_group_ids)
.transpose()?,
})
}
}
@@ -240,6 +269,7 @@ pub struct AccountView {
pub outbound_proxy_endpoint: Option<String>,
pub id: String,
pub name: String,
pub notes: Option<String>,
pub provider: String,
pub groups: Vec<AccountGroupRefView>,
pub resource_ref: String,
@@ -260,6 +290,7 @@ pub struct AccountView {
pub enabled: bool,
pub concurrency_limit: Option<u32>,
pub weight: u16,
pub model_access: gateway_core::account::AccountModelAccess,
pub access_token_expires_at: Option<String>,
pub access_token_expires_at_display: Option<String>,
pub refresh_token_expires_at: Option<String>,
@@ -330,8 +361,10 @@ pub struct AccountGroupRefView {
pub struct AccountQuotaView {
pub refreshed_at_display: String,
pub limit_reached: bool,
/// 429 临时限流(Redis 冷却)到期时间展示;非限流中为 `null`。
/// 冷却到期或可开始恢复探测的时间;非限流中为 `null`。
pub rate_limited_until: Option<String>,
pub rate_limit_reason: Option<String>,
pub recovery_probe_required: bool,
pub windows: Vec<AccountQuotaWindowView>,
}
@@ -653,6 +686,14 @@ pub struct AccountQuotaData {
pub account: AccountView,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountDetailData {
pub account: AccountView,
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_configuration: Option<serde_json::Value>,
}
/// 个人资料、累计统计与订阅的统一响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
+14 -224
View File
@@ -1,54 +1,17 @@
//! 管理员登录、会话与管理请求鉴权接线。
use std::fmt;
//! 管理端权限校验与请求审计上下文。
use axum::{
Router,
extract::{FromRequestParts, State},
http::{
HeaderMap, HeaderValue, StatusCode,
header::{ORIGIN, SET_COOKIE},
request::Parts,
},
response::{IntoResponse, Response},
routing::{get, post},
extract::FromRequestParts,
http::{HeaderMap, request::Parts},
};
use gateway_admin::{
AdminServices,
model::auth::{AdminPrincipal, AdminRequestContext, LoginCommand, LoginError},
};
use serde::{Deserialize, Serialize};
use gateway_admin::model::auth::{AdminPrincipal, AdminRequestContext};
use tower_http::request_id::RequestId;
use url::Url;
use super::{AdminEnvelope, AdminError, AdminJson, AdminResponse};
use crate::{auth::SessionState, session_cookie};
use super::{AdminError, wire::map_admin_service_error};
const REQUEST_ID_HEADER: &str = "x-request-id";
const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session";
/// 所有管理 HTTP 模块从 state 消费同一个认证用例端口。
pub trait AdminSessionState {
fn admin_services(&self) -> &AdminServices;
}
fn admin_session_cookie_attrs(headers: &HeaderMap) -> &'static str {
// 同源管理端的 POST 由浏览器携带 Origin,HTTPS 反代回源 HTTP 不改变它。
// 只对明确的 HTTP 来源放宽;缺失、opaque 或非法来源继续使用 Secure。
let http_origin = headers.get_all(ORIGIN).iter().count() == 1
&& headers
.get(ORIGIN)
.and_then(|value| value.to_str().ok())
.is_some_and(|origin| {
Url::parse(origin).is_ok_and(|url| {
url.scheme() == "http" && url.origin().ascii_serialization() == origin
})
});
if http_origin {
"Path=/; HttpOnly; SameSite=Lax"
} else {
"Path=/; Secure; HttpOnly; SameSite=Lax"
}
}
/// 已通过管理员会话或部署级管理 API Key 鉴权的请求。
pub struct AdminAuth {
@@ -64,7 +27,7 @@ impl AdminAuth {
impl<S> FromRequestParts<S> for AdminAuth
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
type Rejection = AdminError;
@@ -97,23 +60,23 @@ fn admin_request_id(parts: &Parts) -> Option<String> {
pub async fn require_admin_session<S>(state: &S, headers: &HeaderMap) -> Result<String, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
match state
.admin_services()
.auth()
.resolve_admin_user_id(admin_session_cookie(headers).as_deref())
.resolve_admin_user_id(session_cookie::value(headers).as_deref())
.await
{
Ok(Some(admin_user_id)) => Ok(admin_user_id),
Ok(None) => Err(AdminError::admin_session_required()),
Err(_) => Err(AdminError::internal()),
Ok(None) => Err(AdminError::session_required()),
Err(error) => Err(map_admin_service_error(error)),
}
}
async fn require_admin_auth<S>(state: &S, headers: &HeaderMap) -> Result<AdminPrincipal, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
if let Some(api_key) = admin_api_key_header(headers) {
return match state
@@ -124,7 +87,7 @@ where
{
Ok(true) => Ok(AdminPrincipal::ApiKey),
Ok(false) => Err(AdminError::invalid_admin_api_key()),
Err(_) => Err(AdminError::internal()),
Err(error) => Err(map_admin_service_error(error)),
};
}
@@ -137,176 +100,3 @@ fn admin_api_key_header(headers: &HeaderMap) -> Option<String> {
let value = headers.get("x-api-key")?.to_str().ok()?.trim();
(!value.is_empty()).then(|| value.to_owned())
}
/// 管理员登录请求;密码不得进入 Debug。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AdminLoginRequest {
username: Option<String>,
password: String,
}
impl AdminLoginRequest {
/// 取出认证用例所需字段。
#[must_use]
pub fn into_parts(self) -> (Option<String>, String) {
(self.username, self.password)
}
}
impl fmt::Debug for AdminLoginRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AdminLoginRequest")
.field("username", &self.username)
.field("password", &"[REDACTED]")
.finish()
}
}
/// 登录成功响应。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminLoginData {
expires_at: String,
}
impl AdminLoginData {
#[must_use]
pub fn new(expires_at: String) -> Self {
Self { expires_at }
}
}
/// 管理员会话状态响应。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminSessionStatusData {
authenticated: bool,
}
impl AdminSessionStatusData {
#[must_use]
pub const fn new(authenticated: bool) -> Self {
Self { authenticated }
}
}
/// 登出成功响应。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct AdminLogoutData {
message: &'static str,
}
impl AdminLogoutData {
#[must_use]
pub const fn new() -> Self {
Self {
message: "Logged out successfully",
}
}
}
impl Default for AdminLogoutData {
fn default() -> Self {
Self::new()
}
}
/// 构造固定 GET/POST 管理员认证路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/auth/login", post(login::<S>))
.route("/api/admin/auth/status", get(session_status::<S>))
.route("/api/admin/auth/logout", post(logout::<S>))
}
async fn login<S>(
State(state): State<S>,
headers: HeaderMap,
AdminJson(payload): AdminJson<AdminLoginRequest>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (username, password) = payload.into_parts();
let session = state
.admin_services()
.auth()
.login(LoginCommand { username, password })
.await
.map_err(map_login_error)?;
let mut response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminLoginData::new(session.expires_at.to_rfc3339())),
)
.into_response();
let cookie = format!(
"{ADMIN_SESSION_COOKIE}={}; {}",
session.session_id,
admin_session_cookie_attrs(&headers)
);
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
);
Ok(response)
}
async fn session_status<S>(
State(state): State<S>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let authenticated = state
.admin_services()
.auth()
.validate_session(admin_session_cookie(&headers).as_deref())
.await
.map_err(|_| AdminError::internal())?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminSessionStatusData::new(authenticated)),
))
}
async fn logout<S>(State(state): State<S>, headers: HeaderMap) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
{
if let Some(session_id) = admin_session_cookie(&headers) {
let _ = state.admin_services().auth().logout(&session_id).await;
}
let mut response =
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(AdminLogoutData::new()))
.into_response();
let cookie = format!(
"{ADMIN_SESSION_COOKIE}=; {}; Max-Age=0",
admin_session_cookie_attrs(&headers)
);
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
);
Ok(response)
}
fn admin_session_cookie(headers: &HeaderMap) -> Option<String> {
let cookie = headers.get("cookie")?.to_str().ok()?;
cookie.split(';').find_map(|part| {
let (name, value) = part.trim().split_once('=')?;
(name == ADMIN_SESSION_COOKIE).then(|| value.to_owned())
})
}
fn map_login_error(error: LoginError) -> AdminError {
match error {
LoginError::InvalidCredentials => AdminError::invalid_admin_credentials(),
LoginError::Unavailable => AdminError::internal(),
}
}
+13 -10
View File
@@ -1,7 +1,10 @@
//! 备份设置、计划、手动创建、记录、下载与删除路由。
//!
//! 路径统一位于 `/api/admin/settings/backups/*`,内部由独立 `BackupService` 承担业务。
use crate::auth::SessionState;
use axum::{
Router,
extract::State,
@@ -19,7 +22,7 @@ use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminPageData, AdminQuery, AdminResponse,
AdminSessionState, PageMeta, wire::map_admin_service_error,
PageMeta, wire::map_admin_service_error,
};
/// 备份设置视图;`secretAccessKey` 返回已保存的明文凭据,由前端掩码显示。
@@ -200,7 +203,7 @@ impl From<ConnectionTestResult> for ConnectionTestView {
/// 构造固定 GET/POST 备份路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/settings/backups", get(backup_settings::<S>))
@@ -239,7 +242,7 @@ async fn backup_settings<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let settings = state
.admin_services()
@@ -259,7 +262,7 @@ async fn update_backup_storage<S>(
AdminJson(request): AdminJson<UpdateBackupStorageRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = UpdateBackupStorageCommand {
endpoint: request.endpoint,
@@ -287,7 +290,7 @@ async fn test_backup_storage<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -307,7 +310,7 @@ async fn update_backup_schedule<S>(
AdminJson(request): AdminJson<UpdateBackupScheduleRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = UpdateBackupScheduleCommand {
schedule_enabled: request.schedule_enabled,
@@ -334,7 +337,7 @@ async fn backup_records<S>(
AdminQuery(query): AdminQuery<BackupRecordsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let page_size = gateway_admin::model::PageSize::new(query.page_size.unwrap_or(20))
.map_err(|_| AdminError::bad_request("pageSize 不合法"))?;
@@ -389,7 +392,7 @@ async fn create_backup<S>(
body: Option<AdminJson<CreateBackupRequest>>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let expires_at = body
.and_then(|body| body.0.expires_in_days)
@@ -420,7 +423,7 @@ async fn download_backup_url<S>(
AdminJson(request): AdminJson<BackupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -440,7 +443,7 @@ async fn delete_backup<S>(
AdminJson(request): AdminJson<BackupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let record = state
.admin_services()
@@ -1,14 +1,17 @@
//! Client API Key 管理 wire contract。
use crate::auth::SessionState;
use std::fmt;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use gateway_admin::model::client_keys::{
ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue, ClientKeyListQuery,
ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret,
ClientKeySort as DomainSort, ClientKeySortField as DomainSortField, CreateClientKey,
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
ClientKeyBudgetPeriod, ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue,
ClientKeyListQuery, ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord,
ClientKeySecret, ClientKeySort as DomainSort, ClientKeySortField as DomainSortField,
CreateClientKey, CreatedClientKey, DeleteClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
SortDirection, UpdateClientKey,
};
use gateway_core::{
engine::budget::ClientBudgetLimits,
@@ -27,7 +30,7 @@ use axum::{
};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
WireValidationError, wire::map_admin_service_error,
};
@@ -144,6 +147,7 @@ impl ClientKeySort {
#[derive(Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateClientKeyRequest {
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
custom_key: Option<String>,
name: String,
label: Option<String>,
@@ -173,6 +177,9 @@ impl CreateClientKeyRequest {
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(CreateClientKey {
openai_client_profile_override: self
.openai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::new),
custom_key: self
.custom_key
.filter(|key| !key.is_empty())
@@ -199,6 +206,8 @@ impl CreateClientKeyRequest {
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateClientKeyRequest {
#[serde(default, deserialize_with = "deserialize_profile_override")]
openai_client_profile_override: Option<Option<serde_json::Map<String, serde_json::Value>>>,
id: String,
name: String,
label: Option<String>,
@@ -219,6 +228,9 @@ impl UpdateClientKeyRequest {
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(UpdateClientKey {
openai_client_profile_override: self
.openai_client_profile_override
.map(|value| value.map(gateway_core::account::OpaqueProviderData::new)),
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
name: self.name,
label: self.label,
@@ -233,6 +245,36 @@ impl UpdateClientKeyRequest {
}
}
/// 重置指定周期已用金额的请求。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ResetClientKeyBudgetRequest {
id: String,
period: BudgetResetPeriod,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
enum BudgetResetPeriod {
Daily,
Weekly,
All,
}
impl ResetClientKeyBudgetRequest {
pub fn into_command(self) -> Result<ResetClientKeyBudget, WireValidationError> {
validate_required_text(&self.id, "id")?;
Ok(ResetClientKeyBudget {
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
period: match self.period {
BudgetResetPeriod::Daily => ClientKeyBudgetPeriod::Daily,
BudgetResetPeriod::Weekly => ClientKeyBudgetPeriod::Weekly,
BudgetResetPeriod::All => ClientKeyBudgetPeriod::All,
},
})
}
}
/// 只携带 ID 的 Client Key mutation 请求。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -274,6 +316,7 @@ impl ClientKeyMutationRequest {
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientKeyView {
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
id: String,
name: String,
label: Option<String>,
@@ -312,6 +355,9 @@ impl From<ClientKeyRecord> for ClientKeyView {
"groups"
};
Self {
openai_client_profile_override: record
.openai_client_profile_override
.map(gateway_core::account::OpaqueProviderData::into_inner),
id: record.id.to_string(),
name: record.name,
label: record.label,
@@ -679,7 +725,7 @@ fn validate_optional_text(
/// 构造固定 GET/POST 且 ID 仅位于 query/body 的 Client API Key 路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/client-keys", get(list_client_keys::<S>))
@@ -688,6 +734,10 @@ where
post(create_client_key::<S>),
)
.route("/api/admin/client-keys/reveal", get(reveal_client_key::<S>))
.route(
"/api/admin/client-keys/reset-budget",
post(reset_client_key_budget::<S>),
)
.route(
"/api/admin/client-keys/update",
post(update_client_key::<S>),
@@ -712,7 +762,7 @@ async fn list_client_keys<S>(
AdminQuery(query): AdminQuery<ListClientKeysQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -730,7 +780,7 @@ async fn create_client_key<S>(
AdminJson(payload): AdminJson<CreateClientKeyRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let command = payload.into_command().map_err(map_wire_error)?;
let result = state
@@ -751,7 +801,7 @@ async fn reveal_client_key<S>(
AdminQuery(query): AdminQuery<ClientKeyIdQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let id = query.into_domain_id().map_err(map_wire_error)?;
let result = state
@@ -777,7 +827,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,13 +839,36 @@ where
)
}
async fn reset_client_key_budget<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(payload): AdminJson<ResetClientKeyBudgetRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let id = state
.admin_services()
.client_keys()
.reset_budget(
&auth.context().mutation_context(),
payload.into_command().map_err(map_wire_error)?,
)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(MutatedClientKeyData::new(id.as_str().to_owned())),
))
}
async fn disable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let id = payload.into_domain_id().map_err(map_wire_error)?;
mutation_response(
@@ -816,7 +889,7 @@ async fn enable_client_key<S>(
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let id = payload.into_domain_id().map_err(map_wire_error)?;
mutation_response(
@@ -837,7 +910,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(
@@ -872,3 +945,10 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error)
}
// 省略表示不修改,null 表示恢复跟随通用设置。
fn deserialize_profile_override<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Option<serde_json::Map<String, serde_json::Value>>>, D::Error> {
Option::<serde_json::Map<String, serde_json::Value>>::deserialize(deserializer).map(Some)
}
+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;
@@ -214,6 +214,7 @@ fn capitalize_first(value: &str) -> String {
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
match billing? {
domain::UsageBilling::Total { source, total } => Some(BillingView {
image: None,
input_amount_display: "—".to_owned(),
output_amount_display: "—".to_owned(),
cache_read_amount_display: "—".to_owned(),
@@ -232,6 +233,17 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<Bil
multiplier_display: "—".to_owned(),
}),
domain::UsageBilling::Calculated(value) => Some(BillingView {
image: value
.image
.as_ref()
.map(|image| super::wire::ImageBillingView {
input_amount_display: format_money(&image.input_amount),
cache_read_amount_display: format_money(&image.cache_read_amount),
input_price_display: format_token_price(&image.input_price_per_million),
cache_read_price_display: format_token_price(
&image.cache_read_price_per_million,
),
}),
input_amount_display: format_money(&value.input_amount),
output_amount_display: format_money(&value.output_amount),
cache_read_amount_display: format_money(&value.cache_read_amount),
@@ -243,7 +255,15 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<Bil
cache_read_price_display: format_token_price(&value.cache_read_price_per_million),
cache_write_price_display: format_token_price(&value.cache_write_price_per_million),
service_tier_display: format_service_tier(value.service_tier.as_deref()),
multiplier_display: format!("{:.2}x", f64::from(value.multiplier_percent) / 100.0),
multiplier_display: if value.custom_multiplier_bps == 10_000 {
format!("{:.2}x", f64::from(value.multiplier_percent) / 100.0)
} else {
format!(
"档位 {:.2}x · 自定义 {:.4}x",
f64::from(value.multiplier_percent) / 100.0,
f64::from(value.custom_multiplier_bps) / 10_000.0
)
},
}),
}
}
@@ -267,6 +287,7 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi
model,
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
client_transport: record.client_transport,
upstream_transport: record.upstream_transport,
@@ -360,6 +381,7 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
model,
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
status_code,
client_transport: record.client_transport,
@@ -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
@@ -56,6 +56,8 @@ pub struct CostCoverageView {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BillingView {
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<ImageBillingView>,
pub input_amount_display: String,
pub output_amount_display: String,
pub cache_read_amount_display: String,
@@ -70,6 +72,15 @@ pub struct BillingView {
pub multiplier_display: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageBillingView {
pub input_amount_display: String,
pub cache_read_amount_display: String,
pub input_price_display: String,
pub cache_read_price_display: String,
}
/// 使用记录表格的窄展示。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
@@ -84,6 +95,7 @@ pub struct UsageListRecordView {
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub client_transport: String,
pub upstream_transport: Option<String>,
@@ -122,6 +134,7 @@ pub struct UsageRecordView {
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub status_code: Option<i64>,
pub client_transport: String,
+32 -11
View File
@@ -1,3 +1,5 @@
use crate::auth::SessionState;
use axum::{
Router,
extract::State,
@@ -15,8 +17,7 @@ use gateway_admin::model::{
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
accounts::{AccountGroupRefView, AccountProxyUpdate},
};
@@ -47,6 +48,7 @@ struct RemoveAccountRequest {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateRequest {
location: Option<gateway_core::account::RequestLocation>,
name: String,
proxy_url: AccountProxyUpdate,
}
@@ -54,12 +56,23 @@ struct CreateRequest {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct UpdateRequest {
#[serde(default, deserialize_with = "deserialize_location_update")]
location: Option<Option<gateway_core::account::RequestLocation>>,
id: String,
revision: u64,
name: String,
proxy_url: Option<AccountProxyUpdate>,
}
fn deserialize_location_update<'de, D>(
deserializer: D,
) -> Result<Option<Option<gateway_core::account::RequestLocation>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::deserialize(deserializer).map(Some)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct IdRequest {
@@ -79,6 +92,8 @@ struct ProxyTestView {
success: bool,
latency_ms: u64,
exit_ip: Option<String>,
exit_ipv4: Option<String>,
exit_ipv6: Option<String>,
message: String,
}
@@ -88,6 +103,8 @@ impl From<ProxyTestResult> for ProxyTestView {
success: result.success,
latency_ms: result.latency_ms,
exit_ip: result.exit_ip.map(|ip| ip.to_string()),
exit_ipv4: result.exit_ipv4.map(|ip| ip.to_string()),
exit_ipv6: result.exit_ipv6.map(|ip| ip.to_string()),
message: result.message,
}
}
@@ -110,6 +127,7 @@ struct ProxyAccountView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyView {
location: Option<gateway_core::account::RequestLocation>,
id: String,
name: String,
endpoint: String,
@@ -126,6 +144,7 @@ impl From<ProxyRecord> for ProxyView {
fn from(record: ProxyRecord) -> Self {
let endpoint = record.proxy.endpoint();
Self {
location: record.location,
id: record.id,
name: record.name,
has_authentication: record.proxy.expose_url() != endpoint,
@@ -170,7 +189,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 +222,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 +256,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 +314,7 @@ async fn create<S>(
AdminJson(request): AdminJson<CreateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let proxy = request
.proxy_url
@@ -306,6 +325,7 @@ where
.proxies()
.create(
NewProxy {
location: request.location,
name: request.name,
proxy,
},
@@ -325,7 +345,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 +369,7 @@ async fn update<S>(
AdminJson(request): AdminJson<UpdateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let proxy = request
.proxy_url
@@ -364,6 +384,7 @@ where
.proxies()
.update(
UpdateProxy {
location: request.location,
id: request.id,
revision: revision(request.revision)?,
name: request.name,
@@ -385,7 +406,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 +430,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 +454,7 @@ async fn test<S>(
AdminJson(request): AdminJson<IdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -1,5 +1,7 @@
//! Runtime settings、旧设置页聚合投影与明文 Admin API Key wire。
use crate::auth::SessionState;
use std::{collections::BTreeMap, fmt};
use axum::{
@@ -21,7 +23,7 @@ use gateway_core::routing::{PublicModelId, UpstreamModelId};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
WireValidationError, wire::map_admin_service_error,
};
@@ -32,17 +34,31 @@ pub type ModelMappings = BTreeMap<String, String>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u64,
pub max_concurrent_per_account: u64,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u64,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub updated_at: DateTime<Utc>,
}
@@ -50,23 +66,59 @@ pub struct RuntimeSettingsView {
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateRuntimeSettingsRequest {
#[serde(default, deserialize_with = "deserialize_profile_update")]
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u64,
pub max_concurrent_per_account: u64,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u64,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
}
impl UpdateRuntimeSettingsRequest {
/// 校验公共运行参数。
pub fn validate(&self) -> Result<(), WireValidationError> {
self.request_location
.validate()
.map_err(|_| WireValidationError::new("requestLocation"))?;
validate_model_mappings(&self.model_mappings)?;
for (value, field) in [
(self.max_waiting_per_key, "maxWaitingPerKey"),
(self.max_waiting_per_account, "maxWaitingPerAccount"),
] {
if value > 1_000 {
return Err(WireValidationError::new(field));
}
}
if self.responses_max_decompressed_body_bytes == 0
|| isize::try_from(self.responses_max_decompressed_body_bytes).is_err()
{
return Err(WireValidationError::new(
"responsesMaxDecompressedBodyBytes",
));
}
if !(1..=120).contains(&self.concurrency_wait_timeout_seconds) {
return Err(WireValidationError::new("concurrencyWaitTimeoutSeconds"));
}
for (value, field) in [
(self.refresh_margin_seconds, "refreshMarginSeconds"),
(self.refresh_concurrency, "refreshConcurrency"),
@@ -91,12 +143,49 @@ impl UpdateRuntimeSettingsRequest {
self.min_codex_cli_version.as_deref(),
"minCodexCliVersion",
)?;
for (value, field) in [
(
self.account_auto_freeze_threshold,
"accountAutoFreezeThreshold",
),
(
self.account_auto_freeze_window_seconds,
"accountAutoFreezeWindowSeconds",
),
(
self.account_auto_freeze_duration_seconds,
"accountAutoFreezeDurationSeconds",
),
] {
require_positive_i64(value, field)?;
}
if !(2..=1_000).contains(&self.account_auto_freeze_threshold) {
return Err(WireValidationError::new("accountAutoFreezeThreshold"));
}
if !(60..=3_600).contains(&self.account_auto_freeze_window_seconds) {
return Err(WireValidationError::new("accountAutoFreezeWindowSeconds"));
}
if !(300..=604_800).contains(&self.account_auto_freeze_duration_seconds) {
return Err(WireValidationError::new("accountAutoFreezeDurationSeconds"));
}
validate_optional_probe_model(
self.account_auto_freeze_probe_model.as_deref(),
"accountAutoFreezeProbeModel",
)?;
Ok(())
}
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
self.validate()?;
Ok(ReplaceRuntimeSettings {
openai_client_profile: self
.openai_client_profile
.map(gateway_core::account::OpaqueProviderData::new),
request_location_enabled: self.request_location_enabled,
request_location: self
.request_location
.normalized()
.map_err(|_| WireValidationError::new("requestLocation"))?,
model_mappings: domain_model_mappings(self.model_mappings)?,
refresh_margin_seconds: self.refresh_margin_seconds,
refresh_concurrency: u32::try_from(self.refresh_concurrency)
@@ -104,6 +193,10 @@ 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,
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
min_codex_desktop_version: self.min_codex_desktop_version,
@@ -114,6 +207,14 @@ impl UpdateRuntimeSettingsRequest {
.map_err(|_| WireValidationError::new("settingsOpsRetentionOverflow"))?,
audit_retention_days: u32::try_from(self.audit_retention_days)
.map_err(|_| WireValidationError::new("settingsAuditRetentionOverflow"))?,
account_auto_freeze_enabled: self.account_auto_freeze_enabled,
account_auto_freeze_threshold: u32::try_from(self.account_auto_freeze_threshold)
.map_err(|_| WireValidationError::new("settingsFreezeThresholdOverflow"))?,
account_auto_freeze_window_seconds: self.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: self.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: self.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: self.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: self.account_auto_freeze_adaptive_concurrency,
})
}
}
@@ -121,17 +222,34 @@ impl UpdateRuntimeSettingsRequest {
impl From<RuntimeSettings> for RuntimeSettingsView {
fn from(settings: RuntimeSettings) -> Self {
Self {
openai_client_profile: settings
.openai_client_profile
.map(gateway_core::account::OpaqueProviderData::into_inner),
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
model_mappings: wire_model_mappings(settings.model_mappings),
refresh_margin_seconds: settings.refresh_margin_seconds,
refresh_concurrency: u64::from(settings.refresh_concurrency),
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
request_interval_ms: settings.request_interval_ms,
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
rotation_strategy: settings.rotation_strategy.as_str().to_owned(),
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: u64::from(settings.usage_retention_days),
ops_event_retention_days: u64::from(settings.ops_event_retention_days),
audit_retention_days: u64::from(settings.audit_retention_days),
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
account_auto_freeze_threshold: u64::from(settings.account_auto_freeze_threshold),
account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: settings
.account_auto_freeze_adaptive_concurrency,
updated_at: settings.updated_at,
}
}
@@ -228,10 +346,28 @@ 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/pricing", get(pricing::<S>))
.route(
"/api/admin/settings/pricing/update",
post(update_pricing::<S>),
)
.route(
"/api/admin/settings/pricing/sync/preview",
post(preview_pricing_sync::<S>),
)
.route("/api/admin/settings/pricing/sync", post(sync_pricing::<S>))
.route("/api/admin/settings", get(settings::<S>))
.route(
"/api/admin/settings/client-profiles/openai",
get(client_profile_options::<S>),
)
.route(
"/api/admin/settings/client-profiles/openai/preview",
post(preview_client_profile::<S>),
)
.route("/api/admin/settings/update", post(update_settings::<S>))
.route(
"/api/admin/settings/client-downloads/codex-desktop/windows",
@@ -257,7 +393,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()
@@ -270,12 +406,131 @@ where
)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PricingUpdateRequest {
provider: String,
models: Vec<String>,
change: PricingChangeRequest,
}
#[derive(Deserialize)]
#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)]
enum PricingChangeRequest {
Replace {
pricing: gateway_core::metering::ModelPriceOverride,
},
Multiplier {
#[serde(rename = "multiplierBps")]
multiplier_bps: u32,
},
Reset {},
Delete {},
}
async fn pricing<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let pricing = state
.admin_services()
.settings()
.pricing()
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({
"defaults": pricing.defaults, "overrides": pricing.overrides,
"synced": pricing.synced, "syncedAt": pricing.synced_at,
})),
))
}
async fn preview_pricing_sync<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let preview = state
.admin_services()
.settings()
.preview_pricing_sync()
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(preview),
))
}
async fn sync_pricing<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(command): AdminJson<gateway_admin::model::pricing::SyncPricing>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
state
.admin_services()
.settings()
.sync_pricing(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"saved": true})),
))
}
async fn update_pricing<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<PricingUpdateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
use gateway_admin::model::pricing::{PricingChange, UpdatePricing};
let change = match request.change {
PricingChangeRequest::Replace { pricing } => PricingChange::Replace(pricing),
PricingChangeRequest::Multiplier { multiplier_bps } => {
PricingChange::Multiplier(multiplier_bps)
}
PricingChangeRequest::Reset {} => PricingChange::Reset,
PricingChangeRequest::Delete {} => PricingChange::Delete,
};
state
.admin_services()
.settings()
.update_pricing(
&auth.context().mutation_context(),
UpdatePricing {
provider: request.provider,
models: request.models,
change,
},
)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"saved": true})),
))
}
async fn settings<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
@@ -295,7 +550,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 +570,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 +589,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 +610,7 @@ async fn delete_admin_api_key<S>(
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
state
.admin_services()
@@ -427,6 +682,22 @@ fn validate_optional_client_version(
Ok(())
}
/// 探测模型为可选自由文本:非空、去首尾空白后不变、无控制字符且不超过 128 字节。
fn validate_optional_probe_model(
value: Option<&str>,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.is_some_and(|value| {
value.is_empty()
|| value.len() > 128
|| value != value.trim()
|| value.bytes().any(|byte| byte.is_ascii_control())
}) {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn map_wire_error(error: WireValidationError) -> AdminError {
let message = match error.field() {
"settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(),
@@ -434,6 +705,12 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
"settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(),
"settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(),
"settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(),
"requestLocation" => "请求位置不合法,请检查国家代码、地区和城市".to_owned(),
"settingsFreezeThresholdOverflow" => "accountAutoFreezeThreshold 不合法".to_owned(),
"accountAutoFreezeThreshold" => "账号自动冻结阈值应为 2~1000 的整数".to_owned(),
"accountAutoFreezeWindowSeconds" => "账号自动冻结统计窗口应为 60~3600 秒".to_owned(),
"accountAutoFreezeDurationSeconds" => "账号自动冻结时长应为 300~604800 秒".to_owned(),
"accountAutoFreezeProbeModel" => "探测模型格式不合法".to_owned(),
"minCodexDesktopVersion" => "Codex Desktop 最低版本格式不合法".to_owned(),
"minCodexCliVersion" => "Codex CLI 最低版本格式不合法".to_owned(),
field => format!("{field} 字段不合法"),
@@ -444,3 +721,58 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error)
}
// 字段省略时保留已有配置;显式 null 不能清空唯一的通用默认。
fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<serde_json::Map<String, serde_json::Value>>, D::Error> {
serde_json::Map::<String, serde_json::Value>::deserialize(deserializer).map(Some)
}
async fn client_profile_options<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
.settings()
.client_profile_options()
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result.into_inner()),
))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ClientProfilePreviewRequest {
configuration: Option<serde_json::Map<String, serde_json::Value>>,
}
async fn preview_client_profile<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<ClientProfilePreviewRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let configuration = request
.configuration
.map(gateway_core::account::OpaqueProviderData::new);
let result = state
.admin_services()
.settings()
.preview_client_profile(configuration.as_ref())
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result.into_inner()),
))
}
+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
}
}
+285
View File
@@ -0,0 +1,285 @@
//! 控制面统一登录、会话恢复与退出;身份由认证用例返回。
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, ChangePassword, 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,
},
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ChangePasswordRequest {
current_password: String,
new_password: String,
}
impl fmt::Debug for ChangePasswordRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ChangePasswordRequest([REDACTED])")
}
}
impl fmt::Debug for LoginRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
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/password", post(change_password::<S>))
.route("/api/auth", any(not_found))
.route("/api/auth/{*path}", any(not_found))
.method_not_allowed_fallback(method_not_allowed)
.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 response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(LogoutData {
message: "Logged out successfully",
}),
)
.into_response();
clear_session_cookie(response, &headers)
}
async fn change_password<S>(
State(state): State<S>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
AdminJson(payload): AdminJson<ChangePasswordRequest>,
) -> Result<Response, AdminError>
where
S: SessionState + Send + Sync,
{
state
.admin_services()
.auth()
.change_password(
session_cookie::value(&headers).as_deref(),
ChangePassword {
current_password: payload.current_password,
new_password: payload.new_password,
},
peer.ip(),
)
.await
.map_err(map_admin_service_error)?;
let response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(LogoutData {
message: "密码已修改,请重新登录",
}),
)
.into_response();
clear_session_cookie(response, &headers)
}
fn clear_session_cookie(
mut response: Response,
headers: &HeaderMap,
) -> Result<Response, AdminError> {
let cookie = format!(
"{}=; {}; Max-Age=0",
session_cookie::NAME,
session_cookie::attributes(headers)
);
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
);
Ok(response)
}
fn map_login_error(error: LoginError) -> AdminError {
match error {
LoginError::InvalidCredentials => AdminError::invalid_credentials(),
LoginError::TooManyAttempts {
retry_after_seconds,
} => AdminError::too_many_login_attempts().with_retry_after(retry_after_seconds),
LoginError::Unavailable => AdminError::service_unavailable(),
}
}
async fn method_not_allowed() -> AdminError {
AdminError::method_not_allowed()
}
async fn not_found() -> AdminError {
AdminError::not_found("认证接口不存在")
}
async fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
@@ -0,0 +1,93 @@
//! 当前 Key 的单页用量 API;只暴露页面需要的只读字段。
use axum::{
Router,
extract::State,
http::{HeaderMap, HeaderValue, StatusCode, header},
middleware,
response::{IntoResponse, Response},
routing::{any, get},
};
use crate::{
admin::{AdminEnvelope, AdminError, AdminQuery, AdminResponse, wire::map_admin_service_error},
auth::SessionState,
session_cookie,
};
mod presenter;
mod query;
pub(crate) fn router<S>() -> Router<S>
where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/key-usage/overview", get(overview::<S>))
.route("/api/key-usage/records", get(records::<S>))
.route("/api/key-usage", any(not_found))
.route("/api/key-usage/{*path}", any(not_found))
.method_not_allowed_fallback(method_not_allowed)
.layer(middleware::map_response(no_store))
}
async fn overview<S>(
State(state): State<S>,
headers: HeaderMap,
AdminQuery(query): AdminQuery<query::OverviewQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let overview = state
.admin_services()
.key_usage()
.overview(
session_cookie::value(&headers).as_deref(),
query.into_domain()?,
)
.await
.map_err(map_admin_service_error)?
.ok_or_else(AdminError::session_required)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(presenter::overview(overview)),
))
}
async fn records<S>(
State(state): State<S>,
headers: HeaderMap,
AdminQuery(query): AdminQuery<query::RecordsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let records = state
.admin_services()
.key_usage()
.records(
session_cookie::value(&headers).as_deref(),
query.into_domain()?,
)
.await
.map_err(map_admin_service_error)?
.ok_or_else(AdminError::session_required)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(presenter::records(records)),
))
}
async fn not_found() -> AdminError {
AdminError::not_found("密钥用量接口不存在")
}
async fn method_not_allowed() -> AdminError {
AdminError::method_not_allowed()
}
async fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
@@ -0,0 +1,222 @@
//! 只投影当前 Key 可见的用量字段,不序列化账号、凭据、上游标识或诊断正文。
use chrono::{DateTime, Utc};
use gateway_admin::model::{
key_usage::{KeyUsageOverview, KeyUsageRecords},
observability::{
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
},
};
use serde::Serialize;
use crate::admin::observability::{
BillingView, HealthTimelineView, PageData, TokenDetailsView, billing_view,
health_timeline_view, usage_list_token_details,
};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct OverviewView {
as_of: DateTime<Utc>,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
key: KeyView,
summary: MetricsView,
trend: Vec<TrendPointView>,
health_timeline: HealthTimelineView,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct KeyView {
name: String,
prefix: String,
max_concurrency: u64,
requests_per_minute: u64,
daily_limit_usd: String,
daily_used_usd: String,
daily_resets_at: Option<DateTime<Utc>>,
weekly_limit_usd: String,
weekly_used_usd: String,
weekly_resets_at: Option<DateTime<Utc>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MetricsView {
requests: u64,
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
reasoning_tokens: u64,
total_tokens: u64,
cost_usd: Option<String>,
cost_incomplete: bool,
}
fn metrics(value: &RequestMetrics, costs: &[CurrencyCost], coverage: &CostCoverage) -> MetricsView {
MetricsView {
requests: value.request_count,
input_tokens: value.input_tokens,
output_tokens: value.output_tokens,
cached_tokens: value.cached_tokens,
cache_write_tokens: value.cache_write_tokens,
reasoning_tokens: value.reasoning_tokens,
total_tokens: value.total_tokens,
cost_usd: costs
.iter()
.find(|cost| cost.currency.eq_ignore_ascii_case("USD"))
.map(|cost| cost.amount.as_str().to_owned())
.or_else(|| (value.request_count == 0).then(|| "0".to_owned())),
cost_incomplete: coverage.partial_count > 0 || coverage.unavailable_count > 0,
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct TrendPointView {
time: DateTime<Utc>,
bucket_seconds: u32,
#[serde(flatten)]
metrics: MetricsView,
}
pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
let key = value.key;
OverviewView {
as_of: Utc::now(),
start_time: value.overview.range.start,
end_time: value.overview.range.end,
key: KeyView {
name: key.name,
prefix: key.prefix,
max_concurrency: key.limits.max_concurrency,
requests_per_minute: key.limits.requests_per_minute,
daily_limit_usd: key.budget.limits.daily_usd.canonical(),
daily_used_usd: key.budget.daily_used_usd.canonical(),
daily_resets_at: key.budget.daily_resets_at.map(DateTime::from),
weekly_limit_usd: key.budget.limits.weekly_usd.canonical(),
weekly_used_usd: key.budget.weekly_used_usd.canonical(),
weekly_resets_at: key.budget.weekly_resets_at.map(DateTime::from),
},
summary: metrics(
&value.overview.requests,
&value.overview.attempts.costs,
&value.overview.attempts.cost_coverage,
),
trend: value
.trend
.into_iter()
.map(|point| TrendPointView {
time: point.bucket_start,
bucket_seconds: match point.granularity {
Granularity::FifteenMinutes => 900,
Granularity::Hour => 3600,
Granularity::Day => 86400,
},
metrics: metrics(&point.metrics, &point.costs, &point.cost_coverage),
})
.collect(),
health_timeline: health_timeline_view(value.health_timeline),
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RecordView {
id: String,
created_at: DateTime<Utc>,
model: Option<String>,
route: Option<String>,
reasoning_effort: Option<String>,
client_transport: Option<String>,
upstream_transport: Option<String>,
token_details: Option<TokenDetailsView>,
billing: Option<BillingView>,
latency_ms: Option<u64>,
first_token_latency_ms: Option<u64>,
latency_details: OutputTimingView,
client_ip: Option<String>,
user_agent: Option<String>,
status: &'static str,
status_code: Option<u16>,
}
// Key 只查看自身请求的输出时间,不包含账号容量、调度等待等管理侧观测。
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct OutputTimingView {
#[serde(skip_serializing_if = "Option::is_none")]
first_event_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
first_reasoning_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
first_text_ms: Option<u64>,
}
fn success_record(value: UsageListRecord) -> RecordView {
let token_details = Some(usage_list_token_details(&value));
let billing = billing_view(value.billing.as_ref());
RecordView {
id: value.id,
created_at: value.started_at,
model: value.requested_model_id,
route: Some(value.endpoint),
reasoning_effort: value.reasoning_effort,
client_transport: Some(value.client_transport),
upstream_transport: value.upstream_transport,
token_details,
billing,
latency_ms: value.latency_ms,
first_token_latency_ms: value.first_token_ms,
latency_details: OutputTimingView {
first_event_ms: value.first_event_ms,
first_reasoning_ms: value.first_reasoning_ms,
first_text_ms: value.first_text_ms,
},
client_ip: value.client_ip,
user_agent: value.user_agent,
status: "success",
// 成功记录不保存 HTTP 状态,不能用 200 伪造缺失的原始事实。
status_code: None,
}
}
fn error_record(value: OpsError) -> RecordView {
RecordView {
id: value.event_id,
created_at: value.occurred_at,
model: value.requested_model_id,
route: value.endpoint,
reasoning_effort: value.reasoning_effort,
client_transport: value.client_transport,
upstream_transport: value.upstream_transport,
token_details: None,
billing: None,
latency_ms: value.latency_ms,
first_token_latency_ms: None,
latency_details: OutputTimingView::default(),
client_ip: value.client_ip,
user_agent: value.user_agent,
status: "error",
status_code: value.client_status_code,
}
}
pub(super) fn records(value: KeyUsageRecords) -> PageData<RecordView> {
match value {
KeyUsageRecords::Success(page) => PageData {
items: page.items.into_iter().map(success_record).collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
},
KeyUsageRecords::Error(page) => PageData {
items: page.items.into_iter().map(error_record).collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
},
}
}
@@ -0,0 +1,106 @@
//! 查询不接受 Key、账号或 Provider 范围;禁止未知字段穿透权限边界。
use axum::http::StatusCode;
use chrono::{DateTime, Duration};
use gateway_admin::model::{
PageSize,
key_usage::{KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecordsQuery},
observability::TimeRange,
};
use serde::Deserialize;
use crate::admin::AdminError;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct OverviewQuery {
start_time: String,
end_time: String,
model: Option<String>,
}
impl OverviewQuery {
pub(super) fn into_domain(self) -> Result<KeyUsageQuery, AdminError> {
let parse = |value: &str| {
DateTime::parse_from_rfc3339(value)
.map(|value| value.to_utc())
.map_err(|_| {
AdminError::invalid_request(
StatusCode::BAD_REQUEST,
"时间必须使用 RFC3339 格式",
)
})
};
let range = TimeRange::new(parse(&self.start_time)?, parse(&self.end_time)?)
.map_err(|_| AdminError::invalid_request(StatusCode::BAD_REQUEST, "时间范围不合法"))?;
if range.end - range.start > Duration::days(31) {
return Err(AdminError::invalid_request(
StatusCode::BAD_REQUEST,
"一次最多查询 31 天的用量",
));
}
let model = self
.model
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty());
if model
.as_ref()
.is_some_and(|value| value.len() > 256 || value.chars().any(char::is_control))
{
return Err(AdminError::invalid_request(
StatusCode::BAD_REQUEST,
"模型筛选内容不合法",
));
}
Ok(KeyUsageQuery { range, model })
}
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase")]
enum RecordKind {
#[default]
Success,
Error,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct RecordsQuery {
start_time: String,
end_time: String,
model: Option<String>,
current_page: Option<u32>,
page_size: Option<u16>,
#[serde(default)]
kind: RecordKind,
}
impl RecordsQuery {
pub(super) fn into_domain(self) -> Result<KeyUsageRecordsQuery, AdminError> {
let current_page = self.current_page.unwrap_or(1);
let page_size = self.page_size.unwrap_or(20);
if current_page == 0 || !(1..=100).contains(&page_size) {
return Err(AdminError::invalid_request(
StatusCode::BAD_REQUEST,
"页码必须大于 0,每页数量为 1–100",
));
}
Ok(KeyUsageRecordsQuery {
usage: OverviewQuery {
start_time: self.start_time,
end_time: self.end_time,
model: self.model,
}
.into_domain()?,
kind: match self.kind {
RecordKind::Success => KeyUsageRecordKind::Success,
RecordKind::Error => KeyUsageRecordKind::Error,
},
current_page,
page_size: PageSize::new(page_size).map_err(|_| {
AdminError::invalid_request(StatusCode::BAD_REQUEST, "每页数量不合法")
})?,
})
}
}
+16 -8
View File
@@ -29,12 +29,14 @@ use crate::health::HealthStatus;
use crate::openai::service::OpenAiService;
pub mod admin;
pub mod auth;
mod health;
mod key_usage;
pub mod openai;
mod session_cookie;
/// API-owned HTTP 与静态资源配置。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ApiConfig {
pub asset_directory: PathBuf,
pub cors_allowed_origins: Vec<String>,
@@ -59,12 +61,17 @@ impl ApiConfig {
return Err(ApiConfigError::InvalidAssetDirectory);
}
}
if self.asset_directory.as_os_str().is_empty() {
return Err(ApiConfigError::InvalidAssetDirectory);
}
self.validate()?;
if self.asset_directory.is_relative() {
self.asset_directory = source_dir.join(&self.asset_directory);
}
Ok(())
}
fn validate(&mut self) -> Result<(), ApiConfigError> {
if self.asset_directory.as_os_str().is_empty() {
return Err(ApiConfigError::InvalidAssetDirectory);
}
if self.request_timeout_seconds == Some(0) {
return Err(ApiConfigError::InvalidRequestTimeout);
}
@@ -121,9 +128,8 @@ pub fn initialize(
worker_health: Arc<dyn WorkerHealthSource>,
lifecycle: Arc<dyn ConnectionLifecycle>,
) -> Result<ApiBundle, ApiError> {
config
.resolve_and_validate(Path::new("."))
.map_err(ApiError::Config)?;
// 配置加载已解析环境变量与相对路径,初始化只校验,避免覆盖最终目录。
config.validate().map_err(ApiError::Config)?;
let request_id_header = HeaderName::from_str(&config.request_id_header)
.map_err(|_| ApiError::Config(ApiConfigError::InvalidRequestIdHeader))?;
let state = ApiState {
@@ -136,6 +142,8 @@ pub fn initialize(
.route("/healthz", get(health::healthz))
.merge(openai::router::router())
.merge(admin::router::<ApiState>())
.merge(auth::router::<ApiState>())
.merge(key_usage::router::<ApiState>())
.fallback_service(ServeDir::new(config.asset_directory).fallback(ServeFile::new(index)));
if !config.cors_allowed_origins.is_empty() {
let origins = config
@@ -225,7 +233,7 @@ impl ApiState {
}
}
impl admin::AdminSessionState for ApiState {
impl auth::SessionState for ApiState {
fn admin_services(&self) -> &AdminServices {
&self.admin
}
+40 -7
View File
@@ -143,7 +143,7 @@ pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response
}
}
/// Desktop 优先于其内嵌的 CLI/Core 标记;未知客户端保持兼容并返回 `None`。
/// Desktop 优先于内嵌 CLI/Core;未知客户端及未提供应用版本的手机远程客户端返回 `None`。
#[must_use]
pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClient> {
const MAXIMUM_HEADER_LENGTH: usize = 4096;
@@ -154,11 +154,21 @@ pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClien
|| user_agent.is_some_and(|value| contains_ascii_case_insensitive(value, "Codex Desktop"));
if is_desktop {
let explicit_version = bounded_ascii_header(headers, "version", MAXIMUM_HEADER_LENGTH);
let user_agent_version = user_agent.and_then(desktop_version_from_user_agent);
// 手机远程初始化会覆盖进程级 UA 后缀,保留的 Desktop/Core 前缀不代表应用版本。
// 仅缺失版本信息时免于门禁;已提供的版本即使非法,也继续按 Desktop 校验。
if !headers.contains_key("version")
&& user_agent_version.is_none()
&& headers
.get("user-agent")
.is_some_and(|value| value.as_bytes().len() <= MAXIMUM_HEADER_LENGTH)
&& user_agent.is_some_and(has_chatgpt_remote_user_agent_suffix)
{
return None;
}
let version = match explicit_version {
Some(value) => CodexClientVersion::parse(value).ok(),
None => user_agent
.and_then(desktop_version_from_user_agent)
.and_then(|value| CodexClientVersion::parse(value).ok()),
None => user_agent_version.and_then(|value| CodexClientVersion::parse(value).ok()),
};
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Desktop,
@@ -206,11 +216,34 @@ fn bounded_ascii_header<'a>(
fn desktop_version_from_user_agent(user_agent: &str) -> Option<&str> {
let marker = "Codex Desktop;";
let start = find_ascii_case_insensitive(user_agent, marker)? + marker.len();
let version = user_agent[start..]
// 空候选也代表已提供应用版本,不能被手机远程的缺失版本规则放行。
user_agent[start..]
.trim_start()
.split([')', ' ', ';', ','])
.next()?;
(!version.is_empty()).then_some(version)
.next()
}
fn has_chatgpt_remote_user_agent_suffix(user_agent: &str) -> bool {
let Some((_, suffix)) = user_agent
.strip_suffix(')')
.and_then(|value| value.rsplit_once(" ("))
else {
return false;
};
let Some((name, version)) = suffix.split_once("; ") else {
return false;
};
let Some(platform) = name
.strip_prefix("codex_chatgpt_")
.and_then(|value| value.strip_suffix("_remote"))
else {
return false;
};
[platform, version].into_iter().all(|value| {
!value.is_empty()
&& !value.contains(['(', ')', ';'])
&& !value.bytes().any(|byte| byte.is_ascii_whitespace())
})
}
fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
+84 -14
View File
@@ -8,7 +8,7 @@ use axum::{
};
use gateway_core::{
engine::EngineError,
error::{GatewayError, GatewayErrorKind},
error::{GatewayError, GatewayErrorKind, ProviderErrorKind},
policy::{ClientVersionRejection, CodexClientKind},
};
use serde_json::{Value, json};
@@ -22,16 +22,54 @@ pub fn openai_error_response(
error_type: &str,
code: &str,
) -> (StatusCode, Json<Value>) {
(
status,
Json(json!({
"error": {
"message": message,
"type": error_type,
"code": code
}
})),
)
let body = json!({
"error": {"message": message, "type": error_type, "code": code}
});
match capacity_error_for_client(&body) {
Some(body) => (StatusCode::SERVICE_UNAVAILABLE, Json(body)),
None => (status, Json(body)),
}
}
/// Codex 将这两个容量码视为不可重试;仅在交付边界转换,内部仍保留真实上游事实。
pub(super) fn client_error_code(code: &str) -> &str {
match code {
"server_is_overloaded" | "slow_down" => "server_error",
_ => code,
}
}
/// 为 HTTP 错误或流内失败保留原有字段,仅投影客户端需要的重试信号。
pub(super) fn capacity_error_for_client(data: &Value) -> Option<Value> {
const CODE_PATHS: [&str; 2] = ["/error/code", "/response/error/code"];
let capacity_code = |value: &Value| {
value
.as_str()
.is_some_and(|code| client_error_code(code) != code)
};
if !CODE_PATHS
.iter()
.any(|path| data.pointer(path).is_some_and(capacity_code))
{
return None;
}
let mut projected = data.clone();
for path in CODE_PATHS {
if let Some(code) = projected.pointer_mut(path)
&& capacity_code(code)
{
*code = Value::String("server_error".to_owned());
}
}
// WS 包装错误按 HTTP 状态分类;保留 400/429 会使客户端终止重试。
for field in ["status", "status_code"] {
if let Some(status) = projected.get_mut(field)
&& status.is_number()
{
*status = json!(503);
}
}
Some(projected)
}
/// 严格协议 decoder/encoder 返回的安全错误 body。
@@ -166,14 +204,33 @@ pub fn gateway_error_response(error: &GatewayError) -> Response {
response
}
/// 在下游尚未提交时优先交付 Provider 的原始 HTTP 失败响应。
/// 在下游尚未提交时交付 Provider 的 HTTP 失败响应,并应用客户端容量恢复合同。
pub fn engine_error_response(error: &EngineError) -> Response {
let capacity_unavailable = matches!(error, EngineError::Provider(error)
if error.kind() == ProviderErrorKind::UpstreamCapacityUnavailable);
if let EngineError::Provider(error) = error
&& let Some(upstream) = error.client_visible_upstream_response()
&& let Ok(status) = StatusCode::from_u16(upstream.status())
{
let mut response = Response::new(Body::from(upstream.body().clone()));
*response.status_mut() = status;
let projected = serde_json::from_slice::<Value>(upstream.body())
.ok()
.and_then(|body| capacity_error_for_client(&body));
let mut response = match projected {
Some(body) => {
let mut response = Response::new(Body::from(body.to_string()));
*response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
response
}
None => {
let mut response = Response::new(Body::from(upstream.body().clone()));
*response.status_mut() = if capacity_unavailable {
StatusCode::SERVICE_UNAVAILABLE
} else {
status
};
response
}
};
if let Some(content_type) = upstream.content_type()
&& let Ok(content_type) = HeaderValue::from_bytes(content_type)
{
@@ -191,6 +248,9 @@ pub fn engine_error_response(error: &EngineError) -> Response {
return response;
}
let mut response = gateway_error_response(&gateway_error_from_engine(error));
if capacity_unavailable {
*response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
}
// 正文未完整取得时不能透传残缺响应,但已确认的上游关联 ID 仍应交付。
if let EngineError::Provider(error) = error
&& let Some(request_id) = error.upstream_request_id()
@@ -247,6 +307,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",
@@ -42,7 +42,10 @@ pub enum RequestDecodeError {
MalformedJson,
/// 请求体解压后超过允许上限。
#[error("decompressed request body is too large")]
DecompressedBodyTooLarge,
DecompressedBodyTooLarge {
/// 当前请求快照中的解压输出上限。
limit_bytes: usize,
},
/// 请求使用了网关不支持的 `Content-Encoding`。
#[error("request content-encoding `{encoding}` is not supported")]
UnsupportedContentEncoding {
@@ -108,9 +111,11 @@ impl RequestDecodeError {
"Request body must be valid JSON.".to_owned(),
None,
),
Self::DecompressedBodyTooLarge => (
Self::DecompressedBodyTooLarge { limit_bytes } => (
"request_too_large",
"Decompressed request body exceeds the allowed size.".to_owned(),
format!(
"Decompressed request body exceeds the allowed size ({limit_bytes} bytes)."
),
None,
),
Self::UnsupportedContentEncoding { encoding } => (
@@ -52,7 +52,11 @@ pub(crate) async fn responses(
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
let decoded = match decode_request_with_headers(&body, &headers) {
let decoded = match decode_request_with_headers(
&body,
&headers,
client.snapshot().responses_max_decompressed_body_bytes(),
) {
Ok(decoded) => decoded,
Err(error) => {
return protocol_error_response(StatusCode::BAD_REQUEST, error.protocol_body());
@@ -631,7 +635,9 @@ impl ResponsesStreamState {
.push_back(Bytes::from(response_failed_sse_event_with_id(
self.encoder.response_id(),
error.client_error_type().unwrap_or(default_type),
error.client_error_code().unwrap_or(default_code),
super::super::error::client_error_code(
error.client_error_code().unwrap_or(default_code),
),
error.client_message(),
)));
self.pending
@@ -327,13 +327,14 @@ impl fmt::Debug for DecodedResponsesRequest {
pub fn decode_request_with_headers(
body: &[u8],
headers: &HeaderMap,
max_decompressed_bytes: usize,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
let body = decompress_request_body(body, headers)?;
let body = decompress_request_body(body, headers, max_decompressed_bytes)?;
decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))
}
/// 下游请求体解压后的最大字节数;防御解压炸弹。
const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024;
/// zstd 回溯窗口独立于输出上限,调整设置不能放大解码器内部窗口分配。
const MAX_ZSTD_WINDOW_LOG: u32 = 26;
/// 按 `Content-Encoding` 解压下游请求体。
///
@@ -342,6 +343,7 @@ const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024;
fn decompress_request_body<'a>(
body: &'a [u8],
headers: &HeaderMap,
max_decompressed_bytes: usize,
) -> Result<Cow<'a, [u8]>, RequestDecodeError> {
let encoding = headers
.get_all(axum::http::header::CONTENT_ENCODING)
@@ -357,20 +359,20 @@ fn decompress_request_body<'a>(
"" | "identity" => Ok(Cow::Borrowed(body)),
"gzip" => {
let decoder = flate2::read::MultiGzDecoder::new(body);
read_bounded(decoder).map(Cow::Owned)
read_bounded(decoder, max_decompressed_bytes).map(Cow::Owned)
}
"deflate" => {
let decoder = flate2::read::ZlibDecoder::new(body);
read_bounded(decoder).map(Cow::Owned)
read_bounded(decoder, max_decompressed_bytes).map(Cow::Owned)
}
"zstd" => {
let mut decoder = zstd::stream::read::Decoder::with_buffer(body)
.map_err(|_| RequestDecodeError::MalformedJson)?;
// 输出有界之外,也限制压缩帧声明的回溯窗口,防止解码器内部过量分配。
decoder
.window_log_max(MAX_DECOMPRESSED_REQUEST_BYTES.ilog2())
.window_log_max(MAX_ZSTD_WINDOW_LOG)
.map_err(|_| RequestDecodeError::MalformedJson)?;
read_bounded(decoder).map(Cow::Owned)
read_bounded(decoder, max_decompressed_bytes).map(Cow::Owned)
}
other => Err(RequestDecodeError::UnsupportedContentEncoding {
encoding: other.to_owned(),
@@ -378,12 +380,15 @@ fn decompress_request_body<'a>(
}
}
fn read_bounded<R: std::io::Read>(mut reader: R) -> Result<Vec<u8>, RequestDecodeError> {
fn read_bounded<R: std::io::Read>(
mut reader: R,
max_decompressed_bytes: usize,
) -> Result<Vec<u8>, RequestDecodeError> {
let mut decoded = Vec::new();
let mut chunk = [0; 8192];
loop {
let remaining = MAX_DECOMPRESSED_REQUEST_BYTES - decoded.len();
let read_limit = chunk.len().min(remaining + 1);
let remaining = max_decompressed_bytes - decoded.len();
let read_limit = chunk.len().min(remaining.saturating_add(1));
let read = reader
.read(&mut chunk[..read_limit])
.map_err(|_| RequestDecodeError::MalformedJson)?;
@@ -391,15 +396,19 @@ fn read_bounded<R: std::io::Read>(mut reader: R) -> Result<Vec<u8>, RequestDecod
return Ok(decoded);
}
if read > remaining {
return Err(RequestDecodeError::DecompressedBodyTooLarge);
return Err(RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: max_decompressed_bytes,
});
}
// 默认 Vec 扩容和 read_to_end 的 EOF 探测可能突破输出上限;
// 容量增长也受相同边界约束,越界探测只使用栈上分块缓冲区。
let required = decoded.len() + read;
if required > decoded.capacity() {
let capacity = (decoded.capacity() * 2)
let capacity = decoded
.capacity()
.saturating_mul(2)
.max(required)
.min(MAX_DECOMPRESSED_REQUEST_BYTES);
.min(max_decompressed_bytes);
decoded.reserve_exact(capacity - decoded.len());
}
decoded.extend_from_slice(&chunk[..read]);
@@ -535,8 +544,6 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
.iter()
.any(|connection_header| connection_header.eq_ignore_ascii_case(name))
|| is_transport_managed_request_header(name)
|| name.starts_with("x-grok-")
|| name.starts_with("x-xai-")
{
return false;
}
@@ -551,15 +558,9 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
| "cookie"
| "cookie2"
| "chatgpt-account-id"
| "chatgpt-organization-id"
| "chatgpt-org-id"
| "chatgpt-project-id"
| "openai-organization"
| "openai-project"
| "x-openai-organization"
| "x-openai-project"
// installation ID 始终由当前 lease 重建。
| "x-codex-installation-id"
// 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent/version
// 不能作为不透明头透传覆盖,避免不同下游客户端暴露不一致的设备指纹。
| "originator"
@@ -8,6 +8,7 @@ use gateway_protocol::openai::sse::{
use serde_json::Value;
use super::error::ResponseEncodeError;
use crate::openai::error::capacity_error_for_client;
const OPENAI_PROTOCOL: &str = "openai";
@@ -15,7 +16,7 @@ const OPENAI_PROTOCOL: &str = "openai";
///
/// OpenAI Provider 与 xAI adapter 都必须交付 OpenAI wire。wire 是客户端交付
/// 与终态判断的事实来源;canonical facts 只在 wire 暂未携带 ID 时提供旁路观测,
/// 不得反过来拒绝或改写上游事件。
/// 不得反过来拒绝或改写上游事件;客户端错误兼容投影只发生在编码出口。
#[derive(Debug, Default)]
pub struct OpenAiResponsesEncoder {
response_id: Option<String>,
@@ -43,13 +44,15 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
let projected = client_failure_payload(wire);
let data = projected.as_ref().unwrap_or_else(|| wire.data());
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因。
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`。
if wire.event_type() == Some("error")
if effective_event_type(wire) == Some("error")
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
data,
)
{
return vec![Bytes::from(encode_sse_event_with_metadata(
@@ -59,12 +62,14 @@ impl OpenAiResponsesEncoder {
wire.sse_retry(),
))];
}
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
if projected.is_none()
&& let Some(raw_sse_frame) = wire.raw_sse_frame()
{
return vec![raw_sse_frame.clone()];
}
vec![Bytes::from(encode_sse_event_with_metadata(
wire.event_type().unwrap_or_default(),
&wire.data().to_string(),
effective_event_type(wire).unwrap_or_default(),
&data.to_string(),
wire.sse_id(),
wire.sse_retry(),
))]
@@ -77,7 +82,24 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
vec![wire.data().to_string()]
let projected = client_failure_payload(wire);
let data = projected.as_ref().unwrap_or_else(|| wire.data());
// WS 客户端只对它无法消费的裸 `error` 帧做投影:codex 的 WS 端点
// 会静默忽略缺少 status 且不含内置可重试错误码的 `error` 帧,客户
// 端只能空等到 idle 超时。投影成 `response.failed`(消息 JSON 自带
// type 字段),让 codex 按错误码处理。可消费形状(带非 2xx status
// 或特殊错误码)保留 envelope,容量码已由共享客户端投影处理。
if effective_event_type(wire) == Some("error")
&& !ws_client_consumable_error(data)
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
data,
)
{
return vec![data.to_string()];
}
vec![data.to_string()]
}
/// 返回是否已经看到客户端可见的 wire 终态。
@@ -127,9 +149,7 @@ impl OpenAiResponsesEncoder {
{
self.response_id = Some(response_id.to_owned());
}
let effective_type = wire
.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str));
let effective_type = effective_event_type(wire);
if matches!(
effective_type,
Some("response.created" | "response.in_progress" | "response.queued")
@@ -156,3 +176,45 @@ fn openai_wire(event: &ProviderEvent) -> Option<&ProtocolWireEvent> {
.wire_event()
.filter(|wire| wire.protocol() == OPENAI_PROTOCOL)
}
fn effective_event_type(wire: &ProtocolWireEvent) -> Option<&str> {
wire.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str))
}
fn client_failure_payload(wire: &ProtocolWireEvent) -> Option<Value> {
if matches!(
effective_event_type(wire),
Some("error" | "response.failed")
) {
capacity_error_for_client(wire.data())
} else {
None
}
}
/// 判断 WS 上游的 `error` 帧是否已经是客户端可直接消费的形状。
///
/// codex 的 WS 端点只消费两类 `error` 帧:带非 2xx HTTP status 的包装错误,
/// 以及连接数上限 / 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))
}
@@ -2,11 +2,14 @@
use axum::http::{HeaderName, HeaderValue, StatusCode};
use gateway_core::engine::EngineError;
use gateway_core::error::ProviderErrorKind;
use gateway_core::event::ProviderResponseHeader;
use serde_json::{Map, Value, json};
use thiserror::Error;
use crate::openai::error::{gateway_error_contract, gateway_error_from_engine};
use crate::openai::error::{
capacity_error_for_client, gateway_error_contract, gateway_error_from_engine,
};
use super::super::{
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError,
@@ -152,12 +155,18 @@ pub(super) fn initial_engine_error_event(
headers.remove("x-request-id");
headers.remove("x-oai-request-id");
}
let status = upstream
.map(|response| response.status())
.or_else(|| provider.and_then(|error| error.upstream_status()))
.and_then(|value| StatusCode::from_u16(value).ok())
.filter(|value| !value.is_success() && !value.is_informational())
.unwrap_or(default_status);
let status = if provider
.is_some_and(|error| error.kind() == ProviderErrorKind::UpstreamCapacityUnavailable)
{
StatusCode::SERVICE_UNAVAILABLE
} else {
upstream
.map(|response| response.status())
.or_else(|| provider.and_then(|error| error.upstream_status()))
.and_then(|value| StatusCode::from_u16(value).ok())
.filter(|value| !value.is_success() && !value.is_informational())
.unwrap_or(default_status)
};
// 无原响应关联头时使用 Provider 的失败事实;不能让旧会话 ID 遮住当前失败。
if !has_request_id(&headers)
&& let Some(request_id) = provider.and_then(|error| error.upstream_request_id())
@@ -224,7 +233,10 @@ pub(super) fn error_event(
if !headers.is_empty() {
event.insert("headers".to_owned(), Value::Object(headers));
}
Value::Object(event).to_string()
let event = Value::Object(event);
capacity_error_for_client(&event)
.unwrap_or(event)
.to_string()
}
pub(super) fn connection_limit_event() -> String {
@@ -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() {
@@ -272,3 +272,27 @@ async fn response_json(response: axum::response::Response) -> Value {
.expect("account group response body");
serde_json::from_slice(&body).expect("account group response JSON")
}
#[tokio::test]
async fn disable_fast_group_updates_preserve_omitted_values() {
let fixture = authenticated_fixture().await;
for (value, expected) in [(Some(true), true), (None, true), (Some(false), false)] {
let mut body = json!({"id": PRIMARY_GROUP_ID, "name":"Policy", "description":null,"color":"#F43F5ECC"});
if let Some(value) = value {
body["disableFast"] = json!(value);
}
let response = request(
router(&fixture),
Method::POST,
"/api/admin/account-groups/update",
Some(body),
true,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response_json(response).await["data"]["record"]["disableFast"],
expected
);
}
}
@@ -31,7 +31,7 @@ async fn personal_info_requires_admin_and_a_valid_account_query() {
.uri(format!("/api/admin/accounts/personal-info{query}"))
.header("x-request-id", "req_personal_info");
if authenticated {
request = request.header(header::COOKIE, "cpr_admin_session=valid-session");
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = admin::router::<AdminTestState>()
.with_state(fixture.state())
@@ -73,7 +73,7 @@ async fn quota_forecast_requires_admin_and_valid_account_query() {
.uri(uri)
.header("x-request-id", "req_forecast");
if authenticated {
request = request.header(header::COOKIE, "cpr_admin_session=valid-session");
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = admin::router::<AdminTestState>()
.with_state(fixture.state())
@@ -0,0 +1,166 @@
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use gateway_api::admin;
use serde_json::{Value, json};
use tower::ServiceExt as _;
use super::super::{AdminTestFixture, AdminTestState};
async fn send(
fixture: &AdminTestFixture,
method: &str,
uri: &str,
body: Value,
authenticated: bool,
) -> (StatusCode, Value) {
let mut request = Request::builder()
.method(method)
.uri(uri)
.header("x-request-id", "import-task-contract")
.header(header::CONTENT_TYPE, "application/json");
if authenticated {
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = admin::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(request.body(Body::from(body.to_string())).unwrap())
.await
.unwrap();
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
let status = response.status();
let bytes = to_bytes(response.into_body(), 8 * 1024 * 1024)
.await
.unwrap();
let value: Value = serde_json::from_slice(&bytes).unwrap();
assert!(!value.to_string().contains("synthetic-sensitive-token"));
(status, value)
}
fn input() -> Value {
json!({"submissionId": "01994fcb-a333-7333-8000-000000000001", "items": [{"provider": "openai", "data": {"refreshToken": "synthetic-sensitive-token"}}]})
}
#[tokio::test]
async fn import_tasks_should_accept_list_restore_stop_and_deduplicate_without_echoing_credentials()
{
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let (status, accepted) = send(
&fixture,
"POST",
"/api/admin/accounts/import-tasks",
input(),
true,
)
.await;
assert_eq!(status, StatusCode::ACCEPTED);
let id = accepted["data"]["taskId"].as_str().unwrap();
let (_, retried) = send(
&fixture,
"POST",
"/api/admin/accounts/import-tasks",
input(),
true,
)
.await;
assert_eq!(retried["data"]["taskId"], id);
let (_, list) = send(
&fixture,
"GET",
"/api/admin/accounts/import-tasks",
Value::Null,
true,
)
.await;
assert_eq!(list["data"]["items"].as_array().unwrap().len(), 1);
let (_, detail) = send(
&fixture,
"GET",
&format!("/api/admin/accounts/import-tasks/detail?taskId={id}"),
Value::Null,
true,
)
.await;
assert_eq!(detail["data"]["items"][0]["status"], "pending");
let (_, stopped) = send(
&fixture,
"POST",
"/api/admin/accounts/import-tasks/stop",
json!({"taskId": id}),
true,
)
.await;
assert_eq!(stopped["data"]["counts"]["skipped"], 1);
assert!(stopped["data"]["finishedAt"].is_string());
let mut changed = input();
changed["items"][0]["data"]["refreshToken"] = json!("changed-synthetic-input");
assert_eq!(
send(
&fixture,
"POST",
"/api/admin/accounts/import-tasks",
changed,
true
)
.await
.0,
StatusCode::CONFLICT
);
}
#[tokio::test]
async fn import_tasks_should_require_admin_and_enforce_input_bounds() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (method, uri, body) in [
("POST", "/api/admin/accounts/import-tasks", input()),
("GET", "/api/admin/accounts/import-tasks", Value::Null),
(
"GET",
"/api/admin/accounts/import-tasks/detail?taskId=01994fcb-a333-7333-8000-000000000001",
Value::Null,
),
(
"POST",
"/api/admin/accounts/import-tasks/stop",
json!({"taskId": "01994fcb-a333-7333-8000-000000000001"}),
),
] {
assert_eq!(
send(&fixture, method, uri, body, false).await.0,
StatusCode::UNAUTHORIZED
);
}
for count in [0, 201] {
let mut body = input();
body["items"] = json!(vec![body["items"][0].clone(); count]);
assert_eq!(
send(
&fixture,
"POST",
"/api/admin/accounts/import-tasks",
body,
true
)
.await
.0,
StatusCode::BAD_REQUEST
);
}
let mut body = input();
body["items"][0]["data"]["refreshToken"] = json!("x".repeat(4 * 1024 * 1024));
assert_eq!(
send(
&fixture,
"POST",
"/api/admin/accounts/import-tasks",
body,
true
)
.await
.0,
StatusCode::PAYLOAD_TOO_LARGE
);
}

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