Compare commits

...
156 Commits
Author SHA1 Message Date
zyycnandCodex 7542ed365e chore: bump version to 3.0.23
Co-authored-by: Codex <noreply@openai.com>
2026-07-29 03:52:17 +08:00
zyycnandCodex bf4da7a3e7 fix(accounts): unify credential and quota state
Apply OpenAI reauthorization to the selected local account ID while atomically replacing its verified upstream identity. Converge successful quota observations across refresh paths and preserve explicitly disabled account state during connection checks, quota refreshes, and credential rotation.

Co-authored-by: Codex <noreply@openai.com>
2026-07-29 03:51:54 +08:00
zyycnandCodex 0fe354b083 fix(openai): preserve requested service tier
Restore 2.x semantics: a request-specified tier drives persisted display and pricing, while the upstream response tier remains separately observable. Cover HTTP SSE and WebSocket conflicts plus the response-only fallback.

Co-authored-by: Codex <noreply@openai.com>
2026-07-29 03:09:22 +08:00
zyycnandCodex 6d0c583599 chore: bump version to 3.0.22
Co-authored-by: Codex <noreply@openai.com>
2026-07-29 02:36:04 +08:00
zyycnandCodex 2e861c03c4 fix(settings): apply refresh margin immediately
Schedule token refreshes at the exact configured lead time and reschedule existing refreshable accounts when that setting changes. Preserve current retry schedules when unrelated settings are saved.

Co-authored-by: Codex <noreply@openai.com>
2026-07-29 02:34:25 +08:00
zyycnandCodex d5fc778ea7 fix(openai): reconcile account connection failures
Expose exact upstream responses to authenticated connection tests, reload the resulting account state, and classify usage-limit windows as resettable quota exhaustion. Restore non-quota availability after verified credential rotation and refresh quota after reauthorization.

Co-authored-by: Codex <noreply@openai.com>
2026-07-29 02:31:38 +08:00
zyycnandCodex 404cd41ff4 docs: refresh runtime and account behavior
Document current OAuth reauthorization, quota state synchronization, response service tiers, transparent wire behavior, buffered observations, and deployment boundaries.

Co-authored-by: Codex <noreply@openai.com>
2026-07-29 01:38:03 +08:00
zyycnandCodex 851f473b88 fix(store): persist continuation affinity before delivery
Remove the asynchronous continuation queue so Core waits for the bounded Redis write before exposing a reusable response ID. Keep Redis index maintenance bounded per record to protect terminal response latency.

Co-authored-by: Codex <noreply@openai.com>
2026-07-29 01:35:05 +08:00
zyycnandCodex e97da4f551 chore: bump version to 3.0.21
Co-authored-by: Codex <noreply@openai.com>
2026-07-29 01:09:42 +08:00
zyycnandCodex 5544b0fb29 fix(openai): separate quota and credential auth state
Keep quota probes from terminally invalidating OAuth accounts on 401 or 403, and stop manual quota refreshes from querying upstream with an expired access token. Model quota-owned transitions explicitly and cover both boundaries with regression tests.

Co-authored-by: Codex <noreply@openai.com>
2026-07-29 01:09:07 +08:00
zyycnandCodex 428e744710 test(providers): remove destructive live fixtures
Delete secret-dependent integration tests that consume live quota, register upstream tasks, or rotate OAuth refresh tokens. Keep the default workspace suite entirely runnable without ignored cases.

Co-authored-by: Codex <noreply@openai.com>
2026-07-29 01:06:09 +08:00
zyycnandCodex 7b406765d6 fix(store): harden execution observation buffering
Log direct Store failures without changing client-visible results, emit connection-test diagnostics before asynchronous persistence, and keep Provider errors intact when observation writes fail. Add a process-wide byte budget, cumulative queue statistics, ordered failure isolation, and bounded shutdown draining to the PostgreSQL observation worker.

Co-authored-by: Codex <noreply@openai.com>
2026-07-29 00:55:33 +08:00
zyycnandCodex 92ae7df115 fix(openai): bind reauthorization to stable account identity
Co-authored-by: Codex <noreply@openai.com>
2026-07-29 00:45:43 +08:00
zyycnandCodex cc42e78334 refactor(store): buffer data-plane side effects
Move PostgreSQL execution observations and Redis release, circuit, and continuation writes onto bounded non-blocking queues. Supervise independent flush workers so database or coordination failures can drop recoverable side effects without delaying protocol delivery.

Co-authored-by: Codex <noreply@openai.com>
2026-07-29 00:11:26 +08:00
zyycnandCodex 0c90ed30fb fix(proxy): restore transparent wire semantics
Preserve raw request and response data across HTTP, SSE, and WebSocket adapters, including opaque headers and upstream error envelopes. Remove private payload and catalog gates, keep account switching behind the pre-delivery barrier, and make observation failures unable to replace client-visible protocol data.

Co-authored-by: Codex <noreply@openai.com>
2026-07-29 00:10:35 +08:00
zyycnandCodex 5a81856510 fix(openai): preserve opaque response IDs
Treat Codex response handles as opaque strings across request decoding, continuation affinity, and upstream metadata. Store raw UTF-8 bytes without the PostgreSQL text index so long or NUL-containing handles cannot fail after a successful response.

Co-authored-by: Codex <noreply@openai.com>
2026-07-29 00:07:07 +08:00
zyycnandCodex 3110b71512 chore: bump version to 3.0.20
Co-authored-by: Codex <noreply@openai.com>
2026-07-28 18:46:20 +08:00
zyycnandCodex eac1b2c39e fix(xai): classify upstream failures by scope
Co-authored-by: Codex <noreply@openai.com>
2026-07-28 18:31:06 +08:00
zyycnandCodex 337bb80230 fix(openai): restore streaming failure fallback
Restore the v2.x stream commit policies so ordinary requests forward the first event immediately while continuation and same-batch failures remain behind the retry boundary. Preserve typed upstream failure batches through Core and both streaming adapters, with a 64 KiB prefetch limit and end-to-end regression coverage.

Co-authored-by: Codex <noreply@openai.com>
2026-07-28 17:29:15 +08:00
zyycnandCodex 82f2a0da06 fix(openai): preserve quota exhaustion state
Prefer explicit quota evidence over generic 429 feedback, keep exhausted and terminal accounts from being downgraded by temporary rate limits, and reconcile historical cooldown states from current quota observations.

Co-authored-by: Codex <noreply@openai.com>
2026-07-28 14:47:21 +08:00
zyycnandCodex f0d8dad71e chore: bump version to 3.0.19
Co-authored-by: Codex <noreply@openai.com>
2026-07-28 13:05:04 +08:00
zyycnandCodex f5c1d773d7 fix(accounts): use current credential revisions
Load the current credential revision server-side for reauthorization and manual refresh so background token refreshes do not make admin mutations fail stale client fences.

Co-authored-by: Codex <noreply@openai.com>
2026-07-28 13:04:12 +08:00
zyycnandCodex 9148e6b11e chore: bump version to 3.0.18
Co-authored-by: Codex <noreply@openai.com>
2026-07-28 09:48:21 +08:00
zyycnandCodex a7d8c3d3df fix(accounts): make fixed diagnostics resilient
Co-authored-by: Codex <noreply@openai.com>
2026-07-28 09:45:35 +08:00
zyycnandCodex 997eb86aa0 fix(openai): refresh expired OAuth access tokens
Force refresh scheduling for OAuth accounts whose access token has already expired, even when a persisted next_refresh_at points to the future.

Co-authored-by: Codex <noreply@openai.com>
2026-07-28 09:41:44 +08:00
zyycnandCodex 3866acc269 chore: bump version to 3.0.17
Co-authored-by: Codex <noreply@openai.com>
2026-07-28 00:06:15 +08:00
zyycnandCodex 38477f4328 fix(openai): normalize OAuth import credentials
Import OAuth accounts from access and refresh tokens only, then verify identity and observe the initial quota through official endpoints.

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 23:54:59 +08:00
zyycnandCodex 5168f6a924 fix(openai): replace stale passive rate-limit snapshots
Treat each passive response-header observation as a complete rate-limit bucket snapshot, preventing stale secondary windows from surviving into a newer primary-only update.\n\nExtract duplicate OAuth callback parameter rejection to keep the release quality gate warning-free.

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 23:12:17 +08:00
zyycnandCodex 3a407c635a chore: bump version to 3.0.16
Co-authored-by: Codex <noreply@openai.com>
2026-07-27 22:39:31 +08:00
zyycnandCodex 6901e0ed45 fix(openai): accept standard OAuth callback parameters
Accept standard OAuth callback parameters while keeping one-shot flow and duplicate security parameter rejection.

Classify unavailable identity verification correctly and add redacted OAuth/import failure diagnostics.

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 22:39:01 +08:00
zyycnandClaude Fable 5 9758c420fa refactor(availability): converge account availability on the gateway-core enum
- gateway-admin: drop the mirrored 7-variant enum; re-export the core type
- gateway-api: serialize wire availability via core as_str (bytes unchanged)
- gateway-store: drop ProviderAccountAvailability and four identity mappers;
  rows parse through core's codec with the same error string; redis state
  validation reuses core parse instead of a literal 7-string set
- providers/openai+xai: drop core<->admin identity mappers made moot by the
  admin re-export
- providers/xai: a single exhaustive From<GrokCredentialAvailability> impl
  is now the only Grok->core translation point

No behavior change: DB strings, wire JSON, and the AccountStatus fold
(including Invalid->Expired display) are byte-identical. 1244 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 20:40:47 +08:00
ZyyandCodex 6d2bf06ac7 chore: bump version to 3.0.15
Co-authored-by: Codex <noreply@openai.com>
2026-07-27 17:29:36 +08:00
ZyyandCodex b8faaf250f fix(frontend): align dashboard account usage metrics
Add a metric layout variant for account usage windows so dashboard account rows align request bars with adjacent recent-usage text without changing the shared compact account-table presentation.

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 17:22:28 +08:00
ZyyandCodex 40137b1205 fix(backend): preserve safe upstream error details
Keep admin account connection tests routed through the provider probe even when the stored account is already marked unavailable so clicking test can refresh state from the upstream response.

Preserve provider-declared safe upstream details when OpenAI-compatible API errors collapse local provider unavailability to the stable no-provider contract.

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 17:09:59 +08:00
ZyyandCodex c67cde4e90 chore: bump version to 3.0.14
Co-authored-by: Codex <noreply@openai.com>
2026-07-27 16:28:29 +08:00
ZyyandCodex 2e9213a784 fix(backend): derive Codex metadata from provider catalogs
Share Codex Responses request semantics across OpenAI and xAI providers so reasoning effort, presets, request kind, subagent kind, and compaction observations stay consistent.

Read xAI reasoning efforts from official catalog feature options instead of model-name hardcoding, and make Codex service tiers data-driven through ModelPresentation rather than API adapter defaults.

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 16:27:41 +08:00
ZyyandCodex 87496b5656 fix(frontend): polish dashboard layout
Keep dashboard header actions pinned to the action column, widen the xAI runtime field, and align active-account usage metrics without changing the shared usage window component.

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 14:55:36 +08:00
ZyyandCodex c856e4306f fix(xai): mark rate limits as quota exhausted
Classify xAI 429 rate-limit responses as quota exhaustion so the scheduler removes the account from active rotation until the normal recovery flow makes it available again.

Cover the provider replay path and transport classification with tests.

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 14:55:30 +08:00
ZyyandCodex 92090f28bf docs(backend): unify provider comments to Chinese
Convert the remaining English doc comments in the openai and xai provider
crates to Chinese, function-oriented wording so the whole backend follows
one convention. Explanatory narration is compressed into what the item does
and what its errors mean; code identifiers, route paths, rustdoc links and
`# Errors` headings stay as-is.

Also fixes two half-translated comments left in the websocket protocol
module and merges a duplicated doc block on the OIDC verifier constructor.
No code, signature or `#[error(...)]` string changes.

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 13:13:44 +08:00
ZyyandCodex 12c2b612de chore(db): fold retention indexes into initial migration
Keep the database baseline as a single initial migration now that deployed instances have already been upgraded. The retention delete indexes move into 0001, the standalone 0002 file is removed, and the migration tests now expect one applied migration.

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 12:05:49 +08:00
e8af0b9c66 chore(deps): Bump ed25519-dalek from 2.2.0 to 3.0.0 in /backend (#26)
* chore(deps): Bump ed25519-dalek from 2.2.0 to 3.0.0 in /backend

Bumps [ed25519-dalek](https://github.com/dalek-cryptography/curve25519-dalek) from 2.2.0 to 3.0.0.
- [Release notes](https://github.com/dalek-cryptography/curve25519-dalek/releases)
- [Changelog](https://github.com/dalek-cryptography/curve25519-dalek/blob/3.0.0/CHANGELOG.md)
- [Commits](https://github.com/dalek-cryptography/curve25519-dalek/compare/ed25519-2.2.0...3.0.0)

---
updated-dependencies:
- dependency-name: ed25519-dalek
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: enable ed25519 PKCS8 allocation support

ed25519-dalek 3 gates private-key PKCS#8 encoding behind the alloc feature. Enable it alongside pkcs8 so existing credential export tests continue to compile and exercise the same behavior.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zyy <No Email>
Co-authored-by: Codex <noreply@openai.com>
2026-07-27 12:04:48 +08:00
80776e8e03 chore(deps): Bump jsonwebtoken from 10.3.0 to 11.0.0 in /backend (#24)
* chore(deps): Bump jsonwebtoken from 10.3.0 to 11.0.0 in /backend

Bumps [jsonwebtoken](https://github.com/Keats/jsonwebtoken) from 10.3.0 to 11.0.0.
- [Changelog](https://github.com/Keats/jsonwebtoken/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Keats/jsonwebtoken/compare/v10.3.0...v11.0.0)

---
updated-dependencies:
- dependency-name: jsonwebtoken
  dependency-version: 11.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: adapt jsonwebtoken 11 header extras API

jsonwebtoken 11 wraps non-standard JWT header fields in Extras. Keep the existing strict header validation by checking the wrapped inner map instead of calling is_empty directly.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zyy <No Email>
Co-authored-by: Codex <noreply@openai.com>
2026-07-27 11:54:06 +08:00
dependabot[bot] b296c795ac chore(deps): Bump the minor-and-patch group across 1 directory with 3 updates (#22)
Bumps the minor-and-patch group with 3 updates in the / directory: [actions/checkout](https://github.com/actions/checkout), [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) and [docker/login-action](https://github.com/docker/login-action).


Updates `actions/checkout` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1)

Updates `github/codeql-action/upload-sarif` from 4.37.1 to 4.37.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81)

Updates `docker/login-action` from 4.4.0 to 4.5.1
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/af1e73f918a031802d376d3c8bbc3fe56130a9b0...abd2ef45e78c5afb21d64d4ca52ee8550d9572c7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: docker/login-action
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:41:57 +08:00
dependabot[bot] 9fc162a82f chore(deps): Bump base64 from 0.22.1 to 0.23.0 in /backend (#25)
Bumps [base64](https://github.com/marshallpierce/rust-base64) from 0.22.1 to 0.23.0.
- [Changelog](https://github.com/marshallpierce/rust-base64/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/marshallpierce/rust-base64/compare/v0.22.1...v0.23.0)

---
updated-dependencies:
- dependency-name: base64
  dependency-version: 0.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:41:53 +08:00
dependabot[bot] 9532bd438b chore(deps): Bump getrandom from 0.3.4 to 0.4.3 in /backend (#27)
Bumps [getrandom](https://github.com/rust-random/getrandom) from 0.3.4 to 0.4.3.
- [Changelog](https://github.com/rust-random/getrandom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/getrandom/compare/v0.3.4...v0.4.3)

---
updated-dependencies:
- dependency-name: getrandom
  dependency-version: 0.4.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:41:49 +08:00
dependabot[bot] 73319f1943 chore(deps): Bump syn from 2.0.118 to 3.0.3 in /backend (#28)
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.118 to 3.0.3.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.118...3.0.3)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:41:45 +08:00
dependabot[bot] ee56d6a1d2 chore(deps): Bump the minor-and-patch group across 1 directory with 8 updates (#29)
Bumps the minor-and-patch group with 8 updates in the /frontend directory:

| Package | From | To |
| --- | --- | --- |
| [@fontsource-variable/inter](https://github.com/fontsource/font-files/tree/HEAD/fonts/variable/inter) | `5.2.8` | `5.3.0` |
| [@fontsource-variable/jetbrains-mono](https://github.com/fontsource/font-files/tree/HEAD/fonts/variable/jetbrains-mono) | `5.2.8` | `5.3.0` |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.26.0` | `1.27.0` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.49.0` | `1.50.0` |
| [marked](https://github.com/markedjs/marked) | `18.0.6` | `18.0.7` |
| [@antfu/eslint-config](https://github.com/antfu/eslint-config) | `9.1.0` | `9.2.0` |
| [eslint](https://github.com/eslint/eslint) | `10.7.0` | `10.8.0` |
| [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) | `3.3.7` | `3.3.8` |



Updates `@fontsource-variable/inter` from 5.2.8 to 5.3.0
- [Changelog](https://github.com/fontsource/font-files/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fontsource/font-files/commits/HEAD/fonts/variable/inter)

Updates `@fontsource-variable/jetbrains-mono` from 5.2.8 to 5.3.0
- [Changelog](https://github.com/fontsource/font-files/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fontsource/font-files/commits/HEAD/fonts/variable/jetbrains-mono)

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

Updates `es-toolkit` from 1.49.0 to 1.50.0
- [Release notes](https://github.com/toss/es-toolkit/releases)
- [Changelog](https://github.com/toss/es-toolkit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/toss/es-toolkit/compare/v1.49.0...v1.50.0)

Updates `marked` from 18.0.6 to 18.0.7
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.6...v18.0.7)

Updates `@antfu/eslint-config` from 9.1.0 to 9.2.0
- [Release notes](https://github.com/antfu/eslint-config/releases)
- [Commits](https://github.com/antfu/eslint-config/compare/v9.1.0...v9.2.0)

Updates `eslint` from 10.7.0 to 10.8.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.7.0...v10.8.0)

Updates `vue-tsc` from 3.3.7 to 3.3.8
- [Release notes](https://github.com/vuejs/language-tools/releases)
- [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vuejs/language-tools/commits/v3.3.8/packages/tsc)

---
updated-dependencies:
- dependency-name: "@fontsource-variable/inter"
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@fontsource-variable/jetbrains-mono"
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@lucide/vue"
  dependency-version: 1.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@antfu/eslint-config"
  dependency-version: 9.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 10.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:41:41 +08:00
ZyyandCodex 5d29ddfffc chore: bump version to 3.0.13
Co-authored-by: Codex <noreply@openai.com>
2026-07-27 11:33:55 +08:00
ZyyandCodex 45903b684b fix(admin): remove config revision fences from API writes
Operator mutations no longer require callers to carry configRevision values through accounts, client keys, provider credentials, or settings. The store and admin use cases now apply current state directly, which removes stale-revision failures from normal create, update, and delete flows.

xAI Responses catalog entries now let the upstream validate feature support instead of rejecting requests locally when optional catalog metadata is absent, matching the Build routing behavior used by grok2api.

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 11:30:59 +08:00
zyycnandCodex a580874697 chore: bump version to 3.0.12
Co-authored-by: Codex <noreply@openai.com>
2026-07-27 02:57:15 +08:00
zyycnandClaude 885369f19f chore(release): drop Windows target, extend stop grace, document single-replica
The Windows release shipped a zip that self-update cannot extract, so
remove the Windows platform, matrix leg, and its dead archive handling
rather than add zip support. Raise the compose stop grace period above the
serial drain plus worker-shutdown budget so Docker does not SIGKILL mid
shutdown. Record that the deployment is single-replica and why, since the
worker leader lease and self-update swap are per-replica.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 02:26:25 +08:00
zyycnandClaude 24ff339c97 fix(admin): tolerate bad records in usage billing and stop showing invalid accounts as active
One record with an unparseable provider_kind no longer makes the whole
usage list return 500; the failing record is skipped and the list still
renders. An account whose credential availability is Invalid — excluded
from scheduling — was displayed as Active in the directory; it now derives
the Expired status so it reads as unavailable, consistent with how a dead
credential surfaces to the operator.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 02:26:13 +08:00
zyycnandClaude e582eb73d2 fix(xai): preserve unknown fields, scope history sanitize, guard cache-route tools, batch selector reads
Rebuild history items with in-place Map surgery so unknown upstream fields
survive instead of evaporating through a field whitelist. Narrow history
sanitization to the known Grok-internal injection sites rather than
stripping every null and any key named phase at any depth. Ensure the
prompt-cache route's injected web_search/x_search tools can never be
actually invoked when the client sent its own tools. Batch the selector's
per-account observed-model-support and cooldown reads instead of a serial
Redis round trip each. One plan scope failing no longer aborts the whole
provider catalog refresh.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 02:26:02 +08:00
zyycnandClaude ec252b3b46 fix(openai): per-account quota sync, narrower catalog union and expiry, model presentation
One account's quota parse failure no longer aborts the whole synchronize
batch. The per-plan catalog union no longer wedges every refresh on any
presentation/metadata drift — only routing-relevant facts must agree.
compile_model_capabilities now attaches a ModelPresentation so openai
models carry display facts through /v1/models instead of fabricated
defaults. is_expired_credential requires a genuine 401/403 or structured
expiry code instead of matching the substring "unauthorized" anywhere in
the body. The request turn-state header is authoritative over the body
camelCase alias.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 02:25:47 +08:00
zyycnandClaude 6503f36750 fix(core): stop deadline expiry from tripping the circuit and unpin recovery account
A gateway-budget deadline was recorded as a provider Timeout that counts
against the circuit breaker, so many long streams expiring together could
wrongly open it; a deadline reached after downstream delivery has begun no
longer feeds the provider circuit. required_account was pinned for the rest
of the request after a same-account credential recovery, so a later
retryable error terminated instead of switching accounts; the pin now binds
only the immediate recovery replay and is cleared afterward, letting
subsequent errors use the remaining accounts and retry budget.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 02:25:47 +08:00
zyycnandClaude 2366f66f33 refactor(web): type the API layer end to end
Make request generic so the axios envelope is unwrapped as a typed value,
and thread the known response shapes through every api module and their
call sites so a wire rename surfaces as a compile error instead of silent
any. Type-only: no runtime or visual change.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:52:00 +08:00
zyycnandClaude cc0303c65e ci: drop the dead Dockerfile stage and sanitize the build-metadata image tag
Remove the release-asset build stages that nothing references. Strip the
+build semver metadata from the derived container tag, which is not a legal
tag character, while keeping the full version string where a tag is not
required.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:51 +08:00
zyycnandClaude b11eeac29e test: enforce the crate dependency DAG and widen websocket route coverage
Add a workspace-level architecture test that parses each backend crate's
manifest and asserts the layering rules the architecture doc describes —
gateway-core has no HTTP/DB/Redis/provider dependency, the providers do not
depend on each other, gateway-admin has no transport or storage crates —
and that no production src file hosts a test module. Extend the responses
websocket tests to cover the upgrade boundary: handshake routing, upgrade
precedence over authentication, and malformed-handshake rejection.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:51 +08:00
zyycnandClaude ad5f3ba997 fix(xai): tolerate unknown JWKS members and corrupt catalog cache; drop dead sync
Stop rejecting a JWKS document that carries members the struct does not
model, so a token still verifies when the IdP adds a field. Treat an
unparseable catalog cache entry as a cache miss that refetches instead of
a hard failure. Remove the unused GrokCredentialCatalogService::synchronize
cluster and its snapshot re-export.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:41 +08:00
zyycnandClaude 15455ca619 fix(openai): parse Retry-After dates, stop dropping raw frames, restrict base_url
Retry-After now accepts the HTTP-date form in addition to integer seconds.
When a raw SSE passthrough frame fails to construct, fall back to the
serialized form instead of silently dropping the event. Reject upstream
base_url values that are http or resolve to loopback/private ranges, so an
operator-supplied URL cannot point the data plane at an internal address.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:41 +08:00
zyycnandClaude 904315c209 fix(admin): clamp session ttl and de-duplicate the UTC+8 offset helper
An enormous session_ttl_minutes config value overflowed chrono when added
to the current time and panicked session creation; clamp it to a sane
maximum. Route both UTC+8 formatting call sites through a single helper.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:30 +08:00
zyycnandClaude 95d720dd86 fix(host): close self-restart and shutdown races, drop dead preview channel
Retry binding the listener on AddrInUse within a bounded window so a
self-restart handoff that races the old process releasing the port does
not exit the new process offline. Make restart() take the same operation
lock as update and rollback so a restart cannot interleave with an
in-flight update. Enable the idle notification before reading the active
count in wait_until_idle so the last connection dropping between the read
and the first poll cannot lose the wakeup and stall the full drain
timeout. Remove the unreachable preview update-channel configuration.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:30 +08:00
zyycnandClaude 74f59cc4f8 fix(core): correct attempt-observation reset, success status, and error precision
reset_uncommitted_observations left provider_processing_ms set when
discarding a failed attempt's observations, leaking one attempt's upstream
processing time into the next; clear it with the other per-attempt fields.
finish_success fabricated an upstream_status_code of 200 when none was
observed — persist the absent status as NULL instead. Give the "no active
attempt" internal state its own EngineError::NoActiveAttempt instead of
overloading EmptyRoutingPlan, and map it to an internal gateway error.
Remove the unused free function commit_requirement.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:51:19 +08:00
zyycnandClaude e979454457 test(xai): move release-profile tests into the integration test target
The workspace runs tests only through each crate's main integration
target (autotests = false), so the #[cfg(test)] module embedded in
transport/profile.rs never executed — the only such module left in
production src. Mirror the two release-refresh tests into
tests/transport/profile.rs and export the release service surface they
exercise.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:19:12 +08:00
zyycnandCodex 0af8610a48 chore: bump version to 3.0.11
Co-authored-by: Codex <noreply@openai.com>
2026-07-27 01:10:18 +08:00
zyycnandClaude 8226089ade fix(api): resolve the admin request id independently of the header name
The request-id layer injects under the configured api.request_id_header,
but the admin extractor read the hardcoded x-request-id name, so pointing
that setting at any other header made every authenticated admin request
fail with an internal error and took the admin interface down. Read the
name-agnostic RequestId extension the layer also populates, keeping the
header lookup as a fallback for callers that run without the layer.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:09:49 +08:00
zyycnandClaude 1bd40ffa67 docs: sync every document with the behavior at HEAD
Each document was verified claim by claim against the source. Corrections
that mattered: client API keys have no per-key model scoping; admin
requests never need to send x-request-id and the configurable header name
does not apply to admin authentication; GET /v1/models serves two distinct
response shapes depending on client_version; the crate dependency rules
are convention rather than machine-enforced across all crates; the HTTP
server and worker runtime live in gateway-host, not apps/gateway; the
OpenAI path forwards upstream bytes verbatim over both SSE and WebSocket
while xAI translates between the Grok and Responses wires. Document the
graceful-shutdown budget, the CORS contract, the migration freeze rule,
and the integration-test environment variables, and drop the stale login
throttling references.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 01:05:33 +08:00
zyycnandClaude 34a6ccce43 chore: untrack the internal audit report
The 2026-07-26 audit report is a local working document: it enumerates
unfixed findings and deployment details that do not belong in the
published tree.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 00:41:17 +08:00
zyycnandClaude c275119f12 fix(admin): remove login throttling to close the lockout DoS
The failure-count lockout keyed on request source was the wrong control
for this deployment: behind the mandated reverse proxy every login shares
the proxy peer address, so an unauthenticated attacker could trip the
shared bucket and lock the real administrator out for the window,
renewably. Remove the throttle end to end — the LoginError::Throttled
state, the LoginCommand source, the AuthStore/AdminAuthStateRepository
failure-window ports and their Redis Lua script, and the login-source
middleware — and let a wrong password return InvalidCredentials directly.
Online brute-force is bounded by the Argon2 verification cost and admin
password strength; deployments wanting rate limiting apply it at the
reverse proxy, which sees real client IPs.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 00:31:10 +08:00
zyycnandClaude 9322b7aeb8 fix(api,store): aggregate IPv6 login buckets by /64 and honor CI truthiness
Review follow-ups. The login throttle keyed on the full IPv6 /128 address,
so a direct-connect attacker with a routed /64 got an independent
five-guess bucket per address; bucket IPv6 sources by their /64 prefix so
the whole allocation shares one quota. IPv4 keying is unchanged, and a
router-level test pins both the aggregation and cross-prefix isolation.

The store test_env guard treated any present CI variable as CI, so a
developer running CI=false to signal not-CI turned skips into panics; parse
CI per the is-ci convention (present and not 0/false/empty).

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 00:17:02 +08:00
zyycnandClaude c6444b2bf1 ci: assert the migration freeze manifest is append-only vs the PR base
Follow-up to 720eb0c2: the manifest was self-attested — editing a released
migration and regenerating its sha256 line in the same PR passed the
checksum and membership checks, and the next release then bricked every
deployment on the startup checksum mismatch the guard exists to prevent.
On pull requests, fetch the base commit and require every line in the base
manifest to survive verbatim, so a changed hash (edit) or a dropped line
(delete) fails CI; genuinely new migrations only add lines and still pass.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 00:12:44 +08:00
zyycnandClaude 55075677eb fix(xai): log every skipped import entry so silent credential loss is traceable
Follow-up to 97b91d34: on partial import success the first_failure was
discarded and skipped entries left no trace, so an entry whose refresh
token was already rotated inside verify (transient userinfo failure, local
rejection, or duplicate-subject skip) was destroyed upstream while the
response only reported the success count — the operator had no signal that
a credential needed re-authorization. Emit a warn for each skipped entry
naming the account and whether its token was already rotated.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 00:11:30 +08:00
zyycnandClaude 733aa5f88a fix(host): share one absolute drain deadline across both shutdown stages
Review follow-up to d6b9fedb: after axum's graceful shutdown resolved, the
tail wait_until_idle re-armed a fresh full drain_timeout window, so a
stalled SSE client plus a detached WebSocket guard stretched the drain to
2x the configured bound and pushed worker shutdown past an operator-sized
stop grace period. Record the deadline when the cancellation signal fires
and give the tail wait only the remaining time.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 00:08:58 +08:00
zyycnandClaude d704910857 fix(core,store): make the persisted send_state watermark hold across retries
Review follow-ups to c953f6f4. begin_model_request_attempt reset
upstream_send_state to not_sent on every retry, so the watermark only held
in session memory: a crash between the reset and the next mark left a
running row that expired-request recovery finalized as not_sent even
though the first attempt demonstrably reached the upstream. Stop resetting
the column — it is the request-level monotonic watermark, raised only by
mark/finalize — and pin that with a store test.

In handle_stream_error, the escalated watermark also leaked into the retry
gates, letting an Ambiguous second attempt inherit Sent from the first and
pass the != Ambiguous check for idempotent operations (latent today, both
providers reject idempotent operations before send). Gate retries on the
attempt's own send state and keep the watermark for persistence and
terminal writes only.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 00:08:11 +08:00
zyycnandClaude ad6c21392f style: apply rustfmt to host shutdown changes
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 23:43:27 +08:00
zyycnandClaude 55e87c3b16 docs: phrase the clean-break warning per major, not 2.x/3.x
The release body ships on every future release, so a hardcoded "from
2.x" would be stale by 4.0. State the invariant instead: schema and
configuration are compatible within a major only, and self-update refuses
cross-major targets from 3.0 on.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 23:43:14 +08:00
zyycnandClaude 0c4da6c838 docs: warn 2.x deployments that 3.x is a clean break
The runtime cross-major gate only exists in 3.x binaries; a 2.x admin UI
happily offers the one-click update that produces a non-starting service.
State the clean break in the three places a 2.x operator actually sees
before upgrading: the top of every 3.x GitHub Release body, the README
operations section, and the deploy README upgrade section, each with the
fresh-deployment path (new .runtime/, re-import or re-authorize accounts
and client keys).

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 23:41:50 +08:00
zyycnandClaude 225c1166c6 test(store): fail closed in CI when integration databases are missing
The PostgreSQL and Redis integration tests returned early when
CPR_TEST_DATABASE_URL / CPR_TEST_REDIS_URL were unset, so a broken service
container or renamed variable in CI turned 96 real tests into silent green
no-ops. Route every environment read through a shared helper that panics
when CI is set and the variable is missing, while keeping the local
skip-when-unconfigured behavior. Local setup instructions live in
backend/migrations/README.md.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 23:40:14 +08:00
zyycnandClaude 97b91d3409 fix(xai): stop batch import from discarding rotated refresh tokens
prepare_import exchanged refresh tokens entry by entry but aborted the
whole batch on any later failure, dropping every already-rotated token:
the source file's old tokens were consumed upstream, so a re-import hit
invalid_grant and the credentials were unrecoverable. A document containing
the same account twice triggered this deterministically via the duplicate-
subject rejection.

Deduplicate identical refresh tokens at document parse (side-effect-free,
first entry wins) so duplicated entries can no longer invalidate each
other's exchange. In prepare_import, make entries independent: a failed
entry is skipped and the first failure is returned only when no entry
succeeded; a second grant for an already-imported subject is skipped
instead of failing the batch.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 23:38:05 +08:00
zyycnandClaude c953f6f44d fix(core): keep request send_state monotonic across attempts
Retrying takes the current attempt out of the session, so a later attempt
that fails before stream creation (for example NoEligibleAccount on the
second selection) finalized with current_send_state() == NotSent and
overwrote the sent marker the first attempt had already persisted,
producing rows claiming not_sent alongside an upstream 429. Track a
request-level send-state watermark (Sent > Ambiguous > NotSent) raised at
every point send state is observed or persisted, and route all failure
finalizations through it so a terminal write never downgrades the
persisted state. The account-exhaustion regression test now pins the
finalized send_state to Sent.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 23:33:03 +08:00
zyycnandClaude d6b9fedbc7 fix(host): bound graceful shutdown and stop rejoining completed workers
WorkerSupervisor::shutdown re-awaited every handle after a timeout,
including those already joined inside the timeout block, which panics with
'JoinHandle polled after completion' whenever a cooperative worker exits
before an uncooperative one under a shared timeout. Join each handle at
most once against a shared absolute deadline and abort only the handles
that miss it. The existing hung-task test never tripped this because the
hung worker happened to sort first; add a regression test that pins the
hung worker at the end of the join order.

serve_router relied on axum's graceful shutdown, which waits for in-flight
connections without bound while SSE bodies never observe cancellation, so
one stalled client kept the process alive until SIGKILL. Race the server
against a drain deadline that starts at the cancellation signal; past the
deadline the remaining connections are abandoned and die with process
exit.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 23:27:21 +08:00
zyycnandClaude 720eb0c227 ci: freeze applied migrations behind a sha256 manifest
sqlx re-validates applied-migration checksums on every startup, so any byte
change to a released migration file locks existing deployments out of
starting with no automatic recovery. Record the released migrations in
backend/migrations/.frozen-sha256 and fail CI when a frozen file changes or
a new migration lands without being enrolled. Document the freeze rule and
the local test-database environment in backend/migrations/README.md. The
migration files themselves stay byte-identical: adding even a header
comment would trip the same startup validation this guard protects.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 23:20:38 +08:00
zyycnandClaude 495c19b5e4 fix(api): bucket admin login throttling by client ip
The AdminLoginSource extension was never injected, so every login request
fell back to the shared "unknown" failure bucket: five wrong passwords from
any unauthenticated visitor locked the real administrator out for fifteen
minutes, renewable indefinitely. Attach a login-route middleware that keys
the bucket on the socket peer IP taken from ConnectInfo; forwarded headers
are client-forgeable and stay excluded, so reverse-proxy deployments retain
the previous shared-bucket behavior. Add a router-level regression test
proving throttling of one source leaves other sources unaffected.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 23:18:26 +08:00
zyycnandClaude Opus 5 237ba96233 feat(core,store): record account probe failures as ops events
Connection tests run through a transient execution store, so a failed probe
left no trace at all: nothing in model_requests, nothing in ops_events, and
nothing in the log at info level. The reason a probe failed — upstream status,
provider error code, send state — was only reachable by reproducing it.

Add a ProbeFailure port on ExecutionStore, defaulting to a no-op so existing
implementations are unaffected, and have the execution service report probe
failures to the persistent store while the probe itself keeps using the
transient one. The Postgres store logs a warning and appends an ops event with
no model_request_id.

Such events already fit the surrounding contracts: the ops error list left
joins model_requests, health metrics inner join it so probes stay out of
business failure rates, and retention already targets rows with a null
model_request_id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:44:25 +08:00
zyycnandClaude ae46d89de6 refactor(web): consolidate the font-weight scale to four semantic tiers
Replace the nine ad-hoc arbitrary font weights (620-820, 208 usages) with a four-tier scale: font-emphasis (650) and font-heavy (760) as new theme tokens, plus the standard font-bold (700) and font-extrabold (800). Every merge moves a weight by at most 30 units, below the perceptible threshold for UI text sizes. The SystemUpdateModal release-notes scoped styles reference the same tokens. Arbitrary font-[N] classes are now zero across the codebase; the scale is documented in the theme block.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 22:21:36 +08:00
zyycnandClaude Opus 5 69f73e8c5d fix(xai,web): distinguish account cooldown from upstream failure
Selection-stage failures all collapsed into a generic "upstream service is
unavailable", so a 30s runtime cooldown was indistinguishable from a real
upstream outage. The connection test pins a single account and has no
fallback, making every retry inside the window look like a dead upstream.

Split the runtime-cooldown case out of NoEligibleSession into a new
AccountCoolingDown variant carrying the remaining wait, and attach a
structured code and error type to each selection failure. The admin
connection test now renders the reason and the remaining cooldown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:03:42 +08:00
zyycnandClaude 245f87ef35 refactor(web): share dashboard tone maps and retire dead surfaces
Hoist the tone-to-class maps duplicated between MetricCard and AccountOverviewCard into dashboard constants, move the generic ECharts tooltip parameter helpers to components/charts/tooltip.ts shared by the dashboard trend chart and usage charts, derive the health popover's segment and metric lists from one outcome metadata source, and rebuild useOpsErrorsTable on the shared usePagedQuery composable (same stale-response guard and empty-page clamp, first-paint loading preserved). Remove the dead trend-loading prop chain, the unused showAvatar prop, and two unused exports. Built CSS unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 21:57:25 +08:00
zyycnandClaude 81e51db0b6 refactor(web): align accounts and api-keys view organization
Extract AccountFilters to mirror the ApiKeyFilters/UsageFilters toolbar convention, move the CCSwitch import and service-URL orchestration out of api-keys/index.vue into a useApiKeyUse composable, adopt useAsyncAction for the admin API key mutations and useIdSet for the connection-test id tracking (both already the sibling-view convention), and drop the unused store barrel re-exports. Markup moved verbatim; built CSS unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 21:49:04 +08:00
zyycnandClaude 4634496638 refactor(web): consolidate usage view records rendering
Move the ~250 lines of record-derivation functions out of usage/constants.ts into usage/utils/records.ts alongside the existing format/chart utils, leaving constants.ts with the column and option tables. Extract UsageRecordsTable as the single definition of the ten record cell templates that were duplicated byte-for-byte between the usage view and the dashboard card, UsageDetailPopover for the info-dot hover shell repeated in the latency/token/billing cells, and UsageDetailFieldGrid plus UsageDetailCodePanel for the field-grid and code-panel blocks repeated inside the record detail modal. Markup moved verbatim; the built CSS is byte-identical to the baseline.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 21:43:06 +08:00
zyycnandClaude 4e2f0a9346 refactor(web): dedupe shared script-level helpers
Add providerDisplayName as the single provider label mapping (was five hand-written if-chains with per-site fallbacks preserved), useThemeColor for theme-reactive CSS variable reads (three verbatim copies), useCopyText for clipboard-with-toast flows (three variants encoded as options), and usageLegend for the ECharts legend literal repeated across three usage chart cards. Drop the four pageSizes arrays that restated BaseTable's default. Script-only changes; the built CSS is byte-identical to the pre-refactor baseline.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 21:32:05 +08:00
zyycnandClaude 304b3d4772 fix(store,openai): bound dashboard concurrency and restore quota state re-read
Split the dashboard summary into two three-way query batches so one render holds at most three pool connections and cannot starve data-plane writes under the five-second acquire timeout. Restore the fresh account read before the passive-quota availability write-back: the revision fence only covers credential rotation, so deciding transitions from the selection-time snapshot could overwrite availability changes made by other paths and delay exhausted-account recovery. Drop the redundant admin_audit_events retention index (the initial created_at index already serves the delete predicate) and document the rolling-deploy indexing procedure in the migration.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 21:05:00 +08:00
zyycnandClaude 74629f3ef2 perf(openai,xai): trim passive quota round trips and stream chunk copies
Drop the account re-read from passive rate-limit quota sync: the quota CAS is fenced on the account revision and the follow-up state write carries the same fence, so the availability transition can use the selection-time snapshot and converge on the next response. Stream Grok inference chunks as Bytes end to end instead of copying every chunk into a fresh Vec.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 20:35:22 +08:00
zyycnandClaude c9d48188af perf(store): index retention deletes, parallelize dashboard, expose pool budget
Add partial indexes for the three retention delete predicates and delete in bounded ctid batches with per-batch commits, replacing the hourly full-table scan and unbounded single-transaction delete. Run the six dashboard-summary queries concurrently so page latency tracks the slowest query instead of their sum. Make the PostgreSQL pool budget configurable via an optional store.pool section (max_connections, acquire_timeout_seconds) with a 5-second acquire timeout default so pool exhaustion fails fast instead of queueing for 30 seconds.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 20:28:52 +08:00
zyycnandClaude 7e7b88a59e perf(core,store): merge first-attempt persistence and reuse hot-path state
Combine the model-request insert with the first attempt into one ExecutionStore call (default implementation keeps the sequential behavior; the Postgres store overrides it with a single INSERT), removing one database round trip from the pre-first-token critical path. Reuse a session-scoped deadline timer in the event pull loop instead of registering a new global timer per event, and attach continuation session state in place so a uniquely held payload is no longer force-deep-copied by clone-before-make_mut.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 20:20:15 +08:00
zyycnandClaude 5c66dc198d perf(protocol,api): incremental SSE scanning and map-based frame decode
Track a scanned-prefix cursor in SseEventDecoder so a large frame arriving across many chunks is scanned incrementally instead of rescanned from the start on every push (separator matching backs off three bytes to catch CRLF pairs split across chunks). Decode WebSocket response.create frames through a Map-accepting request-decode core, dropping the serialize-and-reparse round trip on the request start path.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 20:09:57 +08:00
zyycnandClaude 47efce7a90 perf(openai): cut websocket hot-path parsing and copies
Parse each WebSocket frame once in the exchange reducer and reuse the parsed JSON across rate-limit, metadata, turn-state, and forwarding extraction (previously up to eight full parses per frame). Serialize the response.create frame by borrowing the request body instead of deep-copying it (byte-identical output, including a client-supplied type key), skip audit artifact construction entirely when no audit directory is configured, and take pending turn-state updates instead of cloning them on every chunk.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 20:05:55 +08:00
zyycnandClaude ea4d3f0ba7 fix(xai): tolerate unknown reasoning effort values in the catalog
Add a serde(other) variant to GrokCatalogReasoningEffort and drop unrecognized efforts from the capability projection, mirroring the api_backend handling: a novel effort value in one model previously failed the whole snapshot parse and collapsed /v1/models to the default model once the cache expired.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 19:57:13 +08:00
zyycnandClaude 2d03e27dcf fix(store): count all request outcomes in health metrics
Scope the completed-usage-fact predicate to per-aggregate FILTER clauses in request_metrics and request_metric_series instead of the whole query: the global filter pinned outcome='succeeded', so failure, cancelled, incomplete, and caller-error counts were structurally zero and the dashboard always reported a 100% success rate. Outcome counts now cover every request in range while token, cache, latency, and cost-coverage aggregates remain restricted to committed usage facts.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 19:57:13 +08:00
zyycnandClaude cf7ff80f71 fix(host): gate self-update to same-major releases
Cross-major releases are withheld from has_update with an explanatory warning, and perform_update rejects a cross-major target before fetching the release: config and migration contracts are only compatible within a major, so a one-click cross-major update previously produced a binary that fails to start. Same-major updates keep the existing flow.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 19:57:13 +08:00
zyycnandClaude 93ab75e42b fix(api): stop CORS assembly from panicking on configured origins
Replace the wildcard allow_headers with an explicit whitelist (authorization, content-type, x-api-key, and the configured request-id header): tower-http asserts against wildcard headers combined with allow_credentials, so any non-empty cors_allowed_origins previously panicked inside initialize() before the server could listen. Add a preflight assembly test covering a configured origin.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 19:57:13 +08:00
zyycnandClaude 449dc7da79 fix(xai): forward structured stream-error details to the client
Surface the upstream error message, code, and type from response.failed/error stream events through the client-visible error pipeline, matching the reference translation baseline and restoring fatal-vs-retryable classification and retry pacing for downstream Codex clients. Messages are scrubbed of account fingerprints (UUIDs) and control characters before exposure; unstructured failures keep the sanitized generic error, and non-2xx HTTP transport errors keep their existing rewrap semantics.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 19:30:19 +08:00
zyycnandClaude 3b129a483a style: describe behavior in comments instead of narrating changes
Rewrite the refresh, token-classification, passthrough, and stream-tolerance comments to state the behavior and constraints they guard, dropping change narration and reviewer-facing baseline references. Also apply rustfmt reflow in the openai test files.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 19:07:22 +08:00
zyycnandClaude 3e0d60bb1f fix(xai): back off credential refresh failures exponentially
Replace the fixed 10-minute refresh retry with the shared Redis-backed exponential backoff (5s x3, capped at 5m): transient and rejected refresh failures record a per-account attempt count inside a 30-minute window and defer the next attempt accordingly, and a successful rotation resets the counter. Aligns the xAI refresh path with the openai provider; invalid_grant/banned stay terminal and ambiguous failures keep their 24-hour cooldown.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 19:07:15 +08:00
zyycnandClaude 1d3645fc26 fix(openai): forward websocket bytes verbatim and back off refresh failures
Enable raw SSE passthrough on the WebSocket upstream so client bytes are never re-serialized, preserving numeric precision on the transparent /v1/responses path.

Stop invalidating accounts on transient credential-refresh failures: classify 5xx/429 by status, defer identity-unavailable instead of terminating, and retry with a Redis-backed exponential backoff (5s x3, capped at 5m) that resets on success. Only invalid_grant/banned/rejected remain terminal, restoring the pre-refactor and official-client behavior.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 18:18:23 +08:00
zyycnandClaude 18bff86214 fix(xai): keep the response stream alive on recoverable event errors
Tolerate field-validation failures on non-terminal content events (empty deltas, duplicate indices, missing fields) and skipped transforms by forwarding the wire event without canonical extraction, matching the reference translation baseline. Terminal completed/incomplete events keep failing closed so usage and cost validation are preserved; genuine upstream errors still propagate.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 18:18:14 +08:00
zyycnandClaude 114084107c refactor: remove dead code across backend crates
Drop production APIs with no callers: the model-request history module, dead ClientApiKey/ProviderAccount repository methods, the CollectedResponse IR, canonical_facts_mut, the UsageExtraction error variant, unused xAI credential-refresh helpers, and the unused uuid/zeroize/chrono dependencies.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 18:18:08 +08:00
zyycnandCodex d60f7238da fix: negotiate model discovery and login transition
Return the Codex catalog only for client_version requests while preserving the standard OpenAI-compatible model list for other clients. Keep the login action pending until successful route loading completes.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 15:17:59 +08:00
zyycnandCodex ea684dceeb chore: bump version to 3.0.10
Co-authored-by: Codex <noreply@openai.com>
2026-07-26 06:17:28 +08:00
zyycnandCodex dfd496b41a fix(ui): restore standard Codex API key setup
Remove command-backed provider auth from the generated configuration and use auth.json again.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 06:17:21 +08:00
zyycnandCodex 6125b10276 chore: bump version to 3.0.9
Co-authored-by: Codex <noreply@openai.com>
2026-07-26 06:14:26 +08:00
zyycnandCodex 071271f003 fix(models): restore OpenAI-compatible discovery
Keep Codex model metadata while preserving the standard list response consumed by provider managers.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 06:14:19 +08:00
zyycnandCodex 13a9df2e07 chore: bump version to 3.0.8
Co-authored-by: Codex <noreply@openai.com>
2026-07-26 05:32:36 +08:00
zyycnandCodex 37a5a6702b fix(xai): restore Codex model capabilities
Read Grok reasoning effort menus from the official catalog, expose xhigh to Codex, and generate command-authenticated client configuration that refreshes remote model metadata without advertising unsupported priority service.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 05:32:15 +08:00
zyycnandCodex afb68e97d6 chore: bump version to 3.0.7
Co-authored-by: Codex <noreply@openai.com>
2026-07-26 04:55:44 +08:00
zyycnandCodex 93b9741af2 fix(update): preserve streamed log history
Include each update event ID in the SSE JSON payload so the frontend appends distinct progress rows instead of replacing one undefined-ID row.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 04:55:29 +08:00
zyycnandCodex 013d592454 chore: bump version to 3.0.6
Co-authored-by: Codex <noreply@openai.com>
2026-07-26 04:35:54 +08:00
zyycnandCodex 0f56e9b80c feat(ui): replace product branding icon
Use the new product mark across the favicon, login panel, and sidebar with a consistent dark brand background and cache-busted browser icon.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 04:34:57 +08:00
zyycnandCodex 6ec5ecb9ac fix(ui): preserve numeric input model values
Keep number inputs on the string model contract so settings edits reach form state and persist after refresh.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 04:34:28 +08:00
zyycnandCodex 460d439351 fix(ui): stabilize button loading spinner
Co-authored-by: Codex <noreply@openai.com>
2026-07-26 03:50:16 +08:00
zyycnandCodex f39406dd80 chore: bump version to 3.0.5
Co-authored-by: Codex <noreply@openai.com>
2026-07-26 03:25:35 +08:00
zyycnandCodex 9de8e75238 fix(ui): refine account and usage empty states
Leave absent account timestamps blank, show zero latency for an empty usage summary, and reduce the expanded sidebar width by five pixels.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 03:24:25 +08:00
zyycnandCodex fee34b6bd1 feat(xai): expose CLI release check status
Record successful and failed official npm checks, retain the last successful version on failure, and publish the checked timestamp through the dashboard wire profile.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 03:24:04 +08:00
zyycnandCodex cbe8699322 perf(ui): optimize table scrollbar updates
Coalesce native scroll events per animation frame, cache scrollbar geometry, and synchronize sticky headers only when horizontal position changes.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 03:23:41 +08:00
zyycnandCodex 066e5551f8 perf(xai): optimize account-isolated upstream connections
Keep per-account HTTP/2 pools warm, build cold clients outside the shared cache lock, and record cache, DNS, and response-header timings for successful and failed requests.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 02:22:03 +08:00
zyycnandCodex 1d73b736a9 fix(update): restore complete streaming progress
Restore the pre-refactor update stage events and human-readable download progress, preserve progressPercent through the admin SSE wire, and wait for the stream subscription before starting replacement.

Co-authored-by: Codex <noreply@openai.com>
2026-07-26 00:17:15 +08:00
zyycnandCodex 8ae6635b9c chore: bump version to 3.0.4
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 23:40:22 +08:00
zyycnandCodex 574f0d580e fix(xai): restore Codex compatibility and runtime semantics
Preserve transient credential failures as runtime cooldowns, restore account-aware retry and observability facts, and align Grok model/tool adaptation with Codex clients.

Keep complete SSE events on the live path while retaining only protocol-required tool and continuation state.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 23:40:15 +08:00
zyycnandCodex 0879515ee8 chore: bump version to 3.0.3
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 16:59:19 +08:00
zyycnandCodex be40783454 fix(ui): restore dashboard and mobile interactions
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 16:58:08 +08:00
zyycnandCodex 946b9d005d chore: bump version to 3.0.2
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:58:47 +08:00
zyycn f181d54ac3 feat(accounts): support mixed CPR batch import
按导出文档中的 provider 分流批量导入 OpenAI 与 xAI 账号。\n\nCo-authored-by: Codex <noreply@openai.com>
2026-07-25 03:56:57 +08:00
zyycnandCodex d1a875c19a fix(update): allow GitHub release asset redirects
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:43:19 +08:00
zyycnandCodex b9335ea89f chore: bump version to 3.0.1
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:26:38 +08:00
zyycnandCodex 90ce543295 docs: refresh v3 provider and deployment guides
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:26:18 +08:00
zyycnandCodex aa3e2ddc8f fix(openai): align context and catalog tolerance
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:26:09 +08:00
zyycnandCodex ef57b88066 fix(openai): preserve Agent Identity CPR round trips
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:26:00 +08:00
zyycnandCodex 997e4ed296 fix(ci): configure Redis test auth in service container
Use the Redis client shipped with the service image so the quality workflow does not depend on a runner package.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 02:00:34 +08:00
zyycnandCodex a6bac4601e chore(release): prepare v3.0.0
Refresh verified dependencies, harden CI test credentials, and align release builders with the current supported image.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 01:54:18 +08:00
zyycnandCodex 8eea10d509 feat(catalog): share provider model catalogs by plan
Serve connection-test model lists from a provider-plan Redis cache, refresh manually through bounded same-plan candidates, and keep periodic catalog refreshes isolated from OpenAI ETag handling.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:12 +08:00
zyycnandCodex a9f9125caf fix(gateway): restore account usage and request observability
Restore provider-native quota and reasoning observations, complete request lifecycle accounting, and align account and usage surfaces with the restored semantics.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:12 +08:00
zyycnandCodex 5364508219 refactor(gateway): restore provider-owned protocol boundaries
Keep OpenAI Responses bodies and wire events transparent, remove the generic typed compaction and response reconstruction path, and localize Grok compaction adaptation to the xAI provider.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:12 +08:00
zyycnandCodex 98250268b0 fix(gateway): restore legacy session and provider semantics
Recover OpenAI continuation affinity, session-level account exclusion, catalog/configuration behavior, and provider-owned runtime defaults without changing the transparent OpenAI wire path.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:12 +08:00
zyycnandCodex bcf57351d6 fix(gateway): restore upstream response transparency
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:12 +08:00
zyycnandCodex 965900574a fix(gateway): restore transparent request semantics
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:12 +08:00
zyycnandCodex 46a9d527e1 refactor(gateway): finalize provider-level account architecture
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:12 +08:00
zyycnandCodex 70f8afb122 refactor(gateway): remove provider instance layer
Collapse routing, health, circuit state, account pools, and admin APIs onto the fixed OpenAI and xAI Provider boundary while keeping credential runtime state account-scoped.

Remove configurable provider-instance and routing-catalog persistence, use fixed official endpoints in provider adapters, align account routes and frontend contracts, and mirror provider tests under their source modules.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:12 +08:00
zyycnandCodex acaf98512c fix(gateway): restore account mutation semantics
Allow explicitly selected provider accounts to be deleted regardless of scheduling state, matching the pre-refactor behavior.

Upsert verified credential imports by upstream identity, preserve existing account IDs, and return the committed IDs to admin callers.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:11 +08:00
zyycnandCodex 7cb898b40a refactor(gateway): finalize production API audit
Remove unused full-response transports and unconnected domain reservations while keeping test-only setup inside integration tests.

Restore image-generation accounting and OpenAI WebSocket pool observations across the engine, store, and admin boundaries without changing the transparent OpenAI WS/SSE path.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:11 +08:00
zyycnandCodex 8ddafb9af5 refactor(gateway): remove test-only production APIs
Move protocol and provider fixtures and observations into integration tests while exercising the production decoder, encoder, transport, DNS, and lease paths.

Remove direct credential seeding and routing test setters while preserving OpenAI transparent proxy and WebSocket/SSE fallback behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:11 +08:00
zyycnandCodex e31380a0c7 fix(gateway): preserve OpenAI wire transparency
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:11 +08:00
zyycnandCodex be60e2d763 fix(gateway): scope conversation compaction to xai
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:11 +08:00
zyycnandCodex 1f3bcf68c7 feat(gateway): add provider-native conversation compaction
Add typed compaction operations across the gateway, OpenAI, and xAI providers with provider-owned request, response, accounting, and observability contracts.

Align xAI custom apply_patch calls with the patch function argument and re-encode restored custom calls during provider replay.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:11 +08:00
ZyyandCodex 057c95b0ea fix(gateway): finalize provider runtime behavior
Restore provider-owned identity profiles, credential refresh, cache routing, model catalogs, and runtime configuration across OpenAI and xAI without compatibility paths.

Keep deployment configuration, frontend visibility, and architecture documentation aligned with the finalized multi-provider boundary.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:11 +08:00
zyycnandCodex 198368363e fix(gateway): restore provider request semantics
Restore Provider-owned connection probes, xAI Grok Build request translation, continuation state, credential recovery, SSE metadata, and transport diagnostics without compatibility paths.

Keep upstream identity presentation and startup output aligned with the final package boundaries.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:11 +08:00
zyycnandCodex a934ddaa54 refactor(backend): finalize gateway package boundaries
Move control-plane use cases, host lifecycle, provider capabilities, execution orchestration, and worker contracts into their final owners while keeping the application bootstrap as a thin composition root.

Restore verified OpenAI transport, continuation, pooling, fingerprint, accounting, and observability semantics, and retain the validated xAI multi-account integration.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:11 +08:00
zyycnandCodex 75773a4608 refactor: migrate to multi-provider gateway architecture
Rebuild the backend as the frozen gateway workspace with a single PostgreSQL schema, provider-owned OpenAI and xAI boundaries, and mirrored domain tests.

Restore the admin product contracts for accounts, model catalogs, usage observability, billing details, and provider-scoped settings without compatibility shims.

Co-authored-by: Codex <noreply@openai.com>
2026-07-25 00:33:11 +08:00
924 changed files with 150834 additions and 87070 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
} >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
@@ -76,7 +76,7 @@ jobs:
${{ hashFiles('trivy-results.sarif') != '' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
sarif_file: trivy-results.sarif
category: runtime-container
+61 -4
View File
@@ -61,9 +61,44 @@ jobs:
--health-retries 10
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
# PR 场景需要 base 提交做冻结清单的 append-only 比对。
fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }}
# 已应用迁移的字节被 sqlx 启动校验钉死:改动已发布迁移会让所有
# 存量部署重启即失败。冻结清单保证旧文件不被改、新文件必须入册。
- name: Migration freeze check
working-directory: backend/migrations
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
sha256sum --check --strict .frozen-sha256
for file in *.sql; do
if ! awk '{print $2}' .frozen-sha256 | grep -Fxq "$file"; then
echo "::error file=backend/migrations/$file::migration $file is not frozen; append its sha256 to backend/migrations/.frozen-sha256"
exit 1
fi
done
# 清单自证不足以防「改迁移 + 同 PR 刷新清单行」:校验对新哈希通过。
# 对 PR,断言 .frozen-sha256 相对 base 只增不改——base 里的每一行
# 必须原样保留(改哈希=删行,删迁移=删行,都会被挡)。
if [ -n "${BASE_SHA:-}" ]; then
base_manifest="$(git show "${BASE_SHA}:backend/migrations/.frozen-sha256" 2>/dev/null || true)"
if [ -n "$base_manifest" ]; then
while IFS= read -r line; do
[ -z "$line" ] && continue
if ! grep -Fxq "$line" .frozen-sha256; then
file="$(printf '%s' "$line" | awk '{print $2}')"
echo "::error file=backend/migrations/.frozen-sha256::frozen migration $file was modified or removed; released migrations are append-only — add a new numbered migration instead"
exit 1
fi
done <<< "$base_manifest"
fi
fi
- name: Setup Rust
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
@@ -81,7 +116,29 @@ jobs:
- name: Rust clippy
working-directory: backend
run: cargo clippy --all-targets --all-features --locked
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Configure PostgreSQL and Redis test authentication
shell: bash
run: |
set -euo pipefail
postgres_password="$(openssl rand -hex 24)"
redis_password="$(openssl rand -hex 24)"
echo "::add-mask::$postgres_password"
echo "::add-mask::$redis_password"
docker exec --env PGPASSWORD=codex_proxy "${{ job.services.postgres.id }}" psql \
--username=codex_proxy --dbname=codex_proxy \
--set=ON_ERROR_STOP=1 \
--command="ALTER USER codex_proxy WITH PASSWORD '${postgres_password}'" >/dev/null
docker exec --env PGPASSWORD="$postgres_password" "${{ job.services.postgres.id }}" psql \
--username=codex_proxy --dbname=codex_proxy \
--set=ON_ERROR_STOP=1 --command='SELECT 1' >/dev/null
docker exec "${{ job.services.redis.id }}" redis-cli CONFIG SET requirepass "$redis_password" >/dev/null
docker exec "${{ job.services.redis.id }}" redis-cli --no-auth-warning -a "$redis_password" ping >/dev/null
{
echo "CPR_TEST_DATABASE_URL=postgres://codex_proxy:${postgres_password}@127.0.0.1:5432/codex_proxy"
echo "CPR_TEST_REDIS_URL=redis://:${redis_password}@127.0.0.1:6379"
} >> "$GITHUB_ENV"
- name: Rust tests
working-directory: backend
@@ -95,7 +152,7 @@ jobs:
CI: 'true'
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
@@ -134,7 +191,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
container: ${{ steps.filter.outputs.container }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
+26 -27
View File
@@ -29,6 +29,7 @@ jobs:
outputs:
tag: ${{ steps.version.outputs.tag }}
version: ${{ steps.version.outputs.version }}
docker_version: ${{ steps.version.outputs.docker_version }}
build_time: ${{ steps.version.outputs.build_time }}
git_sha: ${{ steps.version.outputs.git_sha }}
platform_matrix: ${{ steps.version.outputs.platform_matrix }}
@@ -40,7 +41,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
fetch-depth: 0
@@ -104,7 +105,7 @@ jobs:
require_string(platform, key, "#{platforms_path}: platforms[#{index}]")
end
archive = platform["archive"]
fail("#{platforms_path}: platforms[#{index}].archive must be tar.gz or zip") unless ["tar.gz", "zip"].include?(archive)
fail("#{platforms_path}: platforms[#{index}].archive must be tar.gz") unless archive == "tar.gz"
platform_key = "#{platform["os"]}-#{platform["arch"]}"
fail("#{platforms_path}: duplicate platform #{platform_key}") if seen_platforms[platform_key]
seen_platforms[platform_key] = true
@@ -146,6 +147,8 @@ jobs:
puts "tag=#{release_tag}"
puts "version=#{version}"
# Docker tags forbid "+", so semver build metadata maps to "-" in image tags.
puts "docker_version=#{version.tr("+", "-")}"
puts "build_time=#{Time.now.utc.iso8601}"
puts "git_sha=#{git_sha.strip}"
puts "platform_matrix=#{matrix}"
@@ -174,7 +177,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
@@ -205,7 +208,7 @@ jobs:
-v "$HOME/.cargo:/cargo" \
-v "$PWD/..:/workspace" \
-w /workspace/backend \
rust:1.97-bookworm@sha256:7d0723df719e7f213b69dc7c8c595985c3f4b060cfbee4f7bc0e347a86fe3b6a \
rust:1.97-bookworm@sha256:77fac8b98f9f46062bb680b6d25d5bcaabfc400143952ebc572e924bcbedc3fa \
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
working-directory: backend
@@ -234,7 +237,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
@@ -286,7 +289,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
@@ -321,7 +324,6 @@ jobs:
import pathlib
import shutil
import tarfile
import zipfile
APP_NAME = "codex-proxy-rs"
@@ -357,8 +359,7 @@ jobs:
binary_dest = package_root / platform["binary"]
shutil.copy2(binary_src, binary_dest)
if platform["os"] != "windows":
binary_dest.chmod(0o755)
binary_dest.chmod(0o755)
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
@@ -366,11 +367,6 @@ jobs:
if platform["archive"] == "tar.gz":
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(package_root, arcname=APP_NAME)
elif platform["archive"] == "zip":
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(package_root.rglob("*")):
if path.is_file():
archive.write(path, path.relative_to(package_dir))
else:
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
archives.append(archive_path)
@@ -382,7 +378,7 @@ jobs:
PY
- name: Generate release asset provenance
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: dist/release/*
@@ -412,7 +408,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
@@ -458,7 +454,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -512,7 +508,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
@@ -529,7 +525,7 @@ jobs:
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Login to GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -539,7 +535,7 @@ jobs:
id: manifest
shell: bash
env:
VERSION: ${{ needs.release-info.outputs.version }}
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
DOCKER_PLATFORMS: ${{ needs.release-info.outputs.docker_platforms }}
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
@@ -562,7 +558,7 @@ jobs:
fi
target_args=(
-t "${image}:${VERSION}"
-t "${image}:${DOCKER_VERSION}"
-t "${image}:sha-${GIT_SHA}"
)
if [ "$PRERELEASE" != true ]; then
@@ -570,7 +566,7 @@ jobs:
fi
docker buildx imagetools create "${target_args[@]}" "${sources[@]}"
inspect_output="$(docker buildx imagetools inspect "${image}:${VERSION}")"
inspect_output="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}")"
platform_list="$(awk '$1 == "Platform:" { print $2 }' <<< "${inspect_output}")"
for platform in "${platforms[@]}"; do
if ! grep -Eq "^${platform}(/|$)" <<< "${platform_list}"; then
@@ -580,7 +576,7 @@ jobs:
fi
done
digest="$(docker buildx imagetools inspect "${image}:${VERSION}" --format '{{ .Manifest.Digest }}')"
digest="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}" --format '{{ .Manifest.Digest }}')"
if [ -z "${digest}" ]; then
echo "missing image digest"
exit 1
@@ -588,7 +584,7 @@ jobs:
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
- name: Generate container provenance attestation
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.manifest.outputs.digest }}
@@ -598,7 +594,7 @@ jobs:
shell: bash
env:
DIGEST: ${{ steps.manifest.outputs.digest }}
VERSION: ${{ needs.release-info.outputs.version }}
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
run: |
@@ -609,7 +605,7 @@ jobs:
fi
image="${REGISTRY}/${IMAGE_NAME}"
tags=("${image}:${VERSION}" "${image}:sha-${GIT_SHA}")
tags=("${image}:${DOCKER_VERSION}" "${image}:sha-${GIT_SHA}")
if [ "$PRERELEASE" != true ]; then
tags+=("${image}:latest")
fi
@@ -629,12 +625,15 @@ jobs:
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
body: |
> [!WARNING]
> **Upgrading across major versions?** Database schema and configuration are only compatible within a major version, and the one-click self-update refuses cross-major targets (releases before 3.0 lack this gate — do not use their built-in update to reach a newer major). Deploy the new major fresh (new `.runtime/` data directory) and re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
## 📥 Installation
**Docker:**
```bash
# GitHub Container Registry
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.version }}
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
```
**Manual download:**
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
@@ -47,7 +47,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
+1 -1
View File
@@ -1,5 +1,5 @@
# Rust
/backend/target/
/backend/**/target/
*.profraw
*.profdata
+69 -32
View File
@@ -5,14 +5,14 @@
# Codex Proxy RS
**基于 Rust 的高性能 Codex 多账号网关代理**
**基于 Rust 的多 Provider 透明 AI 网关**
[![CI](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/zyycn/codex-proxy-rs?display_name=tag&sort=semver&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/releases)
[![GHCR](https://img.shields.io/badge/GHCR-codex--proxy--rs-2496ED?logo=docker&logoColor=white&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
[![MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/license/mit)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署文档](deploy/README.md) · [架构](docs/architecture.md)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署](deploy/README.md) · [接口](docs/api.md) · [架构](docs/architecture.md)
</div>
@@ -21,15 +21,16 @@
## 能力
| 领域 | 实现 |
| -------- | ------------------------------------------------ |
| 协议 | Responses HTTP、SSE、官方 WebSocket |
| 传输 | 会话级 WebSocket 池、共享 HTTP/2 后备 |
| 调度 | 智能调度、额度优先、轮询、粘滞 |
| 故障处理 | 额度、认证、封禁、模型与传输故障切换 |
| 身份 | 账号身份隔离、稳定 installation ID、会话拓扑透传 |
| 管理 | 账号、客户端 Key、模型别名、运行设置与在线更新 |
| 遥测 | Token、费用、缓存、TTFT、延迟与错误 |
| 领域 | 实现 |
| -------- | ----------------------------------------------------------------------------------------------------- |
| 协议 | OpenAI Responses JSON、SSE、WebSocket 与模型目录 |
| Provider | 固定 OpenAI、xAI 两个 Provider;各自管理 OAuth、账号、额度与模型目录 |
| 路由 | Client Key 绑定 Provider、全局模型映射、会话亲和与同 Provider 安全 fallback |
| 透明边界 | OpenAI 请求保留未知字段和字段顺序,SSE 与 WebSocket 原样转发业务字节;xAI 在 Provider 内做协议转换 |
| 延续 | OpenAI native/replay continuation;xAI 使用客户端提交的完整历史 |
| 账号 | 导入、OAuth、credential 刷新、主动额度刷新,以及正常响应中的被动额度/状态观测 |
| 管理 | Client Key、账号、模型目录、设置、观测、系统更新与 OAuth |
| 计量 | 模型请求 Token、费用、延迟、账号与 Provider 归因,并记录响应确认的实际 `service_tier` |
## 快速开始
@@ -48,19 +49,23 @@ sudo chown "$(id -u):10001" deploy/config.yaml
chmod 0640 deploy/config.yaml
```
分别执行三次:
分别生成 PostgreSQL 与 Redis 密码:
```bash
openssl rand -hex 24
openssl rand -hex 24
```
将结果写入 `deploy/config.yaml`:
把两个结果写入 `deploy/config.yaml`,并单独设置管理员初始密码:
| 配置 | 约束 |
| ------------------------------ | ------------------------ |
| `x-cpr.database.password` | 48 位十六进制 |
| `x-cpr.redis.password` | 48 位十六进制 |
| `x-cpr.admin.default_password` | 至少 12 位,不能包含 `$` |
| 配置 | 约束 |
| -------------------------- | ---------------------------- |
| `store.database.password` | 48 位十六进制 |
| `store.redis.password` | 48 位十六进制 |
| `admin.default_password` | 至少 12 位,不能包含 `$` |
PostgreSQL、Redis 容器和应用通过同一份 `config.yaml` 使用这两个基础设施密码,不需要额外
导出环境变量。管理员密码只在首次创建管理员时生效。
Linux 需要允许容器组写入运行目录:
@@ -84,9 +89,18 @@ curl -i http://127.0.0.1:8080/healthz
### 3. 初始化
1. 使用 `admin@cpr.local` 与初始密码登录。
2. 添加 Codex 账号并执行连接测试。
3. 创建 `sk_...` 客户端 Key。
4. 复制 Codex CLI 配置,或导入 CCSwitch。
2. 在账号页选择对应 Provider:OpenAI 支持 OAuth、账号文件与 Agent Identity;xAI 支持 OAuth
和兼容账号文件导入。
3. 按需配置客户端模型到上游模型的全局映射;未命中映射的模型名原样透传。
4. 创建 `sk_...` Client Key,绑定 `openai` 或 `xai`,并设置速率与并发限制。
OpenAI 账号导入和首次 OAuth 创建在 credential 提交后会立即尝试一次额度观测;额度接口临时失败不会
回滚已提交的账号。Free、K12 等套餐共用同一套额度与状态转换逻辑,套餐字段只作为账号事实并隔离
对应的模型目录 cache。credential 刷新、额度刷新和正常请求中的被动额度观测是三条独立链路,详见
[账号接口](docs/api.md#5-账号)。
> [!IMPORTANT]
> xAI 使用 OAuth session,不支持把 xAI API Key 作为上游 credential。
> [!NOTE]
> `admin.default_password` 只在首次创建管理员时生效。
@@ -118,23 +132,26 @@ curl http://127.0.0.1:8080/v1/responses \
<details>
<summary>API 路由</summary>
| 路由 | 用途 |
| --------------------------- | ------------------- |
| `POST /v1/responses` | JSON 或 SSE |
| `GET /v1/responses` | Responses WebSocket |
| `POST /v1/responses/review` | Review |
| `GET /v1/models` | 模型列表 |
| `GET /v1/models/catalog` | 模型目录 |
| `GET /v1/models/{model_id}` | 模型详情 |
| 路由 | 用途 |
| ---------------------------------- | ------------------------------ |
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
| `GET /v1/responses` | Responses WebSocket 升级 |
| `POST /v1/responses/review` | review 子代理请求 |
| `GET /v1/models` | 当前 Client Key 可用的模型列表 |
| `GET /v1/models/catalog` | Codex 客户端展示用模型目录 |
| `GET /v1/models/{model_id}/info` | Codex 客户端展示用模型详情 |
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
所有 `/v1/*` 路由都需要客户端 API Key。
</details>
完整的客户端与管理端路由、鉴权和 mutation 规则见 [接口文档](docs/api.md)。
## 运维
```bash
# 升级
# 实例升级
docker compose -f deploy/compose.yaml pull codex-proxy-rs
docker compose -f deploy/compose.yaml up -d --no-build
@@ -147,14 +164,34 @@ docker compose -f deploy/compose.yaml up -d
```
> [!IMPORTANT]
> `.runtime/` 保存数据库、Redis、身份密钥和日志。删除该目录会永久清除运行状态。
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点密钥和自更新状态。删除该目录会永久清除
> 运行状态。
> [!WARNING]
> 跨大版本不是原地升级:数据库 schema 与配置只在同一大版本内兼容,内置一键自更新
> 会拒绝跨大版本目标(3.0 之前的旧版没有这道门禁,不要用旧版界面的一键更新跨大版本)。
> 升级到新的大版本需要全新部署(全新 `.runtime/` 数据目录),并重新导入或重新授权
> Provider 账号与客户端 Key。
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket
upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
## 维护与发布
发布仓库版本必须从干净、已同步上游的发布分支运行:
```bash
release/publish <version>
```
脚本会更新 `release/version.yaml`、创建约定提交与带注释的 `v<version>` tag,并原子推送分支和 tag。
不要手工修改版本后单独打 tag。该脚本只发布仓库版本和触发 Release 构建,不会登录服务器,也不会
改变任何运行实例;源码提交、仓库发版和实例升级是三个独立状态。
## 文档
- [部署](deploy/README.md)
- [架构](docs/architecture.md)
- [客户端与管理端接口](docs/api.md)
- [架构与数据边界](docs/architecture.md)
- [Release](https://github.com/zyycn/codex-proxy-rs/releases)
- [容器镜像](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
+912 -126
View File
File diff suppressed because it is too large Load Diff
+18 -62
View File
@@ -1,73 +1,29 @@
[package]
name = "codex-proxy-rs"
[workspace]
members = [
"apps/gateway",
"crates/gateway-admin",
"crates/gateway-api",
"crates/gateway-core",
"crates/gateway-host",
"crates/gateway-protocol",
"crates/gateway-store",
"crates/providers/openai",
"crates/providers/xai",
]
resolver = "3"
[workspace.package]
version = "0.0.0"
edition = "2024"
license = "MIT"
publish = false
rust-version = "1.97"
[dependencies]
argon2 = "0.5.3"
async-trait = "0.1.89"
axum = { version = "0.8.9", default-features = false, features = ["http1", "json", "query", "tokio", "tracing"] }
base64 = "0.22.1"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["serde", "clock"] }
config = { version = "0.15.23", default-features = false, features = ["yaml"] }
flate2 = "1.1.9"
futures = "0.3.32"
hex = "0.4.3"
hmac = "0.13.0"
hyper = { version = "1.10.1", features = ["http1", "server"] }
hyper-util = { version = "0.1.20", features = ["tokio"] }
indexmap = { version = "2.0", features = ["serde"] }
ipnet = "2.12.0"
rand = "0.10.1"
# Enables password_hash::rand_core::OsRng for argon2 0.5.
rand_core = { version = "0.6.4", features = ["getrandom"] }
redis = { version = "1.3.0", default-features = false, features = ["tokio-comp", "connection-manager", "script"] }
roxmltree = "0.21.1"
# Pinned to the Codex Desktop 26.707.41301 HTTP stack; re-audit a newer Desktop before upgrading.
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate", "http2"] }
rustls = { version = "=0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
rustls-native-certs = "0.8.4"
rustls-pki-types = "1.14.1"
secrecy = { version = "0.10.3", features = ["serde"] }
semver = "1.0.27"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["preserve_order"] }
sha2 = "0.11.0"
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "json"] }
subtle = "2.6.1"
tar = "0.4.44"
thiserror = "2.0.18"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt-multi-thread", "signal", "fs", "time", "sync", "net"] }
tokio-util = { version = "0.7.18", features = ["rt"] }
tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-native-roots"] }
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.11", features = ["trace", "cors", "request-id", "timeout", "fs"] }
tracing = "0.1.44"
tracing-appender = "0.2.5"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] }
url = "2.5.8"
uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
[dev-dependencies]
sqlx = { version = "0.9.0", default-features = false, features = ["derive"] }
syn = { version = "2.0.118", features = ["full", "visit"] }
tempfile = "3.27.0"
tokio = { version = "1.52.3", default-features = false, features = ["test-util"] }
wiremock = "0.6.5"
[patch.crates-io]
tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" }
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" }
[lints.rust]
[workspace.lints.rust]
warnings = "deny"
unsafe_code = "forbid"
[lints.clippy]
[workspace.lints.clippy]
all = { level = "deny", priority = 10 }
redundant_clone = { level = "deny", priority = 9 }
large_enum_variant = { level = "warn", priority = 8 }
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "codex-proxy-rs"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true
autotests = false
[[test]]
name = "main"
path = "tests/main.rs"
[dependencies]
gateway-admin = { path = "../../crates/gateway-admin" }
gateway-api = { path = "../../crates/gateway-api" }
gateway-core = { path = "../../crates/gateway-core" }
gateway-host = { path = "../../crates/gateway-host" }
gateway-store = { path = "../../crates/gateway-store" }
provider-openai = { path = "../../crates/providers/openai" }
provider-xai = { path = "../../crates/providers/xai" }
serde = { version = "1.0.228", features = ["derive"] }
thiserror = "2.0.18"
tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-thread"] }
[dev-dependencies]
config = { version = "0.15.23", default-features = false, features = ["yaml"] }
syn = { version = "3.0.3", features = ["full"] }
tempfile = "3.27.0"
[lints]
workspace = true
+129
View File
@@ -0,0 +1,129 @@
//! 网关唯一组合根:加载包级配置并连接各 Bundle。
use gateway_core::engine::provider::{ProviderRegistry, RegistryError};
use gateway_host::{ConfigError, HostConfig, LoadableConfig};
use serde::Deserialize;
const CONFIG_SCHEMA_VERSION: u32 = 1;
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GatewayConfig {
schema_version: u32,
host: HostConfig,
store: gateway_store::StoreConfig,
admin: gateway_admin::AdminConfig,
api: gateway_api::ApiConfig,
openai: provider_openai::OpenAiConfig,
xai: provider_xai::XaiConfig,
#[serde(default, rename = "services")]
_compose_services: Option<serde::de::IgnoredAny>,
}
impl LoadableConfig for GatewayConfig {
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
if self.schema_version != CONFIG_SCHEMA_VERSION {
return Err(ConfigError::InvalidField("schema_version"));
}
self.host.resolve_and_validate(source_dir)?;
self.store
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("store"))?;
self.admin
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("admin"))?;
self.api
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.openai
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
self.xai
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("xai"))?;
Ok(())
}
}
/// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host。
pub async fn run() -> Result<(), BootstrapError> {
let config = gateway_host::load_config::<GatewayConfig>()?;
let GatewayConfig {
schema_version: _,
host,
store,
admin,
api,
openai,
xai,
_compose_services: _,
} = config;
let host = gateway_host::initialize(host).await?;
host.report_startup_ready("Host");
let mut store = gateway_store::initialize(store).await?;
host.report_startup_ready("Store");
let provider_ports = store.provider_ports();
let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?;
host.report_startup_ready("OpenAI Provider");
let mut xai = provider_xai::initialize(xai, provider_ports).await?;
host.report_startup_ready("xAI Provider");
let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
let mut core = gateway_core::initialize(store.core_ports(), providers).await?;
host.report_startup_ready("Core");
let admin = gateway_admin::initialize(
admin,
store.admin_ports(),
vec![openai.admin_provider(), xai.admin_provider()],
core.snapshot_control(),
core.account_probe(),
host.system_operations(),
)
.await?;
host.report_startup_ready("Admin");
let mut probes = store.health_probes();
probes.extend(core.health_probes());
let api = gateway_api::initialize(
api,
core.execution_service(),
admin.services(),
probes,
host.worker_health(),
host.connection_lifecycle(),
)?;
host.report_startup_ready("API");
let mut plan = store.take_worker_contributions();
plan.extend(core.take_worker_contributions());
plan.extend(openai.take_worker_contributions());
plan.extend(xai.take_worker_contributions());
host.start_workers(plan, store.worker_leader_lease())?;
host.report_startup_ready("Workers");
host.serve(api.router()).await?;
Ok(())
}
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置。
#[derive(Debug, thiserror::Error)]
pub enum BootstrapError {
#[error(transparent)]
Config(#[from] gateway_host::ConfigError),
#[error(transparent)]
Host(#[from] gateway_host::HostError),
#[error(transparent)]
Store(#[from] gateway_store::StoreError),
#[error(transparent)]
OpenAi(#[from] provider_openai::OpenAiInitializeError),
#[error(transparent)]
Xai(#[from] provider_xai::XaiInitializeError),
#[error(transparent)]
Registry(#[from] RegistryError),
#[error(transparent)]
Core(#[from] gateway_core::CoreError),
#[error(transparent)]
Admin(#[from] gateway_admin::model::AdminError),
#[error(transparent)]
Api(#[from] gateway_api::ApiError),
}
+3
View File
@@ -0,0 +1,3 @@
//! 网关进程组装根。
pub mod bootstrap;
+32
View File
@@ -0,0 +1,32 @@
use std::{env, error::Error, io};
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
let mut arguments = env::args_os().skip(1);
match arguments.next().as_deref().and_then(|value| value.to_str()) {
None | Some("serve") => {
reject_extra_arguments(arguments)?;
runtime()?.block_on(codex_proxy_rs::bootstrap::run())?;
Ok(())
}
Some(command) => {
Err(invalid_cli(&format!("unknown command {command:?}; expected serve")).into())
}
}
}
fn runtime() -> Result<tokio::runtime::Runtime, io::Error> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
}
fn reject_extra_arguments(mut arguments: impl Iterator) -> Result<(), io::Error> {
if arguments.next().is_some() {
return Err(invalid_cli("unexpected extra command arguments"));
}
Ok(())
}
fn invalid_cli(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message)
}
+139
View File
@@ -0,0 +1,139 @@
//! docs/architecture.md 依赖 DAG 与生产源码纪律的 workspace 级机器校验。
use std::{
fs,
path::{Path, PathBuf},
};
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。
const WORKSPACE_MEMBERS: &[&str] = &[
"apps/gateway",
"crates/gateway-admin",
"crates/gateway-api",
"crates/gateway-core",
"crates/gateway-host",
"crates/gateway-protocol",
"crates/gateway-store",
"crates/providers/openai",
"crates/providers/xai",
];
#[test]
fn workspace_member_list_matches_the_frozen_dag_scope() {
let manifest =
fs::read_to_string(backend_root().join("Cargo.toml")).expect("read workspace manifest");
for member in WORKSPACE_MEMBERS {
assert!(
manifest.contains(&format!("\"{member}\"")),
"workspace must include {member}"
);
}
let member_count = manifest
.lines()
.map(str::trim)
.filter(|line| line.starts_with("\"apps/") || line.starts_with("\"crates/"))
.count();
assert_eq!(
member_count,
WORKSPACE_MEMBERS.len(),
"new workspace members must extend the dependency DAG rules"
);
}
#[test]
fn gateway_core_depends_on_no_http_db_redis_or_provider_crate() {
assert_no_dependency(
"crates/gateway-core",
&[
"axum",
"hyper",
"reqwest",
"sqlx",
"redis",
"gateway-host",
"gateway-store",
"provider-openai",
"provider-xai",
],
);
}
#[test]
fn gateway_protocol_has_no_workspace_dependencies() {
for name in dependency_names("crates/gateway-protocol") {
assert!(
!name.starts_with("gateway-") && !name.starts_with("provider-"),
"gateway-protocol must not depend on workspace crate `{name}`"
);
}
}
#[test]
fn provider_crates_do_not_depend_on_each_other() {
assert_no_dependency("crates/providers/openai", &["provider-xai"]);
assert_no_dependency("crates/providers/xai", &["provider-openai"]);
}
#[test]
fn gateway_admin_stays_free_of_infrastructure_dependencies() {
assert_no_dependency(
"crates/gateway-admin",
&["axum", "sqlx", "redis", "reqwest"],
);
}
#[test]
fn production_sources_do_not_host_tests() {
for member in WORKSPACE_MEMBERS {
let src = backend_root().join(member).join("src");
let sources = super::rust_files(&src);
assert!(!sources.is_empty(), "{member} has no production sources");
for relative in sources {
let path = src.join(relative);
let source = fs::read_to_string(&path).expect("read production source");
assert!(
!source.contains("#[cfg(test)]"),
"{} hosts tests in production src",
path.display()
);
}
}
}
fn backend_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("backend workspace root")
}
/// 提取成员 `[dependencies]` 段内声明的依赖名;段落以下一个 `[` 表头结束。
fn dependency_names(member: &str) -> Vec<String> {
let manifest = fs::read_to_string(backend_root().join(member).join("Cargo.toml"))
.expect("read member manifest");
let mut in_dependencies = false;
let mut names = Vec::new();
for line in manifest.lines() {
let line = line.trim();
if line.starts_with('[') {
in_dependencies = line == "[dependencies]";
continue;
}
if !in_dependencies || line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((name, _)) = line.split_once('=') {
names.push(name.trim().to_owned());
}
}
names
}
fn assert_no_dependency(member: &str, forbidden: &[&str]) {
for name in dependency_names(member) {
assert!(
!forbidden.contains(&name.as_str()),
"{member} must not depend on `{name}`"
);
}
}
+197
View File
@@ -0,0 +1,197 @@
use std::{fs, process::Command};
use codex_proxy_rs::bootstrap::GatewayConfig;
use gateway_host::LoadableConfig;
const CONFIG_EXAMPLE: &str = include_str!("../../../../deploy/config.example.yaml");
const POSTGRES_PASSWORD: &str = "111111111111111111111111111111111111111111111111";
const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
const ADMIN_PASSWORD: &str = "test-admin-password";
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
#[test]
fn config_loader_should_load_complete_terminal_example() {
parse_config(&valid_config()).expect("terminal config example");
}
#[test]
fn config_loader_should_resolve_paths_relative_to_config_file() {
let (config, _directory) = parse_config(&valid_config()).expect("resolved config");
let debug = format!("{config:?}");
assert!(debug.contains(".runtime/logs"));
assert!(debug.contains("frontend/dist"));
}
#[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()
.replace("host: '127.0.0.1'", "host: ''")
.replace("port: 8080", "port: 0")
.replace(
"url: 'postgres://codex_proxy@127.0.0.1:5432/codex_proxy'",
"url: 'invalid-postgres-url'",
)
.replace("url: 'redis://127.0.0.1:6379/'", "url: 'invalid-redis-url'")
.replace(POSTGRES_PASSWORD, "invalid-postgres-password")
.replace(REDIS_PASSWORD, "invalid-redis-password");
if std::env::var_os(TOPOLOGY_CHILD_ENV).is_some() {
parse_config(&invalid).expect("explicit package-owned environment overrides");
return;
}
assert!(parse_config(&invalid).is_err());
let status = Command::new(std::env::current_exe().expect("current test executable"))
.args([
"--exact",
"bootstrap::config_loader_should_apply_only_explicit_topology_overrides",
])
.env(TOPOLOGY_CHILD_ENV, "1")
.env("CPR_SERVER_HOST", "127.0.0.1")
.env("CPR_SERVER_PORT", "8080")
.env(
"CPR_DATABASE_URL",
"postgres://codex_proxy@127.0.0.1:5432/codex_proxy",
)
.env("CPR_REDIS_URL", "redis://127.0.0.1:6379/")
.env("CPR_DATABASE_PASSWORD", POSTGRES_PASSWORD)
.env("CPR_REDIS_PASSWORD", REDIS_PASSWORD)
.status()
.expect("run isolated environment override test");
assert!(status.success());
}
#[test]
fn bootstrap_config_debug_should_redact_all_passwords() {
let (config, _directory) = parse_config(&valid_config()).expect("config");
let debug = format!("{config:?}");
assert!(!debug.contains(POSTGRES_PASSWORD));
assert!(!debug.contains(REDIS_PASSWORD));
assert!(!debug.contains(ADMIN_PASSWORD));
assert!(debug.contains("[REDACTED]"));
}
#[test]
fn config_loader_should_reject_unknown_fields() {
assert_rejected(format!(
"{}\nunknown_terminal_field: true\n",
valid_config()
));
}
#[test]
fn config_loader_should_reject_removed_tls_section() {
assert_rejected(valid_config().replace("openai:\n", "openai:\n tls: {}\n"));
}
#[test]
fn config_loader_should_reject_missing_explicit_fields() {
assert_rejected(valid_config().replace(" request_id_header: 'x-request-id'\n", ""));
}
#[test]
fn config_loader_should_reject_unsupported_schema_version() {
assert_rejected(valid_config().replace("schema_version: 1", "schema_version: 2"));
}
#[test]
fn config_loader_should_reject_embedded_database_password() {
assert_rejected(valid_config().replace(
"postgres://codex_proxy@127.0.0.1:5432/codex_proxy",
"postgres://codex_proxy:embedded@127.0.0.1:5432/codex_proxy",
));
}
#[test]
fn config_loader_should_reject_non_hex_postgres_password() {
assert_rejected(valid_config().replace(POSTGRES_PASSWORD, &"g".repeat(48)));
}
#[test]
fn config_loader_should_reject_wrong_length_redis_password() {
assert_rejected(valid_config().replace(REDIS_PASSWORD, "1234"));
}
#[test]
fn config_loader_should_reject_weak_admin_password() {
assert_rejected(valid_config().replace(ADMIN_PASSWORD, "password"));
}
#[test]
fn config_loader_should_reject_admin_password_with_compose_interpolation() {
assert_rejected(valid_config().replace(ADMIN_PASSWORD, "unsafe$password"));
}
#[test]
fn config_loader_should_reject_removed_fingerprint_section() {
assert_rejected(valid_config().replace(
"openai:\n",
"openai:\n fingerprint:\n browser: removed\n",
));
}
#[test]
fn config_loader_should_reject_invalid_codex_core_version() {
assert_rejected(valid_config().replace("codex_version: '0.145.0'", "codex_version: 'latest'"));
}
#[test]
fn config_loader_should_reject_disabled_all_log_outputs() {
assert_rejected(
valid_config()
.replace("stdout: true", "stdout: false")
.replace("enabled: true", "enabled: false"),
);
}
#[test]
fn config_loader_should_reject_zero_server_port() {
assert_rejected(valid_config().replace("port: 8080", "port: 0"));
}
#[test]
fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replace("desktop_build: '5828'", "desktop_build: 'build'"));
}
fn assert_rejected(config: String) {
assert!(parse_config(&config).is_err());
}
fn valid_config() -> String {
CONFIG_EXAMPLE
.replacen(
"password: &postgres_password ''",
&format!("password: &postgres_password '{POSTGRES_PASSWORD}'"),
1,
)
.replacen(
"password: &redis_password ''",
&format!("password: &redis_password '{REDIS_PASSWORD}'"),
1,
)
.replace(
"default_password: ''",
&format!("default_password: '{ADMIN_PASSWORD}'"),
)
}
fn parse_config(config: &str) -> Result<(GatewayConfig, tempfile::TempDir), String> {
let directory = tempfile::tempdir().map_err(|error| error.to_string())?;
let deploy = directory.path().join("deploy");
fs::create_dir(&deploy).map_err(|error| error.to_string())?;
let path = deploy.join("config.yaml");
fs::write(&path, config).map_err(|error| error.to_string())?;
let mut config = config::Config::builder()
.add_source(config::File::from(path).required(true))
.build()
.and_then(config::Config::try_deserialize::<GatewayConfig>)
.map_err(|error| error.to_string())?;
config
.resolve_and_validate(&deploy)
.map_err(|error| error.to_string())?;
Ok((config, directory))
}
+154
View File
@@ -0,0 +1,154 @@
mod architecture;
mod bootstrap;
use std::{
collections::BTreeSet,
fs,
path::{Path, PathBuf},
};
use syn::{Attribute, Item, Meta};
#[test]
fn app_tree_matches_frozen_terminal_manifest() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
assert_eq!(
rust_files(&root.join("src")),
BTreeSet::from([
PathBuf::from("bootstrap.rs"),
PathBuf::from("lib.rs"),
PathBuf::from("main.rs"),
]),
);
assert_eq!(
rust_files(&root.join("tests")),
BTreeSet::from([
PathBuf::from("architecture.rs"),
PathBuf::from("bootstrap.rs"),
PathBuf::from("main.rs"),
]),
);
}
#[test]
fn cargo_library_root_is_conventional_lib() {
let manifest = fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"))
.expect("read app Cargo.toml");
assert!(!manifest.contains("[lib]"));
assert!(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src/lib.rs")
.is_file()
);
}
#[test]
fn production_files_have_no_hidden_modules_or_test_hooks() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
for relative in ["bootstrap.rs", "lib.rs", "main.rs"] {
let path = root.join(relative);
let source = fs::read_to_string(&path).expect("read production source");
assert!(
!source.contains("include!("),
"{} uses include!",
path.display()
);
let syntax = syn::parse_file(&source).expect("parse production source");
for item in &syntax.items {
if let Item::Mod(module) = item {
assert!(
module.content.is_none(),
"{} has an inline module",
path.display()
);
}
for attribute in item_attrs(item) {
assert!(
!is_path_or_test_cfg(attribute),
"{} has a production test/path hook",
path.display(),
);
}
}
}
}
#[test]
fn bootstrap_owns_only_bundle_wiring() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let bootstrap = fs::read_to_string(root.join("bootstrap.rs")).expect("read bootstrap");
for forbidden in [
"sqlx::",
"redis::",
"Repository",
"impl Provider",
"impl ExecutionService",
"tokio::spawn",
"access_token",
"refresh_token",
] {
assert!(
!bootstrap.contains(forbidden),
"bootstrap owns `{forbidden}`"
);
}
assert!(bootstrap.lines().count() <= 300);
}
fn rust_files(root: &Path) -> BTreeSet<PathBuf> {
let mut result = BTreeSet::new();
collect_rust_files(root, root, &mut result);
result
}
fn collect_rust_files(root: &Path, current: &Path, result: &mut BTreeSet<PathBuf>) {
for entry in fs::read_dir(current).expect("read app tree") {
let path = entry.expect("read app tree entry").path();
if path.is_dir() {
collect_rust_files(root, &path, result);
} else if path.extension().is_some_and(|extension| extension == "rs") {
result.insert(
path.strip_prefix(root)
.expect("relative app path")
.to_path_buf(),
);
}
}
}
fn is_path_or_test_cfg(attribute: &Attribute) -> bool {
if attribute.path().is_ident("path") {
return true;
}
if !attribute.path().is_ident("cfg") && !attribute.path().is_ident("cfg_attr") {
return false;
}
let Meta::List(list) = &attribute.meta else {
return false;
};
list.tokens
.to_string()
.split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
.any(|segment| segment == "test" || segment.starts_with("test_"))
}
fn item_attrs(item: &Item) -> &[Attribute] {
match item {
Item::Const(item) => &item.attrs,
Item::Enum(item) => &item.attrs,
Item::ExternCrate(item) => &item.attrs,
Item::Fn(item) => &item.attrs,
Item::ForeignMod(item) => &item.attrs,
Item::Impl(item) => &item.attrs,
Item::Macro(item) => &item.attrs,
Item::Mod(item) => &item.attrs,
Item::Static(item) => &item.attrs,
Item::Struct(item) => &item.attrs,
Item::Trait(item) => &item.attrs,
Item::TraitAlias(item) => &item.attrs,
Item::Type(item) => &item.attrs,
Item::Union(item) => &item.attrs,
Item::Use(item) => &item.attrs,
_ => &[],
}
}
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "gateway-admin"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true
autotests = false
[[test]]
name = "main"
path = "tests/main.rs"
[dependencies]
argon2 = "0.5.3"
async-trait = "0.1.89"
base64 = "0.23.0"
chrono = "0.4.45"
futures = "0.3.32"
gateway-core = { path = "../gateway-core" }
hex = "0.4.3"
rand_core = { version = "0.6.4", features = ["getrandom"] }
serde = { version = "1.0.228", features = ["derive"] }
secrecy = { version = "0.10.3", features = ["serde"] }
subtle = "2.6.1"
thiserror = "2.0.18"
tracing = "0.1.44"
uuid = { version = "1.23.3", features = ["v7"] }
[dev-dependencies]
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
[lints]
workspace = true
+290
View File
@@ -0,0 +1,290 @@
//! 管理控制面的语义模型、用例与外部能力端口。
//!
//! 本 crate 不包含 HTTP wire、数据库实现或具体 Provider 实现。
use std::{fmt, path::Path, sync::Arc};
use gateway_core::{
engine::probe::AccountProbe,
routing::{ProviderKind, snapshot::SnapshotControl},
};
use secrecy::{ExposeSecret as _, SecretString};
use serde::Deserialize;
pub mod model;
pub mod ports;
mod use_case;
pub use use_case::{
accounts::AccountsService, auth::AuthService, client_keys::ClientKeyService,
observability::ObservabilityService, openai::OpenAiService, settings::SettingsService,
system::SystemService, xai::XaiService,
};
use model::{AdminError, AdminErrorKind};
use ports::{
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry},
store::AdminStorePorts,
system::SystemOperations,
};
use use_case::{
accounts::DefaultAccountsService, auth::DefaultAuthService,
client_keys::DefaultClientKeyService, observability::DefaultObservabilityService,
openai::DefaultOpenAiService, settings::DefaultSettingsService, system::DefaultSystemService,
xai::DefaultXaiService,
};
const OPENAI_PROVIDER_KIND: &str = "openai";
const XAI_PROVIDER_KIND: &str = "xai";
const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12;
const WEAK_INITIAL_PASSWORDS: &[&str] = &[
"",
"admin",
"123456",
"password",
"changeme",
"change-me",
"replace-me",
"codex-proxy-rs",
];
/// 只用于首次幂等创建默认管理员的启动密码。
#[derive(Clone, Deserialize)]
#[serde(transparent)]
pub struct InitialAdminPassword(SecretString);
impl InitialAdminPassword {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(SecretString::from(value.into()))
}
fn expose(&self) -> &str {
self.0.expose_secret()
}
}
impl PartialEq for InitialAdminPassword {
fn eq(&self, other: &Self) -> bool {
self.expose() == other.expose()
}
}
impl Eq for InitialAdminPassword {}
impl fmt::Debug for InitialAdminPassword {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("InitialAdminPassword([REDACTED])")
}
}
/// 管理控制面的启动配置。
#[derive(Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AdminConfig {
pub session_ttl_minutes: u64,
pub default_username: String,
pub default_password: InitialAdminPassword,
}
impl AdminConfig {
/// 校验 Admin-owned 字段;当前配置不含相对路径。
///
/// # Errors
///
/// 用户名、会话有效期或初始密码不满足安全约束时返回错误。
pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), AdminConfigError> {
if self.default_username.trim().is_empty()
|| self.default_username.chars().any(char::is_control)
{
return Err(AdminConfigError::InvalidField("admin.default_username"));
}
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
return Err(AdminConfigError::InvalidField("admin.session_ttl_minutes"));
}
let password = self.default_password.expose().trim();
if password.len() < MINIMUM_INITIAL_PASSWORD_BYTES
|| password.contains('$')
|| WEAK_INITIAL_PASSWORDS.contains(&password.to_ascii_lowercase().as_str())
{
return Err(AdminConfigError::WeakInitialPassword);
}
Ok(())
}
}
impl fmt::Debug for AdminConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AdminConfig")
.field("session_ttl_minutes", &self.session_ttl_minutes)
.field("default_username", &self.default_username)
.field("default_password", &"[REDACTED]")
.finish()
}
}
/// Admin-owned 启动配置错误;不回显任何配置值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AdminConfigError {
#[error("configuration field `{0}` is invalid")]
InvalidField(&'static str),
#[error("admin.default_password does not meet the initial password policy")]
WeakInitialPassword,
}
/// API 持有的管理资源能力集合。
///
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
#[derive(Clone)]
pub struct AdminServices {
auth: Arc<dyn AuthService>,
accounts: Arc<dyn AccountsService>,
client_keys: Arc<dyn ClientKeyService>,
observability: Arc<dyn ObservabilityService>,
settings: Arc<dyn SettingsService>,
system: Arc<dyn SystemService>,
openai: Arc<dyn OpenAiService>,
xai: Arc<dyn XaiService>,
}
impl AdminServices {
#[must_use]
pub fn auth(&self) -> &dyn AuthService {
self.auth.as_ref()
}
#[must_use]
pub fn accounts(&self) -> &dyn AccountsService {
self.accounts.as_ref()
}
#[must_use]
pub fn client_keys(&self) -> &dyn ClientKeyService {
self.client_keys.as_ref()
}
#[must_use]
pub fn observability(&self) -> &dyn ObservabilityService {
self.observability.as_ref()
}
#[must_use]
pub fn settings(&self) -> &dyn SettingsService {
self.settings.as_ref()
}
#[must_use]
pub fn system(&self) -> &dyn SystemService {
self.system.as_ref()
}
#[must_use]
pub fn openai(&self) -> &dyn OpenAiService {
self.openai.as_ref()
}
#[must_use]
pub fn xai(&self) -> &dyn XaiService {
self.xai.as_ref()
}
}
/// Admin 初始化完成后的封闭能力包。
pub struct AdminBundle {
services: AdminServices,
}
impl AdminBundle {
#[must_use]
pub fn services(&self) -> AdminServices {
self.services.clone()
}
}
/// 校验配置、建立动态 Provider 注册表并完成默认管理员幂等初始化。
///
/// # Errors
///
/// 配置非法、Provider 注册冲突/缺失或默认管理员初始化失败时返回错误。
pub async fn initialize(
mut config: AdminConfig,
store: AdminStorePorts,
providers: Vec<Arc<dyn ProviderAdmin>>,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
system: Arc<dyn SystemOperations>,
) -> Result<AdminBundle, AdminError> {
config
.resolve_and_validate(Path::new("."))
.map_err(|error| AdminError::invalid(error.to_string()))?;
let registry = ProviderAdminRegistry::new(providers).map_err(map_provider_registry_error)?;
let openai = registry
.require(&provider_kind(OPENAI_PROVIDER_KIND)?)
.map_err(map_provider_registry_error)?;
let xai = registry
.require(&provider_kind(XAI_PROVIDER_KIND)?)
.map_err(map_provider_registry_error)?;
let auth = Arc::new(DefaultAuthService::new(
config.default_username,
config.session_ttl_minutes,
store.auth(),
));
auth.ensure_default_admin(config.default_password.expose())
.await?;
let accounts = Arc::new(DefaultAccountsService::new(
store.accounts(),
store.settings(),
registry.clone(),
snapshot.clone(),
probe.clone(),
));
let services = AdminServices {
auth,
accounts,
client_keys: Arc::new(DefaultClientKeyService::new(
store.client_keys(),
snapshot.clone(),
)),
observability: Arc::new(DefaultObservabilityService::new(
store.observability(),
store.settings(),
registry,
)),
settings: Arc::new(DefaultSettingsService::new(
store.settings(),
snapshot.clone(),
)),
system: Arc::new(DefaultSystemService::new(system)),
openai: Arc::new(DefaultOpenAiService::new(
openai,
store.accounts(),
snapshot.clone(),
)),
xai: Arc::new(DefaultXaiService::new(
xai,
store.accounts(),
snapshot.clone(),
)),
};
Ok(AdminBundle { services })
}
fn provider_kind(value: &'static str) -> Result<ProviderKind, AdminError> {
ProviderKind::new(value).map_err(|_| AdminError::internal("Built-in Provider kind is invalid"))
}
fn map_provider_registry_error(error: ProviderAdminError) -> AdminError {
let kind = match error.kind() {
ProviderAdminErrorKind::Invalid | ProviderAdminErrorKind::Unsupported => {
AdminErrorKind::Invalid
}
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, "Provider registry initialization failed")
}
@@ -0,0 +1,224 @@
//! 多 Provider 账号目录与连接测试的公共事实。
use std::pin::Pin;
use chrono::{DateTime, Utc};
use futures::Stream;
use gateway_core::routing::ProviderKind;
use super::{PageSize, Revision, observability::TimeRange};
/// Provider 账号当前可用性;权威定义与字符串编解码在 gateway-core,此处仅复用。
pub use gateway_core::engine::credential::AccountAvailability;
/// 管理页使用的归一化账号状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountStatus {
Active,
Expired,
QuotaExhausted,
Disabled,
Banned,
}
/// 账号列表排序字段。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountSortField {
Email,
Status,
PlanType,
Usage,
LastUsedAt,
ExpiresAt,
}
/// 账号列表排序方向。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
Asc,
Desc,
}
/// 一组完整的账号排序规则。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountSort {
pub field: AccountSortField,
pub direction: SortDirection,
}
/// 账号列表的存储查询条件。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountListQuery {
pub page: u32,
pub page_size: PageSize,
pub provider_kind: Option<ProviderKind>,
pub search: Option<String>,
pub status: Option<AccountStatus>,
pub sort: Option<AccountSort>,
}
/// 账号公共存储投影;Provider 专属字段不进入此结构。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRecord {
pub id: String,
pub provider_kind: ProviderKind,
pub name: String,
pub email: Option<String>,
pub upstream_user_id: String,
pub upstream_account_id: Option<String>,
pub plan_type: Option<String>,
pub authentication_kind: String,
pub credential_revision: Revision,
pub has_refresh_token: bool,
pub access_token_expires_at: Option<DateTime<Utc>>,
pub next_refresh_at: Option<DateTime<Utc>>,
pub enabled: bool,
pub availability: AccountAvailability,
pub availability_reason: Option<String>,
pub cooldown_until: Option<DateTime<Utc>>,
pub availability_observed_at: DateTime<Utc>,
pub quota_observed_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
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,
}
/// 账号历史用量聚合。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountUsage {
pub account_id: 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: Option<DateTime<Utc>>,
pub request_buckets: Vec<AccountRequestBucket>,
pub models: Vec<AccountModelUsage>,
}
/// 某个账号在调用方指定时间窗口内的本地用量查询。
///
/// `key` 只用于把聚合结果关联回调用方的窗口,不承载 Provider 私有语义。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountUsageWindowQuery {
pub account_id: String,
pub key: String,
pub range: TimeRange,
}
/// 一个账号时间窗口用量查询的结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountUsageWindowResult {
pub account_id: String,
pub key: String,
pub usage: AccountUsage,
}
/// 账号列表页所需的完整存储事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountPage {
pub config_revision: Revision,
pub items: Vec<AccountRecord>,
pub total: u64,
pub summary: AccountSummary,
}
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountSummary {
pub total: u64,
pub active: u64,
pub quota_exhausted: u64,
pub unavailable: u64,
}
/// 账号启停写入命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetAccountEnabled {
pub account_id: String,
pub enabled: bool,
}
/// 账号批量删除命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteAccounts {
pub account_ids: Vec<String>,
}
/// 账号连接测试的语义事件。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountConnectionTestEvent {
Started {
model: String,
},
Request {
model: String,
input_text: String,
stream: bool,
store: bool,
},
Content {
text: String,
},
Completed {
account_status: AccountStatus,
},
Failed {
message: String,
provider_error_code: Option<String>,
provider_error_type: Option<String>,
upstream_status: Option<u16>,
upstream_content_type: Option<String>,
upstream_body: Option<String>,
account_status: AccountStatus,
},
}
/// 每次连接测试独占的有限事件流。
pub type AccountConnectionTestEventStream =
Pin<Box<dyn Stream<Item = AccountConnectionTestEvent> + Send + 'static>>;
@@ -0,0 +1,100 @@
//! 管理员认证、会话和安全审计事实。
use chrono::{DateTime, Utc};
use super::{MutationActor, MutationContext, Revision};
/// 已认证的管理主体。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdminPrincipal {
Session { admin_user_id: String },
ApiKey,
}
/// 传给管理用例的安全请求上下文。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminRequestContext {
pub principal: AdminPrincipal,
pub request_id: String,
}
impl AdminRequestContext {
#[must_use]
pub fn mutation_context(&self) -> MutationContext {
let actor = match &self.principal {
AdminPrincipal::Session { admin_user_id } => MutationActor::AdminSession {
admin_user_id: admin_user_id.clone(),
},
AdminPrincipal::ApiKey => MutationActor::AdminApiKey,
};
MutationContext {
actor,
request_id: self.request_id.clone(),
}
}
}
/// 管理员登录命令。
#[derive(Clone, PartialEq, Eq)]
pub struct LoginCommand {
pub username: Option<String>,
pub password: String,
}
impl std::fmt::Debug for LoginCommand {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("LoginCommand")
.field("username", &self.username)
.field("password", &"[REDACTED]")
.finish()
}
}
/// 登录成功后返回的会话事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoginResult {
pub session_id: String,
pub expires_at: DateTime<Utc>,
}
/// 登录状态机可被 API 精确映射的失败类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum LoginError {
#[error("invalid administrator credentials")]
InvalidCredentials,
#[error("administrator authentication is unavailable")]
Unavailable,
}
/// Redis 中可恢复的管理员会话。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminSession {
pub admin_user_id: String,
pub expires_at: DateTime<Utc>,
}
/// 安全审计事件类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditActorKind {
AdminSession,
AdminApiKey,
System,
Anonymous,
}
/// 管理写操作留下的最小审计事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminAuditEvent {
pub id: String,
pub actor_kind: AuditActorKind,
pub actor_admin_user_id: Option<String>,
pub actor_ref: String,
pub request_id: Option<String>,
pub action: String,
pub entity_kind: String,
pub entity_ref: String,
pub config_revision: Option<Revision>,
pub changed_fields: Vec<String>,
pub occurred_at: DateTime<Utc>,
}
@@ -0,0 +1,218 @@
//! Client API Key 的 Command、Result 与安全秘密类型。
use std::{fmt, num::NonZeroU16};
use chrono::{DateTime, Utc};
use gateway_core::{
policy::{ClientApiKeyId, RateLimits},
routing::ProviderKind,
};
use super::{AdminModelError, Revision};
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ClientKeyPageSize(NonZeroU16);
impl ClientKeyPageSize {
/// 创建 1 至 65535 的 Client Key 页大小。
///
/// # Errors
///
/// `value` 为零时返回 [`AdminModelError::InvalidClientKeyPageSize`]。
pub fn new(value: u16) -> Result<Self, AdminModelError> {
NonZeroU16::new(value)
.map(Self)
.ok_or(AdminModelError::InvalidClientKeyPageSize)
}
#[must_use]
pub const fn get(self) -> u16 {
self.0.get()
}
}
/// Client Key 列表排序字段。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientKeySortField {
Name,
Enabled,
CreatedAt,
LastUsedAt,
}
/// Client Key 列表排序方向。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
Asc,
Desc,
}
/// Client Key 列表排序规则。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientKeySort {
pub field: ClientKeySortField,
pub direction: SortDirection,
}
/// 与排序字段绑定的游标值。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientKeyCursorValue {
Name(String),
Enabled(bool),
CreatedAt(DateTime<Utc>),
LastUsedAt(Option<DateTime<Utc>>),
}
/// Client Key 的稳定键集游标。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyCursor {
pub sort: ClientKeySort,
pub value: ClientKeyCursorValue,
pub id: ClientApiKeyId,
}
/// Client Key 列表查询。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyListQuery {
pub cursor: Option<ClientKeyCursor>,
pub page_size: ClientKeyPageSize,
pub search: Option<String>,
pub sort: ClientKeySort,
}
/// 不含完整明文 Key 的管理投影。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyRecord {
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
pub provider_kind: ProviderKind,
pub prefix: String,
pub enabled: bool,
pub limits: RateLimits,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Client Key 列表页。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyPage {
pub config_revision: Revision,
pub items: Vec<ClientKeyRecord>,
pub total: u64,
pub next_cursor: Option<ClientKeyCursor>,
}
/// 仅在创建或显式 reveal 时跨越管理边界的明文 Key。
#[derive(Clone, PartialEq, Eq)]
pub struct ClientKeySecret {
pub record: ClientKeyRecord,
plaintext: String,
}
impl ClientKeySecret {
#[must_use]
pub fn new(record: ClientKeyRecord, plaintext: impl Into<String>) -> Self {
Self {
record,
plaintext: plaintext.into(),
}
}
#[must_use]
pub fn expose_for_response(&self) -> &str {
&self.plaintext
}
}
impl fmt::Debug for ClientKeySecret {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientKeySecret")
.field("record", &self.record)
.field("plaintext", &"[REDACTED]")
.finish()
}
}
/// API 提交的 Client Key 创建命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub name: String,
pub label: Option<String>,
pub provider_kind: ProviderKind,
pub limits: RateLimits,
}
/// 管理用例生成 ID 与明文后的持久化命令。
#[derive(Clone, PartialEq, Eq)]
pub struct NewClientKey {
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
pub provider_kind: ProviderKind,
pub limits: RateLimits,
pub plaintext: String,
}
impl fmt::Debug for NewClientKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("NewClientKey")
.field("id", &self.id)
.field("name", &self.name)
.field("provider_kind", &self.provider_kind)
.field("plaintext", &"[REDACTED]")
.finish_non_exhaustive()
}
}
/// 修改 Client Key 的公开策略字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKey {
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
pub provider_kind: ProviderKind,
pub limits: RateLimits,
}
/// 修改 Client Key 启用状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetClientKeyEnabled {
pub id: ClientApiKeyId,
pub enabled: bool,
}
/// 删除 Client Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteClientKey {
pub id: ClientApiKeyId,
}
/// Client Key 创建结果;完整明文仅存在于该一次性结果中。
pub struct CreatedClientKey {
pub config_revision: Revision,
pub secret: ClientKeySecret,
}
impl fmt::Debug for CreatedClientKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CreatedClientKey")
.field("config_revision", &self.config_revision)
.field("secret", &self.secret)
.finish()
}
}
/// Client Key 普通写操作结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyMutation {
pub config_revision: Revision,
pub record: Option<ClientKeyRecord>,
pub id: ClientApiKeyId,
}
@@ -0,0 +1,155 @@
//! 管理控制面使用的 Command、Result 与稳定值对象。
use std::num::{NonZeroU16, NonZeroU64};
pub mod accounts;
pub mod auth;
pub mod client_keys;
pub mod observability;
pub mod provider_credentials;
pub mod settings;
pub mod system;
/// 管理用例对外返回的稳定错误分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdminErrorKind {
Invalid,
Unauthorized,
NotFound,
Conflict,
RateLimited,
BadGateway,
Unavailable,
Internal,
}
/// 不携带基础设施细节的管理用例错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct AdminError {
kind: AdminErrorKind,
message: String,
}
impl AdminError {
#[must_use]
pub fn new(kind: AdminErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
#[must_use]
pub const fn kind(&self) -> AdminErrorKind {
self.kind
}
#[must_use]
pub fn invalid(message: impl Into<String>) -> Self {
Self::new(AdminErrorKind::Invalid, message)
}
#[must_use]
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(AdminErrorKind::NotFound, message)
}
#[must_use]
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(AdminErrorKind::Conflict, message)
}
#[must_use]
pub fn unavailable(message: impl Into<String>) -> Self {
Self::new(AdminErrorKind::Unavailable, message)
}
#[must_use]
pub fn internal(message: impl Into<String>) -> Self {
Self::new(AdminErrorKind::Internal, message)
}
}
/// PostgreSQL 中所有正整数 revision 的管理层表示。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Revision(NonZeroU64);
impl Revision {
/// 创建正整数 revision。
///
/// # Errors
///
/// `value` 为零时返回 [`AdminModelError::ZeroRevision`]。
pub fn new(value: u64) -> Result<Self, AdminModelError> {
NonZeroU64::new(value)
.map(Self)
.ok_or(AdminModelError::ZeroRevision)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0.get()
}
}
/// 管理列表统一使用的受限页大小。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PageSize(NonZeroU16);
impl PageSize {
pub const MAX: u16 = 200;
/// 创建 1 至 200 的页大小。
///
/// # Errors
///
/// `value` 不在有效范围时返回 [`AdminModelError::InvalidPageSize`]。
pub fn new(value: u16) -> Result<Self, AdminModelError> {
if value > Self::MAX {
return Err(AdminModelError::InvalidPageSize(value));
}
NonZeroU16::new(value)
.map(Self)
.ok_or(AdminModelError::InvalidPageSize(value))
}
#[must_use]
pub const fn get(self) -> u16 {
self.0.get()
}
}
/// 可审计管理写操作的发起者。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MutationActor {
AdminSession { admin_user_id: String },
AdminApiKey,
System,
}
/// 管理写操作必须携带的审计上下文。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MutationContext {
pub actor: MutationActor,
pub request_id: String,
}
/// 领域值对象构造失败。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AdminModelError {
#[error("revision must be greater than zero")]
ZeroRevision,
#[error("page size {0} is outside 1..=200")]
InvalidPageSize(u16),
#[error("client key page size must be inside 1..=65535")]
InvalidClientKeyPageSize,
#[error("request outcome must be 1..=256 bytes without control characters")]
InvalidRequestOutcome,
#[error("time range must be positive and no longer than 366 days")]
InvalidTimeRange,
#[error("decimal amount is not a non-negative numeric(20,10) value")]
InvalidDecimalAmount,
#[error("latency percentile must be finite and non-negative")]
InvalidLatencyPercentile,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,724 @@
//! Provider 管理能力交换的中立 Command 与 Result。
use std::fmt;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use gateway_core::{
engine::credential::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
routing::{ProviderKind, UpstreamModelId},
};
use super::{
AdminError, MutationActor, MutationContext, PageSize, Revision,
accounts::{AccountAvailability, AccountRecord, AccountStatus, AccountSummary, AccountUsage},
};
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
#[derive(Clone, PartialEq)]
pub struct ProviderDocument(OpaqueProviderData);
impl ProviderDocument {
#[must_use]
pub const fn new(data: OpaqueProviderData) -> Self {
Self(data)
}
/// 仅具体 Provider 可以解释内部字段。
#[must_use]
pub const fn expose_to_provider(&self) -> &OpaqueProviderData {
&self.0
}
#[must_use]
pub fn into_provider_data(self) -> OpaqueProviderData {
self.0
}
}
impl fmt::Debug for ProviderDocument {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ProviderDocument([PROVIDER_OWNED])")
}
}
/// Credential 列表稳定游标。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialCursor {
pub created_at: DateTime<Utc>,
pub account_id: ProviderAccountId,
}
/// Provider credential 列表查询。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialListQuery {
pub availability: Option<CredentialAvailabilityFilter>,
pub enabled: Option<bool>,
pub window: CredentialListWindow,
}
/// Credential 可用性筛选;支持一个 wire 值对应多个持久状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialAvailabilityFilter {
Exact(AccountAvailability),
AnyOf(Vec<AccountAvailability>),
}
impl CredentialAvailabilityFilter {
#[must_use]
pub fn matches(&self, availability: AccountAvailability) -> bool {
match self {
Self::Exact(expected) => *expected == availability,
Self::AnyOf(expected) => expected.contains(&availability),
}
}
}
/// Credential 目录的集合窗口;完整列表与游标分页互斥。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialListWindow {
All,
Page {
cursor: Option<CredentialCursor>,
page_size: PageSize,
},
}
/// Provider credential 列表。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialPage {
pub config_revision: Revision,
pub items: Vec<AccountRecord>,
pub next_cursor: Option<CredentialCursor>,
}
/// Provider credential 详情。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialDetails {
pub config_revision: Revision,
pub credential: AccountRecord,
}
/// Provider 正式文档批量导入命令。
pub struct ImportCredentials {
pub context: MutationContext,
pub document: ProviderDocument,
}
impl fmt::Debug for ImportCredentials {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ImportCredentials")
.field("context", &self.context)
.field("document", &self.document)
.finish()
}
}
/// 批量导入提交结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialImportResult {
pub config_revision: Revision,
pub credential_ids: Vec<ProviderAccountId>,
}
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文。
pub struct PrepareCredentialImport {
pub document: ProviderDocument,
}
impl fmt::Debug for PrepareCredentialImport {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PrepareCredentialImport")
.field("document", &self.document)
.finish()
}
}
/// Provider 已验证、可由 Store 原子创建的一份 credential。
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialCreate {
pub account_id: ProviderAccountId,
pub provider_kind: ProviderKind,
pub name: String,
pub email: Option<String>,
pub upstream_user_id: String,
pub upstream_account_id: Option<String>,
pub plan_type: Option<String>,
pub authentication_kind: String,
pub provider_material: ProviderDocument,
pub has_refresh_token: bool,
pub access_token_expires_at: Option<DateTime<Utc>>,
pub next_refresh_at: Option<DateTime<Utc>>,
pub enabled: bool,
pub availability: AccountAvailability,
pub availability_reason: Option<String>,
pub cooldown_until: Option<DateTime<Utc>>,
pub availability_observed_at: DateTime<Utc>,
}
/// Provider 对一份导入文档的完整验证结果。
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialImport {
pub provider_kind: ProviderKind,
pub credentials: Vec<PreparedCredentialCreate>,
}
/// Admin 交给 Store 的导入事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialImportCommit {
pub prepared: PreparedCredentialImport,
}
/// OAuth pending owner 的中立身份;不编码具体 Provider 的 Redis key 或 JSON。
#[derive(Clone, PartialEq, Eq)]
pub enum AuthorizationOwner {
AdminSession { admin_user_id: String },
AdminApiKey,
System,
}
impl fmt::Debug for AuthorizationOwner {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("AuthorizationOwner([REDACTED])")
}
}
/// Provider 写入 pending payload 与 Redis owner binding 所需的全部中立字段。
#[derive(Clone, PartialEq, Eq)]
pub struct AuthorizationOwnerBinding {
owner: AuthorizationOwner,
started_request_id: String,
}
impl AuthorizationOwnerBinding {
#[must_use]
pub fn from_context(context: &MutationContext) -> Self {
let owner = match &context.actor {
MutationActor::AdminSession { admin_user_id } => AuthorizationOwner::AdminSession {
admin_user_id: admin_user_id.clone(),
},
MutationActor::AdminApiKey => AuthorizationOwner::AdminApiKey,
MutationActor::System => AuthorizationOwner::System,
};
Self {
owner,
started_request_id: context.request_id.clone(),
}
}
#[must_use]
pub const fn owner(&self) -> &AuthorizationOwner {
&self.owner
}
#[must_use]
pub fn started_request_id(&self) -> &str {
&self.started_request_id
}
#[must_use]
pub fn matches_context(&self, context: &MutationContext) -> bool {
Self::from_context(context).owner == self.owner
}
}
impl fmt::Debug for AuthorizationOwnerBinding {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("AuthorizationOwnerBinding([REDACTED])")
}
}
/// OAuth 完成时应创建新账号还是 CAS 更新既有 credential。
///
/// 重新授权只绑定稳定的账号身份:credential revision 由服务端在临近写入时读取,
/// 长流程期间的后台刷新不得让恢复操作失效。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorizationMutationTarget {
Create { name: String },
Reauthorize { account_id: ProviderAccountId },
}
/// 必须完整进入 Provider opaque pending payload、并在 complete 后原样恢复的事务信封。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingAuthorizationMutation {
provider_kind: ProviderKind,
target: AuthorizationMutationTarget,
owner_binding: AuthorizationOwnerBinding,
}
impl PendingAuthorizationMutation {
#[must_use]
pub const fn new(
provider_kind: ProviderKind,
target: AuthorizationMutationTarget,
owner_binding: AuthorizationOwnerBinding,
) -> Self {
Self {
provider_kind,
target,
owner_binding,
}
}
#[must_use]
pub const fn provider_kind(&self) -> &ProviderKind {
&self.provider_kind
}
#[must_use]
pub const fn target(&self) -> &AuthorizationMutationTarget {
&self.target
}
#[must_use]
pub const fn owner_binding(&self) -> &AuthorizationOwnerBinding {
&self.owner_binding
}
}
/// 启动 Provider OAuth Authorization Code 流程。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StartAuthorization {
pub context: MutationContext,
pub name: String,
pub reauthorization: Option<ProviderAccountId>,
}
/// OAuth 流程启动结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorizationStarted {
pub flow_id: String,
pub authorization_url: String,
pub expires_at: DateTime<Utc>,
}
/// refresh/rotate/reauthorize 的 lease 或 completion 生命周期。
///
/// 该 guard 不可 Clone。Admin 只能在 Store CAS 与审计事务成功后调用 `finish`;失败路径直接
/// drop,使 Provider 可以释放 lease 或执行补偿。
pub trait CredentialCommitGuard: Send + 'static {
fn finish(self: Box<Self>);
}
/// OAuth pending claim 在 Store 事务结果确定后执行的显式结算动作。
///
/// OAuth provider 必须在 credential 准备阶段持有 claim:Store 提交成功后消费 flow,提交或
/// 校验失败后释放 flow。异步结算不能依赖 `Drop`,否则请求提前返回会让同一授权流无谓地
/// 进入短暂的不可重试状态。
#[async_trait]
pub trait AuthorizationCommitGuard: Send + 'static {
/// Store 已提交 OAuth credential 后消费对应的一次性 flow。
async fn commit(self: Box<Self>) -> Result<(), AdminError>;
/// Store 未提交 OAuth credential 时释放 claim,允许同一 flow 重试。
async fn abort(self: Box<Self>) -> Result<(), AdminError>;
}
/// OAuth complete 后由 Provider 返回的准备结果;Store 仍是唯一提交者。
pub enum PreparedAuthorizationCredential {
Create(PreparedCredentialCreate),
Reauthorize(PreparedCredentialRotation),
}
/// Provider 从 opaque pending payload 恢复的信封与已验证 credential 必须一起返回。
pub struct PreparedAuthorizationCommit {
pub pending: PendingAuthorizationMutation,
pub credential: PreparedAuthorizationCredential,
authorization_guard: Option<Box<dyn AuthorizationCommitGuard>>,
}
impl fmt::Debug for PreparedAuthorizationCommit {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PreparedAuthorizationCommit")
.field("pending", &self.pending)
.field("credential", &"[PREPARED]")
.finish()
}
}
/// Store 可持久化的 OAuth credential facts,不携带 Provider guard。
#[derive(Debug, Clone, PartialEq)]
pub enum AuthorizationCredentialCommit {
Create(PreparedCredentialCreate),
Reauthorize(PreparedCredentialRotationFacts),
}
/// Admin 交给 Store 的 OAuth 原子事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct AuthorizationCommit {
pub pending: PendingAuthorizationMutation,
pub credential: AuthorizationCredentialCommit,
}
/// OAuth 准备结果拆解后的 Store 命令与两个结算 guard。
///
/// Store 成功时先结束 credential guard,再消费 OAuth claim;失败时丢弃 credential guard 并
/// 释放 OAuth claim,使同一授权流可以重试。
pub(crate) struct AuthorizationCommitSettlement {
pub(crate) command: AuthorizationCommit,
pub(crate) credential_guard: Option<Box<dyn CredentialCommitGuard>>,
pub(crate) authorization_guard: Option<Box<dyn AuthorizationCommitGuard>>,
}
impl PreparedAuthorizationCommit {
#[must_use]
pub fn new(
pending: PendingAuthorizationMutation,
credential: PreparedAuthorizationCredential,
) -> Self {
Self {
pending,
credential,
authorization_guard: None,
}
}
/// 让 OAuth claim 覆盖准备完成到 Store 事务结算之间的全部窗口。
#[must_use]
pub fn with_authorization_guard(mut self, guard: Box<dyn AuthorizationCommitGuard>) -> Self {
self.authorization_guard = Some(guard);
self
}
pub(crate) fn into_commit(self) -> AuthorizationCommitSettlement {
let (credential, guard) = match self.credential {
PreparedAuthorizationCredential::Create(credential) => {
(AuthorizationCredentialCommit::Create(credential), None)
}
PreparedAuthorizationCredential::Reauthorize(prepared) => {
let (facts, guard) = prepared.into_parts();
(
AuthorizationCredentialCommit::Reauthorize(facts),
Some(guard),
)
}
};
AuthorizationCommitSettlement {
command: AuthorizationCommit {
pending: self.pending,
credential,
},
credential_guard: guard,
authorization_guard: self.authorization_guard,
}
}
pub(crate) async fn abort(self) -> Result<(), AdminError> {
let AuthorizationCommitSettlement {
credential_guard,
authorization_guard,
..
} = self.into_commit();
drop(credential_guard);
if let Some(guard) = authorization_guard {
guard.abort().await?;
}
Ok(())
}
}
/// 完成 Provider OAuth 流程。
#[derive(Clone, PartialEq, Eq)]
pub struct CompleteAuthorization {
pub context: MutationContext,
pub flow_id: String,
pub callback_url: String,
}
impl fmt::Debug for CompleteAuthorization {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CompleteAuthorization")
.field("context", &self.context)
.field("flow_id", &"[REDACTED]")
.field("callback_url", &"[REDACTED]")
.finish()
}
}
/// Credential 生命周期写操作。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialMutation {
pub context: MutationContext,
pub account_id: ProviderAccountId,
}
/// Credential 写入提交结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialMutationResult {
pub config_revision: Revision,
pub account_id: ProviderAccountId,
pub credential_revision: Option<Revision>,
}
/// 同一 Provider 管理范围内的 credential 批量删除。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialDeletion {
pub context: MutationContext,
pub account_ids: Vec<ProviderAccountId>,
}
/// Credential 批量删除提交结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialDeletionResult {
pub config_revision: Revision,
pub account_ids: Vec<ProviderAccountId>,
}
/// Provider-owned token 轮换命令。
pub struct RotateCredential {
pub mutation: CredentialMutation,
pub provider_material: ProviderDocument,
}
/// Provider 校验手工轮换材料时所需的非事务输入。
pub struct PrepareCredentialRotation {
pub account: AccountRecord,
pub provider_material: ProviderDocument,
}
impl fmt::Debug for PrepareCredentialRotation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PrepareCredentialRotation")
.field("account", &self.account)
.field("provider_material", &self.provider_material)
.finish()
}
}
/// Provider 已验证、可由 Store 以 credential revision CAS 原子提交的轮换 facts。
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialRotationFacts {
pub account_id: ProviderAccountId,
pub provider_kind: ProviderKind,
pub expected_credential_revision: Revision,
pub replacement_identity: Option<ProviderAccountIdentity>,
pub name: String,
pub email: Option<String>,
pub plan_type: Option<String>,
pub provider_material: ProviderDocument,
pub has_refresh_token: bool,
pub access_token_expires_at: Option<DateTime<Utc>>,
pub next_refresh_at: Option<DateTime<Utc>>,
}
/// Provider 返回的轮换准备结果;guard 必须覆盖后续 Store CAS 与审计事务。
pub struct PreparedCredentialRotation {
facts: PreparedCredentialRotationFacts,
guard: Box<dyn CredentialCommitGuard>,
}
impl PreparedCredentialRotation {
#[must_use]
pub fn new(
facts: PreparedCredentialRotationFacts,
guard: Box<dyn CredentialCommitGuard>,
) -> Self {
Self { facts, guard }
}
#[must_use]
pub const fn facts(&self) -> &PreparedCredentialRotationFacts {
&self.facts
}
#[must_use]
pub fn into_parts(
self,
) -> (
PreparedCredentialRotationFacts,
Box<dyn CredentialCommitGuard>,
) {
(self.facts, self.guard)
}
}
impl fmt::Debug for PreparedCredentialRotation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PreparedCredentialRotation")
.field("facts", &self.facts)
.field("guard", &"[COMPLETION-GUARD]")
.finish()
}
}
/// Admin 交给 Store 的轮换或 refresh 事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialRotationCommit {
pub prepared: PreparedCredentialRotationFacts,
}
impl fmt::Debug for RotateCredential {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RotateCredential")
.field("mutation", &self.mutation)
.field("provider_material", &self.provider_material)
.finish()
}
}
/// 一个 Provider quota 窗口的公共投影。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuotaWindow {
pub key: String,
pub group: String,
pub label: String,
pub source: Option<String>,
pub window_seconds: Option<u64>,
pub used_percent: Option<f64>,
pub reset_at: Option<DateTime<Utc>>,
pub local_usage: Option<AccountUsage>,
pub provider_data: Option<ProviderDocument>,
}
/// Provider 解释 quota 所需的公共请求事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderQuotaRequest {
pub account_id: ProviderAccountId,
pub refresh: bool,
pub rolling_usage: Option<AccountUsage>,
}
/// Provider 已解析的 quota 结果及其不透明差异字段。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuota {
pub observed_at: Option<DateTime<Utc>>,
pub refresh_token_expires_at: Option<DateTime<Utc>>,
pub windows: Vec<ProviderQuotaWindow>,
pub provider_data: Option<ProviderDocument>,
}
impl ProviderQuota {
/// 返回 Dashboard 使用的代表性额度比例。
///
/// 优先使用短周期窗口,再依次使用周、月和其它窗口;同一优先级取较高的已用比例。
/// 这里只解释跨 Provider 共享的窗口语义,绝不读取 Provider 私有 JSON。
#[must_use]
pub fn representative_used_percent(&self) -> Option<f64> {
self.windows
.iter()
.filter_map(|window| {
let used_percent = window
.used_percent
.filter(|value| value.is_finite())
.map(|value| value.clamp(0.0, 100.0))?;
Some((quota_usage_priority(window), used_percent))
})
.fold(None::<(u8, f64)>, |selected, candidate| match selected {
Some(current)
if current.0 < candidate.0
|| (current.0 == candidate.0 && current.1 >= candidate.1) =>
{
Some(current)
}
_ => Some(candidate),
})
.map(|(_, used_percent)| used_percent)
}
}
fn quota_usage_priority(window: &ProviderQuotaWindow) -> u8 {
match window.group.as_str() {
"shortTerm" if window.window_seconds.is_some_and(is_week_window) => 1,
"shortTerm" => 0,
"monthly" => 2,
_ => 3,
}
}
fn is_week_window(seconds: u64) -> bool {
const WEEK_SECONDS: u64 = 7 * 24 * 60 * 60;
seconds > 0 && seconds.abs_diff(WEEK_SECONDS) <= WEEK_SECONDS / 20
}
/// Provider 实时模型目录的一项。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModel {
pub id: UpstreamModelId,
pub name: String,
}
/// Provider 实时模型目录。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModels {
pub models: Vec<ProviderModel>,
pub observed_at: Option<DateTime<Utc>>,
}
/// Provider 执行 refresh 时所需的当前公共账号事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrepareCredentialRefresh {
pub account: AccountRecord,
}
/// Provider 敏感导出结果。
pub struct ProviderExport {
pub provider_kind: ProviderKind,
pub account_ids: Vec<ProviderAccountId>,
pub document: ProviderDocument,
}
/// Store 为 Provider 导出序列化准备的最小输入;material 对公共层保持不透明。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderExportCredentialInput {
pub account: AccountRecord,
pub provider_material: ProviderDocument,
}
impl fmt::Debug for ProviderExport {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ProviderExport")
.field("provider_kind", &self.provider_kind)
.field("account_ids", &self.account_ids)
.field("document", &self.document)
.finish()
}
}
/// 统一账号目录的一行完整结果。
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryItem {
pub account: AccountRecord,
pub status: AccountStatus,
pub usage: Option<super::accounts::AccountUsage>,
pub quota: ProviderQuota,
}
/// 统一账号目录页。
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryPage {
pub config_revision: Revision,
pub items: Vec<AccountDirectoryItem>,
pub total: u64,
pub summary: AccountSummary,
}
/// 凭据刷新提交后的完整账号结果。
#[derive(Debug, Clone, PartialEq)]
pub struct AccountRefreshResult {
pub config_revision: Revision,
pub account: AccountDirectoryItem,
}
/// 多 Provider 导出文档集合。
pub struct AccountExportBundle {
pub exported_at: DateTime<Utc>,
pub documents: Vec<ProviderExport>,
}
impl fmt::Debug for AccountExportBundle {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AccountExportBundle")
.field("exported_at", &self.exported_at)
.field("document_count", &self.documents.len())
.finish()
}
}
@@ -0,0 +1,102 @@
//! Runtime settings 与明文管理员 API Key 的语义模型。
use std::{collections::BTreeMap, fmt};
use chrono::{DateTime, Utc};
use gateway_core::routing::{PublicModelId, UpstreamModelId};
use super::Revision;
/// 客户端模型到上游模型的全局精确映射。
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
/// 账号调度策略。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RotationStrategy {
Smart,
QuotaResetPriority,
RoundRobin,
Sticky,
}
/// 完整运行设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeSettings {
pub config_revision: Revision,
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 rotation_strategy: RotationStrategy,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub updated_at: DateTime<Utc>,
}
/// 原子替换运行设置的命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplaceRuntimeSettings {
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 rotation_strategy: RotationStrategy,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
}
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
#[derive(Clone, PartialEq, Eq)]
pub struct AdminApiKey(String);
impl AdminApiKey {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn expose_for_auth(&self) -> &str {
&self.0
}
/// 仅供显式 regenerate 响应读取一次。
#[must_use]
pub fn expose_for_response(&self) -> &str {
&self.0
}
}
impl fmt::Debug for AdminApiKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("AdminApiKey([REDACTED])")
}
}
/// 管理员 API Key 更新结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AdminApiKeyMutation {
pub config_revision: Revision,
pub exists: bool,
}
/// 新管理员 API Key 的一次性返回结果。
pub struct RegeneratedAdminApiKey {
pub mutation: AdminApiKeyMutation,
pub key: AdminApiKey,
}
impl fmt::Debug for RegeneratedAdminApiKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RegeneratedAdminApiKey")
.field("mutation", &self.mutation)
.field("key", &"[REDACTED]")
.finish()
}
}
@@ -0,0 +1,129 @@
//! 版本、自更新、回滚与进程重启的 UTC 语义模型。
use chrono::{DateTime, Utc};
/// 当前运行版本及更新检查摘要。
///
/// `deployment_mode_label` 之类的本地化字符串由 API 根据原始枚举值生成。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemVersion {
pub version: String,
pub git_sha: String,
pub build_time: String,
pub deployment_mode: String,
pub update_channel: String,
pub latest_version: String,
pub has_update: bool,
pub update_cached: bool,
pub update_warning: Option<String>,
}
/// 发布源提供的完整更新详情。
///
/// 部署模式与构建类型的展示标签归 API;其余字段均为 Host 已确认的原始事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemUpdateDetail {
pub current_version: String,
pub latest_version: String,
pub has_update: bool,
pub deployment_mode: String,
pub build_type: String,
pub release_url: Option<String>,
pub notes: Option<String>,
pub cached: bool,
pub update_supported: bool,
pub unsupported_reason: Option<String>,
pub warning: Option<String>,
}
/// 系统操作类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemOperationKind {
Update,
Rollback,
Restart,
}
/// 系统操作状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemOperationStatus {
Idle,
Running,
Succeeded,
Failed,
}
/// 持久化系统操作的完整状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemOperationState {
pub operation_id: Option<String>,
pub kind: Option<SystemOperationKind>,
pub status: SystemOperationStatus,
pub target_version: Option<String>,
pub message: Option<String>,
pub error: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
}
/// 最近一次系统操作及版本交换状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemUpdateStatus {
pub previous_version: Option<String>,
pub current_version: Option<String>,
pub operation: SystemOperationState,
}
/// 自更新日志级别。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemUpdateEventLevel {
Info,
Warning,
Success,
Error,
}
/// 一条已脱敏的系统更新事件。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemUpdateEvent {
pub id: String,
pub operation_id: Option<String>,
pub level: SystemUpdateEventLevel,
pub step: Option<String>,
pub message: String,
pub terminal: bool,
pub progress_percent: Option<u8>,
pub occurred_at: DateTime<Utc>,
}
/// 已受理的异步系统操作;不同 wire shape 由枚举保持为合法状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SystemOperationAccepted {
Update {
operation_id: String,
deployment_mode: String,
message: String,
need_restart: bool,
target_version: String,
},
Rollback {
operation_id: String,
message: String,
need_restart: bool,
},
Restart {
operation_id: String,
message: String,
},
}
impl SystemOperationAccepted {
#[must_use]
pub const fn kind(&self) -> SystemOperationKind {
match self {
Self::Update { .. } => SystemOperationKind::Update,
Self::Rollback { .. } => SystemOperationKind::Rollback,
Self::Restart { .. } => SystemOperationKind::Restart,
}
}
}
@@ -0,0 +1,5 @@
//! 管理用例依赖的外部能力端口。
pub mod provider;
pub mod store;
pub mod system;
@@ -0,0 +1,184 @@
//! Provider 管理能力与动态注册表。
use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
use gateway_core::{
engine::credential::ProviderAccountId,
operation::Operation,
routing::{ProviderKind, UpstreamModelId},
};
use crate::model::observability::{
CalculatedBillingBreakdown, DashboardWireProfile, ProviderBillingInput,
};
use crate::model::provider_credentials::{
AuthorizationStarted, CompleteAuthorization, PendingAuthorizationMutation,
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
ProviderQuotaRequest,
};
/// Provider 管理失败的稳定分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderAdminErrorKind {
Invalid,
Unsupported,
NotFound,
Conflict,
Unavailable,
Internal,
}
/// 不泄漏 OAuth material 与 Provider 响应体的管理错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("provider admin operation failed: {kind:?}")]
pub struct ProviderAdminError {
kind: ProviderAdminErrorKind,
}
impl ProviderAdminError {
#[must_use]
pub const fn new(kind: ProviderAdminErrorKind) -> Self {
Self { kind }
}
#[must_use]
pub const fn kind(&self) -> ProviderAdminErrorKind {
self.kind
}
}
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力。
///
/// 数据变更由 Provider 返回 prepared facts;config revision、审计与 PostgreSQL 事务
/// 全部由 [`crate::ports::store::AccountStore`] 提交。运行时资源通知只在事务成功后发生。
#[async_trait]
pub trait ProviderAdmin: Send + Sync {
fn provider_kind(&self) -> &ProviderKind;
/// 账号已经由控制面提交为不可调度状态,释放 Provider 持有的账号级运行时资源。
///
/// 无账号级运行时资源的 Provider 不需要执行额外操作。该通知发生在 Store 事务
/// 成功之后,不参与事务成败,也不得恢复或改写已经提交的账号状态。
async fn account_unavailable(&self, account_id: &ProviderAccountId);
/// 账号资格事实已经由控制面提交,失效 Provider 持有的可重建派生状态。
///
/// 该通知发生在 Store 事务成功之后、下一份 RuntimeSnapshot 编译之前;通知
/// 不参与已提交事务成败。没有账号派生状态的 Provider 可使用默认空实现。
async fn account_facts_changed(&self, _account_ids: &[ProviderAccountId]) {}
/// 生成一次连接测试所需的 Provider-owned operation;Core 负责实际执行与落账。
fn connection_test_operation(
&self,
upstream_model: &UpstreamModelId,
input_text: &str,
) -> Result<Operation, ProviderAdminError>;
/// 返回该 Provider 实际持有的 Dashboard 上游身份画像。
fn dashboard_wire_profile(&self) -> Option<DashboardWireProfile>;
/// 使用 Provider-owned 价格规则恢复持久请求的逐项费用。
fn calculated_billing(
&self,
input: &ProviderBillingInput,
) -> Result<Option<CalculatedBillingBreakdown>, ProviderAdminError>;
async fn prepare_import(
&self,
command: PrepareCredentialImport,
) -> Result<PreparedCredentialImport, ProviderAdminError>;
async fn start_authorization(
&self,
pending: PendingAuthorizationMutation,
) -> Result<AuthorizationStarted, ProviderAdminError>;
async fn complete_authorization(
&self,
command: CompleteAuthorization,
) -> Result<PreparedAuthorizationCommit, ProviderAdminError>;
async fn prepare_rotation(
&self,
command: PrepareCredentialRotation,
) -> Result<PreparedCredentialRotation, ProviderAdminError>;
async fn prepare_refresh(
&self,
command: PrepareCredentialRefresh,
) -> Result<PreparedCredentialRotation, ProviderAdminError>;
async fn quota(
&self,
request: ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError>;
async fn models(
&self,
account_id: &ProviderAccountId,
refresh: bool,
) -> Result<ProviderModels, ProviderAdminError>;
async fn export_credentials(
&self,
credentials: Vec<ProviderExportCredentialInput>,
) -> Result<ProviderExport, ProviderAdminError>;
}
/// 按 ProviderKind 动态发现管理能力;不含具体 Provider 分支。
#[derive(Clone)]
pub struct ProviderAdminRegistry {
providers: Arc<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
}
impl ProviderAdminRegistry {
/// 创建无重复 ProviderKind 的注册表。
///
/// # Errors
///
/// 重复注册同一 ProviderKind 时返回 Conflict。
pub fn new(
providers: impl IntoIterator<Item = Arc<dyn ProviderAdmin>>,
) -> Result<Self, ProviderAdminError> {
let mut registered = BTreeMap::new();
for provider in providers {
let kind = provider.provider_kind().clone();
if registered.insert(kind, provider).is_some() {
return Err(ProviderAdminError::new(ProviderAdminErrorKind::Conflict));
}
}
Ok(Self {
providers: Arc::new(registered),
})
}
pub fn require(
&self,
provider_kind: &ProviderKind,
) -> Result<Arc<dyn ProviderAdmin>, ProviderAdminError> {
self.providers
.get(provider_kind)
.cloned()
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
pub fn dashboard_wire_profiles(&self) -> Vec<DashboardWireProfile> {
self.providers
.values()
.filter_map(|provider| provider.dashboard_wire_profile())
.collect()
}
/// 动态分派 Provider-owned 费用规则,不含任何具体 Provider 分支。
pub fn calculated_billing(
&self,
provider_kind: &ProviderKind,
input: &ProviderBillingInput,
) -> Result<Option<CalculatedBillingBreakdown>, ProviderAdminError> {
self.require(provider_kind)?.calculated_billing(input)
}
}
@@ -0,0 +1,347 @@
//! 管理控制面所需的持久化能力。
//!
//! 端口按业务资源拆分,方法使用领域模型,不暴露连接池、事务或 Redis client。
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use crate::model::{
MutationContext, Revision,
accounts::{
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
},
auth::{AdminAuditEvent, AdminSession},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
},
observability::{
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
},
provider_credentials::{
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
CredentialListQuery, CredentialMutationResult, CredentialPage, CredentialRotationCommit,
ProviderExportCredentialInput,
},
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
};
/// 管理端可判定的持久化失败类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdminStoreErrorKind {
Invalid,
NotFound,
StaleRevision,
Conflict,
Unavailable,
}
/// 隐藏数据库实现细节的持久化错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{resource} store operation failed: {message}")]
pub struct AdminStoreError {
kind: AdminStoreErrorKind,
resource: &'static str,
message: String,
}
impl AdminStoreError {
#[must_use]
pub fn new(
kind: AdminStoreErrorKind,
resource: &'static str,
message: impl Into<String>,
) -> Self {
Self {
kind,
resource,
message: message.into(),
}
}
#[must_use]
pub const fn kind(&self) -> AdminStoreErrorKind {
self.kind
}
#[must_use]
pub const fn resource(&self) -> &'static str {
self.resource
}
}
pub type AdminStoreResult<T> = Result<T, AdminStoreError>;
/// 账号目录与公共账号写操作。
#[async_trait]
pub trait AccountStore: Send + Sync {
async fn list_accounts(&self, query: AccountListQuery) -> AdminStoreResult<AccountPage>;
async fn load_account(&self, account_id: &str) -> AdminStoreResult<Option<AccountRecord>>;
async fn load_account_usage(
&self,
range: TimeRange,
account_ids: &[String],
) -> AdminStoreResult<Vec<AccountUsage>>;
async fn load_account_usage_by_windows(
&self,
windows: &[AccountUsageWindowQuery],
) -> AdminStoreResult<Vec<AccountUsageWindowResult>>;
async fn list_credentials(
&self,
provider_kind: &gateway_core::routing::ProviderKind,
query: CredentialListQuery,
) -> AdminStoreResult<CredentialPage>;
async fn credential_details(
&self,
provider_kind: &gateway_core::routing::ProviderKind,
account_id: &gateway_core::engine::credential::ProviderAccountId,
) -> AdminStoreResult<Option<CredentialDetails>>;
async fn load_credentials_for_export(
&self,
provider_kind: &gateway_core::routing::ProviderKind,
account_ids: &[gateway_core::engine::credential::ProviderAccountId],
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>>;
async fn commit_credential_import(
&self,
command: CredentialImportCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialImportResult>;
async fn commit_authorization(
&self,
command: AuthorizationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult>;
async fn commit_credential_rotation(
&self,
command: CredentialRotationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult>;
async fn commit_credential_refresh(
&self,
command: CredentialRotationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult>;
async fn set_account_enabled(
&self,
command: SetAccountEnabled,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn delete_accounts(
&self,
command: DeleteAccounts,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn record_credential_export(
&self,
account_ids: &[gateway_core::engine::credential::ProviderAccountId],
context: &MutationContext,
) -> AdminStoreResult<()>;
}
/// 管理员密码、会话和安全审计。
#[async_trait]
pub trait AuthStore: Send + Sync {
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
async fn create_password_hash_if_absent(
&self,
admin_user_id: &str,
password_hash: &str,
) -> AdminStoreResult<bool>;
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>>;
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
async fn store_session(&self, session_id: &str, session: &AdminSession)
-> AdminStoreResult<()>;
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
}
/// Client API Key 管理写入。
#[async_trait]
pub trait ClientKeyStore: Send + Sync {
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage>;
async fn reveal_client_key(
&self,
id: &gateway_core::policy::ClientApiKeyId,
) -> AdminStoreResult<Option<ClientKeySecret>>;
async fn create_client_key(
&self,
command: NewClientKey,
context: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)>;
async fn update_client_key(
&self,
command: UpdateClientKey,
context: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)>;
async fn set_client_key_enabled(
&self,
command: SetClientKeyEnabled,
context: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)>;
async fn delete_client_key(
&self,
command: DeleteClientKey,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
}
/// 用量、趋势、诊断与运维错误的只读能力。
#[async_trait]
pub trait ObservabilityStore: Send + Sync {
async fn dashboard_summary(&self, range: TimeRange) -> AdminStoreResult<DashboardObservation>;
/// 返回 Dashboard 可选的实时槽位事实。
///
/// 该状态来自可丢失的运行时存储;无实现或运行时存储不可用时返回 `None`,不影响
/// 持久观测数据的读取。
async fn dashboard_runtime_slots(
&self,
_observed_at: DateTime<Utc>,
) -> AdminStoreResult<Option<DashboardRuntimeSlots>> {
Ok(None)
}
async fn dashboard_trend(&self, range: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>>;
async fn usage_trend(
&self,
range: TimeRange,
filter: UsageFilter,
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
/// 返回可由 Provider 重新校验的已计算费用事实,用于恢复标准费用趋势。
async fn usage_calculated_billing_facts(
&self,
range: TimeRange,
filter: UsageFilter,
) -> AdminStoreResult<Vec<UsageCalculatedBillingFact>>;
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage>;
async fn usage_record_detail(&self, request_id: &str) -> AdminStoreResult<UsageDetail>;
async fn usage_summary(
&self,
range: TimeRange,
filter: UsageFilter,
) -> AdminStoreResult<UsageOverview>;
async fn usage_diagnostics(
&self,
range: TimeRange,
filter: UsageFilter,
dimension: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>>;
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
}
/// Runtime settings 与管理员 API Key 写入。
#[async_trait]
pub trait SettingsStore: Send + Sync {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings>;
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool>;
async fn replace_runtime_settings(
&self,
command: ReplaceRuntimeSettings,
context: &MutationContext,
) -> AdminStoreResult<RuntimeSettings>;
async fn replace_admin_api_key(
&self,
key: AdminApiKey,
context: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation>;
async fn delete_admin_api_key(
&self,
context: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation>;
}
/// 管理用例所需能力的封闭集合。
///
/// 字段保持私有,每个 getter 只交出一种明确能力。该类型不提供通用拆包入口。
#[derive(Clone)]
pub struct AdminStorePorts {
accounts: Arc<dyn AccountStore>,
auth: Arc<dyn AuthStore>,
client_keys: Arc<dyn ClientKeyStore>,
observability: Arc<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
}
impl AdminStorePorts {
#[must_use]
pub fn new(
accounts: Arc<dyn AccountStore>,
auth: Arc<dyn AuthStore>,
client_keys: Arc<dyn ClientKeyStore>,
observability: Arc<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
) -> Self {
Self {
accounts,
auth,
client_keys,
observability,
settings,
}
}
#[must_use]
pub fn accounts(&self) -> Arc<dyn AccountStore> {
self.accounts.clone()
}
#[must_use]
pub fn auth(&self) -> Arc<dyn AuthStore> {
self.auth.clone()
}
#[must_use]
pub fn client_keys(&self) -> Arc<dyn ClientKeyStore> {
self.client_keys.clone()
}
#[must_use]
pub fn observability(&self) -> Arc<dyn ObservabilityStore> {
self.observability.clone()
}
#[must_use]
pub fn settings(&self) -> Arc<dyn SettingsStore> {
self.settings.clone()
}
}
@@ -0,0 +1,76 @@
//! Host 为系统管理用例提供的进程与操作系统能力。
use std::pin::Pin;
use async_trait::async_trait;
use futures::Stream;
use crate::model::system::{
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateStatus,
SystemVersion,
};
/// Host 系统操作失败类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemOperationErrorKind {
Invalid,
Conflict,
Upstream,
Internal,
}
/// 不泄漏路径、命令行或发布凭据的系统操作错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("system operation failed: {message}")]
pub struct SystemOperationError {
kind: SystemOperationErrorKind,
message: String,
}
impl SystemOperationError {
#[must_use]
pub fn new(kind: SystemOperationErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
#[must_use]
pub const fn kind(&self) -> SystemOperationErrorKind {
self.kind
}
/// 返回 Host 已完成脱敏的客户端安全消息。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
/// 每个订阅者独占的系统事件流。
pub type SystemUpdateEventStream = Pin<Box<dyn Stream<Item = SystemUpdateEvent> + Send + 'static>>;
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。
#[async_trait]
pub trait SystemOperations: Send + Sync {
async fn version(&self) -> Result<SystemVersion, SystemOperationError>;
async fn update_detail(
&self,
refresh: bool,
) -> Result<SystemUpdateDetail, SystemOperationError>;
fn update_events(&self) -> SystemUpdateEventStream;
async fn perform_update(
&self,
target_version: Option<String>,
) -> Result<SystemOperationAccepted, SystemOperationError>;
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError>;
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
}
@@ -0,0 +1,539 @@
//! 统一账号目录与跨 Provider 动态分派。
use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use futures::StreamExt as _;
use gateway_core::{
engine::{
credential::ProviderAccountId,
probe::{AccountProbe, AccountProbeRequest},
},
routing::{ProviderKind, UpstreamModelId, snapshot::SnapshotControl},
};
use crate::{
model::{
AdminError, MutationContext,
accounts::{
AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery,
AccountRecord, AccountStatus, AccountUsageWindowQuery,
},
observability::TimeRange,
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
PrepareCredentialRefresh, ProviderModels, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow,
},
},
ports::{
provider::ProviderAdminRegistry,
store::{AccountStore, SettingsStore},
},
};
use super::{
commit_credential_refresh, map_provider_error, map_store_error, publish_committed,
validate_prepared_rotation,
};
const CONNECTION_TEST_INPUT: &str = "Reply with exactly OK.";
/// 统一账号页消费的服务。
#[async_trait]
pub trait AccountsService: Send + Sync {
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError>;
async fn export(
&self,
context: &MutationContext,
account_ids: Vec<ProviderAccountId>,
) -> Result<AccountExportBundle, AdminError>;
async fn refresh(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<AccountRefreshResult, AdminError>;
async fn quota(
&self,
account_id: &ProviderAccountId,
refresh: bool,
) -> Result<AccountDirectoryItem, AdminError>;
async fn models(
&self,
account_id: &ProviderAccountId,
refresh: bool,
) -> Result<ProviderModels, AdminError>;
async fn test_connection(
&self,
account_id: ProviderAccountId,
upstream_model: UpstreamModelId,
) -> Result<AccountConnectionTestEventStream, AdminError>;
}
pub(crate) struct DefaultAccountsService {
accounts: Arc<dyn AccountStore>,
settings: Arc<dyn SettingsStore>,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
}
impl DefaultAccountsService {
#[must_use]
pub(crate) const fn new(
accounts: Arc<dyn AccountStore>,
settings: Arc<dyn SettingsStore>,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
) -> Self {
Self {
accounts,
settings,
providers,
snapshot,
probe,
}
}
async fn load_account(
&self,
account_id: &ProviderAccountId,
) -> Result<AccountRecord, AdminError> {
self.accounts
.load_account(account_id.as_str())
.await
.map_err(|error| map_store_error(error, "provider account"))?
.ok_or_else(|| AdminError::not_found("Provider account was not found"))
}
async fn provider_for_account(
&self,
account_id: &ProviderAccountId,
) -> Result<
(
AccountRecord,
Arc<dyn crate::ports::provider::ProviderAdmin>,
),
AdminError,
> {
let account = self.load_account(account_id).await?;
let provider = self
.providers
.require(&account.provider_kind)
.map_err(|error| map_provider_error(error, "provider account"))?;
Ok((account, provider))
}
async fn attach_quota_local_usage(
&self,
accounts: &[AccountRecord],
quotas: &mut [ProviderQuota],
) -> Result<(), AdminError> {
let windows = accounts
.iter()
.zip(quotas.iter())
.flat_map(|(account, quota)| {
quota
.windows
.iter()
.filter_map(|window| quota_usage_window(&account.id, window))
})
.collect::<Vec<_>>();
if windows.is_empty() {
return Ok(());
}
let usage_by_window = self
.accounts
.load_account_usage_by_windows(&windows)
.await
.map_err(|error| map_store_error(error, "quota window usage"))?
.into_iter()
.map(|result| ((result.account_id, result.key), result.usage))
.collect::<BTreeMap<_, _>>();
for (account, quota) in accounts.iter().zip(quotas) {
for window in &mut quota.windows {
if window.local_usage.is_none() {
let key = (account.id.clone(), window.key.clone());
if let Some(usage) = usage_by_window.get(&key) {
window.local_usage = Some(usage.clone());
}
}
}
}
Ok(())
}
async fn load_directory_item(
&self,
account_id: &ProviderAccountId,
refresh_quota: bool,
) -> Result<AccountDirectoryItem, AdminError> {
let (account, provider) = self.provider_for_account(account_id).await?;
let settings = self
.settings
.load_runtime_settings()
.await
.map_err(|error| map_store_error(error, "runtime settings"))?;
let now = Utc::now();
let retained_range = retained_usage_range(now, settings.usage_retention_days);
let rolling_range = TimeRange {
start: now - Duration::hours(24),
end: now,
};
let ids = vec![account.id.clone()];
let (usage, rolling_usage) = futures::try_join!(
async {
self.accounts
.load_account_usage(retained_range, &ids)
.await
.map_err(|error| map_store_error(error, "account usage"))
},
async {
self.accounts
.load_account_usage(rolling_range, &ids)
.await
.map_err(|error| map_store_error(error, "rolling account usage"))
},
)?;
let rolling_usage = rolling_usage.into_iter().next();
let mut quota = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: refresh_quota,
rolling_usage: rolling_usage.clone(),
})
.await
.map_err(|error| map_provider_error(error, "provider quota"))?;
self.attach_quota_local_usage(
std::slice::from_ref(&account),
std::slice::from_mut(&mut quota),
)
.await?;
Ok(AccountDirectoryItem {
status: account_status(&account, now),
usage: usage.into_iter().next(),
account,
quota,
})
}
}
#[async_trait]
impl AccountsService for DefaultAccountsService {
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError> {
let (page, settings) = futures::try_join!(
self.accounts.list_accounts(query),
self.settings.load_runtime_settings(),
)
.map_err(|error| map_store_error(error, "account directory"))?;
let now = Utc::now();
let retained_range = retained_usage_range(now, settings.usage_retention_days);
let rolling_range = TimeRange {
start: now - Duration::hours(24),
end: now,
};
let ids = page
.items
.iter()
.map(|account| account.id.clone())
.collect::<Vec<_>>();
let (usage, rolling_usage) = futures::try_join!(
self.accounts.load_account_usage(retained_range, &ids),
self.accounts.load_account_usage(rolling_range, &ids),
)
.map_err(|error| map_store_error(error, "account usage"))?;
let usage = usage
.into_iter()
.map(|usage| (usage.account_id.clone(), usage))
.collect::<BTreeMap<_, _>>();
let rolling_usage = rolling_usage
.into_iter()
.map(|usage| (usage.account_id.clone(), usage))
.collect::<BTreeMap<_, _>>();
let mut quotas = futures::future::join_all(page.items.iter().map(|account| async {
let account_id = ProviderAccountId::new(account.id.clone())
.map_err(|_| AdminError::invalid("Invalid provider account ID"))?;
let provider = self
.providers
.require(&account.provider_kind)
.map_err(|error| map_provider_error(error, "provider quota"))?;
provider
.quota(ProviderQuotaRequest {
account_id,
refresh: false,
rolling_usage: rolling_usage.get(&account.id).cloned(),
})
.await
.map_err(|error| map_provider_error(error, "provider quota"))
}))
.await
.into_iter()
.collect::<Result<Vec<_>, AdminError>>()?;
self.attach_quota_local_usage(&page.items, &mut quotas)
.await?;
let items = page
.items
.into_iter()
.zip(quotas)
.map(|(account, quota)| AccountDirectoryItem {
status: account_status(&account, now),
usage: usage.get(&account.id).cloned(),
account,
quota,
})
.collect();
Ok(AccountDirectoryPage {
config_revision: page.config_revision,
items,
total: page.total,
summary: page.summary,
})
}
async fn export(
&self,
context: &MutationContext,
account_ids: Vec<ProviderAccountId>,
) -> Result<AccountExportBundle, AdminError> {
if account_ids.is_empty() || account_ids.len() > 200 {
return Err(AdminError::invalid(
"Account export requires between 1 and 200 accounts",
));
}
let exported_ids = account_ids.clone();
let mut grouped = BTreeMap::<ProviderKind, Vec<ProviderAccountId>>::new();
for account_id in account_ids {
let account = self.load_account(&account_id).await?;
grouped
.entry(account.provider_kind)
.or_default()
.push(account_id);
}
if grouped.values().any(|ids| {
let unique = ids.iter().collect::<std::collections::BTreeSet<_>>();
unique.len() != ids.len()
}) {
return Err(AdminError::invalid("Account export contains duplicate IDs"));
}
let mut documents = Vec::with_capacity(grouped.len());
for (provider_kind, ids) in grouped {
let provider = self
.providers
.require(&provider_kind)
.map_err(|error| map_provider_error(error, "provider account export"))?;
let credentials = self
.accounts
.load_credentials_for_export(&provider_kind, &ids)
.await
.map_err(|error| map_store_error(error, "provider account export"))?;
documents.push(
provider
.export_credentials(credentials)
.await
.map_err(|error| map_provider_error(error, "provider account export"))?,
);
}
self.accounts
.record_credential_export(&exported_ids, context)
.await
.map_err(|error| map_store_error(error, "provider account export audit"))?;
Ok(AccountExportBundle {
exported_at: Utc::now(),
documents,
})
}
async fn refresh(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<AccountRefreshResult, AdminError> {
let (account, provider) = self.provider_for_account(&account_id).await?;
let prepared = provider
.prepare_refresh(PrepareCredentialRefresh {
account: account.clone(),
})
.await
.map_err(|error| map_provider_error(error, "provider credential refresh"))?;
validate_prepared_rotation(&account, &prepared, "provider credential refresh")?;
let result = commit_credential_refresh(
self.accounts.as_ref(),
prepared,
context,
"provider credential refresh",
)
.await?;
provider
.account_facts_changed(std::slice::from_ref(&result.account_id))
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
let account = self.load_directory_item(&result.account_id, false).await?;
Ok(AccountRefreshResult {
config_revision: result.config_revision,
account,
})
}
async fn quota(
&self,
account_id: &ProviderAccountId,
refresh: bool,
) -> Result<AccountDirectoryItem, AdminError> {
self.load_directory_item(account_id, refresh).await
}
async fn models(
&self,
account_id: &ProviderAccountId,
refresh: bool,
) -> Result<ProviderModels, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.models(account_id, refresh)
.await
.map_err(|error| map_provider_error(error, "provider model catalog"))
}
async fn test_connection(
&self,
account_id: ProviderAccountId,
upstream_model: UpstreamModelId,
) -> Result<AccountConnectionTestEventStream, AdminError> {
let (account, provider) = self.provider_for_account(&account_id).await?;
let initial_status = account_status(&account, Utc::now());
let model = upstream_model.as_str().to_owned();
let operation = provider
.connection_test_operation(&upstream_model, CONNECTION_TEST_INPUT)
.map_err(|error| map_provider_error(error, "provider connection test"))?;
let initial = vec![
AccountConnectionTestEvent::Started {
model: model.clone(),
},
AccountConnectionTestEvent::Request {
model,
input_text: CONNECTION_TEST_INPUT.to_owned(),
stream: true,
store: false,
},
];
let probe = Arc::clone(&self.probe);
let accounts = Arc::clone(&self.accounts);
let stored_account_id = account.id.clone();
let terminal = futures::stream::once(async move {
let result = probe
.probe(AccountProbeRequest {
account_id,
provider_kind: account.provider_kind,
upstream_model,
operation,
})
.await;
let status = accounts
.load_account(&stored_account_id)
.await
.ok()
.flatten()
.map_or(initial_status, |account| {
account_status(&account, Utc::now())
});
match result {
Ok(result) => result
.text
.into_iter()
.map(|text| AccountConnectionTestEvent::Content { text })
.chain(std::iter::once(AccountConnectionTestEvent::Completed {
account_status: status,
}))
.collect(),
Err(error) => {
let upstream_status = error
.upstream_response()
.map(gateway_core::engine::probe::AccountProbeUpstreamResponse::status);
let upstream_content_type = error
.upstream_response()
.and_then(|response| response.content_type())
.and_then(|value| std::str::from_utf8(value).ok())
.map(ToOwned::to_owned);
let upstream_body = error
.upstream_response()
.map(|response| String::from_utf8_lossy(response.body()).into_owned());
let message = upstream_body
.as_deref()
.filter(|body| !body.is_empty())
.unwrap_or_else(|| error.client_message())
.to_owned();
vec![AccountConnectionTestEvent::Failed {
message,
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
provider_error_type: error.client_error_type().map(ToOwned::to_owned),
upstream_status,
upstream_content_type,
upstream_body,
account_status: status,
}]
}
}
})
.flat_map(futures::stream::iter);
Ok(Box::pin(futures::stream::iter(initial).chain(terminal)))
}
}
fn account_status(account: &AccountRecord, now: chrono::DateTime<Utc>) -> AccountStatus {
if !account.enabled {
AccountStatus::Disabled
} else if account.availability == crate::model::accounts::AccountAvailability::Banned {
AccountStatus::Banned
} else if account.availability == crate::model::accounts::AccountAvailability::QuotaExhausted
|| (account.availability == crate::model::accounts::AccountAvailability::Cooldown
&& account.availability_reason.as_deref() == Some("usage_limit_exhausted")
&& account.cooldown_until.is_none_or(|until| until > now))
{
AccountStatus::QuotaExhausted
} else if matches!(
account.availability,
crate::model::accounts::AccountAvailability::Expired
| crate::model::accounts::AccountAvailability::Invalid
) || account
.access_token_expires_at
.is_some_and(|expires_at| expires_at <= now)
{
AccountStatus::Expired
} else {
AccountStatus::Active
}
}
fn retained_usage_range(now: chrono::DateTime<Utc>, retention_days: u32) -> TimeRange {
// 账号留存投影是内部查询,不受外部观测接口 366 天上限约束。
TimeRange {
start: now - Duration::days(i64::from(retention_days)),
end: now,
}
}
fn quota_usage_window(
account_id: &str,
window: &ProviderQuotaWindow,
) -> Option<AccountUsageWindowQuery> {
if window.local_usage.is_some() {
return None;
}
let reset_at = window.reset_at?;
let seconds = i64::try_from(window.window_seconds?).ok()?;
let start = reset_at.checked_sub_signed(Duration::seconds(seconds))?;
let range = TimeRange::new(start, reset_at).ok()?;
// 上游百分比以该 reset 边界定义;以当前时间回推会让本地 Token 属于另一窗口。
Some(AccountUsageWindowQuery {
account_id: account_id.to_owned(),
key: window.key.clone(),
range,
})
}
@@ -0,0 +1,223 @@
//! 管理员认证状态机。
use std::sync::Arc;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{Duration, Utc};
use rand_core::{OsRng, RngCore as _};
use subtle::ConstantTimeEq as _;
use uuid::Uuid;
use crate::{
model::{
AdminError,
auth::{
AdminAuditEvent, AdminSession, AuditActorKind, LoginCommand, LoginError, LoginResult,
},
},
ports::store::AuthStore,
};
use super::map_store_error;
/// API 鉴权与管理员登录消费的统一服务。
#[async_trait]
pub trait AuthService: Send + Sync {
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError>;
async fn resolve_admin_user_id(
&self,
session_id: Option<&str>,
) -> Result<Option<String>, AdminError>;
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError>;
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError>;
async fn validate_session(&self, session_id: Option<&str>) -> Result<bool, AdminError>;
async fn logout(&self, session_id: &str) -> Result<(), AdminError>;
}
/// 会话有效期上限(366 天);超出的配置按上限截断,保证 TTL 构造与
/// `Utc::now() + session_ttl` 永不越界。
const MAX_SESSION_TTL_MINUTES: i64 = 366 * 24 * 60;
/// 单管理员认证策略的最终实现。
pub(crate) struct DefaultAuthService {
default_admin_user_id: String,
session_ttl: Duration,
store: Arc<dyn AuthStore>,
}
impl DefaultAuthService {
#[must_use]
pub(crate) fn new(
default_admin_user_id: impl Into<String>,
session_ttl_minutes: u64,
store: Arc<dyn AuthStore>,
) -> Self {
let minutes = i64::try_from(session_ttl_minutes)
.unwrap_or(MAX_SESSION_TTL_MINUTES)
.clamp(1, MAX_SESSION_TTL_MINUTES);
Self {
default_admin_user_id: default_admin_user_id.into(),
session_ttl: Duration::minutes(minutes),
store,
}
}
fn auth_audit(&self, action: &str, occurred_at: chrono::DateTime<Utc>) -> AdminAuditEvent {
AdminAuditEvent {
id: format!("audit_{}", Uuid::now_v7().simple()),
actor_kind: AuditActorKind::AdminSession,
actor_admin_user_id: Some(self.default_admin_user_id.clone()),
actor_ref: format!("admin:{}", self.default_admin_user_id),
request_id: None,
action: action.to_owned(),
entity_kind: "admin_session".to_owned(),
entity_ref: self.default_admin_user_id.clone(),
config_revision: None,
changed_fields: Vec::new(),
occurred_at,
}
}
}
#[async_trait]
impl AuthService for DefaultAuthService {
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError> {
let hash = hash_admin_password(password)?;
self.store
.create_password_hash_if_absent(&self.default_admin_user_id, &hash)
.await
.map_err(|error| map_store_error(error, "administrator"))
}
async fn resolve_admin_user_id(
&self,
session_id: Option<&str>,
) -> Result<Option<String>, AdminError> {
let Some(session_id) = session_id else {
return Ok(None);
};
self.store
.load_session(session_id)
.await
.map(|session| {
session
.filter(|session| session.expires_at > Utc::now())
.map(|session| session.admin_user_id)
})
.map_err(|error| map_store_error(error, "administrator session"))
}
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError> {
if !valid_admin_api_key_shape(key) {
return Ok(false);
}
let stored = self
.store
.load_admin_api_key()
.await
.map_err(|error| map_store_error(error, "administrator API key"))?;
Ok(stored.as_ref().is_some_and(|stored| {
let stored = stored.expose_for_auth();
key.len() == stored.len() && bool::from(key.as_bytes().ct_eq(stored.as_bytes()))
}))
}
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError> {
if command
.username
.as_deref()
.unwrap_or(&self.default_admin_user_id)
!= self.default_admin_user_id
{
return Err(LoginError::InvalidCredentials);
}
let hash = self
.store
.load_password_hash(&self.default_admin_user_id)
.await
.map_err(|_| LoginError::Unavailable)?
.ok_or(LoginError::InvalidCredentials)?;
if !verify_admin_password(&command.password, &hash).map_err(|_| LoginError::Unavailable)? {
return Err(LoginError::InvalidCredentials);
}
let session_id = random_session_token();
let expires_at = Utc::now() + self.session_ttl;
self.store
.store_session(
&session_id,
&AdminSession {
admin_user_id: self.default_admin_user_id.clone(),
expires_at,
},
)
.await
.map_err(|_| LoginError::Unavailable)?;
if self
.store
.append_audit_event(self.auth_audit("admin.login", Utc::now()))
.await
.is_err()
{
let _ = self.store.delete_session(&session_id).await;
return Err(LoginError::Unavailable);
}
Ok(LoginResult {
session_id,
expires_at,
})
}
async fn validate_session(&self, session_id: Option<&str>) -> Result<bool, AdminError> {
Ok(self.resolve_admin_user_id(session_id).await?.is_some())
}
async fn logout(&self, session_id: &str) -> Result<(), AdminError> {
let session = self
.store
.delete_session(session_id)
.await
.map_err(|error| map_store_error(error, "administrator session"))?;
if let Some(session) = session {
let mut event = self.auth_audit("admin.logout", Utc::now());
event.actor_admin_user_id = Some(session.admin_user_id.clone());
event.actor_ref = format!("admin:{}", session.admin_user_id);
event.entity_ref = session.admin_user_id;
self.store
.append_audit_event(event)
.await
.map_err(|error| map_store_error(error, "administrator audit"))?;
}
Ok(())
}
}
fn hash_admin_password(password: &str) -> Result<String, AdminError> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|_| AdminError::internal("Failed to hash administrator password"))
}
fn verify_admin_password(password: &str, encoded: &str) -> Result<bool, AdminError> {
let hash = PasswordHash::new(encoded)
.map_err(|_| AdminError::internal("Stored administrator password hash is invalid"))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &hash)
.is_ok())
}
fn random_session_token() -> String {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
format!("session_{}", URL_SAFE_NO_PAD.encode(bytes))
}
fn valid_admin_api_key_shape(value: &str) -> bool {
value.len() == 70
&& value.starts_with("admin-")
&& value[6..].bytes().all(|byte| byte.is_ascii_hexdigit())
}
@@ -0,0 +1,203 @@
//! Client API Key 管理用例。
use std::sync::Arc;
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::snapshot::SnapshotControl;
use rand_core::{OsRng, RngCore as _};
use uuid::Uuid;
use crate::{
model::{
AdminError, MutationContext,
client_keys::{
ClientKeyCursorValue, ClientKeyListQuery, ClientKeyMutation, ClientKeyPage,
ClientKeySecret, ClientKeySortField, CreateClientKey, CreatedClientKey,
DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey,
},
},
ports::store::ClientKeyStore,
};
use super::{map_store_error, publish_committed};
/// API 消费的 Client Key 管理服务。
#[async_trait]
pub trait ClientKeyService: Send + Sync {
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError>;
async fn reveal(&self, id: &ClientApiKeyId) -> Result<ClientKeySecret, AdminError>;
async fn create(
&self,
context: &MutationContext,
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError>;
async fn update(
&self,
context: &MutationContext,
command: UpdateClientKey,
) -> Result<ClientKeyMutation, AdminError>;
async fn set_enabled(
&self,
context: &MutationContext,
command: SetClientKeyEnabled,
) -> Result<ClientKeyMutation, AdminError>;
async fn delete(
&self,
context: &MutationContext,
command: DeleteClientKey,
) -> Result<ClientKeyMutation, AdminError>;
}
pub(crate) struct DefaultClientKeyService {
store: Arc<dyn ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultClientKeyService {
#[must_use]
pub(crate) fn new(store: Arc<dyn ClientKeyStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
Self { store, snapshot }
}
}
#[async_trait]
impl ClientKeyService for DefaultClientKeyService {
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError> {
validate_cursor(&query)?;
self.store
.list_client_keys(query)
.await
.map_err(|error| map_store_error(error, "client API key"))
}
async fn reveal(&self, id: &ClientApiKeyId) -> Result<ClientKeySecret, AdminError> {
self.store
.reveal_client_key(id)
.await
.map_err(|error| map_store_error(error, "client API key"))?
.ok_or_else(|| AdminError::not_found("Client API key was not found"))
}
async fn create(
&self,
context: &MutationContext,
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError> {
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("Failed to create Client API key ID"))?;
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes));
let (config_revision, record) = self
.store
.create_client_key(
NewClientKey {
id,
name: command.name,
label: command.label,
provider_kind: command.provider_kind,
limits: command.limits,
plaintext: plaintext.clone(),
},
context,
)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
Ok(CreatedClientKey {
config_revision,
secret: ClientKeySecret::new(record, plaintext),
})
}
async fn update(
&self,
context: &MutationContext,
command: UpdateClientKey,
) -> Result<ClientKeyMutation, AdminError> {
let id = command.id.clone();
let (config_revision, record) = self
.store
.update_client_key(command, context)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
Ok(ClientKeyMutation {
config_revision,
record: Some(record),
id,
})
}
async fn set_enabled(
&self,
context: &MutationContext,
command: SetClientKeyEnabled,
) -> Result<ClientKeyMutation, AdminError> {
let id = command.id.clone();
let (config_revision, record) =
self.store
.set_client_key_enabled(command, context)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
Ok(ClientKeyMutation {
config_revision,
record: Some(record),
id,
})
}
async fn delete(
&self,
context: &MutationContext,
command: DeleteClientKey,
) -> Result<ClientKeyMutation, AdminError> {
let id = command.id.clone();
let config_revision = self
.store
.delete_client_key(command, context)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
Ok(ClientKeyMutation {
config_revision,
record: None,
id,
})
}
}
fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
let Some(cursor) = &query.cursor else {
return Ok(());
};
if cursor.sort != query.sort {
return Err(AdminError::invalid(
"Client API key cursor sort does not match the query",
));
}
let matches = matches!(
(cursor.sort.field, &cursor.value),
(ClientKeySortField::Name, ClientKeyCursorValue::Name(value)) if !value.trim().is_empty()
) || matches!(
(cursor.sort.field, &cursor.value),
(
ClientKeySortField::Enabled,
ClientKeyCursorValue::Enabled(_)
) | (
ClientKeySortField::CreatedAt,
ClientKeyCursorValue::CreatedAt(_)
) | (
ClientKeySortField::LastUsedAt,
ClientKeyCursorValue::LastUsedAt(_)
)
);
if matches {
Ok(())
} else {
Err(AdminError::invalid("Invalid Client API key cursor"))
}
}
@@ -0,0 +1,350 @@
//! 管理控制面的用例实现。
pub mod accounts;
pub mod auth;
pub mod client_keys;
pub mod observability;
pub mod openai;
pub mod settings;
pub mod system;
pub mod xai;
use crate::{
model::{
AdminError, AdminErrorKind, MutationContext,
accounts::{DeleteAccounts, SetAccountEnabled},
provider_credentials::{
AuthorizationMutationTarget, CredentialDeletion, CredentialDeletionResult,
CredentialDetails, CredentialMutation, CredentialMutationResult,
CredentialRotationCommit, PendingAuthorizationMutation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
StartAuthorization,
},
},
ports::{
provider::ProviderAdmin,
store::{AccountStore, AdminStoreError, AdminStoreErrorKind},
},
};
use gateway_core::{
engine::credential::ProviderAccountId,
routing::{ConfigRevision, ProviderKind, snapshot::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::Conflict => {
AdminErrorKind::Conflict
}
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
};
AdminError::new(kind, format!("{resource} operation failed"))
}
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::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, format!("{resource} operation failed"))
}
async fn publish_committed(
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
) -> Result<(), AdminError> {
let revision = ConfigRevision::new(revision.get())
.map_err(|_| AdminError::internal("Committed configuration revision is invalid"))?;
snapshot.publish_committed(revision).await;
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(format!("{resource} was not found")))
}
async fn pending_authorization(
accounts: &dyn AccountStore,
provider_kind: &ProviderKind,
command: &StartAuthorization,
resource: &'static str,
) -> Result<PendingAuthorizationMutation, AdminError> {
let target = match &command.reauthorization {
Some(account_id) => {
required_credential(accounts, provider_kind, account_id, resource).await?;
AuthorizationMutationTarget::Reauthorize {
account_id: account_id.clone(),
}
}
None => AuthorizationMutationTarget::Create {
name: command.name.clone(),
},
};
Ok(PendingAuthorizationMutation::new(
provider_kind.clone(),
target,
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
&command.context,
),
))
}
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(format!(
"{resource} prepared facts do not match the requested Provider scope"
)));
}
Ok(())
}
fn validate_prepared_rotation(
account: &crate::model::accounts::AccountRecord,
prepared: &PreparedCredentialRotation,
resource: &'static str,
) -> Result<(), AdminError> {
let facts = prepared.facts();
if facts.account_id.as_str() != account.id.as_str()
|| facts.provider_kind != account.provider_kind
{
return Err(AdminError::conflict(format!(
"{resource} prepared facts do not match the current credential"
)));
}
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(format!(
"{resource} pending authorization binding is invalid"
));
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(format!(
"{resource} prepared credential does not match its pending target"
));
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,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let crate::model::provider_credentials::AuthorizationCommitSettlement {
command,
credential_guard,
authorization_guard,
} = prepared.into_commit();
match accounts.commit_authorization(command, context).await {
Ok(result) => {
if let Some(guard) = credential_guard {
guard.finish();
}
if let Some(guard) = authorization_guard {
guard.commit().await?;
}
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,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_rotation(CredentialRotationCommit { prepared: facts }, 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 }, context)
.await
{
Ok(result) => {
guard.finish();
Ok(result)
}
Err(error) => {
drop(guard);
Err(map_store_error(error, resource))
}
}
}
async fn set_credential_enabled(
accounts: &dyn AccountStore,
provider: &dyn ProviderAdmin,
command: CredentialMutation,
enabled: bool,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
required_credential(
accounts,
provider.provider_kind(),
&command.account_id,
resource,
)
.await?;
let account_id = command.account_id;
let revision = accounts
.set_account_enabled(
SetAccountEnabled {
account_id: account_id.as_str().to_owned(),
enabled,
},
&command.context,
)
.await
.map_err(|error| map_store_error(error, resource))?;
if !enabled {
provider.account_unavailable(&account_id).await;
}
provider
.account_facts_changed(std::slice::from_ref(&account_id))
.await;
Ok(CredentialMutationResult {
config_revision: revision,
account_id,
credential_revision: None,
})
}
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,837 @@
//! 观测查询、趋势、费用覆盖与健康阈值规则。
use std::{
collections::{BTreeMap, btree_map::Entry},
sync::Arc,
};
use async_trait::async_trait;
use chrono::{DateTime, Duration, Timelike as _, Utc};
use gateway_core::{engine::credential::ProviderAccountId, routing::ProviderKind};
use crate::{
model::{
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, china_day_start,
},
provider_credentials::ProviderQuotaRequest,
},
ports::{
provider::{ProviderAdminErrorKind, ProviderAdminRegistry},
store::{ObservabilityStore, SettingsStore},
},
};
use super::{map_provider_error, map_store_error};
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4;
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 99.0;
/// API 消费的观测控制面服务。
#[async_trait]
pub trait ObservabilityService: Send + Sync {
async fn dashboard_summary(
&self,
range: TimeRange,
kind: TrendKind,
) -> Result<DashboardResult, AdminError>;
async fn dashboard_trend(&self, range: TimeRange, kind: TrendKind)
-> Result<Trend, AdminError>;
async fn usage_records(&self, query: UsageQuery) -> Result<UsagePage, AdminError>;
async fn usage_record_detail(&self, request_id: &str) -> Result<UsageDetail, AdminError>;
async fn usage_summary(
&self,
range: TimeRange,
filter: UsageFilter,
) -> Result<UsageSummary, AdminError>;
async fn usage_insights(
&self,
range: TimeRange,
filter: UsageFilter,
) -> Result<UsageInsights, AdminError>;
async fn diagnostics(
&self,
range: TimeRange,
filter: UsageFilter,
dimension: DiagnosticDimension,
) -> Result<DiagnosticsResult, AdminError>;
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError>;
}
pub(crate) struct DefaultObservabilityService {
store: Arc<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
providers: ProviderAdminRegistry,
}
impl DefaultObservabilityService {
#[must_use]
pub(crate) fn new(
store: Arc<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
providers: ProviderAdminRegistry,
) -> Self {
Self {
store,
settings,
providers,
}
}
}
#[async_trait]
impl ObservabilityService for DefaultObservabilityService {
async fn dashboard_summary(
&self,
range: TimeRange,
kind: TrendKind,
) -> Result<DashboardResult, AdminError> {
let (mut observation, settings, runtime_slots) = futures::try_join!(
self.store.dashboard_summary(range),
self.settings.load_runtime_settings(),
self.store.dashboard_runtime_slots(range.end),
)
.map_err(|error| map_store_error(error, "dashboard"))?;
self.enrich_billing(&mut observation.recent_requests);
self.enrich_dashboard_quotas(&mut observation.account_usage)
.await;
let today_start = china_day_start(observation.range.end);
let yesterday_start = today_start - Duration::days(1);
let today =
dashboard_period_metrics(&observation.trend, today_start, observation.range.end);
let yesterday = dashboard_period_metrics(&observation.trend, yesterday_start, today_start);
let total_billing_usd = observation
.attempts
.costs
.iter()
.find(|cost| cost.currency == "USD")
.map(|cost| cost.amount.clone());
let total_cached_token_rate = rate_or_zero(
observation.requests.cached_tokens,
observation.requests.input_tokens,
);
let average_first_token_latency_ms = average(
observation.requests.first_token_latency_sum_ms,
observation.requests.first_token_latency_count,
);
let trend = trend(kind, observation.trend.clone())?;
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
let wire_profiles = self.providers.dashboard_wire_profiles();
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
let active_accounts = runtime_slots
.as_ref()
.map_or(observation.provider_accounts.active, |slots| {
slots.active_accounts
});
let total_slots = active_accounts.saturating_mul(max_concurrent_per_account);
let used_slots = runtime_slots.and_then(|slots| slots.used_slots);
Ok(DashboardResult {
capacity: DashboardCapacity {
max_concurrent_per_account,
total_slots,
used_slots,
available_slots: used_slots.map(|used| total_slots.saturating_sub(used)),
},
rotation_strategy: settings.rotation_strategy,
observation,
today,
yesterday,
total_billing_usd,
total_cached_token_rate,
average_first_token_latency_ms,
trend,
health_timeline,
wire_profiles,
})
}
async fn dashboard_trend(
&self,
range: TimeRange,
kind: TrendKind,
) -> Result<Trend, AdminError> {
let points = self
.store
.dashboard_trend(range)
.await
.map_err(|error| map_store_error(error, "dashboard trend"))?;
trend(kind, points)
}
async fn usage_records(&self, query: UsageQuery) -> Result<UsagePage, AdminError> {
let mut page = self
.store
.list_usage_records(query)
.await
.map_err(|error| map_store_error(error, "usage records"))?;
self.enrich_billing(&mut page.items);
Ok(page)
}
async fn usage_record_detail(&self, request_id: &str) -> Result<UsageDetail, AdminError> {
if request_id.trim().is_empty() {
return Err(AdminError::invalid("Usage record ID is required"));
}
let mut detail = self
.store
.usage_record_detail(request_id)
.await
.map_err(|error| map_store_error(error, "usage record"))?;
self.enrich_billing(std::slice::from_mut(&mut detail.request));
Ok(detail)
}
async fn usage_summary(
&self,
range: TimeRange,
filter: UsageFilter,
) -> Result<UsageSummary, AdminError> {
let overview = self
.store
.usage_summary(range, filter)
.await
.map_err(|error| map_store_error(error, "usage summary"))?;
let average_latency_ms = average(
overview.requests.latency_sum_ms,
overview.requests.latency_count,
);
Ok(UsageSummary {
overview,
average_latency_ms,
})
}
async fn usage_insights(
&self,
range: TimeRange,
filter: UsageFilter,
) -> Result<UsageInsights, AdminError> {
let (overview, trend, billing_facts) = futures::try_join!(
self.store.usage_summary(range, filter.clone()),
self.store.usage_trend(range, filter.clone()),
self.store.usage_calculated_billing_facts(range, filter),
)
.map_err(|error| map_store_error(error, "usage insights"))?;
let standard_costs = recover_standard_costs(&self.providers, billing_facts)?;
build_usage_insights(overview, trend, standard_costs)
}
async fn diagnostics(
&self,
range: TimeRange,
filter: UsageFilter,
dimension: DiagnosticDimension,
) -> Result<DiagnosticsResult, AdminError> {
let items = self
.store
.usage_diagnostics(range, filter, dimension)
.await
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
let total_requests = items.iter().fold(0_u64, |total, item| {
total.saturating_add(item.request_count)
});
Ok(DiagnosticsResult {
dimension,
items: items
.into_iter()
.map(|item| DiagnosticsItem {
name: item.name,
request_count: item.request_count,
success_count: item.request_count.saturating_sub(item.failure_count),
error_count: item.failure_count,
error_rate: rate_or_zero(item.failure_count, item.request_count),
request_share: rate_or_zero(item.request_count, total_requests),
average_latency_ms: item.average_latency_ms,
estimated_cost: usd_cost(&item.costs),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
})
.collect(),
})
}
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
self.store
.list_ops_errors(query)
.await
.map_err(|error| map_store_error(error, "operations errors"))
}
}
fn build_usage_insights(
overview: UsageOverview,
trend: Vec<RequestMetricPoint>,
standard_costs: UsageStandardCosts,
) -> Result<UsageInsights, AdminError> {
let granularity = trend.first().map_or(
crate::model::observability::Granularity::FifteenMinutes,
|point| point.granularity,
);
if trend.iter().any(|point| point.granularity != granularity) {
return Err(AdminError::internal(
"Usage insight trend contains mixed granularities",
));
}
let requests = &overview.requests;
let failed_requests = service_failure_count(requests);
let health_requests = requests.success_count.saturating_add(failed_requests);
let health = UsageInsightsHealth {
total_requests: health_requests,
success_requests: requests.success_count,
failed_requests,
cancelled_requests: requests.cancelled_count,
incomplete_requests: requests.incomplete_count,
caller_error_requests: requests.caller_error_count,
success_rate: rate_or_zero(requests.success_count, health_requests),
points: trend
.iter()
.map(|point| {
let failed_requests = service_failure_count(&point.metrics);
let health_requests = point.metrics.success_count.saturating_add(failed_requests);
UsageInsightsHealthPoint {
bucket_start: point.bucket_start,
success_requests: point.metrics.success_count,
failed_requests,
cancelled_requests: point.metrics.cancelled_count,
incomplete_requests: point.metrics.incomplete_count,
caller_error_requests: point.metrics.caller_error_count,
error_rate: rate_or_zero(failed_requests, health_requests),
}
})
.collect(),
};
let performance = UsageInsightsPerformance {
latency_percentiles: requests.latency_percentiles,
first_token_latency_percentiles: requests.first_token_latency_percentiles,
latency_coverage: rate_or_zero(requests.latency_count, requests.request_count),
first_token_coverage: rate_or_zero(
requests.first_token_latency_count,
requests.request_count,
),
points: trend
.iter()
.map(|point| UsageInsightsPerformancePoint {
bucket_start: point.bucket_start,
latency_percentiles: point.metrics.latency_percentiles,
first_token_latency_percentiles: point.metrics.first_token_latency_percentiles,
})
.collect(),
};
let estimated_cost = usd_cost(&overview.attempts.costs);
let UsageStandardCosts {
total: standard_cost,
by_bucket: standard_costs_by_bucket,
} = standard_costs;
let cost = UsageInsightsCost {
cost_per_request: estimated_cost
.as_ref()
.and_then(|cost| cost.checked_div_u64(requests.request_count)),
estimated_cost,
standard_cost,
tokens_per_request: rate_or_zero(requests.total_tokens, requests.request_count),
cached_token_rate: rate_or_zero(requests.cached_tokens, requests.input_tokens),
cache_hit_request_rate: ratio(
requests.cache_hit_request_count,
requests.cache_eligible_request_count,
),
input_tokens: requests.input_tokens,
output_tokens: requests.output_tokens,
cached_tokens: requests.cached_tokens,
total_tokens: requests.total_tokens,
points: trend
.iter()
.map(|point| UsageInsightsCostPoint {
bucket_start: point.bucket_start,
input_tokens: point.metrics.input_tokens,
output_tokens: point.metrics.output_tokens,
cached_tokens: point.metrics.cached_tokens,
total_tokens: point.metrics.total_tokens,
estimated_cost: usd_cost(&point.costs),
standard_cost: standard_costs_by_bucket.get(&point.bucket_start).cloned(),
cached_token_rate: rate_or_zero(
point.metrics.cached_tokens,
point.metrics.input_tokens,
),
cache_hit_request_rate: ratio(
point.metrics.cache_hit_request_count,
point.metrics.cache_eligible_request_count,
),
})
.collect(),
costs: overview.attempts.costs.clone(),
coverage: overview.attempts.cost_coverage.clone(),
};
Ok(UsageInsights {
granularity,
health,
performance,
cost,
attempts: overview.attempts,
providers: overview.providers,
})
}
#[derive(Debug, Default)]
struct UsageStandardCosts {
total: Option<DecimalAmount>,
by_bucket: BTreeMap<DateTime<Utc>, DecimalAmount>,
}
impl UsageStandardCosts {
fn add(
&mut self,
bucket_start: DateTime<Utc>,
amount: DecimalAmount,
) -> Result<(), AdminError> {
self.total = Some(match self.total.take() {
Some(current) => current
.checked_add(&amount)
.ok_or_else(|| AdminError::internal("Usage standard cost exceeds decimal range"))?,
None => amount.clone(),
});
match self.by_bucket.entry(bucket_start) {
Entry::Occupied(mut entry) => {
let sum = entry.get().checked_add(&amount).ok_or_else(|| {
AdminError::internal("Usage standard cost exceeds decimal range")
})?;
entry.insert(sum);
}
Entry::Vacant(entry) => {
entry.insert(amount);
}
}
Ok(())
}
}
fn recover_standard_costs(
providers: &ProviderAdminRegistry,
facts: Vec<UsageCalculatedBillingFact>,
) -> Result<UsageStandardCosts, AdminError> {
let mut standard_costs = UsageStandardCosts::default();
for fact in facts {
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind) else {
continue;
};
let input = ProviderBillingInput {
upstream_model_id: fact.upstream_model_id,
service_tier: fact.service_tier,
input_tokens: fact.input_tokens,
output_tokens: fact.output_tokens,
cached_tokens: fact.cached_tokens,
cache_write_tokens: fact.cache_write_tokens,
total: fact.total,
};
let breakdown = match providers.calculated_billing(&provider_kind, &input) {
Ok(breakdown) => breakdown,
Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => continue,
Err(error) => return Err(map_provider_error(error, "usage billing")),
};
let Some(breakdown) = breakdown else {
continue;
};
if !breakdown
.standard_amount
.currency
.eq_ignore_ascii_case("USD")
{
continue;
}
standard_costs.add(fact.bucket_start, breakdown.standard_amount.amount)?;
}
Ok(standard_costs)
}
fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
costs
.iter()
.find(|cost| cost.currency.eq_ignore_ascii_case("USD"))
.map(|cost| cost.amount.clone())
}
impl DefaultObservabilityService {
async fn enrich_dashboard_quotas(&self, accounts: &mut [DashboardAccountUsage]) {
let providers = self.providers.clone();
let quota_reads = accounts.iter().map(|account| {
let account_id = ProviderAccountId::new(account.account_id.clone()).ok();
let provider = ProviderKind::new(account.provider_kind.clone())
.ok()
.and_then(|kind| providers.require(&kind).ok());
async move {
let (Some(account_id), Some(provider)) = (account_id, provider) else {
return None;
};
provider
.quota(ProviderQuotaRequest {
account_id,
refresh: false,
rolling_usage: None,
})
.await
.ok()
.and_then(|quota| quota.representative_used_percent())
}
});
let quota_used = futures::future::join_all(quota_reads).await;
for (account, used_percent) in accounts.iter_mut().zip(quota_used) {
account.quota_used_percent = used_percent
.or_else(|| (account.availability == "quota_exhausted").then_some(100.0));
}
}
/// 逐条尽力把 calculated 总额升级为完整分解;单条脏数据(非法 Provider kind
/// 或费用规则失败)只保留该条已存的总额,不影响整页返回。
fn enrich_billing(&self, records: &mut [crate::model::observability::UsageRecord]) {
for record in records {
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
continue;
};
if source != "calculated" {
continue;
}
let (Some(provider), Some(upstream_model_id)) = (
record.provider_kind.as_deref(),
record.upstream_model_id.as_ref(),
) else {
continue;
};
let Ok(provider_kind) = ProviderKind::new(provider.to_owned()) else {
continue;
};
let input = ProviderBillingInput {
upstream_model_id: upstream_model_id.clone(),
service_tier: record.service_tier.clone(),
input_tokens: record.input_tokens,
output_tokens: record.output_tokens,
cached_tokens: record.cached_tokens,
cache_write_tokens: record.cache_write_tokens,
total: total.clone(),
};
if let Ok(Some(breakdown)) = self.providers.calculated_billing(&provider_kind, &input) {
record.billing = Some(UsageBilling::Calculated(Box::new(breakdown)));
}
}
}
}
/// 按指定时刻计算中国自然日的 96 个 15 分钟健康桶。
#[must_use]
fn health_timeline_at(records: &[RequestMetricPoint], now: DateTime<Utc>) -> HealthTimeline {
let current_slot = quarter_hour_start(now);
let start = china_day_start(now);
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
.map(|index| {
(
start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index),
HealthWindow::default(),
)
})
.collect::<Vec<_>>();
for record in records {
if record.bucket_start < start || record.bucket_start > now {
continue;
}
let record_slot = quarter_hour_start(record.bucket_start);
if let Some((_, bucket)) = buckets
.iter_mut()
.find(|(bucket_start, _)| *bucket_start == record_slot)
{
bucket.add_metrics(&record.metrics);
}
}
let totals = buckets
.iter()
.filter(|(bucket_start, _)| *bucket_start <= current_slot)
.fold(HealthWindow::default(), |mut totals, (_, bucket)| {
totals.add_window(*bucket);
totals
});
HealthTimeline {
reliability_percent: health_reliability(totals),
status: health_status(totals, false),
success_requests: totals.success_requests,
failed_requests: totals.failed_requests,
cancelled_requests: totals.cancelled_requests,
incomplete_requests: totals.incomplete_requests,
caller_error_requests: totals.caller_error_requests,
points: buckets
.into_iter()
.map(|(bucket_start, bucket)| HealthTimelinePoint {
bucket_start,
status: health_status(bucket, bucket_start > current_slot),
reliability_percent: health_reliability(bucket),
success_requests: bucket.success_requests,
failed_requests: bucket.failed_requests,
cancelled_requests: bucket.cancelled_requests,
incomplete_requests: bucket.incomplete_requests,
caller_error_requests: bucket.caller_error_requests,
})
.collect(),
}
}
fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, AdminError> {
let mut metrics = RequestMetrics::default();
let mut coverage = CostCoverage::default();
let mut costs = BTreeMap::<String, DecimalAmount>::new();
for point in &points {
add_request_metrics(&mut metrics, &point.metrics);
coverage.provider_reported_count = coverage
.provider_reported_count
.saturating_add(point.cost_coverage.provider_reported_count);
coverage.calculated_count = coverage
.calculated_count
.saturating_add(point.cost_coverage.calculated_count);
coverage.partial_count = coverage
.partial_count
.saturating_add(point.cost_coverage.partial_count);
coverage.unavailable_count = coverage
.unavailable_count
.saturating_add(point.cost_coverage.unavailable_count);
coverage.not_billable_count = coverage
.not_billable_count
.saturating_add(point.cost_coverage.not_billable_count);
for cost in &point.costs {
if let Some(amount) = costs.get_mut(&cost.currency) {
*amount = amount.checked_add(&cost.amount).ok_or_else(|| {
AdminError::internal("Cost aggregation exceeded numeric bounds")
})?;
} else {
costs.insert(cost.currency.clone(), cost.amount.clone());
}
}
}
let average_latency_ms = average(metrics.latency_sum_ms, metrics.latency_count);
let service_failure_count = service_failure_count(&metrics);
let success_rate = ratio(
metrics.success_count,
metrics.success_count.saturating_add(service_failure_count),
);
let cache_hit_request_rate = ratio(
metrics.cache_hit_request_count,
metrics.cache_eligible_request_count,
);
Ok(Trend {
kind,
points: points.into_iter().map(trend_point).collect(),
summary: TrendSummary {
request_count: metrics.request_count,
input_tokens: metrics.input_tokens,
output_tokens: metrics.output_tokens,
cached_tokens: metrics.cached_tokens,
total_tokens: metrics.total_tokens,
service_failure_count,
average_latency_ms,
max_latency_ms: metrics.max_latency_ms,
min_latency_ms: metrics.min_latency_ms,
success_rate,
cache_hit_request_rate,
costs: costs
.into_iter()
.map(|(currency, amount)| CurrencyCost { currency, amount })
.collect(),
cost_coverage: coverage,
},
})
}
fn trend_point(point: RequestMetricPoint) -> TrendPoint {
let service_failure_count = service_failure_count(&point.metrics);
TrendPoint {
bucket_start: point.bucket_start,
granularity: point.granularity,
service_failure_count,
average_latency_ms: average(point.metrics.latency_sum_ms, point.metrics.latency_count),
average_first_token_latency_ms: average(
point.metrics.first_token_latency_sum_ms,
point.metrics.first_token_latency_count,
),
cached_token_rate: rate_or_zero(point.metrics.cached_tokens, point.metrics.input_tokens),
cache_hit_request_rate: ratio(
point.metrics.cache_hit_request_count,
point.metrics.cache_eligible_request_count,
),
success_rate: ratio(point.metrics.success_count, point.metrics.request_count),
metrics: point.metrics,
cost_coverage: point.cost_coverage,
costs: point.costs,
}
}
fn dashboard_period_metrics(
points: &[RequestMetricPoint],
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> DashboardPeriodMetrics {
let mut result = DashboardPeriodMetrics::default();
for metrics in points
.iter()
.filter(|point| point.bucket_start >= start && point.bucket_start < end)
.map(|point| &point.metrics)
{
result.request_count = result.request_count.saturating_add(metrics.request_count);
result.input_tokens = result.input_tokens.saturating_add(metrics.input_tokens);
result.output_tokens = result.output_tokens.saturating_add(metrics.output_tokens);
result.cached_tokens = result.cached_tokens.saturating_add(metrics.cached_tokens);
result.total_tokens = result.total_tokens.saturating_add(metrics.total_tokens);
}
result.cached_token_rate = rate_or_zero(result.cached_tokens, result.input_tokens);
result.observed_cached_token_rate = ratio(result.cached_tokens, result.input_tokens);
result
}
fn add_request_metrics(total: &mut RequestMetrics, value: &RequestMetrics) {
total.request_count = total.request_count.saturating_add(value.request_count);
total.success_count = total.success_count.saturating_add(value.success_count);
total.failure_count = total.failure_count.saturating_add(value.failure_count);
total.cancelled_count = total.cancelled_count.saturating_add(value.cancelled_count);
total.incomplete_count = total
.incomplete_count
.saturating_add(value.incomplete_count);
total.caller_error_count = total
.caller_error_count
.saturating_add(value.caller_error_count);
total.input_tokens = total.input_tokens.saturating_add(value.input_tokens);
total.output_tokens = total.output_tokens.saturating_add(value.output_tokens);
total.cached_tokens = total.cached_tokens.saturating_add(value.cached_tokens);
total.cache_write_tokens = total
.cache_write_tokens
.saturating_add(value.cache_write_tokens);
total.reasoning_tokens = total
.reasoning_tokens
.saturating_add(value.reasoning_tokens);
total.total_tokens = total.total_tokens.saturating_add(value.total_tokens);
total.first_token_latency_sum_ms = total
.first_token_latency_sum_ms
.saturating_add(value.first_token_latency_sum_ms);
total.first_token_latency_count = total
.first_token_latency_count
.saturating_add(value.first_token_latency_count);
total.latency_sum_ms = total.latency_sum_ms.saturating_add(value.latency_sum_ms);
total.latency_count = total.latency_count.saturating_add(value.latency_count);
total.min_latency_ms = match (total.min_latency_ms, value.min_latency_ms) {
(Some(left), Some(right)) => Some(left.min(right)),
(left, right) => left.or(right),
};
total.max_latency_ms = match (total.max_latency_ms, value.max_latency_ms) {
(Some(left), Some(right)) => Some(left.max(right)),
(left, right) => left.or(right),
};
total.cache_eligible_request_count = total
.cache_eligible_request_count
.saturating_add(value.cache_eligible_request_count);
total.cache_hit_request_count = total
.cache_hit_request_count
.saturating_add(value.cache_hit_request_count);
}
fn average(sum: u64, count: u64) -> Option<u64> {
(count > 0).then(|| sum / count)
}
fn ratio(numerator: u64, denominator: u64) -> Option<f64> {
(denominator > 0).then(|| numerator as f64 / denominator as f64)
}
fn rate_or_zero(numerator: u64, denominator: u64) -> f64 {
ratio(numerator, denominator).unwrap_or(0.0)
}
#[derive(Debug, Clone, Copy, Default)]
struct HealthWindow {
success_requests: u64,
failed_requests: u64,
cancelled_requests: u64,
incomplete_requests: u64,
caller_error_requests: u64,
}
impl HealthWindow {
fn add_metrics(&mut self, metrics: &RequestMetrics) {
self.success_requests = self.success_requests.saturating_add(metrics.success_count);
self.failed_requests = self
.failed_requests
.saturating_add(service_failure_count(metrics));
self.cancelled_requests = self
.cancelled_requests
.saturating_add(metrics.cancelled_count);
self.incomplete_requests = self
.incomplete_requests
.saturating_add(metrics.incomplete_count);
self.caller_error_requests = self
.caller_error_requests
.saturating_add(metrics.caller_error_count);
}
fn add_window(&mut self, other: Self) {
self.success_requests = self.success_requests.saturating_add(other.success_requests);
self.failed_requests = self.failed_requests.saturating_add(other.failed_requests);
self.cancelled_requests = self
.cancelled_requests
.saturating_add(other.cancelled_requests);
self.incomplete_requests = self
.incomplete_requests
.saturating_add(other.incomplete_requests);
self.caller_error_requests = self
.caller_error_requests
.saturating_add(other.caller_error_requests);
}
}
fn health_status(bucket: HealthWindow, is_future: bool) -> HealthStatus {
let eligible_requests = bucket
.success_requests
.saturating_add(bucket.failed_requests);
if is_future {
HealthStatus::Future
} else if eligible_requests == 0 {
HealthStatus::NoData
} else if bucket.success_requests == 0
&& bucket.failed_requests >= HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD
{
HealthStatus::Unavailable
} else if eligible_requests < HEALTH_TIMELINE_MIN_SAMPLE_SIZE {
HealthStatus::LowSample
} else if health_reliability(bucket)
.is_some_and(|reliability| reliability < HEALTH_TIMELINE_STABLE_RELIABILITY)
{
HealthStatus::Unstable
} else {
HealthStatus::Stable
}
}
fn health_reliability(bucket: HealthWindow) -> Option<f64> {
let eligible_requests = bucket
.success_requests
.saturating_add(bucket.failed_requests);
(eligible_requests > 0)
.then(|| bucket.success_requests as f64 / eligible_requests as f64 * 100.0)
}
fn service_failure_count(metrics: &RequestMetrics) -> u64 {
metrics
.failure_count
.saturating_sub(metrics.caller_error_count)
}
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
let elapsed = value
.timestamp()
.rem_euclid(HEALTH_TIMELINE_SLOT_MINUTES * 60);
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
}
@@ -0,0 +1,299 @@
//! OpenAI 管理资源的中立 ProviderAdmin 委托。
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::{engine::credential::ProviderAccountId, routing::snapshot::SnapshotControl};
use crate::{
model::{
AdminError,
provider_credentials::{
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
CredentialDeletionResult, CredentialDetails, CredentialImportCommit,
CredentialImportResult, CredentialListQuery, CredentialMutation,
CredentialMutationResult, CredentialPage, ImportCredentials, PrepareCredentialImport,
PrepareCredentialRotation, ProviderQuotaRequest, RotateCredential, StartAuthorization,
},
},
ports::{provider::ProviderAdmin, store::AccountStore},
};
use super::{
commit_authorization, commit_credential_rotation, delete_credentials, map_provider_error,
map_store_error, pending_authorization, publish_committed, required_credential,
set_credential_enabled, validate_authorization_commit, validate_prepared_import,
validate_prepared_rotation,
};
/// OpenAI 固定管理路由消费的服务。
#[async_trait]
pub trait OpenAiService: Send + Sync {
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError>;
async fn details(
&self,
account_id: &ProviderAccountId,
) -> Result<CredentialDetails, AdminError>;
async fn import_document(
&self,
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError>;
async fn start_authorization(
&self,
command: StartAuthorization,
) -> Result<AuthorizationStarted, AdminError>;
async fn complete_authorization(
&self,
command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError>;
async fn rotate(
&self,
command: RotateCredential,
) -> Result<CredentialMutationResult, AdminError>;
async fn enable(
&self,
command: CredentialMutation,
) -> Result<CredentialMutationResult, AdminError>;
async fn disable(
&self,
command: CredentialMutation,
) -> Result<CredentialMutationResult, AdminError>;
async fn delete(
&self,
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError>;
}
pub(crate) struct DefaultOpenAiService {
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultOpenAiService {
#[must_use]
pub(crate) fn new(
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
provider,
accounts,
snapshot,
}
}
async fn observe_initial_quotas(&self, account_ids: &[ProviderAccountId], request_id: &str) {
for account_id in account_ids {
if let Err(error) = self
.provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await
{
tracing::warn!(
request_id,
account_id = %account_id.as_str(),
quota_error = ?error.kind(),
"OpenAI initial quota observation failed"
);
}
}
}
}
#[async_trait]
impl OpenAiService for DefaultOpenAiService {
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError> {
self.accounts
.list_credentials(self.provider.provider_kind(), query)
.await
.map_err(|error| map_store_error(error, "OpenAI credential"))
}
async fn details(
&self,
account_id: &ProviderAccountId,
) -> Result<CredentialDetails, AdminError> {
required_credential(
self.accounts.as_ref(),
self.provider.provider_kind(),
account_id,
"OpenAI credential",
)
.await
}
async fn import_document(
&self,
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
document: command.document,
})
.await
.map_err(|error| map_provider_error(error, "OpenAI credential import"))?;
validate_prepared_import(
self.provider.provider_kind(),
&prepared,
"OpenAI credential import",
)?;
let result = self
.accounts
.commit_credential_import(CredentialImportCommit { prepared }, &context)
.await
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
self.provider
.account_facts_changed(&result.credential_ids)
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
// 导入完成后立即做一次观察;失败不回滚已经提交的 credential。
self.observe_initial_quotas(&result.credential_ids, &context.request_id)
.await;
Ok(result)
}
async fn start_authorization(
&self,
command: StartAuthorization,
) -> Result<AuthorizationStarted, AdminError> {
let pending = pending_authorization(
self.accounts.as_ref(),
self.provider.provider_kind(),
&command,
"OpenAI credential",
)
.await?;
self.provider
.start_authorization(pending)
.await
.map_err(|error| map_provider_error(error, "OpenAI authorization"))
}
async fn complete_authorization(
&self,
command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.context.clone();
let prepared = self
.provider
.complete_authorization(command)
.await
.map_err(|error| map_provider_error(error, "OpenAI authorization"))?;
let prepared = validate_authorization_commit(
self.provider.provider_kind(),
&context,
prepared,
"OpenAI authorization",
)
.await?;
let result = commit_authorization(
self.accounts.as_ref(),
prepared,
&context,
"OpenAI authorization",
)
.await?;
self.provider
.account_facts_changed(std::slice::from_ref(&result.account_id))
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
self.observe_initial_quotas(
std::slice::from_ref(&result.account_id),
&context.request_id,
)
.await;
Ok(result)
}
async fn rotate(
&self,
command: RotateCredential,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.mutation.context;
let account_id = command.mutation.account_id;
let details = required_credential(
self.accounts.as_ref(),
self.provider.provider_kind(),
&account_id,
"OpenAI credential rotation",
)
.await?;
let account = details.credential;
let prepared = self
.provider
.prepare_rotation(PrepareCredentialRotation {
account: account.clone(),
provider_material: command.provider_material,
})
.await
.map_err(|error| map_provider_error(error, "OpenAI credential rotation"))?;
validate_prepared_rotation(&account, &prepared, "OpenAI credential rotation")?;
let result = commit_credential_rotation(
self.accounts.as_ref(),
prepared,
&context,
"OpenAI credential rotation",
)
.await?;
self.provider
.account_facts_changed(std::slice::from_ref(&result.account_id))
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn enable(
&self,
command: CredentialMutation,
) -> Result<CredentialMutationResult, AdminError> {
let result = set_credential_enabled(
self.accounts.as_ref(),
self.provider.as_ref(),
command,
true,
"OpenAI credential",
)
.await?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn disable(
&self,
command: CredentialMutation,
) -> Result<CredentialMutationResult, AdminError> {
let result = set_credential_enabled(
self.accounts.as_ref(),
self.provider.as_ref(),
command,
false,
"OpenAI credential",
)
.await?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn delete(
&self,
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError> {
let result = delete_credentials(
self.accounts.as_ref(),
self.provider.as_ref(),
command,
"OpenAI credential",
)
.await?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
}
@@ -0,0 +1,128 @@
//! Runtime settings 与管理员 API Key 用例。
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::routing::snapshot::SnapshotControl;
use rand_core::{OsRng, RngCore as _};
use crate::{
model::{
AdminError, MutationContext,
settings::{
AdminApiKey, AdminApiKeyMutation, RegeneratedAdminApiKey, ReplaceRuntimeSettings,
RuntimeSettings,
},
},
ports::store::SettingsStore,
};
use super::{map_store_error, publish_committed};
/// API 消费的 Runtime settings 管理服务。
#[async_trait]
pub trait SettingsService: Send + Sync {
async fn load(&self) -> Result<RuntimeSettings, AdminError>;
async fn replace(
&self,
context: &MutationContext,
command: ReplaceRuntimeSettings,
) -> Result<RuntimeSettings, AdminError>;
async fn admin_api_key_exists(&self) -> Result<bool, AdminError>;
async fn regenerate_admin_api_key(
&self,
context: &MutationContext,
) -> Result<RegeneratedAdminApiKey, AdminError>;
async fn delete_admin_api_key(
&self,
context: &MutationContext,
) -> Result<AdminApiKeyMutation, AdminError>;
}
pub(crate) struct DefaultSettingsService {
store: Arc<dyn SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultSettingsService {
#[must_use]
pub(crate) fn new(store: Arc<dyn SettingsStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
Self { store, snapshot }
}
}
#[async_trait]
impl SettingsService for DefaultSettingsService {
async fn load(&self) -> Result<RuntimeSettings, AdminError> {
self.store
.load_runtime_settings()
.await
.map_err(|error| map_store_error(error, "runtime settings"))
}
async fn replace(
&self,
context: &MutationContext,
command: ReplaceRuntimeSettings,
) -> Result<RuntimeSettings, AdminError> {
validate_settings(&command)?;
let settings = self
.store
.replace_runtime_settings(command, context)
.await
.map_err(|error| map_store_error(error, "runtime settings"))?;
publish_committed(self.snapshot.as_ref(), settings.config_revision).await?;
Ok(settings)
}
async fn admin_api_key_exists(&self) -> Result<bool, AdminError> {
self.store
.admin_api_key_exists()
.await
.map_err(|error| map_store_error(error, "administrator API key"))
}
async fn regenerate_admin_api_key(
&self,
context: &MutationContext,
) -> Result<RegeneratedAdminApiKey, AdminError> {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let key = AdminApiKey::new(format!("admin-{}", hex::encode(bytes)));
let mutation = self
.store
.replace_admin_api_key(key.clone(), context)
.await
.map_err(|error| map_store_error(error, "administrator API key"))?;
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
Ok(RegeneratedAdminApiKey { mutation, key })
}
async fn delete_admin_api_key(
&self,
context: &MutationContext,
) -> Result<AdminApiKeyMutation, AdminError> {
let mutation = self
.store
.delete_admin_api_key(context)
.await
.map_err(|error| map_store_error(error, "administrator API key"))?;
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
Ok(mutation)
}
}
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
let valid = command.refresh_margin_seconds > 0
&& command.refresh_concurrency > 0
&& command.max_concurrent_per_account > 0
&& command.usage_retention_days >= 31
&& command.ops_event_retention_days > 0
&& command.audit_retention_days > 0
&& i64::try_from(command.request_interval_ms).is_ok();
if valid {
Ok(())
} else {
Err(AdminError::invalid("Runtime settings violate constraints"))
}
}
@@ -0,0 +1,98 @@
//! 系统管理用例。
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
model::{
AdminError, AdminErrorKind,
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
},
ports::system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream,
},
};
/// API 消费的系统管理服务。
#[async_trait]
pub trait SystemService: Send + Sync {
async fn version(&self) -> Result<SystemVersion, AdminError>;
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError>;
fn update_events(&self) -> SystemUpdateEventStream;
async fn perform_update(
&self,
target_version: Option<String>,
) -> Result<SystemOperationAccepted, AdminError>;
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError>;
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError>;
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError>;
}
/// 保持 Host 能力窄边界的默认系统用例。
pub(crate) struct DefaultSystemService {
operations: Arc<dyn SystemOperations>,
}
impl DefaultSystemService {
#[must_use]
pub(crate) const fn new(operations: Arc<dyn SystemOperations>) -> Self {
Self { operations }
}
}
#[async_trait]
impl SystemService for DefaultSystemService {
async fn version(&self) -> Result<SystemVersion, AdminError> {
self.operations.version().await.map_err(map_system_error)
}
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError> {
self.operations
.update_detail(refresh)
.await
.map_err(map_system_error)
}
fn update_events(&self) -> SystemUpdateEventStream {
self.operations.update_events()
}
async fn perform_update(
&self,
target_version: Option<String>,
) -> Result<SystemOperationAccepted, AdminError> {
let target_version = target_version
.map(|version| version.trim().to_owned())
.filter(|version| !version.is_empty());
self.operations
.perform_update(target_version)
.await
.map_err(map_system_error)
}
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError> {
self.operations
.update_status()
.await
.map_err(map_system_error)
}
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError> {
self.operations.rollback().await.map_err(map_system_error)
}
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError> {
self.operations.restart().await.map_err(map_system_error)
}
}
fn map_system_error(error: SystemOperationError) -> AdminError {
let kind = match error.kind() {
SystemOperationErrorKind::Invalid => AdminErrorKind::Invalid,
SystemOperationErrorKind::Conflict => AdminErrorKind::Conflict,
SystemOperationErrorKind::Upstream => AdminErrorKind::BadGateway,
SystemOperationErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, error.message())
}
@@ -0,0 +1,215 @@
//! xAI 管理资源的中立 ProviderAdmin 委托。
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::routing::snapshot::SnapshotControl;
use crate::{
model::{
AdminError,
provider_credentials::{
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
CredentialListQuery, CredentialMutation, CredentialMutationResult, CredentialPage,
ImportCredentials, PrepareCredentialImport, ProviderQuotaRequest, StartAuthorization,
},
},
ports::{provider::ProviderAdmin, store::AccountStore},
};
use super::{
commit_authorization, delete_credentials, map_provider_error, map_store_error,
pending_authorization, publish_committed, set_credential_enabled,
validate_authorization_commit, validate_prepared_import,
};
/// xAI 固定管理路由消费的服务。
#[async_trait]
pub trait XaiService: Send + Sync {
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError>;
async fn import_document(
&self,
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError>;
async fn start_authorization(
&self,
command: StartAuthorization,
) -> Result<AuthorizationStarted, AdminError>;
async fn complete_authorization(
&self,
command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError>;
async fn enable(
&self,
command: CredentialMutation,
) -> Result<CredentialMutationResult, AdminError>;
async fn disable(
&self,
command: CredentialMutation,
) -> Result<CredentialMutationResult, AdminError>;
async fn delete(
&self,
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError>;
}
pub(crate) struct DefaultXaiService {
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultXaiService {
#[must_use]
pub(crate) fn new(
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
provider,
accounts,
snapshot,
}
}
}
#[async_trait]
impl XaiService for DefaultXaiService {
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError> {
self.accounts
.list_credentials(self.provider.provider_kind(), query)
.await
.map_err(|error| map_store_error(error, "xAI credential"))
}
async fn import_document(
&self,
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
document: command.document,
})
.await
.map_err(|error| map_provider_error(error, "xAI credential import"))?;
validate_prepared_import(
self.provider.provider_kind(),
&prepared,
"xAI credential import",
)?;
let result = self
.accounts
.commit_credential_import(CredentialImportCommit { prepared }, &context)
.await
.map_err(|error| map_store_error(error, "xAI credential import"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
// 导入已经提交,额度属于可重建观察事实;单个账号查询失败由周期任务重试。
for account_id in &result.credential_ids {
let _ = self
.provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await;
}
Ok(result)
}
async fn start_authorization(
&self,
command: StartAuthorization,
) -> Result<AuthorizationStarted, AdminError> {
let pending = pending_authorization(
self.accounts.as_ref(),
self.provider.provider_kind(),
&command,
"xAI credential",
)
.await?;
self.provider
.start_authorization(pending)
.await
.map_err(|error| map_provider_error(error, "xAI authorization"))
}
async fn complete_authorization(
&self,
command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.context.clone();
let prepared = self
.provider
.complete_authorization(command)
.await
.map_err(|error| map_provider_error(error, "xAI authorization"))?;
let prepared = validate_authorization_commit(
self.provider.provider_kind(),
&context,
prepared,
"xAI authorization",
)
.await?;
let result = commit_authorization(
self.accounts.as_ref(),
prepared,
&context,
"xAI authorization",
)
.await?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn enable(
&self,
command: CredentialMutation,
) -> Result<CredentialMutationResult, AdminError> {
let result = set_credential_enabled(
self.accounts.as_ref(),
self.provider.as_ref(),
command,
true,
"xAI credential",
)
.await?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn disable(
&self,
command: CredentialMutation,
) -> Result<CredentialMutationResult, AdminError> {
let result = set_credential_enabled(
self.accounts.as_ref(),
self.provider.as_ref(),
command,
false,
"xAI credential",
)
.await?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn delete(
&self,
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError> {
let result = delete_credentials(
self.accounts.as_ref(),
self.provider.as_ref(),
command,
"xAI credential",
)
.await?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
}
+150
View File
@@ -0,0 +1,150 @@
use std::str::FromStr as _;
use chrono::{TimeDelta, Utc};
use gateway_core::engine::credential::OpaqueProviderData;
use gateway_admin::{
model::{
PageSize, Revision,
auth::LoginCommand,
client_keys::ClientKeyPageSize,
observability::{DecimalAmount, RequestOutcome, TimeRange},
provider_credentials::{
CredentialCommitGuard, ProviderDocument, ProviderQuota, ProviderQuotaWindow,
},
settings::AdminApiKey,
},
ports::store::{AccountStore, AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore},
};
mod use_case;
#[test]
fn revision_should_reject_zero() {
assert!(Revision::new(0).is_err());
}
#[test]
fn page_size_should_accept_frozen_upper_bound() {
assert_eq!(PageSize::new(200).map(PageSize::get), Ok(200));
}
#[test]
fn page_size_should_reject_value_above_frozen_upper_bound() {
assert!(PageSize::new(201).is_err());
}
#[test]
fn client_key_page_size_should_keep_the_full_nonzero_u16_contract() {
assert!(ClientKeyPageSize::new(0).is_err());
assert_eq!(
ClientKeyPageSize::new(u16::MAX).map(ClientKeyPageSize::get),
Ok(u16::MAX)
);
}
#[test]
fn request_outcome_filter_should_preserve_known_and_bounded_other_values() {
let known = RequestOutcome::new("succeeded").expect("known outcome");
let other = RequestOutcome::new("provider_future_state").expect("other outcome");
assert_eq!(known, RequestOutcome::Succeeded);
assert!(matches!(&other, RequestOutcome::Other(_)));
assert_eq!(other.as_str(), "provider_future_state");
assert!(RequestOutcome::new("").is_err());
assert!(RequestOutcome::new("a".repeat(RequestOutcome::MAX_BYTES + 1)).is_err());
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();
assert!(TimeRange::new(end - TimeDelta::days(366) - TimeDelta::seconds(1), end).is_err());
}
#[test]
fn decimal_amount_should_canonicalize_redundant_zeroes() {
assert_eq!(
DecimalAmount::from_str("00012.34000").map(|amount| amount.to_string()),
Ok("12.34".to_owned())
);
}
#[test]
fn admin_api_key_debug_should_redact_plaintext() {
let key = AdminApiKey::new("secret-admin-key");
assert!(!format!("{key:?}").contains("secret-admin-key"));
}
#[test]
fn login_command_debug_should_redact_password() {
let command = LoginCommand {
username: None,
password: "secret-password".to_owned(),
};
assert!(!format!("{command:?}").contains("secret-password"));
}
#[test]
fn all_store_capability_traits_should_be_object_safe() {
fn assert_object_safe<T: ?Sized + Send + Sync>() {}
assert_object_safe::<dyn AccountStore>();
assert_object_safe::<dyn AuthStore>();
assert_object_safe::<dyn ClientKeyStore>();
assert_object_safe::<dyn ObservabilityStore>();
assert_object_safe::<dyn SettingsStore>();
fn assert_send_object_safe<T: ?Sized + Send>() {}
assert_send_object_safe::<dyn CredentialCommitGuard>();
}
#[test]
fn provider_document_debug_should_not_expose_opaque_material() {
let document = ProviderDocument::new(OpaqueProviderData::new(Default::default()));
assert_eq!(
format!("{document:?}"),
"ProviderDocument([PROVIDER_OWNED])"
);
}
#[test]
fn representative_quota_should_prefer_short_window_and_highest_usage() {
let quota = ProviderQuota {
observed_at: None,
refresh_token_expires_at: None,
windows: vec![
quota_window("monthly", Some(2_592_000), Some(99.0)),
quota_window("shortTerm", Some(604_800), Some(80.0)),
quota_window("shortTerm", Some(18_000), Some(30.0)),
quota_window("shortTerm", Some(14_400), Some(45.0)),
],
provider_data: None,
};
assert_eq!(quota.representative_used_percent(), Some(45.0));
}
fn quota_window(
group: &str,
window_seconds: Option<u64>,
used_percent: Option<f64>,
) -> ProviderQuotaWindow {
ProviderQuotaWindow {
key: group.to_owned(),
group: group.to_owned(),
label: group.to_owned(),
source: None,
window_seconds,
used_percent,
reset_at: None,
local_usage: None,
provider_data: None,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,153 @@
use std::{collections::BTreeMap, sync::Mutex};
use async_trait::async_trait;
use chrono::{TimeDelta, Utc};
use gateway_admin::{
model::{
auth::{AdminAuditEvent, AdminSession, LoginCommand},
settings::AdminApiKey,
},
ports::store::{AdminStoreResult, AuthStore},
};
#[derive(Default)]
struct MemoryAuthStore {
password_hash: Mutex<Option<String>>,
sessions: Mutex<BTreeMap<String, AdminSession>>,
audits: Mutex<Vec<AdminAuditEvent>>,
}
#[async_trait]
impl AuthStore for MemoryAuthStore {
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn create_password_hash_if_absent(
&self,
_: &str,
password_hash: &str,
) -> AdminStoreResult<bool> {
let mut stored = self.password_hash.lock().expect("password hash");
if stored.is_some() {
return Ok(false);
}
*stored = Some(password_hash.to_owned());
Ok(true)
}
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>> {
Ok(None)
}
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
Ok(self
.sessions
.lock()
.expect("sessions")
.get(session_id)
.cloned())
}
async fn store_session(
&self,
session_id: &str,
session: &AdminSession,
) -> AdminStoreResult<()> {
self.sessions
.lock()
.expect("sessions")
.insert(session_id.to_owned(), session.clone());
Ok(())
}
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
Ok(self.sessions.lock().expect("sessions").remove(session_id))
}
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
self.audits.lock().expect("audits").push(event);
Ok(())
}
}
#[tokio::test]
async fn successful_login_should_create_expiring_session_and_audit() {
let store = std::sync::Arc::new(MemoryAuthStore::default());
let services = super::AdminHarness::new().auth(store.clone()).build().await;
let result = services
.auth()
.login(LoginCommand {
username: Some("admin".to_owned()),
password: "strong-test-password".to_owned(),
})
.await
.expect("login");
assert!(
services
.auth()
.validate_session(Some(&result.session_id))
.await
.expect("validate")
);
assert_eq!(store.audits.lock().expect("audits").len(), 1);
}
#[tokio::test]
async fn repeated_default_initialization_should_not_replace_password() {
let store = std::sync::Arc::new(MemoryAuthStore::default());
super::AdminHarness::new()
.auth(store.clone())
.default_password("first-strong-password")
.build()
.await;
let services = super::AdminHarness::new()
.auth(store)
.default_password("second-strong-password")
.build()
.await;
assert!(
services
.auth()
.login(LoginCommand {
username: Some("admin".to_owned()),
password: "first-strong-password".to_owned(),
})
.await
.is_ok()
);
}
#[tokio::test]
async fn login_with_huge_session_ttl_should_clamp_expiry_instead_of_panicking() {
let store = std::sync::Arc::new(MemoryAuthStore::default());
let services = super::AdminHarness::new()
.auth(store.clone())
.session_ttl_minutes(i64::MAX as u64)
.build()
.await;
let before = Utc::now();
let result = services
.auth()
.login(LoginCommand {
username: Some("admin".to_owned()),
password: "strong-test-password".to_owned(),
})
.await
.expect("login with huge session TTL");
assert!(result.expires_at > before);
assert!(result.expires_at <= before + TimeDelta::days(366) + TimeDelta::minutes(1));
assert!(
services
.auth()
.validate_session(Some(&result.session_id))
.await
.expect("validate")
);
}
@@ -0,0 +1,127 @@
use async_trait::async_trait;
use gateway_core::policy::ClientApiKeyId;
use gateway_admin::{
model::{
AdminErrorKind, MutationContext, Revision,
client_keys::{
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPage,
ClientKeyPageSize, ClientKeyRecord, ClientKeySecret, ClientKeySort, ClientKeySortField,
DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
},
},
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ClientKeyStore},
};
struct UnusedClientKeyStore;
#[async_trait]
impl ClientKeyStore for UnusedClientKeyStore {
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
assert_eq!(query.page_size.get(), u16::MAX);
Ok(ClientKeyPage {
config_revision: Revision::new(1).expect("revision"),
items: Vec::new(),
total: 0,
next_cursor: None,
})
}
async fn reveal_client_key(
&self,
_: &ClientApiKeyId,
) -> AdminStoreResult<Option<ClientKeySecret>> {
Err(unused())
}
async fn create_client_key(
&self,
_: NewClientKey,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
Err(unused())
}
async fn update_client_key(
&self,
_: UpdateClientKey,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
Err(unused())
}
async fn set_client_key_enabled(
&self,
_: SetClientKeyEnabled,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
Err(unused())
}
async fn delete_client_key(
&self,
_: DeleteClientKey,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unused())
}
}
#[tokio::test]
async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
let services = super::AdminHarness::new()
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
.build()
.await;
let sort = ClientKeySort {
field: ClientKeySortField::Name,
direction: SortDirection::Asc,
};
let error = services
.client_keys()
.list(ClientKeyListQuery {
cursor: Some(ClientKeyCursor {
sort,
value: ClientKeyCursorValue::Enabled(true),
id: ClientApiKeyId::new("key_cursor").expect("key ID"),
}),
page_size: ClientKeyPageSize::new(50).expect("page size"),
search: None,
sort,
})
.await
.expect_err("mismatched cursor must fail");
assert_eq!(error.kind(), AdminErrorKind::Invalid);
}
#[tokio::test]
async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
let services = super::AdminHarness::new()
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
.build()
.await;
let page = services
.client_keys()
.list(ClientKeyListQuery {
cursor: None,
page_size: ClientKeyPageSize::new(u16::MAX).expect("maximum page size"),
search: None,
sort: ClientKeySort {
field: ClientKeySortField::CreatedAt,
direction: SortDirection::Desc,
},
})
.await
.expect("maximum page size should reach store");
assert_eq!(page.total, 0);
}
fn unused() -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
"client key",
"unused in this test",
)
}
@@ -0,0 +1,714 @@
mod accounts;
mod auth;
mod client_keys;
mod observability;
mod openai;
mod settings;
mod system;
mod xai;
use std::{
str::FromStr,
sync::{Arc, Mutex},
};
use async_trait::async_trait;
use futures::future::BoxFuture;
use gateway_admin::{
AdminConfig, AdminServices, InitialAdminPassword,
model::{
MutationContext, Revision,
accounts::{
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
},
auth::{AdminAuditEvent, AdminSession},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
},
observability::{
DashboardDesktopRelease, DashboardObservation, DashboardWireAttribute,
DashboardWireProfile, DashboardWireTarget, DesktopReleaseStatus, DiagnosticDimension,
DiagnosticObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
},
provider_credentials::{
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
CredentialImportCommit, CredentialImportResult, CredentialListQuery,
CredentialMutationResult, CredentialPage, CredentialRotationCommit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput,
ProviderModels, ProviderQuota,
},
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
},
ports::{
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
store::{
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStorePorts, AdminStoreResult,
AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore,
},
system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations,
SystemUpdateEventStream,
},
},
};
use gateway_core::{
engine::{
credential::ProviderAccountId,
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
},
error::{GatewayError, GatewayErrorKind},
policy::ClientApiKeyId,
routing::{ConfigRevision, ProviderKind, snapshot::SnapshotControl},
};
pub(super) struct AdminHarness {
default_password: String,
session_ttl_minutes: u64,
accounts: Arc<dyn AccountStore>,
auth: Arc<dyn AuthStore>,
client_keys: Arc<dyn ClientKeyStore>,
observability: Arc<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
providers: Vec<Arc<dyn ProviderAdmin>>,
probe: Arc<dyn AccountProbe>,
system: Arc<dyn SystemOperations>,
}
impl AdminHarness {
pub(super) fn new() -> Self {
let unavailable = Arc::new(UnavailableStore);
Self {
default_password: "strong-test-password".to_owned(),
session_ttl_minutes: 60,
accounts: unavailable.clone(),
auth: Arc::new(BootstrapAuthStore::default()),
client_keys: unavailable.clone(),
observability: unavailable.clone(),
settings: unavailable,
providers: vec![
Arc::new(UnavailableProvider::new("openai")),
Arc::new(UnavailableProvider::new("xai")),
],
probe: Arc::new(UnavailableProbe),
system: Arc::new(UnavailableSystem),
}
}
pub(super) fn default_password(mut self, password: &str) -> Self {
self.default_password = password.to_owned();
self
}
pub(super) fn session_ttl_minutes(mut self, minutes: u64) -> Self {
self.session_ttl_minutes = minutes;
self
}
pub(super) fn accounts(mut self, store: Arc<dyn AccountStore>) -> Self {
self.accounts = store;
self
}
pub(super) fn auth(mut self, store: Arc<dyn AuthStore>) -> Self {
self.auth = store;
self
}
pub(super) fn client_keys(mut self, store: Arc<dyn ClientKeyStore>) -> Self {
self.client_keys = store;
self
}
pub(super) fn observability(mut self, store: Arc<dyn ObservabilityStore>) -> Self {
self.observability = store;
self
}
pub(super) fn settings(mut self, store: Arc<dyn SettingsStore>) -> Self {
self.settings = store;
self
}
pub(super) fn provider(mut self, provider: Arc<dyn ProviderAdmin>) -> Self {
self.providers
.retain(|registered| registered.provider_kind() != provider.provider_kind());
self.providers.push(provider);
self
}
pub(super) fn probe(mut self, probe: Arc<dyn AccountProbe>) -> Self {
self.probe = probe;
self
}
pub(super) fn system(mut self, system: Arc<dyn SystemOperations>) -> Self {
self.system = system;
self
}
pub(super) async fn build(self) -> AdminServices {
gateway_admin::initialize(
AdminConfig {
session_ttl_minutes: self.session_ttl_minutes,
default_username: "admin".to_owned(),
default_password: InitialAdminPassword::new(self.default_password),
},
AdminStorePorts::new(
self.accounts,
self.auth,
self.client_keys,
self.observability,
self.settings,
),
self.providers,
Arc::new(NoopSnapshot),
self.probe,
self.system,
)
.await
.expect("initialize admin test harness")
.services()
}
}
#[derive(Default)]
struct BootstrapAuthStore {
password_hash: Mutex<Option<String>>,
}
#[async_trait]
impl AuthStore for BootstrapAuthStore {
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn create_password_hash_if_absent(
&self,
_: &str,
password_hash: &str,
) -> AdminStoreResult<bool> {
let mut stored = self.password_hash.lock().expect("password hash");
if stored.is_some() {
return Ok(false);
}
*stored = Some(password_hash.to_owned());
Ok(true)
}
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>> {
Ok(None)
}
async fn load_session(&self, _: &str) -> AdminStoreResult<Option<AdminSession>> {
Ok(None)
}
async fn store_session(&self, _: &str, _: &AdminSession) -> AdminStoreResult<()> {
Err(unavailable("admin session"))
}
async fn delete_session(&self, _: &str) -> AdminStoreResult<Option<AdminSession>> {
Err(unavailable("admin session"))
}
async fn append_audit_event(&self, _: AdminAuditEvent) -> AdminStoreResult<()> {
Ok(())
}
}
struct UnavailableStore;
#[async_trait]
impl AccountStore for UnavailableStore {
async fn list_accounts(&self, _: AccountListQuery) -> AdminStoreResult<AccountPage> {
Err(unavailable("accounts"))
}
async fn load_account(&self, _: &str) -> AdminStoreResult<Option<AccountRecord>> {
Err(unavailable("account"))
}
async fn load_account_usage(
&self,
_: TimeRange,
_: &[String],
) -> AdminStoreResult<Vec<AccountUsage>> {
Err(unavailable("account usage"))
}
async fn load_account_usage_by_windows(
&self,
_: &[AccountUsageWindowQuery],
) -> AdminStoreResult<Vec<AccountUsageWindowResult>> {
Err(unavailable("account quota window usage"))
}
async fn list_credentials(
&self,
_: &ProviderKind,
_: CredentialListQuery,
) -> AdminStoreResult<CredentialPage> {
Err(unavailable("credentials"))
}
async fn credential_details(
&self,
_: &ProviderKind,
_: &ProviderAccountId,
) -> AdminStoreResult<Option<CredentialDetails>> {
Err(unavailable("credential"))
}
async fn load_credentials_for_export(
&self,
_: &ProviderKind,
_: &[ProviderAccountId],
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>> {
Err(unavailable("credential export"))
}
async fn commit_credential_import(
&self,
_: CredentialImportCommit,
_: &MutationContext,
) -> AdminStoreResult<CredentialImportResult> {
Err(unavailable("credential import"))
}
async fn commit_authorization(
&self,
_: AuthorizationCommit,
_: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
Err(unavailable("authorization"))
}
async fn commit_credential_rotation(
&self,
_: CredentialRotationCommit,
_: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
Err(unavailable("credential rotation"))
}
async fn commit_credential_refresh(
&self,
_: CredentialRotationCommit,
_: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
Err(unavailable("credential refresh"))
}
async fn set_account_enabled(
&self,
_: SetAccountEnabled,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable("account enabled"))
}
async fn delete_accounts(
&self,
_: DeleteAccounts,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable("account delete"))
}
async fn record_credential_export(
&self,
_: &[ProviderAccountId],
_: &MutationContext,
) -> AdminStoreResult<()> {
Err(unavailable("credential export audit"))
}
}
#[async_trait]
impl ClientKeyStore for UnavailableStore {
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
Err(unavailable("client key list"))
}
async fn reveal_client_key(
&self,
_: &ClientApiKeyId,
) -> AdminStoreResult<Option<ClientKeySecret>> {
Err(unavailable("client key"))
}
async fn create_client_key(
&self,
_: NewClientKey,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
Err(unavailable("client key create"))
}
async fn update_client_key(
&self,
_: UpdateClientKey,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
Err(unavailable("client key update"))
}
async fn set_client_key_enabled(
&self,
_: SetClientKeyEnabled,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
Err(unavailable("client key enabled"))
}
async fn delete_client_key(
&self,
_: DeleteClientKey,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable("client key delete"))
}
}
#[async_trait]
impl ObservabilityStore for UnavailableStore {
async fn dashboard_summary(&self, _: TimeRange) -> AdminStoreResult<DashboardObservation> {
Err(unavailable("dashboard"))
}
async fn dashboard_trend(&self, _: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>> {
Err(unavailable("dashboard trend"))
}
async fn usage_trend(
&self,
_: TimeRange,
_: UsageFilter,
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
Err(unavailable("usage trend"))
}
async fn usage_calculated_billing_facts(
&self,
_: TimeRange,
_: UsageFilter,
) -> AdminStoreResult<Vec<gateway_admin::model::observability::UsageCalculatedBillingFact>>
{
Err(unavailable("usage billing facts"))
}
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
Err(unavailable("usage records"))
}
async fn usage_record_detail(&self, _: &str) -> AdminStoreResult<UsageDetail> {
Err(unavailable("usage detail"))
}
async fn usage_summary(&self, _: TimeRange, _: UsageFilter) -> AdminStoreResult<UsageOverview> {
Err(unavailable("usage summary"))
}
async fn usage_diagnostics(
&self,
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
Err(unavailable("usage diagnostics"))
}
async fn list_ops_errors(&self, _: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
Err(unavailable("ops errors"))
}
}
#[async_trait]
impl SettingsStore for UnavailableStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unavailable("settings"))
}
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
Err(unavailable("admin API key"))
}
async fn replace_runtime_settings(
&self,
_: ReplaceRuntimeSettings,
_: &MutationContext,
) -> AdminStoreResult<RuntimeSettings> {
Err(unavailable("settings"))
}
async fn replace_admin_api_key(
&self,
_: AdminApiKey,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(unavailable("admin API key"))
}
async fn delete_admin_api_key(
&self,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(unavailable("admin API key"))
}
}
struct UnavailableProvider {
kind: ProviderKind,
dashboard_profile: Option<DashboardWireProfile>,
calculated_billing: Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
}
impl UnavailableProvider {
fn new(kind: &str) -> Self {
Self {
kind: ProviderKind::new(kind).expect("provider kind"),
dashboard_profile: None,
calculated_billing: None,
}
}
}
#[async_trait]
impl ProviderAdmin for UnavailableProvider {
fn provider_kind(&self) -> &ProviderKind {
&self.kind
}
async fn account_unavailable(&self, _: &ProviderAccountId) {}
fn connection_test_operation(
&self,
_: &gateway_core::routing::UpstreamModelId,
_: &str,
) -> Result<gateway_core::operation::Operation, ProviderAdminError> {
Err(unsupported_provider())
}
fn dashboard_wire_profile(&self) -> Option<DashboardWireProfile> {
self.dashboard_profile.clone()
}
fn calculated_billing(
&self,
_: &gateway_admin::model::observability::ProviderBillingInput,
) -> Result<
Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
ProviderAdminError,
> {
Ok(self.calculated_billing.clone())
}
async fn prepare_import(
&self,
_: PrepareCredentialImport,
) -> Result<PreparedCredentialImport, ProviderAdminError> {
Err(unsupported_provider())
}
async fn start_authorization(
&self,
_: PendingAuthorizationMutation,
) -> Result<AuthorizationStarted, ProviderAdminError> {
Err(unsupported_provider())
}
async fn complete_authorization(
&self,
_: CompleteAuthorization,
) -> Result<PreparedAuthorizationCommit, ProviderAdminError> {
Err(unsupported_provider())
}
async fn prepare_rotation(
&self,
_: PrepareCredentialRotation,
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
Err(unsupported_provider())
}
async fn prepare_refresh(
&self,
_: PrepareCredentialRefresh,
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
Err(unsupported_provider())
}
async fn quota(
&self,
_: gateway_admin::model::provider_credentials::ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError> {
Err(unsupported_provider())
}
async fn models(
&self,
_: &ProviderAccountId,
_: bool,
) -> Result<ProviderModels, ProviderAdminError> {
Err(unsupported_provider())
}
async fn export_credentials(
&self,
_: Vec<ProviderExportCredentialInput>,
) -> Result<ProviderExport, ProviderAdminError> {
Err(unsupported_provider())
}
}
pub(super) fn dashboard_profile_provider() -> Arc<dyn ProviderAdmin> {
Arc::new(UnavailableProvider {
kind: ProviderKind::new("openai").expect("provider kind"),
dashboard_profile: Some(DashboardWireProfile {
provider: "openai".to_owned(),
product: "gateway-admin-test".to_owned(),
version: "test".to_owned(),
build: Some("test".to_owned()),
target: DashboardWireTarget {
os_type: "linux".to_owned(),
os_version: "test".to_owned(),
arch: "x86_64".to_owned(),
terminal: "test".to_owned(),
},
user_agent: "gateway-admin-test".to_owned(),
attributes: vec![DashboardWireAttribute {
label: "Core".to_owned(),
value: "test".to_owned(),
}],
verified_at: Some(chrono::Utc::now()),
release: Some(DashboardDesktopRelease {
status: DesktopReleaseStatus::Unchecked,
checked_at: None,
latest_version: None,
latest_build: None,
published_at: None,
minimum_system_version: None,
hardware_requirements: None,
download_url: None,
download_size: None,
signature_present: None,
error: None,
}),
}),
calculated_billing: None,
})
}
pub(super) fn calculated_billing_provider() -> Arc<dyn ProviderAdmin> {
let amount = |value| gateway_admin::model::observability::CurrencyCost {
currency: "USD".to_owned(),
amount: gateway_admin::model::observability::DecimalAmount::from_str(value)
.expect("test billing amount"),
};
Arc::new(UnavailableProvider {
kind: ProviderKind::new("openai").expect("provider kind"),
dashboard_profile: None,
calculated_billing: Some(
gateway_admin::model::observability::CalculatedBillingBreakdown {
input_amount: amount("0.8"),
output_amount: amount("0.2"),
cache_read_amount: amount("0"),
cache_write_amount: amount("0"),
standard_amount: amount("1"),
total_amount: amount("1.25"),
input_price_per_million: amount("0"),
output_price_per_million: amount("0"),
cache_read_price_per_million: amount("0"),
cache_write_price_per_million: amount("0"),
service_tier: None,
multiplier_percent: 125,
},
),
})
}
struct NoopSnapshot;
impl SnapshotControl for NoopSnapshot {
fn publish_committed(&self, _: ConfigRevision) -> BoxFuture<'_, ()> {
Box::pin(async {})
}
}
struct UnavailableProbe;
impl AccountProbe for UnavailableProbe {
fn probe(
&self,
_: AccountProbeRequest,
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async {
Err(GatewayError::new(
GatewayErrorKind::Internal,
"test account probe is unavailable",
)
.into())
})
}
}
struct UnavailableSystem;
#[async_trait]
impl SystemOperations for UnavailableSystem {
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
Err(unavailable_system())
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
Err(unavailable_system())
}
fn update_events(&self) -> SystemUpdateEventStream {
Box::pin(futures::stream::empty())
}
async fn perform_update(
&self,
_: Option<String>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
Err(unavailable_system())
}
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
}
fn unavailable(resource: &'static str) -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
resource,
"unavailable in this test",
)
}
fn unsupported_provider() -> ProviderAdminError {
ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)
}
fn unavailable_system() -> SystemOperationError {
SystemOperationError::new(
SystemOperationErrorKind::Internal,
"unavailable in this test",
)
}
@@ -0,0 +1,678 @@
use std::{
num::NonZeroU32,
str::FromStr as _,
sync::{Arc, Mutex},
};
use async_trait::async_trait;
use chrono::{DateTime, Duration, Timelike as _, Utc};
use gateway_admin::{
AdminServices,
model::{
MutationContext, PageSize, Revision,
observability::{
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
HealthStatus, OpsErrorPage, OpsErrorQuery, PageNumber, RequestMetricPoint,
RequestMetrics, RequestOutcome, TimeRange, TrendKind, UsageBilling,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageOverview, UsagePage,
UsageQuery, UsageRecord, china_day_start,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
RuntimeSettings,
},
},
ports::store::{AdminStoreResult, ObservabilityStore, SettingsStore},
};
#[test]
fn external_observability_range_accepts_exactly_366_days() {
let end = Utc::now();
let range = TimeRange::new(end - Duration::days(366), end)
.expect("366-day external range should be accepted");
assert_eq!(range.end, end);
}
#[test]
fn external_observability_range_rejects_over_366_days_and_reversed_range() {
let end = Utc::now();
assert!(TimeRange::new(end - Duration::days(367), end).is_err());
assert!(TimeRange::new(end, end).is_err());
assert!(TimeRange::new(end + Duration::seconds(1), end).is_err());
}
#[tokio::test]
async fn health_timeline_should_keep_exactly_china_day_quarter_hour_slots() {
let now = Utc::now();
let day_start = china_day_start(now);
let current_slot = quarter_hour_start(now);
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
store.replace_trend(vec![
health_metric_point(
day_start - Duration::minutes(15),
RequestMetrics {
success_count: 100,
..RequestMetrics::default()
},
),
health_metric_point(
current_slot,
RequestMetrics {
success_count: 2,
..RequestMetrics::default()
},
),
health_metric_point(
current_slot + Duration::minutes(15),
RequestMetrics {
success_count: 100,
..RequestMetrics::default()
},
),
]);
let services = observability_services(store).await;
let timeline = services
.observability()
.dashboard_summary(observation_range(now), TrendKind::Usage)
.await
.expect("dashboard summary")
.health_timeline;
assert_eq!(timeline.points.len(), 96);
assert_eq!(
timeline.points.first().map(|point| point.bucket_start),
Some(day_start)
);
assert_eq!(
timeline.points.last().map(|point| point.bucket_start),
Some(day_start + Duration::minutes(95 * 15))
);
assert_eq!(timeline.success_requests, 2);
assert_eq!(
timeline
.points
.iter()
.find(|point| point.bucket_start == current_slot)
.map(|point| point.status),
Some(HealthStatus::LowSample)
);
assert!(timeline.points.iter().all(|point| {
point.bucket_start <= quarter_hour_start(Utc::now())
|| (point.status == HealthStatus::Future && point.success_requests == 0)
}));
}
#[tokio::test]
async fn health_timeline_should_match_legacy_status_precedence_and_thresholds() {
let now = Utc::now();
let current_slot = quarter_hour_start(now);
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
let services = observability_services(store.clone()).await;
let fixtures = [
(
RequestMetrics {
failure_count: 1,
cancelled_count: 1,
incomplete_count: 1,
caller_error_count: 1,
..RequestMetrics::default()
},
HealthStatus::NoData,
None,
),
(
RequestMetrics {
failure_count: 3,
..RequestMetrics::default()
},
HealthStatus::Unavailable,
Some(0.0),
),
(
RequestMetrics {
success_count: 1,
failure_count: 1,
..RequestMetrics::default()
},
HealthStatus::LowSample,
Some(50.0),
),
(
RequestMetrics {
success_count: 98,
failure_count: 2,
..RequestMetrics::default()
},
HealthStatus::Unstable,
Some(98.0),
),
(
RequestMetrics {
success_count: 99,
failure_count: 1,
..RequestMetrics::default()
},
HealthStatus::Stable,
Some(99.0),
),
];
for (metrics, expected_status, expected_reliability) in fixtures {
store.replace_trend(vec![health_metric_point(current_slot, metrics)]);
let timeline = services
.observability()
.dashboard_summary(observation_range(now), TrendKind::Usage)
.await
.expect("dashboard summary")
.health_timeline;
let point = timeline
.points
.iter()
.find(|point| point.bucket_start == current_slot)
.expect("current health slot");
assert_eq!(point.status, expected_status);
assert_eq!(point.reliability_percent, expected_reliability);
}
}
#[tokio::test]
async fn dashboard_summary_should_project_rebuildable_runtime_slots() {
let now = Utc::now();
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
store.replace_runtime_slots(Some(DashboardRuntimeSlots {
active_accounts: 3,
used_slots: Some(2),
}));
let services = observability_services(store).await;
let capacity = services
.observability()
.dashboard_summary(observation_range(now), TrendKind::Usage)
.await
.expect("dashboard summary")
.capacity;
assert_eq!(capacity.total_slots, 3);
assert_eq!(capacity.used_slots, Some(2));
assert_eq!(capacity.available_slots, Some(1));
}
#[tokio::test]
async fn observability_services_should_calculate_usage_insights_and_diagnostic_shares() {
let now = Utc::now();
let range = observation_range(now);
let store = Arc::new(FixtureObservabilityStore::new(range));
let metrics = RequestMetrics {
request_count: 10,
success_count: 6,
failure_count: 4,
caller_error_count: 2,
input_tokens: 800,
output_tokens: 200,
total_tokens: 1_000,
latency_count: 5,
..RequestMetrics::default()
};
store.replace_overview(UsageOverview {
range,
requests: metrics.clone(),
attempts: AttemptMetrics {
attempt_count: 12,
cost_coverage: CostCoverage {
calculated_count: 10,
..CostCoverage::default()
},
costs: vec![CurrencyCost {
currency: "USD".to_owned(),
amount: gateway_admin::model::observability::DecimalAmount::from_str("1.25")
.expect("USD cost"),
}],
..AttemptMetrics::default()
},
providers: Vec::new(),
});
store.replace_trend(vec![RequestMetricPoint {
bucket_start: quarter_hour_start(now),
granularity: Granularity::FifteenMinutes,
metrics,
cost_coverage: CostCoverage::default(),
costs: vec![CurrencyCost {
currency: "USD".to_owned(),
amount: gateway_admin::model::observability::DecimalAmount::from_str("0.25")
.expect("bucket USD cost"),
}],
}]);
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
bucket_start: quarter_hour_start(now),
provider_kind: "openai".to_owned(),
upstream_model_id: "gpt-5.5".to_owned(),
service_tier: None,
input_tokens: Some(800),
output_tokens: Some(200),
cached_tokens: Some(0),
cache_write_tokens: Some(0),
total: CurrencyCost {
currency: "USD".to_owned(),
amount: gateway_admin::model::observability::DecimalAmount::from_str("1.25")
.expect("calculated total"),
},
}]);
store.replace_diagnostics(vec![diagnostic("openai", 3), diagnostic("xai", 1)]);
let services = observability_services_with_calculated_billing(store).await;
let insights = services
.observability()
.usage_insights(range, UsageFilter::default())
.await
.expect("usage insights");
let diagnostics = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Provider)
.await
.expect("usage diagnostics");
assert_eq!(insights.health.total_requests, 8);
assert_eq!(insights.health.failed_requests, 2);
assert_eq!(insights.granularity, Granularity::FifteenMinutes);
assert_eq!(insights.performance.latency_coverage, 0.5);
assert_eq!(insights.cost.tokens_per_request, 100.0);
assert_eq!(
insights
.cost
.estimated_cost
.as_ref()
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("1.25")
);
assert_eq!(
insights
.cost
.cost_per_request
.as_ref()
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("0.125")
);
assert_eq!(
insights.cost.points[0]
.estimated_cost
.as_ref()
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("0.25")
);
assert_eq!(
insights
.cost
.standard_cost
.as_ref()
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("1")
);
assert_eq!(
insights.cost.points[0]
.standard_cost
.as_ref()
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("1")
);
assert_eq!(diagnostics.items[0].request_share, 0.75);
assert_eq!(diagnostics.items[1].request_share, 0.25);
}
#[tokio::test]
async fn usage_records_should_tolerate_records_that_fail_billing_enrichment() {
let now = Utc::now();
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
let invalid_kind = "x".repeat(65);
store.replace_usage_records(vec![
calculated_total_record("request_invalid_kind", Some(&invalid_kind), now),
calculated_total_record("request_unregistered_kind", Some("anthropic"), now),
calculated_total_record("request_enriched", Some("openai"), now),
]);
let services = observability_services_with_calculated_billing(store).await;
let page = services
.observability()
.usage_records(usage_query(now))
.await
.expect("one bad record must not fail the whole usage list");
assert_eq!(page.items.len(), 3);
assert!(
matches!(page.items[0].billing, Some(UsageBilling::Total { .. })),
"invalid Provider kind keeps the stored total"
);
assert!(
matches!(page.items[1].billing, Some(UsageBilling::Total { .. })),
"unregistered Provider kind keeps the stored total"
);
assert!(
matches!(page.items[2].billing, Some(UsageBilling::Calculated(_))),
"healthy record is still enriched"
);
}
struct FixtureObservabilityStore {
trend: Mutex<Vec<RequestMetricPoint>>,
overview: Mutex<UsageOverview>,
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
diagnostics: Mutex<Vec<DiagnosticObservation>>,
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
usage_records: Mutex<Vec<UsageRecord>>,
}
impl FixtureObservabilityStore {
fn new(range: TimeRange) -> Self {
Self {
trend: Mutex::new(Vec::new()),
overview: Mutex::new(UsageOverview {
range,
requests: RequestMetrics::default(),
attempts: AttemptMetrics::default(),
providers: Vec::new(),
}),
calculated_billing_facts: Mutex::new(Vec::new()),
diagnostics: Mutex::new(Vec::new()),
runtime_slots: Mutex::new(None),
usage_records: Mutex::new(Vec::new()),
}
}
fn replace_trend(&self, trend: Vec<RequestMetricPoint>) {
*self.trend.lock().expect("trend") = trend;
}
fn replace_overview(&self, overview: UsageOverview) {
*self.overview.lock().expect("overview") = overview;
}
fn replace_calculated_billing_facts(&self, facts: Vec<UsageCalculatedBillingFact>) {
*self
.calculated_billing_facts
.lock()
.expect("calculated billing facts") = facts;
}
fn replace_diagnostics(&self, diagnostics: Vec<DiagnosticObservation>) {
*self.diagnostics.lock().expect("diagnostics") = diagnostics;
}
fn replace_runtime_slots(&self, runtime_slots: Option<DashboardRuntimeSlots>) {
*self.runtime_slots.lock().expect("runtime slots") = runtime_slots;
}
fn replace_usage_records(&self, records: Vec<UsageRecord>) {
*self.usage_records.lock().expect("usage records") = records;
}
}
#[async_trait]
impl ObservabilityStore for FixtureObservabilityStore {
async fn dashboard_summary(&self, range: TimeRange) -> AdminStoreResult<DashboardObservation> {
Ok(DashboardObservation {
range,
requests: RequestMetrics::default(),
attempts: AttemptMetrics::default(),
provider_accounts: AccountPoolMetrics::default(),
trend: self.trend.lock().expect("trend").clone(),
account_usage: Vec::new(),
recent_requests: Vec::new(),
})
}
async fn dashboard_runtime_slots(
&self,
_: DateTime<Utc>,
) -> AdminStoreResult<Option<DashboardRuntimeSlots>> {
Ok(*self.runtime_slots.lock().expect("runtime slots"))
}
async fn dashboard_trend(&self, _: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>> {
Ok(self.trend.lock().expect("trend").clone())
}
async fn usage_trend(
&self,
_: TimeRange,
_: UsageFilter,
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
Ok(self.trend.lock().expect("trend").clone())
}
async fn usage_calculated_billing_facts(
&self,
_: TimeRange,
_: UsageFilter,
) -> AdminStoreResult<Vec<UsageCalculatedBillingFact>> {
Ok(self
.calculated_billing_facts
.lock()
.expect("calculated billing facts")
.clone())
}
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
let items = self.usage_records.lock().expect("usage records").clone();
let total = u64::try_from(items.len()).unwrap_or(u64::MAX);
Ok(UsagePage {
items,
total,
next_cursor: None,
})
}
async fn usage_record_detail(&self, _: &str) -> AdminStoreResult<UsageDetail> {
Err(super::unavailable("usage detail"))
}
async fn usage_summary(&self, _: TimeRange, _: UsageFilter) -> AdminStoreResult<UsageOverview> {
Ok(self.overview.lock().expect("overview").clone())
}
async fn usage_diagnostics(
&self,
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
Ok(self.diagnostics.lock().expect("diagnostics").clone())
}
async fn list_ops_errors(&self, _: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
Err(super::unavailable("ops errors"))
}
}
struct FixtureSettingsStore;
#[async_trait]
impl SettingsStore for FixtureSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
config_revision: Revision::new(1).expect("revision"),
model_mappings: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: 1,
request_interval_ms: 0,
rotation_strategy: RotationStrategy::Smart,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
updated_at: Utc::now(),
})
}
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
Err(super::unavailable("admin API key"))
}
async fn replace_runtime_settings(
&self,
_: ReplaceRuntimeSettings,
_: &MutationContext,
) -> AdminStoreResult<RuntimeSettings> {
Err(super::unavailable("settings"))
}
async fn replace_admin_api_key(
&self,
_: AdminApiKey,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(super::unavailable("admin API key"))
}
async fn delete_admin_api_key(
&self,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(super::unavailable("admin API key"))
}
}
async fn observability_services(store: Arc<FixtureObservabilityStore>) -> AdminServices {
super::AdminHarness::new()
.observability(store)
.settings(Arc::new(FixtureSettingsStore))
.provider(super::dashboard_profile_provider())
.build()
.await
}
async fn observability_services_with_calculated_billing(
store: Arc<FixtureObservabilityStore>,
) -> AdminServices {
super::AdminHarness::new()
.observability(store)
.settings(Arc::new(FixtureSettingsStore))
.provider(super::calculated_billing_provider())
.build()
.await
}
fn observation_range(end: DateTime<Utc>) -> TimeRange {
TimeRange::new(end - Duration::hours(24), end).expect("observation range")
}
fn usage_query(now: DateTime<Utc>) -> UsageQuery {
UsageQuery {
range: observation_range(now),
filter: UsageFilter::default(),
cursor: None,
page: PageNumber::new(NonZeroU32::new(1).expect("page number")),
page_size: PageSize::new(50).expect("page size"),
}
}
fn calculated_total_record(
id: &str,
provider_kind: Option<&str>,
now: DateTime<Utc>,
) -> UsageRecord {
UsageRecord {
id: id.to_owned(),
client_api_key_ref: "key_billing".to_owned(),
config_revision: 1,
protocol: "openai".to_owned(),
operation: "generate".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: "gpt-5.5".to_owned(),
provider_kind: provider_kind.map(str::to_owned),
provider_account_ref: None,
provider_account_name: None,
provider_account_email: None,
provider_account_authentication_kind: None,
upstream_model_id: Some("gpt-5.5".to_owned()),
upstream_transport: None,
http_version: None,
websocket_pool: None,
service_tier: None,
provider_metadata_json: None,
attempt_count: 1,
upstream_send_state: "sent".to_owned(),
downstream_committed_at: None,
outcome: RequestOutcome::Succeeded,
client_status_code: Some(200),
upstream_status_code: Some(200),
client_response_id: None,
upstream_request_id: None,
upstream_response_id: None,
error_kind: None,
provider_error_code: None,
error_message: None,
retry_after_ms: None,
input_tokens: Some(800),
output_tokens: Some(200),
cached_tokens: Some(0),
cache_write_tokens: Some(0),
reasoning_tokens: Some(0),
image_input_tokens: None,
image_output_tokens: None,
total_tokens: Some(1_000),
cost_source: "calculated".to_owned(),
cost_amount: None,
cost_currency: None,
billing: Some(UsageBilling::Total {
source: "calculated".to_owned(),
total: CurrencyCost {
currency: "USD".to_owned(),
amount: gateway_admin::model::observability::DecimalAmount::from_str("1.25")
.expect("billing total"),
},
}),
transport_decision_wait_ms: None,
connect_ms: None,
headers_ms: None,
first_event_ms: None,
first_reasoning_ms: None,
first_text_ms: None,
first_token_ms: None,
provider_processing_ms: None,
latency_ms: Some(100),
client_ip: None,
user_agent: None,
reasoning_effort: None,
reasoning_preset: None,
request_kind: None,
subagent_kind: None,
compact: false,
image_generation_requested: false,
image_generation_succeeded: None,
started_at: now,
deadline_at: now,
completed_at: Some(now),
}
}
fn health_metric_point(bucket_start: DateTime<Utc>, metrics: RequestMetrics) -> RequestMetricPoint {
RequestMetricPoint {
bucket_start,
granularity: Granularity::FifteenMinutes,
metrics,
cost_coverage: CostCoverage::default(),
costs: Vec::new(),
}
}
fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
DiagnosticObservation {
name: name.to_owned(),
request_count,
success_count: request_count,
failure_count: 0,
attempt_count: request_count,
total_tokens: request_count.saturating_mul(100),
average_latency_ms: Some(100),
cost_coverage: CostCoverage::default(),
costs: Vec::new(),
}
}
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()))
}
@@ -0,0 +1,441 @@
use std::sync::Arc;
use gateway_core::engine::credential::ProviderAccountId;
use gateway_admin::{
AdminServices,
model::provider_credentials::{
AuthorizationMutationTarget, CompleteAuthorization, CredentialDeletion, CredentialMutation,
ImportCredentials, ProviderQuotaRequest, StartAuthorization,
},
ports::provider::ProviderAdminErrorKind,
};
use super::accounts::{
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded, revision,
};
#[tokio::test]
async fn openai_enable_should_use_scoped_store_without_provider_mutation() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider, store.clone()).await;
services
.openai()
.enable(mutation("acct_test"))
.await
.expect("enable credential");
assert_eq!(
recorded(&events),
[
"store.credential_details",
"store.set_enabled",
"provider.account_facts_changed",
]
);
assert_eq!(store.audit_requests(), ["request-openai"]);
}
#[tokio::test]
async fn openai_disable_should_commit_then_release_provider_resources() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider, store.clone()).await;
services
.openai()
.disable(mutation("acct_test"))
.await
.expect("disable credential");
assert_eq!(
recorded(&events),
[
"store.credential_details",
"store.set_enabled",
"provider.account_unavailable",
"provider.account_facts_changed",
]
);
assert_eq!(store.audit_requests(), ["request-openai"]);
}
#[tokio::test]
async fn openai_delete_should_commit_then_release_provider_resources() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider, store.clone()).await;
services
.openai()
.delete(deletion("acct_test"))
.await
.expect("delete credential");
assert_eq!(
recorded(&events),
[
"store.credential_details",
"store.delete",
"provider.account_unavailable",
"provider.account_facts_changed",
]
);
assert_eq!(store.audit_requests(), ["request-openai"]);
}
#[tokio::test]
async fn openai_disable_store_failure_should_not_release_provider_resources() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
store.fail_next_commit();
let services = service(provider, store).await;
services
.openai()
.disable(mutation("acct_test"))
.await
.expect_err("failed Store transaction");
assert_eq!(
recorded(&events),
["store.credential_details", "store.set_enabled"]
);
}
#[tokio::test]
async fn openai_import_should_prepare_before_atomic_store_commit() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider.clone(), store.clone()).await;
services
.openai()
.import_document(ImportCredentials {
context: context("import-openai"),
document: document(),
})
.await
.expect("import credential");
assert_eq!(
recorded(&events),
[
"provider.prepare_import",
"store.commit_import",
"provider.account_facts_changed",
"provider.quota",
]
);
assert_eq!(
provider.quota_requests(),
[ProviderQuotaRequest {
account_id: ProviderAccountId::new("acct_prepared").expect("account ID"),
refresh: true,
rolling_usage: None,
}]
);
assert_eq!(store.audit_requests(), ["import-openai"]);
}
#[tokio::test]
async fn openai_import_should_refresh_quota_for_every_imported_account() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.set_import_account_ids(&["acct_first", "acct_second"]);
let store = FakeAccountStore::new("openai", events);
let services = service(provider.clone(), store).await;
let result = services
.openai()
.import_document(ImportCredentials {
context: context("import-openai-batch"),
document: document(),
})
.await
.expect("import credentials");
assert_eq!(
result.credential_ids,
[
ProviderAccountId::new("acct_first").expect("first account ID"),
ProviderAccountId::new("acct_second").expect("second account ID"),
]
);
assert_eq!(
provider.quota_requests(),
[
ProviderQuotaRequest {
account_id: ProviderAccountId::new("acct_first").expect("first account ID"),
refresh: true,
rolling_usage: None,
},
ProviderQuotaRequest {
account_id: ProviderAccountId::new("acct_second").expect("second account ID"),
refresh: true,
rolling_usage: None,
},
]
);
}
#[tokio::test]
async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.fail_next_quota(ProviderAdminErrorKind::Unavailable);
let store = FakeAccountStore::new("openai", events);
let services = service(provider.clone(), store).await;
let result = services
.openai()
.import_document(ImportCredentials {
context: context("import-openai-quota-failure"),
document: document(),
})
.await
.expect("committed import remains successful");
assert_eq!(
result.credential_ids,
[ProviderAccountId::new("acct_prepared").expect("account ID")]
);
assert_eq!(provider.quota_requests().len(), 1);
}
#[tokio::test]
async fn openai_authorization_create_should_observe_initial_quota() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider.clone(), store.clone()).await;
services
.openai()
.start_authorization(StartAuthorization {
context: context("oauth-start-openai-create"),
name: "new OpenAI credential".to_owned(),
reauthorization: None,
})
.await
.expect("start authorization");
services
.openai()
.complete_authorization(CompleteAuthorization {
context: context("oauth-start-openai-create"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
})
.await
.expect("complete authorization");
assert_eq!(
recorded(&events),
[
"provider.start_authorization",
"provider.complete_authorization",
"store.commit_authorization",
"provider.account_facts_changed",
"provider.quota",
]
);
assert_eq!(
provider.quota_requests(),
[ProviderQuotaRequest {
account_id: ProviderAccountId::new("acct_prepared").expect("account ID"),
refresh: true,
rolling_usage: None,
}]
);
assert_eq!(store.audit_requests(), ["oauth-start-openai-create"]);
}
#[tokio::test]
async fn openai_authorization_create_should_remain_successful_when_initial_quota_fails() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.fail_next_quota(ProviderAdminErrorKind::Unavailable);
let store = FakeAccountStore::new("openai", events);
let services = service(provider.clone(), store).await;
services
.openai()
.start_authorization(StartAuthorization {
context: context("oauth-start-openai-quota-failure"),
name: "new OpenAI credential".to_owned(),
reauthorization: None,
})
.await
.expect("start authorization");
let result = services
.openai()
.complete_authorization(CompleteAuthorization {
context: context("oauth-start-openai-quota-failure"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
})
.await
.expect("quota observation must not fail authorization");
assert_eq!(result.account_id.as_str(), "acct_prepared");
assert_eq!(provider.quota_requests().len(), 1);
}
#[tokio::test]
async fn openai_authorization_store_failure_should_release_claim_for_retry() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.retry_authorization_after_abort();
let store = FakeAccountStore::new("openai", events.clone());
store.fail_next_commit();
let services = service(provider.clone(), store).await;
let command = || CompleteAuthorization {
context: context("oauth-store-retry"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
};
services
.openai()
.start_authorization(StartAuthorization {
context: context("oauth-store-retry"),
name: "new OpenAI credential".to_owned(),
reauthorization: None,
})
.await
.expect("start authorization");
services
.openai()
.complete_authorization(command())
.await
.expect_err("Store commit must fail");
services
.openai()
.complete_authorization(command())
.await
.expect("released claim must permit retry");
assert_eq!(
recorded(&events),
[
"provider.start_authorization",
"provider.complete_authorization",
"store.commit_authorization",
"authorization_guard.abort",
"provider.complete_authorization",
"store.commit_authorization",
"authorization_guard.commit",
"provider.account_facts_changed",
"provider.quota",
]
);
assert!(provider.pending().is_none());
}
#[tokio::test]
async fn openai_import_provider_error_should_not_touch_store_transaction() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.fail_next(ProviderAdminErrorKind::Invalid);
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider, store).await;
services
.openai()
.import_document(ImportCredentials {
context: context("import-openai-error"),
document: document(),
})
.await
.expect_err("invalid Provider document");
assert_eq!(recorded(&events), ["provider.prepare_import"]);
}
#[tokio::test]
async fn openai_reauthorization_should_commit_after_credential_revision_advances() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider.clone(), store.clone()).await;
services
.openai()
.start_authorization(StartAuthorization {
context: context("oauth-start-openai"),
name: "reauthorize".to_owned(),
reauthorization: Some(ProviderAccountId::new("acct_test").expect("account ID")),
})
.await
.expect("start reauthorization");
let pending = provider.pending().expect("pending envelope");
assert_eq!(pending.provider_kind().as_str(), "openai");
assert_eq!(
pending.owner_binding().started_request_id(),
"oauth-start-openai"
);
assert!(matches!(
pending.target(),
AuthorizationMutationTarget::Reauthorize { account_id }
if account_id.as_str() == "acct_test"
));
assert!(!format!("{pending:?}").contains("admin-test"));
provider.set_current_credential_revision(revision(2));
let result = services
.openai()
.complete_authorization(CompleteAuthorization {
context: context("oauth-complete-openai"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
})
.await
.expect("complete reauthorization");
assert_eq!(result.credential_revision, Some(revision(3)));
assert_eq!(
recorded(&events),
[
"store.credential_details",
"provider.start_authorization",
"provider.complete_authorization",
"store.commit_authorization",
"guard.finish",
"provider.account_facts_changed",
"provider.quota",
]
);
assert_eq!(provider.quota_requests().len(), 1);
assert_eq!(store.audit_requests(), ["oauth-complete-openai"]);
}
async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>) -> AdminServices {
super::AdminHarness::new()
.provider(provider)
.accounts(store)
.build()
.await
}
fn mutation(account_id: &str) -> CredentialMutation {
CredentialMutation {
context: context("request-openai"),
account_id: ProviderAccountId::new(account_id).expect("account ID"),
}
}
fn deletion(account_id: &str) -> CredentialDeletion {
CredentialDeletion {
context: context("request-openai"),
account_ids: vec![ProviderAccountId::new(account_id).expect("account ID")],
}
}
@@ -0,0 +1,87 @@
use async_trait::async_trait;
use gateway_admin::{
model::{
AdminErrorKind, MutationContext,
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
RuntimeSettings,
},
},
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, SettingsStore},
};
struct UnusedSettingsStore;
#[async_trait]
impl SettingsStore for UnusedSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unused())
}
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
Err(unused())
}
async fn replace_runtime_settings(
&self,
_: ReplaceRuntimeSettings,
_: &MutationContext,
) -> AdminStoreResult<RuntimeSettings> {
Err(unused())
}
async fn replace_admin_api_key(
&self,
_: AdminApiKey,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(unused())
}
async fn delete_admin_api_key(
&self,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(unused())
}
}
#[tokio::test]
async fn settings_should_reject_zero_refresh_margin_before_store_call() {
let services = super::AdminHarness::new()
.settings(std::sync::Arc::new(UnusedSettingsStore))
.build()
.await;
let error = services
.settings()
.replace(
&MutationContext {
actor: gateway_admin::model::MutationActor::System,
request_id: "request-settings".to_owned(),
},
ReplaceRuntimeSettings {
model_mappings: Default::default(),
refresh_margin_seconds: 0,
refresh_concurrency: 1,
max_concurrent_per_account: 1,
request_interval_ms: 0,
rotation_strategy: RotationStrategy::Smart,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
},
)
.await
.expect_err("invalid settings");
assert_eq!(error.kind(), AdminErrorKind::Invalid);
}
fn unused() -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
"settings",
"unused in this test",
)
}
@@ -0,0 +1,115 @@
use std::sync::Mutex;
use async_trait::async_trait;
use gateway_admin::{
model::system::{
SystemOperationAccepted, SystemOperationState, SystemOperationStatus, SystemUpdateDetail,
SystemUpdateStatus, SystemVersion,
},
ports::system::{SystemOperationError, SystemOperations, SystemUpdateEventStream},
};
#[derive(Default)]
struct RecordingSystemOperations {
target: Mutex<Option<Option<String>>>,
}
#[async_trait]
impl SystemOperations for RecordingSystemOperations {
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
Ok(SystemVersion {
version: "1.0.0".to_owned(),
git_sha: "unknown".to_owned(),
build_time: "unknown".to_owned(),
deployment_mode: "source".to_owned(),
update_channel: "stable".to_owned(),
latest_version: "1.0.0".to_owned(),
has_update: false,
update_cached: false,
update_warning: None,
})
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
Ok(SystemUpdateDetail {
current_version: "1.0.0".to_owned(),
latest_version: "1.0.0".to_owned(),
has_update: false,
deployment_mode: "source".to_owned(),
build_type: "source".to_owned(),
release_url: None,
notes: None,
cached: false,
update_supported: false,
unsupported_reason: Some("source build".to_owned()),
warning: None,
})
}
fn update_events(&self) -> SystemUpdateEventStream {
Box::pin(futures::stream::empty())
}
async fn perform_update(
&self,
target_version: Option<String>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
*self.target.lock().expect("target") = Some(target_version.clone());
Ok(SystemOperationAccepted::Update {
operation_id: "operation-update".to_owned(),
deployment_mode: "source".to_owned(),
message: "accepted".to_owned(),
need_restart: true,
target_version: target_version.unwrap_or_else(|| "latest".to_owned()),
})
}
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
Ok(SystemUpdateStatus {
previous_version: None,
current_version: Some("1.0.0".to_owned()),
operation: SystemOperationState {
operation_id: None,
kind: None,
status: SystemOperationStatus::Idle,
target_version: None,
message: None,
error: None,
started_at: None,
finished_at: None,
},
})
}
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
Ok(SystemOperationAccepted::Rollback {
operation_id: "operation-rollback".to_owned(),
message: "accepted".to_owned(),
need_restart: true,
})
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
Ok(SystemOperationAccepted::Restart {
operation_id: "operation-restart".to_owned(),
message: "accepted".to_owned(),
})
}
}
#[tokio::test]
async fn system_update_should_normalize_blank_target_to_latest() {
let operations = std::sync::Arc::new(RecordingSystemOperations::default());
let services = super::AdminHarness::new()
.system(operations.clone())
.build()
.await;
services
.system()
.perform_update(Some(" ".to_owned()))
.await
.expect("perform update");
assert_eq!(*operations.target.lock().expect("target"), Some(None));
}
@@ -0,0 +1,141 @@
use std::sync::Arc;
use gateway_core::engine::credential::ProviderAccountId;
use gateway_admin::{
AdminServices,
model::provider_credentials::{CredentialMutation, ImportCredentials, ProviderQuotaRequest},
ports::provider::ProviderAdminErrorKind,
};
use super::accounts::{FakeAccountStore, FakeProviderAdmin, context, document, events, recorded};
#[tokio::test]
async fn xai_disable_should_commit_then_notify_the_stateless_provider() {
let events = events();
let provider = FakeProviderAdmin::new("xai", events.clone());
let store = FakeAccountStore::new("xai", events.clone());
let services = service(provider.clone(), store.clone()).await;
services
.xai()
.disable(mutation("acct_test"))
.await
.expect("disable credential");
assert_eq!(
recorded(&events),
[
"store.credential_details",
"store.set_enabled",
"provider.account_unavailable",
"provider.account_facts_changed",
]
);
assert_eq!(store.audit_requests(), ["request-xai"]);
}
#[tokio::test]
async fn xai_import_should_prepare_before_atomic_store_commit() {
let events = events();
let provider = FakeProviderAdmin::new("xai", events.clone());
let store = FakeAccountStore::new("xai", events.clone());
let services = service(provider.clone(), store.clone()).await;
services
.xai()
.import_document(ImportCredentials {
context: context("import-xai"),
document: document(),
})
.await
.expect("import xAI credential");
assert_eq!(
recorded(&events),
[
"provider.prepare_import",
"store.commit_import",
"provider.quota"
]
);
assert_eq!(
provider.quota_requests(),
[ProviderQuotaRequest {
account_id: ProviderAccountId::new("acct_prepared").expect("account ID"),
refresh: true,
rolling_usage: None,
}]
);
assert_eq!(store.audit_requests(), ["import-xai"]);
}
#[tokio::test]
async fn xai_import_should_refresh_quota_for_every_imported_account() {
let events = events();
let provider = FakeProviderAdmin::new("xai", events.clone());
provider.set_import_account_ids(&["acct_first", "acct_second"]);
let store = FakeAccountStore::new("xai", events);
let services = service(provider.clone(), store).await;
services
.xai()
.import_document(ImportCredentials {
context: context("import-xai-batch"),
document: document(),
})
.await
.expect("import credentials");
assert_eq!(
provider.quota_requests(),
[
ProviderQuotaRequest {
account_id: ProviderAccountId::new("acct_first").expect("first account ID"),
refresh: true,
rolling_usage: None,
},
ProviderQuotaRequest {
account_id: ProviderAccountId::new("acct_second").expect("second account ID"),
refresh: true,
rolling_usage: None,
},
]
);
}
#[tokio::test]
async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
let events = events();
let provider = FakeProviderAdmin::new("xai", events.clone());
provider.fail_next_quota(ProviderAdminErrorKind::Unavailable);
let store = FakeAccountStore::new("xai", events);
let services = service(provider.clone(), store).await;
let result = services
.xai()
.import_document(ImportCredentials {
context: context("import-xai-quota-failure"),
document: document(),
})
.await
.expect("committed import remains successful");
assert_eq!(result.credential_ids.len(), 1);
assert_eq!(provider.quota_requests().len(), 1);
}
async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>) -> AdminServices {
super::AdminHarness::new()
.provider(provider)
.accounts(store)
.build()
.await
}
fn mutation(account_id: &str) -> CredentialMutation {
CredentialMutation {
context: context("request-xai"),
account_id: ProviderAccountId::new(account_id).expect("account ID"),
}
}
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "gateway-api"
version = "0.1.0"
edition = "2024"
license = "MIT"
publish = false
rust-version = "1.97"
autotests = false
[[test]]
name = "main"
path = "tests/main.rs"
[dependencies]
axum = { version = "0.8.9", default-features = false, features = ["json", "query", "ws"] }
base64 = "0.23.0"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["serde"] }
futures = "0.3.32"
gateway-admin = { path = "../gateway-admin" }
gateway-core = { path = "../gateway-core" }
gateway-protocol = { path = "../gateway-protocol" }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
thiserror = "2.0.18"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "time"] }
tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeout", "trace"] }
tracing = "0.1.44"
url = "2.5.8"
uuid = { version = "1.23.3", features = ["v7"] }
[dev-dependencies]
async-trait = "0.1.89"
axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] }
tokio = { version = "1.52.3", default-features = false, features = ["macros", "net", "rt-multi-thread"] }
tokio-tungstenite = "0.29.0"
tower = { version = "0.5.3", features = ["util"] }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,292 @@
//! 管理员登录、会话与管理请求鉴权接线。
use std::fmt;
use axum::{
Json, Router,
extract::{FromRequestParts, State},
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, request::Parts},
response::{IntoResponse, Response},
routing::{get, post},
};
use gateway_admin::{
AdminServices,
model::auth::{AdminPrincipal, AdminRequestContext, LoginCommand, LoginError},
};
use serde::{Deserialize, Serialize};
use tower_http::request_id::RequestId;
use super::{AdminEnvelope, AdminError, AdminResponse};
const REQUEST_ID_HEADER: &str = "x-request-id";
const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session";
const ADMIN_SESSION_COOKIE_ATTRS: &str = "Path=/; Secure; HttpOnly; SameSite=Lax";
/// 所有管理 HTTP 模块从 state 消费同一个认证用例端口。
pub trait AdminSessionState {
fn admin_services(&self) -> &AdminServices;
}
/// 已通过管理员会话或部署级管理 API Key 鉴权的请求。
pub struct AdminAuth {
context: AdminRequestContext,
}
impl AdminAuth {
#[must_use]
pub const fn context(&self) -> &AdminRequestContext {
&self.context
}
#[must_use]
pub fn into_context(self) -> AdminRequestContext {
self.context
}
}
impl<S> FromRequestParts<S> for AdminAuth
where
S: AdminSessionState + Send + Sync,
{
type Rejection = AdminError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let principal = require_admin_auth(state, &parts.headers).await?;
let request_id = admin_request_id(parts)
.ok_or_else(|| AdminError::internal("Missing admin request context"))?;
Ok(Self {
context: AdminRequestContext {
principal,
request_id,
},
})
}
}
/// request-id 层按配置的 header 名注入,同时写入与名字无关的扩展;
/// 优先读扩展,使自定义 header 名不会让管理请求失去请求上下文。
/// header 回退覆盖未装配该层的调用方。
fn admin_request_id(parts: &Parts) -> Option<String> {
parts
.extensions
.get::<RequestId>()
.map(RequestId::header_value)
.or_else(|| parts.headers.get(REQUEST_ID_HEADER))
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub async fn require_admin_session<S>(state: &S, headers: &HeaderMap) -> Result<String, AdminError>
where
S: AdminSessionState + Send + Sync,
{
match state
.admin_services()
.auth()
.resolve_admin_user_id(admin_session_cookie(headers).as_deref())
.await
{
Ok(Some(admin_user_id)) => Ok(admin_user_id),
Ok(None) => Err(AdminError::admin_session_required()),
Err(_) => Err(AdminError::internal("Failed to validate admin session")),
}
}
async fn require_admin_auth<S>(state: &S, headers: &HeaderMap) -> Result<AdminPrincipal, AdminError>
where
S: AdminSessionState + Send + Sync,
{
if let Some(api_key) = admin_api_key_header(headers) {
return match state
.admin_services()
.auth()
.verify_admin_api_key(&api_key)
.await
{
Ok(true) => Ok(AdminPrincipal::ApiKey),
Ok(false) => Err(AdminError::invalid_admin_api_key()),
Err(_) => Err(AdminError::internal("Failed to validate admin API key")),
};
}
require_admin_session(state, headers)
.await
.map(|admin_user_id| AdminPrincipal::Session { admin_user_id })
}
fn admin_api_key_header(headers: &HeaderMap) -> Option<String> {
let value = headers.get("x-api-key")?.to_str().ok()?.trim();
(!value.is_empty()).then(|| value.to_owned())
}
/// 管理员登录请求;密码不得进入 Debug。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AdminLoginRequest {
username: Option<String>,
password: String,
}
impl AdminLoginRequest {
/// 取出认证用例所需字段。
#[must_use]
pub fn into_parts(self) -> (Option<String>, String) {
(self.username, self.password)
}
}
impl fmt::Debug for AdminLoginRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AdminLoginRequest")
.field("username", &self.username)
.field("password", &"[REDACTED]")
.finish()
}
}
/// 登录成功响应。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminLoginData {
expires_at: String,
}
impl AdminLoginData {
#[must_use]
pub fn new(expires_at: String) -> Self {
Self { expires_at }
}
}
/// 管理员会话状态响应。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminSessionStatusData {
authenticated: bool,
}
impl AdminSessionStatusData {
#[must_use]
pub const fn new(authenticated: bool) -> Self {
Self { authenticated }
}
}
/// 登出成功响应。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct AdminLogoutData {
message: &'static str,
}
impl AdminLogoutData {
#[must_use]
pub const fn new() -> Self {
Self {
message: "Logged out successfully",
}
}
}
impl Default for AdminLogoutData {
fn default() -> Self {
Self::new()
}
}
/// 构造固定 GET/POST 管理员认证路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/auth/login", post(login::<S>))
.route("/api/admin/auth/status", get(session_status::<S>))
.route("/api/admin/auth/logout", post(logout::<S>))
}
async fn login<S>(
State(state): State<S>,
Json(payload): Json<AdminLoginRequest>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (username, password) = payload.into_parts();
let session = state
.admin_services()
.auth()
.login(LoginCommand { username, password })
.await
.map_err(map_login_error)?;
let mut response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminLoginData::new(session.expires_at.to_rfc3339())),
)
.into_response();
let cookie = format!(
"{ADMIN_SESSION_COOKIE}={}; {ADMIN_SESSION_COOKIE_ATTRS}",
session.session_id
);
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie)
.map_err(|_| AdminError::internal("Failed to create admin session cookie"))?,
);
Ok(response)
}
async fn session_status<S>(
State(state): State<S>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let authenticated = state
.admin_services()
.auth()
.validate_session(admin_session_cookie(&headers).as_deref())
.await
.map_err(|_| AdminError::internal("Failed to validate admin session"))?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminSessionStatusData::new(authenticated)),
))
}
async fn logout<S>(State(state): State<S>, headers: HeaderMap) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
{
if let Some(session_id) = admin_session_cookie(&headers) {
let _ = state.admin_services().auth().logout(&session_id).await;
}
let mut response =
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(AdminLogoutData::new()))
.into_response();
let cookie = format!("{ADMIN_SESSION_COOKIE}=; {ADMIN_SESSION_COOKIE_ATTRS}; Max-Age=0");
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie)
.map_err(|_| AdminError::internal("Failed to clear admin session cookie"))?,
);
Ok(response)
}
fn admin_session_cookie(headers: &HeaderMap) -> Option<String> {
let cookie = headers.get("cookie")?.to_str().ok()?;
cookie.split(';').find_map(|part| {
let (name, value) = part.trim().split_once('=')?;
(name == ADMIN_SESSION_COOKIE).then(|| value.to_owned())
})
}
fn map_login_error(error: LoginError) -> AdminError {
match error {
LoginError::InvalidCredentials => AdminError::invalid_admin_credentials(),
LoginError::Unavailable => AdminError::internal("Failed to create admin session"),
}
}
@@ -0,0 +1,806 @@
//! Client API Key 管理 wire contract。
use std::fmt;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use gateway_admin::model::client_keys::{
ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue, ClientKeyListQuery,
ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret,
ClientKeySort as DomainSort, ClientKeySortField as DomainSortField, CreateClientKey,
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
};
use gateway_core::{
policy::{ClientApiKeyId, RateLimits},
routing::ProviderKind,
};
use serde::{Deserialize, Serialize};
use axum::{
Json, Router,
extract::{Query, State},
http::{HeaderValue, StatusCode, header::CACHE_CONTROL},
response::{IntoResponse, Response},
routing::{get, post},
};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError,
wire::map_admin_service_error,
};
const MAX_CURSOR_BYTES: usize = 512;
const MAX_SEARCH_BYTES: usize = 256;
const DEFAULT_PAGE_SIZE: u16 = 50;
/// Client Key 列表查询。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ListClientKeysQuery {
cursor: Option<String>,
limit: Option<u16>,
search: Option<String>,
sort_by: Option<String>,
sort_direction: Option<String>,
}
impl ListClientKeysQuery {
/// 校验 wire 边界并直接构造管理用例查询。
pub fn into_command(self) -> Result<ClientKeyListQuery, WireValidationError> {
if self
.cursor
.as_deref()
.is_some_and(|cursor| cursor.is_empty() || cursor.len() > MAX_CURSOR_BYTES)
{
return Err(WireValidationError::new("cursor"));
}
if self.limit == Some(0) {
return Err(WireValidationError::new("limit"));
}
let search = self.search.map(|search| search.trim().to_owned());
if search.as_deref().is_some_and(|search| {
search.len() > MAX_SEARCH_BYTES
|| search.chars().any(char::is_control)
|| contains_client_key_material(search)
}) {
return Err(WireValidationError::new("search"));
}
let sort = ClientKeySort::parse(
self.sort_by.as_deref().unwrap_or("createdAt"),
self.sort_direction.as_deref().unwrap_or("desc"),
)?;
let cursor = self
.cursor
.as_deref()
.map(decode_client_key_cursor)
.transpose()?
.map(domain_cursor)
.transpose()?;
let page_size = ClientKeyPageSize::new(self.limit.unwrap_or(DEFAULT_PAGE_SIZE))
.map_err(|_| WireValidationError::new("limit"))?;
Ok(ClientKeyListQuery {
cursor,
page_size,
search: search.filter(|search| !search.is_empty()),
sort: domain_sort(sort),
})
}
}
/// Client Key 数据库排序字段。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ClientKeySortField {
Name,
Enabled,
CreatedAt,
LastUsedAt,
}
/// Client Key 数据库排序方向。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ClientKeySortDirection {
Asc,
Desc,
}
/// 已校验且会写入自描述游标的排序组合。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ClientKeySort {
pub field: ClientKeySortField,
pub direction: ClientKeySortDirection,
}
impl ClientKeySort {
fn parse(field: &str, direction: &str) -> Result<Self, WireValidationError> {
let field = match field {
"name" => ClientKeySortField::Name,
"enabled" => ClientKeySortField::Enabled,
"createdAt" => ClientKeySortField::CreatedAt,
"lastUsedAt" => ClientKeySortField::LastUsedAt,
_ => return Err(WireValidationError::new("sortBy")),
};
let direction = match direction {
"asc" => ClientKeySortDirection::Asc,
"desc" => ClientKeySortDirection::Desc,
_ => return Err(WireValidationError::new("sortDirection")),
};
Ok(Self { field, direction })
}
}
/// 创建 Client Key 请求。
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateClientKeyRequest {
name: String,
label: Option<String>,
provider_kind: String,
max_concurrency: u64,
requests_per_minute: u64,
}
impl CreateClientKeyRequest {
/// 校验 wire 边界并直接构造管理用例命令。
pub fn into_command(self) -> Result<CreateClientKey, WireValidationError> {
validate_required_text(&self.name, "name")?;
validate_optional_text(self.label.as_deref(), "label")?;
validate_provider_kind(&self.provider_kind)?;
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(CreateClientKey {
name: self.name,
label: self.label,
provider_kind: ProviderKind::new(self.provider_kind)
.map_err(|_| WireValidationError::new("providerKind"))?,
limits: RateLimits {
max_concurrency: self.max_concurrency,
requests_per_minute: self.requests_per_minute,
},
})
}
}
/// 更新 Client Key 请求。
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateClientKeyRequest {
id: String,
name: String,
label: Option<String>,
provider_kind: String,
max_concurrency: u64,
requests_per_minute: u64,
}
impl UpdateClientKeyRequest {
/// 校验 wire 边界并直接构造管理用例命令。
pub fn into_command(self) -> Result<UpdateClientKey, WireValidationError> {
validate_required_text(&self.id, "id")?;
validate_required_text(&self.name, "name")?;
validate_optional_text(self.label.as_deref(), "label")?;
validate_provider_kind(&self.provider_kind)?;
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(UpdateClientKey {
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
name: self.name,
label: self.label,
provider_kind: ProviderKind::new(self.provider_kind)
.map_err(|_| WireValidationError::new("providerKind"))?,
limits: RateLimits {
max_concurrency: self.max_concurrency,
requests_per_minute: self.requests_per_minute,
},
})
}
}
/// 只携带 ID 的 Client Key mutation 请求。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ClientKeyMutationRequest {
id: String,
}
/// 读取一次完整 Key 的 ID query。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClientKeyIdQuery {
id: String,
}
impl ClientKeyIdQuery {
pub fn into_id(self) -> Result<String, WireValidationError> {
validate_required_text(&self.id, "id")?;
Ok(self.id)
}
fn into_domain_id(self) -> Result<ClientApiKeyId, WireValidationError> {
client_key_id(self.into_id()?, "clientKeyRevealNotFound")
}
}
impl ClientKeyMutationRequest {
/// 校验请求并取出 ID。
pub fn into_id(self) -> Result<String, WireValidationError> {
validate_required_text(&self.id, "id")?;
Ok(self.id)
}
fn into_domain_id(self) -> Result<ClientApiKeyId, WireValidationError> {
client_key_id(self.into_id()?, "clientKeyMutationNotFound")
}
}
/// 不含完整 Key 的管理端安全视图。
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientKeyView {
id: String,
name: String,
label: Option<String>,
provider_kind: String,
prefix: String,
enabled: bool,
max_concurrency: u64,
requests_per_minute: u64,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
}
impl From<ClientKeyRecord> for ClientKeyView {
fn from(record: ClientKeyRecord) -> Self {
Self {
id: record.id.to_string(),
name: record.name,
label: record.label,
provider_kind: record.provider_kind.to_string(),
prefix: record.prefix,
enabled: record.enabled,
max_concurrency: record.limits.max_concurrency,
requests_per_minute: record.limits.requests_per_minute,
created_at: record.created_at,
updated_at: record.updated_at,
last_used_at: record.last_used_at,
}
}
}
/// Client Key 列表响应数据。
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientKeyListData {
items: Vec<ClientKeyView>,
next_cursor: Option<String>,
total: u64,
}
impl ClientKeyListData {
/// 构造 Client Key 列表响应。
#[must_use]
pub fn new(items: Vec<ClientKeyView>, next_cursor: Option<String>, total: u64) -> Self {
Self {
items,
next_cursor,
total,
}
}
}
impl TryFrom<ClientKeyPage> for ClientKeyListData {
type Error = WireValidationError;
fn try_from(page: ClientKeyPage) -> Result<Self, Self::Error> {
let next_cursor = page
.next_cursor
.map(wire_cursor)
.transpose()?
.as_ref()
.map(encode_client_key_cursor)
.transpose()?;
Ok(Self::new(
page.items.into_iter().map(Into::into).collect(),
next_cursor,
page.total,
))
}
}
/// Client Key 创建响应;完整值只允许出现在本次序列化结果中。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatedClientKeyData {
id: String,
prefix: String,
plaintext_key: String,
}
impl CreatedClientKeyData {
/// 构造一次性创建响应。
#[must_use]
pub fn new(id: String, prefix: String, plaintext_key: String) -> Self {
Self {
id,
prefix,
plaintext_key,
}
}
}
impl From<CreatedClientKey> for CreatedClientKeyData {
fn from(created: CreatedClientKey) -> Self {
Self::new(
created.secret.record.id.to_string(),
created.secret.record.prefix.clone(),
created.secret.expose_for_response().to_owned(),
)
}
}
impl fmt::Debug for CreatedClientKeyData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CreatedClientKeyData")
.field("id", &self.id)
.field("prefix", &self.prefix)
.field("plaintext_key", &"[REDACTED]")
.finish()
}
}
/// 仅由显式 reveal 返回一次的完整明文 Key。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RevealedClientKeyData {
id: String,
plaintext_key: String,
}
impl RevealedClientKeyData {
#[must_use]
pub fn new(id: String, plaintext_key: String) -> Self {
Self { id, plaintext_key }
}
}
impl From<ClientKeySecret> for RevealedClientKeyData {
fn from(secret: ClientKeySecret) -> Self {
Self::new(
secret.record.id.to_string(),
secret.expose_for_response().to_owned(),
)
}
}
impl fmt::Debug for RevealedClientKeyData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RevealedClientKeyData")
.field("id", &self.id)
.field("plaintext_key", &"[REDACTED]")
.finish()
}
}
/// Client Key mutation 响应数据。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MutatedClientKeyData {
id: String,
}
impl MutatedClientKeyData {
/// 构造 mutation 响应。
#[must_use]
pub fn new(id: String) -> Self {
Self { id }
}
}
impl From<ClientKeyMutation> for MutatedClientKeyData {
fn from(mutation: ClientKeyMutation) -> Self {
Self::new(mutation.id.to_string())
}
}
/// 解码后的 Client Key 游标字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyCursorData {
pub sort: ClientKeySort,
pub value: ClientKeyCursorValue,
pub id: String,
}
/// 游标中与排序字段严格对应的最后一行值。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
pub enum ClientKeyCursorValue {
Name(String),
Enabled(bool),
CreatedAt(DateTime<Utc>),
LastUsedAt(Option<DateTime<Utc>>),
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CursorWire {
sort: ClientKeySort,
value: ClientKeyCursorValue,
id: String,
}
/// 把 owner 游标编码为不透明 wire 值。
pub fn encode_client_key_cursor(
cursor: &ClientKeyCursorData,
) -> Result<String, WireValidationError> {
validate_client_key_cursor(cursor)?;
let bytes = serde_json::to_vec(&CursorWire {
sort: cursor.sort,
value: cursor.value.clone(),
id: cursor.id.clone(),
})
.map_err(|_| WireValidationError::new("cursor"))?;
Ok(URL_SAFE_NO_PAD.encode(bytes))
}
/// 解码并严格校验 Client Key 游标。
pub fn decode_client_key_cursor(encoded: &str) -> Result<ClientKeyCursorData, WireValidationError> {
if encoded.is_empty() || encoded.len() > MAX_CURSOR_BYTES {
return Err(WireValidationError::new("cursor"));
}
let bytes = URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|_| WireValidationError::new("cursor"))?;
let cursor: CursorWire =
serde_json::from_slice(&bytes).map_err(|_| WireValidationError::new("cursor"))?;
let cursor = ClientKeyCursorData {
sort: cursor.sort,
value: cursor.value,
id: cursor.id,
};
validate_client_key_cursor(&cursor)?;
Ok(cursor)
}
fn validate_client_key_cursor(cursor: &ClientKeyCursorData) -> Result<(), WireValidationError> {
validate_required_text(&cursor.id, "cursor")?;
let matching = matches!(
(cursor.sort.field, &cursor.value),
(ClientKeySortField::Name, ClientKeyCursorValue::Name(value)) if !value.trim().is_empty()
) || matches!(
(cursor.sort.field, &cursor.value),
(
ClientKeySortField::Enabled,
ClientKeyCursorValue::Enabled(_)
) | (
ClientKeySortField::CreatedAt,
ClientKeyCursorValue::CreatedAt(_)
) | (
ClientKeySortField::LastUsedAt,
ClientKeyCursorValue::LastUsedAt(_)
)
);
if matching {
Ok(())
} else {
Err(WireValidationError::new("cursor"))
}
}
const fn domain_sort(sort: ClientKeySort) -> DomainSort {
DomainSort {
field: match sort.field {
ClientKeySortField::Name => DomainSortField::Name,
ClientKeySortField::Enabled => DomainSortField::Enabled,
ClientKeySortField::CreatedAt => DomainSortField::CreatedAt,
ClientKeySortField::LastUsedAt => DomainSortField::LastUsedAt,
},
direction: match sort.direction {
ClientKeySortDirection::Asc => SortDirection::Asc,
ClientKeySortDirection::Desc => SortDirection::Desc,
},
}
}
const fn wire_sort(sort: DomainSort) -> ClientKeySort {
ClientKeySort {
field: match sort.field {
DomainSortField::Name => ClientKeySortField::Name,
DomainSortField::Enabled => ClientKeySortField::Enabled,
DomainSortField::CreatedAt => ClientKeySortField::CreatedAt,
DomainSortField::LastUsedAt => ClientKeySortField::LastUsedAt,
},
direction: match sort.direction {
SortDirection::Asc => ClientKeySortDirection::Asc,
SortDirection::Desc => ClientKeySortDirection::Desc,
},
}
}
fn domain_cursor(cursor: ClientKeyCursorData) -> Result<ClientKeyCursor, WireValidationError> {
let value = match cursor.value {
ClientKeyCursorValue::Name(value) => DomainCursorValue::Name(value),
ClientKeyCursorValue::Enabled(value) => DomainCursorValue::Enabled(value),
ClientKeyCursorValue::CreatedAt(value) => DomainCursorValue::CreatedAt(value),
ClientKeyCursorValue::LastUsedAt(value) => DomainCursorValue::LastUsedAt(value),
};
Ok(ClientKeyCursor {
sort: domain_sort(cursor.sort),
value,
id: client_key_id(cursor.id, "cursor")?,
})
}
fn wire_cursor(cursor: ClientKeyCursor) -> Result<ClientKeyCursorData, WireValidationError> {
let value = match cursor.value {
DomainCursorValue::Name(value) => ClientKeyCursorValue::Name(value),
DomainCursorValue::Enabled(value) => ClientKeyCursorValue::Enabled(value),
DomainCursorValue::CreatedAt(value) => ClientKeyCursorValue::CreatedAt(value),
DomainCursorValue::LastUsedAt(value) => ClientKeyCursorValue::LastUsedAt(value),
};
let cursor = ClientKeyCursorData {
sort: wire_sort(cursor.sort),
value,
id: cursor.id.to_string(),
};
validate_client_key_cursor(&cursor)?;
Ok(cursor)
}
fn client_key_id(
value: String,
field: &'static str,
) -> Result<ClientApiKeyId, WireValidationError> {
ClientApiKeyId::new(value).map_err(|_| WireValidationError::new(field))
}
fn validate_limit(value: u64, field: &'static str) -> Result<(), WireValidationError> {
if i64::try_from(value).is_err() {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn validate_required_text(value: &str, field: &'static str) -> Result<(), WireValidationError> {
if value.trim().is_empty() {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn validate_provider_kind(value: &str) -> Result<(), WireValidationError> {
let valid = !value.is_empty()
&& value.len() <= 64
&& value.bytes().enumerate().all(|(index, byte)| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| (byte == b'-' && index > 0 && index + 1 < value.len())
})
&& !value.starts_with('-')
&& !value.ends_with('-');
if valid {
Ok(())
} else {
Err(WireValidationError::new("providerKind"))
}
}
fn validate_optional_text(
value: Option<&str>,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.is_some_and(|value| value.trim().is_empty()) {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn contains_client_key_material(value: &str) -> bool {
value.as_bytes().windows(46).any(|window| {
&window[..3] == b"sk_"
&& window[3..]
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
})
}
/// 构造固定 GET/POST 且 ID 仅位于 query/body 的 Client API Key 路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/client-keys", get(list_client_keys::<S>))
.route(
"/api/admin/client-keys/create",
post(create_client_key::<S>),
)
.route("/api/admin/client-keys/reveal", get(reveal_client_key::<S>))
.route(
"/api/admin/client-keys/update",
post(update_client_key::<S>),
)
.route(
"/api/admin/client-keys/disable",
post(disable_client_key::<S>),
)
.route(
"/api/admin/client-keys/enable",
post(enable_client_key::<S>),
)
.route(
"/api/admin/client-keys/delete",
post(delete_client_key::<S>),
)
}
async fn list_client_keys<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ListClientKeysQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.client_keys()
.list(query.into_command().map_err(map_wire_error)?)
.await
.map_err(map_service_error)?;
let data = ClientKeyListData::try_from(result)
.map_err(|_| AdminError::internal("Failed to encode client key cursor"))?;
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn create_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<CreateClientKeyRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = payload.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
.client_keys()
.create(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(CreatedClientKeyData::from(result)),
))
}
async fn reveal_client_key<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ClientKeyIdQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let id = query.into_domain_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.client_keys()
.reveal(&id)
.await
.map_err(map_service_error)?;
let mut response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(RevealedClientKeyData::from(result)),
)
.into_response();
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(response)
}
async fn update_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<UpdateClientKeyRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = payload.into_command().map_err(map_wire_error)?;
mutation_response(
state
.admin_services()
.client_keys()
.update(&auth.context().mutation_context(), command)
.await,
)
}
async fn disable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let id = payload.into_domain_id().map_err(map_wire_error)?;
mutation_response(
state
.admin_services()
.client_keys()
.set_enabled(
&auth.context().mutation_context(),
SetClientKeyEnabled { id, enabled: false },
)
.await,
)
}
async fn enable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let id = payload.into_domain_id().map_err(map_wire_error)?;
mutation_response(
state
.admin_services()
.client_keys()
.set_enabled(
&auth.context().mutation_context(),
SetClientKeyEnabled { id, enabled: true },
)
.await,
)
}
async fn delete_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let id = payload.into_domain_id().map_err(map_wire_error)?;
mutation_response(
state
.admin_services()
.client_keys()
.delete(&auth.context().mutation_context(), DeleteClientKey { id })
.await,
)
}
fn mutation_response(
result: Result<ClientKeyMutation, gateway_admin::model::AdminError>,
) -> Result<AdminResponse<AdminEnvelope<MutatedClientKeyData>>, AdminError> {
let data = result.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(MutatedClientKeyData::from(data)),
))
}
fn map_wire_error(error: WireValidationError) -> AdminError {
match error.field() {
"cursor" => AdminError::bad_request("Invalid client key cursor"),
"clientKeyRevealNotFound" => AdminError::not_found("Client API key was not found"),
"clientKeyMutationNotFound" => AdminError::not_found("client API key was not found"),
_ => AdminError::bad_request("Invalid client API key request"),
}
}
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "Configuration repository unavailable")
}
@@ -0,0 +1,44 @@
//! 管理端 HTTP adapter、wire contract 与固定路由。
use axum::{
Router,
http::{HeaderValue, header},
middleware,
response::Response,
};
pub mod accounts;
pub mod auth;
pub mod client_keys;
pub mod observability;
pub mod settings;
pub mod system;
pub mod wire;
pub use auth::{AdminAuth, AdminSessionState};
pub use wire::{
ADMIN_OK_CODE, ADMIN_OK_MESSAGE, AdminEnvelope, AdminError, AdminErrorBody, AdminErrorCode,
AdminPageData, AdminResponse, PageMeta, WireValidationError,
};
/// 构造完整且固定的 `/api/admin` 路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.merge(accounts::router::<S>())
.merge(auth::router::<S>())
.merge(client_keys::router::<S>())
.merge(observability::router::<S>())
.merge(settings::router::<S>())
.merge(system::router::<S>())
.layer(middleware::map_response(no_store))
}
async fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,361 @@
//! Runtime settings、旧设置页聚合投影与明文 Admin API Key wire。
use std::{collections::BTreeMap, fmt};
use axum::{
Json, Router,
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use chrono::{DateTime, Utc};
use gateway_admin::model::settings::{
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings,
};
use gateway_core::routing::{PublicModelId, UpstreamModelId};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError,
wire::map_admin_service_error,
};
/// 客户端模型到上游模型的全局精确映射。
pub type ModelMappings = BTreeMap<String, String>;
/// 运行配置投影与设置页字段的聚合响应。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
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 rotation_strategy: String,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
pub updated_at: DateTime<Utc>,
}
/// 原子替换全局运行参数的请求。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateRuntimeSettingsRequest {
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 rotation_strategy: String,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
}
impl UpdateRuntimeSettingsRequest {
/// 校验公共运行参数。
pub fn validate(&self) -> Result<(), WireValidationError> {
validate_model_mappings(&self.model_mappings)?;
for (value, field) in [
(self.refresh_margin_seconds, "refreshMarginSeconds"),
(self.refresh_concurrency, "refreshConcurrency"),
(self.max_concurrent_per_account, "maxConcurrentPerAccount"),
(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 !matches!(
self.rotation_strategy.as_str(),
"smart" | "quota_reset_priority" | "round_robin" | "sticky"
) {
return Err(WireValidationError::new("rotationStrategy"));
}
Ok(())
}
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
self.validate()?;
Ok(ReplaceRuntimeSettings {
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,
rotation_strategy: parse_rotation_strategy(&self.rotation_strategy)
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
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"))?,
})
}
}
impl From<RuntimeSettings> for RuntimeSettingsView {
fn from(settings: RuntimeSettings) -> Self {
Self {
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,
rotation_strategy: rotation_strategy_name(settings.rotation_strategy).to_owned(),
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),
updated_at: settings.updated_at,
}
}
}
/// 管理 API Key 状态;状态读取不回显完整值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminApiKeyStatus {
pub exists: bool,
}
/// 管理 API Key 重新生成响应。
#[derive(Serialize)]
pub struct RegeneratedAdminApiKey {
pub key: String,
}
impl fmt::Debug for RegeneratedAdminApiKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RegeneratedAdminApiKey")
.field("key", &"[REDACTED]")
.finish()
}
}
/// 管理 API Key 删除响应。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct DeletedAdminApiKey {
pub message: &'static str,
}
impl Default for DeletedAdminApiKey {
fn default() -> Self {
Self {
message: "Admin API key deleted",
}
}
}
/// 构造固定 GET/POST 设置路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/settings", get(settings::<S>))
.route("/api/admin/settings/update", post(update_settings::<S>))
.route(
"/api/admin/settings/admin-api-key",
get(admin_api_key_status::<S>),
)
.route(
"/api/admin/settings/admin-api-key/delete",
post(delete_admin_api_key::<S>),
)
.route(
"/api/admin/settings/admin-api-key/regenerate",
post(regenerate_admin_api_key::<S>),
)
}
async fn settings<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.settings()
.load()
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(RuntimeSettingsView::from(result)),
))
}
async fn update_settings<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateRuntimeSettingsRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
.settings()
.replace(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(RuntimeSettingsView::from(result)),
))
}
async fn admin_api_key_status<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let exists = state
.admin_services()
.settings()
.admin_api_key_exists()
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminApiKeyStatus { exists }),
))
}
async fn regenerate_admin_api_key<S>(
auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.settings()
.regenerate_admin_api_key(&auth.context().mutation_context())
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(RegeneratedAdminApiKey {
key: result.key.expose_for_response().to_owned(),
}),
))
}
async fn delete_admin_api_key<S>(
auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
state
.admin_services()
.settings()
.delete_admin_api_key(&auth.context().mutation_context())
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(DeletedAdminApiKey::default()),
))
}
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(())
}
fn domain_model_mappings(
mappings: ModelMappings,
) -> Result<DomainModelMappings, WireValidationError> {
mappings
.into_iter()
.map(|(requested, upstream)| {
Ok((
PublicModelId::new(requested)
.map_err(|_| WireValidationError::new("modelMappings"))?,
UpstreamModelId::new(upstream)
.map_err(|_| WireValidationError::new("modelMappings"))?,
))
})
.collect()
}
fn wire_model_mappings(mappings: DomainModelMappings) -> ModelMappings {
mappings
.into_iter()
.map(|(requested, upstream)| (requested.to_string(), upstream.to_string()))
.collect()
}
fn parse_rotation_strategy(value: &str) -> Option<RotationStrategy> {
match value {
"smart" => Some(RotationStrategy::Smart),
"quota_reset_priority" => Some(RotationStrategy::QuotaResetPriority),
"round_robin" => Some(RotationStrategy::RoundRobin),
"sticky" => Some(RotationStrategy::Sticky),
_ => None,
}
}
const fn rotation_strategy_name(strategy: RotationStrategy) -> &'static str {
match strategy {
RotationStrategy::Smart => "smart",
RotationStrategy::QuotaResetPriority => "quota_reset_priority",
RotationStrategy::RoundRobin => "round_robin",
RotationStrategy::Sticky => "sticky",
}
}
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 map_wire_error(error: WireValidationError) -> AdminError {
let message = match error.field() {
"settingsRefreshConcurrencyOverflow" => "Invalid refreshConcurrency".to_owned(),
"settingsMaxConcurrencyOverflow" => "Invalid maxConcurrentPerAccount".to_owned(),
"settingsUsageRetentionOverflow" => "Invalid usageRetentionDays".to_owned(),
"settingsOpsRetentionOverflow" => "Invalid opsEventRetentionDays".to_owned(),
"settingsAuditRetentionOverflow" => "Invalid auditRetentionDays".to_owned(),
field => format!("Invalid field: {field}"),
};
AdminError::bad_request(message)
}
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "Settings repository unavailable")
}
@@ -0,0 +1,481 @@
//! 系统管理接口的查询与请求 wire contract。
//!
//! 这里不依赖更新服务或进程控制;应用层通过窄端口提供系统操作事实。
use std::convert::Infallible;
use axum::{
Json, Router,
extract::{Query, State},
http::StatusCode,
response::{
IntoResponse,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post},
};
use futures::{Stream, StreamExt};
use gateway_admin::model::system::{
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdateStatus,
SystemVersion,
};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState,
wire::map_admin_service_error,
};
/// 更新详情查询参数。
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateDetailQuery {
refresh: Option<bool>,
}
impl UpdateDetailQuery {
/// 是否强制从发布源刷新。
#[must_use]
pub fn refresh(&self) -> bool {
self.refresh.unwrap_or(false)
}
}
/// 执行更新请求。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateRequest {
target_version: String,
}
impl UpdateRequest {
/// 取出确认的目标版本原值,由更新领域负责版本规范化和校验。
#[must_use]
pub fn into_target_version(self) -> String {
self.target_version
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemVersionView {
version: String,
git_sha: String,
build_time: String,
deployment_mode: String,
deployment_mode_label: String,
update_channel: String,
latest_version: String,
has_update: bool,
update_cached: bool,
update_warning: Option<String>,
}
impl From<SystemVersion> for SystemVersionView {
fn from(version: SystemVersion) -> Self {
Self {
deployment_mode_label: deployment_mode_label(&version.deployment_mode).to_owned(),
version: version.version,
git_sha: version.git_sha,
build_time: version.build_time,
deployment_mode: version.deployment_mode,
update_channel: version.update_channel,
latest_version: version.latest_version,
has_update: version.has_update,
update_cached: version.update_cached,
update_warning: version.update_warning,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemUpdateDetailView {
current_version: String,
latest_version: String,
has_update: bool,
deployment_mode: String,
deployment_mode_label: String,
build_type: String,
build_type_label: String,
release_url: Option<String>,
notes: Option<String>,
cached: bool,
update_supported: bool,
unsupported_reason: Option<String>,
warning: Option<String>,
}
impl From<SystemUpdateDetail> for SystemUpdateDetailView {
fn from(detail: SystemUpdateDetail) -> Self {
Self {
deployment_mode_label: deployment_mode_label(&detail.deployment_mode).to_owned(),
build_type_label: build_type_label(&detail.build_type),
current_version: detail.current_version,
latest_version: detail.latest_version,
has_update: detail.has_update,
deployment_mode: detail.deployment_mode,
build_type: detail.build_type,
release_url: detail.release_url,
notes: detail.notes,
cached: detail.cached,
update_supported: detail.update_supported,
unsupported_reason: detail.unsupported_reason,
warning: detail.warning,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemOperationStateView {
operation_id: Option<String>,
kind: Option<&'static str>,
status: &'static str,
target_version: Option<String>,
message: Option<String>,
error: Option<String>,
started_at: Option<String>,
finished_at: Option<String>,
}
impl From<SystemOperationState> for SystemOperationStateView {
fn from(operation: SystemOperationState) -> Self {
Self {
operation_id: operation.operation_id,
kind: operation.kind.map(operation_kind_name),
status: operation_status_name(operation.status),
target_version: operation.target_version,
message: operation.message,
error: operation.error,
started_at: operation.started_at.map(|value| value.to_rfc3339()),
finished_at: operation.finished_at.map(|value| value.to_rfc3339()),
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemUpdateStatusView {
previous_version: Option<String>,
current_version: Option<String>,
operation: SystemOperationStateView,
}
impl From<SystemUpdateStatus> for SystemUpdateStatusView {
fn from(status: SystemUpdateStatus) -> Self {
Self {
previous_version: status.previous_version,
current_version: status.current_version,
operation: status.operation.into(),
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdateAcceptedView {
operation_id: String,
deployment_mode: String,
message: String,
need_restart: bool,
target_version: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct RollbackAcceptedView {
message: String,
need_restart: bool,
operation_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct RestartAcceptedView {
message: String,
operation_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemUpdateEventView {
id: String,
operation_id: Option<String>,
level: &'static str,
step: Option<String>,
message: String,
terminal: bool,
progress_percent: Option<u8>,
at: String,
}
/// 构造固定 GET/POST 系统管理路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/system/version", get(version::<S>))
.route("/api/admin/system/update/detail", get(update_detail::<S>))
.route(
"/api/admin/system/update/events",
get(update_event_stream::<S>),
)
.route("/api/admin/system/update", post(perform_update::<S>))
.route("/api/admin/system/update/status", get(update_status::<S>))
.route("/api/admin/system/rollback", post(rollback::<S>))
.route("/api/admin/system/restart", post(restart::<S>))
}
async fn version<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let version = state
.admin_services()
.system()
.version()
.await
.map_err(map_system_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(SystemVersionView::from(version)),
))
}
async fn update_detail<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UpdateDetailQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let detail = state
.admin_services()
.system()
.update_detail(query.refresh())
.await
.map_err(map_system_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(SystemUpdateDetailView::from(detail)),
))
}
async fn update_event_stream<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let stream = state
.admin_services()
.system()
.update_events()
.map(|message| {
let id = message.id.clone();
let data = SystemUpdateEventView::from(message).into_json().to_string();
Ok(Event::default().event("update").id(id).data(data))
});
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
async fn perform_update<S>(
_auth: AdminAuth,
State(state): State<S>,
payload: Option<Json<UpdateRequest>>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.system()
.perform_update(payload.map(|Json(value)| value.into_target_version()))
.await
.map_err(map_system_error)?;
let SystemOperationAccepted::Update {
operation_id,
deployment_mode,
message,
need_restart,
target_version,
} = result
else {
return Err(AdminError::internal("Invalid system update result"));
};
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(UpdateAcceptedView {
operation_id,
deployment_mode,
message,
need_restart,
target_version,
}),
))
}
async fn update_status<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let status = state
.admin_services()
.system()
.update_status()
.await
.map_err(map_system_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(SystemUpdateStatusView::from(status)),
))
}
async fn rollback<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.system()
.rollback()
.await
.map_err(map_system_error)?;
let SystemOperationAccepted::Rollback {
operation_id,
message,
need_restart,
} = result
else {
return Err(AdminError::internal("Invalid system rollback result"));
};
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(RollbackAcceptedView {
message,
need_restart,
operation_id,
}),
))
}
async fn restart<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.system()
.restart()
.await
.map_err(map_system_error)?;
let SystemOperationAccepted::Restart {
operation_id,
message,
} = result
else {
return Err(AdminError::internal("Invalid system restart result"));
};
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(RestartAcceptedView {
message,
operation_id,
}),
))
}
impl From<SystemUpdateEvent> for SystemUpdateEventView {
fn from(event: SystemUpdateEvent) -> Self {
Self {
id: event.id,
operation_id: event.operation_id,
level: update_event_level_name(event.level),
step: event.step,
message: event.message,
terminal: event.terminal,
progress_percent: event.progress_percent,
at: event.occurred_at.to_rfc3339(),
}
}
}
impl SystemUpdateEventView {
fn into_json(self) -> serde_json::Value {
serde_json::json!({
"id": self.id,
"operationId": self.operation_id,
"level": self.level,
"step": self.step,
"message": self.message,
"terminal": self.terminal,
"progressPercent": self.progress_percent,
"at": self.at,
})
}
}
fn deployment_mode_label(mode: &str) -> &'static str {
match mode {
"docker" => "Docker",
"binary" => "二进制",
_ => "源码运行",
}
}
fn build_type_label(build_type: &str) -> String {
match build_type {
"release" => "正式构建",
"source" => "源码构建",
"dev" => "开发构建",
value => value,
}
.to_owned()
}
const fn operation_kind_name(kind: SystemOperationKind) -> &'static str {
match kind {
SystemOperationKind::Update => "update",
SystemOperationKind::Rollback => "rollback",
SystemOperationKind::Restart => "restart",
}
}
const fn operation_status_name(status: SystemOperationStatus) -> &'static str {
match status {
SystemOperationStatus::Idle => "idle",
SystemOperationStatus::Running => "running",
SystemOperationStatus::Succeeded => "succeeded",
SystemOperationStatus::Failed => "failed",
}
}
const fn update_event_level_name(level: SystemUpdateEventLevel) -> &'static str {
match level {
SystemUpdateEventLevel::Info => "info",
SystemUpdateEventLevel::Warning => "warning",
SystemUpdateEventLevel::Success => "success",
SystemUpdateEventLevel::Error => "error",
}
}
fn map_system_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "System operation unavailable")
}
@@ -0,0 +1,376 @@
//! 管理端公共 wire、响应信封与脱敏错误。
use std::fmt;
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
/// 管理端 wire 字段校验错误;只携带稳定字段名,不回显输入值。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WireValidationError {
field: &'static str,
}
impl WireValidationError {
/// 构造字段校验错误。
#[must_use]
pub const fn new(field: &'static str) -> Self {
Self { field }
}
/// 返回未通过校验的字段名。
#[must_use]
pub const fn field(self) -> &'static str {
self.field
}
}
/// 成功响应的稳定业务码。
pub const ADMIN_OK_CODE: u32 = 200;
/// 成功响应的稳定消息。
pub const ADMIN_OK_MESSAGE: &str = "OK";
/// 页码分页元数据。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageMeta {
page: u32,
page_size: u32,
total: u64,
total_pages: u32,
}
impl PageMeta {
/// 由分页 owner 已经校验并计算的事实构造 wire metadata。
#[must_use]
pub const fn new(page: u32, page_size: u32, total: u64, total_pages: u32) -> Self {
Self {
page,
page_size,
total,
total_pages,
}
}
/// 当前页,从 1 开始。
#[must_use]
pub const fn page(self) -> u32 {
self.page
}
/// 每页数量。
#[must_use]
pub const fn page_size(self) -> u32 {
self.page_size
}
/// 全部记录数量。
#[must_use]
pub const fn total(self) -> u64 {
self.total
}
/// 总页数。
#[must_use]
pub const fn total_pages(self) -> u32 {
self.total_pages
}
}
/// 管理端稳定业务错误码。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AdminErrorCode(u32);
impl AdminErrorCode {
/// JSON 解析失败。
pub const MALFORMED_JSON: Self = Self(40000);
/// 请求参数或状态不合法。
pub const BAD_REQUEST: Self = Self(40001);
/// 时间范围不合法。
pub const INVALID_TIME_RANGE: Self = Self(40002);
/// 模型来源不合法。
pub const INVALID_MODEL_SOURCE: Self = Self(40003);
/// 缺少管理员会话。
pub const SESSION_REQUIRED: Self = Self(40101);
/// 管理员登录凭据错误。
pub const INVALID_CREDENTIALS: Self = Self(40102);
/// 管理 API Key 错误。
pub const INVALID_API_KEY: Self = Self(40103);
/// 资源不存在。
pub const NOT_FOUND: Self = Self(40401);
/// 配置 revision 或资源状态冲突。
pub const CONFLICT: Self = Self(40901);
/// 管理员登录尝试过多。
pub const TOO_MANY_LOGIN_ATTEMPTS: Self = Self(42901);
/// 设置持久化失败。
pub const SETTINGS_PERSIST: Self = Self(50000);
/// 未分类内部错误。
pub const INTERNAL: Self = Self(50001);
/// 用量记录的账号投影失败。
pub const USAGE_RECORD_ACCOUNTS: Self = Self(50002);
/// 上游网关失败。
pub const BAD_GATEWAY: Self = Self(50201);
/// 依赖的服务暂不可用。
pub const SERVICE_UNAVAILABLE: Self = Self(50301);
/// 返回用于 JSON wire contract 的数值。
#[must_use]
pub const fn value(self) -> u32 {
self.0
}
}
/// 管理端错误响应正文。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminErrorBody {
code: AdminErrorCode,
message: String,
data: (),
}
impl AdminErrorBody {
/// 构造不携带业务数据的管理端错误正文。
#[must_use]
pub fn new(code: AdminErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
data: (),
}
}
/// 稳定业务错误码。
#[must_use]
pub const fn code(&self) -> AdminErrorCode {
self.code
}
/// 安全的客户端错误消息。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
/// 管理端 HTTP 错误。
pub struct AdminError {
status: StatusCode,
body: AdminErrorBody,
}
impl AdminError {
fn new(status: StatusCode, code: AdminErrorCode, message: impl Into<String>) -> Self {
Self {
status,
body: AdminErrorBody::new(code, message),
}
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(
StatusCode::BAD_REQUEST,
AdminErrorCode::BAD_REQUEST,
message,
)
}
pub fn admin_session_required() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::SESSION_REQUIRED,
"Admin session required",
)
}
pub fn invalid_admin_credentials() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::INVALID_CREDENTIALS,
"Invalid admin credentials",
)
}
pub fn invalid_admin_api_key() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::INVALID_API_KEY,
"Invalid admin API key",
)
}
pub fn too_many_login_attempts() -> Self {
Self::new(
StatusCode::TOO_MANY_REQUESTS,
AdminErrorCode::TOO_MANY_LOGIN_ATTEMPTS,
"Too many admin login attempts",
)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, AdminErrorCode::CONFLICT, message)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, AdminErrorCode::NOT_FOUND, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminErrorCode::INTERNAL,
message,
)
}
pub fn bad_gateway(message: impl Into<String>) -> Self {
Self::new(
StatusCode::BAD_GATEWAY,
AdminErrorCode::BAD_GATEWAY,
message,
)
}
pub fn service_unavailable(message: impl Into<String>) -> Self {
Self::new(
StatusCode::SERVICE_UNAVAILABLE,
AdminErrorCode::SERVICE_UNAVAILABLE,
message,
)
}
}
/// 把管理用例的稳定错误分类映射到既有 HTTP 错误 contract。
pub(crate) fn map_admin_service_error(
error: gateway_admin::model::AdminError,
unavailable_message: &'static str,
) -> AdminError {
use gateway_admin::model::AdminErrorKind;
match error.kind() {
AdminErrorKind::Invalid => AdminError::bad_request(error.to_string()),
AdminErrorKind::Unauthorized => AdminError::admin_session_required(),
AdminErrorKind::NotFound => AdminError::not_found(error.to_string()),
AdminErrorKind::Conflict => AdminError::conflict(error.to_string()),
AdminErrorKind::RateLimited => AdminError::too_many_login_attempts(),
AdminErrorKind::BadGateway => AdminError::bad_gateway(error.to_string()),
AdminErrorKind::Unavailable => AdminError::service_unavailable(unavailable_message),
AdminErrorKind::Internal => AdminError::internal(error.to_string()),
}
}
impl IntoResponse for AdminError {
fn into_response(self) -> Response {
(self.status, Json(self.body)).into_response()
}
}
impl fmt::Display for AdminError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.body.message())
}
}
/// 带独立 HTTP 状态的管理端响应。
pub struct AdminResponse<T: Serialize> {
status: StatusCode,
body: T,
}
impl<T: Serialize> AdminResponse<T> {
#[must_use]
pub fn new(status: StatusCode, body: T) -> Self {
Self { status, body }
}
}
impl<T: Serialize> IntoResponse for AdminResponse<T> {
fn into_response(self) -> Response {
(self.status, Json(self.body)).into_response()
}
}
/// 管理端响应信封。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminEnvelope<T> {
code: u32,
message: String,
data: T,
}
impl<T> AdminEnvelope<T> {
/// 构造稳定的成功响应。
#[must_use]
pub fn ok(data: T) -> Self {
Self {
code: ADMIN_OK_CODE,
message: ADMIN_OK_MESSAGE.to_owned(),
data,
}
}
/// 稳定业务码。
#[must_use]
pub const fn code(&self) -> u32 {
self.code
}
/// 稳定业务消息。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
/// 响应数据。
#[must_use]
pub const fn data(&self) -> &T {
&self.data
}
/// 取出响应数据。
#[must_use]
pub fn into_data(self) -> T {
self.data
}
}
/// 页码分页响应数据。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminPageData<T> {
items: Vec<T>,
page: PageMeta,
}
impl<T> AdminPageData<T> {
/// 组合查询 owner 返回的当前页数据与分页事实。
#[must_use]
pub const fn new(items: Vec<T>, page: PageMeta) -> Self {
Self { items, page }
}
/// 当前页记录。
#[must_use]
pub fn items(&self) -> &[T] {
&self.items
}
/// 分页元数据。
#[must_use]
pub const fn page(&self) -> PageMeta {
self.page
}
/// 拆分分页数据。
#[must_use]
pub fn into_parts(self) -> (Vec<T>, PageMeta) {
(self.items, self.page)
}
}
+68
View File
@@ -0,0 +1,68 @@
//! `/healthz` 对 Core、Store 与 Host worker 健康事实的聚合。
use std::sync::Arc;
use std::time::Duration;
use axum::{extract::State, http::StatusCode};
use futures::future::join_all;
use gateway_core::health::{
HealthProbe, HealthState, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState,
};
use crate::ApiState;
const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Clone)]
pub(crate) struct HealthStatus {
probes: Arc<[Arc<dyn HealthProbe>]>,
workers: Arc<dyn WorkerHealthSource>,
}
impl HealthStatus {
#[must_use]
pub(crate) fn new(
probes: Vec<Arc<dyn HealthProbe>>,
workers: Arc<dyn WorkerHealthSource>,
) -> Self {
Self {
probes: probes.into(),
workers,
}
}
pub(crate) async fn healthy(&self) -> bool {
if !self.workers.snapshot().iter().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))
})
}
}
pub(crate) async fn healthz(State(state): State<ApiState>) -> StatusCode {
if state.health().healthy().await {
StatusCode::NO_CONTENT
} else {
StatusCode::SERVICE_UNAVAILABLE
}
}
fn worker_is_healthy(worker: &WorkerHealthSnapshot) -> bool {
match worker.state {
WorkerRuntimeState::Disabled | WorkerRuntimeState::Standby => true,
WorkerRuntimeState::Running => worker.consecutive_failures == 0,
WorkerRuntimeState::AcquiringLease | WorkerRuntimeState::Idle => {
worker.consecutive_failures == 0 && worker.last_success_at.is_some()
}
WorkerRuntimeState::Starting
| WorkerRuntimeState::BackingOff
| WorkerRuntimeState::Stopped => false,
}
}
+224
View File
@@ -0,0 +1,224 @@
//! 客户端与管理端 HTTP 协议 adapter。
//!
//! 本 crate 只负责请求解码、Core/Admin 调用和 HTTP/WS/SSE delivery。
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::str::FromStr as _;
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
use axum::routing::get;
use gateway_admin::AdminServices;
use gateway_core::engine::execution::ExecutionService;
use gateway_core::health::{HealthProbe, WorkerHealthSource};
use gateway_core::lifecycle::ConnectionLifecycle;
use serde::Deserialize;
use tower_http::cors::CorsLayer;
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::timeout::TimeoutLayer;
use tower_http::trace::TraceLayer;
use url::Url;
const WEB_DIST_ENV: &str = "CPR_WEB_DIST_DIR";
use crate::health::HealthStatus;
use crate::openai::service::OpenAiService;
pub mod admin;
mod health;
pub mod openai;
/// API-owned HTTP 与静态资源配置。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ApiConfig {
pub asset_directory: PathBuf,
pub cors_allowed_origins: Vec<String>,
pub request_timeout_seconds: Option<u64>,
pub request_id_header: String,
}
impl ApiConfig {
/// 解析静态资源相对路径并校验全部 HTTP 配置。
///
/// # Errors
///
/// 路径为空、origin/header 非法或 timeout 为零时返回脱敏错误。
pub fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), ApiConfigError> {
match std::env::var(WEB_DIST_ENV) {
Ok(value) if value.trim().is_empty() => {
return Err(ApiConfigError::InvalidAssetDirectory);
}
Ok(value) => self.asset_directory = PathBuf::from(value),
Err(std::env::VarError::NotPresent) => {}
Err(std::env::VarError::NotUnicode(_)) => {
return Err(ApiConfigError::InvalidAssetDirectory);
}
}
if self.asset_directory.as_os_str().is_empty() {
return Err(ApiConfigError::InvalidAssetDirectory);
}
if self.asset_directory.is_relative() {
self.asset_directory = source_dir.join(&self.asset_directory);
}
if self.request_timeout_seconds == Some(0) {
return Err(ApiConfigError::InvalidRequestTimeout);
}
let header = HeaderName::from_str(&self.request_id_header)
.map_err(|_| ApiConfigError::InvalidRequestIdHeader)?;
self.request_id_header = header.as_str().to_owned();
let mut origins = Vec::with_capacity(self.cors_allowed_origins.len());
let mut unique = BTreeSet::new();
for origin in &self.cors_allowed_origins {
let origin = validate_origin(origin)?;
if !unique.insert(origin.clone()) {
return Err(ApiConfigError::DuplicateCorsOrigin);
}
origins.push(origin);
}
self.cors_allowed_origins = origins;
Ok(())
}
}
/// API 配置非法的稳定分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ApiConfigError {
#[error("API asset directory is invalid")]
InvalidAssetDirectory,
#[error("API CORS origin is invalid")]
InvalidCorsOrigin,
#[error("API CORS origin is duplicated")]
DuplicateCorsOrigin,
#[error("API request timeout is invalid")]
InvalidRequestTimeout,
#[error("API request ID header is invalid")]
InvalidRequestIdHeader,
}
/// 完成组装的唯一 API router。
pub struct ApiBundle {
router: Router,
}
impl ApiBundle {
pub fn router(self) -> Router {
self.router
}
}
/// 组装客户端、管理端、健康检查和静态资源路由。
pub fn initialize(
mut config: ApiConfig,
execution: Arc<dyn ExecutionService>,
admin: AdminServices,
probes: Vec<Arc<dyn HealthProbe>>,
worker_health: Arc<dyn WorkerHealthSource>,
lifecycle: Arc<dyn ConnectionLifecycle>,
) -> Result<ApiBundle, ApiError> {
config
.resolve_and_validate(Path::new("."))
.map_err(ApiError::Config)?;
let request_id_header = HeaderName::from_str(&config.request_id_header)
.map_err(|_| ApiError::Config(ApiConfigError::InvalidRequestIdHeader))?;
let state = ApiState {
admin,
openai: OpenAiService::new(execution, lifecycle),
health: HealthStatus::new(probes, worker_health),
};
let index = config.asset_directory.join("index.html");
let mut router = Router::new()
.route("/healthz", get(health::healthz))
.merge(openai::router::router())
.merge(admin::router::<ApiState>())
.fallback_service(ServeDir::new(config.asset_directory).fallback(ServeFile::new(index)));
if !config.cors_allowed_origins.is_empty() {
let origins = config
.cors_allowed_origins
.iter()
.map(|origin| {
HeaderValue::from_str(origin)
.map_err(|_| ApiError::Config(ApiConfigError::InvalidCorsOrigin))
})
.collect::<Result<Vec<_>, _>>()?;
// credentials 模式禁止通配 allow_headers;显式列出鉴权与内容协商头。
router = router.layer(
CorsLayer::new()
.allow_origin(origins)
.allow_methods([Method::GET, Method::POST])
.allow_headers([
axum::http::header::AUTHORIZATION,
axum::http::header::CONTENT_TYPE,
HeaderName::from_static("x-api-key"),
request_id_header.clone(),
])
.allow_credentials(true),
);
}
if let Some(seconds) = config.request_timeout_seconds {
router = router.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(seconds),
));
}
let router = router
.layer(PropagateRequestIdLayer::new(request_id_header.clone()))
.layer(SetRequestIdLayer::new(request_id_header, MakeRequestUuid))
.layer(TraceLayer::new_for_http())
.with_state(state);
Ok(ApiBundle { router })
}
/// API 初始化失败的脱敏分类。
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error(transparent)]
Config(ApiConfigError),
}
#[derive(Clone)]
pub(crate) struct ApiState {
admin: AdminServices,
openai: OpenAiService,
health: HealthStatus,
}
impl ApiState {
#[must_use]
pub(crate) const fn openai(&self) -> &OpenAiService {
&self.openai
}
#[must_use]
pub(crate) const fn health(&self) -> &HealthStatus {
&self.health
}
}
impl admin::AdminSessionState for ApiState {
fn admin_services(&self) -> &AdminServices {
&self.admin
}
}
fn validate_origin(raw: &str) -> Result<String, ApiConfigError> {
let url = Url::parse(raw).map_err(|_| ApiConfigError::InvalidCorsOrigin)?;
if !matches!(url.scheme(), "http" | "https")
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.path() != "/"
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(ApiConfigError::InvalidCorsOrigin);
}
let origin = url.origin().ascii_serialization();
HeaderValue::from_str(&origin).map_err(|_| ApiConfigError::InvalidCorsOrigin)?;
Ok(origin)
}
@@ -0,0 +1,98 @@
//! OpenAI 客户端协议的 Bearer API key 认证。
use axum::{
http::{HeaderMap, header::AUTHORIZATION},
response::{IntoResponse, Response},
};
use gateway_core::engine::execution::AuthenticatedClient;
use super::{
error::{missing_client_api_key_response, runtime_unavailable_response},
service::OpenAiService,
};
/// Client API key 鉴权失败原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientApiKeyAuthError {
/// 缺失 Authorization 头。
MissingAuthorization,
/// Authorization 头不是合法的 Bearer token。
MalformedAuthorization,
/// Bearer token 不是 client API key 格式。
InvalidKeyFormat,
/// Key 不存在、已禁用或 wire 格式无效。
InvalidKey,
/// RuntimeSnapshot 一致性保护暂停接收新请求。
RuntimeUnavailable,
}
impl ClientApiKeyAuthError {
/// 返回可用于日志和指标的稳定失败原因。
#[must_use]
pub const fn reason(self) -> &'static str {
match self {
Self::MissingAuthorization => "missing_authorization",
Self::MalformedAuthorization => "malformed_authorization",
Self::InvalidKeyFormat => "invalid_key_format",
Self::InvalidKey => "invalid_key",
Self::RuntimeUnavailable => "runtime_unavailable",
}
}
}
/// 从请求头提取 Bearer Client API key。
///
/// # Errors
///
/// Header 缺失、Bearer 语法错误或不是网关 Client Key 前缀时返回稳定错误。
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 !token.starts_with("sk_") {
return Err(ClientApiKeyAuthError::InvalidKeyFormat);
}
Ok(token)
}
pub(crate) fn authenticate_client(
service: &OpenAiService,
headers: &HeaderMap,
) -> Result<AuthenticatedClient, ClientApiKeyAuthError> {
bearer_client_api_key(headers).and_then(|key| service.authenticate(key))
}
pub(crate) fn authentication_error_response(error: ClientApiKeyAuthError) -> Response {
log_client_api_key_auth_failure(error);
if error == ClientApiKeyAuthError::RuntimeUnavailable {
runtime_unavailable_response().into_response()
} else {
missing_client_api_key_response().into_response()
}
}
fn log_client_api_key_auth_failure(error: ClientApiKeyAuthError) {
match error {
ClientApiKeyAuthError::RuntimeUnavailable => {
tracing::warn!(
auth_failure = error.reason(),
"Client API key authorization failed"
);
}
_ => {
tracing::info!(
auth_failure = error.reason(),
"Client API key authorization failed"
);
}
}
}
@@ -0,0 +1,219 @@
//! OpenAI 客户端协议的稳定错误响应。
use axum::{
Json,
body::Body,
http::{HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use gateway_core::{
engine::{EngineError, UpstreamSendState},
error::{GatewayError, GatewayErrorKind, ProviderErrorKind},
};
use serde_json::{Value, json};
use super::responses::ProtocolErrorBody;
/// OpenAI 风格错误响应。
pub fn openai_error_response(
status: StatusCode,
message: &str,
error_type: &str,
code: &str,
) -> (StatusCode, Json<Value>) {
(
status,
Json(json!({
"error": {
"message": message,
"type": error_type,
"code": code
}
})),
)
}
/// 严格协议 decoder/encoder 返回的安全错误 body。
pub fn protocol_error_response(status: StatusCode, body: ProtocolErrorBody) -> Response {
(status, Json(body.into_value())).into_response()
}
/// 下游 Client API Key 无效。
pub fn missing_client_api_key_response() -> (StatusCode, Json<Value>) {
openai_error_response(
StatusCode::UNAUTHORIZED,
"Missing or invalid API key",
"invalid_request_error",
"invalid_api_key",
)
}
/// RuntimeSnapshot 当前不允许接收新的配置依赖请求。
pub fn runtime_unavailable_response() -> (StatusCode, Json<Value>) {
openai_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Gateway runtime configuration is temporarily unavailable",
"server_error",
"runtime_configuration_unavailable",
)
}
/// 对外模型不存在。
pub fn model_not_found_response() -> (StatusCode, Json<Value>) {
openai_error_response(
StatusCode::NOT_FOUND,
"The requested model does not exist or is not available",
"invalid_request_error",
"model_not_found",
)
}
/// 将 core Engine 错误收敛为稳定 Gateway 分类。
#[must_use]
pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
match error {
EngineError::Provider(error)
if error.kind() == ProviderErrorKind::Unavailable
&& error.send_state() == UpstreamSendState::NotSent =>
{
let gateway = GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no upstream provider is currently available for this request",
);
match error.client_visible_upstream_error() {
Some(upstream) => gateway.with_client_visible_upstream_error(upstream.clone()),
None => gateway,
}
}
EngineError::Provider(error) => GatewayError::from_provider(error),
EngineError::Cancelled => {
GatewayError::new(GatewayErrorKind::Cancelled, "request was cancelled")
}
EngineError::Deadline => {
GatewayError::new(GatewayErrorKind::Timeout, "gateway request timed out")
}
EngineError::ProviderNotRegistered { .. } | EngineError::EmptyRoutingPlan => {
GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no upstream provider is currently available for this request",
)
}
EngineError::Store(_)
| EngineError::ProviderMetadataMismatch
| EngineError::ContinuationPinMismatch
| EngineError::RequiredAccountMismatch
| EngineError::DownstreamCommitRequired
| EngineError::NoActiveAttempt
| EngineError::InvalidDeliveryState => {
GatewayError::new(GatewayErrorKind::Internal, "gateway execution failed")
}
}
}
/// Gateway 错误的 OpenAI HTTP 表达。
pub fn gateway_error_response(error: &GatewayError) -> Response {
let (status, default_type, default_code) = gateway_error_contract(error.kind());
openai_error_response(
status,
error.client_message(),
error.client_error_type().unwrap_or(default_type),
error.client_error_code().unwrap_or(default_code),
)
.into_response()
}
/// 在下游尚未提交时优先交付 Provider 的原始 HTTP 失败响应。
pub fn engine_error_response(error: &EngineError) -> Response {
if let EngineError::Provider(error) = error
&& let Some(upstream) = error.client_visible_upstream_response()
&& let Ok(status) = StatusCode::from_u16(upstream.status())
{
let mut response = Response::new(Body::from(upstream.body().clone()));
*response.status_mut() = status;
if let Some(content_type) = upstream.content_type()
&& let Ok(content_type) = HeaderValue::from_bytes(content_type)
{
response.headers_mut().insert(CONTENT_TYPE, content_type);
}
for header in upstream.headers() {
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
continue;
};
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
continue;
};
response.headers_mut().append(name, value);
}
return response;
}
gateway_error_response(&gateway_error_from_engine(error))
}
/// Gateway 错误稳定映射,供 HTTP、SSE 和 WebSocket 共用。
#[must_use]
pub const fn gateway_error_contract(
kind: GatewayErrorKind,
) -> (StatusCode, &'static str, &'static str) {
match kind {
GatewayErrorKind::InvalidRequest => (
StatusCode::BAD_REQUEST,
"invalid_request_error",
"invalid_request",
),
GatewayErrorKind::Unsupported => (
StatusCode::BAD_REQUEST,
"invalid_request_error",
"unsupported_capability",
),
GatewayErrorKind::Unauthorized => (
StatusCode::UNAUTHORIZED,
"invalid_request_error",
"invalid_api_key",
),
GatewayErrorKind::PolicyDenied => (
StatusCode::FORBIDDEN,
"invalid_request_error",
"policy_denied",
),
GatewayErrorKind::ModelNotFound => (
StatusCode::NOT_FOUND,
"invalid_request_error",
"model_not_found",
),
GatewayErrorKind::NoAvailableProvider => (
StatusCode::SERVICE_UNAVAILABLE,
"server_error",
"no_available_provider",
),
GatewayErrorKind::RateLimited => (
StatusCode::TOO_MANY_REQUESTS,
"rate_limit_error",
"rate_limit_exceeded",
),
GatewayErrorKind::UpstreamUnavailable => (
StatusCode::BAD_GATEWAY,
"server_error",
"upstream_unavailable",
),
GatewayErrorKind::Timeout => (
StatusCode::GATEWAY_TIMEOUT,
"server_error",
"request_timeout",
),
GatewayErrorKind::Cancelled => (
StatusCode::REQUEST_TIMEOUT,
"server_error",
"request_cancelled",
),
GatewayErrorKind::Internal => (
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"internal_error",
),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"internal_error",
),
}
}
@@ -0,0 +1,8 @@
//! OpenAI 客户端协议 adapter。
pub mod auth;
pub mod error;
pub mod models;
pub mod responses;
pub mod router;
pub(crate) mod service;
@@ -0,0 +1,256 @@
//! OpenAI 模型目录 HTTP adapter。
use axum::{
Json,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use gateway_core::routing::{PublicModelId, PublicModelProfile};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::ApiState;
use super::{
auth::{authenticate_client, authentication_error_response},
error::model_not_found_response,
};
const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000;
const CODEX_BASE_INSTRUCTIONS: &str = "You are Codex, a coding agent. Follow the user's instructions and use the available tools to complete software engineering tasks. Inspect relevant files before editing, preserve unrelated changes, and verify the result.";
#[derive(Debug, Deserialize)]
pub(crate) struct ModelsQuery {
client_version: Option<String>,
}
/// `GET /v1/models`。Codex 携带 `client_version` 时返回其专用目录合同。
pub(crate) async fn models(
State(state): State<ApiState>,
Query(query): Query<ModelsQuery>,
headers: HeaderMap,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
};
if query
.client_version
.as_deref()
.is_some_and(|version| !version.trim().is_empty())
{
let models = service
.public_model_profiles(&client)
.iter()
.enumerate()
.map(|(index, profile)| codex_model_json(profile, index))
.collect::<Vec<_>>();
return (
StatusCode::OK,
Json(json!({
"models": models,
})),
)
.into_response();
}
let data = service
.public_models(&client)
.into_iter()
.map(|model| openai_model_json(&model))
.collect::<Vec<_>>();
(
StatusCode::OK,
Json(json!({
"object": "list",
"data": data,
})),
)
.into_response()
}
/// `GET /v1/models/{model_id}`。
pub(crate) async fn model_detail(
State(state): State<ApiState>,
headers: HeaderMap,
Path(model_id): Path<String>,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
};
let Ok(public_model) = PublicModelId::new(model_id) else {
return model_not_found_response().into_response();
};
if !service.contains_public_model(&client, &public_model) {
return model_not_found_response().into_response();
}
(
StatusCode::OK,
Json(openai_model_json(public_model.as_str())),
)
.into_response()
}
/// `GET /v1/models/catalog`。
///
/// 新架构的目录由 core 按客户端 Provider 范围裁剪;这里不读取或重建任一
/// Provider 的 wire catalog,只恢复 Codex 客户端依赖的稳定展示合同。
pub(crate) async fn model_catalog(State(state): State<ApiState>, headers: HeaderMap) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
};
let models = service
.public_models(&client)
.into_iter()
.map(|model| catalog_model_json(model.as_str()))
.collect::<Vec<_>>();
(StatusCode::OK, Json(models)).into_response()
}
/// `GET /v1/models/{model_id}/info`。
pub(crate) async fn model_info(
State(state): State<ApiState>,
headers: HeaderMap,
Path(model_id): Path<String>,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
};
let Ok(public_model) = PublicModelId::new(model_id) else {
return model_not_found_response().into_response();
};
if !service.contains_public_model(&client, &public_model) {
return model_not_found_response().into_response();
}
(
StatusCode::OK,
Json(catalog_model_json(public_model.as_str())),
)
.into_response()
}
fn openai_model_json(id: &str) -> Value {
json!({
"id": id,
"object": "model",
"created": MODEL_CREATED_TIMESTAMP,
"owned_by": "gateway",
})
}
fn catalog_model_json(id: &str) -> Value {
json!({
"id": id,
"displayName": id,
"description": "",
"isDefault": false,
"supportedReasoningEfforts": [],
"defaultReasoningEffort": "",
"inputModalities": ["text"],
"outputModalities": ["text"],
"supportsPersonality": false,
"upgrade": Value::Null,
"source": "gateway",
})
}
fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
let id = profile.model().as_str();
let presentation = profile.presentation();
let reasoning_levels = presentation
.supported_reasoning_efforts()
.iter()
.map(|effort| {
json!({
"effort": effort,
"description": reasoning_effort_description(effort),
})
})
.collect::<Vec<_>>();
let reasoning_supported = presentation
.supported_reasoning_efforts()
.iter()
.any(|effort| effort != "none");
let context_window = presentation.context_window_tokens();
let input_modalities = if presentation.image_input() {
vec!["text", "image"]
} else {
vec!["text"]
};
let additional_speed_tiers = presentation
.service_tiers()
.iter()
.filter_map(|tier| tier.speed_tier())
.collect::<Vec<_>>();
let service_tiers = presentation
.service_tiers()
.iter()
.map(|tier| {
json!({
"id": tier.id(),
"name": tier.name(),
"description": tier.description(),
})
})
.collect::<Vec<_>>();
json!({
"slug": id,
"display_name": presentation.display_name().unwrap_or(id),
"description": presentation.description(),
"default_reasoning_level": presentation.default_reasoning_effort(),
"supported_reasoning_levels": reasoning_levels,
"shell_type": "shell_command",
"visibility": if presentation.hidden() { "hide" } else { "list" },
"supported_in_api": true,
"priority": index.saturating_add(1),
"additional_speed_tiers": additional_speed_tiers,
"service_tiers": service_tiers,
"availability_nux": Value::Null,
"upgrade": Value::Null,
"base_instructions": CODEX_BASE_INSTRUCTIONS,
"model_messages": Value::Null,
"include_skills_usage_instructions": false,
"supports_reasoning_summary_parameter": reasoning_supported,
"default_reasoning_summary": "auto",
"support_verbosity": presentation.verbosity(),
"default_verbosity": Value::Null,
"apply_patch_tool_type": presentation.agent_tools().then_some("freeform"),
"web_search_tool_type": "text",
"truncation_policy": { "mode": "tokens", "limit": 10_000 },
"supports_parallel_tool_calls": presentation.parallel_tool_calls(),
"supports_image_detail_original": presentation.image_detail_original(),
"context_window": context_window,
"max_context_window": context_window,
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": input_modalities,
"supports_search_tool": presentation.search_tool(),
"use_responses_lite": false,
})
}
fn reasoning_effort_description(effort: &str) -> &'static str {
match effort {
"none" => "No reasoning",
"minimal" => "Minimal reasoning",
"low" => "Fast responses with lighter reasoning",
"medium" => "Balances speed and reasoning depth for everyday tasks",
"high" => "Greater reasoning depth for complex problems",
"xhigh" => "Extra high reasoning depth for complex problems",
"max" => "Maximum reasoning depth for the hardest problems",
_ => "Provider-supported reasoning level",
}
}
@@ -0,0 +1,173 @@
//! OpenAI Responses adapter 的稳定错误 contract。
use serde::Serialize;
use serde_json::Value;
use thiserror::Error;
/// OpenAI 风格的协议错误 JSON。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ProtocolErrorBody {
/// 错误对象。
pub error: ProtocolError,
}
/// 不包含请求正文或 prompt 的错误对象。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ProtocolError {
/// OpenAI 错误类别。
#[serde(rename = "type")]
pub kind: &'static str,
/// 稳定机器码。
pub code: &'static str,
/// 安全的可读信息。
pub message: String,
/// 出错字段;不包含字段值。
#[serde(skip_serializing_if = "Option::is_none")]
pub param: Option<String>,
}
impl ProtocolErrorBody {
/// 序列化为 JSON value。
#[must_use]
pub fn into_value(self) -> Value {
serde_json::json!({ "error": self.error })
}
}
/// Responses 请求解码错误。
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum RequestDecodeError {
/// 请求不是合法 JSON。
#[error("request body must be valid JSON")]
MalformedJson,
/// 顶层不是 object。
#[error("request body must be a JSON object")]
ExpectedObject,
/// 缺少必填字段。
#[error("required field `{field}` is missing")]
MissingField {
/// 字段路径。
field: String,
},
/// 字段类型错误。
#[error("field `{field}` has an invalid type; expected {expected}")]
InvalidType {
/// 字段路径。
field: String,
/// 安全的期望类型。
expected: &'static str,
},
/// 字段为空。
#[error("field `{field}` must not be empty")]
EmptyField {
/// 字段路径。
field: String,
},
/// 字段值不满足稳定约束。
#[error("field `{field}` has an invalid value")]
InvalidValue {
/// 字段路径;不保存原始值。
field: String,
},
/// 未知字段。
#[error("unknown field `{field}`")]
UnknownField {
/// 字段路径;不保存字段值。
field: String,
},
/// 已知但尚未实现的语义。
#[error("field `{field}` is not supported by this gateway")]
UnsupportedField {
/// 字段路径。
field: String,
},
/// Core operation 拒绝规范化后的字段。
#[error("field `{field}` violates the canonical operation contract")]
CanonicalContract {
/// 字段路径。
field: String,
},
}
impl RequestDecodeError {
/// 转换为 OpenAI 风格且不泄露正文的错误 payload。
#[must_use]
pub fn protocol_body(&self) -> ProtocolErrorBody {
let (code, message, param) = match self {
Self::MalformedJson => (
"invalid_json",
"Request body must be valid JSON.".to_owned(),
None,
),
Self::ExpectedObject => (
"invalid_request_body",
"Request body must be a JSON object.".to_owned(),
None,
),
Self::MissingField { field } => (
"missing_required_parameter",
format!("Required parameter `{field}` is missing."),
Some(field.clone()),
),
Self::InvalidType { field, expected } => (
"invalid_type",
format!("Parameter `{field}` must be {expected}."),
Some(field.clone()),
),
Self::EmptyField { field } => (
"invalid_value",
format!("Parameter `{field}` must not be empty."),
Some(field.clone()),
),
Self::InvalidValue { field } | Self::CanonicalContract { field } => (
"invalid_value",
format!("Parameter `{field}` has an invalid value."),
Some(field.clone()),
),
Self::UnknownField { field } => (
"unknown_parameter",
format!("Unknown parameter `{field}`."),
Some(field.clone()),
),
Self::UnsupportedField { field } => (
"unsupported_parameter",
format!("Parameter `{field}` is not supported by this gateway."),
Some(field.clone()),
),
};
ProtocolErrorBody {
error: ProtocolError {
kind: "invalid_request_error",
code,
message,
param,
},
}
}
}
/// OpenAI Responses 原生 wire 转发错误。
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ResponseEncodeError {
/// 协议原生流缺少可返回的终态 response object。
#[error("responses wire stream has no terminal response")]
MissingWireTerminal,
/// 终态 response object 无法序列化为完整 HTTP JSON 响应。
#[error("responses wire terminal serialization failed")]
Serialization,
}
impl ResponseEncodeError {
/// 转换为不包含生成内容的 OpenAI 风格错误 payload。
#[must_use]
pub fn protocol_body(&self) -> ProtocolErrorBody {
ProtocolErrorBody {
error: ProtocolError {
kind: "server_error",
code: "invalid_upstream_response",
message: "The gateway could not forward the upstream response.".to_owned(),
param: None,
},
}
}
}
@@ -0,0 +1,614 @@
//! OpenAI Responses HTTP 与 SSE adapter。
use std::collections::VecDeque;
use std::convert::Infallible;
use std::net::{IpAddr, SocketAddr};
use axum::{
body::{Body, Bytes},
extract::{Extension, State, connect_info::ConnectInfo},
http::{
HeaderMap, HeaderName, HeaderValue, StatusCode,
header::{CACHE_CONTROL, CONTENT_TYPE, USER_AGENT},
},
response::{IntoResponse, Response},
};
use futures::stream;
use gateway_core::engine::CommitRequirement;
use gateway_core::engine::execution::{ClientTransport, ExecutionSession, StartedExecution};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
use gateway_core::lifecycle::ConnectionGuard;
use gateway_protocol::openai::sse::{DONE_SSE_FRAME, response_failed_sse_event_with_id};
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, authentication_error_response},
error::{
engine_error_response, gateway_error_contract, gateway_error_from_engine,
gateway_error_response, protocol_error_response, runtime_unavailable_response,
},
};
use super::{
OpenAiResponsesEncoder, ProtocolErrorBody, ResponseEncodeError,
request::{decode_request_with_headers, decode_review_request_with_headers},
};
/// `POST /v1/responses`。
pub(crate) async fn responses(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_responses(state, connect_info, headers, body, false).await
}
/// `POST /v1/responses/review`。
pub(crate) async fn review_responses(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_responses(state, connect_info, headers, body, true).await
}
async fn handle_responses(
state: ApiState,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
review: bool,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
};
let decoded = match if review {
decode_review_request_with_headers(&body, &headers)
} else {
decode_request_with_headers(&body, &headers)
} {
Ok(decoded) => decoded,
Err(error) => {
return protocol_error_response(StatusCode::BAD_REQUEST, error.protocol_body());
}
};
let (client_ip, user_agent) = request_client_context(
&headers,
connect_info.map(|Extension(ConnectInfo(address))| address),
);
let decoded = decoded.with_client_context(client_ip, user_agent);
let streaming = decoded.metadata().stream();
let connection_guard = if streaming {
match service.try_register_connection() {
Ok(guard) => Some(guard),
Err(_) => return runtime_unavailable_response().into_response(),
}
} else {
None
};
let started = match service
.start_response(
client,
decoded,
if streaming {
ClientTransport::HttpSse
} else {
ClientTransport::HttpJson
},
if review {
"/v1/responses/review"
} else {
"/v1/responses"
},
)
.await
{
Ok(started) => started,
Err(error) => return gateway_error_response(&error),
};
let StartedExecution {
stream, session, ..
} = started;
if stream {
stream_execution_response(session, connection_guard).await
} else {
drop(connection_guard);
collect_execution_response(session).await
}
}
/// 从 socket 与标准转发头提取旧 Usage 页面使用的诊断事实。
pub(super) fn request_client_context(
headers: &HeaderMap,
peer_address: Option<SocketAddr>,
) -> (Option<IpAddr>, Option<String>) {
let client_ip = ["cf-connecting-ip", "x-real-ip"]
.into_iter()
.find_map(|name| header_ip(headers, name))
.or_else(|| forwarded_client_ip(headers))
.or_else(|| peer_address.map(|address| address.ip()));
let user_agent = headers
.get(USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
(client_ip, user_agent)
}
fn header_ip(headers: &HeaderMap, name: &str) -> Option<IpAddr> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.and_then(|value| value.parse().ok())
}
fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
let addresses = headers
.get("x-forwarded-for")?
.to_str()
.ok()?
.split(',')
.filter_map(|value| value.trim().parse::<IpAddr>().ok())
.collect::<Vec<_>>();
addresses
.iter()
.copied()
.find(|address| !is_private_or_loopback(*address))
.or_else(|| addresses.first().copied())
}
const fn is_private_or_loopback(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => address.is_private() || address.is_loopback(),
IpAddr::V6(address) => address.is_unique_local() || address.is_loopback(),
}
}
/// 编码完整 canonical event 集合,并在完整 JSON 成功后提交下游。
pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> Response {
let mut execution = PendingExecution::new(session);
let Some(session) = execution.session_mut() else {
return internal_gateway_response("gateway response session is unavailable");
};
let events = match session.collect_uncommitted().await {
Ok(events) => events,
Err(error) => {
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
};
let encoded = match encode_collected_events(&events) {
Ok(encoded) => encoded,
Err(error) => {
let response =
protocol_error_response(StatusCode::INTERNAL_SERVER_ERROR, error.protocol_body());
let response = execution.record_response_status(response).await;
execution.cancel_and_finalize().await;
return response;
}
};
let response_headers = session.response_headers().to_vec();
let response = json_body_response(encoded, &response_headers);
let Some(session) = execution.session_mut() else {
return internal_gateway_response("gateway response session is unavailable");
};
if let Err(error) = session
.commit_downstream(Some(StatusCode::OK.as_u16()))
.await
{
execution.cancel_and_finalize().await;
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
if !session.is_finalized() {
execution.cancel_and_finalize().await;
return internal_gateway_response("gateway response was not finalized after commit");
}
execution.disarm();
response
}
#[derive(Debug)]
enum BufferedResponseEncodeError {
Canonical(ResponseEncodeError),
Json,
}
impl BufferedResponseEncodeError {
fn protocol_body(&self) -> ProtocolErrorBody {
match self {
Self::Canonical(error) => error.protocol_body(),
Self::Json => ResponseEncodeError::Serialization.protocol_body(),
}
}
}
fn encode_collected_events(
events: &[ProviderEvent],
) -> Result<Vec<u8>, BufferedResponseEncodeError> {
let mut encoder = OpenAiResponsesEncoder::new();
for event in events {
encoder.push_sse(event);
}
let response = encoder
.finish()
.map_err(BufferedResponseEncodeError::Canonical)?;
serde_json::to_vec(&response).map_err(|_| BufferedResponseEncodeError::Json)
}
fn json_body_response(encoded: Vec<u8>, response_headers: &[ProviderResponseHeader]) -> Response {
let mut response = Response::new(Body::from(encoded));
*response.status_mut() = StatusCode::OK;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
apply_response_headers(response, response_headers)
}
fn apply_response_headers(
mut response: Response,
response_headers: &[ProviderResponseHeader],
) -> Response {
let connection_options = super::response_connection_options(response_headers);
for header in response_headers {
if !super::response_header_is_forwardable(header.name(), &connection_options) {
continue;
}
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
continue;
};
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
continue;
};
response.headers_mut().append(name, value);
}
response
}
/// 编码首个 SSE frame 后提交下游,再持续驱动同一执行会话。
pub async fn stream_execution_response(
session: Box<dyn ExecutionSession>,
connection_guard: Option<Box<dyn ConnectionGuard>>,
) -> Response {
let mut execution = PendingExecution::new(session);
let Some(session) = execution.session_mut() else {
return internal_gateway_response("gateway response session is unavailable");
};
let first = match session.next_event().await {
Ok(Some(event)) => event,
Ok(None) => {
let response = gateway_error_response(&GatewayError::new(
GatewayErrorKind::Internal,
"gateway response ended before its first event",
));
let response = execution.record_response_status(response).await;
execution.cancel_and_finalize().await;
return response;
}
Err(error) => {
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
};
let first_requirement = first.commit_requirement();
let first_events = first.into_provider_events();
if first_requirement != CommitRequirement::CommitBeforeDelivery {
let response = internal_gateway_response("gateway first event did not require commit");
let response = execution.record_response_status(response).await;
execution.cancel_and_finalize().await;
return response;
}
let mut encoder = OpenAiResponsesEncoder::new();
let mut frames = Vec::new();
for event in &first_events {
frames.extend(encoder.push_sse(event));
}
if frames.is_empty() {
let response = internal_gateway_response("gateway commit batch encoded no output");
let response = execution.record_response_status(response).await;
execution.cancel_and_finalize().await;
return response;
}
let Some(session) = execution.session_mut() else {
return internal_gateway_response("gateway response session is unavailable");
};
let response_headers = session.response_headers().to_vec();
if let Err(error) = session
.commit_downstream(Some(StatusCode::OK.as_u16()))
.await
{
execution.cancel_and_finalize().await;
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
let Some(session) = execution.into_session() else {
return internal_gateway_response("gateway response session is unavailable");
};
let mut state = ResponsesStreamState::new(session, encoder, frames, connection_guard);
if state.encoder.is_completed() {
state.finish_completed(Vec::new()).await;
}
let body = Body::from_stream(stream::unfold(state, |mut state| async move {
loop {
if let Some(chunk) = state.pending.pop_front() {
return Some((Ok::<Bytes, Infallible>(chunk), state));
}
if state.output_finished {
return None;
}
state.advance().await;
}
}));
event_stream_response(body, &response_headers)
}
fn event_stream_response(body: Body, response_headers: &[ProviderResponseHeader]) -> Response {
let mut response = Response::new(body);
*response.status_mut() = StatusCode::OK;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
apply_response_headers(response, response_headers)
}
fn internal_gateway_response(message: &'static str) -> Response {
gateway_error_response(&GatewayError::new(GatewayErrorKind::Internal, message))
}
pub(super) struct PendingExecution {
session: Option<Box<dyn ExecutionSession>>,
}
impl PendingExecution {
pub(super) fn new(session: Box<dyn ExecutionSession>) -> Self {
Self {
session: Some(session),
}
}
pub(super) fn session_mut(&mut self) -> Option<&mut (dyn ExecutionSession + 'static)> {
self.session.as_deref_mut()
}
async fn cancel_and_finalize(&mut self) {
if let Some(session) = self.session.as_mut() {
session.cancel();
if !session.is_finalized() {
let _ = session.next_event().await;
}
}
}
async fn record_response_status(&mut self, response: Response) -> Response {
if let Some(session) = self.session.as_mut() {
let _ = session
.record_client_status(response.status().as_u16())
.await;
}
response
}
pub(super) fn disarm(&mut self) {
self.session = None;
}
fn into_session(mut self) -> Option<Box<dyn ExecutionSession>> {
self.session.take()
}
}
impl Drop for PendingExecution {
fn drop(&mut self) {
let Some(session) = self.session.take() else {
return;
};
if session.is_finalized() {
return;
}
session.cancel();
detach_finalize(session);
}
}
struct ResponsesStreamState {
session: Option<Box<dyn ExecutionSession>>,
encoder: OpenAiResponsesEncoder,
pending: VecDeque<Bytes>,
output_finished: bool,
execution_terminal: bool,
_connection_guard: Option<Box<dyn ConnectionGuard>>,
}
impl ResponsesStreamState {
fn new(
session: Box<dyn ExecutionSession>,
encoder: OpenAiResponsesEncoder,
initial_frames: Vec<Bytes>,
connection_guard: Option<Box<dyn ConnectionGuard>>,
) -> Self {
Self {
session: Some(session),
encoder,
pending: initial_frames.into_iter().collect(),
output_finished: false,
execution_terminal: false,
_connection_guard: connection_guard,
}
}
async fn advance(&mut self) {
let next = match self.session.as_mut() {
Some(session) => session.next_event().await,
None => {
self.finish_with_gateway_error(GatewayError::new(
GatewayErrorKind::Internal,
"gateway response session is unavailable",
));
return;
}
};
match next {
Ok(Some(event)) => {
let requirement = event.commit_requirement();
let events = event.into_provider_events();
if requirement != CommitRequirement::AlreadyCommitted {
self.cancel_execution().await;
self.finish_with_gateway_error(GatewayError::new(
GatewayErrorKind::Internal,
"gateway requested another downstream commit",
));
} else {
for event in events {
self.push_event(event).await;
if self.output_finished {
break;
}
}
}
}
Ok(None) => {
self.execution_terminal = self
.session
.as_ref()
.is_some_and(|session| session.is_finalized());
if self.execution_terminal {
self.pending
.push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes()));
self.output_finished = true;
} else {
self.finish_with_gateway_error(GatewayError::new(
GatewayErrorKind::Internal,
"gateway response ended without finalizing the execution",
));
}
}
Err(error) => {
self.execution_terminal = self
.session
.as_ref()
.is_some_and(|session| session.is_finalized());
if self.encoder.has_wire_failure() {
self.pending
.push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes()));
self.output_finished = true;
} else {
self.finish_with_gateway_error(gateway_error_from_engine(&error));
}
}
}
}
async fn push_event(&mut self, event: ProviderEvent) {
let frames = self.encoder.push_sse(&event);
if self.encoder.is_completed() {
self.finish_completed(frames).await;
} else {
self.pending.extend(frames);
}
}
async fn finish_completed(&mut self, frames: Vec<Bytes>) {
let next = match self.session.as_mut() {
Some(session) => session.next_event().await,
None => {
self.finish_with_gateway_error(GatewayError::new(
GatewayErrorKind::Internal,
"gateway response session is unavailable",
));
return;
}
};
match next {
Ok(None)
if self
.session
.as_ref()
.is_some_and(|session| session.is_finalized()) =>
{
self.pending.extend(frames);
self.execution_terminal = true;
self.pending
.push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes()));
self.output_finished = true;
}
Ok(None) => {
self.cancel_execution().await;
self.finish_with_gateway_error(GatewayError::new(
GatewayErrorKind::Internal,
"gateway response was not finalized after its terminal event",
));
}
Ok(Some(_)) => {
self.cancel_execution().await;
self.finish_with_gateway_error(GatewayError::new(
GatewayErrorKind::Internal,
"gateway response continued after its terminal event",
));
}
Err(error) => {
self.execution_terminal = self
.session
.as_ref()
.is_some_and(|session| session.is_finalized());
self.finish_with_gateway_error(gateway_error_from_engine(&error));
}
}
}
async fn cancel_execution(&mut self) {
if let Some(session) = self.session.as_mut() {
session.cancel();
if !session.is_finalized() {
let _ = session.next_event().await;
}
self.execution_terminal = session.is_finalized();
}
}
fn finish_with_gateway_error(&mut self, error: GatewayError) {
let (_, default_type, default_code) = gateway_error_contract(error.kind());
self.pending
.push_back(Bytes::from(response_failed_sse_event_with_id(
self.encoder.response_id(),
error.client_error_type().unwrap_or(default_type),
error.client_error_code().unwrap_or(default_code),
error.client_message(),
)));
self.pending
.push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes()));
self.output_finished = true;
}
}
impl Drop for ResponsesStreamState {
fn drop(&mut self) {
if self.execution_terminal {
return;
}
let Some(session) = self.session.take() else {
return;
};
session.cancel();
// Body drop 后由 Core 继续状态机清理,HTTP body 生命周期不等待它。
detach_finalize(session);
}
}
fn detach_finalize(session: Box<dyn ExecutionSession>) {
let finalize = session.detach_finalize();
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
drop(runtime.spawn(finalize));
}
}
@@ -0,0 +1,79 @@
//! OpenAI Responses 的透明 wire adapter 与 Core canonical facts 投影。
mod error;
mod http;
mod request;
mod response;
mod websocket;
pub use error::{ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
pub use http::{collect_execution_response, stream_execution_response};
pub(crate) use http::{responses, review_responses};
pub use request::{
ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, ResponsesRequestMetadata,
decode_request_with_headers,
};
pub use response::OpenAiResponsesEncoder;
pub(crate) use websocket::responses_websocket;
pub use websocket::{ResponseCreateFrameError, decode_response_create_with_context};
use gateway_core::event::ProviderResponseHeader;
pub(super) fn response_connection_options(headers: &[ProviderResponseHeader]) -> Vec<String> {
headers
.iter()
.filter(|header| header.name().trim().eq_ignore_ascii_case("connection"))
.filter_map(|header| std::str::from_utf8(header.value()).ok())
.flat_map(|value| value.split(','))
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_ascii_lowercase)
.collect()
}
pub(super) fn response_header_is_forwardable(name: &str, connection_options: &[String]) -> bool {
let name = name.trim().to_ascii_lowercase();
if connection_options
.iter()
.any(|option| option.eq_ignore_ascii_case(&name))
|| name.starts_with("sec-websocket-")
{
return false;
}
!matches!(
name.as_str(),
"connection"
| "keep-alive"
| "proxy-connection"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
| "content-length"
| "content-type"
| "content-encoding"
| "authorization"
| "x-api-key"
| "www-authenticate"
| "authentication-info"
| "proxy-authenticate"
| "proxy-authorization"
| "proxy-authentication-info"
| "cookie"
| "cookie2"
| "set-cookie"
| "set-cookie2"
| "chatgpt-account-id"
| "chatgpt-organization-id"
| "chatgpt-org-id"
| "chatgpt-project-id"
| "openai-organization"
| "openai-project"
| "x-openai-organization"
| "x-openai-project"
| "x-codex-installation-id"
| "x-codex-turn-state"
| "x-codex-turn-metadata"
)
}
@@ -0,0 +1,497 @@
//! OpenAI Responses JSON 到 Core 路由事实与不透明 wire payload 的单一解码边界。
use std::{fmt, net::IpAddr};
use axum::http::HeaderMap;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState};
use gateway_protocol::openai::{
X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, X_OPENAI_MEMGEN_REQUEST_HEADER,
};
use serde_json::{Map, Value};
use super::error::RequestDecodeError;
const OPENAI_PROTOCOL: &str = "openai";
const REVIEW_SUBAGENT: &str = "review";
const OPENAI_SUBAGENT_KEY: &str = "x-openai-subagent";
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
#[derive(Clone, Default)]
pub struct OpenAiRequestHeaders {
turn_state: Option<String>,
turn_metadata: Option<String>,
beta_features: Option<String>,
version: Option<String>,
include_timing_metrics: Option<String>,
codex_window_id: Option<String>,
parent_thread_id: Option<String>,
conversation_id: Option<String>,
session_id: Option<String>,
thread_id: Option<String>,
client_request_id: Option<String>,
turn_id: Option<String>,
responses_lite: Option<String>,
memgen_request: Option<String>,
subagent: Option<String>,
passthrough_headers: Vec<Value>,
}
impl OpenAiRequestHeaders {
/// 提取 OpenAI/Codex 连接级请求头上下文。
#[must_use]
pub fn from_headers(headers: &HeaderMap) -> Self {
Self {
turn_state: header_string(headers, "x-codex-turn-state"),
turn_metadata: header_string(headers, "x-codex-turn-metadata"),
beta_features: header_string(headers, "x-codex-beta-features"),
version: header_string(headers, "version"),
include_timing_metrics: header_string(headers, "x-responsesapi-include-timing-metrics"),
codex_window_id: header_string(headers, "x-codex-window-id"),
parent_thread_id: header_string(headers, "x-codex-parent-thread-id"),
conversation_id: header_string(headers, "conversation-id")
.or_else(|| header_string(headers, "conversation_id")),
session_id: header_string(headers, "session-id")
.or_else(|| header_string(headers, "session_id")),
thread_id: header_string(headers, "thread-id"),
client_request_id: header_string(headers, "x-client-request-id"),
turn_id: header_string(headers, "x-codex-turn-id"),
responses_lite: header_string(headers, X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER),
memgen_request: header_string(headers, X_OPENAI_MEMGEN_REQUEST_HEADER),
subagent: header_string(headers, OPENAI_SUBAGENT_KEY),
passthrough_headers: passthrough_headers(headers),
}
}
fn apply_subagent(&self, body: &mut Map<String, Value>, forced_subagent: Option<&str>) {
if let Some(subagent) = forced_subagent.or(self.subagent.as_deref()) {
inject_subagent_metadata(body, subagent);
}
}
fn protocol_context(&self, use_websocket: Option<bool>) -> Map<String, Value> {
let mut context = Map::new();
insert_protocol_context(&mut context, "turn_state", self.turn_state.as_ref());
insert_protocol_context(&mut context, "turn_metadata", self.turn_metadata.as_ref());
insert_protocol_context(&mut context, "beta_features", self.beta_features.as_ref());
insert_protocol_context(&mut context, "version", self.version.as_ref());
insert_protocol_context(
&mut context,
"include_timing_metrics",
self.include_timing_metrics.as_ref(),
);
insert_protocol_context(
&mut context,
"codex_window_id",
self.codex_window_id.as_ref(),
);
insert_protocol_context(
&mut context,
"parent_thread_id",
self.parent_thread_id.as_ref(),
);
insert_protocol_context(
&mut context,
"conversation_id",
self.conversation_id.as_ref(),
);
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,
"client_request_id",
self.client_request_id.as_ref(),
);
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()),
);
}
if let Some(use_websocket) = use_websocket {
context.insert("use_websocket".to_owned(), Value::Bool(use_websocket));
}
context
}
}
fn insert_protocol_context(context: &mut Map<String, Value>, field: &str, value: Option<&String>) {
if let Some(value) = value {
context.insert(field.to_owned(), Value::String(value.clone()));
}
}
/// 客户端声明的 continuation 意图。
#[derive(Clone, PartialEq, Eq)]
pub enum ContinuationIntent {
/// 不使用先前响应。
None,
/// 使用当前调用方可见的 OpenAI response ID。
PreviousResponseId(String),
}
impl ContinuationIntent {
/// 返回是否请求了对话延续。
#[must_use]
pub const fn is_continuation(&self) -> bool {
matches!(self, Self::PreviousResponseId(_))
}
/// 返回待 history owner 解析的 response ID。
#[must_use]
pub fn previous_response_id(&self) -> Option<&str> {
match self {
Self::None => None,
Self::PreviousResponseId(value) => Some(value),
}
}
}
impl fmt::Debug for ContinuationIntent {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::None => formatter.write_str("None"),
Self::PreviousResponseId(_) => formatter.write_str("PreviousResponseId(<redacted>)"),
}
}
}
/// Handler、Router 和 history owner 使用的请求元数据。
#[derive(Clone, PartialEq, Eq)]
pub struct ResponsesRequestMetadata {
requested_model: String,
public_model: String,
stream: bool,
store: bool,
continuation: ContinuationIntent,
client_ip: Option<IpAddr>,
user_agent: Option<String>,
}
impl ResponsesRequestMetadata {
/// 返回客户端原始模型名。
#[must_use]
pub fn requested_model(&self) -> &str {
&self.requested_model
}
/// 返回协议层公开模型候选。
#[must_use]
pub fn public_model(&self) -> &str {
&self.public_model
}
/// 返回客户端是否请求 SSE。
#[must_use]
pub const fn stream(&self) -> bool {
self.stream
}
/// 返回客户端 storage intent。
#[must_use]
pub const fn store(&self) -> bool {
self.store
}
/// 返回 continuation intent。
#[must_use]
pub const fn continuation(&self) -> &ContinuationIntent {
&self.continuation
}
/// 返回从 HTTP 连接边界解析出的客户端地址。
#[must_use]
pub const fn client_ip(&self) -> Option<IpAddr> {
self.client_ip
}
/// 返回经过 UTF-8 校验和空白归一化的 User-Agent。
#[must_use]
pub fn user_agent(&self) -> Option<&str> {
self.user_agent.as_deref()
}
}
impl fmt::Debug for ResponsesRequestMetadata {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ResponsesRequestMetadata")
.field("requested_model", &self.requested_model)
.field("public_model", &self.public_model)
.field("stream", &self.stream)
.field("store", &self.store)
.field("continuation", &self.continuation)
.field("client_ip", &self.client_ip)
.field("has_user_agent", &self.user_agent.is_some())
.finish()
}
}
/// 一次成功解码的 Responses 请求。
#[derive(Clone, PartialEq)]
pub struct DecodedResponsesRequest {
operation: Operation,
metadata: ResponsesRequestMetadata,
}
impl DecodedResponsesRequest {
/// 附着当前 WebSocket 连接保存的 Provider 私有上一轮状态。
#[must_use]
pub fn with_provider_session_state(mut self, state: ProviderSessionState) -> Self {
self.operation = self.operation.with_provider_session_state(state);
self
}
/// 返回协议无关 operation。
#[must_use]
pub const fn operation(&self) -> &Operation {
&self.operation
}
/// 返回 handler 元数据。
#[must_use]
pub const fn metadata(&self) -> &ResponsesRequestMetadata {
&self.metadata
}
/// 拆分为 operation 与元数据。
#[must_use]
pub fn into_parts(self) -> (Operation, ResponsesRequestMetadata) {
(self.operation, self.metadata)
}
/// 附着只由 HTTP/WebSocket 连接边界提供的诊断事实。
#[must_use]
pub fn with_client_context(
mut self,
client_ip: Option<IpAddr>,
user_agent: Option<String>,
) -> Self {
self.metadata.client_ip = client_ip;
self.metadata.user_agent = user_agent;
self
}
}
impl fmt::Debug for DecodedResponsesRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("DecodedResponsesRequest")
.field("operation", &self.operation)
.field("metadata", &self.metadata)
.finish()
}
}
/// 使用下游 OpenAI/Codex 请求头解码 `POST /v1/responses`。
///
/// # Errors
///
/// JSON 非法、顶层不是 object,或网关必须解释的路由字段无效时返回安全错误。
pub fn decode_request_with_headers(
body: &[u8],
headers: &HeaderMap,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
decode_request_inner(body, false, &OpenAiRequestHeaders::from_headers(headers))
}
/// 解码 `POST /v1/responses/review` 请求并冻结 review subagent 语义。
pub(super) fn decode_review_request_with_headers(
body: &[u8],
headers: &HeaderMap,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
decode_request_inner(body, true, &OpenAiRequestHeaders::from_headers(headers))
}
pub(super) fn decode_request_inner(
body: &[u8],
review: bool,
request_headers: &OpenAiRequestHeaders,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
let value =
serde_json::from_slice::<Value>(body).map_err(|_| RequestDecodeError::MalformedJson)?;
let Value::Object(object) = value else {
return Err(RequestDecodeError::ExpectedObject);
};
decode_request_object(object, review, request_headers)
}
/// 解码已解析的顶层 object;WebSocket 帧解码复用此内核,避免序列化往返。
pub(super) fn decode_request_object(
mut object: Map<String, Value>,
review: bool,
request_headers: &OpenAiRequestHeaders,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
// 仅消费已识别的本地 transport 开关;未知同名值保留给未来上游协议。
let use_websocket = object.get("use_websocket").and_then(Value::as_bool);
if use_websocket.is_some() {
object.remove("use_websocket");
}
let model = required_non_empty_string(&object, "model", "model")?;
if model.trim().is_empty() {
return Err(RequestDecodeError::EmptyField {
field: "model".to_owned(),
});
}
let model = model.to_owned();
let stream = object
.get("stream")
.and_then(Value::as_bool)
.unwrap_or(true);
let store = object
.get("store")
.and_then(Value::as_bool)
.unwrap_or(false);
let continuation = object
.get("previous_response_id")
.and_then(Value::as_str)
.map(|response_id| ContinuationIntent::PreviousResponseId(response_id.to_owned()))
.unwrap_or(ContinuationIntent::None);
request_headers.apply_subagent(&mut object, review.then_some(REVIEW_SUBAGENT));
let protocol_context = request_headers.protocol_context(use_websocket);
let payload = ProtocolPayload::json_object(OPENAI_PROTOCOL, object)
.map_err(|_| RequestDecodeError::CanonicalContract {
field: "request".to_owned(),
})?
.with_context(protocol_context);
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload));
Ok(DecodedResponsesRequest {
operation,
metadata: ResponsesRequestMetadata {
requested_model: model.clone(),
public_model: model,
stream,
store,
continuation,
client_ip: None,
user_agent: None,
},
})
}
fn inject_subagent_metadata(body: &mut Map<String, Value>, subagent: &str) {
let metadata = body
.entry("client_metadata".to_owned())
.or_insert_with(|| Value::Object(Map::new()));
let Some(metadata) = metadata.as_object_mut() else {
return;
};
metadata.insert(
OPENAI_SUBAGENT_KEY.to_owned(),
Value::String(subagent.to_owned()),
);
}
fn header_string(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
}
fn passthrough_headers(headers: &HeaderMap) -> Vec<Value> {
let connection_headers = headers
.get_all("connection")
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_ascii_lowercase)
.collect::<Vec<_>>();
headers
.iter()
.filter(|(name, _)| passthrough_header_name(name.as_str(), &connection_headers))
.map(|(name, value)| {
Value::Array(vec![
Value::String(name.as_str().to_owned()),
Value::String(STANDARD.encode(value.as_bytes())),
])
})
.collect()
}
fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
if connection_headers
.iter()
.any(|connection_header| connection_header.eq_ignore_ascii_case(name))
|| name.starts_with("sec-websocket-")
|| name.starts_with("x-grok-")
|| name.starts_with("x-xai-")
{
return false;
}
!matches!(
name,
// 逐跳头、上游 authority 和重序列化后的实体长度由 transport 重建。
"connection"
| "keep-alive"
| "proxy-connection"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
| "host"
| "content-length"
// 下游连接诊断事实只用于本地观测,不能伪装成 OpenAI 请求事实。
| "forwarded"
| "x-forwarded-for"
| "x-forwarded-host"
| "x-forwarded-proto"
| "x-forwarded-port"
| "x-real-ip"
| "true-client-ip"
| "cf-connecting-ip"
| "x-request-id"
// 下游鉴权和账号 cookie 绝不能成为上游账号身份。
| "authorization"
| "x-api-key"
| "cookie"
| "cookie2"
| "chatgpt-account-id"
| "chatgpt-organization-id"
| "chatgpt-org-id"
| "chatgpt-project-id"
| "openai-organization"
| "openai-project"
| "x-openai-organization"
| "x-openai-project"
// installation ID 始终由当前 lease 重建。
| "x-codex-installation-id"
)
}
fn required_non_empty_string<'a>(
object: &'a Map<String, Value>,
key: &str,
field: &str,
) -> Result<&'a str, RequestDecodeError> {
let value = object
.get(key)
.ok_or_else(|| RequestDecodeError::MissingField {
field: field.to_owned(),
})?
.as_str()
.ok_or_else(|| RequestDecodeError::InvalidType {
field: field.to_owned(),
expected: "a string",
})?;
if value.is_empty() {
return Err(RequestDecodeError::EmptyField {
field: field.to_owned(),
});
}
Ok(value)
}
@@ -0,0 +1,128 @@
//! Provider OpenAI Responses wire 到客户端 transport 的透明转发边界。
use bytes::Bytes;
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent};
use gateway_protocol::openai::sse::encode_sse_event_with_metadata;
use serde_json::Value;
use super::error::ResponseEncodeError;
const OPENAI_PROTOCOL: &str = "openai";
/// OpenAI Responses wire 的转发器。
///
/// OpenAI Provider 与 xAI adapter 都必须交付 OpenAI wire。wire 是客户端交付
/// 与终态判断的事实来源;canonical facts 只在 wire 暂未携带 ID 时提供旁路观测,
/// 不得反过来拒绝或改写上游事件。
#[derive(Debug, Default)]
pub struct OpenAiResponsesEncoder {
response_id: Option<String>,
wire_terminal: Option<Value>,
wire_failure: bool,
}
impl OpenAiResponsesEncoder {
/// 创建响应 wire 转发器。
#[must_use]
pub const fn new() -> Self {
Self {
response_id: None,
wire_terminal: None,
wire_failure: false,
}
}
/// 消费一个 Provider event,并返回 SSE frames。
pub fn push_sse(&mut self, event: &ProviderEvent) -> Vec<Bytes> {
self.observe_canonical_identity(event);
let Some(wire) = openai_wire(event) else {
return Vec::new();
};
self.observe_wire(wire);
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
return vec![raw_sse_frame.clone()];
}
vec![Bytes::from(encode_sse_event_with_metadata(
wire.event_type().unwrap_or_default(),
&wire.data().to_string(),
wire.sse_id(),
wire.sse_retry(),
))]
}
/// 消费一个 Provider event,并返回 WebSocket JSON messages。
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 {
return Vec::new();
};
self.observe_wire(wire);
vec![wire.data().to_string()]
}
/// 返回是否已经看到客户端可见的 wire 终态。
#[must_use]
pub fn is_completed(&self) -> bool {
self.wire_terminal.is_some()
}
/// 返回是否已把 Provider 原生失败 event 交付给客户端。
#[must_use]
pub const fn has_wire_failure(&self) -> bool {
self.wire_failure
}
/// 返回 Core 已观察到的客户端可见 Provider 原生响应 ID。
#[must_use]
pub fn response_id(&self) -> Option<&str> {
self.response_id.as_deref()
}
/// 校验完整响应并返回原生终态 response object。
///
/// # Errors
///
/// 缺少可转换为非流式响应的 wire 终态时返回错误。
pub fn finish(self) -> Result<Value, ResponseEncodeError> {
self.wire_terminal
.ok_or(ResponseEncodeError::MissingWireTerminal)
}
fn observe_canonical_identity(&mut self, event: &ProviderEvent) {
for fact in event.canonical_facts() {
let metadata = match fact {
GatewayEvent::Started(metadata) | GatewayEvent::Completed(metadata) => metadata,
_ => continue,
};
self.response_id = Some(metadata.response_id().to_owned());
}
}
fn observe_wire(&mut self, wire: &ProtocolWireEvent) {
if let Some(response_id) = wire
.data()
.pointer("/response/id")
.or_else(|| wire.data().get("response_id"))
.and_then(Value::as_str)
{
self.response_id = Some(response_id.to_owned());
}
let effective_type = wire
.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str));
if matches!(
effective_type,
Some("response.completed" | "response.incomplete")
) {
self.wire_terminal = wire.data().get("response").cloned();
} else if matches!(effective_type, Some("response.failed" | "error")) {
self.wire_failure = true;
}
}
}
fn openai_wire(event: &ProviderEvent) -> Option<&ProtocolWireEvent> {
event
.wire_event()
.filter(|wire| wire.protocol() == OPENAI_PROTOCOL)
}
@@ -0,0 +1,699 @@
//! OpenAI Responses WebSocket adapter。
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use axum::{
extract::{
Extension, State,
connect_info::ConnectInfo,
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade, close_code},
},
http::{HeaderMap, HeaderName, StatusCode},
response::{IntoResponse, Response},
};
use futures::StreamExt;
use gateway_core::engine::execution::{AuthenticatedClient, ClientTransport, StartedExecution};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::event::ProviderResponseHeader;
use gateway_core::lifecycle::{ConnectionGuard, ConnectionLifecycle};
use gateway_core::operation::ProviderSessionState;
use serde_json::{Map, Value, json};
use thiserror::Error;
use crate::{
ApiState,
openai::{
auth::{authenticate_client, authentication_error_response},
error::{gateway_error_contract, gateway_error_from_engine, runtime_unavailable_response},
service::OpenAiService,
},
};
use super::{
DecodedResponsesRequest, OpenAiResponsesEncoder, ProtocolErrorBody, RequestDecodeError,
http::{PendingExecution, request_client_context},
request::OpenAiRequestHeaders,
};
const ACTIVE_RESPONSE_VIOLATION: &str = "Only one response.create may be active per connection";
const TEXT_FRAMES_ONLY: &str = "Responses WebSocket accepts text frames only";
/// 将已认证的 `GET /v1/responses` 升级为 Responses WebSocket。
pub(crate) async fn responses_websocket(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
websocket: WebSocketUpgrade,
) -> Response {
let service = state.openai().clone();
let client = match authenticate_client(&service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
};
let (client_ip, user_agent) = request_client_context(
&headers,
connect_info.map(|Extension(ConnectInfo(address))| address),
);
ResponsesWebSocketAdapter::new(service).upgrade_with_client_context(
websocket,
client,
client_ip,
user_agent,
OpenAiRequestHeaders::from_headers(&headers),
)
}
/// 已鉴权 Responses WebSocket 升级边界。
#[derive(Clone)]
pub(crate) struct ResponsesWebSocketAdapter {
service: OpenAiService,
}
impl ResponsesWebSocketAdapter {
/// 绑定应用提供的唯一 OpenAI 客户端服务端口。
#[must_use]
pub const fn new(service: OpenAiService) -> Self {
Self { service }
}
fn upgrade_with_client_context(
&self,
websocket: WebSocketUpgrade,
client: AuthenticatedClient,
client_ip: Option<IpAddr>,
user_agent: Option<String>,
request_headers: OpenAiRequestHeaders,
) -> Response {
let connection_guard = match self.service.try_register_connection() {
Ok(guard) => guard,
Err(_) => return runtime_unavailable_response().into_response(),
};
let session = ResponsesWebSocketSession {
service: self.service.clone(),
client,
connection_id: self.service.next_request_id().replacen("req_", "ws_", 1),
client_ip,
user_agent,
request_headers,
lifecycle: self.service.lifecycle(),
_connection_guard: connection_guard,
};
websocket
// 覆盖 axum/tungstenite 的私有 64 MiB message 与 16 MiB frame 默认值。
// Responses JSON 的协议可接受性由上游决定,代理不另设 wire 长度上限。
.max_message_size(usize::MAX)
.max_frame_size(usize::MAX)
.on_upgrade(move |socket| async move {
serve_responses_websocket(socket, session).await;
})
}
}
struct ResponsesWebSocketSession {
service: OpenAiService,
client: AuthenticatedClient,
connection_id: String,
client_ip: Option<IpAddr>,
user_agent: Option<String>,
request_headers: OpenAiRequestHeaders,
lifecycle: Arc<dyn ConnectionLifecycle>,
_connection_guard: Box<dyn ConnectionGuard>,
}
async fn serve_responses_websocket(mut socket: WebSocket, session: ResponsesWebSocketSession) {
tracing::info!(
websocket_connection_id = %session.connection_id,
"Responses WebSocket connected"
);
let mut request_count = 0_u64;
let mut replay = ConnectionReplaySnapshot::default();
let cancellation = session.lifecycle.cancellation();
loop {
let message = tokio::select! {
() = cancellation.cancelled() => break,
message = socket.next() => message,
};
let Some(message) = message else {
break;
};
let payload = match message {
Ok(Message::Text(payload)) => payload,
Ok(Message::Ping(_) | Message::Pong(_)) => continue,
Ok(Message::Close(_)) => break,
Ok(Message::Binary(_)) => {
close_for_protocol_violation(&mut socket, TEXT_FRAMES_ONLY).await;
break;
}
Err(error) => {
tracing::info!(
websocket_connection_id = %session.connection_id,
error = %error,
"Responses WebSocket receive failed"
);
break;
}
};
request_count = request_count.saturating_add(1);
let correlation_id = session.service.next_request_id();
let decoded =
match decode_response_create_with_context(payload.as_str(), &session.request_headers) {
Ok(decoded) => {
decoded.with_client_context(session.client_ip, session.user_agent.clone())
}
Err(error) => {
tracing::info!(
websocket_connection_id = %session.connection_id,
request_id = %correlation_id,
error = %error,
"Responses WebSocket request rejected"
);
if send_protocol_error(
&mut socket,
StatusCode::BAD_REQUEST,
error.protocol_body(),
&correlation_id,
)
.await
== ForwardOutcome::Disconnect
{
break;
}
continue;
}
};
let decoded = replay.prepare(decoded);
let started = match session
.service
.start_response(
session.client.clone(),
decoded,
ClientTransport::WebSocket,
"/v1/responses",
)
.await
{
Ok(started) => started,
Err(error) => {
if send_gateway_error(&mut socket, &error, &correlation_id).await
== ForwardOutcome::Disconnect
{
break;
}
continue;
}
};
if forward_execution(&mut socket, started, &mut replay).await == ForwardOutcome::Disconnect
{
break;
}
}
tracing::info!(
websocket_connection_id = %session.connection_id,
request_count,
"Responses WebSocket disconnected"
);
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ForwardOutcome {
Continue,
Disconnect,
}
#[derive(Default)]
struct ConnectionReplaySnapshot {
last_response_id: Option<String>,
provider_state: Option<ProviderSessionState>,
}
impl ConnectionReplaySnapshot {
fn prepare(&self, request: DecodedResponsesRequest) -> DecodedResponsesRequest {
match (
request.metadata().continuation().previous_response_id(),
self.last_response_id.as_deref(),
self.provider_state.as_ref(),
) {
(Some(previous), Some(last), Some(state)) if previous == last => {
request.with_provider_session_state(state.clone())
}
_ => request,
}
}
fn commit(&mut self, response_id: String, provider_state: Option<ProviderSessionState>) {
self.last_response_id = Some(response_id);
self.provider_state = provider_state;
}
}
async fn forward_execution(
socket: &mut WebSocket,
started: StartedExecution,
replay: &mut ConnectionReplaySnapshot,
) -> ForwardOutcome {
let request_id = started.request_id.to_string();
let streaming = started.stream;
let mut execution = PendingExecution::new(started.session);
if !streaming {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"WebSocket execution was not initialized as a stream",
);
return send_gateway_error(socket, &error, &request_id).await;
}
let first = loop {
match next_active_input(socket, &mut execution).await {
ActiveInput::Event(Ok(Some(event))) => break event,
ActiveInput::Event(Ok(None)) => {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway response ended before its first event",
);
return send_gateway_error(socket, &error, &request_id).await;
}
ActiveInput::Event(Err(error)) => {
let error = gateway_error_from_engine(&error);
return send_gateway_error(socket, &error, &request_id).await;
}
ActiveInput::Control => continue,
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
ActiveInput::ProtocolViolation => {
close_for_protocol_violation(socket, ACTIVE_RESPONSE_VIOLATION).await;
return ForwardOutcome::Disconnect;
}
}
};
let requirement = first.commit_requirement();
let mut first = first.into_provider_events();
if requirement != CommitRequirement::CommitBeforeDelivery {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway first event did not require commit",
);
return send_gateway_error(socket, &error, &request_id).await;
}
let mut encoder = OpenAiResponsesEncoder::new();
let mut provider_state = None;
let mut first_messages = Vec::new();
for event in &mut first {
if let Some(update) = event.take_session_update() {
provider_state = Some(update);
}
first_messages.extend(encoder.push_websocket(event));
}
if first_messages.is_empty() {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway commit batch encoded no output",
);
return send_gateway_error(socket, &error, &request_id).await;
}
let Some(response_session) = execution.session_mut() else {
return ForwardOutcome::Disconnect;
};
let response_headers = response_session.response_headers().to_vec();
if let Err(error) = response_session.commit_downstream(None).await {
let error = gateway_error_from_engine(&error);
return send_gateway_error(socket, &error, &request_id).await;
}
if encoder.is_completed() {
if let Err(outcome) = confirm_completed_execution(socket, &mut execution, &request_id).await
{
return outcome;
}
commit_connection_replay(replay, &encoder, provider_state.take());
if !send_text(
socket,
response_metadata_event(&request_id, &response_headers),
)
.await
|| !send_messages(socket, first_messages).await
{
return ForwardOutcome::Disconnect;
}
execution.disarm();
return ForwardOutcome::Continue;
}
if !send_text(
socket,
response_metadata_event(&request_id, &response_headers),
)
.await
|| !send_messages(socket, first_messages).await
{
return ForwardOutcome::Disconnect;
}
loop {
match next_active_input(socket, &mut execution).await {
ActiveInput::Event(Ok(Some(delivery))) => {
let requirement = delivery.commit_requirement();
let mut events = delivery.into_provider_events();
if requirement != CommitRequirement::AlreadyCommitted {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway requested another downstream commit",
);
return send_gateway_error(socket, &error, &request_id).await;
}
let mut messages = Vec::new();
for event in &mut events {
if let Some(update) = event.take_session_update() {
provider_state = Some(update);
}
messages.extend(encoder.push_websocket(event));
}
if encoder.is_completed() {
if let Err(outcome) =
confirm_completed_execution(socket, &mut execution, &request_id).await
{
return outcome;
}
commit_connection_replay(replay, &encoder, provider_state.take());
if !send_messages(socket, messages).await {
return ForwardOutcome::Disconnect;
}
execution.disarm();
return ForwardOutcome::Continue;
}
if !send_messages(socket, messages).await {
return ForwardOutcome::Disconnect;
}
}
ActiveInput::Event(Ok(None)) => {
if execution
.session_mut()
.is_some_and(|session| session.is_finalized())
{
commit_connection_replay(replay, &encoder, provider_state.take());
execution.disarm();
return ForwardOutcome::Continue;
}
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway response ended without finalizing the execution",
);
return send_gateway_error(socket, &error, &request_id).await;
}
ActiveInput::Event(Err(error)) => {
if encoder.has_wire_failure() {
execution.disarm();
return ForwardOutcome::Continue;
}
let error = gateway_error_from_engine(&error);
return send_gateway_error(socket, &error, &request_id).await;
}
ActiveInput::Control => {}
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
ActiveInput::ProtocolViolation => {
close_for_protocol_violation(socket, ACTIVE_RESPONSE_VIOLATION).await;
return ForwardOutcome::Disconnect;
}
}
}
}
fn commit_connection_replay(
replay: &mut ConnectionReplaySnapshot,
encoder: &OpenAiResponsesEncoder,
provider_state: Option<ProviderSessionState>,
) {
if let Some(response_id) = encoder.response_id() {
replay.commit(response_id.to_owned(), provider_state);
}
}
async fn confirm_completed_execution(
socket: &mut WebSocket,
execution: &mut PendingExecution,
request_id: &str,
) -> Result<(), ForwardOutcome> {
loop {
match next_active_input(socket, execution).await {
ActiveInput::Event(Ok(None))
if execution
.session_mut()
.is_some_and(|session| session.is_finalized()) =>
{
return Ok(());
}
ActiveInput::Event(Ok(None)) => {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway response was not finalized after its terminal event",
);
return Err(send_gateway_error(socket, &error, request_id).await);
}
ActiveInput::Event(Ok(Some(_))) => {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway response continued after its terminal event",
);
return Err(send_gateway_error(socket, &error, request_id).await);
}
ActiveInput::Event(Err(error)) => {
let error = gateway_error_from_engine(&error);
return Err(send_gateway_error(socket, &error, request_id).await);
}
ActiveInput::Control => {}
ActiveInput::Disconnect => return Err(ForwardOutcome::Disconnect),
ActiveInput::ProtocolViolation => {
close_for_protocol_violation(socket, ACTIVE_RESPONSE_VIOLATION).await;
return Err(ForwardOutcome::Disconnect);
}
}
}
}
enum ActiveInput {
Event(Result<Option<CoordinatedEvent>, EngineError>),
Control,
Disconnect,
ProtocolViolation,
}
async fn next_active_input(
socket: &mut WebSocket,
execution: &mut PendingExecution,
) -> ActiveInput {
let Some(session) = execution.session_mut() else {
return ActiveInput::Disconnect;
};
tokio::select! {
event = session.next_event() => ActiveInput::Event(event),
message = socket.next() => match message {
Some(Ok(Message::Ping(_) | Message::Pong(_))) => ActiveInput::Control,
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => ActiveInput::Disconnect,
Some(Ok(Message::Text(_) | Message::Binary(_))) => ActiveInput::ProtocolViolation,
},
}
}
/// 使用连接级请求头和 Provider 上下文解码官方 `response.create` 文本帧。
///
/// 缺省 `stream` 等价于 WebSocket 固有的流式语义;显式 `false` 会被拒绝。
///
/// # Errors
///
/// 帧不是合法 JSON object、消息类型错误、显式关闭 stream,或 Responses 请求
/// 无法映射到 canonical operation 时返回不包含正文内容的稳定错误。
pub fn decode_response_create_with_context(
payload: &str,
request_headers: &OpenAiRequestHeaders,
) -> Result<DecodedResponsesRequest, ResponseCreateFrameError> {
decode_response_create_inner(payload, request_headers)
}
fn decode_response_create_inner(
payload: &str,
request_headers: &OpenAiRequestHeaders,
) -> Result<DecodedResponsesRequest, ResponseCreateFrameError> {
let Value::Object(mut body) = serde_json::from_str::<Value>(payload)
.map_err(|_| ResponseCreateFrameError::InvalidJson)?
else {
return Err(ResponseCreateFrameError::ExpectedObject);
};
match body.remove("type") {
Some(Value::String(message_type)) if message_type == "response.create" => {}
_ => return Err(ResponseCreateFrameError::UnsupportedType),
}
if matches!(body.get("stream"), Some(value) if value.as_bool() != Some(true)) {
return Err(ResponseCreateFrameError::StreamingRequired);
}
super::request::decode_request_object(body, false, request_headers)
.map_err(ResponseCreateFrameError::Request)
}
/// `response.create` 帧的稳定安全错误。
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ResponseCreateFrameError {
/// 文本不是合法 JSON。
#[error("response.create frame must be valid JSON")]
InvalidJson,
/// 顶层不是 object。
#[error("response.create frame must be a JSON object")]
ExpectedObject,
/// `type` 缺失或不是 `response.create`。
#[error("unsupported Responses WebSocket message type")]
UnsupportedType,
/// WebSocket 请求显式声明 `stream=false`。
#[error("Responses WebSocket requests require stream=true")]
StreamingRequired,
/// 内层 Responses 请求无法映射到 canonical operation。
#[error(transparent)]
Request(RequestDecodeError),
}
impl ResponseCreateFrameError {
fn protocol_body(&self) -> ProtocolErrorBody {
match self {
Self::Request(error) => error.protocol_body(),
Self::InvalidJson => RequestDecodeError::MalformedJson.protocol_body(),
Self::ExpectedObject => RequestDecodeError::ExpectedObject.protocol_body(),
Self::UnsupportedType => RequestDecodeError::InvalidValue {
field: "type".to_owned(),
}
.protocol_body(),
Self::StreamingRequired => RequestDecodeError::InvalidValue {
field: "stream".to_owned(),
}
.protocol_body(),
}
}
}
fn response_metadata_event(
request_id: &str,
response_headers: &[ProviderResponseHeader],
) -> String {
let connection_options = super::response_connection_options(response_headers);
let mut headers = Map::new();
for header in response_headers {
if !super::response_header_is_forwardable(header.name(), &connection_options) {
continue;
}
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
continue;
};
let Ok(value) = std::str::from_utf8(header.value()) else {
continue;
};
// 官方 response.metadata 使用 string map,无法表达同名多值;后值保持既有覆盖语义。
headers.insert(name.as_str().to_owned(), Value::String(value.to_owned()));
}
headers
.entry("x-request-id".to_owned())
.or_insert_with(|| Value::String(request_id.to_owned()));
json!({
"type": "response.metadata",
"headers": headers,
})
.to_string()
}
async fn send_gateway_error(
socket: &mut WebSocket,
error: &GatewayError,
request_id: &str,
) -> ForwardOutcome {
let (status, default_type, default_code) = gateway_error_contract(error.kind());
if send_error_event(
socket,
status,
error.client_error_type().unwrap_or(default_type),
error.client_error_code().unwrap_or(default_code),
error.client_message(),
None,
request_id,
)
.await
{
ForwardOutcome::Continue
} else {
ForwardOutcome::Disconnect
}
}
async fn send_protocol_error(
socket: &mut WebSocket,
status: StatusCode,
body: ProtocolErrorBody,
request_id: &str,
) -> ForwardOutcome {
let error = body.error;
if send_error_event(
socket,
status,
error.kind,
error.code,
&error.message,
error.param.as_deref(),
request_id,
)
.await
{
ForwardOutcome::Continue
} else {
ForwardOutcome::Disconnect
}
}
async fn send_error_event(
socket: &mut WebSocket,
status: StatusCode,
error_type: &str,
code: &str,
message: &str,
param: Option<&str>,
request_id: &str,
) -> bool {
let mut error = Map::new();
error.insert("type".to_owned(), Value::String(error_type.to_owned()));
error.insert("code".to_owned(), Value::String(code.to_owned()));
error.insert("message".to_owned(), Value::String(message.to_owned()));
if let Some(param) = param {
error.insert("param".to_owned(), Value::String(param.to_owned()));
}
send_text(
socket,
json!({
"type": "error",
"status": status.as_u16(),
"error": error,
"request_id": request_id,
})
.to_string(),
)
.await
}
async fn send_messages(socket: &mut WebSocket, messages: Vec<String>) -> bool {
for message in messages {
if !send_text(socket, message).await {
return false;
}
}
true
}
async fn send_text(socket: &mut WebSocket, payload: String) -> bool {
if let Err(error) = socket.send(Message::Text(payload.into())).await {
tracing::info!(error = %error, "Responses WebSocket send failed");
return false;
}
true
}
async fn close_for_protocol_violation(socket: &mut WebSocket, reason: &'static str) {
let _ = socket
.send(Message::Close(Some(CloseFrame {
code: close_code::POLICY,
reason: reason.into(),
})))
.await;
}
@@ -1,4 +1,4 @@
//! OpenAI 兼容 API 路由。
//! OpenAI 客户端协议路由。
use axum::{
Router,
@@ -6,23 +6,24 @@ use axum::{
routing::{get, post},
};
use crate::api::AppState;
use super::{
models::{model_catalog, model_detail, model_info, models},
responses::{responses, responses_websocket, review_responses},
};
pub const MAX_CLIENT_REQUEST_BODY_BYTES: usize = 16 * 1024 * 1024;
use crate::ApiState;
/// 构造 OpenAI 兼容 API 路由。
pub fn router() -> Router<AppState> {
/// 构造 OpenAI 客户端协议路由。
pub(crate) fn router() -> Router<ApiState> {
Router::new()
.route("/v1/responses", get(responses_websocket).post(responses))
.route("/v1/responses/review", post(review_responses))
.route("/v1/models", get(models))
.route("/v1/models/catalog", get(model_catalog))
.route("/v1/models/{model_id}", get(model_detail))
.route("/v1/models/{model_id}/info", get(model_info))
.layer(DefaultBodyLimit::max(MAX_CLIENT_REQUEST_BODY_BYTES))
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
.route("/v1/models/{model_id}", get(model_detail))
// Responses 正文属于 OpenAI/Codex 协议;代理不能用私有大小上限提前拒绝
// 上游本可接受的未来 payload。
.layer(DefaultBodyLimit::disable())
}
@@ -0,0 +1,130 @@
//! OpenAI wire adapter 到 Core 执行用例的唯一映射。
use std::sync::Arc;
use gateway_core::engine::continuation::PreviousResponseId;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionRequestMetadata,
ExecutionService, StartExecution, StartedExecution,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
use gateway_core::routing::{PublicModelId, PublicModelProfile};
use uuid::Uuid;
use super::auth::ClientApiKeyAuthError;
use super::responses::{ContinuationIntent, DecodedResponsesRequest};
/// OpenAI HTTP/WS adapter 共享的 Core 与连接生命周期能力。
#[derive(Clone)]
pub(crate) struct OpenAiService {
execution: Arc<dyn ExecutionService>,
lifecycle: Arc<dyn ConnectionLifecycle>,
}
impl OpenAiService {
#[must_use]
pub(crate) const fn new(
execution: Arc<dyn ExecutionService>,
lifecycle: Arc<dyn ConnectionLifecycle>,
) -> Self {
Self {
execution,
lifecycle,
}
}
pub(crate) fn authenticate(
&self,
plaintext: &str,
) -> Result<AuthenticatedClient, ClientApiKeyAuthError> {
self.execution
.authenticate(plaintext)
.map_err(map_authentication_error)
}
pub(crate) fn public_models(&self, client: &AuthenticatedClient) -> Vec<String> {
self.execution
.public_models(client)
.into_iter()
.map(|model| model.as_str().to_owned())
.collect()
}
pub(crate) fn public_model_profiles(
&self,
client: &AuthenticatedClient,
) -> Vec<PublicModelProfile> {
self.execution.public_model_profiles(client)
}
pub(crate) fn contains_public_model(
&self,
client: &AuthenticatedClient,
model: &PublicModelId,
) -> bool {
self.execution.contains_public_model(client, model)
}
pub(crate) async fn start_response(
&self,
client: AuthenticatedClient,
request: DecodedResponsesRequest,
transport: ClientTransport,
endpoint: &'static str,
) -> Result<StartedExecution, GatewayError> {
let (operation, metadata) = request.into_parts();
let public_model = PublicModelId::from_client_wire(metadata.public_model().to_owned())
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::ModelNotFound,
"requested model was not found",
)
})?;
let previous_response_id = match metadata.continuation() {
ContinuationIntent::None => None,
ContinuationIntent::PreviousResponseId(value) => {
Some(PreviousResponseId::new(value.clone()))
}
};
self.execution
.start(StartExecution {
client,
public_model,
operation,
metadata: ExecutionRequestMetadata {
protocol: "openai".to_owned(),
endpoint: endpoint.to_owned(),
transport,
stream: metadata.stream(),
client_ip: metadata.client_ip(),
user_agent: metadata.user_agent().map(str::to_owned),
previous_response_id,
},
})
.await
}
pub(crate) fn try_register_connection(
&self,
) -> Result<Box<dyn ConnectionGuard>, ConnectionDraining> {
self.lifecycle.try_register()
}
#[must_use]
pub(crate) fn lifecycle(&self) -> Arc<dyn ConnectionLifecycle> {
Arc::clone(&self.lifecycle)
}
#[must_use]
pub(crate) fn next_request_id(&self) -> String {
format!("req_{}", Uuid::now_v7().simple())
}
}
const fn map_authentication_error(error: ClientAuthenticationError) -> ClientApiKeyAuthError {
match error {
ClientAuthenticationError::InvalidKey => ClientApiKeyAuthError::InvalidKey,
ClientAuthenticationError::SnapshotUnavailable => ClientApiKeyAuthError::RuntimeUnavailable,
}
}
@@ -0,0 +1,393 @@
mod query {
use gateway_admin::model::accounts::{AccountSortField, AccountStatus, SortDirection};
use gateway_api::admin::accounts::ListQuery;
use serde_json::json;
#[test]
fn account_query_should_parse_provider_status_and_sort_once() {
let query: ListQuery = serde_json::from_value(json!({
"page": 3,
"pageSize": 20,
"provider": "xai",
"search": " operator ",
"status": "active",
"sortBy": "lastUsedAt",
"sortDirection": "desc"
}))
.expect("deserialize account query");
let query = query.validate().expect("validate account query");
assert_eq!(query.page, 3);
assert_eq!(query.page_size.get(), 20);
assert!(matches!(
query.provider_kind,
Some(ref provider) if provider.as_str() == "xai"
));
assert_eq!(query.search.as_deref(), Some("operator"));
assert_eq!(query.status, Some(AccountStatus::Active));
assert_eq!(
query.sort.expect("sort").field,
AccountSortField::LastUsedAt
);
assert_eq!(
query.sort.expect("copy sort").direction,
SortDirection::Desc
);
}
#[test]
fn account_query_should_reject_unbounded_page_size() {
let query: ListQuery =
serde_json::from_value(json!({"pageSize": 201})).expect("deserialize account query");
assert_eq!(
query.validate().expect_err("reject page size").field(),
"pageSize"
);
}
#[test]
fn account_query_should_reject_incomplete_sort() {
let query: ListQuery =
serde_json::from_value(json!({"sortBy": "usage"})).expect("deserialize account query");
assert_eq!(query.validate().expect_err("reject sort").field(), "sort");
}
#[test]
fn account_query_should_reject_unknown_fields() {
assert!(serde_json::from_value::<ListQuery>(json!({"id": "cred_1"})).is_err());
}
}
mod response {
use gateway_api::admin::accounts::AccountUsageView;
#[test]
fn account_usage_view_should_keep_unobserved_numbers_null() {
let view = AccountUsageView {
request_count: None,
request_count_display: "-".to_owned(),
input_tokens: None,
input_tokens_display: "-".to_owned(),
output_tokens: None,
output_tokens_display: "-".to_owned(),
cached_tokens: None,
cached_tokens_display: "-".to_owned(),
image_input_tokens: None,
image_input_tokens_display: "-".to_owned(),
image_output_tokens: None,
image_output_tokens_display: "-".to_owned(),
image_request_count: None,
image_request_count_display: "-".to_owned(),
image_request_failed_count: None,
image_request_failed_count_display: "-".to_owned(),
total_tokens: None,
total_tokens_display: "-".to_owned(),
created_tokens: None,
created_tokens_display: "-".to_owned(),
read_tokens: None,
read_tokens_display: "-".to_owned(),
last_used_at: None,
last_used_at_display: "-".to_owned(),
cost_estimate_status: "unknown".to_owned(),
known_cost_count: None,
partial_cost_count: None,
unknown_cost_count: None,
costs: Vec::new(),
models: Vec::new(),
};
let value = serde_json::to_value(view).expect("serialize account usage");
assert!(value["inputTokens"].is_null());
assert!(value["totalTokens"].is_null());
assert_eq!(value["createdTokensDisplay"], "-");
}
}
mod actions {
use gateway_admin::model::{
Revision,
accounts::{AccountConnectionTestEvent as DomainConnectionTestEvent, AccountStatus},
provider_credentials::{
CredentialDeletionResult, CredentialImportResult, CredentialMutationResult,
},
};
use gateway_api::admin::accounts::{
AccountActionRequest, AccountConnectionTestEvent, AccountDeletionData,
AccountDeletionRequest, AccountExportData, AccountExportQuery, AccountIdQuery,
AccountImportData, AccountImportRequest, AccountMutationData, AccountRefreshRequest,
AccountTestQuery, RotateAccountRequest, StartAccountAuthorizationRequest,
};
use gateway_core::engine::credential::ProviderAccountId;
use serde_json::json;
#[test]
fn export_should_require_explicit_unique_ids_and_confirmation() {
let valid: AccountExportQuery = serde_json::from_value(json!({
"accountIds": "acct_1,acct_2",
"confirm": "export_sensitive_accounts"
}))
.expect("decode export query");
assert_eq!(
valid
.into_ids()
.expect("valid export")
.into_iter()
.map(|id| id.to_string())
.collect::<Vec<_>>(),
["acct_1".to_owned(), "acct_2".to_owned()]
);
for query in [
json!({ "accountIds": "", "confirm": "export_sensitive_accounts" }),
json!({ "accountIds": "acct_1,acct_1", "confirm": "export_sensitive_accounts" }),
json!({ "accountIds": "acct_1", "confirm": "yes" }),
] {
assert!(
serde_json::from_value::<AccountExportQuery>(query)
.expect("decode invalid export query")
.into_ids()
.is_err()
);
}
}
#[test]
fn account_actions_should_require_frozen_account_ids_and_reject_unknown_revision() {
let id: AccountIdQuery =
serde_json::from_value(json!({ "accountId": "acct_1" })).expect("decode ID query");
assert!(id.validate().is_ok());
let action: AccountActionRequest =
serde_json::from_value(json!({ "accountId": "legacy-id" })).expect("decode action");
assert_eq!(action.validate().unwrap_err().field(), "accountId");
let refresh: AccountRefreshRequest = serde_json::from_value(json!({
"accountId": "acct_1"
}))
.expect("decode refresh");
assert!(refresh.validate().is_ok());
assert!(
serde_json::from_value::<AccountRefreshRequest>(json!({
"accountId": "acct_1",
"expectedConfigRevision": 0
}))
.is_err()
);
}
#[test]
fn credential_recovery_requests_should_not_accept_client_revision_fences() {
let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({
"provider": "openai",
"name": "reauthorize",
"accountId": "acct_1"
}))
.expect("decode reauthorization");
assert!(authorization.validate().is_ok());
assert!(
serde_json::from_value::<StartAccountAuthorizationRequest>(json!({
"provider": "openai",
"name": "reauthorize",
"accountId": "acct_1",
"expectedCredentialRevision": 1
}))
.is_err()
);
let rotation: RotateAccountRequest = serde_json::from_value(json!({
"provider": "openai",
"accountId": "acct_1",
"accessToken": "header.payload.signature",
"refreshToken": "refresh-token"
}))
.expect("decode rotation");
assert!(rotation.validate().is_ok());
assert!(
serde_json::from_value::<RotateAccountRequest>(json!({
"provider": "openai",
"accountId": "acct_1",
"expectedCredentialRevision": 1,
"accessToken": "header.payload.signature"
}))
.is_err()
);
}
#[test]
fn credential_mutation_response_should_not_expose_internal_revision() {
let response = AccountMutationData::from(CredentialMutationResult {
config_revision: Revision::new(8).expect("config revision"),
account_id: ProviderAccountId::new("acct_1").expect("account ID"),
credential_revision: Some(Revision::new(9).expect("credential revision")),
});
assert_eq!(
serde_json::to_value(response).expect("serialize credential mutation"),
json!({ "accountId": "acct_1" })
);
}
#[test]
fn account_import_should_use_provider_and_opaque_data_fields() {
let valid: AccountImportRequest = serde_json::from_value(json!({
"provider": "openai",
"data": {
"providerOwnedUnknownField": {"nested": [1, 2, 3]},
"accounts": [{"credentials": {"access_token": "provider-validates-this"}}]
}
}))
.expect("decode account import");
assert!(valid.validate().is_ok());
let invalid: AccountImportRequest = serde_json::from_value(json!({
"provider": "xai",
"data": []
}))
.expect("decode invalid account import");
assert_eq!(invalid.validate().unwrap_err().field(), "data");
assert!(
serde_json::from_value::<AccountImportRequest>(json!({
"provider": "openai",
"document": {}
}))
.is_err()
);
assert!(
serde_json::from_value::<AccountImportRequest>(json!({
"provider": "openai",
"expectedConfigRevision": 7,
"data": {}
}))
.is_err()
);
}
#[test]
fn account_deletion_should_validate_one_provider_batch_and_emit_account_ids() {
let request: AccountDeletionRequest = serde_json::from_value(json!({
"provider": "xai",
"accountIds": ["acct_1", "acct_2"]
}))
.expect("decode account deletion");
assert!(request.validate().is_ok());
let duplicate: AccountDeletionRequest = serde_json::from_value(json!({
"provider": "xai",
"accountIds": ["acct_1", "acct_1"]
}))
.expect("decode duplicate account deletion");
assert_eq!(duplicate.validate().unwrap_err().field(), "accountIds");
let response = AccountDeletionData::from(CredentialDeletionResult {
config_revision: Revision::new(9).expect("revision"),
account_ids: vec![
ProviderAccountId::new("acct_1").expect("account ID"),
ProviderAccountId::new("acct_2").expect("account ID"),
],
});
assert_eq!(
serde_json::to_value(response).expect("serialize account deletion"),
json!({
"deletedCount": 2,
"accountIds": ["acct_1", "acct_2"]
})
);
}
#[test]
fn account_import_response_should_emit_account_ids() {
let response = AccountImportData::from(CredentialImportResult {
config_revision: Revision::new(8).expect("revision"),
credential_ids: vec![ProviderAccountId::new("acct_imported").expect("account ID")],
});
assert_eq!(
serde_json::to_value(response).expect("serialize account import"),
json!({
"importedCount": 1,
"accountIds": ["acct_imported"]
})
);
}
#[test]
fn connection_test_should_require_model_in_query() {
let query: AccountTestQuery = serde_json::from_value(json!({
"accountId": "acct_1",
"modelId": " "
}))
.expect("decode connection test query");
assert_eq!(query.validate().unwrap_err().field(), "modelId");
}
#[test]
fn connection_test_events_should_preserve_the_existing_frontend_contract() {
let events = [
DomainConnectionTestEvent::Started {
model: "grok-4.5".to_owned(),
},
DomainConnectionTestEvent::Request {
model: "grok-4.5".to_owned(),
input_text: "Reply with exactly OK.".to_owned(),
stream: true,
store: false,
},
DomainConnectionTestEvent::Content {
text: "OK".to_owned(),
},
DomainConnectionTestEvent::Completed {
account_status: AccountStatus::Active,
},
DomainConnectionTestEvent::Failed {
message: "upstream unavailable".to_owned(),
provider_error_code: Some("usage_exhausted".to_owned()),
provider_error_type: Some("invalid_request_error".to_owned()),
upstream_status: Some(429),
upstream_content_type: Some("application/json".to_owned()),
upstream_body: Some(r#"{"error":{"type":"usage_limit_reached"}}"#.to_owned()),
account_status: AccountStatus::Active,
},
]
.map(|event| AccountConnectionTestEvent::from(event).data);
assert_eq!(
events,
[
json!({ "type": "test_start", "model": "grok-4.5", "text": "正在连接上游 Responses" }),
json!({
"type": "request",
"payload": {
"model": "grok-4.5",
"input": [{
"role": "user",
"content": [{
"type": "input_text",
"text": "Reply with exactly OK."
}]
}],
"stream": true,
"store": false
}
}),
json!({ "type": "content", "text": "OK" }),
json!({ "type": "test_complete", "success": true, "accountStatus": "active" }),
json!({
"type": "error",
"error": "upstream unavailable",
"providerErrorCode": "usage_exhausted",
"providerErrorType": "invalid_request_error",
"upstreamStatus": 429,
"upstreamContentType": "application/json",
"upstreamBody": r#"{"error":{"type":"usage_limit_reached"}}"#,
"accountStatus": "active"
}),
]
);
}
#[test]
fn provider_export_document_should_serialize_but_never_debug_secret() {
let secret = "provider-refresh-token-must-not-enter-debug";
let document = AccountExportData::new(json!({ "refresh_token": secret }));
assert!(!format!("{document:?}").contains(secret));
assert_eq!(
serde_json::to_value(document).expect("serialize export"),
json!({ "refresh_token": secret })
);
}
}
@@ -0,0 +1,136 @@
use gateway_admin::model::auth::{LoginCommand, LoginError};
use gateway_api::admin::auth::{
AdminLoginData, AdminLoginRequest, AdminLogoutData, AdminSessionStatusData,
};
use serde_json::json;
use super::AdminTestFixture;
#[test]
fn login_request_should_deny_unknown_fields_and_redact_password_debug() {
let password = "admin-password-must-not-leak";
let request = serde_json::from_value::<AdminLoginRequest>(json!({
"username": "admin@example.invalid",
"password": password
}))
.expect("deserialize login request");
assert!(!format!("{request:?}").contains(password));
let (username, parsed_password) = request.into_parts();
assert_eq!(username.as_deref(), Some("admin@example.invalid"));
assert_eq!(parsed_password, password);
assert!(
serde_json::from_value::<AdminLoginRequest>(json!({
"password": password,
"rememberMe": true
}))
.is_err()
);
}
#[test]
fn auth_responses_should_keep_stable_wire_shapes() {
assert_eq!(
serde_json::to_value(AdminLoginData::new("2026-07-18T08:00:00+08:00".to_owned()))
.expect("serialize login"),
json!({ "expiresAt": "2026-07-18T08:00:00+08:00" })
);
assert_eq!(
serde_json::to_value(AdminSessionStatusData::new(true)).expect("serialize status"),
json!({ "authenticated": true })
);
assert_eq!(
serde_json::to_value(AdminLogoutData::new()).expect("serialize logout"),
json!({ "message": "Logged out successfully" })
);
}
#[tokio::test]
async fn default_auth_service_should_initialize_login_validate_and_logout() {
let fixture = AdminTestFixture::new().await;
let service = fixture.services.auth();
let session = service
.login(LoginCommand {
username: Some("admin_1".to_owned()),
password: "strong-admin-password".to_owned(),
})
.await
.expect("login succeeds");
assert!(
service
.validate_session(Some(&session.session_id))
.await
.expect("validate session")
);
assert_eq!(
service
.resolve_admin_user_id(Some(&session.session_id))
.await
.expect("resolve session")
.as_deref(),
Some("admin_1")
);
service
.logout(&session.session_id)
.await
.expect("logout session");
assert!(
!service
.validate_session(Some(&session.session_id))
.await
.expect("validate logged-out session")
);
assert_eq!(fixture.auth.audit_count(), 2);
}
#[tokio::test]
async fn default_auth_service_should_verify_only_full_plaintext_admin_key() {
let fixture = AdminTestFixture::new().await;
let key = format!("admin-{}", "a".repeat(64));
fixture.auth.set_api_key(&key);
assert!(
fixture
.services
.auth()
.verify_admin_api_key(&key)
.await
.unwrap()
);
assert!(
!fixture
.services
.auth()
.verify_admin_api_key("admin-short")
.await
.unwrap()
);
assert!(
!fixture
.services
.auth()
.verify_admin_api_key(&format!("admin-{}", "b".repeat(64)))
.await
.unwrap()
);
}
#[tokio::test]
async fn audit_failure_should_revoke_new_session_before_returning_it() {
let fixture = AdminTestFixture::new().await;
fixture.auth.fail_audit(true);
assert_eq!(
fixture
.services
.auth()
.login(LoginCommand {
username: None,
password: "strong-admin-password".to_owned(),
})
.await
.expect_err("audit failure rejects login"),
LoginError::Unavailable
);
assert_eq!(fixture.auth.session_count(), 0);
}
@@ -0,0 +1,332 @@
use chrono::{DateTime, TimeZone as _, Utc};
use gateway_api::admin::client_keys::{
self, ClientKeyCursorData, ClientKeyCursorValue, ClientKeyListData, ClientKeyMutationRequest,
ClientKeySort, ClientKeySortDirection, ClientKeySortField, ClientKeyView,
CreateClientKeyRequest, CreatedClientKeyData, ListClientKeysQuery, MutatedClientKeyData,
RevealedClientKeyData, UpdateClientKeyRequest, decode_client_key_cursor,
encode_client_key_cursor,
};
use serde_json::json;
use super::{AdminTestFixture, AdminTestState};
#[test]
fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
let unknown = serde_json::from_value::<ListClientKeysQuery>(json!({ "other": true }));
let zero = serde_json::from_value::<ListClientKeysQuery>(json!({ "limit": 0 }))
.expect("deserialize zero limit");
let oversized = serde_json::from_value::<ListClientKeysQuery>(json!({
"cursor": "a".repeat(513)
}))
.expect("deserialize oversized cursor");
let oversized_search = serde_json::from_value::<ListClientKeysQuery>(json!({
"search": "a".repeat(257)
}))
.expect("deserialize oversized search");
let secret_search = serde_json::from_value::<ListClientKeysQuery>(json!({
"search": format!("sk_{}", "a".repeat(43))
}))
.expect("deserialize secret search");
let invalid_sort = serde_json::from_value::<ListClientKeysQuery>(json!({
"sortBy": "plaintextKey",
"sortDirection": "asc"
}))
.expect("deserialize invalid sort");
assert!(unknown.is_err());
assert_eq!(
zero.into_command().expect_err("reject zero limit").field(),
"limit"
);
assert_eq!(
oversized
.into_command()
.expect_err("reject oversized cursor")
.field(),
"cursor"
);
assert_eq!(
oversized_search
.into_command()
.expect_err("reject oversized search")
.field(),
"search"
);
assert_eq!(
secret_search
.into_command()
.expect_err("reject full key in search")
.field(),
"search"
);
assert_eq!(
invalid_sort
.into_command()
.expect_err("reject invalid sort")
.field(),
"sortBy"
);
let sorted = serde_json::from_value::<ListClientKeysQuery>(json!({
"sortBy": "lastUsedAt",
"sortDirection": "asc"
}))
.expect("deserialize valid sort")
.into_command()
.expect("validate sort");
assert_eq!(
sorted.sort.field,
gateway_admin::model::client_keys::ClientKeySortField::LastUsedAt,
);
assert_eq!(
sorted.sort.direction,
gateway_admin::model::client_keys::SortDirection::Asc,
);
assert_eq!(
serde_json::from_value::<ListClientKeysQuery>(json!({ "search": " " }))
.expect("deserialize blank search")
.into_command()
.expect("normalize blank search")
.search,
None
);
assert_eq!(
serde_json::from_value::<ListClientKeysQuery>(json!({ "limit": u16::MAX }))
.expect("deserialize maximum u16 page size")
.into_command()
.expect("accept maximum u16 page size")
.page_size
.get(),
u16::MAX
);
}
#[test]
fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
let valid = serde_json::from_value::<CreateClientKeyRequest>(json!({
"name": "terminal key",
"label": "production",
"providerKind": "openai",
"maxConcurrency": 2,
"requestsPerMinute": 60
}))
.expect("deserialize create")
.into_command()
.expect("validate create");
assert_eq!(valid.name, "terminal key");
for (payload, field) in [
(
json!({
"name": " ",
"providerKind": "openai",
"maxConcurrency": 0,
"requestsPerMinute": 0
}),
"name",
),
(
json!({
"name": "key",
"providerKind": "openai",
"maxConcurrency": u64::MAX,
"requestsPerMinute": 0
}),
"maxConcurrency",
),
] {
let request = serde_json::from_value::<CreateClientKeyRequest>(payload)
.expect("deserialize invalid create shape");
assert_eq!(
request
.into_command()
.expect_err("reject invalid create")
.field(),
field
);
}
assert!(
serde_json::from_value::<UpdateClientKeyRequest>(json!({
"id": "key_1",
"expectedConfigRevision": 1,
"name": "key",
"providerKind": "openai",
"maxConcurrency": 0,
"requestsPerMinute": 0,
"tokensPerMinute": 0
}))
.is_err()
);
assert!(
serde_json::from_value::<CreateClientKeyRequest>(json!({
"expectedConfigRevision": 7,
"name": "terminal key",
"providerKind": "openai",
"maxConcurrency": 2,
"requestsPerMinute": 60
}))
.is_err()
);
let mutation_id = serde_json::from_value::<ClientKeyMutationRequest>(json!({
"id": "key_1"
}))
.expect("deserialize mutation")
.into_id()
.expect("validate mutation");
assert_eq!(mutation_id, "key_1");
}
#[test]
fn client_key_cursor_should_round_trip_and_reject_noncanonical_input() {
let created_at = Utc
.with_ymd_and_hms(2026, 7, 18, 8, 0, 0)
.single()
.expect("valid time");
let cursor = ClientKeyCursorData {
sort: ClientKeySort {
field: ClientKeySortField::CreatedAt,
direction: ClientKeySortDirection::Desc,
},
value: ClientKeyCursorValue::CreatedAt(created_at),
id: "key_cursor".to_owned(),
};
let encoded = encode_client_key_cursor(&cursor).expect("encode cursor");
let decoded = decode_client_key_cursor(&encoded).expect("decode cursor");
assert_eq!(decoded, cursor);
assert!(!encoded.contains("key_cursor"));
for invalid in ["", "not-base64!", "e30"] {
assert!(decode_client_key_cursor(invalid).is_err());
}
}
#[test]
fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
let created_at = Utc
.with_ymd_and_hms(2026, 7, 18, 8, 0, 0)
.single()
.expect("valid time");
let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord {
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
name: "visible".to_owned(),
label: None,
provider_kind: gateway_core::routing::ProviderKind::new("openai").expect("Provider kind"),
prefix: "sk_visible12".to_owned(),
enabled: true,
limits: gateway_core::policy::RateLimits {
max_concurrency: 2,
requests_per_minute: 60,
},
created_at,
updated_at: created_at,
last_used_at: Some(created_at),
});
let list =
serde_json::to_value(ClientKeyListData::new(vec![view], None, 1)).expect("serialize list");
assert!(list.get("configRevision").is_none());
assert_eq!(list["total"], 1);
assert_eq!(list["items"][0]["id"], "key_visible");
assert_eq!(list["items"][0]["providerKind"], "openai");
assert_eq!(list["items"][0]["maxConcurrency"], 2);
assert_eq!(list["items"][0]["requestsPerMinute"], 60);
assert!(list["items"][0].get("tokensPerMinute").is_none());
assert!(list["items"][0].get("policyJson").is_none());
assert_eq!(
DateTime::parse_from_rfc3339(
list["items"][0]["lastUsedAt"]
.as_str()
.expect("serialized last-used timestamp")
)
.expect("parse serialized last-used timestamp"),
created_at.fixed_offset()
);
assert!(list["items"][0].get("plaintextKey").is_none());
let plaintext = format!("sk_{}", "a".repeat(43));
let created = CreatedClientKeyData::new(
"key_created".to_owned(),
"sk_aaaaaaaaa".to_owned(),
plaintext.clone(),
);
assert!(!format!("{created:?}").contains(&plaintext));
let value = serde_json::to_value(created).expect("serialize created key");
assert_eq!(value["plaintextKey"], plaintext);
let revealed = RevealedClientKeyData::new("key_created".to_owned(), plaintext.clone());
assert!(!format!("{revealed:?}").contains(&plaintext));
assert_eq!(
serde_json::to_value(revealed).expect("serialize revealed key"),
json!({ "id": "key_created", "plaintextKey": plaintext })
);
assert_eq!(
serde_json::to_value(MutatedClientKeyData::new("key_created".to_owned()))
.expect("serialize mutation"),
json!({ "id": "key_created" })
);
}
#[tokio::test]
async fn reveal_route_should_use_query_id_and_no_store_response() {
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use tower::ServiceExt as _;
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let response = client_keys::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/client-keys/reveal?id=key_1")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header("x-request-id", "req_reveal")
.body(Body::empty())
.expect("reveal request"),
)
.await
.expect("reveal response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let body = to_bytes(response.into_body(), 4096)
.await
.expect("reveal body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("reveal JSON");
assert_eq!(value["data"]["id"], "key_1");
assert_eq!(
value["data"]["plaintextKey"],
format!("sk_{}", "a".repeat(43))
);
}
#[tokio::test]
async fn list_route_should_accept_the_full_nonzero_u16_page_size() {
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use tower::ServiceExt as _;
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let response = client_keys::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/client-keys?limit=65535")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header("x-request-id", "req_client_keys_max_page")
.body(Body::empty())
.expect("client key list request"),
)
.await
.expect("client key list response");
assert_eq!(response.status(), StatusCode::OK);
}
@@ -0,0 +1,762 @@
use std::{
collections::BTreeMap,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use futures::future::BoxFuture;
use gateway_admin::{
AdminConfig, AdminServices, InitialAdminPassword,
model::{
MutationContext, Revision,
accounts::{
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
},
auth::{AdminAuditEvent, AdminSession},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
},
observability::{
DashboardObservation, DiagnosticDimension, DiagnosticObservation, OpsErrorPage,
OpsErrorQuery, RequestMetricPoint, TimeRange, UsageDetail, UsageFilter, UsageOverview,
UsagePage, UsageQuery, UsageRecord,
},
provider_credentials::{
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
CredentialImportCommit, CredentialImportResult, CredentialListQuery,
CredentialMutationResult, CredentialPage, CredentialRotationCommit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput,
ProviderModels, ProviderQuota,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ModelMappings, ReplaceRuntimeSettings,
RotationStrategy, RuntimeSettings,
},
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
},
ports::{
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
store::{
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStorePorts, AdminStoreResult,
AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore,
},
system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations,
SystemUpdateEventStream,
},
},
};
use gateway_api::admin::AdminSessionState;
use gateway_core::{
engine::{
credential::ProviderAccountId,
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
},
policy::{ClientApiKeyId, RateLimits},
routing::{
ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId, snapshot::SnapshotControl,
},
};
mod accounts;
mod auth;
mod client_keys;
mod observability;
mod settings;
mod system;
mod wire;
pub(super) struct AdminTestFixture {
pub services: AdminServices,
pub auth: Arc<MemoryAuthStore>,
pub settings: Arc<MemorySettingsStore>,
pub usage_records: Arc<Mutex<Vec<UsageRecord>>>,
}
impl AdminTestFixture {
pub async fn new() -> Self {
Self::with_system(Arc::new(UnusedSystem)).await
}
pub async fn with_system(system: Arc<dyn SystemOperations>) -> Self {
let api_key = Arc::new(Mutex::new(None));
let auth = Arc::new(MemoryAuthStore::new(api_key.clone()));
let settings = Arc::new(MemorySettingsStore::new(api_key));
let client_keys = Arc::new(MemoryClientKeyStore);
let usage_records = Arc::new(Mutex::new(Vec::new()));
let unused = Arc::new(UnusedStore {
usage_records: Arc::clone(&usage_records),
});
let stores = AdminStorePorts::new(
unused.clone(),
auth.clone(),
client_keys.clone(),
unused,
settings.clone(),
);
let providers: Vec<Arc<dyn ProviderAdmin>> = vec![
Arc::new(UnusedProvider::new("openai")),
Arc::new(UnusedProvider::new("xai")),
];
let bundle = gateway_admin::initialize(
AdminConfig {
session_ttl_minutes: 60,
default_username: "admin_1".to_owned(),
default_password: InitialAdminPassword::new("strong-admin-password"),
},
stores,
providers,
Arc::new(NoopSnapshot),
Arc::new(NoopProbe),
system,
)
.await
.expect("initialize test admin services");
Self {
services: bundle.services(),
auth,
settings,
usage_records,
}
}
pub fn state(&self) -> AdminTestState {
AdminTestState(self.services.clone())
}
}
#[derive(Clone)]
pub(super) struct AdminTestState(AdminServices);
impl AdminSessionState for AdminTestState {
fn admin_services(&self) -> &AdminServices {
&self.0
}
}
pub(super) struct MemoryAuthStore {
password_hash: Mutex<Option<String>>,
sessions: Mutex<BTreeMap<String, AdminSession>>,
audits: Mutex<Vec<AdminAuditEvent>>,
api_key: Arc<Mutex<Option<AdminApiKey>>>,
fail_audit: AtomicBool,
}
impl MemoryAuthStore {
fn new(api_key: Arc<Mutex<Option<AdminApiKey>>>) -> Self {
Self {
password_hash: Mutex::new(None),
sessions: Mutex::new(BTreeMap::new()),
audits: Mutex::new(Vec::new()),
api_key,
fail_audit: AtomicBool::new(false),
}
}
pub fn insert_session(&self, session_id: &str) {
self.sessions.lock().expect("sessions").insert(
session_id.to_owned(),
AdminSession {
admin_user_id: "admin_1".to_owned(),
expires_at: Utc::now() + Duration::hours(1),
},
);
}
pub fn set_api_key(&self, value: &str) {
*self.api_key.lock().expect("API key") = Some(AdminApiKey::new(value));
}
pub fn fail_audit(&self, fail: bool) {
self.fail_audit.store(fail, Ordering::SeqCst);
}
pub fn session_count(&self) -> usize {
self.sessions.lock().expect("sessions").len()
}
pub fn audit_count(&self) -> usize {
self.audits.lock().expect("audits").len()
}
}
#[async_trait]
impl AuthStore for MemoryAuthStore {
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
Ok(self.password_hash.lock().expect("password hash").clone())
}
async fn create_password_hash_if_absent(
&self,
_: &str,
password_hash: &str,
) -> AdminStoreResult<bool> {
let mut stored = self.password_hash.lock().expect("password hash");
if stored.is_some() {
return Ok(false);
}
*stored = Some(password_hash.to_owned());
Ok(true)
}
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>> {
Ok(self.api_key.lock().expect("API key").clone())
}
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
Ok(self
.sessions
.lock()
.expect("sessions")
.get(session_id)
.cloned())
}
async fn store_session(
&self,
session_id: &str,
session: &AdminSession,
) -> AdminStoreResult<()> {
self.sessions
.lock()
.expect("sessions")
.insert(session_id.to_owned(), session.clone());
Ok(())
}
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
Ok(self.sessions.lock().expect("sessions").remove(session_id))
}
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
if self.fail_audit.load(Ordering::SeqCst) {
return Err(unavailable("auth audit"));
}
self.audits.lock().expect("audits").push(event);
Ok(())
}
}
pub(super) struct MemorySettingsStore {
settings: Mutex<RuntimeSettings>,
api_key: Arc<Mutex<Option<AdminApiKey>>>,
}
impl MemorySettingsStore {
fn new(api_key: Arc<Mutex<Option<AdminApiKey>>>) -> Self {
Self {
settings: Mutex::new(test_runtime_settings()),
api_key,
}
}
pub fn set_api_key(&self, value: &str) {
*self.api_key.lock().expect("API key") = Some(AdminApiKey::new(value));
}
}
#[async_trait]
impl SettingsStore for MemorySettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(self.settings.lock().expect("settings").clone())
}
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
Ok(self.api_key.lock().expect("API key").is_some())
}
async fn replace_runtime_settings(
&self,
command: ReplaceRuntimeSettings,
_: &MutationContext,
) -> AdminStoreResult<RuntimeSettings> {
let mut settings = self.settings.lock().expect("settings");
let updated = RuntimeSettings {
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,
rotation_strategy: command.rotation_strategy,
usage_retention_days: command.usage_retention_days,
ops_event_retention_days: command.ops_event_retention_days,
audit_retention_days: command.audit_retention_days,
updated_at: Utc::now(),
};
*settings = updated.clone();
Ok(updated)
}
async fn replace_admin_api_key(
&self,
key: AdminApiKey,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
*self.api_key.lock().expect("API key") = Some(key);
let revision = self.settings.lock().expect("settings").config_revision;
Ok(AdminApiKeyMutation {
config_revision: next_revision(revision),
exists: true,
})
}
async fn delete_admin_api_key(
&self,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
*self.api_key.lock().expect("API key") = None;
let revision = self.settings.lock().expect("settings").config_revision;
Ok(AdminApiKeyMutation {
config_revision: next_revision(revision),
exists: false,
})
}
}
pub(super) struct MemoryClientKeyStore;
#[async_trait]
impl ClientKeyStore for MemoryClientKeyStore {
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
Ok(ClientKeyPage {
config_revision: Revision::new(1).expect("revision"),
items: Vec::new(),
total: 0,
next_cursor: None,
})
}
async fn reveal_client_key(
&self,
id: &ClientApiKeyId,
) -> AdminStoreResult<Option<ClientKeySecret>> {
let now = Utc::now();
Ok(Some(ClientKeySecret::new(
ClientKeyRecord {
id: id.clone(),
name: "revealed".to_owned(),
label: None,
provider_kind: ProviderKind::new("openai").expect("provider kind"),
prefix: "sk_aaaaaaaaa".to_owned(),
enabled: true,
limits: RateLimits::unlimited(),
last_used_at: None,
created_at: now,
updated_at: now,
},
format!("sk_{}", "a".repeat(43)),
)))
}
async fn create_client_key(
&self,
_: NewClientKey,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
Err(unavailable("client key create"))
}
async fn update_client_key(
&self,
_: UpdateClientKey,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
Err(unavailable("client key update"))
}
async fn set_client_key_enabled(
&self,
_: SetClientKeyEnabled,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
Err(unavailable("client key enabled"))
}
async fn delete_client_key(
&self,
_: DeleteClientKey,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable("client key delete"))
}
}
struct UnusedStore {
usage_records: Arc<Mutex<Vec<UsageRecord>>>,
}
#[async_trait]
impl AccountStore for UnusedStore {
async fn list_accounts(&self, _: AccountListQuery) -> AdminStoreResult<AccountPage> {
Err(unavailable("account list"))
}
async fn load_account(&self, _: &str) -> AdminStoreResult<Option<AccountRecord>> {
Err(unavailable("account"))
}
async fn load_account_usage(
&self,
_: TimeRange,
_: &[String],
) -> AdminStoreResult<Vec<AccountUsage>> {
Err(unavailable("account usage"))
}
async fn load_account_usage_by_windows(
&self,
_: &[AccountUsageWindowQuery],
) -> AdminStoreResult<Vec<AccountUsageWindowResult>> {
Err(unavailable("account quota window usage"))
}
async fn list_credentials(
&self,
_: &ProviderKind,
_: CredentialListQuery,
) -> AdminStoreResult<CredentialPage> {
Err(unavailable("credential list"))
}
async fn credential_details(
&self,
_: &ProviderKind,
_: &ProviderAccountId,
) -> AdminStoreResult<Option<CredentialDetails>> {
Err(unavailable("credential"))
}
async fn load_credentials_for_export(
&self,
_: &ProviderKind,
_: &[ProviderAccountId],
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>> {
Err(unavailable("credential export"))
}
async fn commit_credential_import(
&self,
_: CredentialImportCommit,
_: &MutationContext,
) -> AdminStoreResult<CredentialImportResult> {
Err(unavailable("credential import"))
}
async fn commit_authorization(
&self,
_: AuthorizationCommit,
_: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
Err(unavailable("authorization"))
}
async fn commit_credential_rotation(
&self,
_: CredentialRotationCommit,
_: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
Err(unavailable("credential rotation"))
}
async fn commit_credential_refresh(
&self,
_: CredentialRotationCommit,
_: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
Err(unavailable("credential refresh"))
}
async fn set_account_enabled(
&self,
_: SetAccountEnabled,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable("account enabled"))
}
async fn delete_accounts(
&self,
_: DeleteAccounts,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable("account delete"))
}
async fn record_credential_export(
&self,
_: &[ProviderAccountId],
_: &MutationContext,
) -> AdminStoreResult<()> {
Err(unavailable("credential export audit"))
}
}
#[async_trait]
impl ObservabilityStore for UnusedStore {
async fn dashboard_summary(&self, _: TimeRange) -> AdminStoreResult<DashboardObservation> {
Err(unavailable("dashboard"))
}
async fn dashboard_trend(&self, _: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>> {
Err(unavailable("dashboard trend"))
}
async fn usage_trend(
&self,
_: TimeRange,
_: UsageFilter,
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
Err(unavailable("usage trend"))
}
async fn usage_calculated_billing_facts(
&self,
_: TimeRange,
_: UsageFilter,
) -> AdminStoreResult<Vec<gateway_admin::model::observability::UsageCalculatedBillingFact>>
{
Err(unavailable("usage billing facts"))
}
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
let items = self.usage_records.lock().expect("usage records").clone();
Ok(UsagePage {
total: items.len() as u64,
items,
next_cursor: None,
})
}
async fn usage_record_detail(&self, _: &str) -> AdminStoreResult<UsageDetail> {
Err(unavailable("usage detail"))
}
async fn usage_summary(&self, _: TimeRange, _: UsageFilter) -> AdminStoreResult<UsageOverview> {
Err(unavailable("usage summary"))
}
async fn usage_diagnostics(
&self,
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
Err(unavailable("usage diagnostics"))
}
async fn list_ops_errors(&self, _: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
Err(unavailable("ops errors"))
}
}
struct UnusedProvider {
kind: ProviderKind,
}
impl UnusedProvider {
fn new(kind: &str) -> Self {
Self {
kind: ProviderKind::new(kind).expect("provider kind"),
}
}
}
#[async_trait]
impl ProviderAdmin for UnusedProvider {
fn provider_kind(&self) -> &ProviderKind {
&self.kind
}
async fn account_unavailable(&self, _: &ProviderAccountId) {}
fn connection_test_operation(
&self,
_: &gateway_core::routing::UpstreamModelId,
_: &str,
) -> Result<gateway_core::operation::Operation, ProviderAdminError> {
Err(unsupported_provider())
}
fn dashboard_wire_profile(
&self,
) -> Option<gateway_admin::model::observability::DashboardWireProfile> {
None
}
fn calculated_billing(
&self,
_: &gateway_admin::model::observability::ProviderBillingInput,
) -> Result<
Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
ProviderAdminError,
> {
Ok(None)
}
async fn prepare_import(
&self,
_: PrepareCredentialImport,
) -> Result<PreparedCredentialImport, ProviderAdminError> {
Err(unsupported_provider())
}
async fn start_authorization(
&self,
_: PendingAuthorizationMutation,
) -> Result<AuthorizationStarted, ProviderAdminError> {
Err(unsupported_provider())
}
async fn complete_authorization(
&self,
_: CompleteAuthorization,
) -> Result<PreparedAuthorizationCommit, ProviderAdminError> {
Err(unsupported_provider())
}
async fn prepare_rotation(
&self,
_: PrepareCredentialRotation,
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
Err(unsupported_provider())
}
async fn prepare_refresh(
&self,
_: PrepareCredentialRefresh,
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
Err(unsupported_provider())
}
async fn quota(
&self,
_: gateway_admin::model::provider_credentials::ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError> {
Err(unsupported_provider())
}
async fn models(
&self,
_: &ProviderAccountId,
_: bool,
) -> Result<ProviderModels, ProviderAdminError> {
Err(unsupported_provider())
}
async fn export_credentials(
&self,
_: Vec<ProviderExportCredentialInput>,
) -> Result<ProviderExport, ProviderAdminError> {
Err(unsupported_provider())
}
}
struct NoopSnapshot;
impl SnapshotControl for NoopSnapshot {
fn publish_committed(&self, _: ConfigRevision) -> BoxFuture<'_, ()> {
Box::pin(async {})
}
}
struct NoopProbe;
impl AccountProbe for NoopProbe {
fn probe(
&self,
_: AccountProbeRequest,
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async { panic!("unused account probe") })
}
}
struct UnusedSystem;
#[async_trait]
impl SystemOperations for UnusedSystem {
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
Err(unavailable_system())
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
Err(unavailable_system())
}
fn update_events(&self) -> SystemUpdateEventStream {
Box::pin(futures::stream::empty())
}
async fn perform_update(
&self,
_: Option<String>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
Err(unavailable_system())
}
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
}
fn test_runtime_settings() -> RuntimeSettings {
let mappings: ModelMappings = BTreeMap::from([
(
PublicModelId::new("coding-default").expect("public model"),
UpstreamModelId::new("gpt-5.4").expect("upstream model"),
),
(
PublicModelId::new("grok-latest").expect("public model"),
UpstreamModelId::new("grok-4.5").expect("upstream model"),
),
]);
RuntimeSettings {
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,
rotation_strategy: RotationStrategy::Smart,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
updated_at: Utc::now(),
}
}
fn next_revision(revision: Revision) -> Revision {
Revision::new(revision.get().saturating_add(1)).expect("next revision")
}
fn unavailable(resource: &'static str) -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
resource,
"unused test port",
)
}
fn unsupported_provider() -> ProviderAdminError {
ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)
}
fn unavailable_system() -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Internal, "unused test operation")
}
@@ -0,0 +1,459 @@
mod query {
use gateway_api::admin::observability::{
DashboardQuery, DiagnosticDimension, DiagnosticsQuery, OpsQuery, TrendKind, UsageQuery,
parse_attempt_index, parse_datetime, parse_status,
};
use serde_json::json;
#[test]
fn dashboard_query_should_parse_terminal_trend_kinds() {
let query: DashboardQuery = serde_json::from_value(json!({"kind": "errors"})).unwrap();
assert_eq!(query.trend_kind().unwrap(), TrendKind::Errors);
}
#[test]
fn dashboard_query_should_reject_unknown_trend_kind() {
let query: DashboardQuery = serde_json::from_value(json!({"kind": "secret"})).unwrap();
assert_eq!(query.trend_kind().unwrap_err().field(), "kind");
}
#[test]
fn usage_query_should_bound_page_size_and_cursor() {
let query: UsageQuery = serde_json::from_value(json!({
"page": 2,
"pageSize": 100,
"cursor": "opaque"
}))
.unwrap();
assert_eq!(query.validate_page().unwrap(), (2, 100));
assert!(query.validate_cursor().is_ok());
}
#[test]
fn usage_query_should_reject_zero_page() {
let query: UsageQuery = serde_json::from_value(json!({"page": 0})).unwrap();
assert_eq!(query.validate_page().unwrap_err().field(), "page");
}
#[test]
fn ops_query_should_reject_page_size_above_terminal_limit() {
let query: OpsQuery = serde_json::from_value(json!({"pageSize": 101})).unwrap();
assert_eq!(query.validate_page().unwrap_err().field(), "pageSize");
}
#[test]
fn diagnostics_query_should_keep_wire_dimension_name() {
let query: DiagnosticsQuery =
serde_json::from_value(json!({"dimension": "failure_class"})).unwrap();
assert_eq!(query.dimension().unwrap(), DiagnosticDimension::Failure);
assert_eq!(DiagnosticDimension::Failure.display_name(), "failureClass");
}
#[test]
fn scalar_query_parsers_should_reject_out_of_range_values_without_echoing_input() {
assert_eq!(parse_status(Some(99)).unwrap_err().field(), "statusCode");
assert_eq!(
parse_attempt_index(Some(0)).unwrap_err().field(),
"attemptIndex"
);
assert_eq!(
parse_datetime(Some("not-a-time")).unwrap_err().field(),
"timeRange"
);
}
}
mod response {
use chrono::{TimeZone, Utc};
use gateway_admin::model::observability::DesktopReleaseStatus;
use gateway_api::admin::observability::{
BillingView, CostCoverageView, CursorWire, DashboardAccountRequestBucketView,
DashboardAccountUsageView, DashboardDesktopReleaseStatusView, DashboardWireAttributeView,
DashboardWireProfileView, DashboardWireTargetView, PageData, PageMeta, TokenDetailsView,
TrendData, TrendKind, TrendPointView, TrendSummaryView,
};
use serde_json::json;
#[test]
fn usage_page_should_keep_terminal_camel_case_shape() {
let data = PageData {
items: vec![json!({"id": "request_1"})],
page: PageMeta::new(1, 50, 1, 1),
next_cursor: Some("cursor".to_owned()),
};
let value = serde_json::to_value(data).unwrap();
assert_eq!(value["page"]["pageSize"], 50);
assert_eq!(value["nextCursor"], "cursor");
}
#[test]
fn dashboard_wire_profiles_should_keep_provider_specific_attributes() {
let value = serde_json::to_value(DashboardWireProfileView {
provider: "xai".to_owned(),
product: "Grok Build".to_owned(),
version: "0.2.106".to_owned(),
build: None,
target: DashboardWireTargetView {
os_type: "linux".to_owned(),
os_version: "—".to_owned(),
arch: "x86_64".to_owned(),
terminal: "headless".to_owned(),
},
user_agent: "grok-shell/0.2.106 (linux; x86_64)".to_owned(),
attributes: vec![DashboardWireAttributeView {
label: "客户端标识".to_owned(),
value: "grok-shell".to_owned(),
}],
verified_at: None,
release: None,
})
.expect("dashboard profile");
assert_eq!(value["provider"], "xai");
assert_eq!(value["version"], "0.2.106");
assert_eq!(value["attributes"][0]["label"], "客户端标识");
assert!(value.get("release").is_none());
assert!(value.get("verifiedAt").is_none());
}
#[test]
fn dashboard_account_usage_should_keep_daily_request_timeline() {
let bucket_start = Utc.timestamp_opt(0, 0).single().unwrap();
let value = serde_json::to_value(DashboardAccountUsageView {
id: "account_1".to_owned(),
provider: "xai".to_owned(),
authentication_kind: "oauth".to_owned(),
email: "account@example.com".to_owned(),
plan_type: Some("free".to_owned()),
tokens: "—".to_owned(),
request_count: 3,
request_buckets: vec![DashboardAccountRequestBucketView {
bucket_start,
request_count: 3,
}],
quota_used_percent: None,
last_used: "刚刚".to_owned(),
})
.expect("dashboard account usage");
assert_eq!(value["requestCount"], 3);
assert_eq!(
value["requestBuckets"][0]["bucketStart"],
"1970-01-01T00:00:00Z"
);
assert_eq!(value["requestBuckets"][0]["requestCount"], 3);
}
#[test]
fn trend_wire_should_serialize_kind_and_values_without_store_types() {
let now = Utc.timestamp_opt(0, 0).single().unwrap();
let data = TrendData {
kind: TrendKind::Usage,
points: vec![TrendPointView {
time: "08:00".to_owned(),
bucket: now,
label: "01-01 08:00".to_owned(),
requests: "1".to_owned(),
requests_value: 1,
input_tokens: "2".to_owned(),
input_tokens_value: 2,
output_tokens: "3".to_owned(),
output_tokens_value: 3,
cached_tokens: "0".to_owned(),
cached_tokens_value: 0,
cache_hit_rate_value: 0.0,
tokens_value: 5,
errors: "0".to_owned(),
errors_value: 0,
latency: "1 ms".to_owned(),
latency_value: Some(1),
first_token_latency: "1 ms".to_owned(),
first_token_latency_value: Some(1),
max_latency: "1 ms".to_owned(),
max_latency_value: Some(1),
min_latency: "1 ms".to_owned(),
min_latency_value: Some(1),
success_rate: "100.0%".to_owned(),
success_rate_value: Some(100.0),
}],
summary: vec![TrendSummaryView {
label: "输入".to_owned(),
value: "2".to_owned(),
ratio: None,
}],
};
let value = serde_json::to_value(data).unwrap();
assert_eq!(value["kind"], "usage");
assert_eq!(value["points"][0]["requestsValue"], 1);
}
#[test]
fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() {
let coverage = CostCoverageView {
known: 1,
partial: 0,
unknown: 0,
not_billable: 0,
};
let token_details = TokenDetailsView {
input_tokens: Some(1),
output_tokens: Some(2),
cached_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
image_input_tokens: None,
image_output_tokens: None,
total_tokens: Some(3),
input_tokens_display: "1".to_owned(),
output_tokens_display: "2".to_owned(),
cached_tokens_display: "-".to_owned(),
cache_write_tokens_display: "-".to_owned(),
reasoning_tokens_display: "-".to_owned(),
image_input_tokens_display: "-".to_owned(),
image_output_tokens_display: "-".to_owned(),
total_tokens_display: "3".to_owned(),
};
let cursor = CursorWire {
observed_at: Utc.timestamp_opt(0, 0).single().unwrap(),
stable_id: "request_1".to_owned(),
};
let value = serde_json::to_value((&coverage, &token_details, &cursor)).unwrap();
assert!(value.to_string().contains("known"));
assert!(!value.to_string().contains("secret"));
}
#[test]
fn billing_view_should_preserve_the_original_detail_contract() {
let value = serde_json::to_value(BillingView {
input_amount_display: "$0.03".to_owned(),
output_amount_display: "$0.00".to_owned(),
cache_read_amount_display: "$0.14".to_owned(),
cache_write_amount_display: "$0.00".to_owned(),
standard_amount_display: "$0.17".to_owned(),
total_amount_display: "$0.17".to_owned(),
input_price_display: "$10.0000 / 1M Token".to_owned(),
output_price_display: "$60.0000 / 1M Token".to_owned(),
cache_read_price_display: "$1.0000 / 1M Token".to_owned(),
cache_write_price_display: "$12.5000 / 1M Token".to_owned(),
service_tier_display: "Fast".to_owned(),
multiplier_display: "1.00x".to_owned(),
})
.expect("billing view");
assert_eq!(value["inputAmountDisplay"], "$0.03");
assert_eq!(value["cacheReadPriceDisplay"], "$1.0000 / 1M Token");
assert_eq!(value["serviceTierDisplay"], "Fast");
assert_eq!(value["multiplierDisplay"], "1.00x");
}
#[test]
fn desktop_release_status_should_preserve_the_existing_dashboard_wire_values() {
for (domain, expected) in [
(DesktopReleaseStatus::Unchecked, "unchecked"),
(DesktopReleaseStatus::Current, "aligned"),
(DesktopReleaseStatus::UpdateAvailable, "review_required"),
(DesktopReleaseStatus::Failed, "check_failed"),
] {
let status = DashboardDesktopReleaseStatusView::from(domain);
assert_eq!(serde_json::to_value(status).unwrap(), expected);
}
}
}
#[tokio::test]
async fn usage_route_should_forward_a_bounded_unknown_outcome_filter() {
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/usage/records?outcome=provider_future_state")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header("x-request-id", "req_usage_other_outcome")
.body(Body::empty())
.expect("usage request"),
)
.await
.expect("usage response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn usage_route_should_expose_image_and_websocket_facts() {
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use chrono::Utc;
use gateway_admin::model::observability::{RequestOutcome, UsageRecord};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
fixture
.usage_records
.lock()
.expect("usage records")
.push(UsageRecord {
id: "request_endpoint".to_owned(),
client_api_key_ref: "key_endpoint".to_owned(),
config_revision: 1,
protocol: "openai".to_owned(),
operation: "generate".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: "grok-4.5".to_owned(),
provider_kind: Some("xai".to_owned()),
provider_account_ref: None,
provider_account_name: None,
provider_account_email: None,
provider_account_authentication_kind: Some("oauth".to_owned()),
upstream_model_id: Some("grok-4.5".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
http_version: Some("h2".to_owned()),
websocket_pool: Some("reuse".to_owned()),
service_tier: Some("priority".to_owned()),
provider_metadata_json: Some(
serde_json::json!({
"effectiveModel": "grok-4.5",
"requestSummary": {"inputItemsCount": 2},
"transport": "must-not-overwrite-core",
})
.to_string(),
),
attempt_count: 1,
upstream_send_state: "sent".to_owned(),
downstream_committed_at: None,
outcome: RequestOutcome::Succeeded,
client_status_code: Some(200),
upstream_status_code: Some(200),
client_response_id: Some("resp_usage_metadata".to_owned()),
upstream_request_id: Some("upstream_usage_metadata".to_owned()),
upstream_response_id: None,
error_kind: None,
provider_error_code: None,
error_message: None,
retry_after_ms: None,
input_tokens: Some(1),
output_tokens: Some(1),
cached_tokens: Some(0),
cache_write_tokens: Some(0),
reasoning_tokens: Some(0),
image_input_tokens: Some(31),
image_output_tokens: Some(9),
total_tokens: Some(2),
cost_source: "unavailable".to_owned(),
cost_amount: None,
cost_currency: None,
billing: None,
transport_decision_wait_ms: Some(7),
connect_ms: Some(11),
headers_ms: Some(13),
first_event_ms: Some(17),
first_reasoning_ms: Some(19),
first_text_ms: Some(23),
first_token_ms: Some(19),
provider_processing_ms: Some(29),
latency_ms: Some(31),
client_ip: Some("127.0.0.1".to_owned()),
user_agent: Some("usage-metadata-test".to_owned()),
reasoning_effort: Some("max".to_owned()),
reasoning_preset: Some("ultra".to_owned()),
request_kind: Some("review".to_owned()),
subagent_kind: Some("worker".to_owned()),
compact: true,
image_generation_requested: true,
image_generation_succeeded: Some(true),
started_at: Utc::now(),
deadline_at: Utc::now(),
completed_at: Some(Utc::now()),
});
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/usage/records")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header("x-request-id", "req_usage_endpoint")
.body(Body::empty())
.expect("usage request"),
)
.await
.expect("usage response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("usage response body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("usage response JSON");
assert_eq!(
serde_json::json!({
"route": value["data"]["items"][0]["route"],
"serviceTier": value["data"]["items"][0]["serviceTier"],
"authenticationKind": value["data"]["items"][0]["authenticationKind"],
"imageInputTokens": value["data"]["items"][0]["tokenDetails"]["imageInputTokens"],
"imageOutputTokens": value["data"]["items"][0]["tokenDetails"]["imageOutputTokens"],
"websocketPool": value["data"]["items"][0]["metadata"]["websocketPool"],
"imageGenerationRequested": value["data"]["items"][0]["metadata"]["imageGenerationRequested"],
"imageGenerationSucceeded": value["data"]["items"][0]["metadata"]["imageGenerationSucceeded"],
"requestedModel": value["data"]["items"][0]["metadata"]["requestedModel"],
"upstreamModel": value["data"]["items"][0]["metadata"]["upstreamModel"],
"clientIp": value["data"]["items"][0]["metadata"]["clientIp"],
"userAgent": value["data"]["items"][0]["metadata"]["userAgent"],
"reasoningEffort": value["data"]["items"][0]["metadata"]["reasoningEffort"],
"reasoningPreset": value["data"]["items"][0]["metadata"]["reasoningPreset"],
"requestKind": value["data"]["items"][0]["metadata"]["requestKind"],
"subagentKind": value["data"]["items"][0]["metadata"]["subagentKind"],
"compact": value["data"]["items"][0]["metadata"]["compact"],
"transport": value["data"]["items"][0]["metadata"]["transport"],
"transportDecisionWaitMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["transportDecisionWaitMs"],
"wsConnectMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["wsConnectMs"],
"firstReasoningMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["firstReasoningMs"],
"firstTextMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["firstTextMs"],
"firstTokenMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["firstTokenMs"],
"effectiveModel": value["data"]["items"][0]["metadata"]["effectiveModel"],
"requestSummary": value["data"]["items"][0]["metadata"]["requestSummary"],
}),
serde_json::json!({
"route": "/v1/responses",
"serviceTier": "priority",
"authenticationKind": "oauth",
"imageInputTokens": 31,
"imageOutputTokens": 9,
"websocketPool": {"kind": "reuse"},
"imageGenerationRequested": true,
"imageGenerationSucceeded": true,
"requestedModel": "grok-4.5",
"upstreamModel": "grok-4.5",
"clientIp": "127.0.0.1",
"userAgent": "usage-metadata-test",
"reasoningEffort": "max",
"reasoningPreset": "ultra",
"requestKind": "review",
"subagentKind": "worker",
"compact": true,
"transport": "http_sse",
"transportDecisionWaitMs": 7,
"wsConnectMs": 11,
"firstReasoningMs": 19,
"firstTextMs": 23,
"firstTokenMs": 19,
"effectiveModel": "grok-4.5",
"requestSummary": {"inputItemsCount": 2},
})
);
}
@@ -0,0 +1,226 @@
use axum::{
Router,
body::{Body, to_bytes},
http::{Method, Request, StatusCode, header},
};
use gateway_api::admin::settings::{self, UpdateRuntimeSettingsRequest};
use serde_json::{Value, json};
use tower::ServiceExt;
use super::{AdminTestFixture, AdminTestState};
fn app(state: AdminTestState) -> Router {
settings::router::<AdminTestState>().with_state(state)
}
fn request(method: Method, path: &str, body: Option<Value>) -> Request<Body> {
let mut builder = Request::builder()
.method(method)
.uri(path)
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header("x-request-id", "req_admin_settings");
let body = if let Some(value) = body {
builder = builder.header(header::CONTENT_TYPE, "application/json");
Body::from(value.to_string())
} else {
Body::empty()
};
builder.body(body).expect("build settings request")
}
async fn response_json(response: axum::response::Response) -> Value {
let bytes = to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("read response body");
serde_json::from_slice(&bytes).expect("parse response JSON")
}
fn update_body() -> Value {
json!({
"modelMappings": {
"gpt-5.4": "gpt-5.5",
"grok-latest": "grok-4.5"
},
"refreshMarginSeconds": 1800,
"refreshConcurrency": 4,
"maxConcurrentPerAccount": 5,
"requestIntervalMs": 25,
"rotationStrategy": "round_robin",
"usageRetentionDays": 32,
"opsEventRetentionDays": 31,
"auditRetentionDays": 91
})
}
#[test]
fn settings_request_should_reject_unknown_rotation_strategy() {
let mut body = update_body();
body["rotationStrategy"] = json!("random");
let request: UpdateRuntimeSettingsRequest =
serde_json::from_value(body).expect("decode settings");
assert_eq!(request.validate().unwrap_err().field(), "rotationStrategy");
}
#[test]
fn settings_request_should_reject_unknown_revision_field() {
let mut body = update_body();
body["expectedConfigRevision"] = json!(7);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
}
#[test]
fn settings_request_should_reject_removed_bucket_retention() {
let mut body = update_body();
body["bucketRetentionDays"] = json!(365);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
}
#[tokio::test]
async fn settings_get_should_preserve_global_model_mappings() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let response = app(fixture.state())
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.expect("settings response");
let data = response_json(response).await["data"].clone();
assert_eq!(
(
data["modelMappings"]["coding-default"].as_str(),
data["modelMappings"]["grok-latest"].as_str(),
data["rotationStrategy"].as_str()
),
(Some("gpt-5.4"), Some("grok-4.5"), Some("smart"))
);
}
#[tokio::test]
async fn settings_post_should_replace_global_model_mappings() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(update_body()),
))
.await
.expect("settings update response");
let data = response_json(response).await["data"].clone();
assert!(data.get("configRevision").is_none());
assert_eq!(data["modelMappings"]["gpt-5.4"], "gpt-5.5");
assert_eq!(data["modelMappings"]["grok-latest"], "grok-4.5");
}
#[test]
fn settings_request_should_reject_invalid_model_mapping_name() {
let mut body = update_body();
body["modelMappings"] = json!({ "\0": "gpt-5.5" });
let request: UpdateRuntimeSettingsRequest =
serde_json::from_value(body).expect("decode settings");
assert_eq!(request.validate().unwrap_err().field(), "modelMappings");
}
#[tokio::test]
async fn settings_should_require_admin_auth() {
let fixture = AdminTestFixture::new().await;
let response = app(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/settings")
.header("x-request-id", "req_unauthorized")
.body(Body::empty())
.expect("unauthorized request"),
)
.await
.expect("unauthorized response");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn admin_key_should_return_secret_only_on_regenerate() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/admin-api-key/regenerate",
None,
))
.await
.expect("regenerate response");
let data = response_json(response).await["data"].clone();
assert!(
data["key"]
.as_str()
.is_some_and(|key| key.starts_with("admin-") && key.len() == 70)
);
}
#[tokio::test]
async fn admin_key_delete_should_use_fixed_post_path() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
fixture.settings.set_api_key("admin-valid-test-key");
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/admin-api-key/delete",
None,
))
.await
.expect("delete response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn settings_should_accept_admin_api_key_header() {
let fixture = AdminTestFixture::new().await;
let key = format!("admin-{}", "a".repeat(64));
fixture.auth.set_api_key(&key);
let response = app(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/settings")
.header("x-api-key", key)
.header("x-request-id", "req_api_key")
.body(Body::empty())
.expect("api key request"),
)
.await
.expect("api key response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn admin_auth_should_accept_a_configured_request_id_header_name() {
use axum::http::HeaderName;
use tower_http::request_id::{MakeRequestUuid, SetRequestIdLayer};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
// 部署把 api.request_id_header 改名后,注入的 header 不再叫 x-request-id;
// 管理请求仍须拿到请求上下文,而不是退化为 500。
let custom = HeaderName::from_static("x-trace-id");
let app = app(fixture.state()).layer(SetRequestIdLayer::new(custom, MakeRequestUuid));
let unlabelled = Request::builder()
.method(Method::GET)
.uri("/api/admin/settings")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.body(Body::empty())
.expect("build settings request");
let response = app.oneshot(unlabelled).await.expect("settings response");
assert_eq!(response.status(), StatusCode::OK);
}
@@ -0,0 +1,119 @@
use std::sync::Arc;
use async_trait::async_trait;
use axum::{
Router,
body::{Body, to_bytes},
http::{Request, header},
};
use chrono::Utc;
use futures::stream;
use gateway_admin::{
model::system::{
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel,
SystemUpdateStatus, SystemVersion,
},
ports::system::{SystemOperationError, SystemOperations, SystemUpdateEventStream},
};
use gateway_api::admin::system::{self, UpdateDetailQuery, UpdateRequest};
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState, unavailable_system};
#[test]
fn update_detail_query_should_default_to_no_refresh_and_reject_unknown_fields() {
let query: UpdateDetailQuery = serde_json::from_str("{}").expect("empty query object");
assert!(!query.refresh());
assert!(serde_json::from_str::<UpdateDetailQuery>(r#"{"refresh":true,"extra":1}"#).is_err());
}
#[test]
fn update_request_should_preserve_target_version_for_domain_validation() {
let request: UpdateRequest =
serde_json::from_str(r#"{"targetVersion":"v0.2.0"}"#).expect("update request");
assert_eq!(request.into_target_version(), "v0.2.0");
assert!(
serde_json::from_str::<UpdateRequest>(r#"{"targetVersion":"v0.2.0","extra":1}"#).is_err()
);
}
#[tokio::test]
async fn update_event_stream_should_preserve_event_id_and_download_progress_percent() {
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem)).await;
fixture.auth.insert_session("valid-session");
let response = app(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/system/update/events")
.header(header::COOKIE, "cpr_admin_session=valid-session")
.header("x-request-id", "req_system_update_events")
.body(Body::empty())
.expect("update event request"),
)
.await
.expect("update event response");
let body = to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("update event body");
let body = String::from_utf8(body.to_vec()).expect("UTF-8 event stream");
assert!(
body.contains(r#""id":"update-progress-1""#),
"unexpected update event stream: {body}"
);
assert!(
body.contains(r#""progressPercent":40"#),
"unexpected update event stream: {body}"
);
}
fn app(state: AdminTestState) -> Router {
system::router::<AdminTestState>().with_state(state)
}
struct ProgressSystem;
#[async_trait]
impl SystemOperations for ProgressSystem {
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
Err(unavailable_system())
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
Err(unavailable_system())
}
fn update_events(&self) -> SystemUpdateEventStream {
Box::pin(stream::once(async {
SystemUpdateEvent {
id: "update-progress-1".to_owned(),
operation_id: Some("update-1".to_owned()),
level: SystemUpdateEventLevel::Info,
step: Some("download".to_owned()),
message: "已下载 4.0 MiB / 10.0 MiB (40%)".to_owned(),
terminal: true,
progress_percent: Some(40),
occurred_at: Utc::now(),
}
}))
}
async fn perform_update(
&self,
_: Option<String>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
Err(unavailable_system())
}
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
}
@@ -0,0 +1,229 @@
mod common {
use gateway_api::admin::PageMeta;
use serde_json::json;
#[test]
fn page_meta_should_keep_the_existing_camel_case_wire_shape() {
let value = serde_json::to_value(PageMeta::new(2, 10, 21, 3))
.expect("page metadata should serialize");
assert_eq!(
value,
json!({
"page": 2,
"pageSize": 10,
"total": 21,
"totalPages": 3
})
);
}
}
mod error {
use gateway_api::admin::{AdminErrorBody, AdminErrorCode};
use serde_json::json;
#[test]
fn error_codes_should_keep_all_published_numeric_values() {
let actual = [
AdminErrorCode::MALFORMED_JSON,
AdminErrorCode::BAD_REQUEST,
AdminErrorCode::INVALID_TIME_RANGE,
AdminErrorCode::INVALID_MODEL_SOURCE,
AdminErrorCode::SESSION_REQUIRED,
AdminErrorCode::INVALID_CREDENTIALS,
AdminErrorCode::INVALID_API_KEY,
AdminErrorCode::NOT_FOUND,
AdminErrorCode::CONFLICT,
AdminErrorCode::TOO_MANY_LOGIN_ATTEMPTS,
AdminErrorCode::SETTINGS_PERSIST,
AdminErrorCode::INTERNAL,
AdminErrorCode::USAGE_RECORD_ACCOUNTS,
AdminErrorCode::BAD_GATEWAY,
AdminErrorCode::SERVICE_UNAVAILABLE,
]
.map(AdminErrorCode::value);
assert_eq!(
actual,
[
40000, 40001, 40002, 40003, 40101, 40102, 40103, 40401, 40901, 42901, 50000, 50001,
50002, 50201, 50301,
]
);
}
#[test]
fn invalid_admin_api_key_should_keep_code_and_null_data() {
let value = serde_json::to_value(AdminErrorBody::new(
AdminErrorCode::INVALID_API_KEY,
"Invalid admin API key",
))
.expect("admin error body should serialize");
assert_eq!(
value,
json!({
"code": 40103,
"message": "Invalid admin API key",
"data": null
})
);
}
}
mod response {
use axum::{
body::to_bytes,
http::StatusCode,
response::{IntoResponse, Response},
};
use gateway_api::admin::{AdminEnvelope, AdminError, AdminPageData, AdminResponse, PageMeta};
use serde_json::json;
#[test]
fn success_envelope_should_keep_the_existing_wire_shape() {
let value = serde_json::to_value(AdminEnvelope::ok(json!({ "id": "resource_1" })))
.expect("admin success envelope should serialize");
assert_eq!(
value,
json!({
"code": 200,
"message": "OK",
"data": { "id": "resource_1" }
})
);
}
#[test]
fn page_envelope_should_keep_items_and_page_nested_under_data() {
let data = AdminPageData::new(
vec![json!({ "id": "resource_1" })],
PageMeta::new(2, 10, 21, 3),
);
let value = serde_json::to_value(AdminEnvelope::ok(data))
.expect("admin page envelope should serialize");
assert_eq!(
value,
json!({
"code": 200,
"message": "OK",
"data": {
"items": [{ "id": "resource_1" }],
"page": {
"page": 2,
"pageSize": 10,
"total": 21,
"totalPages": 3
}
}
})
);
}
async fn response_json(response: Response) -> serde_json::Value {
let body = to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("read admin response body");
serde_json::from_slice(&body).expect("parse admin response body")
}
#[tokio::test]
async fn admin_response_should_keep_http_status_independent_from_envelope_code() {
let response: Response = AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(json!({ "id": "resource_created" })),
)
.into_response();
let status = response.status();
let body = response_json(response).await;
assert_eq!(
(status, body["code"].as_u64()),
(StatusCode::CREATED, Some(200))
);
}
#[tokio::test]
async fn invalid_admin_api_key_should_keep_its_published_wire_contract() {
let response = AdminError::invalid_admin_api_key().into_response();
let status = response.status();
let body = response_json(response).await;
assert_eq!(
(status, body),
(
StatusCode::UNAUTHORIZED,
json!({
"code": 40103,
"message": "Invalid admin API key",
"data": null
})
)
);
}
#[tokio::test]
async fn admin_error_variants_should_use_stable_http_and_null_data_contract() {
let cases = [
(
AdminError::bad_request("bad input"),
StatusCode::BAD_REQUEST,
40001,
),
(
AdminError::admin_session_required(),
StatusCode::UNAUTHORIZED,
40101,
),
(
AdminError::invalid_admin_credentials(),
StatusCode::UNAUTHORIZED,
40102,
),
(
AdminError::too_many_login_attempts(),
StatusCode::TOO_MANY_REQUESTS,
42901,
),
(
AdminError::conflict("conflict"),
StatusCode::CONFLICT,
40901,
),
(
AdminError::not_found("missing"),
StatusCode::NOT_FOUND,
40401,
),
(
AdminError::internal("internal"),
StatusCode::INTERNAL_SERVER_ERROR,
50001,
),
(
AdminError::bad_gateway("upstream"),
StatusCode::BAD_GATEWAY,
50201,
),
(
AdminError::service_unavailable("storage"),
StatusCode::SERVICE_UNAVAILABLE,
50301,
),
];
for (error, expected_status, expected_code) in cases {
let response = error.into_response();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, expected_status);
assert_eq!(body["code"], expected_code);
assert!(body["message"].is_string());
assert!(body["data"].is_null());
}
}
}
@@ -0,0 +1,150 @@
use std::{fs, path::Path};
#[test]
fn manifest_should_depend_only_on_core_protocol_and_http_adapter_layers() {
let manifest = include_str!("../Cargo.toml");
let forbidden = [
"codex-proxy-rs",
"gateway-store",
"provider-",
"redis",
"reqwest",
"sqlx",
];
assert!(
forbidden
.iter()
.all(|dependency| !manifest.contains(dependency)),
"gateway-api manifest contains an infrastructure/provider dependency"
);
}
#[test]
fn workspace_should_include_gateway_api() {
let workspace = include_str!("../../../Cargo.toml");
assert!(workspace.contains("\"crates/gateway-api\""));
}
#[test]
fn source_tree_should_match_frozen_machine_manifest() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut actual = rust_files(&root.join("src"));
actual.sort();
let mut expected = vec![
"src/admin/accounts.rs",
"src/admin/auth.rs",
"src/admin/client_keys.rs",
"src/admin/mod.rs",
"src/admin/observability.rs",
"src/admin/settings.rs",
"src/admin/system.rs",
"src/admin/wire.rs",
"src/health.rs",
"src/lib.rs",
"src/openai/auth.rs",
"src/openai/error.rs",
"src/openai/mod.rs",
"src/openai/models.rs",
"src/openai/responses/error.rs",
"src/openai/responses/http.rs",
"src/openai/responses/mod.rs",
"src/openai/responses/request.rs",
"src/openai/responses/response.rs",
"src/openai/responses/websocket.rs",
"src/openai/router.rs",
"src/openai/service.rs",
];
expected.sort_unstable();
assert_eq!(actual, expected);
}
#[test]
fn test_tree_should_match_frozen_rust_mirror() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut actual = rust_files(&root.join("tests"));
actual.sort();
let mut expected = vec![
"tests/admin/accounts.rs",
"tests/admin/auth.rs",
"tests/admin/client_keys.rs",
"tests/admin/mod.rs",
"tests/admin/observability.rs",
"tests/admin/settings.rs",
"tests/admin/system.rs",
"tests/admin/wire.rs",
"tests/architecture.rs",
"tests/health.rs",
"tests/main.rs",
"tests/openai/auth.rs",
"tests/openai/error.rs",
"tests/openai/mod.rs",
"tests/openai/models.rs",
"tests/openai/responses/http.rs",
"tests/openai/responses/mod.rs",
"tests/openai/responses/websocket.rs",
"tests/openai/router.rs",
];
expected.sort_unstable();
assert_eq!(actual, expected);
}
#[test]
fn frozen_openai_fixture_set_should_be_complete() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/openai/responses/fixtures");
let files = all_files(&root);
assert_eq!(files.len(), 69);
}
#[test]
fn admin_routes_should_use_only_get_post_and_static_paths() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/admin");
let mut combined = String::new();
for path in all_files(&root) {
if path.extension().and_then(|value| value.to_str()) == Some("rs") {
combined.push_str(&fs::read_to_string(path).expect("read admin source"));
}
}
assert!(
!combined.contains("routing::put")
&& !combined.contains("routing::patch")
&& !combined.contains("routing::delete")
&& !combined.contains("/:id")
&& !combined.contains("/{id}")
);
}
fn rust_files(root: &Path) -> Vec<String> {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
all_files(root)
.into_iter()
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("rs"))
.map(|path| {
path.strip_prefix(manifest)
.expect("path below manifest")
.to_string_lossy()
.replace('\\', "/")
})
.collect()
}
fn all_files(root: &Path) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(path) = pending.pop() {
for entry in fs::read_dir(path).expect("read architecture directory") {
let path = entry.expect("read architecture entry").path();
if path.is_dir() {
pending.push(path);
} else {
files.push(path);
}
}
}
files
}
@@ -0,0 +1,52 @@
use std::sync::Arc;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use futures::future::BoxFuture;
use gateway_core::{
engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution,
StartedExecution,
},
error::GatewayError,
routing::PublicModelId,
};
use tower::ServiceExt;
#[tokio::test]
async fn healthz_should_return_no_content_when_all_inputs_are_healthy() {
let response = crate::openai::api_router(Arc::new(UnusedExecution))
.await
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.expect("health request"),
)
.await
.expect("health response");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
struct UnusedExecution;
impl ExecutionService for UnusedExecution {
fn authenticate(&self, _: &str) -> Result<AuthenticatedClient, ClientAuthenticationError> {
unreachable!("health check does not authenticate")
}
fn public_models(&self, _: &AuthenticatedClient) -> Vec<PublicModelId> {
unreachable!("health check does not list models")
}
fn contains_public_model(&self, _: &AuthenticatedClient, _: &PublicModelId) -> bool {
unreachable!("health check does not inspect models")
}
fn start(&self, _: StartExecution) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async { unreachable!("health check does not execute requests") })
}
}
+4
View File
@@ -0,0 +1,4 @@
mod admin;
mod architecture;
mod health;
mod openai;
@@ -0,0 +1,90 @@
use axum::http::{HeaderMap, HeaderValue, header::AUTHORIZATION};
use gateway_api::openai::auth::{ClientApiKeyAuthError, bearer_client_api_key};
#[test]
fn bearer_client_api_key_should_reject_missing_authorization() {
assert_eq!(
bearer_client_api_key(&HeaderMap::new()),
Err(ClientApiKeyAuthError::MissingAuthorization)
);
}
#[test]
fn bearer_client_api_key_should_reject_non_utf8_authorization() {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_bytes(&[0xff]).expect("opaque header value"),
);
assert_eq!(
bearer_client_api_key(&headers),
Err(ClientApiKeyAuthError::MalformedAuthorization)
);
}
#[test]
fn bearer_client_api_key_should_require_exact_bearer_scheme() {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_static("bearer sk_client"));
assert_eq!(
bearer_client_api_key(&headers),
Err(ClientApiKeyAuthError::MalformedAuthorization)
);
}
#[test]
fn bearer_client_api_key_should_reject_empty_bearer_token() {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer "));
assert_eq!(
bearer_client_api_key(&headers),
Err(ClientApiKeyAuthError::MalformedAuthorization)
);
}
#[test]
fn bearer_client_api_key_should_reject_non_gateway_key_prefix() {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer xai-secret"));
assert_eq!(
bearer_client_api_key(&headers),
Err(ClientApiKeyAuthError::InvalidKeyFormat)
);
}
#[test]
fn bearer_client_api_key_should_return_trimmed_gateway_key() {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer sk_client_secret "),
);
assert_eq!(bearer_client_api_key(&headers), Ok("sk_client_secret"));
}
#[test]
fn client_auth_failure_reasons_should_be_stable_and_secret_free() {
assert_eq!(
[
ClientApiKeyAuthError::MissingAuthorization,
ClientApiKeyAuthError::MalformedAuthorization,
ClientApiKeyAuthError::InvalidKeyFormat,
ClientApiKeyAuthError::InvalidKey,
ClientApiKeyAuthError::RuntimeUnavailable,
]
.map(ClientApiKeyAuthError::reason),
[
"missing_authorization",
"malformed_authorization",
"invalid_key_format",
"invalid_key",
"runtime_unavailable",
]
);
}
@@ -0,0 +1,354 @@
use axum::{body::to_bytes, http::StatusCode};
use gateway_core::engine::{EngineError, UpstreamSendState};
use gateway_core::error::{
ClientVisibleUpstreamError, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind,
StoreError, StoreErrorKind,
};
use gateway_api::openai::error::{
gateway_error_contract, gateway_error_from_engine, gateway_error_response,
openai_error_response,
};
#[test]
fn invalid_request_error_should_map_to_openai_bad_request() {
assert_eq!(
gateway_error_contract(GatewayErrorKind::InvalidRequest),
(
StatusCode::BAD_REQUEST,
"invalid_request_error",
"invalid_request",
)
);
}
#[test]
fn unsupported_error_should_map_to_openai_bad_request() {
assert_eq!(
gateway_error_contract(GatewayErrorKind::Unsupported),
(
StatusCode::BAD_REQUEST,
"invalid_request_error",
"unsupported_capability",
)
);
}
#[test]
fn model_not_found_error_should_map_to_openai_model_not_found() {
assert_eq!(
gateway_error_contract(GatewayErrorKind::ModelNotFound),
(
StatusCode::NOT_FOUND,
"invalid_request_error",
"model_not_found",
)
);
}
#[test]
fn no_available_provider_error_should_map_to_service_unavailable() {
assert_eq!(
gateway_error_contract(GatewayErrorKind::NoAvailableProvider),
(
StatusCode::SERVICE_UNAVAILABLE,
"server_error",
"no_available_provider",
)
);
}
#[test]
fn rate_limited_error_should_map_to_openai_retryable_status() {
assert_eq!(
gateway_error_contract(GatewayErrorKind::RateLimited),
(
StatusCode::TOO_MANY_REQUESTS,
"rate_limit_error",
"rate_limit_exceeded",
)
);
}
#[test]
fn upstream_unavailable_error_should_map_to_bad_gateway() {
assert_eq!(
gateway_error_contract(GatewayErrorKind::UpstreamUnavailable),
(
StatusCode::BAD_GATEWAY,
"server_error",
"upstream_unavailable",
)
);
}
#[test]
fn timeout_error_should_map_to_gateway_timeout() {
assert_eq!(
gateway_error_contract(GatewayErrorKind::Timeout),
(
StatusCode::GATEWAY_TIMEOUT,
"server_error",
"request_timeout",
)
);
}
#[test]
fn cancelled_error_should_not_be_misclassified_as_upstream_failure() {
assert_eq!(
gateway_error_contract(GatewayErrorKind::Cancelled),
(
StatusCode::REQUEST_TIMEOUT,
"server_error",
"request_cancelled",
)
);
}
#[test]
fn engine_provider_error_should_preserve_retry_classification() {
let error = EngineError::Provider(ProviderError::new(
ProviderErrorKind::RateLimited,
UpstreamSendState::Sent,
));
assert_eq!(
gateway_error_from_engine(&error).kind(),
GatewayErrorKind::RateLimited
);
}
#[test]
fn local_provider_capacity_exhaustion_should_map_to_service_unavailable() {
let error = EngineError::Provider(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
));
assert_eq!(
gateway_error_from_engine(&error),
GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no upstream provider is currently available for this request"
)
);
}
#[test]
fn local_provider_capacity_exhaustion_should_preserve_safe_provider_detail() {
let detail = ClientVisibleUpstreamError::new(
"no account is eligible for the requested model",
Some("no_eligible_account".to_owned()),
Some("account_unavailable_error".to_owned()),
);
let error = EngineError::Provider(
ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
.with_client_visible_upstream_error(detail),
);
let gateway = gateway_error_from_engine(&error);
assert_eq!(gateway.kind(), GatewayErrorKind::NoAvailableProvider);
assert_eq!(
gateway.client_message(),
"no account is eligible for the requested model"
);
assert_eq!(gateway.client_error_code(), Some("no_eligible_account"));
assert_eq!(
gateway.client_error_type(),
Some("account_unavailable_error")
);
assert_eq!(
gateway.safe_message(),
"no upstream provider is currently available for this request"
);
}
#[test]
fn no_eligible_provider_account_should_map_to_service_unavailable() {
let error = EngineError::Provider(ProviderError::new(
ProviderErrorKind::NoEligibleAccount,
UpstreamSendState::NotSent,
));
assert_eq!(
gateway_error_from_engine(&error),
GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no upstream provider is currently available for this request"
)
);
}
#[test]
fn sent_provider_unavailability_should_remain_bad_gateway() {
let error = EngineError::Provider(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::Sent,
));
assert_eq!(
gateway_error_from_engine(&error).kind(),
GatewayErrorKind::UpstreamUnavailable
);
}
#[test]
fn engine_provider_invalid_request_should_remain_a_client_request_error() {
let error = EngineError::Provider(ProviderError::new(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::Sent,
));
assert_eq!(
gateway_error_from_engine(&error).kind(),
GatewayErrorKind::InvalidRequest
);
}
#[test]
fn engine_provider_unsupported_capability_should_remain_unsupported() {
let error = EngineError::Provider(ProviderError::new(
ProviderErrorKind::Unsupported,
UpstreamSendState::NotSent,
));
assert_eq!(
gateway_error_from_engine(&error).kind(),
GatewayErrorKind::Unsupported
);
}
#[test]
fn engine_provider_credential_failures_should_not_impersonate_client_auth_failures() {
for kind in [
ProviderErrorKind::Unauthorized,
ProviderErrorKind::PermissionDenied,
] {
let error = EngineError::Provider(ProviderError::new(kind, UpstreamSendState::Sent));
let mapped = gateway_error_from_engine(&error);
assert_eq!(mapped.kind(), GatewayErrorKind::UpstreamUnavailable);
assert_eq!(
mapped.safe_message(),
"upstream authentication resource is unavailable"
);
}
}
#[test]
fn engine_provider_quota_exhaustion_should_use_the_retryable_capacity_contract() {
let error = EngineError::Provider(ProviderError::new(
ProviderErrorKind::QuotaExhausted,
UpstreamSendState::Sent,
));
assert_eq!(
gateway_error_from_engine(&error).kind(),
GatewayErrorKind::RateLimited
);
}
#[test]
fn engine_provider_timeout_and_cancellation_should_remain_distinct() {
let timeout = EngineError::Provider(ProviderError::new(
ProviderErrorKind::Timeout,
UpstreamSendState::Ambiguous,
));
let cancelled = EngineError::Provider(ProviderError::new(
ProviderErrorKind::Cancelled,
UpstreamSendState::Sent,
));
assert_eq!(
(
gateway_error_from_engine(&timeout).kind(),
gateway_error_from_engine(&cancelled).kind(),
),
(GatewayErrorKind::Timeout, GatewayErrorKind::Cancelled)
);
}
#[test]
fn engine_provider_runtime_failures_should_collapse_to_safe_unavailability() {
for kind in [
ProviderErrorKind::Transport,
ProviderErrorKind::Protocol,
ProviderErrorKind::Unavailable,
ProviderErrorKind::ProcessTerminated,
] {
let error = EngineError::Provider(ProviderError::new(kind, UpstreamSendState::Ambiguous));
assert_eq!(
gateway_error_from_engine(&error),
GatewayError::new(
GatewayErrorKind::UpstreamUnavailable,
"upstream service is unavailable"
)
);
}
}
#[test]
fn engine_store_error_should_collapse_to_safe_internal_error() {
let error = EngineError::Store(StoreError::new(StoreErrorKind::Unavailable));
assert_eq!(
gateway_error_from_engine(&error),
GatewayError::new(GatewayErrorKind::Internal, "gateway execution failed")
);
}
#[test]
fn openai_error_response_should_preserve_only_safe_contract_fields() {
let (status, body) = openai_error_response(
StatusCode::BAD_GATEWAY,
"upstream service is unavailable",
"server_error",
"upstream_unavailable",
);
assert_eq!(
(status, body.0),
(
StatusCode::BAD_GATEWAY,
serde_json::json!({
"error": {
"message": "upstream service is unavailable",
"type": "server_error",
"code": "upstream_unavailable"
}
}),
)
);
}
#[tokio::test]
async fn gateway_error_response_should_expose_only_structured_client_visible_upstream_fields() {
let error = GatewayError::from_provider(
&ProviderError::new(ProviderErrorKind::QuotaExhausted, UpstreamSendState::Sent)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"Your Codex quota is exhausted",
Some("quota_exhausted".to_owned()),
Some("rate_limit_error".to_owned()),
)),
);
let response = gateway_error_response(&error);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read OpenAI error body");
let body: serde_json::Value = serde_json::from_slice(&body).expect("OpenAI error JSON");
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
body,
serde_json::json!({
"error": {
"message": "Your Codex quota is exhausted",
"type": "rate_limit_error",
"code": "quota_exhausted"
}
})
);
}
@@ -0,0 +1,274 @@
mod auth;
mod error;
mod models;
mod responses;
mod router;
use std::collections::BTreeSet;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use futures::future::BoxFuture;
use gateway_core::engine::admission::{
ClientAdmissionDecision, ClientAdmissionError, ClientAdmissionPort, ClientAdmissionRecovery,
ClientAdmissionRequest, ClientAdmissionRestoreResult,
};
use gateway_core::engine::continuation::{
NativeContinuationPin, NativeContinuationPort, NativeContinuationStoreError, PreviousResponseId,
};
use gateway_core::engine::credential::{AccountSelectionPolicy, RotationStrategy};
use gateway_core::engine::execution::{
AuthenticatedClient, ClientApiKeyUsageSink, DefaultExecutionService, ExecutionService,
ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
};
use gateway_core::engine::provider::ProviderRegistry;
use gateway_core::engine::{
AttemptRecord, ExecutionStore, IntermediateFailure, ModelRequestFinalization, ModelRequestId,
NewModelRequest, RecoveryReport, UpstreamSendState,
};
use gateway_core::error::StoreError;
use gateway_core::health::{WorkerHealthSnapshot, WorkerHealthSource};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
use gateway_core::routing::snapshot::RuntimeSnapshotHandle;
use gateway_core::routing::{
ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, RuntimeSnapshot,
UpstreamModelId,
};
pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Router {
api_router_with_origins(execution, Vec::new()).await
}
pub(super) async fn api_router_with_origins(
execution: Arc<dyn ExecutionService>,
cors_allowed_origins: Vec<String>,
) -> axum::Router {
let admin = crate::admin::AdminTestFixture::new().await;
gateway_api::initialize(
gateway_api::ApiConfig {
asset_directory: std::env::temp_dir(),
cors_allowed_origins,
request_timeout_seconds: None,
request_id_header: "x-request-id".to_owned(),
},
execution,
admin.services,
Vec::new(),
Arc::new(EmptyWorkerHealth),
Arc::new(TestLifecycle::default()),
)
.expect("API bundle")
.router()
}
pub(super) fn authenticated_client(plaintext: &str) -> AuthenticatedClient {
authenticated_client_for_provider(plaintext, "openai")
}
pub(super) fn authenticated_client_for_provider(
plaintext: &str,
provider_name: &str,
) -> AuthenticatedClient {
let source = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot(plaintext, provider_name)),
Arc::new(UnusedExecutionStore),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(IgnoredClientApiKeyUsage),
);
source
.authenticate(plaintext)
.expect("authenticated client")
}
struct IgnoredClientApiKeyUsage;
impl ClientApiKeyUsageSink for IgnoredClientApiKeyUsage {
fn record_used(&self, _: &ClientApiKeyId) {}
}
fn snapshot(plaintext: &str, provider_name: &str) -> RuntimeSnapshot {
let provider = ProviderKind::new(provider_name).expect("provider");
let capabilities = ModelCapabilities::new(
BTreeSet::from([gateway_core::operation::OperationKind::Generate]),
Some(16_000),
);
RuntimeSnapshot::new(
ConfigRevision::new(1).expect("revision"),
AccountSelectionPolicy::new(
RotationStrategy::Smart,
NonZeroU32::new(2).expect("concurrency"),
Duration::from_millis(1),
),
vec![provider.clone()],
["model-a", "model-b"]
.into_iter()
.map(|model| {
ProviderModel::new(
provider.clone(),
UpstreamModelId::new(model).expect("model"),
capabilities.clone(),
)
})
.collect(),
vec![ClientPolicy::new(
ClientApiKeyId::new("key_api_test").expect("key ID"),
PlaintextClientApiKey::new(plaintext).expect("plaintext key"),
provider,
true,
RateLimits::unlimited(),
)],
)
.expect("runtime snapshot")
}
#[derive(Default)]
struct TestLifecycle {
cancellation: gateway_core::engine::CancellationToken,
}
struct TestConnectionGuard;
impl ConnectionGuard for TestConnectionGuard {}
impl ConnectionLifecycle for TestLifecycle {
fn try_register(&self) -> Result<Box<dyn ConnectionGuard>, ConnectionDraining> {
Ok(Box::new(TestConnectionGuard))
}
fn cancellation(&self) -> gateway_core::engine::CancellationToken {
self.cancellation.clone()
}
fn is_draining(&self) -> bool {
self.cancellation.is_cancelled()
}
}
struct EmptyWorkerHealth;
impl WorkerHealthSource for EmptyWorkerHealth {
fn snapshot(&self) -> Vec<WorkerHealthSnapshot> {
Vec::new()
}
}
struct UnusedExecutionStore;
#[async_trait]
impl ExecutionStore for UnusedExecutionStore {
async fn create_model_request(&self, _: NewModelRequest) -> Result<(), StoreError> {
unreachable!("authentication fixture does not execute")
}
async fn record_attempt(&self, _: AttemptRecord) -> Result<(), StoreError> {
unreachable!("authentication fixture does not execute")
}
async fn mark_send_state(
&self,
_: &ModelRequestId,
_: UpstreamSendState,
) -> Result<(), StoreError> {
unreachable!("authentication fixture does not execute")
}
async fn mark_downstream_committed(
&self,
_: &ModelRequestId,
_: SystemTime,
_: Option<u16>,
) -> Result<(), StoreError> {
unreachable!("authentication fixture does not execute")
}
async fn record_client_status(&self, _: &ModelRequestId, _: u16) -> Result<(), StoreError> {
unreachable!("authentication fixture does not execute")
}
async fn record_intermediate_failure(&self, _: IntermediateFailure) -> Result<(), StoreError> {
unreachable!("authentication fixture does not execute")
}
async fn finalize_model_request(&self, _: ModelRequestFinalization) -> Result<(), StoreError> {
unreachable!("authentication fixture does not execute")
}
async fn recover_expired(&self, _: SystemTime) -> Result<RecoveryReport, StoreError> {
unreachable!("authentication fixture does not execute")
}
}
struct UnusedAdmissions;
impl ClientAdmissionPort for UnusedAdmissions {
fn admit(
&self,
_: ClientAdmissionRequest,
) -> BoxFuture<'_, Result<ClientAdmissionDecision, ClientAdmissionError>> {
Box::pin(async { unreachable!("authentication fixture does not execute") })
}
fn release<'a>(
&'a self,
_: &'a ClientApiKeyId,
_: &'a ModelRequestId,
) -> BoxFuture<'a, Result<bool, ClientAdmissionError>> {
Box::pin(async { unreachable!("authentication fixture does not execute") })
}
fn restore(
&self,
_: ClientAdmissionRecovery,
) -> BoxFuture<'_, Result<ClientAdmissionRestoreResult, ClientAdmissionError>> {
Box::pin(async { unreachable!("authentication fixture does not execute") })
}
}
struct UnusedCircuits;
impl ProviderCircuitPort for UnusedCircuits {
fn decision<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<ProviderCircuitDecision, ProviderCircuitError>> {
Box::pin(async { unreachable!("authentication fixture does not execute") })
}
fn observe_failure<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async { unreachable!("authentication fixture does not execute") })
}
fn observe_success<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async { unreachable!("authentication fixture does not execute") })
}
}
struct UnusedContinuation;
impl NativeContinuationPort for UnusedContinuation {
fn resolve<'a>(
&'a self,
_: &'a PreviousResponseId,
) -> BoxFuture<'a, Result<Option<NativeContinuationPin>, NativeContinuationStoreError>> {
Box::pin(async { unreachable!("authentication fixture does not execute") })
}
fn record<'a>(
&'a self,
_: NativeContinuationPin,
) -> BoxFuture<'a, Result<(), NativeContinuationStoreError>> {
Box::pin(async { unreachable!("authentication fixture does not execute") })
}
}
@@ -0,0 +1,304 @@
use std::sync::Arc;
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header::AUTHORIZATION},
};
use futures::future::BoxFuture;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution,
StartedExecution,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::routing::{
ModelPresentation, ModelServiceTier, PublicModelId, PublicModelProfile,
};
use tower::ServiceExt;
use super::{api_router, authenticated_client};
pub(super) struct ModelsExecution {
client: AuthenticatedClient,
profiles: bool,
}
impl ModelsExecution {
pub(super) fn new() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
profiles: false,
})
}
fn with_profiles() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client("sk_models_test"),
profiles: true,
})
}
}
impl ExecutionService for ModelsExecution {
fn authenticate(
&self,
plaintext: &str,
) -> Result<AuthenticatedClient, ClientAuthenticationError> {
if plaintext == "sk_models_test" {
Ok(self.client.clone())
} else {
Err(ClientAuthenticationError::InvalidKey)
}
}
fn public_models(&self, _: &AuthenticatedClient) -> Vec<PublicModelId> {
["model-a", "model-b"]
.into_iter()
.map(|model| PublicModelId::new(model).expect("model"))
.collect()
}
fn public_model_profiles(&self, _: &AuthenticatedClient) -> Vec<PublicModelProfile> {
if !self.profiles {
return Vec::new();
}
vec![PublicModelProfile::new(
PublicModelId::new("grok-4.5").expect("model"),
ModelPresentation::new(
Some("Grok 4.5".to_owned()),
Some("xAI Grok 4.5 frontier model.".to_owned()),
)
.with_reasoning(
Some("medium".to_owned()),
["low", "medium", "high", "xhigh"]
.map(str::to_owned)
.to_vec(),
)
.with_context_window_tokens(Some(500_000))
.with_image_input(true)
.with_agent_tools(true, true)
.with_service_tiers(vec![
ModelServiceTier::new(
"priority",
"Fast",
"Route the request through Codex fast mode.",
)
.with_speed_tier("fast"),
]),
)]
}
fn contains_public_model(&self, _: &AuthenticatedClient, model: &PublicModelId) -> bool {
matches!(model.as_str(), "model-a" | "model-b")
}
fn start(&self, _: StartExecution) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async {
Err(GatewayError::new(
GatewayErrorKind::Internal,
"models test must not start a response",
))
})
}
}
fn authorized_request(path: &str) -> Request<Body> {
Request::get(path)
.header(AUTHORIZATION, "Bearer sk_models_test")
.body(Body::empty())
.expect("build models request")
}
#[tokio::test]
async fn models_should_encode_the_service_visible_catalog() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models"))
.await
.expect("list models response");
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read models body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("models JSON");
assert_eq!(
value,
serde_json::json!({
"object": "list",
"data": [
{"id":"model-a","object":"model","created":1700000000_i64,"owned_by":"gateway"},
{"id":"model-b","object":"model","created":1700000000_i64,"owned_by":"gateway"}
]
})
);
}
#[tokio::test]
async fn models_should_encode_provider_profiles_for_current_codex_clients() {
let response = api_router(ModelsExecution::with_profiles())
.await
.oneshot(authorized_request("/v1/models?client_version=0.145.0"))
.await
.expect("list Codex models response");
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read Codex models body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("models JSON");
let model = &value["models"][0];
assert_eq!(
value
.as_object()
.map(|catalog| catalog.keys().map(String::as_str).collect::<Vec<_>>()),
Some(vec!["models"])
);
assert_eq!(model["slug"], "grok-4.5");
assert_eq!(model["default_reasoning_level"], "medium");
assert_eq!(model["context_window"], 500_000);
assert_eq!(model["apply_patch_tool_type"], "freeform");
assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"]));
assert_eq!(
model["service_tiers"],
serde_json::json!([{
"id": "priority",
"name": "Fast",
"description": "Route the request through Codex fast mode."
}])
);
assert_eq!(
model["supported_reasoning_levels"],
serde_json::json!([
{
"effort": "low",
"description": "Fast responses with lighter reasoning"
},
{
"effort": "medium",
"description": "Balances speed and reasoning depth for everyday tasks"
},
{
"effort": "high",
"description": "Greater reasoning depth for complex problems"
},
{
"effort": "xhigh",
"description": "Extra high reasoning depth for complex problems"
}
])
);
}
#[tokio::test]
async fn models_should_keep_the_codex_contract_when_profiles_are_empty() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models?client_version=0.145.0"))
.await
.expect("list empty Codex models response");
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read empty Codex models body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("Codex models JSON");
assert_eq!(value, serde_json::json!({ "models": [] }));
}
#[tokio::test]
async fn models_with_provider_profiles_should_keep_the_openai_list_contract() {
let response = api_router(ModelsExecution::with_profiles())
.await
.oneshot(authorized_request("/v1/models"))
.await
.expect("list compatible models response");
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read compatible models body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("models JSON");
assert_eq!(
value,
serde_json::json!({
"object": "list",
"data": [
{"id":"model-a","object":"model","created":1700000000_i64,"owned_by":"gateway"},
{"id":"model-b","object":"model","created":1700000000_i64,"owned_by":"gateway"}
]
})
);
}
#[tokio::test]
async fn model_detail_should_keep_the_official_path_id_contract() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models/model-a"))
.await
.expect("model detail response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn model_detail_should_hide_unknown_models() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models/model-private"))
.await
.expect("unknown model response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn model_catalog_should_keep_the_codex_catalog_contract() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models/catalog"))
.await
.expect("catalog response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read catalog body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("catalog JSON");
assert_eq!(value[0]["id"], "model-a");
assert_eq!(value[0]["displayName"], "model-a");
assert_eq!(value[0]["source"], "gateway");
}
#[tokio::test]
async fn model_info_should_return_a_single_visible_catalog_entry() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models/model-a/info"))
.await
.expect("model info response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn model_info_should_hide_unknown_models() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models/model-private/info"))
.await
.expect("missing model info response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn models_should_require_a_valid_bearer_client_key() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(
Request::get("/v1/models")
.body(Body::empty())
.expect("build unauthenticated request"),
)
.await
.expect("unauthenticated models response");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
@@ -1,5 +1,5 @@
event: response.completed
data: {}
data: [DONE]
event: response.completed
data: {}
data: [DONE]

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