Compare commits

...
42 Commits
Author SHA1 Message Date
ZyyandCodex 705f0fad06 chore(release): v3.22.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 18:11:24 +08:00
ZyyandCodex f0c60f501b fix(client): enable remote compaction in Codex config
Declare the V2 remote compaction capability in generated Codex configuration and deployment examples. Document that CC Switch imports do not preserve the full capability configuration.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 18:05:07 +08:00
ZyyandCodex d1febdd249 fix(openai): remove precommit byte limit
Keep large structural events buffered until semantic output, terminal completion, EOF, or the replay grace deadline. Retain byte counts for diagnostics and preserve historical byte_limit exports.

Refs #369

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 18:05:01 +08:00
ZyyandCodex 53d4086cc0 fix(openai): preserve opaque request fields and websocket messages
Forward numeric and extended reasoning effort values and preserve standalone endpoint business headers.

Retain business metadata and original WebSocket text through provider, middleware, and client delivery. Validate rewritten plugin output without rejecting unchanged heartbeats or opaque messages.

Add HTTP, WebSocket, and plugin process regressions for the passthrough boundaries.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 17:23:40 +08:00
ZyyandCodex ce03e8119d fix(diagnostics): retain health and xAI worker failure causes
Record health component failures through operational diagnostics with transition deduplication. Preserve quota and catalog failure context without interrupting the account batch.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 17:10:29 +08:00
ZyyandCodex 3f05b6450c fix(usage): preserve precommit facts in diagnostics export
Export allowlisted release reasons, prefetch bytes and wait times, and document diagnostics schema v4.

Refs #369

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 16:55:12 +08:00
ZyyandCodex a4b73099b5 fix(openai): pass through websocket control frames
Forward non-create text frames unchanged over the original upstream WebSocket while preserving response.create admission and serialization. Keep the control transport available after completion and relay idle upstream events without creating another inference attempt.

Cover unknown frames, late interrupts, terminal settlement, binding revocation, and connection reuse.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 16:26:31 +08:00
ZyyandCodex d875a62652 chore(release): v3.22.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 14:10:51 +08:00
ZyyandCodex 98704dfde8 fix(usage): parse response critical path timings
Read response-scoped engine and API overhead metrics while excluding logical-turn totals and incomplete observations. Validate nested response IDs and cover the observed payload over SSE and WebSocket.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 13:51:40 +08:00
ZyyandCodex b07bca312a fix(upgrade): handle long migrations and unify forced shutdown
Extend the startup healthcheck grace period to five minutes and report elapsed migration time every fifteen seconds without changing published migrations.

Use one shutdown path for OS signals, internal cancellation, and managed restarts. Stop listening without waiting for active sessions, preserve bounded worker cleanup, and remove the obsolete connection drain timeout.

Cover shutdown entry points and worker cleanup with real HTTP lifecycle tests and document the deployment behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 13:51:14 +08:00
ZyyandCodex 60966d935e chore(release): v3.22.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 13:03:32 +08:00
ZyyandCodex 52981f40f9 fix(providers): align Codex 0.162 protocol handling
Apply latest incremental tool declarations during Grok normalization and align nested Retry-After header parsing with official Codex semantics.

Cover developer instructions in input, Responses Lite tool declarations, partial_answer phases, and end_turn preservation.

Pin the upstream SQLx fix for cancelled BEGIN rollback and add a deterministic pooled-connection regression test.

Report safe Redis error categories and timeout status when the cooldown persistence regression fails.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 12:10:02 +08:00
ZyyandCodex a51c4cd640 refactor(plugins): simplify lifecycle and reuse unchanged instances
Share unchanged prepared instances across extension sets and close each
process when its last owner releases it. Keep inflight request plans intact.

Unify migration recovery and publication, centralize authentication binding
validation, avoid duplicate package inspection, and separate middleware plans.

Validated with 292 admin, 182 runtime, 37 plugin storage and 15 architecture
tests, rustfmt, and strict workspace Clippy.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 00:12:29 +08:00
ZyyandCodex bda6d973a8 docs: streamline documentation around current behavior
Remove version migration narratives, consolidate repeated explanations, and organize settings and architecture references.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 22:26:12 +08:00
ZyyandCodex 66a7e4a695 fix(client): align Codex config export with CC Switch 4.0.4
Export config.toml with provider bearer authentication and send supported connection and usage fields to CC Switch. Remove the separate auth.json output and size the configuration preview to its content.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 22:09:02 +08:00
think-backandCodex 9483f2c91b perf(groups): avoid aggregate queries when loading group selectors
Load account and API key group selectors through a lightweight admin options
endpoint while preserving pagination, filters, disabled groups and revision
metadata. Keep aggregate statistics on the group management endpoint.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 18:20:24 +08:00
ZyyandCodex faf50c28ad feat(usage): unify request observations and upstream performance
Consolidate request diagnostics into typed request_observation_json with
migration 0025, preserving historical facts and query constraints.

Use upstream response timestamps for output throughput, align TTFT event
boundaries, and surface official timing metrics in the latency popover.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 18:17:09 +08:00
ZyyandCodex cb397ae53e chore(release): v3.21.3
Co-authored-by: Codex <noreply@openai.com>
2026-10-08 12:12:34 +08:00
ZyyandCodex 63802f9b1a fix(usage): exclude cached tokens from displayed input
Reuse the fresh input calculation across usage records, token details and key usage metrics.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 11:43:26 +08:00
ZyyandCodex de599ed768 fix(openai): apply timezone overrides without content kinds
Recognize complete Codex environment contexts when custom providers omit
content classifications, while keeping explicit classifications authoritative.

Cover HTTP and WebSocket overrides and proxy location precedence, and
document timezone and client privacy boundaries.

Refs #366

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 11:16:31 +08:00
ZyyandCodex 3315ddbff6 fix(openai): isolate guardian concurrency from ordinary requests
Give Guardian requests a separate per-account lease pool and wait queue while preserving ordinary concurrency and shared request intervals. Use the ordinary pool when the Guardian limit is zero, and align scheduling, diagnostics, settings copy, and documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 10:38:52 +08:00
ZyyandCodex 6c583e341a fix(openai): reject invalid continuation replay before account selection
Check the recorded native continuation scope before selecting an account
or committing a session binding. Reuse the same scope validation after
middleware while preserving native connection checks and migration policy.

Cover ReplayOwner and ReplayAny with persisted provider metadata or a
connection-local pin, with and without an existing session binding.
Rejected replays must not acquire leases, write bindings or aliases, send
upstream requests, or redirect subsequent child requests.

Refs #364

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 22:45:42 +08:00
ZyyandCodex fb15f3e36c chore(release): v3.21.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-07 20:21:38 +08:00
ZyyandCodex a940eb599f fix(openai): allow atomic first binding from any session request
Allow requests with a reliable session identity to claim a missing account
binding, including child threads and independent background tasks. Remove
the missing-binding wait queue while preserving existing migration rights
and bound-account waiting behavior.

Cover concurrent root/child claims, memory prewarm and turn reuse,
middleware identity changes, cancellation, and bound-account timeouts.
Update the documented strict-affinity contract.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 20:18:03 +08:00
ZyyandCodex 22a848b360 refactor(backend): clarify ownership and align xai contracts
Separate execution, provider ports, administration, and plugin lifecycle responsibilities.

Fix shutdown ordering, account lock reclamation, multipart cancellation, budget windows, and WebSocket close handling.

Align xAI catalog, reasoning, tools, replay, quota, and cost handling with current Grok contracts and remove legacy fallbacks.

Update API and architecture documentation with regression coverage. Validated backend suites, strict workspace Clippy, isolated PostgreSQL/Redis integration, and real Codex/Grok client flows.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 19:34:10 +08:00
ZyyandCodex 754da83b02 refactor(backend): simplify query ownership and resource handling
Move observability query policies into Admin and reuse account state reads.
Bound Redis reads to measured batches and share protocol and transport helpers.
Close backup process and staging cleanup gaps, and remove unused entry points,
redundant models, dependencies, and obsolete tests.

Validated 3,526 workspace tests, then all 352 Store tests after batch tuning,
plus strict workspace Clippy and formatting checks. Local release benchmarks
cover concurrent account reads and controlled transport delays.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 15:43:29 +08:00
ZyyandCodex f174320e2a chore(release): v3.21.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-07 03:57:15 +08:00
ZyyandCodex 9db8528758 fix(metrics): align TTFT and throughput with sub2api
Count non-preamble Responses events for first-token timing while preserving
semantic output boundaries and request-level timing origins. Calculate
output throughput over the complete request duration in usage rows and
aggregate queries.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 03:52:59 +08:00
ZyyandCodex 119032c067 feat(settings): add preferred account affinity mode
Route relaxed requests through the configured scheduler and keep the session primary during preferred-mode spillover. Default new settings to strict while preserving saved modes.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 03:50:59 +08:00
ZyyandCodex 3d1dc79f5d fix(auth): refresh sessions only after authentication failures
Use read-only status checks for initial authentication and remove activity-driven renewal. Keep failed and newly queued requests waiting for the current refresh before replaying with the updated cookie.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 03:10:50 +08:00
ZyyandCodex 128ced4093 chore(release): v3.21.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-07 02:49:11 +08:00
ZyyandCodex 693025afa4 refactor(store): consolidate unreleased database migrations
Merge error details and account affinity settings into migration 0023. Preserve all migrations shipped in v3.20.0 and validate upgrades from its 0022 baseline.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 02:49:01 +08:00
ZyyandCodex 0bfeff28fc refactor(backend): centralize shared models and settings validation
Reuse domain models for observability, account statistics, execution facts,
and group validation. Compose shared settings values while preserving
HTTP and SDK contracts and explicit storage and runtime boundaries.

Verify serialization, concurrency, persistence, and snapshot behavior with
3528 backend tests, strict Clippy, formatting, and architecture checks.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 02:32:53 +08:00
ZyyandCodex cb7daffb66 feat(settings): configure account affinity and rotation limits
Add relaxed and strict affinity modes, configurable binding retention, and per-request account rotation limits. Freeze policies for each request and preserve continuation ownership and plugin selection behavior.

Expose the controls in a dedicated affinity settings card and cover scheduling, persistence, renewal, and retry boundaries.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 01:30:10 +08:00
ZyyandCodex d922c3af60 fix(diagnostics): preserve error chains and avoid logging backpressure
Preserve native failure causes through store, provider, plugin, admin, and
worker boundaries, including secondary cleanup failures. Route operational
failures through the shared diagnostics sink with protected error details.

Use bounded nonblocking file logging with batched writes and reserved
warning/error capacity. Reserve observation capacity for failures and
request lifecycle records, and rate-limit overload warnings.

Validate error propagation and blocked-output HTTP, admin, SSE, and WebSocket
behavior; keep the local migration plan out of version control.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 00:25:18 +08:00
ZyyandCodex 366bc5f1ca feat(client-profile): expose desktop OS version setting
Co-authored-by: Codex <noreply@openai.com>
2026-10-06 18:29:12 +08:00
ZyyandCodex a5e4041295 fix(diagnostics): preserve failure causes across request tracing
Retain preparation and account-selection diagnostics, preserve plugin fault
details, and record failures before attempt startup. Keep failure
classification when trace summaries are truncated.

Carry native error sources through Core, infrastructure availability errors,
OpenAI credential selection, provider inference, and plugin upstream adapters.
Preserve original upstream codes independently of failure classification and
project bounded cause chains and upstream bodies into protected error details.

Rename persisted and admin API error detail fields, keep raw details out of
ordinary logs and diagnostic exports, and account for retained error data in
the observation queue memory budget. Add regression coverage for source
retention, safe output, and migration upgrades; remove account-dependent live
tests. Document the implemented diagnostic boundaries.

Refs #357

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 18:19:04 +08:00
ZyyandCodex b27877c99b fix(memory): release request copies and preallocate XZ buffers
Release preliminary Responses decodes before middleware execution and move
WebSocket frame ownership without cloning the request body. Reserve XZ
block buffers from validated lengths while retaining overflow detection.

Add allocation regression coverage for 24 MiB and 32 MiB XZ blocks.

Refs #338

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 15:47:25 +08:00
ZyyandCodex 7505459ece fix(accounts): constrain long plan badges to available width
Truncate overflowing plan names and show the full display name on hover while preserving the existing subscription column width.

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 13:52:29 +08:00
ZyyandCodex 102d064ab0 fix(metrics): align first-token timing with request duration
Record semantic output from the first stream chunk and reuse Core timing for xAI. Add regression and opt-in live transport coverage.

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 13:52:24 +08:00
ZyyandCodex 7616fdf4ab feat(auth): add seamless admin session renewal
Renew active administrator sessions with atomic Redis expiry updates,
synchronized cookies, and idle and absolute lifetime limits. Preserve
revocation after logout and password changes.

Coordinate session recovery, bounded retries, error handling, and raw
responses through request plugins. Gate initial navigation in the router
and organize frontend API modules by their views.

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 12:49:50 +08:00
ZyyandCodex 5cfe0c68cc fix(deps): update source-map-js to 1.2.2
Resolve GHSA-68fv-2mgg-jv7q in transitive frontend dependencies.

Co-authored-by: Codex <noreply@openai.com>
2026-10-06 11:22:48 +08:00
637 changed files with 42956 additions and 27678 deletions
+2 -3
View File
@@ -21,7 +21,7 @@
## 快速预览
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计
无需部署即可体验管理端的系统概览、账号分组、代理管理与用量统计:
| 登录信息 | 默认值 |
| --- | --- |
@@ -53,8 +53,7 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
自动生成密码、设置目录权限并启动服务
完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
[添加账号与客户端密钥](#添加账号与客户端密钥)
完成后保存显示的访问地址和管理员密码,再[登录管理端](#登录管理端)并[添加账号与客户端密钥](#添加账号与客户端密钥)
可在执行 `bash install.sh` 时传入环境变量:
+60 -44
View File
@@ -303,7 +303,7 @@ dependencies = [
"lru",
"percent-encoding",
"regex-lite",
"sha2 0.11.0",
"sha2",
"tracing",
"url",
]
@@ -325,7 +325,7 @@ dependencies = [
"hmac",
"http",
"percent-encoding",
"sha2 0.11.0",
"sha2",
"time",
"tracing",
]
@@ -358,7 +358,7 @@ dependencies = [
"md-5",
"pin-project-lite",
"sha1 0.11.0",
"sha2 0.11.0",
"sha2",
"tracing",
]
@@ -877,7 +877,7 @@ dependencies = [
"gateway-plugin-sdk",
"hex",
"serde_json",
"sha2 0.11.0",
"sha2",
"tar",
"tempfile",
"thiserror",
@@ -1688,7 +1688,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"subtle",
"syn 3.0.6",
"tempfile",
@@ -1719,7 +1719,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"thiserror",
"tokio",
"tokio-tungstenite 0.29.0",
@@ -1745,12 +1745,11 @@ dependencies = [
"futures",
"futures-timer",
"http",
"http-body",
"http-body-util",
"semver",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"thiserror",
"tracing",
"url",
@@ -1790,7 +1789,7 @@ dependencies = [
"serde_ignored",
"serde_json",
"serde_path_to_error",
"sha2 0.11.0",
"sha2",
"tar",
"tempfile",
"thiserror",
@@ -1831,7 +1830,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"sqlx",
"tar",
"tempfile",
@@ -1859,6 +1858,7 @@ dependencies = [
name = "gateway-protocol"
version = "0.0.0"
dependencies = [
"http",
"httpdate",
"memchr",
"serde",
@@ -1887,7 +1887,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"sqlx",
"tempfile",
"thiserror",
@@ -2525,6 +2525,15 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
@@ -2842,6 +2851,24 @@ dependencies = [
"libc",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2930,7 +2957,7 @@ dependencies = [
"elliptic-curve",
"primefield",
"primeorder",
"sha2 0.11.0",
"sha2",
]
[[package]]
@@ -3238,6 +3265,7 @@ dependencies = [
name = "provider-openai"
version = "0.0.0"
dependencies = [
"allocation-counter",
"async-stream",
"async-trait",
"aws-lc-rs",
@@ -3270,7 +3298,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"subtle",
"tar",
"tempfile",
@@ -3312,7 +3340,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"thiserror",
"tokio",
"tracing",
@@ -4023,17 +4051,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
@@ -4154,8 +4171,7 @@ dependencies = [
[[package]]
name = "sqlx"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -4167,8 +4183,7 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -4191,7 +4206,7 @@ dependencies = [
"rustls",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2",
"smallvec",
"thiserror",
"tokio",
@@ -4205,8 +4220,7 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"proc-macro2",
"quote",
@@ -4218,8 +4232,7 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"cfg-if",
"dotenvy",
@@ -4230,7 +4243,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
@@ -4244,8 +4257,7 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"bitflags",
"byteorder",
@@ -4262,7 +4274,7 @@ dependencies = [
"percent-encoding",
"serde",
"sha1 0.11.0",
"sha2 0.11.0",
"sha2",
"sqlx-core",
"thiserror",
"tracing",
@@ -4272,8 +4284,7 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -4296,7 +4307,7 @@ dependencies = [
"rand 0.10.2",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
@@ -4309,8 +4320,7 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
dependencies = [
"atoi",
"chrono",
@@ -4493,7 +4503,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -5182,6 +5192,12 @@ name = "whoami"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"web-sys",
]
[[package]]
name = "winapi"
+2
View File
@@ -59,3 +59,5 @@ panic = "abort"
[patch.crates-io]
# Upstream SOCKS IPv6 fix; remove once a release includes hyperium/hyper-util#302.
hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6" }
# 固定上游 BEGIN 取消回滚修复,避免未结束事务复用;见 transact-rs/sqlx#4394
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "f1e94ec34dd5f11ad6488a3a8ac7988439a3f757" }
@@ -93,7 +93,7 @@ async fn execute(
Some(plugin_runtime.clone()),
Some(plugin_runtime.observer_registry()),
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.middleware_registry()),
Some(plugin_runtime.execution_registry()),
);
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
@@ -110,7 +110,7 @@ async fn execute(
providers.admin,
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
);
let mut services = gateway_admin::service::Registry::new(service_middleware(
let mut services = gateway_admin::public_service::Registry::new(service_middleware(
core.snapshots(),
&plugin_runtime,
));
@@ -72,11 +72,11 @@ impl ManagementPorts {
pub(super) fn service_middleware(
snapshots: RuntimeSnapshotHandle,
runtime: &PluginRuntime,
) -> gateway_admin::service::PlanSource {
let middleware = runtime.middleware_registry();
) -> gateway_admin::public_service::PlanSource {
let middleware = runtime.execution_registry();
Arc::new(move || {
let snapshot = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(snapshot.extensions()?)
middleware.middleware(snapshot.extensions()?)
})
}
+7 -6
View File
@@ -1,4 +1,4 @@
//! 服务模式完成全部组装后交给 Host 监听,并在请求和 Worker 排空后关闭插件
//! 服务模式完成全部组装后交给 Host 监听,并在停止监听与 Worker 收尾后关闭插件
use gateway_host::ConfigError;
@@ -28,7 +28,7 @@ pub async fn run() -> Result<(), BootstrapError> {
Some(plugin_runtime.clone()),
Some(plugin_runtime.observer_registry()),
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.middleware_registry()),
Some(plugin_runtime.execution_registry()),
Some(plugin_runtime.frontend_authentication_registry()),
);
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
@@ -117,18 +117,19 @@ pub async fn run() -> Result<(), BootstrapError> {
probes,
host.worker_health(),
host.connection_lifecycle(),
store.diagnostics(),
)
)
.with_middleware({
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
let middleware = plugin_runtime.execution_registry();
move |snapshot| {
if let Some(snapshot) = snapshot {
return middleware.resolve(snapshot.extensions()?);
return middleware.middleware(snapshot.extensions()?);
}
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件
let diagnostic = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(diagnostic.extensions()?)
middleware.middleware(diagnostic.extensions()?)
}
});
let http_dispatcher = api.dispatcher();
@@ -155,7 +156,7 @@ pub async fn run() -> Result<(), BootstrapError> {
));
or_shutdown!(
plugin_runtime,
host.start_workers(plan, store.worker_leader_lease())
host.start_workers(plan, store.worker_leader_lease(), store.diagnostics())
);
host.report_startup_ready("Workers");
let served = host.serve(api.router()).await;
+142 -29
View File
@@ -79,18 +79,25 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
path if path.starts_with("policy/") => {
Some(&["account", "identity", "policy", "validation"])
}
"settings/values.rs" | "settings/compiled.rs" => {
Some(&["account", "concurrency", "identity", "metering", "policy"])
}
path if path.starts_with("settings/") => Some(&[
"account",
"concurrency",
"identity",
"metering",
"policy",
"settings",
]),
path if path.starts_with("account/") => Some(&["account", "identity", "validation"]),
_ => None,
};
let Some(allowed) = allowed else { continue };
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
let syntax = syn::parse_file(&source).expect("parse core source");
let mut references = CrateReferences::default();
let mut references = CrateReferences::in_module(&relative);
references.visit_file(&syntax);
for dependency in references.0 {
// 相对路径中的 self 指向当前 owner,不是跨 owner 依赖
references.owners.remove(&references.module_path[0]);
for dependency in references.owners {
assert!(
allowed.contains(&dependency.as_str()),
"{} must not depend on crate::{dependency}",
@@ -100,49 +107,155 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
}
}
#[test]
fn core_owner_paths_form_a_dag() {
let root = backend_root().join("crates/gateway-core/src");
let mut graph = BTreeMap::<String, BTreeSet<String>>::new();
for relative in super::rust_files(&root) {
if relative == Path::new("lib.rs") {
continue;
}
let mut references = CrateReferences::in_module(&relative);
let owner = references.module_path[0].clone();
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
references.visit_file(&syn::parse_file(&source).expect("parse core source"));
references.owners.remove(&owner);
graph.entry(owner).or_default().extend(references.owners);
}
for owner in graph.keys() {
let mut pending = graph[owner].iter().cloned().collect::<Vec<_>>();
let mut visited = BTreeSet::new();
while let Some(dependency) = pending.pop() {
assert_ne!(
&dependency, owner,
"Core owner dependency cycle reaches {owner}"
);
if visited.insert(dependency.clone())
&& let Some(next) = graph.get(&dependency)
{
pending.extend(next.iter().cloned());
}
}
}
}
#[test]
fn core_reference_scan_resolves_groups_reexports_and_relative_paths() {
let syntax = syn::parse_file(
r#"
pub use crate::{engine::AttemptContext, settings::SettingsValues as Values};
use super::super::runtime as publication;
use self::compiled::Value;
// crate::ignored::Comment
const EXAMPLE: &str = "crate::ignored::Literal";
"#,
)
.expect("parse reference fixture");
let mut references = CrateReferences::in_module(Path::new("routing/snapshot.rs"));
references.visit_file(&syntax);
assert_eq!(
references.owners,
BTreeSet::from([
"engine".to_owned(),
"settings".to_owned(),
"runtime".to_owned(),
"routing".to_owned(),
])
);
}
#[derive(Default)]
struct CrateReferences(BTreeSet<String>);
struct CrateReferences {
owners: BTreeSet<String>,
module_path: Vec<String>,
}
impl<'ast> Visit<'ast> for CrateReferences {
fn visit_path(&mut self, path: &'ast syn::Path) {
let mut segments = path.segments.iter();
if segments
.next()
.is_some_and(|segment| segment.ident == "crate")
&& let Some(owner) = segments.next()
{
self.0.insert(owner.ident.to_string());
}
self.record(
&path
.segments
.iter()
.map(|part| part.ident.to_string())
.collect::<Vec<_>>(),
);
syn::visit::visit_path(self, path);
}
fn visit_item_use(&mut self, item: &'ast syn::ItemUse) {
self.use_tree(&item.tree, false);
self.use_tree(&item.tree, &mut Vec::new());
}
}
impl CrateReferences {
fn use_tree(&mut self, tree: &syn::UseTree, crate_root: bool) {
fn in_module(relative: &Path) -> Self {
let mut module = relative
.with_extension("")
.components()
.map(|part| {
part.as_os_str()
.to_str()
.expect("UTF-8 module path")
.to_owned()
})
.collect::<Vec<_>>();
if module.last().is_some_and(|part| part == "mod") {
module.pop();
}
Self {
owners: BTreeSet::new(),
module_path: module,
}
}
fn record(&mut self, path: &[String]) {
let Some(first) = path.first() else { return };
let owner = if first == "crate" {
path.get(1).cloned()
} else if first == "self" || first == "super" {
let mut module = self.module_path.clone();
let mut rest = path;
while rest
.first()
.is_some_and(|part| part == "super" || part == "self")
{
if rest[0] == "super" {
module.pop();
}
rest = &rest[1..];
}
module.first().or_else(|| rest.first()).cloned()
} else {
None
};
if let Some(owner) = owner {
self.owners.insert(owner);
}
}
fn use_tree(&mut self, tree: &syn::UseTree, prefix: &mut Vec<String>) {
match tree {
syn::UseTree::Path(path) if crate_root => {
self.0.insert(path.ident.to_string());
syn::UseTree::Path(path) => {
prefix.push(path.ident.to_string());
self.use_tree(&path.tree, prefix);
prefix.pop();
}
syn::UseTree::Name(name) if crate_root => {
self.0.insert(name.ident.to_string());
syn::UseTree::Name(name) => {
prefix.push(name.ident.to_string());
self.record(prefix);
prefix.pop();
}
syn::UseTree::Rename(rename) if crate_root => {
self.0.insert(rename.ident.to_string());
syn::UseTree::Rename(rename) => {
prefix.push(rename.ident.to_string());
self.record(prefix);
prefix.pop();
}
syn::UseTree::Path(path) => self.use_tree(&path.tree, path.ident == "crate"),
syn::UseTree::Group(group) => {
for item in &group.items {
self.use_tree(item, crate_root);
self.use_tree(item, prefix);
}
}
syn::UseTree::Glob(_) if crate_root => {
self.0.insert("*".to_owned());
}
_ => {}
syn::UseTree::Glob(_) => self.record(prefix),
}
}
}
@@ -221,7 +334,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
"crates/providers/openai",
&["config", "credential", "transport"],
),
("crates/providers/xai", &["credential", "transport"]),
("crates/providers/xai", &["transport"]),
];
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景
@@ -76,11 +76,6 @@ fn config_loader_should_reject_missing_runtime_data_dir() {
assert!(parse_config(&config).is_err());
}
#[test]
fn config_loader_should_inject_connection_passwords_into_urls() {
parse_config(&valid_config()).expect("Store validates password injection into both URLs");
}
#[test]
fn config_loader_should_apply_only_explicit_topology_overrides() {
let invalid = valid_config()
+2 -5
View File
@@ -28,11 +28,8 @@ cpr-plugin package \
资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖
作者清单可省略普通贡献项的 `id`、`version` 和固定阶段;`middleware` 必须显式声明版本 `4`,
并从 `http`、`websocket`、`service`、`request`、`attempt` 中选择挂载阶段;`upstream_adapter` 必须显式声明版本 `2`。
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口;归档中的
`plugin.json` 写入补全后的贡献项字段,不保留另一份作者声明。
完整规则见 [SDK 清单](../../crates/gateway-plugin/sdk/docs/manifest.md)
作者清单的省略规则、能力版本和挂载阶段见 [SDK 扩展项声明](../../crates/gateway-plugin/sdk/docs/manifest.md#扩展项简写)。
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口,归档只保留补全字段后的 `plugin.json`
支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。
完整开发示例在独立仓库 `codex-proxy-plugins` 的 `examples/workbench`,可通过[子模块](../../../docs/development.md#源码联调)检出;本工具不负责源码构建、上传或启用插件
+22 -12
View File
@@ -28,7 +28,7 @@ use crate::{
impl From<BackupError> for WorkerTaskError {
fn from(error: BackupError) -> Self {
WorkerTaskError::safe(error.message())
WorkerTaskError::safe(error.message()).with_source(error)
}
}
@@ -125,8 +125,9 @@ impl BackupTask {
if cron.is_empty() {
return Ok(());
}
let schedule = BackupSchedule::parse(cron, self.timezone)
.map_err(|_| WorkerTaskError::safe("backup schedule is invalid"))?;
let schedule = BackupSchedule::parse(cron, self.timezone).map_err(|source| {
WorkerTaskError::safe("backup schedule is invalid").with_source(source)
})?;
if settings.schedule_timezone.as_deref() != Some(timezone) {
// 时区切换只初始化未来游标,不把旧时区计划当作应补跑任务
@@ -280,7 +281,6 @@ impl BackupTask {
.await?;
}
None => {
let _ = self.dump.cleanup_staging(&record.id).await;
self.fail_task(record, code::PG_DUMP_FAILED, "导出进程中断,暂存归档不完整")
.await?;
}
@@ -306,7 +306,8 @@ impl BackupTask {
&& record.sha256.as_deref() == Some(remote.sha256.as_str())
{
let now = Utc::now();
self.repository
if self
.repository
.transition_status(
&record.id,
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
@@ -314,7 +315,11 @@ impl BackupTask {
now,
)
.await
.map_err(repo_error)?;
.map_err(repo_error)?
.is_some()
{
self.cleanup_staging(&record.id).await;
}
return Ok(());
}
match self.dump.inspect_staging(&record.id).await? {
@@ -454,9 +459,7 @@ impl BackupTask {
now,
cancellation,
)
.await?;
let _ = self.dump.cleanup_staging(&record.id).await;
Ok(())
.await
}
/// 上传 + 远端校验,内部完成终态迁移
@@ -517,6 +520,7 @@ impl BackupTask {
size_bytes,
"备份完成"
);
self.cleanup_staging(&record.id).await;
}
}
Ok(Some(remote)) => {
@@ -595,10 +599,16 @@ impl BackupTask {
)
.await
.map_err(repo_error)?;
let _ = self.dump.cleanup_staging(&record.id).await;
self.cleanup_staging(&record.id).await;
Ok(())
}
async fn cleanup_staging(&self, backup_id: &str) {
if let Err(error) = self.dump.cleanup_staging(backup_id).await {
warn!(backup_id, error = %error, "清理备份暂存失败");
}
}
/// 执行一小批到期保留清理:先处理 `expires_at` 已到期的记录,再按天数/份数清理计划备份
async fn run_retention_batch(&self, now: DateTime<Utc>) -> Result<(), WorkerTaskError> {
let settings = self.repository.load_settings().await.map_err(repo_error)?;
@@ -685,9 +695,9 @@ fn status_transition(
}
fn repo_error(error: crate::ports::store::AdminStoreError) -> WorkerTaskError {
WorkerTaskError::safe(format!("backup store failed: {error}"))
WorkerTaskError::safe("backup store failed").with_source(error)
}
fn infra_error(error: BackupError) -> WorkerTaskError {
WorkerTaskError::safe(error.message())
WorkerTaskError::safe(error.message()).with_source(error)
}
@@ -66,13 +66,13 @@ impl FreezeRecoveryTask {
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,
settings.values.account_auto_freeze_enabled,
settings.values.account_auto_freeze_threshold,
settings.values.account_auto_freeze_window_seconds,
settings.values.account_auto_freeze_duration_seconds,
settings.values.account_auto_freeze_probe_enabled,
settings.values.account_auto_freeze_probe_model,
settings.values.account_auto_freeze_adaptive_concurrency,
)
.ok()
}
+26 -19
View File
@@ -20,7 +20,7 @@ pub mod backup;
pub mod freeze_recovery;
pub mod model;
pub mod ports;
pub mod service;
pub mod public_service;
mod use_case;
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
@@ -104,10 +104,16 @@ impl fmt::Debug for InitialAdminPassword {
#[derive(Clone, Deserialize, PartialEq, Eq)]
pub struct AdminConfig {
pub session_ttl_minutes: u64,
#[serde(default = "default_absolute_session_ttl_minutes")]
pub session_absolute_ttl_minutes: u64,
pub default_username: String,
pub default_password: InitialAdminPassword,
}
fn default_absolute_session_ttl_minutes() -> u64 {
30 * 24 * 60
}
/// Client 登录域的通用启动配置
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ClientConfig {
@@ -151,6 +157,13 @@ impl AdminConfig {
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
return Err(AdminConfigError::InvalidField("admin.session_ttl_minutes"));
}
if self.session_absolute_ttl_minutes == 0
|| i64::try_from(self.session_absolute_ttl_minutes).is_err()
{
return Err(AdminConfigError::InvalidField(
"admin.session_absolute_ttl_minutes",
));
}
let password = self.default_password.expose().trim();
if password.len() < MINIMUM_INITIAL_PASSWORD_BYTES
|| password.contains('$')
@@ -167,6 +180,10 @@ impl fmt::Debug for AdminConfig {
formatter
.debug_struct("AdminConfig")
.field("session_ttl_minutes", &self.session_ttl_minutes)
.field(
"session_absolute_ttl_minutes",
&self.session_absolute_ttl_minutes,
)
.field("default_username", &self.default_username)
.field("default_password", &"[REDACTED]")
.finish()
@@ -188,7 +205,7 @@ pub enum AdminConfigError {
#[derive(Clone)]
pub struct AdminServices {
timezone: gateway_core::time::DeploymentTimeZone,
public_services: Arc<service::Registry>,
public_services: Arc<public_service::Registry>,
plugins: Arc<PluginsService>,
plugin_management: Arc<PluginManagementService>,
proxies: Arc<dyn ProxiesService>,
@@ -202,7 +219,8 @@ pub struct AdminServices {
settings: Arc<dyn SettingsService>,
system: Arc<dyn SystemService>,
credentials: Arc<CredentialsService>,
plugin_accounts: Arc<dyn PluginAccountAccess>,
// Runtime 只持有 Weak,这个强引用保障账号端口与 Admin 服务同寿命
_plugin_accounts: Arc<dyn PluginAccountAccess>,
backups: Arc<dyn BackupService>,
import_tasks: Arc<dyn ImportTasksService>,
}
@@ -213,7 +231,7 @@ impl AdminServices {
self.timezone
}
pub fn public_services(&self) -> Arc<service::Registry> {
pub fn public_services(&self) -> Arc<public_service::Registry> {
self.public_services.clone()
}
@@ -237,12 +255,6 @@ impl AdminServices {
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()
@@ -292,12 +304,6 @@ impl AdminServices {
self.credentials.as_ref()
}
/// Runtime 只持有该窄端口的 Weak;AdminBundle 保持实际生命周期
#[must_use]
pub fn plugin_accounts_handle(&self) -> Arc<dyn PluginAccountAccess> {
Arc::clone(&self.plugin_accounts)
}
#[must_use]
pub fn backups(&self) -> &dyn BackupService {
self.backups.as_ref()
@@ -325,7 +331,7 @@ impl AdminBundle {
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入
pub struct AdminRuntimePorts {
pub timezone: gateway_core::time::DeploymentTimeZone,
pub service_middleware: service::PlanSource,
pub service_middleware: public_service::PlanSource,
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
pub plugin_management: Arc<dyn ports::plugin_management::PluginManagement>,
pub published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle,
@@ -401,6 +407,7 @@ async fn initialize_inner(
let auth = Arc::new(DefaultAuthService::new(
config.default_username,
config.session_ttl_minutes,
config.session_absolute_ttl_minutes,
client_config.session_ttl_minutes,
store.auth(),
client_key_verifier.clone(),
@@ -459,7 +466,7 @@ async fn initialize_inner(
registry.clone(),
pricing_source,
);
let mut public_services = service::Registry::new(service_middleware);
let mut public_services = public_service::Registry::new(service_middleware);
public_services.register_settings(&settings)?;
let services = AdminServices {
timezone,
@@ -508,7 +515,7 @@ async fn initialize_inner(
settings,
system,
credentials,
plugin_accounts,
_plugin_accounts: plugin_accounts,
import_tasks,
backups,
};
@@ -10,6 +10,23 @@ use gateway_core::{
use super::{PageSize, Revision, observability::DecimalAmount};
/// 名称与描述的共同约束,HTTP、用例和存储边界只映射错误
pub fn validate_group_fields(
name: &str,
description: Option<&str>,
) -> Result<(), super::AdminModelError> {
if name.trim() != name
|| name.is_empty()
|| name.chars().count() > 100
|| name.chars().any(char::is_control)
|| description
.is_some_and(|value| value.len() > 4096 || value.chars().any(char::is_control))
{
return Err(super::AdminModelError::InvalidAccountGroupFields);
}
Ok(())
}
/// 账号分组持久化使用的标准 `#RRGGBBAA` 颜色
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupColor(String);
@@ -109,6 +126,16 @@ pub struct AccountGroupPage {
pub page_size: u16,
}
/// 分组选择器使用的轻量分页结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupOptionsPage {
pub config_revision: Revision,
pub items: Vec<AccountGroupRef>,
pub total: u64,
pub page: u32,
pub page_size: u16,
}
/// 创建账号分组
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAccountGroup {
@@ -133,40 +133,10 @@ pub struct AccountRecord {
pub updated_at: DateTime<Utc>,
}
/// 单一货币的账号成本聚合
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountCost {
pub currency: String,
pub amount: super::observability::DecimalAmount,
}
/// 账号在一个模型上的历史用量
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountModelUsage {
pub model: String,
pub request_count: u64,
pub success_count: u64,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub cache_write_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
pub image_input_tokens: Option<u64>,
pub image_output_tokens: Option<u64>,
pub image_request_count: u64,
pub image_request_failed_count: u64,
pub total_tokens: Option<u64>,
pub cost_coverage: super::observability::CostCoverage,
pub costs: Vec<AccountCost>,
pub last_used_at: DateTime<Utc>,
}
/// 账号在一个小时窗口内的请求数
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRequestBucket {
pub bucket_start: DateTime<Utc>,
pub request_count: u64,
}
pub use super::observability::{
AccountModelUsage, AccountPoolMetrics as AccountSummary, AccountRequestBucket,
CurrencyCost as AccountCost,
};
/// 账号历史用量聚合
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -235,19 +205,6 @@ pub struct AccountCapacity {
pub total_slots: Option<u64>,
}
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响
///
/// 计数与 [`AccountStatus`] 一一对应,由 store 按派生状态聚合
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountSummary {
pub total: u64,
pub normal: u64,
pub quota_exhausted: u64,
pub rate_limited: u64,
pub disabled: u64,
pub error: u64,
}
/// 账号可编辑事实的一次性替换命令
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccount {
@@ -138,11 +138,12 @@ impl std::fmt::Debug for ChangePassword {
}
}
/// 两种登录方式共用的固定有效期会话
/// 会话有效期与管理员续期上限;没有上限字段的旧会话保持固定有效期
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthSession {
pub subject: SessionSubject,
pub expires_at: DateTime<Utc>,
pub absolute_expires_at: Option<DateTime<Utc>>,
}
/// 安全审计事件类型
@@ -20,6 +20,20 @@ pub type ProviderRequestProfileOverrides =
pub type ProviderRequestProfileOverrideUpdates =
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
/// Client Key 关联分组的数量与唯一性约束
pub fn validate_group_ids(group_ids: &[AccountGroupId]) -> Result<(), AdminModelError> {
if group_ids.len() > 1000
|| group_ids
.iter()
.collect::<std::collections::BTreeSet<_>>()
.len()
!= group_ids.len()
{
return Err(AdminModelError::InvalidClientKeyGroups);
}
Ok(())
}
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ClientKeyPageSize(NonZeroU16);
@@ -1,10 +1,10 @@
//! 单页 Key 用量的查询合同;身份范围不由调用方提供
use super::{
PageSize,
client_keys::ClientKeyRecord,
observability::{
HealthTimeline, OpsErrorPage, RequestMetricPoint, TimeRange, UsageOverview, UsagePage,
HealthTimeline, ObservabilityPageSize, OpsErrorPage, RequestMetricPoint, TimeRange,
UsageOverview, UsagePage,
},
};
@@ -25,7 +25,7 @@ pub struct KeyUsageRecordsQuery {
pub usage: KeyUsageQuery,
pub kind: KeyUsageRecordKind,
pub current_page: u32,
pub page_size: PageSize,
pub page_size: ObservabilityPageSize,
}
pub struct KeyUsageOverview {
+39 -2
View File
@@ -39,12 +39,31 @@ pub enum AdminErrorKind {
Internal,
}
/// 不携带基础设施细节、可安全返回给管理员的管理用例错误
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
impl AdminErrorKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Invalid => "invalid",
Self::Forbidden => "forbidden",
Self::Unauthorized => "unauthorized",
Self::NotFound => "not_found",
Self::Conflict => "conflict",
Self::RateLimited => "rate_limited",
Self::BadGateway => "bad_gateway",
Self::UpstreamResultUnknown => "upstream_result_unknown",
Self::Unavailable => "unavailable",
Self::Internal => "internal",
}
}
}
/// 管理用例的安全消息与内部原因分离,原始来源不进入响应
#[derive(Debug, Clone, thiserror::Error)]
#[error("{message}")]
pub struct AdminError {
kind: AdminErrorKind,
message: String,
source: Option<gateway_core::error::ErrorSource>,
}
impl AdminError {
@@ -53,9 +72,21 @@ impl AdminError {
Self {
kind,
message: message.into(),
source: None,
}
}
#[must_use]
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
self.source = Some(source.into());
self
}
#[must_use]
pub fn error_details(&self) -> Option<gateway_core::error::ErrorDetails> {
gateway_core::error::ErrorDetails::capture(self.source.as_ref(), None, false)
}
#[must_use]
pub const fn kind(&self) -> AdminErrorKind {
self.kind
@@ -184,6 +215,12 @@ pub enum AdminModelError {
ZeroRevision,
#[error("page size {0} is outside 1..=200")]
InvalidPageSize(u16),
#[error("observability page size {0} is outside 1..=100")]
InvalidObservabilityPageSize(u16),
#[error("account group name or description is invalid")]
InvalidAccountGroupFields,
#[error("client key group IDs are duplicated or exceed 1000 entries")]
InvalidClientKeyGroups,
#[error("client key page size must be inside 1..=65535")]
InvalidClientKeyPageSize,
#[error("request outcome must be 1..=256 bytes without control characters")]
@@ -1,10 +1,32 @@
//! 用量、成本、健康与错误诊断的 UTC 语义事实
use std::str::FromStr;
use std::{num::NonZeroU16, str::FromStr};
use chrono::{DateTime, TimeDelta, Utc};
use super::{AdminModelError, PageSize};
use super::AdminModelError;
/// 观测记录查询的页大小,由 HTTP、管理用例和存储端口共同使用
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ObservabilityPageSize(NonZeroU16);
impl ObservabilityPageSize {
pub const MAX: u16 = 100;
pub fn new(value: u16) -> Result<Self, AdminModelError> {
if value > Self::MAX {
return Err(AdminModelError::InvalidObservabilityPageSize(value));
}
NonZeroU16::new(value)
.map(Self)
.ok_or(AdminModelError::InvalidObservabilityPageSize(value))
}
#[must_use]
pub const fn get(self) -> u16 {
self.0.get()
}
}
/// 页面筛选表达自然日范围,具体 UTC 边界由部署时区解析
#[derive(Debug, Clone, Copy)]
@@ -152,7 +174,33 @@ pub struct UsageQuery {
pub range: TimeRange,
pub filter: UsageFilter,
pub current_page: u32,
pub page_size: PageSize,
pub page_size: ObservabilityPageSize,
}
/// Admin 确定概览的数量、趋势粒度及最近请求筛选,Store 只执行该选择
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardQuery {
pub range: TimeRange,
pub granularity: Granularity,
pub account_limit: u16,
pub recent_request_limit: u16,
pub recent_request_filter: UsageFilter,
}
impl DashboardQuery {
#[must_use]
pub fn new(range: TimeRange) -> Self {
Self {
range,
granularity: Granularity::for_range(range),
account_limit: 4,
recent_request_limit: 10,
recent_request_filter: UsageFilter {
outcome: Some(RequestOutcome::Succeeded),
..UsageFilter::default()
},
}
}
}
/// 运维错误过滤条件
@@ -178,7 +226,7 @@ pub struct OpsErrorQuery {
pub range: TimeRange,
pub filter: OpsErrorFilter,
pub current_page: u32,
pub page_size: PageSize,
pub page_size: ObservabilityPageSize,
}
/// 用量诊断维度
@@ -220,7 +268,7 @@ impl DecimalAmount {
}
fn from_decimal(value: gateway_core::metering::Decimal) -> Option<Self> {
Some(Self(value.canonical()))
Self::from_str(&value.canonical()).ok()
}
}
@@ -455,6 +503,30 @@ pub enum Granularity {
Day,
}
impl Granularity {
/// 管理趋势按范围选择粒度,费用校验与请求指标必须使用同一策略
#[must_use]
pub fn for_range(range: TimeRange) -> Self {
let seconds = range.end.signed_duration_since(range.start).num_seconds();
if seconds <= 2 * 24 * 60 * 60 {
Self::FifteenMinutes
} else if seconds <= 31 * 24 * 60 * 60 {
Self::Hour
} else {
Self::Day
}
}
#[must_use]
pub const fn seconds(self) -> i64 {
match self {
Self::FifteenMinutes => 15 * 60,
Self::Hour => 60 * 60,
Self::Day => 24 * 60 * 60,
}
}
}
/// 一段时间桶内的请求指标
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestMetricPoint {
@@ -466,7 +538,7 @@ pub struct RequestMetricPoint {
}
/// 账号池的统一五态统计
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AccountPoolMetrics {
pub total: u64,
pub normal: u64,
@@ -476,9 +548,9 @@ pub struct AccountPoolMetrics {
pub error: u64,
}
/// Dashboard 中一个账号的模型级用量事实
/// 账号在查询时间范围内的模型级用量事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardAccountModelUsage {
pub struct AccountModelUsage {
pub model: String,
pub request_count: u64,
pub success_count: u64,
@@ -497,9 +569,9 @@ pub struct DashboardAccountModelUsage {
pub last_used_at: DateTime<Utc>,
}
/// Dashboard 中账号的单小时请求数
/// 账号在单小时窗口内的请求数
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardAccountRequestBucket {
pub struct AccountRequestBucket {
pub bucket_start: DateTime<Utc>,
pub request_count: u64,
}
@@ -530,14 +602,14 @@ pub struct DashboardAccountUsage {
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCost>,
pub last_used_at: Option<DateTime<Utc>>,
pub request_buckets: Vec<DashboardAccountRequestBucket>,
pub request_buckets: Vec<AccountRequestBucket>,
/// Provider 已持久化额度窗口投影出的代表性已用比例
///
/// `None` 表示上游未提供可比较的百分比,不应伪造为零
pub quota_used_percent: Option<f64>,
/// Provider 返回的代表窗口;缺失时保留未知语义
pub quota_window: Option<super::provider_credentials::ProviderQuotaWindow>,
pub models: Vec<DashboardAccountModelUsage>,
pub models: Vec<AccountModelUsage>,
}
/// Dashboard 当前账号池的可重建运行时槽位事实
@@ -552,7 +624,7 @@ pub struct DashboardRuntimeSlots {
}
/// 仪表盘卡片脚注展示的全历史累计
#[derive(Debug, Clone, Default, PartialEq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DashboardTotals {
pub request_count: u64,
pub input_tokens: u64,
@@ -567,13 +639,14 @@ pub struct DashboardObservation {
pub range: TimeRange,
pub totals: DashboardTotals,
pub provider_accounts: AccountPoolMetrics,
pub runtime_slots: Option<DashboardRuntimeSlots>,
pub trend: Vec<RequestMetricPoint>,
pub account_usage: Vec<DashboardAccountUsage>,
pub recent_requests: Vec<UsageListRecord>,
}
/// 使用记录表格的窄读模型
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct UsageListRecord {
pub client_api_key_name: Option<String>,
pub id: String,
@@ -614,6 +687,13 @@ pub struct UsageListRecord {
pub first_text_ms: Option<u64>,
pub first_token_ms: Option<u64>,
pub provider_processing_ms: Option<u64>,
pub upstream_response_ms: Option<u64>,
pub upstream_api_overhead_ms: Option<f64>,
pub upstream_engine_ms: Option<f64>,
pub upstream_engine_iapi_ttft_ms: Option<f64>,
pub upstream_engine_service_ttft_ms: Option<f64>,
pub upstream_engine_iapi_tbt_ms: Option<f64>,
pub upstream_engine_service_tbt_ms: Option<f64>,
pub latency_ms: Option<u64>,
pub admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
@@ -629,7 +709,7 @@ pub struct UsageListRecord {
}
/// 一次完整模型请求的公共观测记录
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct UsageRecord {
pub id: String,
pub client_api_key_ref: String,
@@ -688,6 +768,13 @@ pub struct UsageRecord {
pub first_text_ms: Option<u64>,
pub first_token_ms: Option<u64>,
pub provider_processing_ms: Option<u64>,
pub upstream_response_ms: Option<u64>,
pub upstream_api_overhead_ms: Option<f64>,
pub upstream_engine_ms: Option<f64>,
pub upstream_engine_iapi_ttft_ms: Option<f64>,
pub upstream_engine_service_ttft_ms: Option<f64>,
pub upstream_engine_iapi_tbt_ms: Option<f64>,
pub upstream_engine_service_tbt_ms: Option<f64>,
pub latency_ms: Option<u64>,
pub admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
@@ -708,7 +795,7 @@ pub struct UsageRecord {
}
/// 用量分页结果
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct UsagePage {
pub items: Vec<UsageListRecord>,
pub current_page: u32,
@@ -754,7 +841,7 @@ pub struct UsageAttempt {
}
/// 一条请求及其全部尝试
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct UsageDetail {
pub trace: Option<serde_json::Value>,
pub related_requests: Vec<serde_json::Value>,
@@ -851,7 +938,7 @@ pub struct OpsError {
pub upstream_request_id: Option<String>,
pub latency_ms: Option<u64>,
pub message: String,
pub raw_upstream_error: Option<String>,
pub error_details: Option<String>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
@@ -862,7 +862,7 @@ pub struct ProviderSubscription {
}
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone)]
pub struct AccountPersonalInfo {
pub profile: Result<ProviderProfileStatistics, AdminError>,
pub subscription: Option<ProviderSubscription>,
@@ -17,8 +17,26 @@ pub struct RetentionPolicy {
}
impl RetentionPolicy {
/// 保留期管理与全量设置保存共用的字段约束
pub fn validate_values(
usage_days: u32,
ops_days: u32,
audit_days: u32,
) -> Result<(), &'static str> {
for (valid, field) in [
(usage_days >= 31, "usage_retention_days"),
(ops_days > 0, "ops_event_retention_days"),
(audit_days > 0, "audit_retention_days"),
] {
if !valid {
return Err(field);
}
}
Ok(())
}
pub fn try_new(usage_days: u32, ops_days: u32, audit_days: u32) -> Result<Self, AdminError> {
if usage_days < 31 || ops_days == 0 || audit_days == 0 {
if Self::validate_values(usage_days, ops_days, audit_days).is_err() {
return Err(AdminError::invalid(
"请求保留期至少 31 天,事件与审计保留期必须为正数",
));
@@ -11,6 +11,26 @@ use super::Revision;
/// 客户端模型到上游模型的全局精确映射
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
/// 全局模型映射的数量与文本约束;调用方保留各自的键值表示
pub fn validate_model_mappings<'a>(
mappings: impl ExactSizeIterator<Item = (&'a str, &'a str)>,
) -> Result<(), &'static str> {
if mappings.len() > 512 {
return Err("model_mappings");
}
for (requested, upstream) in mappings {
for value in [requested, upstream] {
if value.is_empty()
|| value.len() > 256
|| value.bytes().any(|byte| byte.is_ascii_control())
{
return Err("model_mappings");
}
}
}
Ok(())
}
/// Provider-owned 请求画像选择;Provider ID 是唯一命名空间
pub type ProviderRequestProfiles =
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
@@ -22,15 +42,11 @@ pub type ProviderRequestProfileUpdates =
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射
pub use gateway_core::account::RotationStrategy;
/// 完整运行设置事实
/// 运行设置的共同值;版本、Provider 画像更新和数据库秘密由外层类型拥有
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeSettings {
pub request_profiles: ProviderRequestProfiles,
pub config_revision: Revision,
pub struct RuntimeSettingsValues {
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,
@@ -39,9 +55,11 @@ pub struct RuntimeSettings {
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
pub max_account_rotations: u32,
pub openai_session_affinity_ttl_hours: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
@@ -57,6 +75,18 @@ pub struct RuntimeSettings {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
}
/// 完整运行设置事实
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeSettings {
#[serde(flatten)]
pub values: RuntimeSettingsValues,
pub request_profiles: ProviderRequestProfiles,
pub config_revision: Revision,
pub model_mappings: ModelMappings,
pub rotation_strategy: RotationStrategy,
pub updated_at: DateTime<Utc>,
}
@@ -64,39 +94,14 @@ pub struct RuntimeSettings {
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReplaceRuntimeSettings {
#[serde(flatten)]
pub values: RuntimeSettingsValues,
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新
pub expected_revision: Revision,
/// 只覆盖提交的 Provider;未提交项保留当前持久值
pub request_profile_updates: ProviderRequestProfileUpdates,
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 openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
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 account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
}
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏
@@ -174,36 +179,63 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
.into_iter()
.map(|(provider, value)| (provider, Some(value)))
.collect(),
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
values: settings.values,
model_mappings: settings.model_mappings,
refresh_margin_seconds: settings.refresh_margin_seconds,
refresh_concurrency: settings.refresh_concurrency,
max_concurrent_per_account: 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,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
rotation_strategy: settings.rotation_strategy,
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: settings.usage_retention_days,
ops_event_retention_days: settings.ops_event_retention_days,
audit_retention_days: settings.audit_retention_days,
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
account_auto_freeze_threshold: 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,
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
}
}
}
impl RuntimeSettingsValues {
/// 校验共同事实,返回字段名供边界转换错误;SQL 类型转换仍由 Store 处理
pub fn validate(&self) -> Result<(), &'static str> {
self.request_location
.validate()
.map_err(|_| "request_location")?;
gateway_core::settings::validate_request_limits(
self.max_waiting_per_key,
self.max_waiting_per_account,
self.concurrency_wait_timeout_seconds,
self.max_account_rotations,
self.openai_session_affinity_ttl_hours,
)?;
gateway_core::settings::response_body_limit(self.responses_max_decompressed_body_bytes)?;
gateway_core::settings::client_min_versions(
self.min_codex_desktop_version.as_deref(),
self.min_codex_cli_version.as_deref(),
)?;
for (valid, field) in [
(
self.refresh_margin_seconds > 0
&& i64::try_from(self.refresh_margin_seconds).is_ok(),
"refresh_margin_seconds",
),
(self.refresh_concurrency > 0, "refresh_concurrency"),
(
i64::try_from(self.request_interval_ms).is_ok(),
"request_interval_ms",
),
] {
if !valid {
return Err(field);
}
}
super::retention::RetentionPolicy::validate_values(
self.usage_retention_days,
self.ops_event_retention_days,
self.audit_retention_days,
)?;
gateway_core::provider_ports::ProviderFreezePolicy::validate_values(
self.account_auto_freeze_threshold,
self.account_auto_freeze_window_seconds,
self.account_auto_freeze_duration_seconds,
self.account_auto_freeze_probe_model.as_deref(),
)?;
gateway_core::provider_ports::ProviderWarmupPolicy::validate_values(
self.account_warmup_enabled,
&self.account_warmup_schedule_time,
self.account_warmup_model.as_deref(),
)?;
Ok(())
}
}
@@ -1,7 +1,7 @@
//! 插件管理页面、资源访问与授权回调的运行时端口
use async_trait::async_trait;
use gateway_core::runtime::extensions::ExtensionSetReference;
use gateway_core::routing::extensions::ExtensionSetReference;
use crate::model::{
AdminError,
@@ -26,7 +26,7 @@ use crate::model::{
PluginCompatibilityRequirements, PluginSource,
},
};
use gateway_core::runtime::extensions::ExtensionSetReference;
use gateway_core::routing::extensions::ExtensionSetReference;
/// Admin 准备完整候选并保活到提交后的发布结束;进程状态不能代替持久启用状态
#[async_trait]
@@ -175,12 +175,13 @@ pub trait PluginDistribution: Send + Sync {
/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin
#[async_trait]
pub trait PluginPackageInspector: Send + Sync {
/// 可解析但版本范围未经宿主承诺的诊断,不决定启动资格
/// 完整校验包并返回版本范围诊断;校验失败与可加载但未经宿主承诺的提醒分开
async fn compatibility_warning(
&self,
_archive: Arc<[u8]>,
_expected_sha256: String,
archive: Arc<[u8]>,
expected_sha256: String,
) -> Result<Option<String>, AdminError> {
self.inspect(archive, Some(expected_sha256)).await?;
Ok(None)
}
@@ -46,11 +46,12 @@ pub enum ProviderAdminErrorKind {
/// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案
/// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在
/// `public_message` 则是明确标记为可公开的静态提示,不允许携带动态上游材料
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
#[derive(Clone, thiserror::Error)]
#[error("provider admin operation failed: {kind:?}")]
pub struct ProviderAdminError {
kind: ProviderAdminErrorKind,
message: Option<String>,
#[source]
diagnostic: Option<Arc<ProviderAdminDiagnostic>>,
public_message: Option<&'static str>,
}
@@ -59,7 +60,7 @@ impl std::fmt::Debug for ProviderAdminError {
formatter
.debug_struct("ProviderAdminError")
.field("kind", &self.kind)
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
.field("message", &self.message().map(|_| "<redacted>"))
.field("public_message", &self.public_message)
.finish()
}
@@ -70,14 +71,22 @@ impl ProviderAdminError {
pub const fn new(kind: ProviderAdminErrorKind) -> Self {
Self {
kind,
message: None,
diagnostic: None,
public_message: None,
}
}
#[must_use]
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
Arc::make_mut(self.diagnostic.get_or_insert_with(Default::default)).source =
Some(source.into());
self
}
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
Arc::make_mut(self.diagnostic.get_or_insert_with(Default::default)).message =
Some(message.into());
self
}
@@ -100,10 +109,20 @@ impl ProviderAdminError {
#[must_use]
pub fn message(&self) -> Option<&str> {
self.message.as_deref()
self.diagnostic
.as_ref()
.and_then(|diagnostic| diagnostic.message.as_deref())
}
}
// Provider 局部消息与 typed 来源共用一个不公开的上下文,避免跨用例转换后丢失
#[derive(Debug, Clone, Default, thiserror::Error)]
#[error("{}", message.as_deref().unwrap_or("provider admin dependency failed"))]
struct ProviderAdminDiagnostic {
message: Option<String>,
source: Option<gateway_core::error::ErrorSource>,
}
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力
///
/// 数据变更由 Provider 返回 prepared facts;config revision、审计与 PostgreSQL 事务
+47 -18
View File
@@ -12,8 +12,9 @@ use super::backup::BackupStorePorts;
use crate::model::{
MutationContext, Revision,
account_groups::{
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage,
DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation,
AccountGroupOptionsPage, AccountGroupPage, DeleteAccountGroup, NewAccountGroup,
SetAccountGroupEnabled, UpdateAccountGroup,
},
accounts::{
AccountListQuery, AccountPage, AccountPageItem, AccountRuntimeSnapshot,
@@ -27,9 +28,9 @@ use crate::model::{
UpdateClientKey, UpdateClientKeyBudgetLimits,
},
observability::{
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticsObservation,
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
DashboardObservation, DashboardQuery, DiagnosticDimension, DiagnosticsObservation,
Granularity, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
},
provider_credentials::{
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
@@ -52,12 +53,13 @@ pub enum AdminStoreErrorKind {
}
/// 隐藏数据库实现细节的持久化错误
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[derive(Debug, Clone, thiserror::Error)]
#[error("{resource} store operation failed: {message}")]
pub struct AdminStoreError {
kind: AdminStoreErrorKind,
resource: &'static str,
message: String,
source: Option<gateway_core::error::ErrorSource>,
}
impl AdminStoreError {
@@ -71,9 +73,27 @@ impl AdminStoreError {
kind,
resource,
message: message.into(),
source: None,
}
}
/// 来源留在内部诊断链,公开消息不展开基础设施错误
#[must_use]
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
self.source = Some(source.into());
self
}
/// 清理失败不覆盖最初操作的分类与原因
#[must_use]
pub fn with_cleanup(mut self, cleanup: impl Into<gateway_core::error::ErrorSource>) -> Self {
self.source = Some(gateway_core::error::ErrorSource::cleanup(
self.source.take(),
cleanup,
));
self
}
#[must_use]
pub const fn kind(&self) -> AdminStoreErrorKind {
self.kind
@@ -274,6 +294,14 @@ pub trait AuthStore: Send + Sync {
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()>;
/// 只延长仍存在且匹配的会话;并发续期返回当前值,已退出或过期时不重建
async fn renew_session(
&self,
session_id: &str,
expected: &AuthSession,
expires_at: chrono::DateTime<chrono::Utc>,
) -> AdminStoreResult<Option<AuthSession>>;
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>>;
async fn client_key_enabled(
@@ -358,6 +386,11 @@ pub trait AccountGroupStore: Send + Sync {
query: AccountGroupListQuery,
) -> AdminStoreResult<AccountGroupPage>;
async fn list_account_group_options(
&self,
query: AccountGroupListQuery,
) -> AdminStoreResult<AccountGroupOptionsPage>;
async fn load_account_group_members(
&self,
group_ids: &[gateway_core::routing::AccountGroupId],
@@ -398,27 +431,21 @@ pub trait ObservabilityStore: Send + Sync {
/// 返回历史统计区间和指定观测时刻下的实时账号状态
async fn dashboard_summary(
&self,
range: TimeRange,
query: DashboardQuery,
observed_at: DateTime<Utc>,
) -> AdminStoreResult<DashboardObservation>;
/// 返回 Dashboard 可选的实时槽位事实
///
/// 该状态来自可丢失的运行时存储;无实现或运行时存储不可用时返回 `None`,不影响
/// 持久观测数据的读取
async fn dashboard_runtime_slots(
async fn dashboard_trend(
&self,
_observed_at: DateTime<Utc>,
) -> AdminStoreResult<Option<DashboardRuntimeSlots>> {
Ok(None)
}
async fn dashboard_trend(&self, range: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>>;
range: TimeRange,
granularity: Granularity,
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
async fn usage_trend(
&self,
range: TimeRange,
filter: UsageFilter,
granularity: Granularity,
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
/// 流式返回可由 Provider 重新校验的已计算费用事实,不保证顺序
@@ -427,6 +454,7 @@ pub trait ObservabilityStore: Send + Sync {
&self,
range: TimeRange,
filter: UsageFilter,
granularity: Granularity,
) -> UsageCalculatedBillingStream<'_>;
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage>;
@@ -444,6 +472,7 @@ pub trait ObservabilityStore: Send + Sync {
range: TimeRange,
filter: UsageFilter,
dimension: DiagnosticDimension,
limit: u16,
) -> AdminStoreResult<DiagnosticsObservation>;
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
@@ -8,11 +8,11 @@ use std::{future::Future, sync::Arc};
use futures::future::BoxFuture;
use gateway_core::{
engine::middleware::FrozenMiddlewarePlan,
middleware::{
compose,
engine::middleware::{
FrozenMiddlewarePlan,
service::{Context, Error, Value},
},
middleware::compose,
};
use crate::model::{AdminError, AdminErrorKind};
@@ -2,7 +2,7 @@
use crate::model::AdminError;
use futures::future::BoxFuture;
use gateway_core::middleware::service::{Error, Value};
use gateway_core::engine::middleware::service::{Error, Value};
use serde::{Serialize, de::DeserializeOwned};
use std::{collections::BTreeMap, sync::Arc};
@@ -19,9 +19,9 @@ use crate::{
AdminError, MutationContext,
account_groups::{
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupListQuery,
AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage, AccountGroupRecord,
CreateAccountGroup, DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled,
UpdateAccountGroup,
AccountGroupMemberFact, AccountGroupMutation, AccountGroupOptionsPage,
AccountGroupPage, AccountGroupRecord, CreateAccountGroup, DeleteAccountGroup,
NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
},
},
ports::store::{AccountGroupStore, AccountRuntimeStore},
@@ -33,6 +33,10 @@ use super::{map_store_error, publish_committed};
#[async_trait]
pub trait AccountGroupService: Send + Sync {
async fn list(&self, query: AccountGroupListQuery) -> Result<AccountGroupPage, AdminError>;
async fn list_options(
&self,
query: AccountGroupListQuery,
) -> Result<AccountGroupOptionsPage, AdminError>;
async fn create(
&self,
context: &MutationContext,
@@ -130,11 +134,26 @@ impl AccountGroupService for DefaultAccountGroupService {
Ok(page)
}
async fn list_options(
&self,
query: AccountGroupListQuery,
) -> Result<AccountGroupOptionsPage, AdminError> {
self.store
.list_account_group_options(query)
.await
.map_err(|error| map_store_error(error, "account group options"))
}
async fn create(
&self,
context: &MutationContext,
command: CreateAccountGroup,
) -> Result<AccountGroupMutation, AdminError> {
crate::model::account_groups::validate_group_fields(
&command.name,
command.description.as_deref(),
)
.map_err(|_| AdminError::invalid("账号组请求不合法"))?;
let id = AccountGroupId::new(format!("grp_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建账号组 ID 失败"))?;
self.publish(
@@ -159,6 +178,11 @@ impl AccountGroupService for DefaultAccountGroupService {
context: &MutationContext,
command: UpdateAccountGroup,
) -> Result<AccountGroupMutation, AdminError> {
crate::model::account_groups::validate_group_fields(
&command.name,
command.description.as_deref(),
)
.map_err(|_| AdminError::invalid("账号组请求不合法"))?;
self.publish(self.store.update_account_group(command, context).await)
.await
}
@@ -36,6 +36,10 @@ pub trait AuthService: Send + Sync {
) -> 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 renew_session(
&self,
session_id: Option<&str>,
) -> Result<Option<AuthSession>, AdminError>;
async fn resolve_admin_user_id(
&self,
session_id: Option<&str>,
@@ -58,6 +62,7 @@ const LOGIN_ATTEMPTS_GLOBAL: u32 = 200;
pub(crate) struct DefaultAuthService {
default_admin_user_id: String,
admin_session_ttl: Duration,
admin_session_absolute_ttl: Duration,
key_session_ttl: Duration,
store: Arc<dyn AuthStore>,
verifier: Arc<dyn ClientKeyVerifier>,
@@ -68,6 +73,7 @@ impl DefaultAuthService {
pub(crate) fn new(
default_admin_user_id: impl Into<String>,
admin_session_ttl_minutes: u64,
admin_session_absolute_ttl_minutes: u64,
key_session_ttl_minutes: u64,
store: Arc<dyn AuthStore>,
verifier: Arc<dyn ClientKeyVerifier>,
@@ -75,6 +81,7 @@ impl DefaultAuthService {
Self {
default_admin_user_id: default_admin_user_id.into(),
admin_session_ttl: session_ttl(admin_session_ttl_minutes),
admin_session_absolute_ttl: session_ttl(admin_session_absolute_ttl_minutes),
key_session_ttl: session_ttl(key_session_ttl_minutes),
store,
verifier,
@@ -232,8 +239,12 @@ impl AuthService for DefaultAuthService {
else {
return Ok(None);
};
if session.expires_at <= Utc::now() {
let _ = self.store.delete_session(session_id).await;
if session.expires_at <= Utc::now()
|| session
.absolute_expires_at
.is_some_and(|expiry| expiry <= Utc::now())
{
// Redis 的 TTL 负责过期清理,避免旧读取删除另一个请求刚续期的会话
return Ok(None);
}
if let SessionSubject::Admin {
@@ -266,6 +277,37 @@ impl AuthService for DefaultAuthService {
Ok(Some(session))
}
async fn renew_session(
&self,
session_id: Option<&str>,
) -> Result<Option<AuthSession>, AdminError> {
let Some(session_id) = session_id else {
return Ok(None);
};
let Some(session) = self.session(Some(session_id)).await? else {
return Ok(None);
};
// Key 与升级前的会话保留原有期限,不能凭续期获得新的最长登录窗口
let Some(absolute) = session
.absolute_expires_at
.filter(|_| matches!(session.subject, SessionSubject::Admin { .. }))
else {
return Ok(Some(session));
};
let now = Utc::now();
let expires_at = (now + self.admin_session_ttl).min(absolute);
let interval = (self.admin_session_ttl / 4).min(Duration::minutes(1));
if expires_at <= session.expires_at
|| session.expires_at - now > self.admin_session_ttl - interval
{
return Ok(Some(session));
}
self.store
.renew_session(session_id, &session, expires_at)
.await
.map_err(|error| map_store_error(error, "authentication session renewal"))
}
async fn resolve_admin_user_id(
&self,
session_id: Option<&str>,
@@ -325,9 +367,13 @@ impl AuthService for DefaultAuthService {
SessionSubject::Admin { .. } => self.admin_session_ttl,
SessionSubject::Key { .. } => self.key_session_ttl,
};
let now = Utc::now();
let absolute_expires_at = matches!(subject, SessionSubject::Admin { .. })
.then_some(now + self.admin_session_absolute_ttl);
let session = AuthSession {
subject,
expires_at: Utc::now() + ttl,
expires_at: (now + ttl).min(absolute_expires_at.unwrap_or(now + ttl)),
absolute_expires_at,
};
let session_id = random_session_token();
self.store
@@ -157,6 +157,8 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError> {
crate::model::client_keys::validate_group_ids(&command.group_ids)
.map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?;
for (provider, profile) in &command.request_profile_overrides {
self.providers
.require(provider)
@@ -199,6 +201,8 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: UpdateClientKey,
) -> Result<ClientKeyMutation, AdminError> {
crate::model::client_keys::validate_group_ids(&command.group_ids)
.map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?;
for (provider, profile) in &command.request_profile_override_updates {
if let Some(profile) = profile {
self.providers
@@ -0,0 +1,371 @@
//! 凭据变更共享的绑定校验、事务结算、失效与提交后观察
use crate::{
model::{
AdminError, MutationContext,
accounts::DeleteAccounts,
provider_credentials::{
AuthorizationMutationTarget, CredentialDeletion, CredentialDeletionResult,
CredentialDetails, CredentialMutationResult, CredentialRotationCommit,
PendingAuthorizationMutation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
ProviderQuotaRequest, StartAuthorization,
},
},
ports::{provider::ProviderAdmin, store::AccountStore},
};
use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::SnapshotControl};
use super::{map_store_error, publish_committed};
pub(super) async fn publish_credentials_and_observe_quota(
provider: &std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
account_ids: &[ProviderAccountId],
request_id: &str,
) -> Result<(), AdminError> {
provider.account_facts_changed(account_ids).await;
publish_committed(snapshot, revision).await?;
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果
let provider = provider.clone();
let account_ids = account_ids.to_vec();
let request_id = request_id.to_owned();
tokio::spawn(async move {
for account_id in account_ids {
if let Err(error) = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await
{
tracing::warn!(
request_id,
provider = %provider.provider_kind().as_str(),
account_id = %account_id.as_str(),
quota_error = ?error.kind(),
"initial quota observation failed"
);
}
}
});
Ok(())
}
pub(super) async fn required_credential(
accounts: &dyn AccountStore,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
resource: &'static str,
) -> Result<CredentialDetails, AdminError> {
accounts
.credential_details(provider_kind, account_id)
.await
.map_err(|error| map_store_error(error, resource))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
pub(super) async fn required_plugin_credential(
accounts: &dyn AccountStore,
account_id: &ProviderAccountId,
resource: &'static str,
) -> Result<CredentialDetails, AdminError> {
accounts
.credential_details_by_id(account_id)
.await
.map_err(|error| map_store_error(error, resource))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
pub(super) async fn import_proxy_binding(
proxies: &dyn crate::ports::proxy::ProxyStore,
id: Option<&str>,
) -> Result<Option<crate::ports::proxy::ProxyImportReservation>, AdminError> {
let Some(id) = id else { return Ok(None) };
let reservation = proxies
.reserve_import(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
Ok(Some(reservation))
}
pub(super) async fn pending_authorization(
accounts: &dyn AccountStore,
proxies: &dyn crate::ports::proxy::ProxyStore,
provider_kind: &ProviderKind,
command: &StartAuthorization,
resource: &'static str,
) -> Result<PendingAuthorizationMutation, AdminError> {
let proxy;
let mut proxy_id = None;
let target = match &command.reauthorization {
Some(account_id) => {
proxy = required_credential(accounts, provider_kind, account_id, resource)
.await?
.credential
.outbound_proxy;
AuthorizationMutationTarget::Reauthorize {
account_id: account_id.clone(),
}
}
None => {
use crate::model::proxies::AccountProxySelection;
proxy = match &command.outbound_proxy {
Some(AccountProxySelection::Url(value)) => Some(value.clone()),
Some(AccountProxySelection::Saved(id)) => {
let record = proxies
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
proxy_id = Some(record.id);
Some(record.proxy)
}
Some(AccountProxySelection::Direct) | None => None,
};
AuthorizationMutationTarget::Create {
name: command.name.clone(),
}
}
};
Ok(PendingAuthorizationMutation::new(
provider_kind.clone(),
target,
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
&command.context,
),
)
.with_outbound_proxy(proxy)
.with_outbound_proxy_id(proxy_id))
}
pub(super) fn validate_prepared_import(
provider_kind: &ProviderKind,
prepared: &PreparedCredentialImport,
_resource: &'static str,
) -> Result<(), AdminError> {
if prepared.provider_kind != *provider_kind
|| prepared
.credentials
.iter()
.any(|credential| credential.provider_kind != *provider_kind)
{
return Err(AdminError::conflict("Provider 准备结果与请求范围不一致"));
}
Ok(())
}
pub(super) fn validate_prepared_rotation(
account: &crate::model::accounts::AccountRecord,
prepared: &PreparedCredentialRotation,
_resource: &'static str,
) -> Result<(), AdminError> {
validate_prepared_rotation_facts(account, prepared.facts())
}
pub(super) fn validate_prepared_rotation_facts(
account: &crate::model::accounts::AccountRecord,
facts: &crate::model::provider_credentials::PreparedCredentialRotationFacts,
) -> Result<(), AdminError> {
if facts.account_id.as_str() != account.id.as_str()
|| facts.provider_kind != account.provider_kind
{
return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致"));
}
Ok(())
}
pub(super) async fn validate_authorization_commit(
provider_kind: &ProviderKind,
context: &MutationContext,
prepared: PreparedAuthorizationCommit,
resource: &'static str,
) -> Result<PreparedAuthorizationCommit, AdminError> {
if prepared.pending.provider_kind() != provider_kind
|| !prepared.pending.owner_binding().matches_context(context)
{
let validation_error = AdminError::conflict("OAuth 授权绑定已失效,请重新发起授权");
if let Err(error) = prepared.abort().await {
tracing::warn!(
resource,
settlement_error = %error,
"OAuth authorization claim release failed after preparation validation"
);
return Err(error);
}
return Err(validation_error);
}
let matches_target = match (prepared.pending.target(), &prepared.credential) {
(
AuthorizationMutationTarget::Create { .. },
PreparedAuthorizationCredential::Create(credential),
) => credential.provider_kind == *provider_kind,
(
AuthorizationMutationTarget::Reauthorize { account_id },
PreparedAuthorizationCredential::Reauthorize(credential),
) => {
let facts = credential.facts();
facts.provider_kind == *provider_kind && facts.account_id == *account_id
}
_ => false,
};
if !matches_target {
let validation_error = AdminError::conflict("OAuth 凭据与待提交目标不一致");
if let Err(error) = prepared.abort().await {
tracing::warn!(
resource,
settlement_error = %error,
"OAuth authorization claim release failed after preparation validation"
);
return Err(error);
}
return Err(validation_error);
}
Ok(prepared)
}
pub(super) async fn commit_authorization(
accounts: &dyn AccountStore,
prepared: PreparedAuthorizationCommit,
key: crate::model::provider_credentials::AuthorizationReceiptKey,
settings: Option<crate::model::accounts::AccountImportSettings>,
context: &MutationContext,
resource: &'static str,
) -> Result<crate::model::provider_credentials::AuthorizationCommitResult, AdminError> {
if settings.is_some()
&& matches!(
&prepared.credential,
PreparedAuthorizationCredential::Reauthorize(_)
)
{
prepared.abort().await?;
return Err(AdminError::invalid("重新授权不能修改账号设置"));
}
let crate::model::provider_credentials::AuthorizationCommitSettlement {
command,
credential_guard,
authorization_guard,
} = prepared.into_commit(settings, key);
match accounts.commit_authorization(command, context).await {
Ok(result) => {
if let Some(guard) = credential_guard
&& result.newly_committed
{
guard.finish();
}
if let Some(guard) = authorization_guard
&& let Err(error) = guard.commit().await
{
// 账号与回执已经原子提交,Redis 清理失败不能把确定的成功改写为失败
tracing::warn!(resource, settlement_error = %error, "authorization committed but pending cleanup failed");
}
Ok(result)
}
Err(error) => {
drop(credential_guard);
if let Some(guard) = authorization_guard
&& let Err(settlement_error) = guard.abort().await
{
tracing::warn!(
resource,
store_error = %error,
settlement_error = %settlement_error,
"OAuth authorization claim release failed after Store commit failure"
);
return Err(settlement_error);
}
Err(map_store_error(error, resource))
}
}
}
pub(super) 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,
settings,
},
context,
)
.await
{
Ok(result) => {
guard.finish();
Ok(result)
}
Err(error) => {
drop(guard);
Err(map_store_error(error, resource))
}
}
}
pub(super) async fn commit_credential_refresh(
accounts: &dyn AccountStore,
prepared: PreparedCredentialRotation,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_refresh(
CredentialRotationCommit {
prepared: facts,
settings: None,
},
context,
)
.await
{
Ok(result) => {
guard.finish();
Ok(result)
}
Err(error) => {
drop(guard);
Err(map_store_error(error, resource))
}
}
}
pub(super) async fn delete_credentials(
accounts: &dyn AccountStore,
provider: &dyn ProviderAdmin,
command: CredentialDeletion,
resource: &'static str,
) -> Result<CredentialDeletionResult, AdminError> {
for account_id in &command.account_ids {
required_credential(accounts, provider.provider_kind(), account_id, resource).await?;
}
let account_ids = command.account_ids;
let revision = accounts
.delete_accounts(
DeleteAccounts {
account_ids: account_ids
.iter()
.map(|account_id| account_id.as_str().to_owned())
.collect(),
},
&command.context,
)
.await
.map_err(|error| map_store_error(error, resource))?;
for account_id in &account_ids {
provider.account_unavailable(account_id).await;
}
provider.account_facts_changed(&account_ids).await;
Ok(CredentialDeletionResult {
config_revision: revision,
account_ids,
})
}
@@ -0,0 +1,67 @@
//! 管理端口错误到用例错误的统一分类与来源保留
use crate::model::{AdminError, AdminErrorKind};
use crate::ports::store::{AdminStoreError, AdminStoreErrorKind};
pub(super) fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError {
let kind = match error.kind() {
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
AdminStoreErrorKind::StaleRevision
| AdminStoreErrorKind::DuplicateName
| AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict,
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
};
let message = match kind {
AdminErrorKind::Invalid => "请求参数不合法",
AdminErrorKind::NotFound => "请求的资源不存在",
AdminErrorKind::Conflict => "当前资源状态冲突,请刷新后重试",
AdminErrorKind::Unavailable => "依赖服务暂不可用",
_ => "服务内部错误",
};
AdminError::new(kind, message).with_source(OperationFailure {
operation: resource,
source: error.into(),
})
}
pub(super) fn map_provider_error(
error: crate::ports::provider::ProviderAdminError,
resource: &'static str,
) -> AdminError {
use crate::ports::provider::ProviderAdminErrorKind;
let kind = match error.kind() {
ProviderAdminErrorKind::Invalid => AdminErrorKind::Invalid,
ProviderAdminErrorKind::Unsupported => AdminErrorKind::Invalid,
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::CredentialRefreshRequired => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
let message = match kind {
AdminErrorKind::Invalid => "Provider 请求不合法",
AdminErrorKind::NotFound => "Provider 资源不存在",
AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试",
AdminErrorKind::BadGateway => "上游服务请求失败",
AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试",
AdminErrorKind::Unavailable => "Provider 服务暂不可用",
AdminErrorKind::Internal => "服务内部错误",
_ => "Provider 操作失败",
};
AdminError::new(kind, error.public_message().unwrap_or(message)).with_source(OperationFailure {
operation: resource,
source: error.into(),
})
}
// 用例的操作名补充端口资源上下文,底层分类与原始原因仍由来源持有
#[derive(Debug, thiserror::Error)]
#[error("admin operation failed: {operation}")]
struct OperationFailure {
operation: &'static str,
source: gateway_core::error::ErrorSource,
}
@@ -14,7 +14,9 @@ use crate::{
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
KeyUsageRecordsQuery,
},
observability::{OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery},
observability::{
Granularity, OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery,
},
system::SystemVersion,
},
ports::store::{ClientKeyStore, ObservabilityStore},
@@ -192,9 +194,13 @@ impl KeyUsageService for DefaultKeyUsageService {
};
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)),
.usage_trend(query.range, filter, Granularity::for_range(query.range)),
self.observations.usage_trend(
today,
usage_filter(&id, None),
Granularity::for_range(today)
),
)
.map_err(|error| map_store_error(error, "key usage overview"))?;
Ok(Some(KeyUsageOverview {
+12 -434
View File
@@ -19,438 +19,16 @@ pub mod proxies;
pub mod settings;
pub mod system;
use crate::{
model::{
AdminError, AdminErrorKind, MutationContext,
accounts::DeleteAccounts,
provider_credentials::{
AuthorizationMutationTarget, CredentialDeletion, CredentialDeletionResult,
CredentialDetails, CredentialMutationResult, CredentialRotationCommit,
PendingAuthorizationMutation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
ProviderQuotaRequest, StartAuthorization,
},
},
ports::{
provider::ProviderAdmin,
store::{AccountStore, AdminStoreError, AdminStoreErrorKind},
},
mod credential_mutation;
mod error;
mod publication;
use credential_mutation::{
commit_authorization, commit_credential_refresh, commit_credential_rotation,
delete_credentials, import_proxy_binding, pending_authorization,
publish_credentials_and_observe_quota, required_credential, required_plugin_credential,
validate_authorization_commit, validate_prepared_import, validate_prepared_rotation,
validate_prepared_rotation_facts,
};
use gateway_core::{
account::ProviderAccountId,
routing::{ConfigRevision, ProviderKind},
runtime::SnapshotControl,
};
fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError {
let kind = match error.kind() {
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
AdminStoreErrorKind::StaleRevision
| AdminStoreErrorKind::DuplicateName
| AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict,
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
};
tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed");
let message = match kind {
AdminErrorKind::Invalid => "请求参数不合法",
AdminErrorKind::NotFound => "请求的资源不存在",
AdminErrorKind::Conflict => "当前资源状态冲突,请刷新后重试",
AdminErrorKind::Unavailable => "依赖服务暂不可用",
_ => "服务内部错误",
};
AdminError::new(kind, message)
}
fn map_provider_error(
error: crate::ports::provider::ProviderAdminError,
resource: &'static str,
) -> AdminError {
use crate::ports::provider::ProviderAdminErrorKind;
let kind = match error.kind() {
ProviderAdminErrorKind::Invalid => AdminErrorKind::Invalid,
ProviderAdminErrorKind::Unsupported => AdminErrorKind::Invalid,
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::CredentialRefreshRequired => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
tracing::warn!(resource, error_kind = ?error.kind(), "admin provider operation failed");
let message = match kind {
AdminErrorKind::Invalid => "Provider 请求不合法",
AdminErrorKind::NotFound => "Provider 资源不存在",
AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试",
AdminErrorKind::BadGateway => "上游服务请求失败",
AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试",
AdminErrorKind::Unavailable => "Provider 服务暂不可用",
AdminErrorKind::Internal => "服务内部错误",
_ => "Provider 操作失败",
};
AdminError::new(kind, error.public_message().unwrap_or(message))
}
async fn publish_committed(
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
) -> Result<(), AdminError> {
let revision = ConfigRevision::new(revision.get())
.map_err(|_| AdminError::internal("已提交的配置版本不合法"))?;
snapshot.publish_committed(revision).await;
Ok(())
}
async fn publish_credentials_and_observe_quota(
provider: &std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
account_ids: &[ProviderAccountId],
request_id: &str,
) -> Result<(), AdminError> {
provider.account_facts_changed(account_ids).await;
publish_committed(snapshot, revision).await?;
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果
let provider = provider.clone();
let account_ids = account_ids.to_vec();
let request_id = request_id.to_owned();
tokio::spawn(async move {
for account_id in account_ids {
if let Err(error) = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await
{
tracing::warn!(
request_id,
provider = %provider.provider_kind().as_str(),
account_id = %account_id.as_str(),
quota_error = ?error.kind(),
"initial quota observation failed"
);
}
}
});
Ok(())
}
async fn required_credential(
accounts: &dyn AccountStore,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
resource: &'static str,
) -> Result<CredentialDetails, AdminError> {
accounts
.credential_details(provider_kind, account_id)
.await
.map_err(|error| map_store_error(error, resource))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
async fn required_plugin_credential(
accounts: &dyn AccountStore,
account_id: &ProviderAccountId,
resource: &'static str,
) -> Result<CredentialDetails, AdminError> {
accounts
.credential_details_by_id(account_id)
.await
.map_err(|error| map_store_error(error, resource))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
async fn import_proxy_binding(
proxies: &dyn crate::ports::proxy::ProxyStore,
id: Option<&str>,
) -> Result<Option<crate::ports::proxy::ProxyImportReservation>, AdminError> {
let Some(id) = id else { return Ok(None) };
let reservation = proxies
.reserve_import(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
Ok(Some(reservation))
}
async fn pending_authorization(
accounts: &dyn AccountStore,
proxies: &dyn crate::ports::proxy::ProxyStore,
provider_kind: &ProviderKind,
command: &StartAuthorization,
resource: &'static str,
) -> Result<PendingAuthorizationMutation, AdminError> {
let proxy;
let mut proxy_id = None;
let target = match &command.reauthorization {
Some(account_id) => {
proxy = required_credential(accounts, provider_kind, account_id, resource)
.await?
.credential
.outbound_proxy;
AuthorizationMutationTarget::Reauthorize {
account_id: account_id.clone(),
}
}
None => {
use crate::model::proxies::AccountProxySelection;
proxy = match &command.outbound_proxy {
Some(AccountProxySelection::Url(value)) => Some(value.clone()),
Some(AccountProxySelection::Saved(id)) => {
let record = proxies
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
proxy_id = Some(record.id);
Some(record.proxy)
}
Some(AccountProxySelection::Direct) | None => None,
};
AuthorizationMutationTarget::Create {
name: command.name.clone(),
}
}
};
Ok(PendingAuthorizationMutation::new(
provider_kind.clone(),
target,
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
&command.context,
),
)
.with_outbound_proxy(proxy)
.with_outbound_proxy_id(proxy_id))
}
fn validate_prepared_import(
provider_kind: &ProviderKind,
prepared: &PreparedCredentialImport,
_resource: &'static str,
) -> Result<(), AdminError> {
if prepared.provider_kind != *provider_kind
|| prepared
.credentials
.iter()
.any(|credential| credential.provider_kind != *provider_kind)
{
return Err(AdminError::conflict("Provider 准备结果与请求范围不一致"));
}
Ok(())
}
fn validate_prepared_rotation(
account: &crate::model::accounts::AccountRecord,
prepared: &PreparedCredentialRotation,
_resource: &'static str,
) -> Result<(), AdminError> {
validate_prepared_rotation_facts(account, prepared.facts())
}
fn validate_prepared_rotation_facts(
account: &crate::model::accounts::AccountRecord,
facts: &crate::model::provider_credentials::PreparedCredentialRotationFacts,
) -> Result<(), AdminError> {
if facts.account_id.as_str() != account.id.as_str()
|| facts.provider_kind != account.provider_kind
{
return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致"));
}
Ok(())
}
async fn validate_authorization_commit(
provider_kind: &ProviderKind,
context: &MutationContext,
prepared: PreparedAuthorizationCommit,
resource: &'static str,
) -> Result<PreparedAuthorizationCommit, AdminError> {
if prepared.pending.provider_kind() != provider_kind
|| !prepared.pending.owner_binding().matches_context(context)
{
let validation_error = AdminError::conflict("OAuth 授权绑定已失效,请重新发起授权");
if let Err(error) = prepared.abort().await {
tracing::warn!(
resource,
settlement_error = %error,
"OAuth authorization claim release failed after preparation validation"
);
return Err(error);
}
return Err(validation_error);
}
let matches_target = match (prepared.pending.target(), &prepared.credential) {
(
AuthorizationMutationTarget::Create { .. },
PreparedAuthorizationCredential::Create(credential),
) => credential.provider_kind == *provider_kind,
(
AuthorizationMutationTarget::Reauthorize { account_id },
PreparedAuthorizationCredential::Reauthorize(credential),
) => {
let facts = credential.facts();
facts.provider_kind == *provider_kind && facts.account_id == *account_id
}
_ => false,
};
if !matches_target {
let validation_error = AdminError::conflict("OAuth 凭据与待提交目标不一致");
if let Err(error) = prepared.abort().await {
tracing::warn!(
resource,
settlement_error = %error,
"OAuth authorization claim release failed after preparation validation"
);
return Err(error);
}
return Err(validation_error);
}
Ok(prepared)
}
async fn commit_authorization(
accounts: &dyn AccountStore,
prepared: PreparedAuthorizationCommit,
key: crate::model::provider_credentials::AuthorizationReceiptKey,
settings: Option<crate::model::accounts::AccountImportSettings>,
context: &MutationContext,
resource: &'static str,
) -> Result<crate::model::provider_credentials::AuthorizationCommitResult, AdminError> {
if settings.is_some()
&& matches!(
&prepared.credential,
PreparedAuthorizationCredential::Reauthorize(_)
)
{
prepared.abort().await?;
return Err(AdminError::invalid("重新授权不能修改账号设置"));
}
let crate::model::provider_credentials::AuthorizationCommitSettlement {
command,
credential_guard,
authorization_guard,
} = prepared.into_commit(settings, key);
match accounts.commit_authorization(command, context).await {
Ok(result) => {
if let Some(guard) = credential_guard
&& result.newly_committed
{
guard.finish();
}
if let Some(guard) = authorization_guard
&& let Err(error) = guard.commit().await
{
// 账号与回执已经原子提交,Redis 清理失败不能把确定的成功改写为失败
tracing::warn!(resource, settlement_error = %error, "authorization committed but pending cleanup failed");
}
Ok(result)
}
Err(error) => {
drop(credential_guard);
if let Some(guard) = authorization_guard
&& let Err(settlement_error) = guard.abort().await
{
tracing::warn!(
resource,
store_error = %error,
settlement_error = %settlement_error,
"OAuth authorization claim release failed after Store commit failure"
);
return Err(settlement_error);
}
Err(map_store_error(error, resource))
}
}
}
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,
settings,
},
context,
)
.await
{
Ok(result) => {
guard.finish();
Ok(result)
}
Err(error) => {
drop(guard);
Err(map_store_error(error, resource))
}
}
}
async fn commit_credential_refresh(
accounts: &dyn AccountStore,
prepared: PreparedCredentialRotation,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_refresh(
CredentialRotationCommit {
prepared: facts,
settings: None,
},
context,
)
.await
{
Ok(result) => {
guard.finish();
Ok(result)
}
Err(error) => {
drop(guard);
Err(map_store_error(error, resource))
}
}
}
async fn delete_credentials(
accounts: &dyn AccountStore,
provider: &dyn ProviderAdmin,
command: CredentialDeletion,
resource: &'static str,
) -> Result<CredentialDeletionResult, AdminError> {
for account_id in &command.account_ids {
required_credential(accounts, provider.provider_kind(), account_id, resource).await?;
}
let account_ids = command.account_ids;
let revision = accounts
.delete_accounts(
DeleteAccounts {
account_ids: account_ids
.iter()
.map(|account_id| account_id.as_str().to_owned())
.collect(),
},
&command.context,
)
.await
.map_err(|error| map_store_error(error, resource))?;
for account_id in &account_ids {
provider.account_unavailable(account_id).await;
}
provider.account_facts_changed(&account_ids).await;
Ok(CredentialDeletionResult {
config_revision: revision,
account_ids,
})
}
use error::{map_provider_error, map_store_error};
use publication::publish_committed;
@@ -17,14 +17,14 @@ use crate::{
AdminError,
observability::{
CostCoverage, CurrencyCost, DashboardAccountUsage, DashboardCapacity,
DashboardPeriodMetrics, DashboardResult, DecimalAmount, DiagnosticDimension,
DiagnosticsItem, DiagnosticsResult, HealthStatus, HealthTimeline, HealthTimelinePoint,
OpsErrorPage, OpsErrorQuery, ProviderBillingInput, RequestMetricPoint, RequestMetrics,
TimeRange, Trend, TrendKind, TrendPoint, TrendSummary, UsageBilling,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost,
UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint,
UsageInsightsPerformance, UsageInsightsPerformancePoint, UsageOverview, UsagePage,
UsageQuery, UsageSummary,
DashboardPeriodMetrics, DashboardQuery, DashboardResult, DecimalAmount,
DiagnosticDimension, DiagnosticsItem, DiagnosticsResult, Granularity, HealthStatus,
HealthTimeline, HealthTimelinePoint, OpsErrorPage, OpsErrorQuery, ProviderBillingInput,
RequestMetricPoint, RequestMetrics, TimeRange, Trend, TrendKind, TrendPoint,
TrendSummary, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
UsageInsights, UsageInsightsCost, UsageInsightsCostPoint, UsageInsightsHealth,
UsageInsightsHealthPoint, UsageInsightsPerformance, UsageInsightsPerformancePoint,
UsageOverview, UsagePage, UsageQuery, UsageSummary,
},
provider_credentials::ProviderQuotaRequest,
},
@@ -157,12 +157,13 @@ impl DefaultObservabilityService {
range: TimeRange,
) -> Result<DashboardResult, AdminError> {
let observed_at = Utc::now();
let (mut observation, settings, runtime_slots) = futures::try_join!(
self.store.dashboard_summary(range, observed_at),
let (mut observation, settings) = futures::try_join!(
self.store
.dashboard_summary(DashboardQuery::new(range), observed_at),
self.settings.load_runtime_settings(),
self.store.dashboard_runtime_slots(observed_at),
)
.map_err(|error| map_store_error(error, "dashboard"))?;
let runtime_slots = observation.runtime_slots;
self.enrich_list_records(&mut observation.recent_requests);
self.enrich_dashboard_quotas(&mut observation.account_usage)
.await;
@@ -204,7 +205,7 @@ impl DefaultObservabilityService {
let wire_profiles = self
.providers
.dashboard_wire_profiles(&settings.request_profiles);
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
let max_concurrent_per_account = u64::from(settings.values.max_concurrent_per_account);
let (inherited_accounts, overridden_slots) = runtime_slots
.as_ref()
.map_or((observation.provider_accounts.normal, 0), |slots| {
@@ -265,7 +266,7 @@ impl ObservabilityService for DefaultObservabilityService {
) -> Result<Trend, AdminError> {
let points = self
.store
.dashboard_trend(range)
.dashboard_trend(range, Granularity::for_range(range))
.await
.map_err(|error| map_store_error(error, "dashboard trend"))?;
trend(kind, points)
@@ -319,10 +320,11 @@ impl ObservabilityService for DefaultObservabilityService {
range: TimeRange,
filter: UsageFilter,
) -> Result<UsageInsights, AdminError> {
let granularity = Granularity::for_range(range);
let aggregates = async {
futures::try_join!(
self.store.usage_summary(range, filter.clone()),
self.store.usage_trend(range, filter.clone()),
self.store.usage_trend(range, filter.clone(), granularity),
)
.map_err(|error| map_store_error(error, "usage insights"))
};
@@ -331,7 +333,7 @@ impl ObservabilityService for DefaultObservabilityService {
recover_standard_costs(
&self.providers,
self.store
.usage_calculated_billing_facts(range, filter.clone()),
.usage_calculated_billing_facts(range, filter.clone(), granularity),
),
)?;
build_usage_insights(overview, trend, standard_costs)
@@ -345,7 +347,7 @@ impl ObservabilityService for DefaultObservabilityService {
) -> Result<DiagnosticsResult, AdminError> {
let observation = self
.store
.usage_diagnostics(range, filter, dimension)
.usage_diagnostics(range, filter, dimension, 100)
.await
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
let items = observation
@@ -2,7 +2,10 @@
use std::collections::{BTreeMap, BTreeSet};
use gateway_core::{policy::ClientApiKeyId, routing::AccountGroupId};
use gateway_core::{
policy::ClientApiKeyId,
routing::{AccountGroupId, extensions::ExtensionSetReference},
};
use secrecy::ExposeSecret;
use super::PluginsService;
@@ -41,14 +44,11 @@ impl PluginsService {
.map_err(|error| map_store_error(error, "plugin"))?;
let warning = match self
.inspector
.inspect(artifact.archive.clone(), Some(digest.to_owned()))
.compatibility_warning(artifact.archive, digest.to_owned())
.await
{
Ok(_) => PackageStatus {
warning: self
.inspector
.compatibility_warning(artifact.archive, digest.to_owned())
.await?,
Ok(warning) => PackageStatus {
warning,
load_error: None,
},
Err(error) if error.kind() == AdminErrorKind::Invalid => PackageStatus {
@@ -368,25 +368,8 @@ impl PluginsService {
)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let prepared = if result.config_revision == candidate_revision
&& result.instance.revision == candidate_revision
{
prepared
} else {
snapshot.config_revision = result.config_revision;
snapshot
.instances
.retain(|item| item.id != result.instance.id);
snapshot.instances.push(result.instance.clone());
apply_replacements(&mut snapshot, &replacements);
self.preparation.prepare(snapshot).await?
};
self.preparation
.activate_state(&prepared, &result.instance)
self.publish_instance(snapshot, prepared, &result, &replacements)
.await?;
// prepared 的强引用跨过提交与唯一发布入口,防止候选在被读取之前回收
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
drop(prepared);
Ok(result)
}
@@ -463,48 +446,45 @@ impl PluginsService {
.await);
}
};
if let Err(error) = self
.preparation
.migrate_state(&migration_prepared, transition.clone())
.await
{
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
// 迁移开始后的所有提交前失败都走同一恢复出口;提交成功后不再回滚旧配置
let committed = async {
self.preparation
.migrate_state(&migration_prepared, transition.clone())
.await?;
let mut current = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if current.config_revision != expected_revision {
return Err(AdminError::conflict("迁移期间插件配置已发生变化"));
}
let candidate_revision = next_revision(expected_revision)?;
target.revision = candidate_revision;
current.instances.retain(|item| item.id != target.id);
current.instances.push(target.clone());
current.config_revision = candidate_revision;
apply_replacements(&mut current, replacements);
let prepared = self.preparation.prepare(current.clone()).await?;
let result = self
.store
.save_instance_replacing(
target,
expected_revision,
PluginStateCommit {
configuration: target_state,
transition_id: Some(transition.id.clone()),
},
replacements,
context,
error,
)
.await);
.await
.map_err(|error| map_store_error(error, "plugin"))?;
Ok((current, prepared, result))
}
let mut current = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if current.config_revision != expected_revision {
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
context,
AdminError::conflict("迁移期间插件配置已发生变化"),
)
.await);
}
let candidate_revision = next_revision(expected_revision)?;
target.revision = candidate_revision;
current.instances.retain(|item| item.id != target.id);
current.instances.push(target.clone());
current.config_revision = candidate_revision;
apply_replacements(&mut current, replacements);
let prepared = match self.preparation.prepare(current.clone()).await {
Ok(prepared) => prepared,
.await;
let (current, prepared, result) = match committed {
Ok(committed) => committed,
Err(error) => {
return Err(self
.recover_failed_state_transition(
@@ -518,53 +498,9 @@ impl PluginsService {
.await);
}
};
let result = match self
.store
.save_instance_replacing(
target,
expected_revision,
PluginStateCommit {
configuration: target_state,
transition_id: Some(transition.id.clone()),
},
replacements,
context,
)
.await
{
Ok(result) => result,
Err(error) => {
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
context,
map_store_error(error, "plugin"),
)
.await);
}
};
let prepared = if result.config_revision == candidate_revision
&& result.instance.revision == candidate_revision
{
prepared
} else {
current.config_revision = result.config_revision;
current
.instances
.retain(|item| item.id != result.instance.id);
current.instances.push(result.instance.clone());
apply_replacements(&mut current, replacements);
self.preparation.prepare(current).await?
};
self.preparation
.activate_state(&prepared, &result.instance)
self.publish_instance(current, prepared, &result, replacements)
.await?;
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
drop(migration_prepared);
drop(prepared);
Ok(result)
}
@@ -615,22 +551,8 @@ impl PluginsService {
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let prepared = if mutation.config_revision == candidate_revision
&& mutation.instance.revision == candidate_revision
{
prepared
} else {
snapshot.config_revision = mutation.config_revision;
snapshot
.instances
.retain(|item| item.id != mutation.instance.id);
snapshot.instances.push(mutation.instance.clone());
self.preparation.prepare(snapshot).await?
};
self.preparation
.activate_state(&prepared, &mutation.instance)
self.publish_instance(snapshot, prepared, &mutation, &[])
.await?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
Ok::<(), AdminError>(())
}
.await;
@@ -646,6 +568,35 @@ impl PluginsService {
}
}
/// 以事务返回的版本激活并发布,候选强引用必须覆盖整个提交后阶段
async fn publish_instance(
&self,
mut snapshot: PluginInstanceSnapshot,
prepared: ExtensionSetReference,
mutation: &PluginInstanceMutation,
replacements: &[PluginInstanceReplacement],
) -> Result<(), AdminError> {
let prepared = if mutation.config_revision == snapshot.config_revision
&& mutation.instance.revision == snapshot.config_revision
{
prepared
} else {
snapshot.config_revision = mutation.config_revision;
snapshot
.instances
.retain(|item| item.id != mutation.instance.id);
snapshot.instances.push(mutation.instance.clone());
apply_replacements(&mut snapshot, replacements);
self.preparation.prepare(snapshot).await?
};
self.preparation
.activate_state(&prepared, &mutation.instance)
.await?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
drop(prepared);
Ok(())
}
pub async fn delete_instance(
&self,
id: &str,
@@ -2,7 +2,8 @@
use std::sync::Arc;
use gateway_core::runtime::{RuntimeSnapshotHandle, extensions::ExtensionSetReference};
use gateway_core::routing::extensions::ExtensionSetReference;
use gateway_core::runtime::RuntimeSnapshotHandle;
use crate::{
model::{
@@ -0,0 +1,14 @@
//! 管理事务提交后的运行快照发布入口
use crate::model::AdminError;
use gateway_core::{routing::ConfigRevision, runtime::SnapshotControl};
pub(super) async fn publish_committed(
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
) -> Result<(), AdminError> {
let revision = ConfigRevision::new(revision.get())
.map_err(|_| AdminError::internal("已提交的配置版本不合法"))?;
snapshot.publish_committed(revision).await;
Ok(())
}
@@ -3,7 +3,6 @@
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::policy::CodexClientVersion;
use gateway_core::runtime::SnapshotControl;
use rand_core::{OsRng, RngCore as _};
@@ -103,50 +102,17 @@ impl DefaultSettingsService {
}
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
let valid = command.request_location.validate().is_ok()
&& command.responses_max_decompressed_body_bytes > 0
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
&& command.refresh_margin_seconds > 0
&& command.refresh_concurrency > 0
&& command.max_waiting_per_key <= 1_000
&& command.max_waiting_per_account <= 1_000
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
&& crate::model::retention::RetentionPolicy::try_new(
command.usage_retention_days,
command.ops_event_retention_days,
command.audit_retention_days,
)
.is_ok()
&& valid_client_version(command.min_codex_desktop_version.as_deref())
&& valid_client_version(command.min_codex_cli_version.as_deref())
&& valid_probe_model(command.account_auto_freeze_probe_model.as_deref())
&& gateway_core::provider_ports::valid_warmup_schedule_time(
&command.account_warmup_schedule_time,
)
&& valid_probe_model(command.account_warmup_model.as_deref())
&& (!command.account_warmup_enabled || command.account_warmup_model.is_some())
&& 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 {
Err(AdminError::invalid("运行时设置不满足约束"))
}
}
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())
})
crate::model::settings::validate_model_mappings(
command
.model_mappings
.iter()
.map(|(requested, upstream)| (requested.as_str(), upstream.as_str())),
)
.map_err(|_| AdminError::invalid("运行时设置不满足约束"))?;
command
.values
.validate()
.map_err(|_| AdminError::invalid("运行时设置不满足约束"))
}
#[async_trait]
@@ -519,9 +519,16 @@ impl DatabaseDumpPort for FakeDumpPort {
async fn cleanup_staging(
&self,
_backup_id: &str,
backup_id: &str,
) -> Result<(), gateway_admin::model::backup::BackupError> {
Ok(())
match std::fs::remove_file(self.dir.path().join(format!("{backup_id}.dump"))) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(_) => Err(gateway_admin::model::backup::BackupError::new(
gateway_admin::model::backup::code::PG_DUMP_FAILED,
"cleanup failed".to_owned(),
)),
}
}
}
@@ -683,6 +690,15 @@ impl AuthStore for FakeAuthStore {
) -> AdminStoreResult<()> {
Ok(())
}
async fn renew_session(
&self,
_: &str,
_: &gateway_admin::model::auth::AuthSession,
_: chrono::DateTime<chrono::Utc>,
) -> AdminStoreResult<Option<gateway_admin::model::auth::AuthSession>> {
Ok(None)
}
async fn delete_session(
&self,
_session_id: &str,
@@ -55,6 +55,7 @@ async fn backup_task_runs_full_dump_upload_verify_pipeline() {
.object(&seed.object_key)
.expect("uploaded object exists");
assert_eq!(stored.as_slice(), DUMP_CONTENT);
assert!(dump.inspect_staging(&seed.id).await.unwrap().is_none());
}
#[tokio::test]
@@ -259,3 +260,67 @@ async fn timezone_change_rebases_future_cursor_without_running_old_due_backup()
task.run_cycle(&CancellationToken::new()).await.unwrap();
assert!(repository.all_records().is_empty());
}
#[tokio::test]
async fn completed_recovery_removes_staging_for_dumping_and_uploading_tasks() {
for (status, remote_exists) in [
(BackupStatus::Dumping, false),
(BackupStatus::Uploading, false),
(BackupStatus::Uploading, true),
] {
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
let dump = Arc::new(FakeDumpPort::new());
let object_store = Arc::new(FakeObjectStore::new());
let seed = BackupRecordSeed {
id: backup_id("recovery-cleanup"),
trigger_kind: BackupTriggerKind::Manual,
scheduled_at: None,
object_key: "recovery.dump".to_owned(),
expires_at: None,
};
repository.insert_backup_record(seed.clone()).await.unwrap();
let artifact = dump
.dump(DumpRequest {
backup_id: seed.id.clone(),
cancellation: CancellationToken::new(),
})
.await
.unwrap();
repository.force_status(&seed.id, BackupStatus::Dumping);
if status == BackupStatus::Uploading {
repository
.transition_status(
&seed.id,
gateway_admin::model::backup::BackupStatusTransition::try_new(
BackupStatus::Dumping,
status,
)
.unwrap(),
gateway_admin::ports::backup::StatusTransitionUpdate {
size_bytes: Some(artifact.size_bytes),
sha256: Some(artifact.sha256),
..Default::default()
},
Utc::now(),
)
.await
.unwrap();
}
if remote_exists {
object_store
.objects
.lock()
.unwrap()
.insert(seed.object_key.clone(), DUMP_CONTENT.to_vec());
}
BackupTask::new(repository.clone(), dump.clone(), object_store)
.run_cycle(&CancellationToken::new())
.await
.unwrap();
assert_eq!(repository.all_records()[0].status, BackupStatus::Completed);
assert!(
dump.inspect_staging(&seed.id).await.unwrap().is_none(),
"completed recovery must remove staging: {status:?}"
);
}
}
@@ -70,12 +70,6 @@ fn request_outcome_filter_should_preserve_known_and_bounded_other_values() {
assert!(RequestOutcome::new("future\nstate").is_err());
}
#[test]
fn time_range_should_accept_exactly_366_days() {
let end = Utc::now();
assert!(TimeRange::new(end - TimeDelta::days(366), end).is_ok());
}
#[test]
fn time_range_should_reject_more_than_366_days() {
let end = Utc::now();
@@ -0,0 +1,17 @@
//! 验证分组文本共用规则中的字符数、字节数和控制字符边界
use gateway_admin::model::account_groups::validate_group_fields;
#[test]
fn group_fields_distinguish_name_characters_from_description_bytes() {
let name = "组".repeat(100);
let description = "é".repeat(2048);
assert!(validate_group_fields(&name, Some(&description)).is_ok());
assert!(validate_group_fields(&"组".repeat(101), None).is_err());
assert!(validate_group_fields("组", Some(&"é".repeat(2049))).is_err());
for name in ["", " 组", "组 ", "组\n名"] {
assert!(validate_group_fields(name, None).is_err());
}
assert!(validate_group_fields("组", Some("含\t制表符")).is_err());
assert!(validate_group_fields("组", Some("")).is_ok());
}
@@ -0,0 +1,16 @@
//! 验证 Client Key 分组集合的共同上限与唯一性规则
use gateway_admin::model::client_keys::validate_group_ids;
use gateway_core::routing::AccountGroupId;
#[test]
fn client_key_groups_reject_duplicates_and_more_than_one_thousand_ids() {
assert!(validate_group_ids(&[]).is_ok());
let mut groups: Vec<_> = (0..1000)
.map(|index| AccountGroupId::new(format!("grp_{index:032x}")).unwrap())
.collect();
assert!(validate_group_ids(&groups).is_ok());
groups.push(AccountGroupId::new("grp_ffffffffffffffffffffffffffffffff").unwrap());
assert!(validate_group_ids(&groups).is_err());
assert!(validate_group_ids(&[groups[0].clone(), groups[0].clone()]).is_err());
}
@@ -1,7 +1,10 @@
//! 模型模块测试:镜像 src/model/ 中带逻辑的模块
mod account_groups;
mod audit;
mod backup;
mod client_keys;
mod observability;
mod plugins;
mod provider_credentials;
mod quota_forecast;
@@ -0,0 +1,67 @@
//! 验证观测查询和数值类型在 HTTP、管理及持久化边界共用的约束
use gateway_admin::model::{
PageSize,
observability::{DecimalAmount, ObservabilityPageSize, PercentileMilliseconds},
};
#[test]
fn observability_page_size_has_its_own_limit() {
for value in [1, 100] {
assert_eq!(ObservabilityPageSize::new(value).unwrap().get(), value);
}
for value in [0, 101, 150, 200, u16::MAX] {
assert!(ObservabilityPageSize::new(value).is_err(), "{value}");
}
assert!(PageSize::new(200).is_ok());
}
#[test]
fn decimal_amount_preserves_numeric_precision_and_normalization() {
for (input, expected) in [
("0", "0"),
(" 00012.34000 ", "12.34"),
("00000.00000", "0"),
("9999999999.9999999999", "9999999999.9999999999"),
("0.0000000001", "0.0000000001"),
] {
assert_eq!(input.parse::<DecimalAmount>().unwrap().as_str(), expected);
}
for input in [
"",
"-1",
"+1",
"NaN",
"inf",
"1e3",
".1",
"1.",
"1.2.3",
"10000000000",
"0.00000000001",
"1",
] {
assert!(input.parse::<DecimalAmount>().is_err(), "{input:?}");
}
}
#[test]
fn decimal_arithmetic_keeps_the_shared_storage_precision() {
let max: DecimalAmount = "9999999999.9999999999".parse().unwrap();
let unit: DecimalAmount = "0.0000000001".parse().unwrap();
assert!(max.checked_add(&unit).is_none());
let one: DecimalAmount = "1".parse().unwrap();
assert_eq!(one.checked_div_u64(3).unwrap().as_str(), "0.3333333333");
assert!(one.checked_div_u64(0).is_none());
assert_eq!(one.checked_add(&unit).unwrap().as_str(), "1.0000000001");
}
#[test]
fn percentile_preserves_interpolation_and_rejects_invalid_values() {
for value in [0.0, 0.125, 1.5, 95.75] {
assert_eq!(PercentileMilliseconds::new(value).unwrap().as_f64(), value);
}
for value in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(PercentileMilliseconds::new(value).is_err());
}
}
@@ -223,8 +223,9 @@ fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
assert_eq!(
PendingAuthorizationMutation::from_storage_v1(Value::Object(
expected.to_storage_v1()
)),
Ok(expected.clone()),
))
.unwrap(),
expected.clone(),
);
let with_proxy = expected
.with_outbound_proxy(Some(
@@ -237,8 +238,9 @@ fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
assert_eq!(
PendingAuthorizationMutation::from_storage_v1(Value::Object(
with_proxy.to_storage_v1()
)),
Ok(with_proxy)
))
.unwrap(),
with_proxy
);
}
}
@@ -10,12 +10,13 @@ use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use gateway_admin::{
model::{
MutationContext, PageSize, Revision,
AdminErrorKind, MutationContext, PageSize, Revision,
account_groups::{
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupColor,
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage,
AccountGroupRecord, AccountGroupUsage, DeleteAccountGroup, NewAccountGroup,
SetAccountGroupEnabled, UpdateAccountGroup,
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation,
AccountGroupOptionsPage, AccountGroupPage, AccountGroupRecord, AccountGroupRef,
AccountGroupUsage, DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled,
UpdateAccountGroup,
},
accounts::AccountRuntimeSnapshot,
observability::DecimalAmount,
@@ -90,6 +91,62 @@ async fn group_query_service_enriches_only_current_page_members_with_runtime_fac
);
}
#[tokio::test]
async fn group_options_query_should_skip_member_and_runtime_enrichment() {
let groups = Arc::new(FakeGroupStore::default());
let runtime = Arc::new(FakeRuntimeStore::default());
let service = AdminHarness::new()
.account_groups(groups.clone())
.account_runtime(runtime.clone())
.build()
.await;
let page = service
.account_groups()
.list_options(AccountGroupListQuery {
page: 1,
page_size: PageSize::new(20).expect("page size"),
search: None,
enabled: None,
})
.await
.expect("list account group options");
assert_eq!(page.items[0].id.as_str(), GROUP_ID);
assert!(
groups
.requested_groups
.lock()
.expect("requested groups")
.is_empty()
);
assert!(
runtime
.requested_accounts
.lock()
.expect("requested accounts")
.is_empty()
);
}
#[tokio::test]
async fn group_options_query_should_preserve_store_failures() {
let service = AdminHarness::new().build().await;
let error = service
.account_groups()
.list_options(AccountGroupListQuery {
page: 1,
page_size: PageSize::new(20).expect("page size"),
search: None,
enabled: None,
})
.await
.expect_err("unavailable options store must fail");
assert_eq!(error.kind(), AdminErrorKind::Unavailable);
}
#[derive(Default)]
struct FakeGroupStore {
requested_groups: Mutex<Vec<String>>,
@@ -144,6 +201,24 @@ impl AccountGroupStore for FakeGroupStore {
})
}
async fn list_account_group_options(
&self,
query: AccountGroupListQuery,
) -> AdminStoreResult<AccountGroupOptionsPage> {
Ok(AccountGroupOptionsPage {
config_revision: Revision::new(1).expect("revision"),
items: vec![AccountGroupRef {
id: group_id(),
name: "Primary".to_owned(),
color: AccountGroupColor::parse("#2563EBFF").expect("color"),
enabled: true,
}],
total: 1,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn load_account_group_members(
&self,
group_ids: &[AccountGroupId],
@@ -297,3 +372,46 @@ fn unused() -> AdminStoreError {
"unused test operation",
)
}
#[tokio::test]
async fn invalid_group_fields_are_rejected_before_store_mutations() {
use gateway_admin::model::{MutationActor, account_groups::CreateAccountGroup};
let service = AdminHarness::new()
.account_groups(Arc::new(FakeGroupStore::default()))
.build()
.await;
let context = MutationContext {
actor: MutationActor::System,
request_id: "invalid-group".to_owned(),
};
let record = group_record();
let created = service
.account_groups()
.create(
&context,
CreateAccountGroup {
fast_mode: FastMode::Default,
name: " invalid".to_owned(),
description: None,
color: record.color.clone(),
},
)
.await
.unwrap_err();
let updated = service
.account_groups()
.update(
&context,
UpdateAccountGroup {
fast_mode: None,
id: record.id,
name: "valid".to_owned(),
description: Some("invalid\n".to_owned()),
color: record.color,
},
)
.await
.unwrap_err();
assert_eq!(created.kind(), AdminErrorKind::Invalid);
assert_eq!(updated.kind(), AdminErrorKind::Invalid);
}
@@ -1110,37 +1110,42 @@ impl SettingsStore for StaticSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
request_profiles: Default::default(),
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,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
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,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: 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,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
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,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
})
}
@@ -1533,10 +1538,13 @@ async fn plugin_account_save_rejects_stale_authentication_kind_before_commit() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider, store.clone()).await;
let access = gateway_admin::initialize_plugin_accounts(
ProviderAdminRegistry::new([provider as Arc<dyn ProviderAdmin>]).unwrap(),
store.clone(),
Arc::new(RecordingPluginAccountPublication(events.clone())),
);
let error = services
.plugin_accounts_handle()
let error = access
.save(
PreparedPluginAccountSave::Replace {
facts: plugin_rotation_facts(&account_record("openai")),
@@ -69,6 +69,7 @@ async fn password_change_obeys_rate_limit_and_rejects_unbound_legacy_sessions()
admin_user_id: "admin".into(),
credential_fingerprint: String::new(),
},
absolute_expires_at: None,
expires_at: Utc::now() + TimeDelta::hours(1),
},
);
@@ -137,6 +138,25 @@ impl AuthStore for MemoryAuthStore {
Ok(())
}
async fn renew_session(
&self,
session_id: &str,
expected: &AuthSession,
expires_at: chrono::DateTime<chrono::Utc>,
) -> AdminStoreResult<Option<AuthSession>> {
let mut sessions = self.sessions.lock().unwrap();
if let Some(session) = sessions.get_mut(session_id) {
if session.expires_at <= chrono::Utc::now() {
return Ok(None);
}
if session == expected {
session.expires_at = expires_at;
}
return Ok(Some(session.clone()));
}
Ok(None)
}
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"));
@@ -262,7 +282,7 @@ async fn login_with_huge_session_ttl_should_clamp_expiry_instead_of_panicking()
}
#[tokio::test]
async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logout() {
async fn expired_sessions_are_rejected_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;
@@ -273,6 +293,7 @@ async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logou
credential_fingerprint: String::new(),
admin_user_id: "admin".to_owned(),
},
absolute_expires_at: None,
expires_at: Utc::now() - TimeDelta::seconds(1),
},
);
@@ -284,7 +305,6 @@ async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logou
.unwrap()
.is_none()
);
assert!(store.sessions.lock().unwrap().is_empty());
store
.unavailable
.store(true, std::sync::atomic::Ordering::SeqCst);
@@ -370,3 +390,110 @@ async fn failed_rotation_discards_the_new_session_and_leaves_the_old_session_ret
services.auth().logout(&original.session_id).await.unwrap();
assert!(store.sessions.lock().unwrap().is_empty());
}
#[tokio::test]
async fn renewal_is_throttled_and_capped_by_the_original_absolute_expiry() {
let store = std::sync::Arc::new(MemoryAuthStore::default());
let services = super::AdminHarness::new()
.auth(store.clone())
.session_ttl_minutes(60)
.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();
assert_eq!(
auth.renew_session(Some(&login.session_id)).await.unwrap(),
Some(login.session.clone())
);
let limit = Utc::now() + TimeDelta::minutes(30);
{
let mut sessions = store.sessions.lock().unwrap();
let session = sessions.get_mut(&login.session_id).unwrap();
session.expires_at = Utc::now() + TimeDelta::minutes(1);
session.absolute_expires_at = Some(limit);
}
let renewed = auth
.renew_session(Some(&login.session_id))
.await
.unwrap()
.unwrap();
assert_eq!(renewed.expires_at, limit);
assert_eq!(renewed.absolute_expires_at, Some(limit));
assert_eq!(
auth.renew_session(Some(&login.session_id)).await.unwrap(),
Some(renewed)
);
}
#[tokio::test]
async fn renewal_never_revives_expired_revoked_or_legacy_sessions() {
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();
let mut session = login.session.clone();
session.absolute_expires_at = None;
session.expires_at = Utc::now() + TimeDelta::seconds(30);
store
.sessions
.lock()
.unwrap()
.insert(login.session_id.clone(), session.clone());
assert_eq!(
auth.renew_session(Some(&login.session_id)).await.unwrap(),
Some(session.clone())
);
session.expires_at = Utc::now() - TimeDelta::seconds(1);
store
.sessions
.lock()
.unwrap()
.insert(login.session_id.clone(), session);
assert!(
auth.renew_session(Some(&login.session_id))
.await
.unwrap()
.is_none()
);
let mut session = login.session;
session.absolute_expires_at = Some(Utc::now() - TimeDelta::seconds(1));
store
.sessions
.lock()
.unwrap()
.insert(login.session_id.clone(), session);
assert!(
auth.renew_session(Some(&login.session_id))
.await
.unwrap()
.is_none()
);
auth.logout(&login.session_id).await.unwrap();
assert!(
auth.renew_session(Some(&login.session_id))
.await
.unwrap()
.is_none()
);
assert!(store.sessions.lock().unwrap().is_empty());
}
@@ -285,6 +285,25 @@ impl AuthStore for MemoryKeyAuthStore {
Ok(())
}
async fn renew_session(
&self,
session_id: &str,
expected: &AuthSession,
expires_at: chrono::DateTime<chrono::Utc>,
) -> AdminStoreResult<Option<AuthSession>> {
let mut sessions = self.sessions.lock().unwrap();
if let Some(session) = sessions.get_mut(session_id) {
if session.expires_at <= chrono::Utc::now() {
return Ok(None);
}
if session == expected {
session.expires_at = expires_at;
}
return Ok(Some(session.clone()));
}
Ok(None)
}
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
Ok(self
.sessions
@@ -414,3 +414,41 @@ fn unused() -> AdminStoreError {
"unused in this test",
)
}
#[tokio::test]
async fn duplicate_group_ids_are_rejected_before_key_creation_or_update() {
let store = Arc::new(TestClientKeyStore::default());
let services = super::AdminHarness::new()
.client_keys(store.clone())
.build()
.await;
let group =
gateway_core::routing::AccountGroupId::new("grp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
let mut command = create_command(None);
command.group_ids = vec![group.clone(), group.clone()];
let created = services
.client_keys()
.create(&mutation_context(), command)
.await
.unwrap_err();
let updated = services
.client_keys()
.update(
&mutation_context(),
UpdateClientKey {
request_profile_override_updates: Default::default(),
id: ClientApiKeyId::new("key_existing").unwrap(),
name: "Migration".to_owned(),
label: None,
group_ids: vec![group.clone(), group],
limits: RateLimits::unlimited(),
daily_limit_usd: None,
weekly_limit_usd: None,
},
)
.await
.unwrap_err();
assert_eq!(created.kind(), AdminErrorKind::Invalid);
assert_eq!(updated.kind(), AdminErrorKind::Invalid);
assert!(store.plaintexts.lock().unwrap().is_empty());
}
@@ -5,7 +5,6 @@ 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::{
@@ -18,7 +17,9 @@ use gateway_core::engine::probe::{
AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult,
};
use gateway_core::lifecycle::CancellationToken;
use gateway_core::task::{ScheduledTask as _, WorkerCycleContext, WorkerId, WorkerKind};
use gateway_core::task::{
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerId, WorkerKind, WorkerRunnable,
};
use super::AdminHarness;
use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, events, revision};
@@ -26,37 +27,42 @@ use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, event
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
RuntimeSettings {
request_profiles: Default::default(),
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,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
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,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: 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,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
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,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
}
}
@@ -241,7 +247,7 @@ async fn recovery_task(
settings: RuntimeSettings,
runtime: Arc<FreezeRuntimeStore>,
probe: Arc<dyn AccountProbe>,
) -> (FreezeRecoveryTask, Arc<FakeAccountStore>) {
) -> (Box<dyn ScheduledTask>, Arc<FakeAccountStore>) {
recovery_task_with_store(settings, runtime, probe).await
}
@@ -249,9 +255,9 @@ async fn recovery_task_with_store(
settings: RuntimeSettings,
runtime: Arc<FreezeRuntimeStore>,
probe: Arc<dyn AccountProbe>,
) -> (FreezeRecoveryTask, Arc<FakeAccountStore>) {
) -> (Box<dyn ScheduledTask>, Arc<FakeAccountStore>) {
let store = FakeAccountStore::new("openai", events());
let services = AdminHarness::new()
let bundle = AdminHarness::new()
.accounts(Arc::clone(&store) as Arc<dyn AccountStore>)
.account_runtime(Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>)
.settings(Arc::new(FreezeSettingsStore {
@@ -259,20 +265,32 @@ async fn recovery_task_with_store(
}))
.provider(FakeProviderAdmin::new("openai", events()))
.probe(probe)
.build()
.build_bundle()
.await;
let task = FreezeRecoveryTask::new(FreezeRecoveryDeps {
accounts: services.accounts_handle(),
store: store.clone(),
runtime,
settings: Arc::new(FreezeSettingsStore {
settings: settings.clone(),
}),
});
let task = freeze_worker(bundle);
(task, store)
}
async fn run_cycle(task: &FreezeRecoveryTask) {
fn freeze_worker(mut bundle: gateway_admin::AdminBundle) -> Box<dyn ScheduledTask> {
bundle
.take_worker_contributions()
.into_iter()
.find_map(|contribution| {
let WorkerContribution::Registration(registration) = contribution else {
return None;
};
if registration.id.kind() != WorkerKind::AccountFreezeRecovery {
return None;
}
match registration.runnable {
WorkerRunnable::Scheduled { task, .. } => Some(task),
WorkerRunnable::Daemon { .. } => None,
}
})
.expect("registered freeze recovery worker")
}
async fn run_cycle(task: &dyn ScheduledTask) {
let worker = WorkerId::try_new(WorkerKind::AccountFreezeRecovery, "test").expect("worker id");
let context = WorkerCycleContext::new(worker, None, CancellationToken::new());
task.run_cycle(context)
@@ -306,7 +324,7 @@ async fn probe_success_clears_freeze() {
)
.await;
run_cycle(&task).await;
run_cycle(task.as_ref()).await;
assert_eq!(runtime.cleared(), vec!["acct_test".to_owned()]);
assert!(runtime.extended().is_empty());
@@ -322,7 +340,7 @@ async fn probe_failure_postpones_freeze() {
)
.await;
run_cycle(&task).await;
run_cycle(task.as_ref()).await;
assert!(runtime.cleared().is_empty());
let extended = runtime.extended();
@@ -346,7 +364,7 @@ async fn disabled_policy_releases_due_freeze_without_probing() {
)
.await;
run_cycle(&task).await;
run_cycle(task.as_ref()).await;
assert_eq!(runtime.cleared(), vec!["acct_test".to_owned()]);
assert!(runtime.extended().is_empty());
@@ -365,7 +383,7 @@ async fn adaptive_concurrency_lowers_limit_to_observed_peak() {
)
.await;
run_cycle(&task).await;
run_cycle(task.as_ref()).await;
// 峰值 4 × 0.8 = 3(不低于下限 2),低于全局默认 5,应下调到 3
assert_eq!(
@@ -390,7 +408,7 @@ async fn adaptive_concurrency_never_raises_limit() {
)
.await;
run_cycle(&task).await;
run_cycle(task.as_ref()).await;
// 峰值 40 的目标 32 高于全局默认 5;只降不升,不下发任何更新
assert!(store.update_commands().is_empty());
@@ -410,7 +428,7 @@ async fn probe_skips_freezes_far_from_expiry() {
)
.await;
run_cycle(&task).await;
run_cycle(task.as_ref()).await;
assert!(runtime.cleared().is_empty());
assert!(runtime.extended().is_empty());
@@ -426,7 +444,7 @@ async fn disabled_accounts_are_skipped() {
let mut record = account_record("openai");
record.enabled = false;
let store = FakeAccountStore::with_account(record, events());
let services = AdminHarness::new()
let bundle = AdminHarness::new()
.accounts(Arc::clone(&store) as Arc<dyn AccountStore>)
.account_runtime(Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>)
.settings(Arc::new(FreezeSettingsStore {
@@ -434,18 +452,11 @@ async fn disabled_accounts_are_skipped() {
}))
.provider(FakeProviderAdmin::new("openai", events()))
.probe(Arc::new(SuccessfulProbe))
.build()
.build_bundle()
.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),
}),
});
let task = freeze_worker(bundle);
run_cycle(&task).await;
run_cycle(task.as_ref()).await;
assert!(runtime.cleared().is_empty());
assert!(runtime.extended().is_empty());
@@ -29,8 +29,9 @@ use gateway_admin::{
model::{
MutationContext, Revision,
account_groups::{
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage,
DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation,
AccountGroupOptionsPage, AccountGroupPage, DeleteAccountGroup, NewAccountGroup,
SetAccountGroupEnabled, UpdateAccountGroup,
},
accounts::{
AccountListQuery, AccountPage, AccountRuntimeSnapshot, AccountUpdateResult,
@@ -44,10 +45,10 @@ use gateway_admin::{
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
},
observability::{
DashboardDesktopRelease, DashboardObservation, DashboardWireAttribute,
DashboardDesktopRelease, DashboardObservation, DashboardQuery, DashboardWireAttribute,
DashboardWireProfile, DashboardWireTarget, DesktopReleaseStatus, DiagnosticDimension,
DiagnosticsObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
DiagnosticsObservation, Granularity, OpsErrorPage, OpsErrorQuery, RequestMetricPoint,
TimeRange, UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
},
provider_credentials::{
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
@@ -107,7 +108,7 @@ pub(super) struct AdminHarness {
plugin_store: Arc<dyn gateway_admin::ports::plugins::PluginStore>,
plugin_inspector: Arc<dyn gateway_admin::ports::plugins::PluginPackageInspector>,
client_key_verifier: Arc<dyn ClientKeyVerifier>,
service_middleware: gateway_admin::service::PlanSource,
service_middleware: gateway_admin::public_service::PlanSource,
}
impl AdminHarness {
@@ -194,7 +195,10 @@ impl AdminHarness {
self
}
pub(super) fn service_middleware(mut self, source: gateway_admin::service::PlanSource) -> Self {
pub(super) fn service_middleware(
mut self,
source: gateway_admin::public_service::PlanSource,
) -> Self {
self.service_middleware = source;
self
}
@@ -246,6 +250,7 @@ impl AdminHarness {
pub(super) async fn build_bundle(self) -> gateway_admin::AdminBundle {
gateway_admin::initialize(
AdminConfig {
session_absolute_ttl_minutes: 30 * 24 * 60,
session_ttl_minutes: self.session_ttl_minutes,
default_username: "admin".to_owned(),
default_password: InitialAdminPassword::new(self.default_password),
@@ -364,6 +369,15 @@ impl AuthStore for BootstrapAuthStore {
Err(unavailable("admin session"))
}
async fn renew_session(
&self,
_: &str,
_: &gateway_admin::model::auth::AuthSession,
_: chrono::DateTime<chrono::Utc>,
) -> AdminStoreResult<Option<gateway_admin::model::auth::AuthSession>> {
Ok(None)
}
async fn delete_session(&self, _: &str) -> AdminStoreResult<Option<AuthSession>> {
Err(unavailable("admin session"))
}
@@ -411,6 +425,13 @@ impl AccountGroupStore for UnavailableAccountGroupStore {
Err(unavailable("account groups"))
}
async fn list_account_group_options(
&self,
_: AccountGroupListQuery,
) -> AdminStoreResult<AccountGroupOptionsPage> {
Err(unavailable("account group options"))
}
async fn load_account_group_members(
&self,
_: &[gateway_core::routing::AccountGroupId],
@@ -728,13 +749,17 @@ impl ClientKeyStore for UnavailableStore {
impl ObservabilityStore for UnavailableStore {
async fn dashboard_summary(
&self,
_: TimeRange,
_: DashboardQuery,
_: DateTime<Utc>,
) -> AdminStoreResult<DashboardObservation> {
Err(unavailable("dashboard"))
}
async fn dashboard_trend(&self, _: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>> {
async fn dashboard_trend(
&self,
_: TimeRange,
_: Granularity,
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
Err(unavailable("dashboard trend"))
}
@@ -742,6 +767,7 @@ impl ObservabilityStore for UnavailableStore {
&self,
_: TimeRange,
_: UsageFilter,
_: Granularity,
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
Err(unavailable("usage trend"))
}
@@ -750,6 +776,7 @@ impl ObservabilityStore for UnavailableStore {
&self,
_: TimeRange,
_: UsageFilter,
_: Granularity,
) -> gateway_admin::ports::store::UsageCalculatedBillingStream<'_> {
Box::pin(futures::stream::once(async {
Err(unavailable("usage billing facts"))
@@ -773,6 +800,7 @@ impl ObservabilityStore for UnavailableStore {
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
_: u16,
) -> AdminStoreResult<DiagnosticsObservation> {
Err(unavailable("usage diagnostics"))
}
@@ -15,15 +15,15 @@ use chrono::{DateTime, Duration, Timelike as _, Utc};
use gateway_admin::{
AdminServices,
model::{
MutationContext, PageSize, Revision,
MutationContext, Revision,
observability::{
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardAccountUsage,
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension,
DashboardObservation, DashboardQuery, DashboardRuntimeSlots, DiagnosticDimension,
DiagnosticObservation, DiagnosticsObservation, Granularity, HealthStatus,
LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds,
RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, UsageBilling,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord, UsageOverview,
UsagePage, UsageQuery,
LatencyPercentiles, ObservabilityPageSize, OpsErrorPage, OpsErrorQuery,
PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange, TrendKind,
UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord,
UsageOverview, UsagePage, UsageQuery,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -282,7 +282,7 @@ async fn dashboard_capacity_distinguishes_unlimited_inheritance_finite_overrides
}
#[tokio::test]
async fn dashboard_summary_should_share_one_current_observation_time_across_runtime_facts() {
async fn dashboard_summary_should_observe_current_accounts_for_a_historical_range() {
let historical_end = Utc::now() - Duration::hours(1);
let store = Arc::new(FixtureObservabilityStore::new(observation_range(
historical_end,
@@ -295,8 +295,7 @@ async fn dashboard_summary_should_share_one_current_observation_time_across_runt
.await
.expect("dashboard summary");
let (summary_observed_at, slots_observed_at) = store.observed_times();
assert_eq!(summary_observed_at, slots_observed_at);
let summary_observed_at = *store.summary_observed_at.lock().unwrap();
assert!(summary_observed_at.is_some_and(|value| value > historical_end));
}
@@ -789,7 +788,8 @@ struct FixtureObservabilityStore {
diagnostics: Mutex<DiagnosticsObservation>,
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
slots_observed_at: Mutex<Option<DateTime<Utc>>>,
summary_query: Mutex<Option<DashboardQuery>>,
usage_granularities: Mutex<Vec<Granularity>>,
usage_records: Mutex<Vec<UsageListRecord>>,
account_usage: Mutex<Vec<DashboardAccountUsage>>,
dashboard_delay: Mutex<StdDuration>,
@@ -811,7 +811,8 @@ impl FixtureObservabilityStore {
diagnostics: Mutex::new(DiagnosticsObservation::default()),
runtime_slots: Mutex::new(None),
summary_observed_at: Mutex::new(None),
slots_observed_at: Mutex::new(None),
summary_query: Mutex::new(None),
usage_granularities: Mutex::new(Vec::new()),
usage_records: Mutex::new(Vec::new()),
account_usage: Mutex::new(Vec::new()),
dashboard_delay: Mutex::new(StdDuration::ZERO),
@@ -850,16 +851,6 @@ impl FixtureObservabilityStore {
*self.runtime_slots.lock().expect("runtime slots") = runtime_slots;
}
fn observed_times(&self) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
(
*self
.summary_observed_at
.lock()
.expect("summary observed at"),
*self.slots_observed_at.lock().expect("slots observed at"),
)
}
fn replace_usage_records(&self, records: Vec<UsageListRecord>) {
*self.usage_records.lock().expect("usage records") = records;
}
@@ -869,10 +860,11 @@ impl FixtureObservabilityStore {
impl ObservabilityStore for FixtureObservabilityStore {
async fn dashboard_summary(
&self,
range: TimeRange,
query: DashboardQuery,
observed_at: DateTime<Utc>,
) -> AdminStoreResult<DashboardObservation> {
self.dashboard_summary_calls.fetch_add(1, Ordering::Relaxed);
*self.summary_query.lock().unwrap() = Some(query.clone());
*self
.summary_observed_at
.lock()
@@ -882,24 +874,21 @@ impl ObservabilityStore for FixtureObservabilityStore {
tokio::time::sleep(dashboard_delay).await;
}
Ok(DashboardObservation {
range,
range: query.range,
totals: Default::default(),
provider_accounts: AccountPoolMetrics::default(),
runtime_slots: *self.runtime_slots.lock().expect("runtime slots"),
trend: self.trend.lock().expect("trend").clone(),
account_usage: self.account_usage.lock().expect("account usage").clone(),
recent_requests: Vec::new(),
})
}
async fn dashboard_runtime_slots(
async fn dashboard_trend(
&self,
observed_at: DateTime<Utc>,
) -> AdminStoreResult<Option<DashboardRuntimeSlots>> {
*self.slots_observed_at.lock().expect("slots observed at") = Some(observed_at);
Ok(*self.runtime_slots.lock().expect("runtime slots"))
}
async fn dashboard_trend(&self, _: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>> {
_: TimeRange,
_: Granularity,
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
Ok(self.trend.lock().expect("trend").clone())
}
@@ -907,7 +896,9 @@ impl ObservabilityStore for FixtureObservabilityStore {
&self,
_: TimeRange,
_: UsageFilter,
granularity: Granularity,
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
self.usage_granularities.lock().unwrap().push(granularity);
Ok(self.trend.lock().expect("trend").clone())
}
@@ -915,7 +906,9 @@ impl ObservabilityStore for FixtureObservabilityStore {
&self,
_: TimeRange,
_: UsageFilter,
granularity: Granularity,
) -> gateway_admin::ports::store::UsageCalculatedBillingStream<'_> {
self.usage_granularities.lock().unwrap().push(granularity);
let facts = self
.calculated_billing_facts
.lock()
@@ -954,6 +947,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
_: u16,
) -> AdminStoreResult<DiagnosticsObservation> {
Ok(self.diagnostics.lock().expect("diagnostics").clone())
}
@@ -989,37 +983,42 @@ impl SettingsStore for FixtureSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
request_profiles: Default::default(),
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: self.max_concurrent_per_account,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
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,
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,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: self.max_concurrent_per_account,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
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,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
})
}
@@ -1084,7 +1083,7 @@ fn usage_query(now: DateTime<Utc>) -> UsageQuery {
range: observation_range(now),
filter: UsageFilter::default(),
current_page: 1,
page_size: PageSize::new(50).expect("page size"),
page_size: ObservabilityPageSize::new(50).expect("page size"),
}
}
@@ -1139,6 +1138,13 @@ fn total_record(
first_text_ms: None,
first_token_ms: None,
provider_processing_ms: None,
upstream_response_ms: None,
upstream_api_overhead_ms: None,
upstream_engine_ms: None,
upstream_engine_iapi_ttft_ms: None,
upstream_engine_service_ttft_ms: None,
upstream_engine_iapi_tbt_ms: None,
upstream_engine_service_tbt_ms: None,
latency_ms: Some(100),
admission_decision_ms: None,
account_selection_wait_ms: None,
@@ -1190,3 +1196,41 @@ fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
let elapsed = value.timestamp().rem_euclid(15 * 60);
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
}
#[tokio::test]
async fn admin_selects_dashboard_defaults_and_matching_usage_buckets_at_range_boundaries() {
let now = Utc::now();
for (duration, expected) in [
(Duration::days(2), Granularity::FifteenMinutes),
(Duration::days(2) + Duration::seconds(1), Granularity::Hour),
(Duration::days(31), Granularity::Hour),
(Duration::days(31) + Duration::seconds(1), Granularity::Day),
] {
let range = TimeRange::new(now - duration, now).unwrap();
let store = Arc::new(FixtureObservabilityStore::new(range));
let services = observability_services(store.clone()).await;
services
.observability()
.dashboard_summary(range, TrendKind::Usage)
.await
.unwrap();
let query = store.summary_query.lock().unwrap().clone().unwrap();
assert_eq!(query.range, range);
assert_eq!(query.granularity, expected);
assert_eq!(query.account_limit, 4);
assert_eq!(query.recent_request_limit, 10);
assert_eq!(
query.recent_request_filter.outcome,
Some(gateway_admin::model::observability::RequestOutcome::Succeeded)
);
services
.observability()
.usage_insights(range, UsageFilter::default())
.await
.unwrap();
assert_eq!(
*store.usage_granularities.lock().unwrap(),
vec![expected, expected]
);
}
}
@@ -24,12 +24,10 @@ use gateway_admin::{
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
},
};
use gateway_core::routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference};
use gateway_core::{
routing::ConfigRevision,
runtime::{
RuntimeSnapshotHandle, SnapshotControl,
extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference},
},
runtime::{RuntimeSnapshotHandle, SnapshotControl},
};
use serde_json::json;
@@ -117,7 +115,7 @@ impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for Fixture {
&self,
_: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
_: Option<u64>,
_: Option<&gateway_core::runtime::extensions::ExtensionSetReference>,
_: Option<&gateway_core::routing::extensions::ExtensionSetReference>,
) -> Option<
std::collections::BTreeMap<
String,
@@ -132,7 +130,7 @@ impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for Fixture {
impl gateway_admin::ports::plugins::PluginStateLifecycle for Fixture {
async fn activate_state(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_core::routing::extensions::ExtensionSetReference,
_: &gateway_admin::model::plugins::instances::PluginInstance,
) -> Result<(), AdminError> {
Ok(())
@@ -142,7 +140,7 @@ impl gateway_admin::ports::plugins::PluginStateLifecycle for Fixture {
}
async fn migrate_state(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_core::routing::extensions::ExtensionSetReference,
_: gateway_admin::model::plugins::state::PluginStateTransition,
) -> Result<(), AdminError> {
panic!("unexpected state migration")
@@ -40,12 +40,10 @@ use gateway_admin::{
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
},
};
use gateway_core::routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference};
use gateway_core::{
routing::ConfigRevision,
runtime::{
RuntimeSnapshotHandle, SnapshotControl,
extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference},
},
runtime::{RuntimeSnapshotHandle, SnapshotControl},
};
use serde_json::json;
@@ -87,9 +85,10 @@ impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture
}
async fn compatibility_warning(
&self,
_: Arc<[u8]>,
_: String,
archive: Arc<[u8]>,
digest: String,
) -> Result<Option<String>, AdminError> {
self.inspect(archive, Some(digest)).await?;
Ok(self.data.lock().unwrap().compatibility_warning.clone())
}
async fn inspect(
@@ -132,6 +131,9 @@ struct FixtureData {
saves: Vec<SavedInstance>,
history: BTreeMap<String, PluginVersionConfiguration>,
aborted: usize,
transition_active: bool,
fail_reload_after_migration: bool,
fail_next_load: bool,
activated: usize,
quiesced: usize,
diagnostics: Option<BTreeMap<String, PluginInstanceRuntime>>,
@@ -185,6 +187,9 @@ impl LifecycleFixture {
},
)]),
aborted: 0,
transition_active: false,
fail_reload_after_migration: false,
fail_next_load: false,
activated: 0,
quiesced: 0,
diagnostics: None,
@@ -237,6 +242,9 @@ impl LifecycleFixture {
.instances
.retain(|current| current.id != instance.id);
data.snapshot.instances.push(instance.clone());
if transition_id.is_some() {
data.transition_active = false;
}
data.saves.push(SavedInstance {
artifact_sha256: instance.artifact_sha256.clone(),
enabled: instance.enabled,
@@ -336,7 +344,11 @@ impl gateway_admin::ports::plugins::PluginStateLifecycle for LifecycleFixture {
_: PluginStateTransition,
) -> Result<(), AdminError> {
match self.behavior {
MigrationBehavior::Succeed => Ok(()),
MigrationBehavior::Succeed => {
let mut data = self.data.lock().unwrap();
data.fail_next_load = std::mem::take(&mut data.fail_reload_after_migration);
Ok(())
}
MigrationBehavior::Fail => Err(AdminError::invalid("migration fixture failed")),
MigrationBehavior::ConcurrentChangeThenFail => {
let mut data = self.data.lock().unwrap();
@@ -400,9 +412,10 @@ impl PluginStateStore for LifecycleFixture {
artifact_sha256: &str,
_: PluginStateConfiguration,
) -> PluginStateStoreResult<PluginStateTransition> {
let data = self.data.lock().unwrap();
let mut data = self.data.lock().unwrap();
let current = &data.snapshot.instances[0];
if current.enabled
if data.transition_active
|| current.enabled
|| current.id != instance_id
|| current.revision != expected_instance_revision
|| artifact_sha256 != NEW_ARTIFACT
@@ -411,6 +424,7 @@ impl PluginStateStore for LifecycleFixture {
PluginStateStoreErrorKind::Conflict,
));
}
data.transition_active = true;
Ok(PluginStateTransition {
id: "00000000-0000-7000-8000-000000000002".into(),
instance_id: instance_id.into(),
@@ -440,7 +454,9 @@ impl PluginStateStore for LifecycleFixture {
}
async fn abort_transition(&self, _: &str) -> PluginStateStoreResult<()> {
self.data.lock().unwrap().aborted += 1;
let mut data = self.data.lock().unwrap();
data.aborted += 1;
data.transition_active = false;
Ok(())
}
}
@@ -465,7 +481,11 @@ impl PluginStore for LifecycleFixture {
}))
}
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot> {
Ok(self.snapshot())
let mut data = self.data.lock().unwrap();
if std::mem::take(&mut data.fail_next_load) {
return Err(admin_error(AdminStoreErrorKind::Unavailable));
}
Ok(data.snapshot.clone())
}
async fn load_version_configuration(
@@ -1048,6 +1068,36 @@ async fn failed_state_migration_restores_the_previous_enabled_version_with_cas()
assert_eq!(published.revisions.lock().unwrap().as_slice(), [11, 12]);
}
#[tokio::test]
async fn reload_failure_after_migration_aborts_restores_and_allows_retry() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
fixture.data.lock().unwrap().fail_reload_after_migration = true;
let published = Arc::new(Published::default());
let service = service(fixture.clone(), published.clone());
let id = "00000000-0000-7000-8000-000000000001";
let error = service
.configure_instance(Some(id), input(), &context())
.await
.err()
.unwrap();
assert_eq!(error.kind(), AdminErrorKind::Unavailable);
{
let data = fixture.data.lock().unwrap();
assert_eq!(data.aborted, 1);
assert!(!data.transition_active);
assert!(data.snapshot.instances[0].enabled);
assert_eq!(data.snapshot.instances[0].artifact_sha256, OLD_ARTIFACT);
assert_eq!(*published.revisions.lock().unwrap(), [11, 12]);
}
let result = service
.configure_instance(Some(id), input(), &context())
.await
.unwrap();
assert_eq!(result.instance.artifact_sha256, NEW_ARTIFACT);
assert!(result.instance.enabled);
assert!(!fixture.data.lock().unwrap().transition_active);
}
#[tokio::test]
async fn failed_state_migration_never_overwrites_a_concurrent_configuration_change() {
let fixture = LifecycleFixture::new(MigrationBehavior::ConcurrentChangeThenFail);
@@ -111,7 +111,7 @@ impl gateway_admin::ports::proxy::ProxyStore for TestPluginPorts {
impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPorts {
async fn validate_target(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_core::routing::extensions::ExtensionSetReference,
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
) -> Result<(), AdminError> {
Err(AdminError::not_found("unused plugin fixture"))
@@ -119,7 +119,7 @@ impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPor
async fn start_callback(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_core::routing::extensions::ExtensionSetReference,
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
_: gateway_admin::model::plugins::management::StartPluginManagementCallback,
_: &gateway_admin::model::auth::AdminRequestContext,
@@ -129,7 +129,7 @@ impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPor
}
async fn callback(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_core::routing::extensions::ExtensionSetReference,
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
_: &str,
_: gateway_admin::model::plugins::management::PluginManagementRequest,
@@ -139,14 +139,14 @@ impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPor
}
async fn views(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_core::routing::extensions::ExtensionSetReference,
) -> Result<Vec<gateway_admin::model::plugins::management::PluginManagementView>, AdminError>
{
Ok(Vec::new())
}
async fn resource(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_core::routing::extensions::ExtensionSetReference,
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
_: &str,
_: bool,
@@ -156,7 +156,7 @@ impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPor
}
async fn handle(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_core::routing::extensions::ExtensionSetReference,
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
_: gateway_admin::model::plugins::management::PluginManagementRequest,
) -> Result<gateway_admin::model::plugins::management::PluginManagementResponse, AdminError>
@@ -171,7 +171,7 @@ impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for TestPluginPorts
&self,
_: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
_: Option<u64>,
_: Option<&gateway_core::runtime::extensions::ExtensionSetReference>,
_: Option<&gateway_core::routing::extensions::ExtensionSetReference>,
) -> Option<
std::collections::BTreeMap<
String,
@@ -186,7 +186,7 @@ impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for TestPluginPorts
impl gateway_admin::ports::plugins::PluginStateLifecycle for TestPluginPorts {
async fn activate_state(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_core::routing::extensions::ExtensionSetReference,
_: &gateway_admin::model::plugins::instances::PluginInstance,
) -> Result<(), AdminError> {
panic!("unexpected state activation")
@@ -196,7 +196,7 @@ impl gateway_admin::ports::plugins::PluginStateLifecycle for TestPluginPorts {
}
async fn migrate_state(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_core::routing::extensions::ExtensionSetReference,
_: gateway_admin::model::plugins::state::PluginStateTransition,
) -> Result<(), AdminError> {
panic!("unexpected state migration")
@@ -222,7 +222,7 @@ impl gateway_admin::ports::plugins::PluginPreparation for TestPluginPorts {
async fn prepare(
&self,
_: gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
) -> Result<gateway_core::runtime::extensions::ExtensionSetReference, AdminError> {
) -> Result<gateway_core::routing::extensions::ExtensionSetReference, AdminError> {
Err(AdminError::invalid("unused plugin fixture"))
}
}
@@ -163,7 +163,37 @@ fn sdk_settings_contract_matches_host_declarations() {
_ => false,
})
.unwrap_or_else(|| panic!("缺少合同类型 {name}"));
if let Item::Struct(item) = item
let mut item = item.clone();
// SDK 保持独立的平铺合同,不暴露宿主内部的值对象组合
if matches!(*name, "RuntimeSettings" | "ReplaceRuntimeSettings") {
let values = items
.iter()
.find_map(|item| match item {
Item::Struct(item) if item.ident == "RuntimeSettingsValues" => {
Some(&item.fields)
}
_ => None,
})
.expect("settings values");
let Item::Struct(record) = &mut item else {
unreachable!()
};
let syn::Fields::Named(fields) = &mut record.fields else {
unreachable!()
};
fields.named = fields
.named
.iter()
.flat_map(|field| {
if field.ident.as_ref().is_some_and(|ident| ident == "values") {
values.iter().cloned().collect::<Vec<_>>()
} else {
vec![field.clone()]
}
})
.collect();
}
if let Item::Struct(item) = &item
&& item.ident == "ReplaceRuntimeSettings"
{
replacement_fields = item
@@ -173,7 +203,7 @@ fn sdk_settings_contract_matches_host_declarations() {
.collect();
}
types.push(contract_type(
item.clone(),
item,
if *name == "SmartSchedulingValues" {
"SmartSchedulingConfig"
} else {
@@ -217,7 +247,7 @@ fn sdk_settings_contract_matches_host_declarations() {
_ => None,
})
.collect();
let register = source(root, "src/service/settings.rs")
let register = source(root, "src/public_service/settings.rs")
.items
.into_iter()
.find_map(|item| match item {
@@ -263,7 +293,7 @@ fn sdk_settings_contract_matches_host_declarations() {
let generated = quote! {
//! 宿主设置服务的操作标识与请求、响应数据合同
//!
//! 从宿主设置类型与 service/settings.rs 生成;更新命令见 SDK 维护说明
//! 从宿主设置类型与 public_service/settings.rs 生成;更新命令见 SDK 维护说明
use super::Operation;
use serde::{Deserialize, Serialize};
@@ -273,6 +303,7 @@ fn sdk_settings_contract_matches_host_declarations() {
pub type ModelMappings = BTreeMap<String, String>;
pub type Revision = NonZeroU64;
pub type RotationStrategy = String;
pub type AccountAffinity = String;
pub type PricingOverrides = BTreeMap<String, BTreeMap<String, ModelPriceOverride>>;
#(#types)*
#(#operations)*
@@ -28,7 +28,7 @@ struct PricingSettingsStore {
#[tokio::test]
async fn explicit_service_calls_freeze_empty_plan_and_cancel_before_entering_terminal() {
use gateway_admin::service::{Origin, Plan, scope};
use gateway_admin::public_service::{Origin, Plan, scope};
use std::sync::atomic::{AtomicUsize, Ordering};
let resolutions = Arc::new(AtomicUsize::new(0));
@@ -277,35 +277,40 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
ReplaceRuntimeSettings {
expected_revision: gateway_admin::model::Revision::new(1).unwrap(),
request_profile_updates: Default::default(),
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,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
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,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: 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,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
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,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
},
)
.await
@@ -16,8 +16,9 @@ use gateway_admin::model::{
PageSize,
account_groups::{
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupColor, AccountGroupListQuery,
AccountGroupMutation, AccountGroupPage, AccountGroupRecord, AccountGroupUsage,
CreateAccountGroup, DeleteAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
AccountGroupMutation, AccountGroupOptionsPage, AccountGroupPage, AccountGroupRecord,
AccountGroupUsage, CreateAccountGroup, DeleteAccountGroup, SetAccountGroupEnabled,
UpdateAccountGroup,
},
};
use gateway_core::{account::FastMode, routing::AccountGroupId};
@@ -28,6 +29,27 @@ use super::{
WireValidationError, wire::map_admin_service_error,
};
/// 账号、代理与 Client Key 共用的分组引用
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountGroupRefView {
pub id: String,
pub name: String,
pub color: String,
pub enabled: bool,
}
impl From<gateway_admin::model::account_groups::AccountGroupRef> for AccountGroupRefView {
fn from(group: gateway_admin::model::account_groups::AccountGroupRef) -> Self {
Self {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
}
}
}
const DEFAULT_PAGE_SIZE: u32 = 50;
const MAX_PAGE_SIZE: u32 = 200;
@@ -191,6 +213,38 @@ struct AccountGroupPageData {
config_revision: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountGroupOptionsPageData {
items: Vec<AccountGroupRefView>,
page: PageMeta,
config_revision: u64,
}
impl From<AccountGroupOptionsPage> for AccountGroupOptionsPageData {
fn from(page: AccountGroupOptionsPage) -> Self {
let total_pages = if page.total == 0 {
0
} else {
page.total.div_ceil(u64::from(page.page_size))
};
Self {
items: page
.items
.into_iter()
.map(AccountGroupRefView::from)
.collect(),
page: PageMeta::new(
page.page,
u32::from(page.page_size),
page.total,
u32::try_from(total_pages).unwrap_or(u32::MAX),
),
config_revision: page.config_revision.get(),
}
}
}
impl From<(AccountGroupPage, crate::time::TimePresenter)> for AccountGroupPageData {
fn from((page, time): (AccountGroupPage, crate::time::TimePresenter)) -> Self {
let total_pages = if page.total == 0 {
@@ -242,6 +296,7 @@ where
{
Router::new()
.route("/api/admin/account-groups", get(list::<S>))
.route("/api/admin/account-groups/options", get(list_options::<S>))
.route("/api/admin/account-groups/create", post(create::<S>))
.route("/api/admin/account-groups/update", post(update::<S>))
.route("/api/admin/account-groups/enable", post(enable::<S>))
@@ -249,6 +304,26 @@ where
.route("/api/admin/account-groups/delete", post(delete::<S>))
}
async fn list_options<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<ListAccountGroupsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
.account_groups()
.list_options(query.into_command().map_err(map_wire_error)?)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountGroupOptionsPageData::from(result)),
))
}
async fn list<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -420,16 +495,8 @@ fn group_color(value: &str) -> Result<AccountGroupColor, AdminError> {
}
fn validate_group_fields(name: &str, description: Option<&str>) -> Result<(), AdminError> {
if name.trim() != name
|| name.is_empty()
|| name.chars().count() > 100
|| name.chars().any(char::is_control)
|| description
.is_some_and(|value| value.len() > 4096 || value.chars().any(char::is_control))
{
return Err(AdminError::bad_request("账号组请求不合法"));
}
Ok(())
gateway_admin::model::account_groups::validate_group_fields(name, description)
.map_err(|_| AdminError::bad_request("账号组请求不合法"))
}
fn map_wire_error(_: WireValidationError) -> AdminError {
@@ -96,12 +96,7 @@ pub(super) fn account_view(
groups: account
.groups
.into_iter()
.map(|group| AccountGroupRefView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.map(AccountGroupRefView::from)
.collect(),
resource_ref: account.id,
email: account.email,
@@ -378,14 +378,7 @@ pub struct QuotaForecastSourceView {
pub usd_display: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountGroupRefView {
pub id: String,
pub name: String,
pub color: String,
pub enabled: bool,
}
pub use crate::admin::account_groups::AccountGroupRefView;
/// Provider quota 安全视图
#[derive(Debug, Clone, Serialize)]
@@ -29,6 +29,7 @@ use axum::{
routing::{get, post},
};
use super::account_groups::AccountGroupRefView;
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
WireValidationError, wire::map_admin_service_error,
@@ -342,7 +343,7 @@ pub struct ClientKeyView {
name: String,
label: Option<String>,
routing_scope: &'static str,
groups: Vec<ClientKeyGroupView>,
groups: Vec<AccountGroupRefView>,
provider_kinds: Vec<String>,
prefix: String,
enabled: bool,
@@ -365,15 +366,6 @@ pub struct ClientKeyView {
last_used_at_full_display: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientKeyGroupView {
id: String,
name: String,
color: String,
enabled: bool,
}
impl From<(ClientKeyRecord, crate::time::TimePresenter)> for ClientKeyView {
fn from((record, time): (ClientKeyRecord, crate::time::TimePresenter)) -> Self {
let routing_scope = if record.groups.is_empty() {
@@ -399,12 +391,7 @@ impl From<(ClientKeyRecord, crate::time::TimePresenter)> for ClientKeyView {
groups: record
.groups
.into_iter()
.map(|group| ClientKeyGroupView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.map(AccountGroupRefView::from)
.collect(),
provider_kinds: record
.provider_kinds
@@ -847,19 +834,13 @@ fn validate_required_text(value: &str, field: &'static str) -> Result<(), WireVa
}
fn validate_group_ids(values: Vec<String>) -> Result<Vec<AccountGroupId>, WireValidationError> {
if values.len() > 1000
|| values
.iter()
.collect::<std::collections::BTreeSet<_>>()
.len()
!= values.len()
{
return Err(WireValidationError::new("groupIds"));
}
values
let groups = values
.into_iter()
.map(|value| AccountGroupId::new(value).map_err(|_| WireValidationError::new("groupIds")))
.collect()
.collect::<Result<Vec<_>, _>>()?;
gateway_admin::model::client_keys::validate_group_ids(&groups)
.map_err(|_| WireValidationError::new("groupIds"))?;
Ok(groups)
}
fn validate_optional_text(
@@ -0,0 +1,42 @@
//! 管理请求在 HTTP 出口记录一次受控详情,响应只保留安全投影
use std::sync::Arc;
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
};
use gateway_core::diagnostics::{OperationalDiagnostics, OperationalFailure};
use tower_http::request_id::RequestId;
pub(crate) async fn record_failure(
State(diagnostics): State<Arc<dyn OperationalDiagnostics>>,
request: Request,
next: Next,
) -> Response {
let correlation_id = request
.extensions()
.get::<RequestId>()
.and_then(|id| id.header_value().to_str().ok())
.map(str::to_owned);
let mut response = next.run(request).await;
if let Some(error) = response
.extensions_mut()
.remove::<gateway_admin::model::AdminError>()
&& let Some(details) = error.error_details()
{
let mut failure = OperationalFailure::new(
"admin",
"http_request",
error.kind().as_str(),
error.message(),
);
failure.correlation_id = correlation_id;
failure.details = Some(details);
if diagnostics.record_failure(failure).await.is_err() {
tracing::warn!("admin diagnostic could not be recorded");
}
}
response
}
@@ -15,6 +15,7 @@ pub mod accounts;
pub mod auth;
pub mod backups;
pub mod client_keys;
pub(crate) mod diagnostics;
mod extract;
pub mod observability;
mod plugins;
@@ -4,7 +4,9 @@ use std::collections::BTreeMap;
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
use chrono::{DateTime, Utc};
use gateway_admin::model::{PageSize as DomainPageSize, observability as domain};
use gateway_admin::model::observability::{
self as domain, ObservabilityPageSize as DomainPageSize,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -307,8 +307,14 @@ pub(crate) fn usage_list_record_view(
first_event_ms: record.first_event_ms,
first_reasoning_ms: record.first_reasoning_ms,
first_text_ms: record.first_text_ms,
first_token_ms: record.first_token_ms,
openai_processing_ms: record.provider_processing_ms,
upstream_response_ms: record.upstream_response_ms,
upstream_api_overhead_ms: record.upstream_api_overhead_ms,
upstream_engine_ms: record.upstream_engine_ms,
upstream_engine_iapi_ttft_ms: record.upstream_engine_iapi_ttft_ms,
upstream_engine_service_ttft_ms: record.upstream_engine_service_ttft_ms,
upstream_engine_iapi_tbt_ms: record.upstream_engine_iapi_tbt_ms,
upstream_engine_service_tbt_ms: record.upstream_engine_service_tbt_ms,
},
first_token_latency_ms: record.first_token_ms,
latency_ms: record.latency_ms,
@@ -345,7 +351,6 @@ pub(crate) fn usage_record_view(
.error_message
.clone()
.unwrap_or_else(|| outcome.clone());
let first_token_display = display_duration(record.first_token_ms);
let latency_display = display_duration(record.latency_ms);
let cost_coverage = match record.cost_source.as_str() {
"provider_reported" | "calculated" => CostCoverageView {
@@ -409,15 +414,20 @@ pub(crate) fn usage_record_view(
first_event_ms: record.first_event_ms,
first_reasoning_ms: record.first_reasoning_ms,
first_text_ms: record.first_text_ms,
first_token_ms: record.first_token_ms,
openai_processing_ms: record.provider_processing_ms,
upstream_response_ms: record.upstream_response_ms,
upstream_api_overhead_ms: record.upstream_api_overhead_ms,
upstream_engine_ms: record.upstream_engine_ms,
upstream_engine_iapi_ttft_ms: record.upstream_engine_iapi_ttft_ms,
upstream_engine_service_ttft_ms: record.upstream_engine_service_ttft_ms,
upstream_engine_iapi_tbt_ms: record.upstream_engine_iapi_tbt_ms,
upstream_engine_service_tbt_ms: record.upstream_engine_service_tbt_ms,
},
attempt_index: None,
attempt_count: u64::from(record.attempt_count),
response_id: record.client_response_id,
upstream_request_id: record.upstream_request_id,
latency_ms: record.latency_ms,
first_token_ms: record.first_token_ms,
input_tokens: record.input_tokens,
output_tokens: record.output_tokens,
cached_tokens: record.cached_tokens,
@@ -441,7 +451,6 @@ pub(crate) fn usage_record_view(
costs,
cost_coverage,
first_token_latency_ms: record.first_token_ms,
first_token_latency_ms_display: first_token_display,
latency_ms_display: latency_display,
logical_outcome: outcome,
}
@@ -617,7 +626,7 @@ pub(crate) fn ops_error_view(
subagent_kind: error.subagent_kind,
compact: error.compact,
message: error.message,
raw_upstream_error: error.raw_upstream_error,
error_details: error.error_details,
metadata: OpsErrorMetadataView {
source: error.source,
component: error.component,
@@ -756,7 +765,7 @@ pub(crate) fn trend_summary_view(
ratio: None,
},
TrendSummaryView {
label: "吞吐 P50".to_owned(),
label: "速率 P50".to_owned(),
value: summary
.minimum_output_throughput_p50
.map_or_else(|| "—".to_owned(), |value| format!("{value} tok/s")),
@@ -904,6 +913,7 @@ pub(crate) fn dashboard_view(
rotation_strategy,
} = result;
let domain::DashboardObservation {
runtime_slots: _,
range,
totals,
provider_accounts,
@@ -5,7 +5,7 @@ use super::*;
/// 观测列表默认页大小
pub const DEFAULT_PAGE_SIZE: u16 = 50;
/// 观测列表允许的最大页大小
pub const MAX_PAGE_SIZE: u16 = 100;
pub const MAX_PAGE_SIZE: u16 = DomainPageSize::MAX;
/// Dashboard 查询参数
#[derive(Clone, Default, Deserialize)]
@@ -54,15 +54,7 @@ pub struct UsageQuery {
impl UsageQuery {
/// 校验 Element Plus 风格的页码分页字段
pub fn validate_pagination(&self) -> Result<(u32, u16), WireValidationError> {
let current_page = self.current_page.unwrap_or(1);
if current_page == 0 {
return Err(WireValidationError::new("currentPage"));
}
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
if page_size == 0 || page_size > MAX_PAGE_SIZE {
return Err(WireValidationError::new("pageSize"));
}
Ok((current_page, page_size))
validate_pagination(self.current_page, self.page_size)
}
}
@@ -131,15 +123,7 @@ pub struct OpsQuery {
impl OpsQuery {
/// 校验 Element Plus 风格的页码分页字段
pub fn validate_pagination(&self) -> Result<(u32, u16), WireValidationError> {
let current_page = self.current_page.unwrap_or(1);
if current_page == 0 {
return Err(WireValidationError::new("currentPage"));
}
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
if page_size == 0 || page_size > MAX_PAGE_SIZE {
return Err(WireValidationError::new("pageSize"));
}
Ok((current_page, page_size))
validate_pagination(self.current_page, self.page_size)
}
}
@@ -396,3 +380,18 @@ pub(crate) fn non_empty(value: Option<String>) -> Option<String> {
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn validate_pagination(
current_page: Option<u32>,
page_size: Option<u16>,
) -> Result<(u32, u16), WireValidationError> {
let current_page = current_page.unwrap_or(1);
if current_page == 0 {
return Err(WireValidationError::new("currentPage"));
}
let page_size = page_size.unwrap_or(DEFAULT_PAGE_SIZE);
if DomainPageSize::new(page_size).is_err() {
return Err(WireValidationError::new("pageSize"));
}
Ok((current_page, page_size))
}
@@ -160,7 +160,6 @@ pub struct UsageRecordView {
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub latency_ms: Option<u64>,
pub first_token_ms: Option<u64>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
@@ -185,7 +184,6 @@ pub struct UsageRecordView {
pub costs: Vec<CostView>,
pub cost_coverage: CostCoverageView,
pub first_token_latency_ms: Option<u64>,
pub first_token_latency_ms_display: String,
pub latency_ms_display: String,
pub logical_outcome: String,
}
@@ -222,9 +220,21 @@ pub struct UsageLatencyDetailsView {
#[serde(skip_serializing_if = "Option::is_none")]
pub first_text_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_token_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub openai_processing_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_response_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_api_overhead_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_engine_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_engine_iapi_ttft_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_engine_service_ttft_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_engine_iapi_tbt_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_engine_service_tbt_ms: Option<f64>,
}
/// 单次上游尝试展示
@@ -814,8 +824,8 @@ pub struct OpsErrorView {
pub subagent_kind: Option<String>,
pub compact: Option<bool>,
pub message: String,
/// 上游错误正文或 WebSocket close/error frame 原文;不做脱敏
pub raw_upstream_error: Option<String>,
/// 受控错误详情,分别承载本地原因链与上游错误正文
pub error_details: Option<String>,
pub metadata: OpsErrorMetadataView,
pub created_at: DateTime<Utc>,
pub created_at_display: String,
@@ -324,12 +324,7 @@ where
groups: account
.groups
.into_iter()
.map(|group| AccountGroupRefView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.map(AccountGroupRefView::from)
.collect(),
enabled: account.enabled,
})
+159 -233
View File
@@ -18,7 +18,6 @@ use gateway_admin::model::client_distribution::{
use gateway_admin::model::settings::{
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings,
};
use gateway_core::policy::CodexClientVersion;
use gateway_core::routing::{PublicModelId, UpstreamModelId};
use serde::{Deserialize, Serialize};
@@ -33,20 +32,12 @@ pub type ProviderRequestProfiles = BTreeMap<String, serde_json::Map<String, serd
pub type ProviderRequestProfileUpdates =
BTreeMap<String, Option<serde_json::Map<String, serde_json::Value>>>;
/// 运行配置投影与设置页字段的聚合响应
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
/// HTTP 读写共用的设置字段;仅保留 wire 数值类型与命名
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
pub config_revision: u64,
pub smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig,
pub provider_request_profiles: ProviderRequestProfiles,
/// 固定兼容字段;值始终从 provider_request_profiles 派生
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
/// 固定兼容字段;值始终从 provider_request_profiles 派生
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub struct RuntimeSettingsFields {
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,
@@ -55,9 +46,11 @@ pub struct RuntimeSettingsView {
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
pub max_account_rotations: u32,
pub openai_session_affinity_ttl_hours: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
@@ -73,6 +66,23 @@ pub struct RuntimeSettingsView {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
}
/// 运行配置投影与设置页字段的聚合响应
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
#[serde(flatten)]
pub values: RuntimeSettingsFields,
pub config_revision: u64,
pub smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig,
pub provider_request_profiles: ProviderRequestProfiles,
/// 固定兼容字段;值始终从 provider_request_profiles 派生
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
/// 固定兼容字段;值始终从 provider_request_profiles 派生
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub model_mappings: ModelMappings,
pub rotation_strategy: String,
pub updated_at: DateTime<Utc>,
pub updated_at_display: String,
}
@@ -81,6 +91,8 @@ pub struct RuntimeSettingsView {
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateRuntimeSettingsRequest {
#[serde(flatten)]
pub values: RuntimeSettingsFields,
pub config_revision: u64,
#[serde(default)]
pub provider_request_profiles: ProviderRequestProfileUpdates,
@@ -90,128 +102,22 @@ pub struct UpdateRuntimeSettingsRequest {
/// 兼容既有 wire;与泛化字段冲突时拒绝整个请求
#[serde(default, deserialize_with = "deserialize_profile_update")]
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
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 openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
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 account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
}
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"),
(self.usage_retention_days, "usageRetentionDays"),
(self.ops_event_retention_days, "opsEventRetentionDays"),
(self.audit_retention_days, "auditRetentionDays"),
] {
require_positive_i64(value, field)?;
}
if i64::try_from(self.request_interval_ms).is_err() {
return Err(WireValidationError::new("requestIntervalMs"));
}
if RotationStrategy::parse(&self.rotation_strategy).is_none() {
return Err(WireValidationError::new("rotationStrategy"));
}
validate_optional_client_version(
self.min_codex_desktop_version.as_deref(),
"minCodexDesktopVersion",
)?;
validate_optional_client_version(
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",
)?;
if !gateway_core::provider_ports::valid_warmup_schedule_time(
&self.account_warmup_schedule_time,
) {
return Err(WireValidationError::new("accountWarmupScheduleTime"));
}
validate_optional_probe_model(self.account_warmup_model.as_deref(), "accountWarmupModel")?;
if self.account_warmup_enabled && self.account_warmup_model.is_none() {
return Err(WireValidationError::new("accountWarmupModel"));
}
Ok(())
self.values.clone().into_values().map(|_| ())
}
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
self.validate()?;
validate_model_mappings(&self.model_mappings)?;
let request_profile_updates = normalize_request_profile_updates(
self.provider_request_profiles,
self.openai_client_profile,
@@ -221,45 +127,10 @@ impl UpdateRuntimeSettingsRequest {
expected_revision: gateway_admin::model::Revision::new(self.config_revision)
.map_err(|_| WireValidationError::new("configRevision"))?,
request_profile_updates,
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)
.map_err(|_| WireValidationError::new("settingsRefreshConcurrencyOverflow"))?,
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,
openai_guardian_reserved_concurrency: self.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
smart_scheduling: self.smart_scheduling,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
min_codex_desktop_version: self.min_codex_desktop_version,
min_codex_cli_version: self.min_codex_cli_version,
usage_retention_days: u32::try_from(self.usage_retention_days)
.map_err(|_| WireValidationError::new("settingsUsageRetentionOverflow"))?,
ops_event_retention_days: u32::try_from(self.ops_event_retention_days)
.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,
account_warmup_enabled: self.account_warmup_enabled,
account_warmup_schedule_time: self.account_warmup_schedule_time,
account_warmup_model: self.account_warmup_model,
values: self.values.into_values()?,
})
}
}
@@ -276,39 +147,12 @@ impl From<(RuntimeSettings, crate::time::TimePresenter)> for RuntimeSettingsView
openai_client_profile: provider_request_profiles.get("openai").cloned(),
xai_client_profile: provider_request_profiles.get("xai").cloned(),
provider_request_profiles,
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,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig::default(),
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,
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
updated_at_display: time.datetime(&settings.updated_at),
updated_at: settings.updated_at,
values: settings.values.into(),
}
}
}
@@ -699,23 +543,13 @@ where
))
}
fn require_positive_i64(value: u64, field: &'static str) -> Result<(), WireValidationError> {
if value == 0 || i64::try_from(value).is_err() {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn validate_model_mappings(mappings: &ModelMappings) -> Result<(), WireValidationError> {
if mappings.len() > 512 {
return Err(WireValidationError::new("modelMappings"));
}
for (requested, upstream) in mappings {
if !valid_model_name(requested, 256) || !valid_model_name(upstream, 256) {
return Err(WireValidationError::new("modelMappings"));
}
}
Ok(())
gateway_admin::model::settings::validate_model_mappings(
mappings
.iter()
.map(|(requested, upstream)| (requested.as_str(), upstream.as_str())),
)
.map_err(|_| WireValidationError::new("modelMappings"))
}
fn domain_model_mappings(
@@ -741,38 +575,6 @@ fn wire_model_mappings(mappings: DomainModelMappings) -> ModelMappings {
.collect()
}
fn valid_model_name(value: &str, max_len: usize) -> bool {
!value.is_empty()
&& value.len() <= max_len
&& !value.bytes().any(|byte| byte.is_ascii_control())
}
fn validate_optional_client_version(
value: Option<&str>,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.is_some_and(|value| CodexClientVersion::parse(value).is_err()) {
return Err(WireValidationError::new(field));
}
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(),
@@ -911,3 +713,127 @@ fn client_profile_preview_view(
}
profile
}
impl RuntimeSettingsFields {
fn into_values(
self,
) -> Result<gateway_admin::model::settings::RuntimeSettingsValues, WireValidationError> {
let values = gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: self.request_location_enabled,
request_location: self
.request_location
.normalized()
.map_err(|_| WireValidationError::new("requestLocation"))?,
refresh_margin_seconds: self.refresh_margin_seconds,
refresh_concurrency: u32::try_from(self.refresh_concurrency)
.map_err(|_| WireValidationError::new("settingsRefreshConcurrencyOverflow"))?,
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,
openai_guardian_reserved_concurrency: self.openai_guardian_reserved_concurrency,
openai_account_affinity: self.openai_account_affinity,
max_account_rotations: self.max_account_rotations,
openai_session_affinity_ttl_hours: self.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
smart_scheduling: self.smart_scheduling,
min_codex_desktop_version: self.min_codex_desktop_version,
min_codex_cli_version: self.min_codex_cli_version,
usage_retention_days: u32::try_from(self.usage_retention_days)
.map_err(|_| WireValidationError::new("settingsUsageRetentionOverflow"))?,
ops_event_retention_days: u32::try_from(self.ops_event_retention_days)
.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,
account_warmup_enabled: self.account_warmup_enabled,
account_warmup_schedule_time: self.account_warmup_schedule_time,
account_warmup_model: self.account_warmup_model,
};
values.validate().map_err(settings_validation_error)?;
Ok(values)
}
}
impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSettingsFields {
fn from(settings: gateway_admin::model::settings::RuntimeSettingsValues) -> Self {
crate::admin::settings::RuntimeSettingsFields {
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
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,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
openai_account_affinity: settings.openai_account_affinity,
max_account_rotations: settings.max_account_rotations,
openai_session_affinity_ttl_hours: settings.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
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,
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
}
}
}
fn settings_validation_error(field: &'static str) -> WireValidationError {
WireValidationError::new(match field {
"account_auto_freeze_adaptive_concurrency" => "accountAutoFreezeAdaptiveConcurrency",
"account_auto_freeze_duration_seconds" => "accountAutoFreezeDurationSeconds",
"account_auto_freeze_enabled" => "accountAutoFreezeEnabled",
"account_auto_freeze_probe_enabled" => "accountAutoFreezeProbeEnabled",
"account_auto_freeze_probe_model" => "accountAutoFreezeProbeModel",
"account_auto_freeze_threshold" => "accountAutoFreezeThreshold",
"account_auto_freeze_window_seconds" => "accountAutoFreezeWindowSeconds",
"account_warmup_enabled" => "accountWarmupEnabled",
"account_warmup_model" => "accountWarmupModel",
"account_warmup_schedule_time" => "accountWarmupScheduleTime",
"audit_retention_days" => "auditRetentionDays",
"concurrency_wait_timeout_seconds" => "concurrencyWaitTimeoutSeconds",
"max_account_rotations" => "maxAccountRotations",
"max_concurrent_per_account" => "maxConcurrentPerAccount",
"max_waiting_per_account" => "maxWaitingPerAccount",
"max_waiting_per_key" => "maxWaitingPerKey",
"min_codex_cli_version" => "minCodexCliVersion",
"min_codex_desktop_version" => "minCodexDesktopVersion",
"openai_account_affinity" => "openaiAccountAffinity",
"openai_guardian_reserved_concurrency" => "openaiGuardianReservedConcurrency",
"openai_session_affinity_ttl_hours" => "openaiSessionAffinityTtlHours",
"ops_event_retention_days" => "opsEventRetentionDays",
"refresh_concurrency" => "refreshConcurrency",
"refresh_margin_seconds" => "refreshMarginSeconds",
"request_interval_ms" => "requestIntervalMs",
"request_location" => "requestLocation",
"request_location_enabled" => "requestLocationEnabled",
"responses_max_decompressed_body_bytes" => "responsesMaxDecompressedBodyBytes",
"smart_scheduling" => "smartScheduling",
"usage_retention_days" => "usageRetentionDays",
_ => "settings",
})
}
@@ -170,6 +170,7 @@ pub struct AdminError {
status: StatusCode,
body: AdminErrorBody,
retry_after_seconds: Option<u64>,
diagnostic: Option<gateway_admin::model::AdminError>,
}
#[derive(Debug, Clone, Copy)]
@@ -266,6 +267,7 @@ impl AdminError {
status,
body: AdminErrorBody::new(code, message),
retry_after_seconds: None,
diagnostic: None,
}
}
@@ -380,12 +382,16 @@ pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -
{
response.body.message = error.message().to_owned();
}
response.diagnostic = Some(error);
response
}
impl IntoResponse for AdminError {
fn into_response(self) -> Response {
let mut response = (self.status, Json(self.body)).into_response();
if let Some(error) = self.diagnostic {
response.extensions_mut().insert(error);
}
if let Some(seconds) = self.retry_after_seconds
&& let Ok(value) = axum::http::HeaderValue::from_str(&seconds.to_string())
{
+64 -28
View File
@@ -114,6 +114,7 @@ where
Router::new()
.route("/api/auth/login", post(login::<S>))
.route("/api/auth/status", get(session_status::<S>))
.route("/api/auth/refresh", post(refresh_session::<S>))
.route("/api/auth/logout", post(logout::<S>))
.route("/api/auth/password", post(change_password::<S>))
.route("/api/auth", any(not_found))
@@ -141,32 +142,16 @@ where
)
.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)),
set_session_cookie(
AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(SessionData::from(&result.session)),
)
.into_response(),
&headers,
&result.session_id,
&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>(
@@ -182,13 +167,64 @@ where
.session(session_cookie::value(&headers).as_deref())
.await
.map_err(map_admin_service_error)?;
Ok(AdminResponse::new(
Ok(session_response(session.as_ref()))
}
async fn refresh_session<S>(
State(state): State<S>,
headers: HeaderMap,
) -> Result<Response, AdminError>
where
S: SessionState + Send + Sync,
{
let session_id = session_cookie::value(&headers);
let session = state
.admin_services()
.auth()
.renew_session(session_id.as_deref())
.await
.map_err(map_admin_service_error)?;
let response = session_response(session.as_ref());
if let (Some(session_id), Some(session)) = (session_id, session) {
return set_session_cookie(response, &headers, &session_id, &session);
}
// 失效响应不清 Cookie,避免晚到的请求覆盖另一个标签页刚建立的会话
Ok(response)
}
fn session_response(session: Option<&AuthSession>) -> Response {
AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(SessionStatusData {
authenticated: session.is_some(),
session: session.as_ref().map(SessionData::from),
session: session.map(SessionData::from),
}),
))
)
.into_response()
}
fn set_session_cookie(
mut response: Response,
headers: &HeaderMap,
session_id: &str,
session: &AuthSession,
) -> Result<Response, AdminError> {
let max_age = session
.expires_at
.signed_duration_since(chrono::Utc::now())
.num_seconds()
.max(1);
let expires = session.expires_at.format("%a, %d %b %Y %H:%M:%S GMT");
let cookie = format!(
"{}={session_id}; {}; Max-Age={max_age}; Expires={expires}",
session_cookie::NAME,
session_cookie::attributes(headers)
);
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
);
Ok(response)
}
async fn logout<S>(State(state): State<S>, headers: HeaderMap) -> Result<Response, AdminError>
+89 -15
View File
@@ -1,10 +1,12 @@
//! `/healthz` 对 Core、Store 与 Host 关键 worker 健康事实的聚合
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::{extract::State, http::StatusCode};
use futures::future::join_all;
use gateway_core::diagnostics::{OperationalDiagnostics, OperationalFailure};
use gateway_core::error::{ErrorDetails, ErrorSource};
use gateway_core::health::{
HealthProbe, HealthState, WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource,
WorkerRuntimeState,
@@ -19,6 +21,16 @@ const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
pub(crate) struct HealthStatus {
probes: Arc<[Arc<dyn HealthProbe>]>,
workers: Arc<dyn WorkerHealthSource>,
diagnostics: Arc<dyn OperationalDiagnostics>,
failures: Arc<Mutex<Vec<HealthFailure>>>,
}
#[derive(Clone, PartialEq, Eq, Debug, thiserror::Error)]
#[error("{component} is {state}: {reason}")]
struct HealthFailure {
component: String,
state: &'static str,
reason: String,
}
impl HealthStatus {
@@ -26,31 +38,93 @@ impl HealthStatus {
pub(crate) fn new(
probes: Vec<Arc<dyn HealthProbe>>,
workers: Arc<dyn WorkerHealthSource>,
diagnostics: Arc<dyn OperationalDiagnostics>,
) -> Self {
Self {
probes: probes.into(),
workers,
diagnostics,
failures: Arc::default(),
}
}
pub(crate) async fn healthy(&self) -> bool {
if !self
let mut failures: Vec<_> = self
.workers
.snapshot()
.iter()
.filter(|worker| worker_affects_healthz(worker))
.all(worker_is_healthy)
{
return false;
}
let checks = self.probes.iter().map(|probe| probe.check());
tokio::time::timeout(HEALTH_CHECK_TIMEOUT, join_all(checks))
.await
.is_ok_and(|states| {
states
.into_iter()
.all(|state| matches!(state, HealthState::Healthy))
.into_iter()
.filter(|worker| worker_affects_healthz(worker) && !worker_is_healthy(worker))
.map(|worker| HealthFailure {
component: match worker.key {
WorkerHealthKey::Task(id) => format!("worker:{id}"),
WorkerHealthKey::Disabled(kind) => format!("worker:{kind:?}"),
},
state: "unhealthy",
reason: format!(
"state={:?}; last_error={}",
worker.state,
worker.last_error.as_deref().unwrap_or("none")
),
})
.collect();
// 逐个探针共享截止时间,保留已完成结果并指出具体超时组件
let deadline = tokio::time::Instant::now() + HEALTH_CHECK_TIMEOUT;
let checks = self.probes.iter().map(|probe| async move {
let (state, reason) = match tokio::time::timeout_at(deadline, probe.check()).await {
Ok(HealthState::Healthy) => return None,
Ok(HealthState::Degraded(reason)) => ("degraded", reason),
Ok(HealthState::Unhealthy(reason)) => ("unhealthy", reason),
Err(_) => ("timed_out", "health probe exceeded 2s deadline".to_owned()),
};
Some(HealthFailure {
component: format!("probe:{}", probe.name()),
state,
reason,
})
});
failures.extend(join_all(checks).await.into_iter().flatten());
let healthy = failures.is_empty();
let changed = {
let mut previous = self
.failures
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let changed = failures
.iter()
.filter(|failure| !previous.contains(failure))
.cloned()
.collect::<Vec<_>>();
for recovered in previous
.iter()
.filter(|old| !failures.iter().any(|new| new.component == old.component))
{
tracing::info!(component = %recovered.component, "health component recovered");
}
*previous = failures;
changed
};
// 只记录故障变化;使用既有有界观测队列,文件日志失效时仍有独立的运维证据
for failure in changed {
tracing::warn!(component = %failure.component, state = failure.state, "health check failed");
let mut event = OperationalFailure::new(
"health",
"check",
"health_check_failed",
"Health check failed",
);
event.correlation_id = Some(failure.component.clone());
event.details =
ErrorDetails::capture(Some(&ErrorSource::new(failure.clone())), None, false);
if self.diagnostics.record_failure(event).await.is_err() {
tracing::warn!("health diagnostic could not be recorded");
// 入队失败不能抑制下次探测的记录机会,也不改变本次健康判定
self.failures
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain(|previous| previous != &failure);
}
}
healthy
}
}
@@ -201,6 +201,20 @@ pub(super) struct RecordView {
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct OutputTimingView {
#[serde(skip_serializing_if = "Option::is_none")]
upstream_response_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
upstream_api_overhead_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
upstream_engine_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
upstream_engine_iapi_ttft_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
upstream_engine_service_ttft_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
upstream_engine_iapi_tbt_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
upstream_engine_service_tbt_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
first_event_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -226,6 +240,13 @@ fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> R
latency_ms: value.latency_ms,
first_token_latency_ms: value.first_token_ms,
latency_details: OutputTimingView {
upstream_response_ms: value.upstream_response_ms,
upstream_api_overhead_ms: value.upstream_api_overhead_ms,
upstream_engine_ms: value.upstream_engine_ms,
upstream_engine_iapi_ttft_ms: value.upstream_engine_iapi_ttft_ms,
upstream_engine_service_ttft_ms: value.upstream_engine_service_ttft_ms,
upstream_engine_iapi_tbt_ms: value.upstream_engine_iapi_tbt_ms,
upstream_engine_service_tbt_ms: value.upstream_engine_service_tbt_ms,
first_event_ms: value.first_event_ms,
first_reasoning_ms: value.first_reasoning_ms,
first_text_ms: value.first_text_ms,
@@ -3,8 +3,8 @@
use axum::http::StatusCode;
use chrono::Duration;
use gateway_admin::model::{
PageSize,
key_usage::{KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecordsQuery},
observability::ObservabilityPageSize,
};
use serde::Deserialize;
@@ -89,13 +89,14 @@ impl RecordsQuery {
timezone: gateway_core::time::DeploymentTimeZone,
) -> 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",
));
let invalid_page = || {
AdminError::invalid_request(StatusCode::BAD_REQUEST, "页码必须大于 0,每页数量为 1–100")
};
if current_page == 0 {
return Err(invalid_page());
}
let page_size =
ObservabilityPageSize::new(self.page_size.unwrap_or(20)).map_err(|_| invalid_page())?;
Ok(KeyUsageRecordsQuery {
usage: OverviewQuery {
period: self.period,
@@ -110,9 +111,7 @@ impl RecordsQuery {
RecordKind::Error => KeyUsageRecordKind::Error,
},
current_page,
page_size: PageSize::new(page_size).map_err(|_| {
AdminError::invalid_request(StatusCode::BAD_REQUEST, "每页数量不合法")
})?,
page_size,
})
}
}
+7 -2
View File
@@ -138,7 +138,7 @@ impl ApiBundle {
}
/// 内部调用使用同一总路由;组合根持有强引用,Runtime 只保存 Weak
pub fn dispatcher(&self) -> Arc<dyn gateway_core::middleware::http::Dispatcher> {
pub fn dispatcher(&self) -> Arc<dyn gateway_core::engine::middleware::http::Dispatcher> {
Arc::new(middleware::RouterDispatcher {
router: middleware::wrap(
self.router.clone(),
@@ -170,6 +170,7 @@ pub fn initialize(
probes: Vec<Arc<dyn HealthProbe>>,
worker_health: Arc<dyn WorkerHealthSource>,
lifecycle: Arc<dyn ConnectionLifecycle>,
diagnostics: Arc<dyn gateway_core::diagnostics::OperationalDiagnostics>,
) -> Result<ApiBundle, ApiError> {
// 配置加载已解析环境变量与相对路径,初始化只校验,避免覆盖最终目录
config.validate().map_err(ApiError::Config)?;
@@ -189,7 +190,7 @@ pub fn initialize(
let state = ApiState {
admin,
openai: OpenAiService::new(execution, lifecycle),
health: HealthStatus::new(probes, worker_health),
health: HealthStatus::new(probes, worker_health, diagnostics.clone()),
};
let index = config.asset_directory.join("index.html");
let mut router = Router::new()
@@ -256,6 +257,10 @@ pub fn initialize(
headers_ms = latency.as_millis(), "HTTP response headers ready");
});
let router = router
.layer(axum::middleware::from_fn_with_state(
diagnostics,
admin::diagnostics::record_failure,
))
.layer(PropagateRequestIdLayer::new(request_id_header.clone()))
.layer(trace_layer)
.layer(SetRequestIdLayer::new(
@@ -17,23 +17,21 @@ use axum::{
http::{HeaderName, HeaderValue, Request, StatusCode},
response::{IntoResponse as _, Response},
};
use gateway_core::{
engine::middleware::FrozenMiddlewarePlan,
lifecycle::CancellationToken,
middleware::{
compose,
http::{self as contract, Settings},
},
};
use gateway_core::engine::middleware::FrozenMiddlewarePlan;
use gateway_core::engine::middleware::http as contract;
use gateway_core::engine::middleware::http::Settings;
use gateway_core::lifecycle::CancellationToken;
use gateway_core::middleware::compose;
use http_body::{Body as HttpBody, Frame, SizeHint};
use http_body_util::BodyExt as _;
use tokio::time::Instant;
use tower::ServiceExt as _;
tokio::task_local! { static CURRENT: contract::Context; }
tokio::task_local! { static SETTINGS: Option<gateway_core::settings::RequestSettings>; }
tokio::task_local! { static SETTINGS: Option<gateway_core::routing::request_settings::RequestSettings>; }
pub(crate) fn current_settings() -> Option<gateway_core::settings::RequestSettings> {
pub(crate) fn current_settings() -> Option<gateway_core::routing::request_settings::RequestSettings>
{
SETTINGS.try_with(Clone::clone).ok().flatten()
}
@@ -52,7 +50,7 @@ pub(crate) async fn scope<T>(
}
pub(crate) type SettingsSource =
Arc<dyn Fn() -> Option<gateway_core::settings::RequestSettings> + Send + Sync>;
Arc<dyn Fn() -> Option<gateway_core::routing::request_settings::RequestSettings> + Send + Sync>;
pub(crate) struct RouterDispatcher {
pub(crate) router: Router,
@@ -60,7 +58,7 @@ pub(crate) struct RouterDispatcher {
}
impl contract::Dispatcher for RouterDispatcher {
fn request_settings(&self) -> Option<gateway_core::settings::RequestSettings> {
fn request_settings(&self) -> Option<gateway_core::routing::request_settings::RequestSettings> {
(self.settings)()
}
@@ -195,12 +193,12 @@ async fn handle(
});
request.extensions_mut().insert(guard.clone());
request.extensions_mut().insert(context.clone());
let service_context = gateway_admin::service::Origin {
let service_context = gateway_admin::public_service::Origin {
request_id: context.request_id.clone(),
call_id: context.call_id.clone(),
cancellation: context.cancellation.clone(),
extensions: context.extensions.clone(),
plan: gateway_admin::service::Plan::Frozen(plan.clone()),
plan: gateway_admin::public_service::Plan::Frozen(plan.clone()),
};
request
.extensions_mut()
@@ -220,7 +218,7 @@ async fn handle(
terminal_context,
SETTINGS.scope(
settings,
gateway_admin::service::scope(
gateway_admin::public_service::scope(
service_context,
dispatch(router, request.map(Body::new), started),
),
@@ -14,15 +14,14 @@ use futures::{
future::BoxFuture,
stream::{SplitSink, SplitStream},
};
use gateway_core::{
engine::middleware::{FrozenMiddlewarePlan, MiddlewareError, MiddlewareHeader},
lifecycle::CancellationToken,
middleware::{
compose,
http::{self as http, upgrade},
websocket as core,
},
};
use gateway_core::engine::middleware::FrozenMiddlewarePlan;
use gateway_core::engine::middleware::MiddlewareError;
use gateway_core::engine::middleware::MiddlewareHeader;
use gateway_core::engine::middleware::http;
use gateway_core::engine::middleware::http::upgrade;
use gateway_core::engine::middleware::websocket as core;
use gateway_core::lifecycle::CancellationToken;
use gateway_core::middleware::compose;
use http_body_util::BodyExt as _;
pub(super) struct Upgrade {
@@ -84,30 +84,6 @@ impl ClientApiKeyAuthError {
}
}
/// 从请求头提取 Bearer Client API key
///
/// # Errors
///
/// Header 缺失、Bearer 语法错误或 Key 不能作为 HTTP Bearer 值时返回稳定错误
pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAuthError> {
let raw = headers
.get(AUTHORIZATION)
.ok_or(ClientApiKeyAuthError::MissingAuthorization)?
.to_str()
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?;
let token = raw
.strip_prefix("Bearer ")
.ok_or(ClientApiKeyAuthError::MalformedAuthorization)?
.trim();
if token.is_empty() {
return Err(ClientApiKeyAuthError::MalformedAuthorization);
}
if gateway_core::policy::PlaintextClientApiKey::validate(token).is_err() {
return Err(ClientApiKeyAuthError::InvalidKeyFormat);
}
Ok(token)
}
pub(crate) async fn authenticate_client(
service: &OpenAiService,
headers: &HeaderMap,
@@ -165,8 +165,11 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
"no upstream provider is currently available for this request",
)
}
EngineError::Store(_)
| EngineError::ProviderMetadataMismatch
EngineError::Store(source) => {
GatewayError::new(GatewayErrorKind::Internal, "gateway execution failed")
.with_source(source.clone())
}
EngineError::ProviderMetadataMismatch
| EngineError::ContinuationPinMismatch
| EngineError::RequiredAccountMismatch
| EngineError::AccountOutsideClientScope
@@ -134,6 +134,8 @@ pub(crate) async fn execute_prepared_responses(
ClientTransport::HttpJson
};
let model_hint = Some(decoded.metadata().requested_model().to_owned());
// 终端会重新校验中间件改写后的正文,初步解码不必保留到响应交付
drop(decoded);
let execution = service.execution();
let request_id = prepared.request_id().clone();
let cancellation = prepared.cancellation();
@@ -95,15 +95,25 @@ impl OpenAiRequestHeaders {
}
}
/// 独立端点只消费会话身份与 turn metadata,不携带 Responses 的连接状态
/// 独立端点保留业务扩展头与会话身份,不携带 Responses 的连接状态
pub(crate) fn session_context(&self) -> Map<String, Value> {
let mut context = Map::new();
insert_protocol_context(&mut context, "session_id", self.session_id.as_ref());
insert_protocol_context(&mut context, "thread_id", self.thread_id.as_ref());
insert_protocol_context(&mut context, "turn_metadata", self.turn_metadata.as_ref());
self.insert_passthrough_headers(&mut context);
context
}
fn insert_passthrough_headers(&self, context: &mut Map<String, Value>) {
if !self.passthrough_headers.is_empty() {
context.insert(
PASSTHROUGH_HEADERS_CONTEXT_KEY.to_owned(),
Value::Array(self.passthrough_headers.clone()),
);
}
}
fn protocol_context(
&self,
use_websocket: Option<bool>,
@@ -153,12 +163,7 @@ impl OpenAiRequestHeaders {
insert_protocol_context(&mut context, "turn_id", self.turn_id.as_ref());
insert_protocol_context(&mut context, "responses_lite", self.responses_lite.as_ref());
insert_protocol_context(&mut context, "memgen_request", self.memgen_request.as_ref());
if !self.passthrough_headers.is_empty() {
context.insert(
PASSTHROUGH_HEADERS_CONTEXT_KEY.to_owned(),
Value::Array(self.passthrough_headers.clone()),
);
}
self.insert_passthrough_headers(&mut context);
if let Some(use_websocket) = use_websocket {
context.insert("use_websocket".to_owned(), Value::Bool(use_websocket));
}
@@ -47,7 +47,7 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
let projected = client_failure_payload(wire);
let projected = client_payload(wire);
let data = projected.as_ref().unwrap_or_else(|| wire.data());
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`
@@ -70,22 +70,29 @@ impl OpenAiResponsesEncoder {
{
return vec![raw_sse_frame.clone()];
}
let data = if projected.is_none() {
wire.raw_websocket_message()
.map(str::to_owned)
.unwrap_or_else(|| data.to_string())
} else {
data.to_string()
};
vec![Bytes::from(encode_sse_event_with_metadata(
effective_event_type(wire).unwrap_or_default(),
&data.to_string(),
&data,
wire.sse_id(),
wire.sse_retry(),
))]
}
/// 消费一个 Provider event,并返回 WebSocket JSON messages
/// 消费一个 Provider event,并返回完整 WebSocket 文本消息
pub fn push_websocket(&mut self, event: &ProviderEvent) -> Vec<String> {
self.observe_canonical_identity(event);
let Some(wire) = openai_wire(event).filter(|wire| wire.has_json_data()) else {
let Some(wire) = openai_wire(event) else {
return Vec::new();
};
self.observe_wire(wire);
let projected = client_failure_payload(wire);
let projected = client_payload(wire);
let data = projected.as_ref().unwrap_or_else(|| wire.data());
// WS 客户端只对它无法消费的裸 `error` 帧做投影:codex 的 WS 端点
// 会静默忽略缺少 status 且不含内置可重试错误码的 `error` 帧,客户
@@ -104,6 +111,14 @@ impl OpenAiResponsesEncoder {
{
return vec![data.to_string()];
}
if projected.is_none()
&& let Some(raw) = wire.raw_websocket_message()
{
return vec![raw.to_owned()];
}
if !wire.has_json_data() {
return Vec::new();
}
vec![data.to_string()]
}
@@ -248,15 +263,43 @@ fn effective_event_type(wire: &ProtocolWireEvent) -> Option<&str> {
.or_else(|| wire.data().get("type").and_then(Value::as_str))
}
fn client_failure_payload(wire: &ProtocolWireEvent) -> Option<Value> {
fn client_payload(wire: &ProtocolWireEvent) -> Option<Value> {
if matches!(
effective_event_type(wire),
Some("error" | "response.failed")
) {
capacity_error_for_client(wire.data())
} else {
None
return capacity_error_for_client(wire.data());
}
if !matches!(
effective_event_type(wire),
Some("response.metadata" | "codex.response.metadata")
) {
return None;
}
let headers = wire.data().get("headers")?.as_object()?;
let connection_options: Vec<String> = headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("connection"))
.flat_map(|(_, value)| match value {
Value::Array(values) => values.iter().filter_map(Value::as_str).collect::<Vec<_>>(),
_ => value.as_str().into_iter().collect(),
})
.flat_map(|value| value.split(','))
.map(|name| name.trim().to_ascii_lowercase())
.collect();
if headers
.keys()
.all(|name| super::response_header_is_forwardable(name, &connection_options))
{
return None;
}
// 只剥离既有响应头隔离策略禁止的字段,metadata 业务扩展保持不透明
let mut projected = wire.data().clone();
projected
.get_mut("headers")?
.as_object_mut()?
.retain(|name, _| super::response_header_is_forwardable(name, &connection_options));
Some(projected)
}
/// 判断 WS 上游的 `error` 帧是否已经是客户端可直接消费的形状
@@ -209,6 +209,11 @@ impl ResponsesDeliveryValidator {
transformed: bool,
facts: &ResponseValidationFacts,
) -> Result<(), ResponseValidationError> {
// 未改写的未知载荷不参与本地关联判断,不能因旁路解析失败截断原流
// 插件实际改写的载荷仍须满足交付合同
if parsed.is_err() && !transformed {
return Ok(());
}
if !self.active && !transformed {
if parsed.and_then(|frame| self.state.apply(frame)).is_err() {
self.invalid_prefix = true;
@@ -253,7 +258,7 @@ impl ResponsesDeliveryValidator {
return Err(ResponseValidationError);
}
}
ParsedFrame::Event { .. } | ParsedFrame::Done => {}
ParsedFrame::Event { .. } | ParsedFrame::Done | ParsedFrame::NonEvent => {}
}
self.state.apply(parsed)
}
@@ -292,6 +297,7 @@ impl ResponsesDeliveryValidator {
enum ParsedFrame {
Event { event_type: String, value: Value },
Done,
NonEvent,
}
impl ParsedFrame {
@@ -301,6 +307,10 @@ impl ParsedFrame {
return Ok(Self::Done);
}
let mut events = parse_sse_events(frame).map_err(|_| ResponseValidationError)?;
// 注释和仅含 SSE 元数据的帧不承载响应业务事实
if events.is_empty() {
return Ok(Self::NonEvent);
}
if events.len() != 1 {
return Err(ResponseValidationError);
}
@@ -363,6 +373,9 @@ struct DeliveryState {
impl DeliveryState {
fn apply(&mut self, frame: ParsedFrame) -> Result<(), ResponseValidationError> {
if matches!(frame, ParsedFrame::NonEvent) {
return Ok(());
}
if self.done {
return Err(ResponseValidationError);
}
@@ -0,0 +1,171 @@
//! 下游连接的帧、写入结果、退出原因与队列预算
use std::{sync::Arc, time::Duration};
use thiserror::Error;
use tokio::sync::OwnedSemaphorePermit;
pub(super) const OUTBOUND_COMMAND_BUFFER: usize = 32;
pub(super) const INBOUND_EVENT_BUFFER: usize = 32;
pub(super) const DOWNSTREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
pub(super) const DOWNSTREAM_CLOSE_TIMEOUT: Duration = Duration::from_secs(1);
pub(super) const CONNECTION_MAX_AGE: Duration = Duration::from_secs(60 * 60);
pub(super) const DOWNSTREAM_PING_INTERVAL: Duration = Duration::from_secs(25);
/// WebSocket pump 的写入与生命周期预算
#[derive(Clone, Copy)]
pub struct ConnectionConfig {
pub(super) write_timeout: Duration,
pub(super) max_age: Duration,
}
impl ConnectionConfig {
/// 生产环境固定预算
pub const PRODUCTION: Self = Self {
write_timeout: DOWNSTREAM_WRITE_TIMEOUT,
max_age: CONNECTION_MAX_AGE,
};
}
/// 业务层可观察的客户端输入;Ping/Pong 始终由 pump 消费
pub enum ConnectionEvent {
Text(String),
Binary,
Expired,
Exited(PumpExitReason),
}
/// 接收队列与活动期间暂存的请求共用容量;移动帧不释放它占用的名额
pub(in super::super) struct PendingConnectionEvent {
pub(in super::super) event: ConnectionEvent,
pub(super) _permit: Option<OwnedSemaphorePermit>,
}
impl PendingConnectionEvent {
pub(super) fn exited(reason: PumpExitReason) -> Self {
Self {
event: ConnectionEvent::Exited(reason),
_permit: None,
}
}
}
/// 下游写入阶段;名称刻意使用 write,而不是暗示客户端已消费的 delivery
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FramePhase {
Metadata,
First,
Data,
Terminal,
FirstAndTerminal,
Error,
ConnectionLimit,
Close,
}
impl FramePhase {
pub(super) const fn as_str(self) -> &'static str {
match self {
Self::Metadata => "metadata",
Self::First => "first",
Self::Data => "data",
Self::Terminal => "terminal",
Self::FirstAndTerminal => "first_and_terminal",
Self::Error => "error",
Self::ConnectionLimit => "connection_limit",
Self::Close => "close",
}
}
pub(super) const fn is_milestone(self) -> bool {
matches!(
self,
Self::First
| Self::Terminal
| Self::FirstAndTerminal
| Self::Error
| Self::ConnectionLimit
| Self::Close
)
}
}
/// 一次下游写入的请求归属与协议阶段
#[derive(Clone)]
pub struct WriteContext {
pub(super) request_id: Option<Arc<str>>,
pub(super) phase: FramePhase,
}
impl WriteContext {
/// 创建归属于某个请求的写入上下文
#[must_use]
pub fn request(request_id: &Arc<str>, phase: FramePhase) -> Self {
Self {
request_id: Some(Arc::clone(request_id)),
phase,
}
}
/// 创建连接级写入上下文
#[must_use]
pub const fn connection(phase: FramePhase) -> Self {
Self {
request_id: None,
phase,
}
}
}
/// WebSocket pump 停止的稳定原因
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PumpExitReason {
ClientClose,
PeerEof,
ReadError,
WriteError,
WriteTimeout,
LifecycleShutdown,
ConnectionMaxAge,
CoordinatorDropped,
InboundOverload,
ServerClose,
PumpStopped,
MiddlewareFailed,
}
impl PumpExitReason {
pub(in super::super) const fn as_str(self) -> &'static str {
match self {
Self::ClientClose => "client_close",
Self::PeerEof => "peer_eof",
Self::ReadError => "read_error",
Self::WriteError => "write_error",
Self::WriteTimeout => "write_timeout",
Self::LifecycleShutdown => "lifecycle_shutdown",
Self::ConnectionMaxAge => "connection_max_age",
Self::CoordinatorDropped => "coordinator_dropped",
Self::InboundOverload => "inbound_overload",
Self::ServerClose => "server_close",
Self::PumpStopped => "pump_stopped",
Self::MiddlewareFailed => "middleware_failed",
}
}
}
/// 下游 WebSocket 写入失败
#[derive(Debug, Error)]
pub enum ConnectionWriteError {
#[error("downstream WebSocket pump is closed")]
Closed,
#[error("downstream WebSocket write timed out after {timeout:?}")]
Timeout { timeout: Duration },
#[error("downstream WebSocket transport write failed: {message}")]
Transport { message: String },
}
/// 只有实际 transport 写入才记为 Written;插件丢弃消息不伪造写入成功
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WriteOutcome {
Written,
Suppressed,
}
@@ -0,0 +1,362 @@
//! 协调层持有的连接句柄与唯一 pump 启动入口
use super::{
frame::*,
pump::run_pump,
state::{ConnectionCommand, ConnectionStats},
};
use axum::extract::ws::{CloseFrame, Message, WebSocket, close_code};
use futures::{Sink, Stream};
use gateway_core::{
engine::middleware::{FrozenMiddlewarePlan, MiddlewareHeader},
lifecycle::CancellationToken,
};
use std::{
collections::VecDeque,
fmt,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use tokio::{
sync::{mpsc, oneshot},
task::JoinHandle,
time::{Instant, timeout},
};
/// 协调层持有的单 owner WebSocket 连接句柄
pub struct ResponsesWebSocketConnection {
connection_id: Arc<str>,
opened_at: Instant,
expired: Arc<AtomicBool>,
commands: Option<mpsc::Sender<ConnectionCommand>>,
incoming: mpsc::Receiver<PendingConnectionEvent>,
deferred: VecDeque<PendingConnectionEvent>,
exited: oneshot::Receiver<PumpExitReason>,
pump_task: Option<JoinHandle<()>>,
stats: Arc<ConnectionStats>,
config: ConnectionConfig,
exit_reason: Option<PumpExitReason>,
}
impl ResponsesWebSocketConnection {
pub(in super::super) fn new(
socket: WebSocket,
connection_id: String,
cancellation: CancellationToken,
middleware: Option<FrozenMiddlewarePlan>,
headers: Arc<[MiddlewareHeader]>,
) -> Self {
spawn_connection(
socket,
Arc::<str>::from(connection_id),
cancellation,
ConnectionConfig::PRODUCTION,
middleware,
headers,
)
}
pub(in super::super) fn id(&self) -> &str {
&self.connection_id
}
/// 返回连接是否已经达到生命周期上限
#[must_use]
pub fn is_expired(&self) -> bool {
self.expired.load(Ordering::Acquire)
}
pub(in super::super) fn age(&self) -> Duration {
self.opened_at.elapsed()
}
/// 等待下一个需要业务层处理的客户端事件
pub async fn next_event(&mut self) -> Option<ConnectionEvent> {
if self.exit_reason.is_none() {
match self.exited.try_recv() {
Ok(reason) => {
self.exit_reason = Some(reason);
}
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
self.exit_reason = Some(PumpExitReason::PumpStopped);
}
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {}
}
}
if let Some(reason) = self.exit_reason {
return Some(ConnectionEvent::Exited(reason));
}
if let Some(event) = self.deferred.pop_front() {
return Some(event.event);
}
self.next_active_event().await.map(|event| event.event)
}
/// 活动响应期间只消费新到的帧,已排队的下一轮请求继续保持串行
pub(in super::super) async fn next_active_event(&mut self) -> Option<PendingConnectionEvent> {
if let Some(reason) = self.exit_reason {
return Some(PendingConnectionEvent::exited(reason));
}
let event = tokio::select! {
biased;
reason = &mut self.exited => {
Some(PendingConnectionEvent::exited(reason.unwrap_or(PumpExitReason::PumpStopped)))
}
event = self.incoming.recv() => event,
};
if let Some(PendingConnectionEvent {
event: ConnectionEvent::Exited(reason),
..
}) = event.as_ref()
{
self.exit_reason.get_or_insert(*reason);
} else if event.is_none() {
self.exit_reason.get_or_insert(PumpExitReason::PumpStopped);
}
event
}
pub(in super::super) fn defer(&mut self, event: PendingConnectionEvent) {
self.deferred.push_back(event);
}
/// 等待连接退出,不消费留给后续串行请求的业务帧
pub async fn wait_for_exit(&mut self) -> PumpExitReason {
if let Some(reason) = self.exit_reason {
return reason;
}
let reason = (&mut self.exited)
.await
.unwrap_or(PumpExitReason::PumpStopped);
self.exit_reason = Some(reason);
reason
}
/// 串行写入文本帧,并等待 pump 确认 transport 写入结果
///
/// # Errors
///
/// pump 已关闭、写入超时或 transport 失败时返回稳定错误
pub async fn send_text(
&mut self,
payload: String,
context: WriteContext,
) -> Result<WriteOutcome, ConnectionWriteError> {
self.send(Message::Text(payload.into()), context).await
}
pub(in super::super) async fn close_policy(
&mut self,
reason: &'static str,
request_id: Option<&Arc<str>>,
) {
let context = request_id.map_or_else(
|| WriteContext::connection(FramePhase::Close),
|request_id| WriteContext::request(request_id, FramePhase::Close),
);
self.close(
CloseFrame {
code: close_code::POLICY,
reason: reason.into(),
},
context,
PumpExitReason::ServerClose,
)
.await;
}
pub(in super::super) async fn close_for_connection_limit(&mut self, reason: &'static str) {
self.close(
CloseFrame {
code: close_code::NORMAL,
reason: reason.into(),
},
WriteContext::connection(FramePhase::Close),
PumpExitReason::ConnectionMaxAge,
)
.await;
}
async fn close(
&mut self,
frame: CloseFrame,
context: WriteContext,
exit_reason: PumpExitReason,
) {
if self
.send(Message::Close(Some(frame)), context)
.await
.is_ok()
{
self.exit_reason.get_or_insert(exit_reason);
}
}
async fn send(
&mut self,
message: Message,
context: WriteContext,
) -> Result<WriteOutcome, ConnectionWriteError> {
let Some(commands) = self.commands.as_ref().cloned() else {
return Err(ConnectionWriteError::Closed);
};
self.stats.observe_command_queue(&commands);
let (acknowledged, acknowledgement) = oneshot::channel();
let started_at = Instant::now();
let request_id = context.request_id.clone();
let phase = context.phase;
let command = ConnectionCommand {
message,
context,
acknowledged,
};
let write = async move {
commands
.send(command)
.await
.map_err(|_| ConnectionWriteError::Closed)?;
acknowledgement
.await
.map_err(|_| ConnectionWriteError::Closed)?
};
let result = match timeout(self.config.write_timeout, write).await {
Ok(result) => result,
Err(_) => Err(ConnectionWriteError::Timeout {
timeout: self.config.write_timeout,
}),
};
let duration = started_at.elapsed();
match &result {
Ok(WriteOutcome::Written) if phase.is_milestone() => tracing::info!(
websocket_connection_id = %self.connection_id,
request_id = request_id.as_deref().unwrap_or(""),
frame_phase = phase.as_str(),
write_duration_ms = duration.as_millis(),
"Responses WebSocket frame write succeeded"
),
Ok(outcome) => tracing::debug!(
websocket_connection_id = %self.connection_id,
request_id = request_id.as_deref().unwrap_or(""),
frame_phase = phase.as_str(),
write_duration_ms = duration.as_millis(),
?outcome,
"Responses WebSocket frame processed"
),
Err(error) => tracing::info!(
websocket_connection_id = %self.connection_id,
request_id = request_id.as_deref().unwrap_or(""),
frame_phase = phase.as_str(),
write_duration_ms = duration.as_millis(),
error = %error,
"Responses WebSocket frame write failed"
),
}
if let Err(error) = &result {
let reason = match error {
ConnectionWriteError::Timeout { .. } => PumpExitReason::WriteTimeout,
ConnectionWriteError::Transport { .. } => PumpExitReason::WriteError,
ConnectionWriteError::Closed => PumpExitReason::PumpStopped,
};
self.terminate(reason);
}
result
}
fn terminate(&mut self, reason: PumpExitReason) {
self.exit_reason.get_or_insert(reason);
self.commands.take();
if let Some(task) = self.pump_task.take() {
task.abort();
}
}
pub(in super::super) fn log_summary(&self, request_count: u64) {
let connection_age_ms = self.age().as_millis();
let read_idle_ms = connection_age_ms
.saturating_sub(u128::from(self.stats.last_read_ms.load(Ordering::Relaxed)));
let write_idle_ms = connection_age_ms
.saturating_sub(u128::from(self.stats.last_write_ms.load(Ordering::Relaxed)));
tracing::info!(
websocket_connection_id = %self.connection_id,
request_count,
connection_age_ms,
pump_exit_reason = self
.exit_reason
.map_or("unknown", PumpExitReason::as_str),
command_queue_high_water = self
.stats
.command_queue_high_water
.load(Ordering::Relaxed),
command_backpressure_count = self
.stats
.command_backpressure_count
.load(Ordering::Relaxed),
ping_received_count = self.stats.ping_received_count.load(Ordering::Relaxed),
ping_written_count = self.stats.ping_written_count.load(Ordering::Relaxed),
pong_received_count = self.stats.pong_received_count.load(Ordering::Relaxed),
read_idle_ms,
write_idle_ms,
"Responses WebSocket disconnected"
);
}
}
impl Drop for ResponsesWebSocketConnection {
fn drop(&mut self) {
self.commands.take();
if let Some(task) = self.pump_task.take() {
task.abort();
}
}
}
/// 为 WebSocket transport 启动包含冻结中间件计划的单 owner pump
pub fn spawn_connection<S, E>(
socket: S,
connection_id: Arc<str>,
cancellation: CancellationToken,
config: ConnectionConfig,
middleware: Option<FrozenMiddlewarePlan>,
headers: Arc<[MiddlewareHeader]>,
) -> ResponsesWebSocketConnection
where
S: Stream<Item = Result<Message, E>> + Sink<Message, Error = E> + Unpin + Send + 'static,
E: fmt::Display + Send + 'static,
{
let opened_at = Instant::now();
let expired = Arc::new(AtomicBool::new(false));
let stats = Arc::new(ConnectionStats::default());
let (command_tx, command_rx) = mpsc::channel(OUTBOUND_COMMAND_BUFFER);
let (incoming_tx, incoming_rx) = mpsc::channel(INBOUND_EVENT_BUFFER);
let (exit_tx, exit_rx) = oneshot::channel();
let pump_task = tokio::spawn(run_pump(
socket,
command_rx,
incoming_tx,
exit_tx,
Arc::clone(&connection_id),
cancellation,
opened_at,
Arc::clone(&expired),
Arc::clone(&stats),
config,
middleware,
headers,
));
ResponsesWebSocketConnection {
connection_id,
opened_at,
expired,
commands: Some(command_tx),
incoming: incoming_rx,
deferred: VecDeque::new(),
exited: exit_rx,
pump_task: Some(pump_task),
stats,
config,
exit_reason: None,
}
}
@@ -1,11 +1,13 @@
//! 插件消息组合在 transport 锁之外执行,主动发送复用唯一 writer
use super::{ConnectionWriteError, PumpContext, write_message};
use super::{ConnectionWriteError, frame::DOWNSTREAM_CLOSE_TIMEOUT, state::PumpContext};
pub(super) use crate::middleware::websocket::CancelOnDrop;
use crate::middleware::websocket::{from_core, into_core};
use axum::extract::ws::Message;
use futures::{Sink, future::BoxFuture};
use gateway_core::{engine::middleware::MiddlewareError, middleware::websocket as core};
use std::sync::Arc;
use futures::{Sink, SinkExt, future::BoxFuture};
use gateway_core::engine::middleware::{MiddlewareError, websocket as core};
use gateway_core::lifecycle::CancellationToken;
use std::{fmt, sync::Arc};
use tokio::time::timeout;
pub(super) struct Writer<S> {
socket: tokio::sync::Mutex<S>,
@@ -37,6 +39,36 @@ where
}) => result.unwrap_or(Err(ConnectionWriteError::Timeout { timeout: self.context.config.write_timeout })),
}
}
pub(super) async fn finish_peer_close(
&self,
cancellation: &CancellationToken,
) -> Result<(), ConnectionWriteError> {
// 主动 Sender 也可能在 pump 之外持锁;先独占 transport 再取消业务,
// 有在途写或已取消的半帧时立即跳过,不能等待它释放锁后继续刷新
let socket = self
.socket
.try_lock()
.ok()
.filter(|_| !self.context.cancellation.is_cancelled());
self.context.cancellation.cancel();
let Some(mut socket) = socket else {
return Err(ConnectionWriteError::Closed);
};
// 收到 Close 后 transport 已排队应答;只刷新它,不能再发送一份 Close
// 业务调用已取消,清理只受宿主取消与独立短预算约束,不沿用业务写入的长超时
tokio::select! {
biased;
() = cancellation.cancelled() => Err(ConnectionWriteError::Closed),
result = timeout(DOWNSTREAM_CLOSE_TIMEOUT, async {
socket.flush().await.map_err(|error| {
ConnectionWriteError::Transport { message: error.to_string() }
})
}) => result.unwrap_or(Err(ConnectionWriteError::Timeout {
timeout: DOWNSTREAM_CLOSE_TIMEOUT,
})),
}
}
}
impl<S, E> core::Sender for Writer<S>
where
@@ -81,3 +113,25 @@ pub(super) async fn transform(
.map(from_core)
.transpose()
}
async fn write_message<S, E>(
socket: &mut S,
message: Message,
context: &PumpContext,
) -> Result<(), ConnectionWriteError>
where
S: Sink<Message, Error = E> + Unpin,
E: fmt::Display,
{
tokio::select! {
biased;
() = context.cancellation.cancelled() => Err(ConnectionWriteError::Closed),
result = timeout(context.config.write_timeout, socket.send(message)) => {
match result {
Ok(Ok(())) => { context.stats.record_write(context.opened_at); Ok(()) }
Ok(Err(error)) => Err(ConnectionWriteError::Transport { message: error.to_string() }),
Err(_) => Err(ConnectionWriteError::Timeout { timeout: context.config.write_timeout }),
}
}
}
}
@@ -1,826 +1,13 @@
//! 下游客户端 WebSocket 的单 owner pump 与有界收发边界
//! 下游客户端 WebSocket 的连接合同、句柄与单 owner pump
mod frame;
mod handle;
mod middleware;
mod pump;
mod state;
use std::{
collections::VecDeque,
fmt,
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
},
time::Duration,
pub use frame::{
ConnectionConfig, ConnectionEvent, ConnectionWriteError, FramePhase, PumpExitReason,
WriteContext, WriteOutcome,
};
use axum::extract::ws::{CloseFrame, Message, WebSocket, close_code};
use futures::{Sink, SinkExt, Stream, StreamExt};
use gateway_core::{
engine::middleware::{FrozenMiddlewarePlan, MiddlewareHeader},
lifecycle::CancellationToken,
middleware::websocket as contract,
};
use thiserror::Error;
use tokio::{
sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot},
task::JoinHandle,
time::{Instant, timeout},
};
const OUTBOUND_COMMAND_BUFFER: usize = 32;
const INBOUND_EVENT_BUFFER: usize = 32;
const DOWNSTREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
const CONNECTION_MAX_AGE: Duration = Duration::from_secs(60 * 60);
const DOWNSTREAM_PING_INTERVAL: Duration = Duration::from_secs(25);
/// WebSocket pump 的写入与生命周期预算
#[derive(Clone, Copy)]
pub struct ConnectionConfig {
write_timeout: Duration,
max_age: Duration,
}
impl ConnectionConfig {
/// 生产环境固定预算
pub const PRODUCTION: Self = Self {
write_timeout: DOWNSTREAM_WRITE_TIMEOUT,
max_age: CONNECTION_MAX_AGE,
};
}
/// 业务层可观察的客户端输入;Ping/Pong 始终由 pump 消费
pub enum ConnectionEvent {
Text(String),
Binary,
Expired,
Exited(PumpExitReason),
}
/// 接收队列与活动期间暂存的请求共用容量;移动帧不释放它占用的名额
pub(super) struct PendingConnectionEvent {
pub(super) event: ConnectionEvent,
_permit: Option<OwnedSemaphorePermit>,
}
impl PendingConnectionEvent {
fn exited(reason: PumpExitReason) -> Self {
Self {
event: ConnectionEvent::Exited(reason),
_permit: None,
}
}
}
/// 下游写入阶段;名称刻意使用 write,而不是暗示客户端已消费的 delivery
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FramePhase {
Metadata,
First,
Data,
Terminal,
FirstAndTerminal,
Error,
ConnectionLimit,
Close,
}
impl FramePhase {
const fn as_str(self) -> &'static str {
match self {
Self::Metadata => "metadata",
Self::First => "first",
Self::Data => "data",
Self::Terminal => "terminal",
Self::FirstAndTerminal => "first_and_terminal",
Self::Error => "error",
Self::ConnectionLimit => "connection_limit",
Self::Close => "close",
}
}
const fn is_milestone(self) -> bool {
matches!(
self,
Self::First
| Self::Terminal
| Self::FirstAndTerminal
| Self::Error
| Self::ConnectionLimit
| Self::Close
)
}
}
/// 一次下游写入的请求归属与协议阶段
#[derive(Clone)]
pub struct WriteContext {
request_id: Option<Arc<str>>,
phase: FramePhase,
}
impl WriteContext {
/// 创建归属于某个请求的写入上下文
#[must_use]
pub fn request(request_id: &Arc<str>, phase: FramePhase) -> Self {
Self {
request_id: Some(Arc::clone(request_id)),
phase,
}
}
/// 创建连接级写入上下文
#[must_use]
pub const fn connection(phase: FramePhase) -> Self {
Self {
request_id: None,
phase,
}
}
}
/// WebSocket pump 停止的稳定原因
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PumpExitReason {
ClientClose,
PeerEof,
ReadError,
WriteError,
WriteTimeout,
LifecycleShutdown,
ConnectionMaxAge,
CoordinatorDropped,
InboundOverload,
ServerClose,
PumpStopped,
MiddlewareFailed,
}
impl PumpExitReason {
pub(super) const fn as_str(self) -> &'static str {
match self {
Self::ClientClose => "client_close",
Self::PeerEof => "peer_eof",
Self::ReadError => "read_error",
Self::WriteError => "write_error",
Self::WriteTimeout => "write_timeout",
Self::LifecycleShutdown => "lifecycle_shutdown",
Self::ConnectionMaxAge => "connection_max_age",
Self::CoordinatorDropped => "coordinator_dropped",
Self::InboundOverload => "inbound_overload",
Self::ServerClose => "server_close",
Self::PumpStopped => "pump_stopped",
Self::MiddlewareFailed => "middleware_failed",
}
}
}
/// 下游 WebSocket 写入失败
#[derive(Debug, Error)]
pub enum ConnectionWriteError {
#[error("downstream WebSocket pump is closed")]
Closed,
#[error("downstream WebSocket write timed out after {timeout:?}")]
Timeout { timeout: Duration },
#[error("downstream WebSocket transport write failed: {message}")]
Transport { message: String },
}
/// 只有实际 transport 写入才记为 Written;插件丢弃消息不伪造写入成功
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WriteOutcome {
Written,
Suppressed,
}
struct ConnectionCommand {
message: Message,
context: WriteContext,
acknowledged: oneshot::Sender<Result<WriteOutcome, ConnectionWriteError>>,
}
#[derive(Default)]
struct ConnectionStats {
command_queue_high_water: AtomicUsize,
command_backpressure_count: AtomicU64,
ping_received_count: AtomicU64,
ping_written_count: AtomicU64,
pong_received_count: AtomicU64,
last_read_ms: AtomicU64,
last_write_ms: AtomicU64,
}
impl ConnectionStats {
fn record_read(&self, opened_at: Instant) {
self.last_read_ms.store(
u64::try_from(opened_at.elapsed().as_millis()).unwrap_or(u64::MAX),
Ordering::Relaxed,
);
}
fn record_write(&self, opened_at: Instant) {
self.last_write_ms.store(
u64::try_from(opened_at.elapsed().as_millis()).unwrap_or(u64::MAX),
Ordering::Relaxed,
);
}
fn observe_command_queue(&self, sender: &mpsc::Sender<ConnectionCommand>) {
let queued = OUTBOUND_COMMAND_BUFFER
.saturating_sub(sender.capacity())
.saturating_add(1)
.min(OUTBOUND_COMMAND_BUFFER);
self.command_queue_high_water
.fetch_max(queued, Ordering::Relaxed);
if sender.capacity() == 0 {
self.command_backpressure_count
.fetch_add(1, Ordering::Relaxed);
}
}
}
/// 协调层持有的单 owner WebSocket 连接句柄
pub struct ResponsesWebSocketConnection {
connection_id: Arc<str>,
opened_at: Instant,
expired: Arc<AtomicBool>,
commands: Option<mpsc::Sender<ConnectionCommand>>,
incoming: mpsc::Receiver<PendingConnectionEvent>,
deferred: VecDeque<PendingConnectionEvent>,
exited: oneshot::Receiver<PumpExitReason>,
pump_task: Option<JoinHandle<()>>,
stats: Arc<ConnectionStats>,
config: ConnectionConfig,
exit_reason: Option<PumpExitReason>,
}
impl ResponsesWebSocketConnection {
pub(super) fn new(
socket: WebSocket,
connection_id: String,
cancellation: CancellationToken,
middleware: Option<FrozenMiddlewarePlan>,
headers: Arc<[MiddlewareHeader]>,
) -> Self {
spawn_with_middleware(
socket,
Arc::<str>::from(connection_id),
cancellation,
ConnectionConfig::PRODUCTION,
middleware,
headers,
)
}
pub(super) fn id(&self) -> &str {
&self.connection_id
}
/// 返回连接是否已经达到生命周期上限
#[must_use]
pub fn is_expired(&self) -> bool {
self.expired.load(Ordering::Acquire)
}
pub(super) fn age(&self) -> Duration {
self.opened_at.elapsed()
}
/// 等待下一个需要业务层处理的客户端事件
pub async fn next_event(&mut self) -> Option<ConnectionEvent> {
if self.exit_reason.is_none() {
match self.exited.try_recv() {
Ok(reason) => {
self.exit_reason = Some(reason);
}
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
self.exit_reason = Some(PumpExitReason::PumpStopped);
}
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {}
}
}
if let Some(reason) = self.exit_reason {
return Some(ConnectionEvent::Exited(reason));
}
if let Some(event) = self.deferred.pop_front() {
return Some(event.event);
}
self.next_active_event().await.map(|event| event.event)
}
/// 活动响应期间只消费新到的帧,已排队的下一轮请求继续保持串行
pub(super) async fn next_active_event(&mut self) -> Option<PendingConnectionEvent> {
if let Some(reason) = self.exit_reason {
return Some(PendingConnectionEvent::exited(reason));
}
let event = tokio::select! {
biased;
reason = &mut self.exited => {
Some(PendingConnectionEvent::exited(reason.unwrap_or(PumpExitReason::PumpStopped)))
}
event = self.incoming.recv() => event,
};
if let Some(PendingConnectionEvent {
event: ConnectionEvent::Exited(reason),
..
}) = event.as_ref()
{
self.exit_reason.get_or_insert(*reason);
} else if event.is_none() {
self.exit_reason.get_or_insert(PumpExitReason::PumpStopped);
}
event
}
pub(super) fn defer(&mut self, event: PendingConnectionEvent) {
self.deferred.push_back(event);
}
/// 等待连接退出,不消费留给后续串行请求的业务帧
pub async fn wait_for_exit(&mut self) -> PumpExitReason {
if let Some(reason) = self.exit_reason {
return reason;
}
let reason = (&mut self.exited)
.await
.unwrap_or(PumpExitReason::PumpStopped);
self.exit_reason = Some(reason);
reason
}
/// 串行写入文本帧,并等待 pump 确认 transport 写入结果
///
/// # Errors
///
/// pump 已关闭、写入超时或 transport 失败时返回稳定错误
pub async fn send_text(
&mut self,
payload: String,
context: WriteContext,
) -> Result<WriteOutcome, ConnectionWriteError> {
self.send(Message::Text(payload.into()), context).await
}
pub(super) async fn close_policy(
&mut self,
reason: &'static str,
request_id: Option<&Arc<str>>,
) {
let context = request_id.map_or_else(
|| WriteContext::connection(FramePhase::Close),
|request_id| WriteContext::request(request_id, FramePhase::Close),
);
self.close(
CloseFrame {
code: close_code::POLICY,
reason: reason.into(),
},
context,
PumpExitReason::ServerClose,
)
.await;
}
pub(super) async fn close_for_connection_limit(&mut self, reason: &'static str) {
self.close(
CloseFrame {
code: close_code::NORMAL,
reason: reason.into(),
},
WriteContext::connection(FramePhase::Close),
PumpExitReason::ConnectionMaxAge,
)
.await;
}
async fn close(
&mut self,
frame: CloseFrame,
context: WriteContext,
exit_reason: PumpExitReason,
) {
if self
.send(Message::Close(Some(frame)), context)
.await
.is_ok()
{
self.exit_reason.get_or_insert(exit_reason);
}
}
async fn send(
&mut self,
message: Message,
context: WriteContext,
) -> Result<WriteOutcome, ConnectionWriteError> {
let Some(commands) = self.commands.as_ref().cloned() else {
return Err(ConnectionWriteError::Closed);
};
self.stats.observe_command_queue(&commands);
let (acknowledged, acknowledgement) = oneshot::channel();
let started_at = Instant::now();
let request_id = context.request_id.clone();
let phase = context.phase;
let command = ConnectionCommand {
message,
context,
acknowledged,
};
let write = async move {
commands
.send(command)
.await
.map_err(|_| ConnectionWriteError::Closed)?;
acknowledgement
.await
.map_err(|_| ConnectionWriteError::Closed)?
};
let result = match timeout(self.config.write_timeout, write).await {
Ok(result) => result,
Err(_) => Err(ConnectionWriteError::Timeout {
timeout: self.config.write_timeout,
}),
};
let duration = started_at.elapsed();
match &result {
Ok(WriteOutcome::Written) if phase.is_milestone() => tracing::info!(
websocket_connection_id = %self.connection_id,
request_id = request_id.as_deref().unwrap_or(""),
frame_phase = phase.as_str(),
write_duration_ms = duration.as_millis(),
"Responses WebSocket frame write succeeded"
),
Ok(outcome) => tracing::debug!(
websocket_connection_id = %self.connection_id,
request_id = request_id.as_deref().unwrap_or(""),
frame_phase = phase.as_str(),
write_duration_ms = duration.as_millis(),
?outcome,
"Responses WebSocket frame processed"
),
Err(error) => tracing::info!(
websocket_connection_id = %self.connection_id,
request_id = request_id.as_deref().unwrap_or(""),
frame_phase = phase.as_str(),
write_duration_ms = duration.as_millis(),
error = %error,
"Responses WebSocket frame write failed"
),
}
if let Err(error) = &result {
let reason = match error {
ConnectionWriteError::Timeout { .. } => PumpExitReason::WriteTimeout,
ConnectionWriteError::Transport { .. } => PumpExitReason::WriteError,
ConnectionWriteError::Closed => PumpExitReason::PumpStopped,
};
self.terminate(reason);
}
result
}
fn terminate(&mut self, reason: PumpExitReason) {
self.exit_reason.get_or_insert(reason);
self.commands.take();
if let Some(task) = self.pump_task.take() {
task.abort();
}
}
pub(super) fn log_summary(&self, request_count: u64) {
let connection_age_ms = self.age().as_millis();
let read_idle_ms = connection_age_ms
.saturating_sub(u128::from(self.stats.last_read_ms.load(Ordering::Relaxed)));
let write_idle_ms = connection_age_ms
.saturating_sub(u128::from(self.stats.last_write_ms.load(Ordering::Relaxed)));
tracing::info!(
websocket_connection_id = %self.connection_id,
request_count,
connection_age_ms,
pump_exit_reason = self
.exit_reason
.map_or("unknown", PumpExitReason::as_str),
command_queue_high_water = self
.stats
.command_queue_high_water
.load(Ordering::Relaxed),
command_backpressure_count = self
.stats
.command_backpressure_count
.load(Ordering::Relaxed),
ping_received_count = self.stats.ping_received_count.load(Ordering::Relaxed),
ping_written_count = self.stats.ping_written_count.load(Ordering::Relaxed),
pong_received_count = self.stats.pong_received_count.load(Ordering::Relaxed),
read_idle_ms,
write_idle_ms,
"Responses WebSocket disconnected"
);
}
}
impl Drop for ResponsesWebSocketConnection {
fn drop(&mut self) {
self.commands.take();
if let Some(task) = self.pump_task.take() {
task.abort();
}
}
}
/// 为自动处理 Ping/Pong 的 WebSocket transport 启动单 owner pump
pub fn spawn_connection<S, E>(
socket: S,
connection_id: Arc<str>,
cancellation: CancellationToken,
config: ConnectionConfig,
) -> ResponsesWebSocketConnection
where
S: Stream<Item = Result<Message, E>> + Sink<Message, Error = E> + Unpin + Send + 'static,
E: fmt::Display + Send + 'static,
{
spawn_with_middleware(
socket,
connection_id,
cancellation,
config,
None,
Arc::from([]),
)
}
fn spawn_with_middleware<S, E>(
socket: S,
connection_id: Arc<str>,
cancellation: CancellationToken,
config: ConnectionConfig,
middleware: Option<FrozenMiddlewarePlan>,
headers: Arc<[MiddlewareHeader]>,
) -> ResponsesWebSocketConnection
where
S: Stream<Item = Result<Message, E>> + Sink<Message, Error = E> + Unpin + Send + 'static,
E: fmt::Display + Send + 'static,
{
let opened_at = Instant::now();
let expired = Arc::new(AtomicBool::new(false));
let stats = Arc::new(ConnectionStats::default());
let (command_tx, command_rx) = mpsc::channel(OUTBOUND_COMMAND_BUFFER);
let (incoming_tx, incoming_rx) = mpsc::channel(INBOUND_EVENT_BUFFER);
let (exit_tx, exit_rx) = oneshot::channel();
let pump_task = tokio::spawn(run_pump(
socket,
command_rx,
incoming_tx,
exit_tx,
Arc::clone(&connection_id),
cancellation,
opened_at,
Arc::clone(&expired),
Arc::clone(&stats),
config,
middleware,
headers,
));
ResponsesWebSocketConnection {
connection_id,
opened_at,
expired,
commands: Some(command_tx),
incoming: incoming_rx,
deferred: VecDeque::new(),
exited: exit_rx,
pump_task: Some(pump_task),
stats,
config,
exit_reason: None,
}
}
#[expect(clippy::too_many_arguments)]
async fn run_pump<S, E>(
socket: S,
commands: mpsc::Receiver<ConnectionCommand>,
incoming: mpsc::Sender<PendingConnectionEvent>,
exited: oneshot::Sender<PumpExitReason>,
connection_id: Arc<str>,
cancellation: CancellationToken,
opened_at: Instant,
expired: Arc<AtomicBool>,
stats: Arc<ConnectionStats>,
config: ConnectionConfig,
middleware: Option<FrozenMiddlewarePlan>,
headers: Arc<[MiddlewareHeader]>,
) where
S: Stream<Item = Result<Message, E>> + Sink<Message, Error = E> + Unpin + Send + 'static,
E: fmt::Display + Send + 'static,
{
let context = Arc::new(PumpContext {
connection_id,
cancellation: cancellation.child_token(),
opened_at,
expired,
stats,
config,
middleware,
headers,
});
let _lifetime = middleware::CancelOnDrop(Some(context.cancellation.clone()));
let (writer, mut reader) = socket.split();
let writer = Arc::new(middleware::Writer::new(writer, context.clone()));
let sender: Arc<dyn contract::Sender> = writer.clone();
// 同一个受管任务同时驱动两侧;等待写入不能阻塞入站控制、关闭或取消
let reason = tokio::select! {
biased;
reason = read_connection(&mut reader, &incoming, sender.clone(), &context) => reason,
reason = write_connection(writer.clone(), commands, &context) => reason,
};
context.cancellation.cancel();
let _ = exited.send(reason);
tracing::debug!(websocket_connection_id = %context.connection_id, pump_exit_reason = reason.as_str(), "Responses WebSocket pump exited");
}
struct PumpContext {
middleware: Option<FrozenMiddlewarePlan>,
headers: Arc<[MiddlewareHeader]>,
connection_id: Arc<str>,
cancellation: CancellationToken,
opened_at: Instant,
expired: Arc<AtomicBool>,
stats: Arc<ConnectionStats>,
config: ConnectionConfig,
}
async fn read_connection<S, E>(
socket: &mut S,
incoming: &mpsc::Sender<PendingConnectionEvent>,
sender: Arc<dyn contract::Sender>,
context: &PumpContext,
) -> PumpExitReason
where
S: Stream<Item = Result<Message, E>> + Unpin,
E: fmt::Display,
{
let event_slots = Arc::new(Semaphore::new(INBOUND_EVENT_BUFFER));
let deadline = tokio::time::sleep_until(context.opened_at + context.config.max_age);
tokio::pin!(deadline);
let mut deadline_elapsed = false;
loop {
tokio::select! {
biased;
() = context.cancellation.cancelled() => return PumpExitReason::LifecycleShutdown,
() = &mut deadline, if !deadline_elapsed => {
deadline_elapsed = true;
context.expired.store(true, Ordering::Release);
// 到期只阻止下一轮,当前响应仍由协调层按原有合同收尾
match emit_incoming(incoming, &event_slots, ConnectionEvent::Expired) {
Ok(()) | Err(PumpExitReason::InboundOverload) => {}
Err(reason) => return reason,
}
}
message = socket.next() => {
if matches!(&message, Some(Ok(_))) { context.stats.record_read(context.opened_at); }
let message = match message {
Some(Ok(message)) => match middleware::transform(message, contract::Direction::Incoming, sender.clone(), context).await {
Ok(Some(message)) => Some(Ok(message)),
Ok(None) => continue,
Err(_) => return PumpExitReason::MiddlewareFailed,
},
other => other,
};
let event = match message {
Some(Ok(Message::Text(payload))) => Some(ConnectionEvent::Text(payload.to_string())),
Some(Ok(Message::Binary(_))) => Some(ConnectionEvent::Binary),
Some(Ok(Message::Ping(_))) => {
// Axum/tungstenite 在继续读取时自动刷新 Pong,不能再手工发送一份
context.stats.ping_received_count.fetch_add(1, Ordering::Relaxed);
None
}
Some(Ok(Message::Pong(_))) => {
context.stats.pong_received_count.fetch_add(1, Ordering::Relaxed);
None
}
Some(Ok(Message::Close(_))) => return PumpExitReason::ClientClose,
Some(Err(error)) => {
tracing::info!(websocket_connection_id = %context.connection_id, error = %error, "Responses WebSocket receive failed");
return PumpExitReason::ReadError;
}
None => return PumpExitReason::PeerEof,
};
if let Some(event) = event && let Err(reason) = emit_incoming(incoming, &event_slots, event) { return reason; }
}
}
}
}
async fn write_connection<S, E>(
writer: Arc<middleware::Writer<S>>,
mut commands: mpsc::Receiver<ConnectionCommand>,
context: &PumpContext,
) -> PumpExitReason
where
S: Sink<Message, Error = E> + Unpin + Send + 'static,
E: fmt::Display + Send + 'static,
{
let mut heartbeat = tokio::time::interval_at(
context.opened_at + DOWNSTREAM_PING_INTERVAL,
DOWNSTREAM_PING_INTERVAL,
);
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut ping_sequence = 0_u64;
loop {
let message = tokio::select! {
biased;
() = context.cancellation.cancelled() => return PumpExitReason::LifecycleShutdown,
command = commands.recv() => {
let Some(command) = command else { return PumpExitReason::CoordinatorDropped; };
let ConnectionCommand { message, context: write_context, acknowledged } = command;
let closing = matches!(message, Message::Close(_));
let result = match middleware::transform(message, contract::Direction::Outgoing, writer.clone(), context).await {
Ok(Some(message)) => writer.write(message).await.map(|()| WriteOutcome::Written),
Ok(None) => Ok(WriteOutcome::Suppressed),
Err(error) => Err(ConnectionWriteError::Transport { message: error.to_string() }),
};
let reason = result.as_ref().err().map(|error| write_exit_reason(error, context));
if let Err(error) = &result {
tracing::debug!(websocket_connection_id = %context.connection_id, request_id = write_context.request_id.as_deref().unwrap_or(""), frame_phase = write_context.phase.as_str(), error = %error, "Responses WebSocket pump transport write failed");
}
let _ = acknowledged.send(result);
if let Some(reason) = reason { return reason; }
if closing { return PumpExitReason::ServerClose; }
continue;
}
_ = heartbeat.tick() => {
ping_sequence = ping_sequence.wrapping_add(1);
Message::Ping(ping_sequence.to_be_bytes().to_vec().into())
}
};
let message = match middleware::transform(
message,
contract::Direction::Outgoing,
writer.clone(),
context,
)
.await
{
Ok(Some(message)) => message,
Ok(None) => continue,
Err(_) => return PumpExitReason::MiddlewareFailed,
};
if let Err(error) = writer.write(message).await {
tracing::info!(websocket_connection_id = %context.connection_id, error = %error, "Responses WebSocket control write failed");
return write_exit_reason(&error, context);
}
context
.stats
.ping_written_count
.fetch_add(1, Ordering::Relaxed);
}
}
async fn write_message<S, E>(
socket: &mut S,
message: Message,
context: &PumpContext,
) -> Result<(), ConnectionWriteError>
where
S: Sink<Message, Error = E> + Unpin,
E: fmt::Display,
{
tokio::select! {
biased;
() = context.cancellation.cancelled() => Err(ConnectionWriteError::Closed),
result = timeout(context.config.write_timeout, socket.send(message)) => {
match result {
Ok(Ok(())) => { context.stats.record_write(context.opened_at); Ok(()) }
Ok(Err(error)) => Err(ConnectionWriteError::Transport { message: error.to_string() }),
Err(_) => Err(ConnectionWriteError::Timeout { timeout: context.config.write_timeout }),
}
}
}
}
fn write_exit_reason(error: &ConnectionWriteError, context: &PumpContext) -> PumpExitReason {
match error {
ConnectionWriteError::Timeout { .. } => PumpExitReason::WriteTimeout,
ConnectionWriteError::Transport { .. } => PumpExitReason::WriteError,
ConnectionWriteError::Closed if context.cancellation.is_cancelled() => {
PumpExitReason::LifecycleShutdown
}
ConnectionWriteError::Closed => PumpExitReason::PumpStopped,
}
}
fn emit_incoming(
incoming: &mpsc::Sender<PendingConnectionEvent>,
slots: &Arc<Semaphore>,
event: ConnectionEvent,
) -> Result<(), PumpExitReason> {
let permit = Arc::clone(slots)
.try_acquire_owned()
.map_err(|_| PumpExitReason::InboundOverload)?;
incoming
.try_send(PendingConnectionEvent {
event,
_permit: Some(permit),
})
.map_err(|error| match error {
mpsc::error::TrySendError::Full(_) => PumpExitReason::InboundOverload,
mpsc::error::TrySendError::Closed(_) => PumpExitReason::CoordinatorDropped,
})
}
pub use handle::{ResponsesWebSocketConnection, spawn_connection};
@@ -0,0 +1,230 @@
//! 单 owner 驱动连接读写、心跳与有界入站队列
use super::{
frame::*,
middleware,
state::{ConnectionCommand, ConnectionStats, PumpContext},
};
use axum::extract::ws::Message;
use futures::{Sink, Stream, StreamExt};
use gateway_core::{
engine::middleware::{FrozenMiddlewarePlan, MiddlewareHeader, websocket as contract},
lifecycle::CancellationToken,
};
use std::{
fmt,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use tokio::{
sync::{Semaphore, mpsc, oneshot},
time::Instant,
};
#[expect(clippy::too_many_arguments)]
pub(super) async fn run_pump<S, E>(
socket: S,
commands: mpsc::Receiver<ConnectionCommand>,
incoming: mpsc::Sender<PendingConnectionEvent>,
exited: oneshot::Sender<PumpExitReason>,
connection_id: Arc<str>,
cancellation: CancellationToken,
opened_at: Instant,
expired: Arc<AtomicBool>,
stats: Arc<ConnectionStats>,
config: ConnectionConfig,
middleware: Option<FrozenMiddlewarePlan>,
headers: Arc<[MiddlewareHeader]>,
) where
S: Stream<Item = Result<Message, E>> + Sink<Message, Error = E> + Unpin + Send + 'static,
E: fmt::Display + Send + 'static,
{
let context = Arc::new(PumpContext {
connection_id,
cancellation: cancellation.child_token(),
opened_at,
expired,
stats,
config,
middleware,
headers,
});
let _lifetime = middleware::CancelOnDrop(Some(context.cancellation.clone()));
let (writer, mut reader) = socket.split();
let writer = Arc::new(middleware::Writer::new(writer, context.clone()));
let sender: Arc<dyn contract::Sender> = writer.clone();
// 同一个受管任务同时驱动两侧;等待写入不能阻塞入站控制、关闭或取消
let reason = tokio::select! {
biased;
reason = read_connection(&mut reader, &incoming, sender.clone(), &context) => reason,
reason = write_connection(writer.clone(), commands, &context) => reason,
};
if matches!(reason, PumpExitReason::ClientClose) {
if let Err(error) = writer.finish_peer_close(&cancellation).await {
tracing::debug!(websocket_connection_id = %context.connection_id, %error, "Responses WebSocket close acknowledgement could not be flushed");
}
} else {
context.cancellation.cancel();
}
let _ = exited.send(reason);
tracing::debug!(websocket_connection_id = %context.connection_id, pump_exit_reason = reason.as_str(), "Responses WebSocket pump exited");
}
async fn read_connection<S, E>(
socket: &mut S,
incoming: &mpsc::Sender<PendingConnectionEvent>,
sender: Arc<dyn contract::Sender>,
context: &PumpContext,
) -> PumpExitReason
where
S: Stream<Item = Result<Message, E>> + Unpin,
E: fmt::Display,
{
let event_slots = Arc::new(Semaphore::new(INBOUND_EVENT_BUFFER));
let deadline = tokio::time::sleep_until(context.opened_at + context.config.max_age);
tokio::pin!(deadline);
let mut deadline_elapsed = false;
loop {
tokio::select! {
biased;
() = context.cancellation.cancelled() => return PumpExitReason::LifecycleShutdown,
() = &mut deadline, if !deadline_elapsed => {
deadline_elapsed = true;
context.expired.store(true, Ordering::Release);
// 到期只阻止下一轮,当前响应仍由协调层按原有合同收尾
match emit_incoming(incoming, &event_slots, ConnectionEvent::Expired) {
Ok(()) | Err(PumpExitReason::InboundOverload) => {}
Err(reason) => return reason,
}
}
message = socket.next() => {
if matches!(&message, Some(Ok(_))) { context.stats.record_read(context.opened_at); }
let message = match message {
Some(Ok(message)) => match middleware::transform(message, contract::Direction::Incoming, sender.clone(), context).await {
Ok(Some(message)) => Some(Ok(message)),
Ok(None) => continue,
Err(_) => return PumpExitReason::MiddlewareFailed,
},
other => other,
};
let event = match message {
Some(Ok(Message::Text(payload))) => Some(ConnectionEvent::Text(payload.to_string())),
Some(Ok(Message::Binary(_))) => Some(ConnectionEvent::Binary),
Some(Ok(Message::Ping(_))) => {
// Axum/tungstenite 在继续读取时自动刷新 Pong,不能再手工发送一份
context.stats.ping_received_count.fetch_add(1, Ordering::Relaxed);
None
}
Some(Ok(Message::Pong(_))) => {
context.stats.pong_received_count.fetch_add(1, Ordering::Relaxed);
None
}
Some(Ok(Message::Close(_))) => return PumpExitReason::ClientClose,
Some(Err(error)) => {
tracing::info!(websocket_connection_id = %context.connection_id, error = %error, "Responses WebSocket receive failed");
return PumpExitReason::ReadError;
}
None => return PumpExitReason::PeerEof,
};
if let Some(event) = event && let Err(reason) = emit_incoming(incoming, &event_slots, event) { return reason; }
}
}
}
}
async fn write_connection<S, E>(
writer: Arc<middleware::Writer<S>>,
mut commands: mpsc::Receiver<ConnectionCommand>,
context: &PumpContext,
) -> PumpExitReason
where
S: Sink<Message, Error = E> + Unpin + Send + 'static,
E: fmt::Display + Send + 'static,
{
let mut heartbeat = tokio::time::interval_at(
context.opened_at + DOWNSTREAM_PING_INTERVAL,
DOWNSTREAM_PING_INTERVAL,
);
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut ping_sequence = 0_u64;
loop {
let message = tokio::select! {
biased;
() = context.cancellation.cancelled() => return PumpExitReason::LifecycleShutdown,
command = commands.recv() => {
let Some(command) = command else { return PumpExitReason::CoordinatorDropped; };
let ConnectionCommand { message, context: write_context, acknowledged } = command;
let closing = matches!(message, Message::Close(_));
let result = match middleware::transform(message, contract::Direction::Outgoing, writer.clone(), context).await {
Ok(Some(message)) => writer.write(message).await.map(|()| WriteOutcome::Written),
Ok(None) => Ok(WriteOutcome::Suppressed),
Err(error) => Err(ConnectionWriteError::Transport { message: error.to_string() }),
};
let reason = result.as_ref().err().map(|error| write_exit_reason(error, context));
if let Err(error) = &result {
tracing::debug!(websocket_connection_id = %context.connection_id, request_id = write_context.request_id.as_deref().unwrap_or(""), frame_phase = write_context.phase.as_str(), error = %error, "Responses WebSocket pump transport write failed");
}
let _ = acknowledged.send(result);
if let Some(reason) = reason { return reason; }
if closing { return PumpExitReason::ServerClose; }
continue;
}
_ = heartbeat.tick() => {
ping_sequence = ping_sequence.wrapping_add(1);
Message::Ping(ping_sequence.to_be_bytes().to_vec().into())
}
};
let message = match middleware::transform(
message,
contract::Direction::Outgoing,
writer.clone(),
context,
)
.await
{
Ok(Some(message)) => message,
Ok(None) => continue,
Err(_) => return PumpExitReason::MiddlewareFailed,
};
if let Err(error) = writer.write(message).await {
tracing::info!(websocket_connection_id = %context.connection_id, error = %error, "Responses WebSocket control write failed");
return write_exit_reason(&error, context);
}
context
.stats
.ping_written_count
.fetch_add(1, Ordering::Relaxed);
}
}
fn write_exit_reason(error: &ConnectionWriteError, context: &PumpContext) -> PumpExitReason {
match error {
ConnectionWriteError::Timeout { .. } => PumpExitReason::WriteTimeout,
ConnectionWriteError::Transport { .. } => PumpExitReason::WriteError,
ConnectionWriteError::Closed if context.cancellation.is_cancelled() => {
PumpExitReason::LifecycleShutdown
}
ConnectionWriteError::Closed => PumpExitReason::PumpStopped,
}
}
fn emit_incoming(
incoming: &mpsc::Sender<PendingConnectionEvent>,
slots: &Arc<Semaphore>,
event: ConnectionEvent,
) -> Result<(), PumpExitReason> {
let permit = Arc::clone(slots)
.try_acquire_owned()
.map_err(|_| PumpExitReason::InboundOverload)?;
incoming
.try_send(PendingConnectionEvent {
event,
_permit: Some(permit),
})
.map_err(|error| match error {
mpsc::error::TrySendError::Full(_) => PumpExitReason::InboundOverload,
mpsc::error::TrySendError::Closed(_) => PumpExitReason::CoordinatorDropped,
})
}
@@ -0,0 +1,75 @@
//! 单连接共享的命令、运行预算与观测状态
use super::frame::{
ConnectionConfig, ConnectionWriteError, OUTBOUND_COMMAND_BUFFER, WriteContext, WriteOutcome,
};
use axum::extract::ws::Message;
use gateway_core::{
engine::middleware::{FrozenMiddlewarePlan, MiddlewareHeader},
lifecycle::CancellationToken,
};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
};
use tokio::{
sync::{mpsc, oneshot},
time::Instant,
};
pub(super) struct ConnectionCommand {
pub(super) message: Message,
pub(super) context: WriteContext,
pub(super) acknowledged: oneshot::Sender<Result<WriteOutcome, ConnectionWriteError>>,
}
#[derive(Default)]
pub(super) struct ConnectionStats {
pub(super) command_queue_high_water: AtomicUsize,
pub(super) command_backpressure_count: AtomicU64,
pub(super) ping_received_count: AtomicU64,
pub(super) ping_written_count: AtomicU64,
pub(super) pong_received_count: AtomicU64,
pub(super) last_read_ms: AtomicU64,
pub(super) last_write_ms: AtomicU64,
}
impl ConnectionStats {
pub(super) fn record_read(&self, opened_at: Instant) {
self.last_read_ms.store(
u64::try_from(opened_at.elapsed().as_millis()).unwrap_or(u64::MAX),
Ordering::Relaxed,
);
}
pub(super) fn record_write(&self, opened_at: Instant) {
self.last_write_ms.store(
u64::try_from(opened_at.elapsed().as_millis()).unwrap_or(u64::MAX),
Ordering::Relaxed,
);
}
pub(super) fn observe_command_queue(&self, sender: &mpsc::Sender<ConnectionCommand>) {
let queued = OUTBOUND_COMMAND_BUFFER
.saturating_sub(sender.capacity())
.saturating_add(1)
.min(OUTBOUND_COMMAND_BUFFER);
self.command_queue_high_water
.fetch_max(queued, Ordering::Relaxed);
if sender.capacity() == 0 {
self.command_backpressure_count
.fetch_add(1, Ordering::Relaxed);
}
}
}
pub(super) struct PumpContext {
pub(super) middleware: Option<FrozenMiddlewarePlan>,
pub(super) headers: Arc<[MiddlewareHeader]>,
pub(super) connection_id: Arc<str>,
pub(super) cancellation: CancellationToken,
pub(super) opened_at: Instant,
pub(super) expired: Arc<AtomicBool>,
pub(super) stats: Arc<ConnectionStats>,
pub(super) config: ConnectionConfig,
}
@@ -12,7 +12,7 @@ use gateway_core::engine::middleware::{
MiddlewareBody, MiddlewareError, MiddlewareFrame, MiddlewareFraming, MiddlewareHeader,
MiddlewareResponse,
};
use gateway_core::engine::response_control::ResponseControl;
use gateway_core::engine::response_control::{ResponseControl, ResponseControlUnavailable};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::event::ProviderResponseHeader;
use gateway_core::operation::ProviderSessionState;
@@ -25,7 +25,7 @@ use super::{
super::{DecodedResponsesRequest, OpenAiResponsesEncoder, ProtocolErrorBody},
connection::{ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext},
protocol::{
decode_response_interrupt, error_event, initial_engine_error_event, response_metadata_event,
error_event, initial_engine_error_event, is_response_create, response_metadata_event,
},
};
@@ -137,7 +137,7 @@ pub(super) async fn forward_response(
replay: &mut ConnectionReplaySnapshot,
capture: ReplayCaptureHandle,
validation: ResponseValidationFacts,
response_control: ResponseControl,
response_control: &ResponseControl,
) -> ForwardOutcome {
let (protocol, status, headers, mut body, _) = response.into_parts();
if protocol != "openai" || StatusCode::from_u16(status).is_err() {
@@ -150,8 +150,14 @@ pub(super) async fn forward_response(
ProviderResponseHeader::new(name, value)
})
.collect::<Vec<_>>();
let first = match next_body_input(connection, body.as_mut(), &response_control, &request_id)
.await
let first = match next_body_input(
connection,
body.as_mut(),
response_control,
&request_id,
false,
)
.await
{
BodyInput::Frame(Ok(Some(frame))) => frame,
BodyInput::Frame(Ok(None)) => {
@@ -197,8 +203,14 @@ pub(super) async fn forward_response(
let frame = match current.take() {
Some(frame) => frame,
None => {
match next_body_input(connection, body.as_mut(), &response_control, &request_id)
.await
match next_body_input(
connection,
body.as_mut(),
response_control,
&request_id,
terminal_seen,
)
.await
{
BodyInput::Frame(Ok(Some(frame))) => frame,
BodyInput::Frame(Ok(None)) if terminal_seen && body.is_finalized() => {
@@ -284,8 +296,10 @@ fn detach_body(body: Box<dyn MiddlewareBody>) {
}
fn validate_frame(frame: &MiddlewareFrame) -> Result<(), MiddlewareError> {
if frame.framing() != MiddlewareFraming::JsonDocument
|| serde_json::from_slice::<serde::de::IgnoredAny>(frame.bytes()).is_err()
if !matches!(
frame.framing(),
MiddlewareFraming::JsonDocument | MiddlewareFraming::RawBytes
) || std::str::from_utf8(frame.bytes()).is_err()
{
return Err(MiddlewareError::InvalidState);
}
@@ -302,6 +316,7 @@ async fn next_body_input(
body: &mut dyn MiddlewareBody,
response_control: &ResponseControl,
request_id: &Arc<str>,
terminal_seen: bool,
) -> BodyInput {
// 处理控制帧时继续持有同一个读取 future,不能取消正在加工正文的中间件
let frame = body.next_frame();
@@ -312,19 +327,11 @@ async fn next_body_input(
let Some(event) = event else { return BodyInput::Disconnect; };
match &event.event {
ConnectionEvent::Text(payload) => {
let error = match decode_response_interrupt(payload) {
Ok(Some(response_id)) => response_control.interrupt(&response_id).err().map(|_| {
super::super::RequestDecodeError::InvalidValue { field: "response_id".to_owned() }.protocol_body()
}),
Ok(None) => {
connection.defer(event);
continue;
}
Err(error) => Some(error.protocol_body()),
};
if let Some(error) = error
&& send_protocol_error(connection, StatusCode::BAD_REQUEST, error, request_id).await == ForwardOutcome::Disconnect
{
if is_response_create(payload) {
connection.defer(event);
continue;
}
if send_control(connection, response_control, payload, request_id).await == ForwardOutcome::Disconnect {
return BodyInput::Disconnect;
}
}
@@ -337,6 +344,11 @@ async fn next_body_input(
ConnectionEvent::Exited(_) => return BodyInput::Disconnect,
}
}
event = response_control.receive(), if terminal_seen => {
if forward_control_event(connection, response_control, event).await == ForwardOutcome::Disconnect {
return BodyInput::Disconnect;
}
}
frame = &mut frame => return BodyInput::Frame(frame),
}
}
@@ -537,19 +549,26 @@ impl WebSocketExecutionBody {
self.transformed_pending = transformed;
continue;
}
messages.extend(encoded.into_iter().map(|message| (message, transformed)));
let framing = if event.wire_event().is_some_and(|wire| {
wire.raw_websocket_message().is_some() && !wire.has_json_data()
}) {
MiddlewareFraming::RawBytes
} else {
MiddlewareFraming::JsonDocument
};
messages.extend(
encoded
.into_iter()
.map(|message| (message, transformed, framing)),
);
self.transformed_pending = false;
}
let terminal = self.encoder.is_completed() || self.encoder.has_wire_failure();
let last = messages.len().saturating_sub(1);
self.pending.extend(messages.into_iter().enumerate().map(
|(index, (message, transformed))| {
MiddlewareFrame::new(
Bytes::from(message),
MiddlewareFraming::JsonDocument,
terminal && index == last,
)
.with_transformed(transformed)
|(index, (message, transformed, framing))| {
MiddlewareFrame::new(Bytes::from(message), framing, terminal && index == last)
.with_transformed(transformed)
},
));
self.awaiting_terminal_eof = terminal;
@@ -720,3 +739,46 @@ async fn send_metadata(
.await
.is_ok()
}
pub(super) async fn send_control(
connection: &mut ResponsesWebSocketConnection,
control: &ResponseControl,
payload: &str,
request_id: &Arc<str>,
) -> ForwardOutcome {
let result = tokio::select! {
_ = connection.wait_for_exit() => return ForwardOutcome::Disconnect,
result = control.send(payload) => result,
};
if result.is_err() {
return send_gateway_error(
connection,
&GatewayError::new(
GatewayErrorKind::InvalidRequest,
"No upstream WebSocket connection is available for control messages",
),
request_id,
)
.await;
}
ForwardOutcome::Continue
}
pub(super) async fn forward_control_event(
connection: &mut ResponsesWebSocketConnection,
control: &ResponseControl,
event: Result<String, ResponseControlUnavailable>,
) -> ForwardOutcome {
let Ok(payload) = event else {
control.clear();
return ForwardOutcome::Continue;
};
if connection
.send_text(payload, WriteContext::connection(FramePhase::Data))
.await
.is_err()
{
return ForwardOutcome::Disconnect;
}
ForwardOutcome::Continue
}
@@ -24,6 +24,7 @@ use gateway_core::{
engine::{
execution::{AuthenticatedClient, ClientTransport},
middleware::{FrozenMiddlewarePlan, MiddlewareError},
response_control::ResponseControl,
},
lifecycle::{ConnectionGuard, ConnectionLifecycle},
operation::OperationKind,
@@ -46,11 +47,12 @@ use super::{
};
use connection::{ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext};
use forward::{
ConnectionReplaySnapshot, ForwardOutcome, execution_response, forward_response,
new_replay_capture, send_gateway_error, send_middleware_error, send_protocol_error,
ConnectionReplaySnapshot, ForwardOutcome, execution_response, forward_control_event,
forward_response, new_replay_capture, send_control, send_gateway_error, send_middleware_error,
send_protocol_error,
};
use protocol::connection_limit_event;
pub use protocol::{ResponseCreateFrameError, decode_response_create_with_context};
use protocol::{connection_limit_event, is_response_create};
const TEXT_FRAMES_ONLY: &str = "Responses WebSocket accepts text frames only";
const CONNECTION_LIMIT_CLOSE_REASON: &str = "Responses websocket connection limit reached";
@@ -191,10 +193,20 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
);
let mut request_count = 0_u64;
let mut replay = ConnectionReplaySnapshot::default();
let mut response_control = ResponseControl::default();
loop {
let Some(event) = connection.next_event().await else {
break;
let event = tokio::select! {
event = connection.next_event() => {
let Some(event) = event else { break };
event
}
event = response_control.receive() => {
if forward_control_event(&mut connection, &response_control, event).await == ForwardOutcome::Disconnect {
break;
}
continue;
}
};
let payload = match event {
ConnectionEvent::Text(payload) => payload,
@@ -212,10 +224,25 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
expire_connection(&mut connection).await;
break;
}
request_count = request_count.saturating_add(1);
let correlation_id = Arc::<str>::from(service.next_request_id());
let decoded = match decode_response_create_with_context(&payload, &request_headers) {
Ok(decoded) => decoded,
if !is_response_create(&payload) {
if send_control(
&mut connection,
&response_control,
&payload,
&correlation_id,
)
.await
== ForwardOutcome::Disconnect
{
break;
}
continue;
}
request_count = request_count.saturating_add(1);
// 初步解码只保留路由事实,避免整份正文跨越准入等待和响应交付
let model_hint = match decode_response_create_with_context(&payload, &request_headers) {
Ok(decoded) => Some(decoded.metadata().requested_model().to_owned()),
Err(error) => {
trace_rejected_request(
&correlation_id,
@@ -250,6 +277,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
expire_connection(&mut connection).await;
break;
}
response_control.clear();
let execution = service.execution();
let preparation = async {
// 握手的显式改写可继承,Key 策略与宿主设置仍在每轮执行前刷新
@@ -290,7 +318,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
}
};
let request_id = Arc::<str>::from(prepared.request_id().to_string());
let response_control = prepared.response_control();
response_control = prepared.response_control();
let capture = new_replay_capture();
let validation = ResponseValidationFacts::default();
let input = RequestInput {
@@ -298,9 +326,9 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
protocol: "openai".to_owned(),
operation: Some(OperationKind::Generate),
transport: ClientTransport::WebSocket,
model_hint: Some(decoded.metadata().requested_model().to_owned()),
model_hint,
headers: encode_headers(&raw_headers),
body: Bytes::from(payload.clone()),
body: Bytes::from(payload),
};
let service_for_terminal = service.clone();
let replay_for_terminal = replay.clone();
@@ -380,7 +408,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
&mut replay,
capture,
validation,
response_control,
&response_control,
)
.await
== ForwardOutcome::Disconnect
@@ -31,42 +31,22 @@ pub fn decode_response_create_with_context(
decode_response_create_inner(payload, request_headers)
}
/// 中断只引用活动响应;普通创建帧仍交给原有解码与串行准入路径
pub(super) fn decode_response_interrupt(
payload: &str,
) -> Result<Option<String>, ResponseCreateFrameError> {
/// 只识别需要本地准入的创建帧,其余文本不在网关解释
pub(super) fn is_response_create(payload: &str) -> bool {
#[derive(serde::Deserialize)]
struct FrameType {
#[serde(rename = "type")]
message_type: Option<String>,
message_type: String,
}
// 非控制帧仍在原来的串行请求边界校验,不能抢先拒绝排队的普通请求
// 只投影类型,避免为排队的大型 response.create 再构造完整 JSON 树
let Ok(frame) = serde_json::from_str::<FrameType>(payload) else {
return Ok(None);
};
if frame.message_type.as_deref() != Some("response.interrupt") {
return Ok(None);
match serde_json::from_str::<FrameType>(payload) {
Ok(frame) => frame.message_type == "response.create",
// struct 投影拒绝重复 type;沿用创建解码器的 JSON object 语义,
// 避免这类创建请求绕过准入进入透传路径
Err(_) => serde_json::from_str::<Value>(payload).is_ok_and(|frame| {
frame.get("type").and_then(Value::as_str) == Some("response.create")
}),
}
let value: Value =
serde_json::from_str(payload).map_err(|_| ResponseCreateFrameError::InvalidJson)?;
if value.get("mode").and_then(Value::as_str) != Some("discard_partial_items") {
return Err(ResponseCreateFrameError::Request(
RequestDecodeError::InvalidValue {
field: "mode".to_owned(),
},
));
}
let id = value
.get("response_id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.ok_or_else(|| {
ResponseCreateFrameError::Request(RequestDecodeError::InvalidValue {
field: "response_id".to_owned(),
})
})?;
Ok(Some(id.to_owned()))
}
fn decode_response_create_inner(
@@ -49,6 +49,36 @@ async fn list_route_should_keep_camel_case_group_and_page_wire() {
assert!(value["data"]["items"][0].get("member_count").is_none());
}
#[tokio::test]
async fn options_route_should_return_only_lightweight_group_fields() {
let fixture = authenticated_fixture().await;
let response = request(
router(&fixture),
Method::GET,
"/api/admin/account-groups/options?page=1&pageSize=1&search=alpha&enabled=true",
None,
true,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let value = response_json(response).await;
assert_eq!(
value["data"]["items"][0],
json!({
"id": PRIMARY_GROUP_ID,
"name": "Alpha routing",
"color": "#2563EBFF",
"enabled": true
})
);
assert_eq!(value["data"]["page"]["page"], 1);
assert_eq!(value["data"]["page"]["pageSize"], 1);
assert_eq!(value["data"]["page"]["total"], 1);
assert_eq!(value["data"]["page"]["totalPages"], 1);
assert_eq!(value["data"]["configRevision"], 7);
}
#[tokio::test]
async fn create_route_should_create_an_empty_group() {
let fixture = authenticated_fixture().await;
@@ -163,6 +193,11 @@ async fn list_route_should_reject_unknown_and_invalid_pagination_fields() {
"/api/admin/account-groups?page=0",
"/api/admin/account-groups?pageSize=0",
"/api/admin/account-groups?pageSize=201",
"/api/admin/account-groups/options?other=true",
"/api/admin/account-groups/options?page=0",
"/api/admin/account-groups/options?pageSize=0",
"/api/admin/account-groups/options?pageSize=201",
"/api/admin/account-groups/options?search=%00",
] {
let response = request(router(&fixture), Method::GET, uri, None, true).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{uri}");
@@ -219,6 +254,14 @@ async fn account_group_routes_should_require_admin_authentication() {
false,
)
.await;
let options_response = request(
router(&fixture),
Method::GET,
"/api/admin/account-groups/options",
None,
false,
)
.await;
let post_response = request(
router(&fixture),
Method::POST,
@@ -229,9 +272,29 @@ async fn account_group_routes_should_require_admin_authentication() {
.await;
assert_eq!(get_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(options_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(post_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn options_route_should_reject_client_key_identity() {
let fixture = crate::support::key_fixture().await;
let response = router(&fixture)
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/admin/account-groups/options")
.header("x-api-key", crate::support::RAW_KEY)
.header("x-request-id", "req_account_group_options_client_key")
.body(Body::empty())
.expect("client key options request"),
)
.await
.expect("client key options response");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
async fn authenticated_fixture() -> AdminTestFixture {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
@@ -0,0 +1,61 @@
//! 验证管理端来源经用例和 HTTP 出口到达受控记录,公开响应和扩展不泄漏
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode},
};
use std::sync::Arc;
use tower::ServiceExt;
#[tokio::test]
async fn store_failure_is_recorded_once_with_the_incoming_request_id() {
let fixture = super::AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let diagnostics = Arc::new(crate::support::RecordingDiagnostics::default());
let bundle = crate::openai::api_bundle_with_diagnostics(
fixture.services,
gateway_api::ApiConfig {
asset_directory: std::env::temp_dir(),
request_timeout_seconds: None,
cors_allowed_origins: Vec::new(),
request_id_header: "x-request-id".to_owned(),
},
diagnostics.clone(),
);
let response = bundle
.router()
.oneshot(
Request::builder()
.uri("/api/admin/accounts")
.header("cookie", "cpr_session=valid-session")
.header("x-request-id", "admin-native-cause-test")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(
response
.extensions()
.get::<gateway_admin::model::AdminError>()
.is_none()
);
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
assert!(!String::from_utf8_lossy(&bytes).contains("PRIVATE_ADMIN_NATIVE_CAUSE"));
let failures = diagnostics.0.lock().unwrap();
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].kind, "unavailable");
assert_eq!(
failures[0].correlation_id.as_deref(),
Some("admin-native-cause-test")
);
assert!(
failures[0]
.details
.as_ref()
.unwrap()
.as_str()
.contains("PRIVATE_ADMIN_NATIVE_CAUSE")
);
}
+160 -63
View File
@@ -18,9 +18,10 @@ use gateway_admin::{
MutationContext, Revision,
account_groups::{
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupColor,
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage,
AccountGroupRecord, AccountGroupUsage, DeleteAccountGroup, NewAccountGroup,
SetAccountGroupEnabled, UpdateAccountGroup,
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation,
AccountGroupOptionsPage, AccountGroupPage, AccountGroupRecord, AccountGroupRef,
AccountGroupUsage, DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled,
UpdateAccountGroup,
},
accounts::{
AccountListQuery, AccountPage, AccountPageItem, AccountRuntimeSnapshot,
@@ -37,9 +38,10 @@ use gateway_admin::{
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
},
observability::{
DashboardObservation, DecimalAmount, DiagnosticDimension, DiagnosticsObservation,
OpsError, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageDetail,
UsageFilter, UsageListRecord, UsageOverview, UsagePage, UsageQuery,
DashboardObservation, DashboardQuery, DecimalAmount, DiagnosticDimension,
DiagnosticsObservation, Granularity, OpsError, OpsErrorPage, OpsErrorQuery,
RequestMetricPoint, TimeRange, UsageDetail, UsageFilter, UsageListRecord,
UsageOverview, UsagePage, UsageQuery,
},
provider_credentials::{
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
@@ -86,6 +88,7 @@ mod account_groups;
mod accounts;
mod auth;
mod client_keys;
mod error_diagnostics;
mod errors;
mod observability;
mod proxies;
@@ -185,6 +188,7 @@ impl AdminTestFixture {
];
let bundle = gateway_admin::initialize(
AdminConfig {
session_absolute_ttl_minutes: 30 * 24 * 60,
session_ttl_minutes: 60,
default_username: "admin_1".to_owned(),
default_password: InitialAdminPassword::new("strong-admin-password"),
@@ -313,6 +317,7 @@ impl MemoryAuthStore {
},
admin_user_id: "admin_1".to_owned(),
},
absolute_expires_at: None,
expires_at: Utc::now() + Duration::hours(1),
},
);
@@ -326,6 +331,15 @@ impl MemoryAuthStore {
self.fail_audit.store(fail, Ordering::SeqCst);
}
pub fn set_session_expiry(&self, session_id: &str, expires_at: chrono::DateTime<Utc>) {
self.sessions
.lock()
.unwrap()
.get_mut(session_id)
.unwrap()
.expires_at = expires_at;
}
pub fn session_count(&self) -> usize {
self.sessions.lock().expect("sessions").len()
}
@@ -397,6 +411,25 @@ impl AuthStore for MemoryAuthStore {
Ok(())
}
async fn renew_session(
&self,
session_id: &str,
expected: &AuthSession,
expires_at: chrono::DateTime<chrono::Utc>,
) -> AdminStoreResult<Option<AuthSession>> {
let mut sessions = self.sessions.lock().unwrap();
if let Some(session) = sessions.get_mut(session_id) {
if session.expires_at <= chrono::Utc::now() {
return Ok(None);
}
if session == expected {
session.expires_at = expires_at;
}
return Ok(Some(session.clone()));
}
Ok(None)
}
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
Ok(self.sessions.lock().expect("sessions").remove(session_id))
}
@@ -562,37 +595,46 @@ impl SettingsStore for MemorySettingsStore {
}
let updated = RuntimeSettings {
request_profiles,
request_location_enabled: command.request_location_enabled,
request_location: command.request_location,
config_revision: next_revision(settings.config_revision),
model_mappings: command.model_mappings,
refresh_margin_seconds: command.refresh_margin_seconds,
refresh_concurrency: command.refresh_concurrency,
max_concurrent_per_account: command.max_concurrent_per_account,
request_interval_ms: command.request_interval_ms,
max_waiting_per_key: command.max_waiting_per_key,
max_waiting_per_account: command.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: command.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: command.responses_max_decompressed_body_bytes,
smart_scheduling: command.smart_scheduling,
rotation_strategy: command.rotation_strategy,
min_codex_desktop_version: command.min_codex_desktop_version,
min_codex_cli_version: command.min_codex_cli_version,
usage_retention_days: command.usage_retention_days,
ops_event_retention_days: command.ops_event_retention_days,
audit_retention_days: command.audit_retention_days,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: command.values.request_location_enabled,
request_location: command.values.request_location,
refresh_margin_seconds: command.values.refresh_margin_seconds,
refresh_concurrency: command.values.refresh_concurrency,
max_concurrent_per_account: command.values.max_concurrent_per_account,
request_interval_ms: command.values.request_interval_ms,
max_waiting_per_key: command.values.max_waiting_per_key,
max_waiting_per_account: command.values.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.values.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: command
.values
.openai_guardian_reserved_concurrency,
openai_account_affinity: command.values.openai_account_affinity,
max_account_rotations: command.values.max_account_rotations,
openai_session_affinity_ttl_hours: command.values.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: command
.values
.responses_max_decompressed_body_bytes,
smart_scheduling: command.values.smart_scheduling,
min_codex_desktop_version: command.values.min_codex_desktop_version,
min_codex_cli_version: command.values.min_codex_cli_version,
usage_retention_days: command.values.usage_retention_days,
ops_event_retention_days: command.values.ops_event_retention_days,
audit_retention_days: command.values.audit_retention_days,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
};
*settings = updated.clone();
Ok(updated)
@@ -748,6 +790,48 @@ impl AccountGroupStore for MemoryAccountGroupStore {
})
}
async fn list_account_group_options(
&self,
query: AccountGroupListQuery,
) -> AdminStoreResult<AccountGroupOptionsPage> {
let state = self.state.lock().expect("account groups");
let search = query.search.as_ref().map(|value| value.to_lowercase());
let mut matching = state
.groups
.values()
.filter(|record| {
query
.enabled
.is_none_or(|enabled| record.enabled == enabled)
})
.filter(|record| {
search
.as_ref()
.is_none_or(|search| record.name.to_lowercase().contains(search))
})
.map(|record| AccountGroupRef {
id: record.id.clone(),
name: record.name.clone(),
color: record.color.clone(),
enabled: record.enabled,
})
.collect::<Vec<_>>();
matching.sort_by(|left, right| left.name.cmp(&right.name));
let total = matching.len() as u64;
let page_size = usize::from(query.page_size.get());
let offset = usize::try_from(query.page.saturating_sub(1))
.unwrap_or(usize::MAX)
.saturating_mul(page_size);
let items = matching.into_iter().skip(offset).take(page_size).collect();
Ok(AccountGroupOptionsPage {
config_revision: state.revision,
items,
total,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn load_account_group_members(
&self,
group_ids: &[gateway_core::routing::AccountGroupId],
@@ -1263,13 +1347,13 @@ impl AccountRuntimeStore for UnusedStore {
impl ObservabilityStore for UnusedStore {
async fn dashboard_summary(
&self,
range: TimeRange,
query: DashboardQuery,
_: DateTime<Utc>,
) -> AdminStoreResult<DashboardObservation> {
*self
.dashboard_summary_range
.lock()
.expect("dashboard summary range") = Some(range);
.expect("dashboard summary range") = Some(query.range);
self.dashboard_observation
.lock()
.expect("dashboard observation")
@@ -1277,7 +1361,11 @@ impl ObservabilityStore for UnusedStore {
.ok_or_else(|| unavailable("dashboard"))
}
async fn dashboard_trend(&self, _: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>> {
async fn dashboard_trend(
&self,
_: TimeRange,
_: Granularity,
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
Err(unavailable("dashboard trend"))
}
@@ -1285,6 +1373,7 @@ impl ObservabilityStore for UnusedStore {
&self,
range: TimeRange,
filter: UsageFilter,
_: Granularity,
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
let mut data = self.observations.lock().expect("observations");
data.trends.push((range, filter));
@@ -1295,6 +1384,7 @@ impl ObservabilityStore for UnusedStore {
&self,
_: TimeRange,
_: UsageFilter,
_: Granularity,
) -> gateway_admin::ports::store::UsageCalculatedBillingStream<'_> {
Box::pin(futures::stream::once(async {
Err(unavailable("usage billing facts"))
@@ -1342,6 +1432,7 @@ impl ObservabilityStore for UnusedStore {
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
_: u16,
) -> AdminStoreResult<DiagnosticsObservation> {
Ok(self.diagnostics.lock().expect("diagnostics").clone())
}
@@ -1571,37 +1662,42 @@ fn test_runtime_settings() -> RuntimeSettings {
]);
RuntimeSettings {
request_profiles: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
model_mappings: mappings,
refresh_margin_seconds: 3_600,
refresh_concurrency: 2,
max_concurrent_per_account: 3,
request_interval_ms: 50,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 3_600,
refresh_concurrency: 2,
max_concurrent_per_account: 3,
request_interval_ms: 50,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
}
}
@@ -1643,6 +1739,7 @@ fn unavailable(resource: &'static str) -> AdminStoreError {
resource,
"unused test port",
)
.with_source(std::io::Error::other("PRIVATE_ADMIN_NATIVE_CAUSE"))
}
fn not_found(resource: &'static str) -> AdminStoreError {

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