Compare commits

...
243 Commits
Author SHA1 Message Date
ZyyandCodex a709acb149 chore: bump version to 3.3.19
Co-authored-by: Codex <noreply@openai.com>
2026-09-03 17:56:10 +08:00
ZyyandCodex 466842acd8 fix(observability): search usage by API key
Resolve sk_ searches through client key references while only sending the visible key prefix from the frontend, and clarify the request detail search hint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 17:38:29 +08:00
ZyyandCodex b91f28f95b fix(frontend): align configuration and usage copy
Generate the displayed Codex files and CCSwitch import payload from one source, carry the full settings through deep links, and clarify the quota percentage label.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 16:37:53 +08:00
ZyyandCodex 87164db3da chore: bump version to 3.3.18
Co-authored-by: Codex <noreply@openai.com>
2026-09-03 15:41:50 +08:00
ZyyandCodex ad16dec3db fix(openai): align official client routes
Remove unsupported review and model extension routes, preserve review semantics on standard Responses requests, and refine usage observability for review and empty performance data.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 15:37:14 +08:00
ZyyandCodex a0da0f4713 feat(openai): proxy standalone search
Add /v1/alpha/search as a transparent raw JSON endpoint, route requests through the OpenAI credential pipeline, and preserve account-scoped turn metadata and upstream response headers.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 14:27:15 +08:00
ZyyandCodex 65e5cde60d chore: bump version to 3.3.17
Co-authored-by: Codex <noreply@openai.com>
2026-09-03 12:44:42 +08:00
ZyyandCodex 0f0b464f4e fix(frontend): refine account usage presentation
Select quota summaries by the most recently used model, streamline dashboard account identities, and align diagnostic client metadata with successful records.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 12:21:15 +08:00
ZyyandCodex bd223f6540 chore: bump version to 3.3.16
Co-authored-by: Codex <noreply@openai.com>
2026-09-02 22:45:09 +08:00
ZyyandCodex 168be0ca68 fix(openai): add continuation recovery observability
Persist stable continuation and WebSocket lifecycle facts, correlate successful client replays, and hide recovered failures from default business metrics.

Co-authored-by: Codex <noreply@openai.com>
2026-09-02 21:38:48 +08:00
ZyyandCodex f2e91bbdaa fix(openai): preserve websocket continuation routing
Limit session-sticky HTTP fallback to new chains, keep continuation requests on their safe transport, and classify rebuild-required failures separately from account health.

Co-authored-by: Codex <noreply@openai.com>
2026-09-02 18:21:30 +08:00
ZyyandCodex d0c9248478 fix(openai): refine smart scheduler failure scoring
Limit account feedback to an explicit upstream reason allowlist, aggregate same-account retries, and decay failure rates over time.

Co-authored-by: Codex <noreply@openai.com>
2026-09-02 15:08:31 +08:00
ZyyandCodex 22c697bbff fix(usage): refine record details and error filters
Co-authored-by: Codex <noreply@openai.com>
2026-09-02 14:43:44 +08:00
ZyyandCodex 08d518324a fix(frontend): refine account quota layout
Co-authored-by: Codex <noreply@openai.com>
2026-09-02 12:14:03 +08:00
ZyyandCodex dfacb20e43 fix(frontend): tighten usage attempt table layout
Co-authored-by: Codex <noreply@openai.com>
2026-09-01 19:46:49 +08:00
ZyyandCodex 55c9291c35 chore: bump version to 3.3.15
Co-authored-by: Codex <noreply@openai.com>
2026-09-01 18:14:49 +08:00
ZyyandCodex 6e8e787b82 fix(openai): harden websocket failure recovery
Prevent hidden retries after ambiguous WebSocket sends, enforce connection-local continuation ownership, and persist raw upstream failure details for operations diagnostics.

Co-authored-by: Codex <noreply@openai.com>
2026-09-01 18:12:48 +08:00
ZyyandCodex 2a0a50adc5 refactor(backend): complete module boundary cleanup
Extract runtime snapshot lifecycle into its semantic owner, split oversized provider and admin modules, and enforce mirrored src/tests module layouts.

Co-authored-by: Codex <noreply@openai.com>
2026-09-01 17:17:57 +08:00
ZyyandCodex 5e13b6af6a refactor(backend): align module ownership boundaries
Normalize Rust module layout, assign shared core contracts to semantic owners, rename metering concepts, and add workspace architecture guards and documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-09-01 16:25:59 +08:00
ZyyandCodex bd2454a5b6 feat(settings): enforce minimum client versions
Add configurable minimum versions for Codex Desktop and CLI, return 426 for unsupported recognized clients, and provide install and Windows offline download guidance.

Co-authored-by: Codex <noreply@openai.com>
2026-09-01 13:37:04 +08:00
ZyyandCodex 2640a03219 chore: bump version to 3.3.14
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 19:23:58 +08:00
ZyyandCodex 7d0e9a0a20 fix(frontend): refine dashboard account metadata
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 19:23:10 +08:00
ZyyandCodex 09982d7221 fix(openai): align websocket retry fallback
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 19:14:18 +08:00
ZyyandCodex c4db1bb244 fix(observability): prefer per-frame Codex metadata
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 19:08:16 +08:00
ZyyandCodex e7c2f025cc chore: bump version to 3.3.13
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 13:29:18 +08:00
ZyyandCodex 4ee294b208 fix(frontend): improve mobile layout sizing
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 13:28:50 +08:00
Zyy 93f1a82d3f fix(frontend): improve observability chart continuity 2026-08-31 13:09:24 +08:00
ZyyandCodex c46f98cd2b fix: align wire profiles and diagnostics
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 12:29:29 +08:00
ZyyandCodex 5c69b1b082 chore: bump version to 3.3.12
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 11:56:13 +08:00
dependabot[bot]andZyy 6a78fb9232 chore(deps): bump argon2 from 0.5.3 to 0.6.0 in /backend (#49)
* chore(deps): bump argon2 from 0.5.3 to 0.6.0 in /backend

Bumps [argon2](https://github.com/RustCrypto/password-hashes) from 0.5.3 to 0.6.0.
- [Commits](https://github.com/RustCrypto/password-hashes/compare/argon2-v0.5.3...argon2-v0.6.0)

---
updated-dependencies:
- dependency-name: argon2
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

* fix(admin): update argon2 password hashing API

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-08-31 11:47:53 +08:00
ZyyandCodex 02a03df79a fix(observability): preserve upstream failure evidence
Log original OpenAI error payloads and WebSocket close reasons with request, account, attempt, transport, and connection correlation. Project PostgreSQL inet values as bare client addresses in observability queries.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 11:36:28 +08:00
dependabot[bot] 2aca8fb79b chore(deps): bump the minor-and-patch group across 1 directory with 3 updates (#48)
Bumps the minor-and-patch group with 3 updates in the /backend directory: [syn](https://github.com/dtolnay/syn), [uuid](https://github.com/uuid-rs/uuid) and [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust).


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

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

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

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-version: 1.144.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: syn
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: uuid
  dependency-version: 1.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:13:35 +08:00
dependabot[bot] 891b227b69 chore(deps): bump debian from abd67ff to 8820086 in /deploy (#52)
Bumps debian from `abd67ff` to `8820086`.

---
updated-dependencies:
- dependency-name: debian
  dependency-version: bookworm-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:13:27 +08:00
dependabot[bot] 04096dddb7 chore(deps): bump rust from e70e2ee to 82150a5 in /deploy (#51)
Bumps rust from `e70e2ee` to `82150a5`.

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:13:20 +08:00
dependabot[bot] 3a9b1c479f chore(deps): bump the minor-and-patch group in /frontend with 8 updates (#50)
Bumps the minor-and-patch group in /frontend with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.33.0` | `1.37.0` |
| [axios](https://github.com/axios/axios) | `1.19.0` | `1.20.0` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.51.0` | `1.52.0` |
| [marked](https://github.com/markedjs/marked) | `18.0.10` | `18.0.11` |
| [vue](https://github.com/vuejs/core) | `3.5.41` | `3.5.42` |
| [vue-router](https://github.com/vuejs/router) | `5.2.0` | `5.3.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.2.0` | `26.4.0` |
| [eslint](https://github.com/eslint/eslint) | `10.9.0` | `10.9.1` |


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

Updates `axios` from 1.19.0 to 1.20.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.19.0...v1.20.0)

Updates `es-toolkit` from 1.51.0 to 1.52.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.51.0...v1.52.0)

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

Updates `vue` from 3.5.41 to 3.5.42
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/compare/v3.5.41...v3.5.42)

Updates `vue-router` from 5.2.0 to 5.3.0
- [Release notes](https://github.com/vuejs/router/releases)
- [Commits](https://github.com/vuejs/router/compare/v5.2.0...v5.3.0)

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

Updates `eslint` from 10.9.0 to 10.9.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.9.0...v10.9.1)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: axios
  dependency-version: 1.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue
  dependency-version: 3.5.42
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue-router
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 10.9.1
  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-08-31 11:13:12 +08:00
dependabot[bot] 944d3f299e chore(deps): bump github/codeql-action/upload-sarif (#47)
Bumps the minor-and-patch group with 1 update: [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/upload-sarif` from 4.37.8 to 4.37.9
- [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/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

---
updated-dependencies:
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.9
  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-08-31 11:12:30 +08:00
ZyyandCodex 56451601fb feat(observability): enrich error request traces
Expose request, client, model, transport, and account facts in operations errors and surface them in the error table and detail view.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 10:57:53 +08:00
ZyyandCodex 4d53ef6068 fix(openai): recover premature websocket closes
Pin the selected account and retry over HTTP/SSE when a new-chain WebSocket fails before downstream delivery.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 10:57:35 +08:00
ZyyandCodex a62bb196f7 fix(accounts): proxy profile avatars through backend
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 09:56:23 +08:00
ZyyandCodex 7ad0ca48c5 fix(frontend): improve radio contrast in light mode
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 09:56:01 +08:00
ZyyandCodex e25639c11b fix(release): gate publishing on container validation
Skip standalone CI for version-only release commits so they do not cancel the preceding source run. Reuse the container workflow in Release and require it before publishing.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 04:40:24 +08:00
ZyyandCodex 8abac080a2 chore: bump version to 3.3.11
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 04:22:47 +08:00
ZyyandCodex 37cd059531 fix(openai): normalize HTTP previous response errors
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 04:22:11 +08:00
ZyyandCodex c46910df22 fix(observability): restore direct page navigation
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 04:09:31 +08:00
ZyyandCodex 8cfd7f53be chore: bump version to 3.3.10
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 03:30:13 +08:00
ZyyandCodex 75e22d7a5b fix(openai): harden downstream websocket delivery
Move downstream socket ownership into a bounded pump, preserve official Codex control-frame behavior, and bound downstream writes.

Enforce the official connection lifetime after active terminal delivery, add structured transport diagnostics, and mirror connection/protocol coverage under the test tree without adding stream_id.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 03:27:11 +08:00
ZyyandCodex 6b2d335bc5 fix(observability): align completed usage projections
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 02:58:16 +08:00
ZyyandCodex 270b1540cb perf(observability): bound retention cleanup cycles
Rotate bounded deletion batches across retained tables with row, batch, duration, and pacing budgets. Report per-cycle progress, honor worker cancellation, and leave rollups or parallel writer lanes behind measured production thresholds.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 02:40:40 +08:00
ZyyandCodex 660214d9a5 refactor(store): simplify PostgreSQL lifecycle reads
Reuse the direct runtime-settings read for the single-row control-plane path, collapse repeated provider snapshot lookups in request lifecycle writes, and remove the unused credential revision vector surface.

No schema migration is added because no candidate crossed the EXPLAIN and write-amplification gate.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 02:40:40 +08:00
ZyyandCodex 2231fee768 perf(observability): narrow usage and ops read models
Separate Usage list and detail reads, use keyset paging with first-page totals, push Ops predicates into source branches, and merge repeated account and diagnostics aggregates. Apply the shared per-query PostgreSQL budget, coalesce identical Dashboard windows, and preserve the existing IP, User-Agent, and latency detail surface.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 02:40:40 +08:00
ZyyandCodex a2e60c1c6e refactor(admin): page account read models in PostgreSQL
Move account, account-group, and client-key filtering, stable ordering, counts, and current-page membership reads into PostgreSQL. Keep Redis cooldown and lease enrichment in the Admin application boundary, share stable page state in the UI, and supervise client-key last-used flushing through the host worker lifecycle.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 02:40:40 +08:00
ZyyandCodex fc388bb799 refactor(providers): bound credential refresh queries
Move refresh candidate filtering, ordering, exclusions, and limits into one PostgreSQL query for OpenAI and xAI. Keep refresh policy in each provider and preserve the final credential revision fence without adding a scheduling cache or catalog table.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 02:34:49 +08:00
ZyyandCodex 62a35c3960 feat(store): add PostgreSQL connection guardrails
Raise the recommended pool default, reserve data-plane capacity dynamically, configure PostgreSQL session timeouts, isolate migrations, and add a fair per-query budget primitive with regression coverage.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 02:34:32 +08:00
ZyyandCodex 82653544ac perf(observability): eliminate attempt metrics temp spill
Materialize only the narrow request facts shared by the request and ops aggregates, then compute each aggregate once. This removes repeated scans of the wide model_requests CTE while preserving usage-fact cost coverage.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 02:34:21 +08:00
ZyyandCodex f10f825404 fix(observability): correct provider failure semantics
Count provider request, attempt, and failure facts across the full execution audit while restricting token totals to completed usage facts. Add a PostgreSQL regression assertion for the mixed success/failure boundary.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 02:34:21 +08:00
ZyyandCodex a268f5ca66 fix(observability): restore WebSocket usage records
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 22:40:16 +08:00
ZyyandCodex 832e2e8bdd chore: bump version to 3.3.9
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 09:49:48 +08:00
ZyyandCodex be703a0c5f fix(frontend): refine chart clarity and mobile controls
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 09:33:31 +08:00
ZyyandCodex c2c86928c5 fix(frontend): refine responsive interface states
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 05:40:31 +08:00
ZyyandCodex bda21ccc61 chore: bump version to 3.3.8
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 03:38:03 +08:00
ZyyandCodex 72c56680b9 fix(frontend): polish interface presentation
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 03:37:35 +08:00
ZyyandCodex a6808499f0 chore: bump version to 3.3.7
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 02:24:41 +08:00
ZyyandCodex fdb954b3d1 fix(frontend): refine semantic theme states
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 02:23:29 +08:00
ZyyandCodex a069001f01 chore: bump version to 3.3.6
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 01:55:01 +08:00
ZyyandCodex d3a87d11ad fix(frontend): restore theme visual consistency
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 01:53:00 +08:00
ZyyandCodex d7275dfd08 chore: bump version to 3.3.5
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 00:53:46 +08:00
ZyyandCodex 3396f0880f fix(frontend): refine login controls and spacing
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 00:46:01 +08:00
ZyyandCodex d5fa6c4921 refactor(frontend): remove redundant aria hiding
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 00:46:01 +08:00
ZyyandCodex d4855cca9c feat(observability): expose runtime pressure insights
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 00:46:01 +08:00
ZyyandCodex 1d862922db refactor(theme): rebuild runtime token architecture
Derive semantic and component tokens from structured appearance seeds, align visual consumers and Tailwind v4 base utilities, and keep popover arrows matched to their adjacent surfaces across placement changes.

Co-authored-by: Codex <noreply@openai.com>
2026-08-29 22:14:57 +08:00
ZyyandCodex 4830ef093b refactor(architecture): remove obsolete compatibility scaffolding
Remove orphaned fixtures, unused capability and resource branches, and stale credential APIs.

Retire legacy Usage metadata fallbacks and record the completed cleanup.

Co-authored-by: Codex <noreply@openai.com>
2026-08-29 22:08:34 +08:00
ZyyandCodex ad20064d15 fix(admin): unify control-plane error responses
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 20:22:14 +08:00
ZyyandCodex 515487baee docs(api): document official profile statistics
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 04:07:54 +08:00
ZyyandCodex a38c328975 style(frontend): format account usage summary
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:57:06 +08:00
ZyyandCodex 97d95d25f1 chore: bump version to 3.3.4
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:51:49 +08:00
ZyyandCodex abfd24267b fix(frontend): refine usage visual hierarchy
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:51:05 +08:00
ZyyandCodex a363682929 feat(frontend): refine surfaces and draggable modals
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:25:40 +08:00
ZyyandCodex 57422275bb feat(accounts): add official profile statistics
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 02:36:45 +08:00
Zyy cb7f69850b feat(openai): add session affinity observability 2026-08-28 02:20:55 +08:00
ZyyandCodex 5ea3740de3 refactor(openai): remove agent identity support
Co-authored-by: Codex <noreply@openai.com>
2026-08-27 13:27:10 +08:00
ZyyandCodex 3920624862 chore: bump version to 3.3.3
Co-authored-by: Codex <noreply@openai.com>
2026-08-27 01:53:05 +08:00
Zyy d804bca495 fix(openai): strip unsupported Codex request fields 2026-08-27 01:51:30 +08:00
ZyyandCodex ca527d5833 fix(accounts): use personal usage reports only
Remove the workspace token-breakdown probe and its dedicated statistics mode so usage aggregation always uses the personal model-credit and token-total reports.

Drop the invariant mode field from provider projections, the admin response, and the account usage UI.

Co-authored-by: Codex <noreply@openai.com>
2026-08-26 11:31:55 +08:00
ZyyandCodex ae4eb49afc chore: bump version to 3.3.2
Co-authored-by: Codex <noreply@openai.com>
2026-08-26 00:51:31 +08:00
ZyyandCodex 5d97086511 feat(accounts): add official usage statistics
Fetch Codex cycle usage in the provider, calculate model and daily Token costs with the shared OpenAI pricing rules, and expose the result through the admin API.

Add the account usage statistics modal, structured loading state, and feature-scoped quota components.

Co-authored-by: Codex <noreply@openai.com>
2026-08-26 00:46:53 +08:00
ZyyandCodex 0e6da1e731 refactor: consolidate migration baseline and remove design source
Fold the nullable requested-model change into the initial migration and keep the frozen manifest and project guidance aligned.

Remove the staged Pen design source.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 17:53:29 +08:00
ZyyandCodex 5d0922b9d1 fix(theme): align functional colors with Ant Design
Keep functional base tokens on Ant Design P6 so light themes preserve the configured seed while dark themes retain palette adaptation.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 17:06:05 +08:00
ZyyandCodex ed3d622554 chore: bump version to 3.3.1
Co-authored-by: Codex <noreply@openai.com>
2026-08-25 15:53:32 +08:00
dependabot[bot] 0f71cf6df2 chore(deps): bump rust in /deploy in the minor-and-patch group (#46)
Bumps the minor-and-patch group in /deploy with 1 update: rust.


Updates `rust` from 1.97-bookworm to 1.98-bookworm

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.98-bookworm
  dependency-type: direct:production
  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-08-25 15:37:44 +08:00
dependabot[bot] a1d3eba874 chore(deps): bump the minor-and-patch group in /frontend with 8 updates (#45)
Bumps the minor-and-patch group in /frontend with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.31.0` | `1.33.0` |
| [dayjs](https://github.com/iamkun/dayjs) | `1.11.21` | `1.11.23` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.13` | `3.4.14` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.50.0` | `1.51.0` |
| [marked](https://github.com/markedjs/marked) | `18.0.9` | `18.0.10` |
| [eslint](https://github.com/eslint/eslint) | `10.8.1` | `10.9.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.1` | `8.2.2` |
| [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) | `3.3.10` | `3.3.11` |


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

Updates `dayjs` from 1.11.21 to 1.11.23
- [Release notes](https://github.com/iamkun/dayjs/releases)
- [Changelog](https://github.com/iamkun/dayjs/blob/v1.11.23/CHANGELOG.md)
- [Commits](https://github.com/iamkun/dayjs/compare/v1.11.21...v1.11.23)

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

Updates `es-toolkit` from 1.50.0 to 1.51.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.50.0...v1.51.0)

Updates `marked` from 18.0.9 to 18.0.10
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.9...v18.0.10)

Updates `eslint` from 10.8.1 to 10.9.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.8.1...v10.9.0)

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

Updates `vue-tsc` from 3.3.10 to 3.3.11
- [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.11/packages/tsc)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: dayjs
  dependency-version: 1.11.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 10.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.11
  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-08-25 15:37:10 +08:00
dependabot[bot] d22c1d4ab9 chore(deps): bump the minor-and-patch group in /backend with 4 updates (#44)
Bumps the minor-and-patch group in /backend with 4 updates: [uuid](https://github.com/uuid-rs/uuid), [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust), [aws-smithy-runtime-api](https://github.com/smithy-lang/smithy-rs) and [redis](https://github.com/redis-rs/redis-rs).


Updates `uuid` from 1.24.0 to 1.24.1
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.24.0...v1.24.1)

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

Updates `aws-smithy-runtime-api` from 1.14.0 to 1.15.0
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

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

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.24.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.142.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-runtime-api
  dependency-version: 1.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: redis
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 15:36:39 +08:00
dependabot[bot] cc8dfe3dd8 chore(deps): bump the minor-and-patch group with 2 updates (#43)
Bumps the minor-and-patch group with 2 updates: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e)

Updates `github/codeql-action/upload-sarif` from 4.37.7 to 4.37.8
- [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/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  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.8
  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-08-25 15:36:01 +08:00
ZyyandCodex f25edadd5f fix(ci): keep OpenAI regression tests out of sources
Move the thread-spawn transport identity regression behind the public initialized-provider boundary so production source files remain free of embedded test modules.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 15:27:28 +08:00
ZyyandCodex 4f50c67332 fix(openai): preserve root session account affinity
Share the preferred account across derived child executions while keeping their continuation and WebSocket transport identities isolated.

Claim and migrate affinity atomically, clear account-bound state on fallback, reject cross-account previous-response continuations, and preserve complete replay transcripts.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 14:22:36 +08:00
ZyyandCodex 75f9d2a290 fix(openai): remove websocket pool slot caps
Co-authored-by: Codex <noreply@openai.com>
2026-08-25 11:22:07 +08:00
ZyyandCodex b93b8859f9 fix(frontend): polish theme controls and page headers
Remove the redundant preview mode switch, round the color picker input, and restore inline right-aligned page header actions.

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 20:21:16 +08:00
ZyyandCodex 3510a45742 chore: bump version to 3.3.0
Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:07:37 +08:00
ZyyandCodex 85eb283b40 docs(readme): streamline project introduction
Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:06:49 +08:00
ZyyandCodex b8e1c5077d docs(theme): document runtime theme architecture
Describe the token model, preset themes, runtime stylesheet, isolated editor preview, naming conventions, and extension workflow.

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:04:59 +08:00
ZyyandCodex 8422240967 feat(theme): add runtime token theme editor
Introduce Seed-to-Component token derivation, isolated live previews, semantic base component theming, and responsive theme controls.

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:04:22 +08:00
ZyyandCodex fbd922117e fix(accounts): return persisted OAuth account ID
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 19:37:03 +08:00
ZyyandCodex 941c4d57fa chore: bump version to 3.2.16
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:28:34 +08:00
ZyyandCodex cf046ad9a4 fix(admin): close quota reset modal on success
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:25:05 +08:00
ZyyandCodex 7699a954ac fix(admin): format account counts with separators
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:21:11 +08:00
ZyyandCodex 2c31708d1b feat(admin): allow selecting quota reset credits
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:19:20 +08:00
ZyyandCodex 80f338ce01 fix(admin): show raw account counts
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 23:12:54 +08:00
ZyyandCodex 299088a6e7 chore: bump version to 3.2.15
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 20:00:59 +08:00
ZyyandCodex a23bfca848 fix(admin): normalize OAuth credential formats
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 14:57:16 +08:00
ZyyandCodex 8951a64a05 fix(admin): raise billing abbreviation threshold
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 14:15:22 +08:00
ZyyandCodex eb20561893 fix(admin): align xAI quota usage displays
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 13:58:02 +08:00
ZyyandCodex a33637a99e fix(openai): use Codex image endpoint paths
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 13:02:40 +08:00
ZyyandCodex 97cd7a7097 chore: bump version to 3.2.14
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 01:11:57 +08:00
ZyyandCodex b0fe132736 docs: refresh architecture and project guides
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 01:01:07 +08:00
ZyyandCodex e2c75d1054 feat(admin): add direct token import and polish displays
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 00:33:26 +08:00
ZyyandCodex 94f298927d feat(openai): add active quota reset credits
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 00:33:21 +08:00
ZyyandCodex 3be827204f fix(openai): keep image routes model-agnostic
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 19:05:14 +08:00
ZyyandCodex 227a1174b9 feat(openai): proxy image generation and edits
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 17:41:15 +08:00
ZyyandCodex c0defcb6df fix(admin): prefer account-wide dashboard quota
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 16:06:05 +08:00
ZyyandCodex 53739ebe5c chore: bump version to 3.2.13
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:20:29 +08:00
ZyyandCodex 7f420257a6 fix(api): keep provider degradation out of healthz
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex 4f85209dc9 fix(config): centralize runtime data paths
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex cd89e2e8fc fix(admin): align quota metadata and usage layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex 0cb5a29699 chore: bump version to 3.2.12
Co-authored-by: Codex <noreply@openai.com>
2026-08-19 23:52:11 +08:00
ZyyandCodex 61788804b4 fix(openai): recover cross-account websocket continuations
Co-authored-by: Codex <noreply@openai.com>
2026-08-19 23:40:40 +08:00
ZyyandCodex 2751ea6aa4 fix(openai): preserve cross-account session recovery
Co-authored-by: Codex <noreply@openai.com>
2026-08-19 23:05:48 +08:00
ZyyandCodex 58fe66789c chore(deps): bump h2 to 0.4.16
Resolve RUSTSEC-2026-0258 reported by cargo audit.

Co-authored-by: Codex <noreply@openai.com>
2026-08-19 11:34:07 +08:00
ZyyandCodex 573b6b26db fix(openai): isolate quota buckets by limit id
Co-authored-by: Codex <noreply@openai.com>
2026-08-18 16:48:37 +08:00
ZyyandCodex d425428ebe chore: bump version to 3.2.11
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 20:02:02 +08:00
ZyyandCodex b26d3b386a fix(admin): align quota diagnostics and usage layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 20:00:29 +08:00
ZyyandCodex cf0b14c5fd chore: bump version to 3.2.10
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 15:40:35 +08:00
ZyyandCodex 056561de41 refactor(frontend): clarify component boundaries
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 15:33:36 +08:00
dependabot[bot] f2215df72e chore(deps): bump the minor-and-patch group in /frontend with 4 updates
Bumps the minor-and-patch group in /frontend with 4 updates: [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue), [pinia](https://github.com/vuejs/pinia), [eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility) and [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc).


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

Updates `pinia` from 4.0.2 to 4.0.3
- [Release notes](https://github.com/vuejs/pinia/releases)
- [Commits](https://github.com/vuejs/pinia/compare/v4.0.2...v4.0.3)

Updates `eslint-plugin-vuejs-accessibility` from 2.5.0 to 2.6.0
- [Release notes](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/releases)
- [Changelog](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/compare/v2.5.0...v2.6.0)

Updates `vue-tsc` from 3.3.9 to 3.3.10
- [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.10/packages/tsc)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: pinia
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: eslint-plugin-vuejs-accessibility
  dependency-version: 2.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
dependabot[bot] 72f8957163 chore(deps): bump the minor-and-patch group in /backend with 5 updates
Bumps the minor-and-patch group in /backend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [thiserror](https://github.com/dtolnay/thiserror) | `2.0.19` | `2.0.20` |
| [async-trait](https://github.com/dtolnay/async-trait) | `0.1.91` | `0.1.92` |
| [futures](https://github.com/rust-lang/futures-rs) | `0.3.33` | `0.3.34` |
| [aws-smithy-types](https://github.com/smithy-lang/smithy-rs) | `1.6.1` | `1.6.2` |
| [cookie](https://github.com/SergioBenitez/cookie-rs) | `0.18.1` | `0.18.2` |


Updates `thiserror` from 2.0.19 to 2.0.20
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/2.0.19...2.0.20)

Updates `async-trait` from 0.1.91 to 0.1.92
- [Release notes](https://github.com/dtolnay/async-trait/releases)
- [Commits](https://github.com/dtolnay/async-trait/compare/0.1.91...0.1.92)

Updates `futures` from 0.3.33 to 0.3.34
- [Release notes](https://github.com/rust-lang/futures-rs/releases)
- [Changelog](https://github.com/rust-lang/futures-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/futures-rs/compare/0.3.33...0.3.34)

Updates `aws-smithy-types` from 1.6.1 to 1.6.2
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

Updates `cookie` from 0.18.1 to 0.18.2
- [Changelog](https://github.com/rwf2/cookie-rs/blob/0.18.2/CHANGELOG.md)
- [Commits](https://github.com/SergioBenitez/cookie-rs/compare/0.18.1...0.18.2)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: async-trait
  dependency-version: 0.1.92
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: futures
  dependency-version: 0.3.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-types
  dependency-version: 1.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: cookie
  dependency-version: 0.18.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
dependabot[bot] 73f0c15790 chore(deps): bump github/codeql-action/upload-sarif
Bumps the minor-and-patch group with 1 update: [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/upload-sarif` from 4.37.6 to 4.37.7
- [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/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

---
updated-dependencies:
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
ZyyandCodex 0558693949 fix(openai): harden refresh recovery handling
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 13:37:54 +08:00
ZyyandCodex 2a9cb02cc9 fix(openai): align OAuth refresh error handling
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 12:30:05 +08:00
ZyyandCodex 5ce36931a8 chore: bump version to 3.2.9
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:38:49 +08:00
ZyyandCodex 0d826d9eba fix(openai): align OAuth token refresh
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:14:03 +08:00
ZyyandCodex 304242957a fix(accounts): preserve upstream error messages
Co-authored-by: Codex <noreply@openai.com>
2026-08-16 13:28:28 +08:00
ZyyandCodex ef37ae8d9e chore: bump version to 3.2.8
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 23:31:04 +08:00
ZyyandCodex fc13d4f2b6 fix(frontend): restore responsive component behavior
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 20:38:48 +08:00
ZyyandCodex 78f9c0c800 chore: bump version to 3.2.7
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:52 +08:00
ZyyandCodex a3bc7bc445 fix(frontend): increase account table row height
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:02 +08:00
ZyyandCodex 0132114285 fix(openai): let upstream validate catalog models
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:45:06 +08:00
ZyyandCodex 5970efbb7c fix(container): refresh runtime packages before scans
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:34:41 +08:00
ZyyandCodex 7bf28b571d chore: bump version to 3.2.6
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 15:47:32 +08:00
ZyyandCodex bc3bd6bc43 refactor(frontend): unify component design system
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 15:39:31 +08:00
ZyyandCodex 9acfab0a86 chore(frontend): enforce template interpolation layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 12:31:10 +08:00
ZyyandCodex 3e5a84d2a0 fix(frontend): refine dashboard capacity display
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 12:31:06 +08:00
ZyyandCodex 92948f51a9 fix(frontend): remove table bottom gap
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 11:59:58 +08:00
ZyyandCodex 776637fccb chore: bump version to 3.2.5
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:52:54 +08:00
ZyyandCodex 82acb08d26 fix(frontend): restore diagnostic table scrolling
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:51:56 +08:00
ZyyandCodex 7744e1b0b4 fix(accounts): preserve rolling window usage
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:28:44 +08:00
ZyyandCodex 0b8a1add18 feat(frontend): refresh account setup defaults and copy
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:09:31 +08:00
ZyyandCodex 2dd60c08b6 chore: bump version to 3.2.4
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 00:29:44 +08:00
ZyyandCodex cce9880937 fix(xai): align Grok behavior with sub2api
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 23:36:12 +08:00
Zyy b8ea1cd076 fix(core): normalize account scheduling load 2026-08-14 23:27:06 +08:00
ZyyandCodex 4117840f75 fix(xai): align OAuth and Codex compatibility
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 22:24:08 +08:00
ZyyandCodex f642764f34 fix(openai): align billing with official pricing
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 21:14:36 +08:00
ZyyandCodex 482e192e2d fix(openai): align desktop oauth identity
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 17:43:07 +08:00
ZyyandCodex ee51a07de4 fix(openai): align desktop wire profile
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 16:13:11 +08:00
ZyyandCodex bf23658aaf chore: bump version to 3.2.3
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 01:25:44 +08:00
ZyyandCodex bc4728332e fix(frontend): polish dashboard and table interactions
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 01:24:49 +08:00
ZyyandCodex 2cb5cda077 fix(quota): report exhausted usage as full
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 01:07:20 +08:00
ZyyandCodex 9d90dedb81 fix(frontend): refresh account data without table loading
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 01:07:12 +08:00
ZyyandCodex 6074cff68c chore: bump version to 3.2.2
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:43:13 +08:00
ZyyandCodex 6ab8e7d0a6 fix(frontend): grade model success rate colors
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:42:20 +08:00
ZyyandCodex 17f9ef8915 fix(observability): omit inactive accounts from daily usage
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:39:59 +08:00
ZyyandCodex 15e0a28bd0 fix(frontend): refine refresh loading and group alignment
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:39:50 +08:00
ZyyandCodex 58bc438992 chore: bump version to 3.2.1
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:13:58 +08:00
ZyyandCodex b04616f59f refactor(migrations): squash schema into initial baseline
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:13:09 +08:00
ZyyandCodex 82ba4fcbba fix(observability): align dashboard and quota window usage
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:11:56 +08:00
ZyyandCodex 819f428ac3 fix(frontend): align loading and daily usage display
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:10:13 +08:00
ZyyandCodex 35f28e8aab chore: bump version to 3.2.0
Co-authored-by: Codex <noreply@openai.com>
2026-08-13 22:43:48 +08:00
ZyyandCodex 65645c7403 fix(accounts): gate quota recovery and add manual reset
Co-authored-by: Codex <noreply@openai.com>
2026-08-13 22:43:03 +08:00
ZyyandCodex 71159d5633 refactor(account-groups): remove unused members endpoint
Co-authored-by: Codex <noreply@openai.com>
2026-08-13 17:28:43 +08:00
ZyyandCodex f2ebb09e34 docs: describe group routing and account scheduling
Co-authored-by: Codex <noreply@openai.com>
2026-08-13 16:58:36 +08:00
ZyyandCodex 07acc67ce6 feat(frontend): add account group management
Co-authored-by: Codex <noreply@openai.com>
2026-08-13 16:58:36 +08:00
ZyyandCodex 01ad8bba8a feat(routing): add account groups and weighted scheduling
Co-authored-by: Codex <noreply@openai.com>
2026-08-13 16:58:36 +08:00
ZyyandCodex 986a128d15 fix(observability): separate live status from history range
Co-authored-by: Codex <noreply@openai.com>
2026-08-13 00:26:10 +08:00
ZyyandCodex 6cad2bb6a1 fix(openai): align account import with official token claims
Co-authored-by: Codex <noreply@openai.com>
2026-08-12 23:51:26 +08:00
ZyyandCodex 2b8b3de0e4 refactor(accounts): unify five-state account status
Co-authored-by: Codex <noreply@openai.com>
2026-08-12 23:50:31 +08:00
ZyyandCodex 4127886584 chore: bump version to 3.1.21
Co-authored-by: Codex <noreply@openai.com>
2026-08-11 17:42:54 +08:00
ZyyandCodex ee3e5db6c2 fix(accounts): isolate model-specific quota usage
Co-authored-by: Codex <noreply@openai.com>
2026-08-11 17:42:02 +08:00
ZyyandCodex 153cb67b9c refactor(login): refine panel details
Co-authored-by: Codex <noreply@openai.com>
2026-08-11 17:21:31 +08:00
ZyyandCodex f370d355b6 fix(accounts): align quota ordering and summaries
Co-authored-by: Codex <noreply@openai.com>
2026-08-11 17:21:23 +08:00
ZyyandCodex a31dad992c fix(frontend): stabilize button loading indicator
Co-authored-by: Codex <noreply@openai.com>
2026-08-11 17:21:16 +08:00
ZyyandCodex 55a88f5f6b chore: bump version to 3.1.20
Co-authored-by: Codex <noreply@openai.com>
2026-08-11 15:13:40 +08:00
ZyyandCodex 5d90a92582 fix(logging): rename OAuth recovery log file
Co-authored-by: Codex <noreply@openai.com>
2026-08-11 15:06:40 +08:00
ZyyandCodex 93abe8efcd fix(logging): generalize OAuth recovery logs
Co-authored-by: Codex <noreply@openai.com>
2026-08-11 14:55:59 +08:00
ZyyandCodex f4b75c42ed fix(openai): isolate quota and recovery signals
Co-authored-by: Codex <noreply@openai.com>
2026-08-11 13:37:16 +08:00
ZyyandCodex 36ff038c78 chore: bump version to 3.1.19
Co-authored-by: Codex <noreply@openai.com>
2026-08-11 12:17:28 +08:00
ZyyandCodex 545009590c fix(openai): persist authoritative quota exhaustion
Co-authored-by: Codex <noreply@openai.com>
2026-08-11 12:16:34 +08:00
ZyyandCodex 5b7b9b21ee fix(openai): require low usage for quota recovery
Co-authored-by: Codex <noreply@openai.com>
2026-08-10 15:55:58 +08:00
ZyyandCodex 44a2a87b69 fix(frontend): center button loading spinner
Co-authored-by: Codex <noreply@openai.com>
2026-08-10 15:55:58 +08:00
ZyyandCodex 184e88083b chore: bump version to 3.1.18
Co-authored-by: Codex <noreply@openai.com>
2026-08-10 12:25:42 +08:00
ZyyandCodex 53b40aea46 chore(deps): ignore fingerprint-sensitive updates
Co-authored-by: Codex <noreply@openai.com>
2026-08-10 12:23:45 +08:00
ZyyandCodex aa328b6dbf fix(openai): restore official TLS fingerprint pins
Co-authored-by: Codex <noreply@openai.com>
2026-08-10 12:23:36 +08:00
dependabot[bot]andZyy 64231ff403 chore(deps): bump the minor-and-patch group in /backend with 4 updates (#37)
Bumps the minor-and-patch group in /backend with 4 updates: [base64](https://github.com/marshallpierce/rust-base64), [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust), [hyper-rustls](https://github.com/rustls/hyper-rustls) and [rustls](https://github.com/rustls/rustls).


Updates `base64` from 0.23.0 to 0.23.1
- [Changelog](https://github.com/marshallpierce/rust-base64/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/marshallpierce/rust-base64/compare/v0.23.0...v0.23.1)

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

Updates `hyper-rustls` from 0.27.7 to 0.27.9
- [Release notes](https://github.com/rustls/hyper-rustls/releases)
- [Commits](https://github.com/rustls/hyper-rustls/compare/v/0.27.7...v/0.27.9)

Updates `rustls` from 0.23.36 to 0.23.43
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.36...v/0.23.43)

---
updated-dependencies:
- dependency-name: base64
  dependency-version: 0.23.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.141.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: hyper-rustls
  dependency-version: 0.27.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: rustls
  dependency-version: 0.23.43
  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>
Co-authored-by: Zyy <39424122+zyycn@users.noreply.github.com>
2026-08-10 12:05:00 +08:00
dependabot[bot]andZyy d7b80a2066 chore(deps): bump the minor-and-patch group with 5 updates (#36)
Bumps the minor-and-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.37.4` | `4.37.6` |
| [Swatinem/rust-cache](https://github.com/swatinem/rust-cache) | `2.9.1` | `2.9.2` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `6.0.9` | `6.0.10` |
| [dorny/paths-filter](https://github.com/dorny/paths-filter) | `4.0.2` | `4.0.3` |
| [actions/attest](https://github.com/actions/attest) | `4.2.1` | `4.2.2` |


Updates `github/codeql-action/upload-sarif` from 4.37.4 to 4.37.6
- [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/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

Updates `Swatinem/rust-cache` from 2.9.1 to 2.9.2
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/c19371144df3bb44fab255c43d04cbc2ab54d1c4...6323deb102c322ba6fcbdcafc7e3dddab59af2b6)

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

Updates `dorny/paths-filter` from 4.0.2 to 4.0.3
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dorny/paths-filter/compare/7b450fff21473bca461d4b92ce414b9d0420d706...ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d)

Updates `actions/attest` from 4.2.1 to 4.2.2
- [Release notes](https://github.com/actions/attest/releases)
- [Changelog](https://github.com/actions/attest/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest/compare/508db95dd578ae2727ebd6217d5ba78e4fbda05d...1e69f48acb82d1966a394da916b4c1698aa569d6)

---
updated-dependencies:
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Swatinem/rust-cache
  dependency-version: 2.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: dorny/paths-filter
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: actions/attest
  dependency-version: 4.2.2
  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>
Co-authored-by: Zyy <39424122+zyycn@users.noreply.github.com>
2026-08-10 12:01:38 +08:00
dependabot[bot] 08d63ea3f3 chore(deps): bump debian from 7b140f3 to abd67ff in /deploy (#39)
Bumps debian from `7b140f3` to `abd67ff`.

---
updated-dependencies:
- dependency-name: debian
  dependency-version: bookworm-slim
  dependency-type: direct:production
...

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

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.28.0` | `1.30.0` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.12` | `3.4.13` |
| [marked](https://github.com/markedjs/marked) | `18.0.7` | `18.0.9` |
| [vue](https://github.com/vuejs/core) | `3.5.40` | `3.5.41` |
| [@antfu/eslint-config](https://github.com/antfu/eslint-config) | `9.2.0` | `9.3.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.1.2` | `26.2.0` |
| [eslint](https://github.com/eslint/eslint) | `10.8.0` | `10.8.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.0` | `8.2.1` |


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

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

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

Updates `vue` from 3.5.40 to 3.5.41
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/compare/v3.5.40...v3.5.41)

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

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

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

Updates `vite` from 8.2.0 to 8.2.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.2.1/packages/vite)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue
  dependency-version: 3.5.41
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@antfu/eslint-config"
  dependency-version: 9.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 10.8.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.1
  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-08-10 11:54:36 +08:00
ZyyandCodex 492c401599 fix(openai): preserve state on quota auth failures
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Zyy <zyycn@users.noreply.github.com>
2026-08-10 11:46:19 +08:00
ZyyandCodex a063258a40 chore: bump version to 3.1.17
Co-authored-by: Codex <noreply@openai.com>
2026-08-09 15:14:06 +08:00
ZyyandCodex dc590f2b5f fix: make OAuth refresh margin runtime-only
Co-authored-by: Codex <noreply@openai.com>
2026-08-09 15:12:43 +08:00
ZyyandCodex ca52e2bfd9 chore: bump version to 3.1.16
Co-authored-by: Codex <noreply@openai.com>
2026-08-09 11:59:08 +08:00
ZyyandCodex 43604e36dc fix: correct first token timing
Co-authored-by: Codex <noreply@openai.com>
2026-08-07 18:56:13 +08:00
ZyyandCodex a4a997e351 fix: use lifetime dashboard card totals
Co-authored-by: Codex <noreply@openai.com>
2026-08-07 18:56:13 +08:00
ZyyandCodex 73ca463924 feat: distinguish pro plan badges
Co-authored-by: Codex <noreply@openai.com>
2026-08-07 18:56:13 +08:00
ZyyandCodex 9eeec2c78c chore: bump version to 3.1.15
Co-authored-by: Codex <noreply@openai.com>
2026-08-07 15:15:00 +08:00
ZyyandCodex 90890be2b4 fix(openai): persist OAuth tokens before quota sync
Align OAuth token persistence and ID token metadata handling with Codex, and retry initial quota observation asynchronously.

Co-authored-by: Codex <noreply@openai.com>
2026-08-07 14:59:42 +08:00
ZyyandCodex 2e4bab17a8 chore: bump version to 3.1.14
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 16:01:07 +08:00
ZyyandCodex b052e79a16 fix(openai): preserve imported token expiry
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 15:49:42 +08:00
ZyyandCodex 9775451e83 fix(web): preserve button labels while loading
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 15:27:00 +08:00
ZyyandCodex c22a2ad144 test(logging): move recovery filter coverage
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 15:14:26 +08:00
ZyyandCodex 7bd362d544 chore: bump version to 3.1.13
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 14:56:08 +08:00
ZyyandCodex e7036a616f fix(web): center button loading indicators
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 14:53:53 +08:00
ZyyandCodex 98652d4c68 fix(openai): write oauth recovery to app logs
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 14:22:13 +08:00
ZyyandCodex d0dc7ef92b fix(openai): complete import profile from access token
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 12:31:13 +08:00
ZyyandCodex 194fb20d5f chore: bump version to 3.1.12
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 11:14:32 +08:00
ZyyandCodex 30f528f084 fix(openai): simplify import and oauth validation
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 11:14:18 +08:00
ZyyandCodex 28d024ceda chore: bump version to 3.1.11
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 09:54:05 +08:00
ZyyandCodex 6b9c98c59f fix(openai): persist successful refresh tokens directly
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 09:50:03 +08:00
ZyyandCodex 1294e50b76 chore: bump version to 3.1.10
Co-authored-by: Codex <noreply@openai.com>
2026-08-05 12:22:46 +08:00
ZyyandCodex a0e540642b fix(oauth): bound refresh recovery and persist failure reasons
Co-authored-by: Codex <noreply@openai.com>
2026-08-05 12:21:16 +08:00
ZyyandCodex 14d6a7de31 fix(dashboard): align summary with daily trend
Co-authored-by: Codex <noreply@openai.com>
2026-08-05 10:46:14 +08:00
ZyyandCodex cd7b60a3ba chore: bump version to 3.1.9
Co-authored-by: Codex <noreply@openai.com>
2026-08-05 00:03:39 +08:00
ZyyandCodex ebbed22f36 fix(openai): keep Codex wire OS exact
Co-authored-by: Codex <noreply@openai.com>
2026-08-05 00:03:21 +08:00
ZyyandCodex f6e5d8f673 fix(openai): drop null secondary quota windows
Co-authored-by: Codex <noreply@openai.com>
2026-08-05 00:03:11 +08:00
ZyyandCodex bf1c6211b8 chore: bump version to 3.1.8
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 22:30:40 +08:00
ZyyandCodex d388fa344a fix(openai): drop placeholder secondary quota windows
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 22:30:04 +08:00
ZyyandCodex eabb61b08c fix(openai): restore Codex Core app-server identity
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 22:30:01 +08:00
ZyyandCodex a5c3438927 fix(openai): isolate thread-spawn session affinity
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 22:29:53 +08:00
ZyyandCodex b6948542bb fix: align Codex Desktop HTTP identity
- send the official Desktop HTTP user agent across OpenAI upstream paths
- keep the audited model catalog version separate from the wire identity
- remove CLI release drift and clarify the dashboard client identity

Co-authored-by: Codex <noreply@openai.com>
2026-08-04 18:36:03 +08:00
ZyyandCodex 0242bd3193 chore: bump version to 3.1.7
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 16:19:58 +08:00
ZyyandCodex 229ef48470 fix: harden backup verification and detail UI
- read every multipart backup payload to its requested length
- make remote verification failures diagnosable
- refine status details, loading icons, and attempt-chain presentation

Co-authored-by: Codex <noreply@openai.com>
2026-08-04 16:18:53 +08:00
ZyyandCodex 19c35ba901 chore: bump version to 3.1.6
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 15:12:29 +08:00
ZyyandCodex 3c06ae97e8 fix: reconcile quota, backup, and session behavior
- preserve confirmed quota exhaustion until its recorded reset advances
- normalize account quota presentation and subagent session affinity
- repair S3-compatible backup handling and related admin UI details

Co-authored-by: Codex <noreply@openai.com>
2026-08-04 15:12:15 +08:00
ZyyandCodex f34966e4c7 chore: bump version to 3.1.5
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 08:54:00 +08:00
ZyyandCodex e9ebf45640 fix(accounts): reconcile quota status and dashboard metrics
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 08:53:29 +08:00
ZyyandCodex c00e5bd243 chore: bump version to 3.1.4
Co-authored-by: Codex <noreply@openai.com>
2026-08-04 01:10:51 +08:00
ZyyandCodex ab5f18a437 fix(accounts): update admin list test for cooldown-driven rate_limited
quota_limit_reached without Redis cooldown maps to quota_exhausted
now that rate_limited comes only from 429 cooldown. Adjust summary
assertions: quota_exhausted 1→2, rate_limited 1→0.

Co-authored-by: Codex <noreply@openai.com>
2026-08-04 01:10:40 +08:00
ZyyandClaude Fable 5 330b222359 feat(accounts): separate rate-limited status into Redis cooldown
429 临时限流从 quota 快照分离为 Redis 冷却(ProviderCooldownPort):
- apply_rate_limit_429 只写 Redis(put_if_later,不缩短已有冷却),不再合成 quota 窗口,额度重置时间不被 429 污染
- 调度选择时排除冷却中的账号,到期自动恢复;诊断请求绕过
- 状态派生分离:rate_limited_until 存在 → 限流中;quota_limit_reached → 配额耗尽
- store 层(账号列表/筛选 + dashboard poolSummary)注入 cooldown 端口,精确区分限流中与配额耗尽,"正常"筛选不再混入限流中
- wire 透传 rate_limited_until,前端状态弹窗改为状态卡片(icon 块 + 状态名 + 原因/到期明细),BasePopover 加 hover delay
- 更新 429 相关测试断言,新增配额耗尽映射测试

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:39:38 +08:00
755 changed files with 82704 additions and 70722 deletions
+5
View File
@@ -21,6 +21,11 @@ updates:
day: monday
time: '09:10'
timezone: Asia/Shanghai
# 固定官方 Codex Desktop 的 TLS 指纹;升级前必须抓取握手并比对扩展顺序。
ignore:
- dependency-name: hyper-rustls
- dependency-name: reqwest
- dependency-name: rustls
groups:
minor-and-patch:
update-types:
+5 -2
View File
@@ -45,7 +45,7 @@ jobs:
ref: ${{ inputs.ref }}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build runtime image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
@@ -55,6 +55,9 @@ jobs:
target: runtime
load: true
tags: ${{ env.CPR_IMAGE }}
no-cache-filters: |
runtime-base
runtime
cache-from: type=gha,scope=runtime-ci
cache-to: type=gha,mode=max,scope=runtime-ci
@@ -76,7 +79,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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: trivy-results.sarif
category: runtime-container
+2 -2
View File
@@ -106,7 +106,7 @@ jobs:
components: rustfmt,clippy
- name: Cache Rust
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: backend -> target
@@ -157,7 +157,7 @@ jobs:
ref: ${{ inputs.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
version: 11.7.0
+3 -1
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
paths-ignore:
- 'release/version.yaml'
pull_request:
branches:
- main
@@ -32,7 +34,7 @@ jobs:
- name: Detect affected checks
id: filter
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
filters: |
backend:
+17 -5
View File
@@ -163,6 +163,15 @@ jobs:
ref: ${{ needs.release-info.outputs.git_sha }}
upload_frontend: true
container:
needs: [release-info]
permissions:
contents: read
security-events: write
uses: ./.github/workflows/_container.yml
with:
ref: ${{ needs.release-info.outputs.git_sha }}
build-docker-binary:
name: build-docker-binary (${{ matrix.os }}-${{ matrix.arch }})
needs: release-info
@@ -375,7 +384,7 @@ jobs:
PY
- name: Generate release asset provenance
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: dist/release/*
@@ -448,7 +457,7 @@ jobs:
PY
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -468,6 +477,9 @@ jobs:
push: true
build-args: |
CPR_PREBUILT_DIR=dist/docker
no-cache-filters: |
runtime-base
runtime-prebuilt
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }}
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }},mode=min
provenance: mode=max
@@ -494,7 +506,7 @@ jobs:
exit-code: '1'
publish:
needs: [release-info, package-assets, build-docker-image]
needs: [release-info, package-assets, build-docker-image, container]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@@ -516,7 +528,7 @@ jobs:
path: dist/release
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
@@ -581,7 +593,7 @@ jobs:
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
- name: Generate container provenance attestation
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.manifest.outputs.digest }}
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
- name: Cache Rust
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: backend -> target
@@ -52,7 +52,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
version: 11.7.0
+70 -70
View File
@@ -12,31 +12,32 @@
[![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/api.md) · [架构](docs/architecture.md)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运行维护](#运行维护) · [部署](deploy/README.md) · [接口](docs/api.md) · [架构](docs/architecture.md)
</div>
> [!NOTE]
> 只支持 OpenAI Responses API,不提供 `/v1/chat/completions`。
> 数据面支持 OpenAI Responses、Images 和模型目录协议,不提供 `/v1/chat/completions`。
## 能力
## 功能
| 领域 | 实现 |
| -------- | ----------------------------------------------------------------------------------------------------- |
| 协议 | 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` |
| 领域 | 当前能力 |
| --- | --- |
| 数据面 | Responses JSON、SSE、WebSocket(含 Codex 子代理语义)、Images 生成/编辑与模型目录 |
| Provider | 固定 `openai`、`xai` 两个 Provider,各自拥有 OAuth、credential、额度、目录与 transport |
| 路由 | Client Key 限定账号分组;按模型能力编译 Provider 候选;支持全局精确模型映射、会话亲和和安全 fallback |
| 透明边界 | OpenAI 保留未知请求字段及字段顺序,并原样转发 SSE、WebSocket 与 Images 业务字节;xAI 在 Provider 内转换协议 |
| 账号 | OAuth、OpenAI AT/RT 与账号文件导入,credential/额度刷新、账号恢复及主动额度重置卡 |
| 管理 | 账号与分组、Client Key、运行设置、用量/错误观测、S3/R2 备份、系统更新与回滚 |
| 计量 | Token、图片、费用、延迟、账号与 Provider 归因,并记录上游响应确认的实际 `service_tier` |
架构边界、状态所有权和请求不变量见 [架构文档](docs/architecture.md)。
## 快速开始
需要 Docker Engine 与 Docker Compose Plugin。
### 1. 准备
### 1. 准备配置和目录
```bash
git clone https://github.com/zyycn/codex-proxy-rs.git
@@ -56,16 +57,16 @@ openssl rand -hex 24
openssl rand -hex 24
```
把两个结果写入 `deploy/config.yaml`,并单独设置管理员初始密码:
把两个结果分别写入 `deploy/config.yaml`,再设置管理员初始密码:
| 配置 | 约束 |
| -------------------------- | ---------------------------- |
| `store.database.password` | 48 位十六进制 |
| `store.redis.password` | 48 位十六进制 |
| `admin.default_password` | 至少 12 位,不能包含 `$` |
| 配置 | 约束 |
| --- | --- |
| `store.database.password` | 48 位十六进制 |
| `store.redis.password` | 48 位十六进制 |
| `admin.default_password` | 至少 12 位,不能包含 `$` |
PostgreSQL、Redis 容器和应用通过同一份 `config.yaml` 使用这两个基础设施密码,不需要额外
导出环境变量。管理员密码只在首次创建管理员时生效。
PostgreSQL、Redis 和应用从同一份 `config.yaml` 读取基础设施密码,不需要额外导出环境变量。
管理员密码只在首次创建管理员时生效。
Linux 需要允许容器组写入运行目录:
@@ -83,37 +84,36 @@ docker compose -f deploy/compose.yaml up -d --no-build
curl -i http://127.0.0.1:8080/healthz
```
`204 No Content` 表示应用、PostgreSQL 与 Redis 均可用。管理端地址:
`204 No Content` 表示应用、PostgreSQL 与 Redis 均可用。管理端地址为
`http://127.0.0.1:8080`。
### 3. 初始化
### 3. 初始化网关
1. 使用 `admin@cpr.local` 与初始密码登录。
2. 在账号页选择对应 Provider:OpenAI 支持 OAuth、账号文件与 Agent Identity;xAI 支持 OAuth
和兼容账号文件导入。
3. 按需配置客户端模型到上游模型的全局映射;未命中映射的模型名原样透传。
4. 创建 `sk_...` Client Key,绑定 `openai` 或 `xai`,并设置速率与并发限制。
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT 与 OAuth JSON;
xAI 支持 OAuth 和账号 JSON。
3. 创建账号分组并按需配置全局模型映射。未命中的模型名原样交给候选 Provider。
4. 创建 `sk_...` Client Key,选择可用账号分组,并设置速率和并发限制;空分组范围表示全部账号。
OpenAI 账号导入和首次 OAuth 创建在 credential 提交后会立即尝试一次额度观测;额度接口临时失败不会
回滚已提交的账号。Free、K12 等套餐共用同一套额度与状态转换逻辑,套餐字段只作为账号事实并隔离
对应的模型目录 cache。credential 刷新、额度刷新和正常请求中的被动额度观测是三条独立链路,详见
[账号接口](docs/api.md#5-账号)。
OpenAI RT 导入会先向官方 token endpoint 换取 AT;仅 AT 的账号不具备自动续期能力,
能否参与调度仍取决于 token 中可验证的身份与后续上游事实。账号导入和首次 OAuth 创建会尽力读取一次额度,
额度上游暂时失败不会回滚已提交的账号。
OpenAI 账号的主动重置卡只在用户打开相应弹窗或手工刷新时查询。消费成功后管理端重新查询卡片与额度;
系统不会通过修改本地 `reset_at` 伪造重置。完整账号合同见 [账号接口](docs/api.md#5-账号)。
> [!IMPORTANT]
> xAI 使用 OAuth session,不支持把 xAI API Key 作为上游 credential。
> [!NOTE]
> `admin.default_password` 只在首次创建管理员时生效。
完整部署、权限和密码轮换规则见 [deploy/README.md](deploy/README.md)。
完整的权限、密码轮换、备份和升级规则见 [部署文档](deploy/README.md)。
## 客户端接入
| 配置 | 值 |
| -------- | ---------------------------------------- |
| Base URL | `http://127.0.0.1:8080/v1` |
| API Key | 管理端创建的 `sk_...` |
| 鉴权 | `Authorization: Bearer <client-api-key>` |
| 配置 | 值 |
| --- | --- |
| Base URL | `http://127.0.0.1:8080/v1` |
| API Key | 管理端创建的 `sk_...` |
| 鉴权 | `Authorization: Bearer <client-api-key>` |
```bash
curl http://127.0.0.1:8080/v1/models \
@@ -130,67 +130,67 @@ curl http://127.0.0.1:8080/v1/responses \
```
<details>
<summary>API 路由</summary>
<summary>公开数据面路由</summary>
| 路由 | 用途 |
| ---------------------------------- | ------------------------------ |
| `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 兼容模型详情 |
| 路由 | 用途 |
| --- | --- |
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
| `GET /v1/responses` | Responses WebSocket 升级 |
| `POST /v1/images/generations` | OpenAI Images 生成 JSON 透明转发 |
| `POST /v1/images/edits` | OpenAI Images 编辑 JSON 透明转发 |
| `GET /v1/models` | 当前 Client Key 账号范围内的模型列表 |
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
所有 `/v1/*` 路由都需要客户端 API Key。
所有 `/v1/*` 路由都需要 Client Key。Images 是 OpenAI Provider 自有端点,不读取模型字段,也不参与
文本模型映射。
</details>
完整的客户端与管理端路由、鉴权和 mutation 规则见 [接口文档](docs/api.md)。
完整的客户端与管理端路由、鉴权、请求字段和 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
# 日志
# 查看日志
docker compose -f deploy/compose.yaml logs -f codex-proxy-rs
# 从源码构建
# 从当前源码构建
docker compose -f deploy/compose.yaml build codex-proxy-rs
docker compose -f deploy/compose.yaml up -d
```
> [!IMPORTANT]
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点密钥和自更新状态。删除该目录会永久清除
> 运行状态。
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点和在线更新状态。删除该目录会永久清除
> 本地运行状态。
> [!WARNING]
> 不支持跨大版本在线升级。升级到新的大版本时,请使用全新的 `.runtime/` 数据目录重新部署,
> 并重新导入或重新授权 Provider 账号与客户端 Key。
> 不支持跨大版本在线升级。升级到新的大版本时请使用全新的 `.runtime/` 数据目录,并重新导入或授权
> Provider 账号与 Client Key。
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket
upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket upgrade 与真实客户端
IP;不要暴露 PostgreSQL 或 Redis。当前运行拓扑为单副本,不能通过复制应用容器扩容。
## 维护与发布
发布仓库版本必须从干净、已同步上游的发布分支运行:
仓库版本只能从干净、已同步上游的发布分支发布:
```bash
release/publish <version>
```
脚本依赖已登录的 GitHub CLI(`gh auth login`)。它会更新 `release/version.yaml`、创建约定提交与带
注释的 `v<version>` tag,原子推送分支和 tag,再从发布分支触发 Release 构建,使构建缓存可以跨版本
复用。不要手工修改版本后单独打 tag。该脚本不会登录服务器,也不会改变任何运行实例;源码提交、
仓库发版和实例升级是三个独立状态。
脚本依赖已登录的 GitHub CLI(`gh auth login`),会更新 `release/version.yaml`、创建约定提交和带注释的
`v<version>` tag,原子推送分支与 tag,再触发 Release 构建。源码提交、仓库发版和运行实例升级是三个
独立状态;脚本不会登录服务器或改变任何运行实例。
## 文档
- [部署](deploy/README.md)
- [部署与运维](deploy/README.md)
- [客户端与管理端接口](docs/api.md)
- [架构与数据边界](docs/architecture.md)
- [系统架构](docs/architecture.md)
- [数据库迁移规则](backend/migrations/README.md)
- [Release](https://github.com/zyycn/codex-proxy-rs/releases)
- [容器镜像](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
+332 -274
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -27,8 +27,9 @@ impl LoadableConfig for GatewayConfig {
return Err(ConfigError::InvalidField("schema_version"));
}
self.host.resolve_and_validate(source_dir)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(source_dir)
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("store"))?;
self.admin
.resolve_and_validate(source_dir)
@@ -37,7 +38,7 @@ impl LoadableConfig for GatewayConfig {
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.openai
.resolve_and_validate(source_dir)
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
self.xai
.resolve_and_validate(source_dir)
@@ -78,6 +79,7 @@ pub async fn run() -> Result<(), BootstrapError> {
vec![openai.admin_provider(), xai.admin_provider()],
core.snapshot_control(),
core.account_probe(),
host.client_distribution_resolver(),
host.system_operations(),
)
.await?;
+1 -1
View File
@@ -1,6 +1,6 @@
use std::{env, error::Error, io};
use provider_openai::transport::tls::ensure_rustls_provider;
use provider_openai::ensure_rustls_provider;
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
ensure_rustls_provider();
+249 -2
View File
@@ -1,12 +1,15 @@
//! docs/architecture.md 依赖 DAG 与生产源码纪律的 workspace 级机器校验。
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
use syn::Item;
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。
const WORKSPACE_MEMBERS: &[&str] = &[
pub(super) const WORKSPACE_MEMBERS: &[&str] = &[
"apps/gateway",
"crates/gateway-admin",
"crates/gateway-api",
@@ -95,6 +98,36 @@ const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
("provider-xai", "crates/providers/xai"),
];
/// Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
("crates/gateway-api", &["admin", "openai"]),
(
"crates/gateway-host",
&[
"client_distribution",
"config",
"serve",
"system_update",
"workers",
],
),
("crates/gateway-store", &["backup", "postgres", "redis"]),
(
"crates/providers/openai",
&["config", "credential", "transport"],
),
(
"crates/providers/xai",
&["config", "credential", "transport"],
),
];
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景。
const ROOT_TEST_SCENARIOS: &[(&str, &[&str])] = &[
("apps/gateway", &["architecture"]),
("crates/gateway-api", &["architecture"]),
];
/// 冻结的 workspace 内部运行时依赖边;新增/删除任何边都必须同步本表。
const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
("codex-proxy-rs", "gateway-admin"),
@@ -167,6 +200,35 @@ fn workspace_internal_dependency_edges_match_frozen_dag() {
);
}
#[test]
fn adapter_public_module_surfaces_match_allowlist() {
for (member, allowed) in ADAPTER_PUBLIC_MODULES {
let root = backend_root().join(member).join("src/lib.rs");
let source = fs::read_to_string(&root).expect("read adapter crate root");
let syntax = syn::parse_file(&source).expect("parse adapter crate root");
let actual = syntax
.items
.iter()
.filter_map(|item| match item {
Item::Mod(module) if matches!(module.vis, syn::Visibility::Public(_)) => {
Some(module.ident.to_string())
}
_ => None,
})
.collect::<BTreeSet<_>>();
let expected = allowed
.iter()
.map(|module| (*module).to_owned())
.collect::<BTreeSet<_>>();
assert_eq!(
actual,
expected,
"{} public module surface diverged from its allowlist",
root.display()
);
}
}
#[test]
fn production_sources_do_not_host_tests() {
for member in WORKSPACE_MEMBERS {
@@ -185,7 +247,192 @@ fn production_sources_do_not_host_tests() {
}
}
fn backend_root() -> PathBuf {
#[test]
fn workspace_modules_follow_conventional_file_layout() {
for member in WORKSPACE_MEMBERS {
let member_root = backend_root().join(member);
assert_module_tree(&member_root.join("src"), &["lib.rs", "main.rs"]);
let tests = member_root.join("tests");
if tests.is_dir() {
assert_module_tree(&tests, &["main.rs"]);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModuleLayout {
File,
Directory,
}
#[test]
fn integration_tests_mirror_production_module_tree() {
for member in WORKSPACE_MEMBERS {
let member_root = backend_root().join(member);
let tests_root = member_root.join("tests");
if !tests_root.is_dir() {
continue;
}
let production = module_layouts(&member_root.join("src"), &["lib.rs", "main.rs"]);
let tests = module_layouts(&tests_root, &["main.rs"]);
for (module, test_layout) in tests {
if module == Path::new("support") {
continue;
}
if let Some(production_layout) = production.get(&module) {
assert_eq!(
test_layout,
*production_layout,
"{} must mirror the production module layout for {}",
tests_root.join(module.with_extension("rs")).display(),
member_root.join("src").join(&module).display(),
);
continue;
}
let has_production_owner = module
.parent()
.into_iter()
.flat_map(Path::ancestors)
.take_while(|ancestor| !ancestor.as_os_str().is_empty())
.any(|ancestor| production.contains_key(ancestor));
assert!(
has_production_owner || root_test_scenario_allowed(member, &module),
"{} has no mirrored production module owner",
tests_root.join(module.with_extension("rs")).display(),
);
}
}
}
fn module_layouts(root: &Path, crate_roots: &[&str]) -> BTreeMap<PathBuf, ModuleLayout> {
super::rust_files(root)
.into_iter()
.filter(|relative| {
!crate_roots
.iter()
.any(|candidate| relative == Path::new(candidate))
})
.map(|relative| {
if relative.file_name().and_then(|value| value.to_str()) == Some("mod.rs") {
(
relative.parent().expect("mod.rs parent").to_path_buf(),
ModuleLayout::Directory,
)
} else {
(relative.with_extension(""), ModuleLayout::File)
}
})
.collect()
}
fn root_test_scenario_allowed(member: &str, module: &Path) -> bool {
let Some(module) = module.to_str() else {
return false;
};
ROOT_TEST_SCENARIOS
.iter()
.any(|(candidate, scenarios)| *candidate == member && scenarios.contains(&module))
}
fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
let files = super::rust_files(root);
for relative in &files {
if crate_roots
.iter()
.any(|candidate| relative == Path::new(candidate))
{
continue;
}
let file_name = relative
.file_name()
.and_then(|value| value.to_str())
.expect("Rust source file name");
let parent = relative.parent().expect("Rust source parent");
let module_name = if file_name == "mod.rs" {
parent
.file_name()
.and_then(|value| value.to_str())
.expect("mod.rs module name")
} else {
relative
.file_stem()
.and_then(|value| value.to_str())
.expect("Rust source module name")
};
if file_name != "mod.rs" {
assert_ne!(
parent.file_name().and_then(|value| value.to_str()),
Some(module_name),
"{} repeats its parent module name",
root.join(relative).display()
);
let child_directory = relative.with_extension("");
assert!(
!files
.iter()
.any(|candidate| candidate.starts_with(&child_directory)),
"{} mixes a leaf module with a same-name module directory",
root.join(relative).display()
);
}
let declaration_parents = if parent.as_os_str().is_empty()
|| (file_name == "mod.rs"
&& parent
.parent()
.is_some_and(|ancestor| ancestor.as_os_str().is_empty()))
{
crate_roots
.iter()
.map(|candidate| root.join(candidate))
.collect::<Vec<_>>()
} else {
let declaration_directory = if file_name == "mod.rs" {
parent.parent().expect("nested mod.rs parent")
} else {
parent
};
vec![root.join(declaration_directory).join("mod.rs")]
};
let declaration_count = declaration_parents
.iter()
.map(|path| external_module_declaration_count(path, module_name))
.sum::<usize>();
assert_eq!(
declaration_count,
1,
"{} must be declared exactly once by its parent module",
root.join(relative).display()
);
}
}
fn external_module_declaration_count(path: &Path, module_name: &str) -> usize {
if !path.is_file() {
return 0;
}
let source = fs::read_to_string(path).expect("read parent module source");
let syntax = syn::parse_file(&source).expect("parse parent module source");
syntax
.items
.iter()
.filter(|item| {
matches!(
item,
Item::Mod(module)
if module.content.is_none() && module.ident == module_name
)
})
.count()
}
pub(super) fn backend_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
+14 -3
View File
@@ -18,10 +18,18 @@ fn config_loader_should_load_complete_terminal_example() {
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/data"));
assert!(debug.contains(".runtime/logs"));
assert!(debug.contains("frontend/dist"));
}
#[test]
fn config_loader_should_reject_missing_runtime_data_dir() {
let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", "");
assert!(parse_config(&config).is_err());
}
#[test]
fn config_loader_should_inject_connection_passwords_into_urls() {
parse_config(&valid_config()).expect("Store validates password injection into both URLs");
@@ -134,8 +142,11 @@ fn config_loader_should_reject_removed_fingerprint_section() {
}
#[test]
fn config_loader_should_reject_invalid_codex_core_version() {
assert_rejected(valid_config().replace("codex_version: '0.146.0'", "codex_version: 'latest'"));
fn config_loader_should_reject_invalid_codex_cli_version() {
assert_rejected(valid_config().replace(
"codex_version: '0.151.0-alpha.7.2'",
"codex_version: 'latest'",
));
}
#[test]
@@ -154,7 +165,7 @@ fn config_loader_should_reject_zero_server_port() {
#[test]
fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replace("desktop_build: '6119'", "desktop_build: 'build'"));
assert_rejected(valid_config().replace("desktop_build: '7377'", "desktop_build: 'build'"));
}
fn assert_rejected(config: String) {
+27 -25
View File
@@ -43,31 +43,33 @@ fn cargo_library_root_is_conventional_lib() {
}
#[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(),
);
fn workspace_production_files_have_no_hidden_modules_or_test_hooks() {
for member in architecture::WORKSPACE_MEMBERS {
let root = architecture::backend_root().join(member).join("src");
for relative in rust_files(&root) {
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(),
);
}
}
}
}
+3 -2
View File
@@ -12,9 +12,10 @@ name = "main"
path = "tests/main.rs"
[dependencies]
argon2 = "0.5.3"
argon2 = "0.6.0"
async-trait = "0.1.89"
base64 = "0.23.0"
bytes = "1.11.1"
chrono = "0.4.45"
futures = "0.3.32"
gateway-core = { path = "../gateway-core" }
@@ -28,7 +29,7 @@ tracing = "0.1.44"
uuid = { version = "1.23.3", features = ["v7"] }
cron = "0.17"
chrono-tz = "0.10"
tokio = { version = "1.52", features = ["rt", "time", "macros"] }
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
[dev-dependencies]
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
@@ -119,11 +119,11 @@ pub struct RetentionDecision {
/// 从按完成时间倒序的 completed 计划备份中决定纳入删除流程的记录。
///
/// 规则(`docs/s3-backup-design-audit.md` §4.4):
/// 自动保留规则:
/// - `retentionDays = 0` 时不按天数清理;`retentionCount = 0` 时不按份数清理。
/// - 自动计划备份满足任一启用阈值即可进入删除。
/// - 无论阈值如何,至少保留最近一个成功的计划备份(倒序下标 0)。
/// - 手动备份不受自动阈值影响;无 TTL,仅由管理员手动删除。
/// - 手动备份不进入本规则;手工/计划记录各自的 `expires_at` 由到期扫描处理。
#[must_use]
pub fn decide_retention(
retention_days: u32,
+60 -14
View File
@@ -9,7 +9,7 @@ use std::time::Duration;
use chrono::{DateTime, Utc};
use futures::future::BoxFuture;
use gateway_core::{
engine::CancellationToken,
lifecycle::CancellationToken,
task::{DaemonTask, WorkerTaskError},
};
use tracing::{info, warn};
@@ -220,7 +220,8 @@ impl BackupTask {
sha256: Some(artifact.sha256.clone()),
..Default::default()
};
self.repository
let Some(uploading_record) = self
.repository
.transition_status(
&record.id,
BackupStatus::Dumping,
@@ -229,9 +230,12 @@ impl BackupTask {
now,
)
.await
.map_err(repo_error)?;
.map_err(repo_error)?
else {
return Ok(());
};
self.upload_and_verify(
record,
&uploading_record,
&artifact.path,
artifact.size_bytes,
&artifact.sha256,
@@ -394,7 +398,8 @@ impl BackupTask {
sha256: Some(artifact.sha256.clone()),
..Default::default()
};
self.repository
let Some(uploading_record) = self
.repository
.transition_status(
&record.id,
BackupStatus::Dumping,
@@ -403,10 +408,13 @@ impl BackupTask {
now,
)
.await
.map_err(repo_error)?;
.map_err(repo_error)?
else {
return Ok(());
};
self.upload_and_verify(
record,
&uploading_record,
&artifact.path,
artifact.size_bytes,
&artifact.sha256,
@@ -448,8 +456,12 @@ impl BackupTask {
.await
{
let _ = store.delete_object(&config, &record.object_key).await;
self.fail_task(record, error.code(), "上传到对象存储失败")
.await?;
self.fail_task(
record,
error.code(),
format!("上传到对象存储失败:{}", error.message()),
)
.await?;
return Ok(());
}
@@ -475,19 +487,46 @@ impl BackupTask {
);
}
}
Ok(_) => {
Ok(Some(remote)) => {
let size_matches = remote.size_bytes == size_bytes;
let sha256_matches = remote.sha256 == sha256;
warn!(
backup_id = %record.id,
expected_size_bytes = size_bytes,
remote_size_bytes = remote.size_bytes,
sha256_matches,
"备份远端对象校验不一致"
);
let _ = store.delete_object(&config, &record.object_key).await;
self.fail_task(
record,
code::REMOTE_VERIFICATION_FAILED,
"远端对象大小或校验值不一致",
match (size_matches, sha256_matches) {
(false, true) => "远端对象大小不一致",
(true, false) => "远端对象 sha256 校验值不一致",
(false, false) => "远端对象大小和 sha256 校验值均不一致",
(true, true) => "远端对象校验状态异常",
},
)
.await?;
}
Ok(None) => {
warn!(backup_id = %record.id, "上传完成后未找到远端对象");
self.fail_task(
record,
code::REMOTE_VERIFICATION_FAILED,
"上传完成后未找到远端对象",
)
.await?;
}
Err(error) => {
let _ = store.delete_object(&config, &record.object_key).await;
self.fail_task(record, error.code(), "远端对象校验失败")
.await?;
self.fail_task(
record,
error.code(),
format!("远端对象校验失败:{}", error.message()),
)
.await?;
}
}
Ok(())
@@ -500,10 +539,17 @@ impl BackupTask {
error_code: &'static str,
message: impl Into<String>,
) -> Result<(), WorkerTaskError> {
let message = message.into();
warn!(
backup_id = %record.id,
error_code,
error_message = %message,
"备份任务失败"
);
let now = Utc::now();
let update = StatusTransitionUpdate {
error_code: Some(error_code.to_string()),
error_message: Some(message.into()),
error_message: Some(message),
completed_at: Some(now),
..Default::default()
};
+42 -15
View File
@@ -6,7 +6,8 @@ use std::{fmt, path::Path, sync::Arc, time::Duration};
use gateway_core::{
engine::probe::AccountProbe,
routing::{ProviderKind, snapshot::SnapshotControl},
routing::ProviderKind,
runtime::SnapshotControl,
task::{
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerRegistration,
WorkerRunnable,
@@ -21,22 +22,25 @@ pub mod ports;
mod use_case;
pub use use_case::{
accounts::AccountsService, auth::AuthService, backup::BackupService,
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
backup::BackupService, client_distribution::ClientDistributionService,
client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService,
settings::SettingsService, system::SystemService, xai::XaiService,
};
use model::{AdminError, AdminErrorKind};
use ports::{
client_distribution::ClientDistributionResolver,
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry},
store::AdminStorePorts,
system::SystemOperations,
};
use use_case::{
accounts::DefaultAccountsService, auth::DefaultAuthService, backup::DefaultBackupService,
client_keys::DefaultClientKeyService, observability::DefaultObservabilityService,
openai::DefaultOpenAiService, settings::DefaultSettingsService, system::DefaultSystemService,
xai::DefaultXaiService,
account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService,
auth::DefaultAuthService, backup::DefaultBackupService,
client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService,
observability::DefaultObservabilityService, openai::DefaultOpenAiService,
settings::DefaultSettingsService, system::DefaultSystemService, xai::DefaultXaiService,
};
const OPENAI_PROVIDER_KIND: &str = "openai";
@@ -134,9 +138,9 @@ impl fmt::Debug for AdminConfig {
/// Admin-owned 启动配置错误;不回显任何配置值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AdminConfigError {
#[error("configuration field `{0}` is invalid")]
#[error("配置字段 `{0}` 不合法")]
InvalidField(&'static str),
#[error("admin.default_password does not meet the initial password policy")]
#[error("admin.default_password 不符合初始密码策略")]
WeakInitialPassword,
}
@@ -147,7 +151,9 @@ pub enum AdminConfigError {
pub struct AdminServices {
auth: Arc<dyn AuthService>,
accounts: Arc<dyn AccountsService>,
account_groups: Arc<dyn AccountGroupService>,
client_keys: Arc<dyn ClientKeyService>,
client_distribution: Arc<dyn ClientDistributionService>,
observability: Arc<dyn ObservabilityService>,
settings: Arc<dyn SettingsService>,
system: Arc<dyn SystemService>,
@@ -167,11 +173,21 @@ impl AdminServices {
self.accounts.as_ref()
}
#[must_use]
pub fn account_groups(&self) -> &dyn AccountGroupService {
self.account_groups.as_ref()
}
#[must_use]
pub fn client_keys(&self) -> &dyn ClientKeyService {
self.client_keys.as_ref()
}
#[must_use]
pub fn client_distribution(&self) -> &dyn ClientDistributionService {
self.client_distribution.as_ref()
}
#[must_use]
pub fn observability(&self) -> &dyn ObservabilityService {
self.observability.as_ref()
@@ -232,6 +248,7 @@ pub async fn initialize(
providers: Vec<Arc<dyn ProviderAdmin>>,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
client_distribution: Arc<dyn ClientDistributionResolver>,
system: Arc<dyn SystemOperations>,
) -> Result<AdminBundle, AdminError> {
config
@@ -255,7 +272,7 @@ pub async fn initialize(
let accounts = Arc::new(DefaultAccountsService::new(
store.accounts(),
store.settings(),
store.account_runtime(),
registry.clone(),
snapshot.clone(),
probe.clone(),
@@ -275,10 +292,16 @@ pub async fn initialize(
let services = AdminServices {
auth,
accounts,
account_groups: Arc::new(DefaultAccountGroupService::new(
store.account_groups(),
store.account_runtime(),
snapshot.clone(),
)),
client_keys: Arc::new(DefaultClientKeyService::new(
store.client_keys(),
snapshot.clone(),
)),
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
observability: Arc::new(DefaultObservabilityService::new(
store.observability(),
store.settings(),
@@ -313,9 +336,9 @@ fn backup_worker_contribution(
task: backup::task::BackupTask,
) -> Result<Vec<WorkerContribution>, AdminError> {
let id = WorkerId::try_new(WorkerKind::Backup, BACKUP_WORKER_OWNER)
.map_err(|_| AdminError::internal("Backup worker id is invalid"))?;
.map_err(|_| AdminError::internal("备份 Worker ID 不合法"))?;
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
.map_err(|_| AdminError::internal("Backup worker restart policy is invalid"))?;
.map_err(|_| AdminError::internal("备份 Worker 重启策略不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Daemon {
@@ -323,12 +346,12 @@ fn backup_worker_contribution(
task: Box::new(task),
},
)
.map_err(|_| AdminError::internal("Backup worker registration is invalid"))?;
.map_err(|_| AdminError::internal("备份 Worker 注册信息不合法"))?;
Ok(vec![WorkerContribution::Registration(registration)])
}
fn provider_kind(value: &'static str) -> Result<ProviderKind, AdminError> {
ProviderKind::new(value).map_err(|_| AdminError::internal("Built-in Provider kind is invalid"))
ProviderKind::new(value).map_err(|_| AdminError::internal("内置 Provider 类型不合法"))
}
fn map_provider_registry_error(error: ProviderAdminError) -> AdminError {
@@ -338,8 +361,12 @@ fn map_provider_registry_error(error: ProviderAdminError) -> AdminError {
}
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
ProviderAdminErrorKind::Unavailable | ProviderAdminErrorKind::CredentialRefreshRequired => {
AdminErrorKind::Unavailable
}
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, "Provider registry initialization failed")
AdminError::new(kind, "Provider 注册表初始化失败")
}
@@ -0,0 +1,152 @@
//! Provider-neutral account group commands and projections.
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use gateway_core::{account::AccountStatusFacts, routing::AccountGroupId};
use super::{PageSize, Revision, observability::DecimalAmount};
/// Canonical `#RRGGBBAA` color persisted with an account group.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupColor(String);
impl AccountGroupColor {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
(value.len() == 9
&& value.starts_with('#')
&& value[1..].bytes().all(|byte| byte.is_ascii_hexdigit()))
.then(|| Self(value.to_ascii_uppercase()))
}
}
/// Group member availability at the current observation time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountGroupAccountSummary {
pub available: u64,
pub limited: u64,
pub total: u64,
}
/// Group scheduling slots derived from available accounts and runtime leases.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountGroupCapacity {
pub used_slots: Option<u64>,
pub total_slots: u64,
}
/// Successful, downstream-committed USD request costs for group accounts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupUsage {
pub today_usd: DecimalAmount,
/// 当前 usage retention 窗口内、按请求发生时 routing group 快照归属的累计成本。
pub retained_total_usd: DecimalAmount,
}
/// 当前页账号组 membership 对应的持久账号事实;运行态在 Admin query service 合并。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupMemberFact {
pub group_id: AccountGroupId,
pub account_id: String,
pub status: AccountStatusFacts,
pub total_slots: u64,
}
/// Lightweight group reference embedded in account and client-key views.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupRef {
pub id: AccountGroupId,
pub name: String,
pub color: AccountGroupColor,
pub enabled: bool,
}
/// Account group list query.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupListQuery {
pub page: u32,
pub page_size: PageSize,
pub search: Option<String>,
pub enabled: Option<bool>,
}
/// Complete account group summary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupRecord {
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
pub color: AccountGroupColor,
pub enabled: bool,
pub member_count: u64,
pub provider_counts: BTreeMap<String, u64>,
pub client_key_count: u64,
pub account_summary: AccountGroupAccountSummary,
pub capacity: AccountGroupCapacity,
pub usage: AccountGroupUsage,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Paginated account groups.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupPage {
pub config_revision: Revision,
pub items: Vec<AccountGroupRecord>,
pub total: u64,
pub page: u32,
pub page_size: u16,
}
/// Create an account group.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAccountGroup {
pub name: String,
pub description: Option<String>,
pub color: AccountGroupColor,
}
/// Store-ready create command with a generated stable ID.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewAccountGroup {
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
pub color: AccountGroupColor,
}
/// Update an account group's descriptive fields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccountGroup {
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
pub color: AccountGroupColor,
}
/// Enable or disable an account group.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetAccountGroupEnabled {
pub id: AccountGroupId,
pub enabled: bool,
}
/// Delete an account group that is not referenced by a client key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteAccountGroup {
pub id: AccountGroupId,
}
/// Result of any account-group mutation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupMutation {
pub config_revision: Revision,
pub id: AccountGroupId,
pub record: Option<AccountGroupRecord>,
}
@@ -1,155 +1,22 @@
//! 多 Provider 账号目录与连接测试的公共事实。
use std::pin::Pin;
use std::{collections::BTreeMap, pin::Pin};
use chrono::{DateTime, Utc};
use futures::Stream;
use gateway_core::routing::ProviderKind;
use gateway_core::{
engine::probe::AccountProbeErrorSource, error::GatewayErrorKind, routing::ProviderKind,
upstream::UpstreamSendState,
};
use super::{PageSize, Revision, observability::TimeRange};
use super::{PageSize, Revision, account_groups::AccountGroupRef, observability::TimeRange};
/// Provider 账号当前可用性;权威定义与字符串编解码在 gateway-core,此处仅复用。
pub use gateway_core::engine::credential::AccountAvailability;
/// 管理页使用的归一化账号状态。
///
/// 只回答两个问题:是否可调度、是否需要人工干预。具体错误原因不进入此枚举,
/// 由 [`AccountErrorReason`] 承载;新增原因只扩展 reason,不改变分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountStatus {
/// 正常,可调度。
Normal,
/// 配额耗尽(持久化终态),等待额度恢复,无需干预。
QuotaExhausted,
/// 限流中(quota 快照级信号),自动恢复,无需干预。
RateLimited,
/// 管理员手动停用(`enabled = false`)。
Disabled,
/// 错误(过期 / 失效 / 封禁等),需要人工干预。具体原因见 [`AccountErrorReason`]。
Error,
}
impl AccountStatus {
/// 返回稳定 wire 值。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Normal => "normal",
Self::QuotaExhausted => "quota_exhausted",
Self::RateLimited => "rate_limited",
Self::Disabled => "disabled",
Self::Error => "error",
}
}
/// 解析稳定 wire 值。
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value {
"normal" => Some(Self::Normal),
"quota_exhausted" => Some(Self::QuotaExhausted),
"rate_limited" => Some(Self::RateLimited),
"disabled" => Some(Self::Disabled),
"error" => Some(Self::Error),
_ => None,
}
}
}
/// 状态派生所需的最小事实;由 store / use_case 各自构造,派生规则唯一。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountStatusSignals {
pub enabled: bool,
pub availability: AccountAvailability,
pub access_token_expired: bool,
pub quota_limit_reached: bool,
}
impl AccountStatusSignals {
/// 派生产出运营分类。终态优先于瞬时信号:停用 > 配额耗尽 > 错误 > 限流。
#[must_use]
pub const fn derive(self) -> AccountStatus {
if !self.enabled {
AccountStatus::Disabled
} else if matches!(self.availability, AccountAvailability::QuotaExhausted) {
AccountStatus::QuotaExhausted
} else if self.access_token_expired
|| matches!(
self.availability,
AccountAvailability::Expired
| AccountAvailability::Invalid
| AccountAvailability::Banned
| AccountAvailability::Unknown
)
{
AccountStatus::Error
} else if self.quota_limit_reached {
AccountStatus::RateLimited
} else {
AccountStatus::Normal
}
}
}
/// 错误分类下的具体原因;仅在 `AccountStatus::Error` 时存在。
///
/// 受控枚举用于展示与聚合,扩展不改变状态分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountErrorReason {
Expired,
TokenExpired,
Invalid,
Banned,
Unknown,
}
impl AccountErrorReason {
/// 返回稳定 wire 值。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Expired => "expired",
Self::TokenExpired => "token_expired",
Self::Invalid => "invalid",
Self::Banned => "banned",
Self::Unknown => "unknown",
}
}
/// 解析稳定 wire 值。
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value {
"expired" => Some(Self::Expired),
"token_expired" => Some(Self::TokenExpired),
"invalid" => Some(Self::Invalid),
"banned" => Some(Self::Banned),
"unknown" => Some(Self::Unknown),
_ => None,
}
}
}
/// 由信号派生错误原因;非错误分类(正常 / 配额耗尽 / 限流 / 停用)返回 `None`。
///
/// 与 [`AccountStatusSignals::derive`] 保持同一判定:`Error` 当且仅当此处返回 `Some`。
#[must_use]
pub fn derive_error_reason(signals: AccountStatusSignals) -> Option<AccountErrorReason> {
if !signals.enabled {
return None;
}
match signals.availability {
AccountAvailability::QuotaExhausted => None,
AccountAvailability::Ready => signals
.access_token_expired
.then_some(AccountErrorReason::TokenExpired),
AccountAvailability::Expired => Some(AccountErrorReason::Expired),
AccountAvailability::Invalid => Some(AccountErrorReason::Invalid),
AccountAvailability::Banned => Some(AccountErrorReason::Banned),
AccountAvailability::Unknown => Some(AccountErrorReason::Unknown),
}
}
pub use gateway_core::account::{
AccountConcurrencyLimit, AccountErrorReason, AccountStatus, AccountStatusFacts,
AccountStatusProjection, AccountWeight, CredentialState, QuotaAccessState, QuotaEvidence,
QuotaState, resolve_account_status,
};
/// 账号列表排序字段。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -182,19 +49,36 @@ pub struct AccountListQuery {
pub page: u32,
pub page_size: PageSize,
pub provider_kind: Option<ProviderKind>,
pub group_filter: Option<AccountGroupFilter>,
pub search: Option<String>,
pub status: Option<AccountStatus>,
pub sort: Option<AccountSort>,
}
/// Admin query service 从运行态存储取得的当前账号冷却快照。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AccountRuntimeSnapshot {
pub rate_limited_until: BTreeMap<String, DateTime<Utc>>,
/// `None` 表示实时 lease 存储不可用;`Some` 中未出现的账号当前使用量为零。
pub in_flight: Option<BTreeMap<String, u64>>,
}
/// Optional account membership filter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountGroupFilter {
Group(gateway_core::routing::AccountGroupId),
Ungrouped,
}
/// 账号公共存储投影;Provider 专属字段不进入此结构。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRecord {
pub id: String,
pub provider_kind: ProviderKind,
pub groups: Vec<AccountGroupRef>,
pub name: String,
pub email: Option<String>,
pub upstream_user_id: String,
pub upstream_user_id: Option<String>,
pub upstream_account_id: Option<String>,
pub plan_type: Option<String>,
pub authentication_kind: String,
@@ -203,11 +87,13 @@ pub struct AccountRecord {
pub access_token_expires_at: Option<DateTime<Utc>>,
pub next_refresh_at: Option<DateTime<Utc>>,
pub enabled: bool,
pub availability: AccountAvailability,
pub availability_observed_at: DateTime<Utc>,
/// 最近一次非 ready 状态变更的原始原因(上游错误原文 / 原因码);恢复 ready 后清空。
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub credential_state: CredentialState,
pub credential_observed_at: DateTime<Utc>,
pub quota: QuotaState,
pub last_error_reason: Option<AccountErrorReason>,
pub last_error_message: Option<String>,
pub quota_observed_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -292,11 +178,18 @@ pub struct AccountUsageWindowResult {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountPage {
pub config_revision: Revision,
pub items: Vec<AccountRecord>,
pub items: Vec<AccountPageItem>,
pub total: u64,
pub summary: AccountSummary,
}
/// 同一状态快照下的账号事实与唯一状态投影。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountPageItem {
pub account: AccountRecord,
pub projection: AccountStatusProjection,
}
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响。
///
/// 计数与 [`AccountStatus`] 一一对应,由 store 按派生状态聚合。
@@ -310,11 +203,38 @@ pub struct AccountSummary {
pub error: u64,
}
/// 账号启停写入命令。
/// 账号可编辑事实的一次性替换命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetAccountEnabled {
pub struct UpdateAccount {
pub account_id: String,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
}
/// 账号更新结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountUpdateResult {
pub config_revision: Revision,
pub account_id: gateway_core::account::ProviderAccountId,
}
/// 一批账号可编辑事实的一次性替换命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchUpdateAccounts {
pub account_ids: Vec<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
}
/// 批量账号更新结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountsUpdateResult {
pub config_revision: Revision,
pub account_ids: Vec<gateway_core::account::ProviderAccountId>,
}
/// 账号批量删除命令。
@@ -338,17 +258,17 @@ pub enum AccountConnectionTestEvent {
Content {
text: String,
},
Completed {
account_status: AccountStatus,
},
Completed,
Failed {
source: AccountProbeErrorSource,
gateway_error_code: GatewayErrorKind,
send_state: Option<UpstreamSendState>,
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,
},
}
@@ -0,0 +1,34 @@
//! 备份领域的稳定错误码。
/// 配置或参数非法。
pub const INVALID_CONFIG: &str = "backup.invalid_config";
/// Cron 表达式非法。
pub const INVALID_CRON: &str = "backup.invalid_cron";
/// IANA 时区非法。
pub const INVALID_TIMEZONE: &str = "backup.invalid_timezone";
/// 记录不存在。
pub const RECORD_NOT_FOUND: &str = "backup.record_not_found";
/// 已存在活跃任务,拒绝创建。
pub const ACTIVE_TASK_CONFLICT: &str = "backup.active_task_conflict";
/// 记录状态与期望不符,无法迁移。
pub const STATE_CONFLICT: &str = "backup.state_conflict";
/// 已有记录时禁止切换存储目标。
pub const STORAGE_IDENTITY_LOCKED: &str = "backup.storage_identity_locked";
/// `pg_dump` 非零退出或失败。
pub const PG_DUMP_FAILED: &str = "backup.pg_dump_failed";
/// 暂存磁盘空间不足。
pub const STAGING_SPACE_EXHAUSTED: &str = "backup.staging_space_exhausted";
/// S3 认证失败。
pub const S3_AUTH_FAILED: &str = "backup.s3_auth_failed";
/// S3 权限拒绝。
pub const S3_PERMISSION_DENIED: &str = "backup.s3_permission_denied";
/// S3 上传失败。
pub const S3_UPLOAD_FAILED: &str = "backup.s3_upload_failed";
/// 远端对象校验失败。
pub const REMOTE_VERIFICATION_FAILED: &str = "backup.remote_verification_failed";
/// 任务被取消。
pub const CANCELLED: &str = "backup.cancelled";
/// 仓储或对象存储暂不可用。
pub const STORE_UNAVAILABLE: &str = "backup.store_unavailable";
/// 上游 S3 服务返回无效或失败响应。
pub const UPSTREAM_FAILURE: &str = "backup.upstream_failure";
@@ -288,7 +288,7 @@ pub struct BackupRecord {
pub error_message: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
/// 手动过期时间;创建时确定,到期自动清理。
/// 创建时确定的过期时间;手工和计划备份均可设置,到期自动清理。
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -447,40 +447,7 @@ impl BackupError {
}
/// 构造稳定错误码常量。
pub mod code {
/// 配置或参数非法。
pub const INVALID_CONFIG: &str = "backup.invalid_config";
/// Cron 表达式非法。
pub const INVALID_CRON: &str = "backup.invalid_cron";
/// IANA 时区非法。
pub const INVALID_TIMEZONE: &str = "backup.invalid_timezone";
/// 记录不存在。
pub const RECORD_NOT_FOUND: &str = "backup.record_not_found";
/// 已存在活跃任务,拒绝创建。
pub const ACTIVE_TASK_CONFLICT: &str = "backup.active_task_conflict";
/// 记录状态与期望不符,无法迁移。
pub const STATE_CONFLICT: &str = "backup.state_conflict";
/// 已有记录时禁止切换存储目标。
pub const STORAGE_IDENTITY_LOCKED: &str = "backup.storage_identity_locked";
/// `pg_dump` 非零退出或失败。
pub const PG_DUMP_FAILED: &str = "backup.pg_dump_failed";
/// 暂存磁盘空间不足。
pub const STAGING_SPACE_EXHAUSTED: &str = "backup.staging_space_exhausted";
/// S3 认证失败。
pub const S3_AUTH_FAILED: &str = "backup.s3_auth_failed";
/// S3 权限拒绝。
pub const S3_PERMISSION_DENIED: &str = "backup.s3_permission_denied";
/// S3 上传失败。
pub const S3_UPLOAD_FAILED: &str = "backup.s3_upload_failed";
/// 远端对象校验失败。
pub const REMOTE_VERIFICATION_FAILED: &str = "backup.remote_verification_failed";
/// 任务被取消。
pub const CANCELLED: &str = "backup.cancelled";
/// 仓储或对象存储暂不可用。
pub const STORE_UNAVAILABLE: &str = "backup.store_unavailable";
/// 上游 S3 服务返回无效或失败响应。
pub const UPSTREAM_FAILURE: &str = "backup.upstream_failure";
}
pub mod code;
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.dump`。
///
@@ -0,0 +1,58 @@
//! 管理设置页使用的 Codex 客户端下载信息。
use chrono::{DateTime, Utc};
/// Windows 安装包架构。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ClientArchitecture {
X64,
Arm64,
}
impl ClientArchitecture {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::X64 => "x64",
Self::Arm64 => "arm64",
}
}
}
/// 下载地址的可信来源。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientDownloadSource {
MicrosoftStore,
OfficialOpenAi,
}
impl ClientDownloadSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::MicrosoftStore => "microsoft_store",
Self::OfficialOpenAi => "official_openai",
}
}
}
/// 一个已经过 Host 校验、可以直接交给浏览器下载的安装包。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientDownloadPackage {
pub architecture: ClientArchitecture,
pub source: ClientDownloadSource,
pub version: Option<String>,
pub file_name: String,
pub size_bytes: Option<u64>,
pub download_url: String,
pub expires_at: Option<DateTime<Utc>>,
}
/// Codex Desktop Windows 下载解析结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexDesktopWindowsDownloads {
pub resolved_at: DateTime<Utc>,
pub cached: bool,
pub warning: Option<String>,
pub packages: Vec<ClientDownloadPackage>,
}
@@ -6,10 +6,10 @@ use chrono::{DateTime, Utc};
use gateway_core::{
policy::{ClientApiKeyId, RateLimits},
routing::ProviderKind,
routing::{AccountGroupId, ProviderKind},
};
use super::{AdminModelError, Revision};
use super::{AdminModelError, Revision, account_groups::AccountGroupRef};
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -88,7 +88,8 @@ pub struct ClientKeyRecord {
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
pub provider_kind: ProviderKind,
pub groups: Vec<AccountGroupRef>,
pub provider_kinds: Vec<ProviderKind>,
pub prefix: String,
pub enabled: bool,
pub limits: RateLimits,
@@ -143,7 +144,7 @@ impl fmt::Debug for ClientKeySecret {
pub struct CreateClientKey {
pub name: String,
pub label: Option<String>,
pub provider_kind: ProviderKind,
pub group_ids: Vec<AccountGroupId>,
pub limits: RateLimits,
}
@@ -153,7 +154,7 @@ pub struct NewClientKey {
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
pub provider_kind: ProviderKind,
pub group_ids: Vec<AccountGroupId>,
pub limits: RateLimits,
pub plaintext: String,
}
@@ -164,7 +165,7 @@ impl fmt::Debug for NewClientKey {
.debug_struct("NewClientKey")
.field("id", &self.id)
.field("name", &self.name)
.field("provider_kind", &self.provider_kind)
.field("group_ids", &self.group_ids)
.field("plaintext", &"[REDACTED]")
.finish_non_exhaustive()
}
@@ -176,7 +177,7 @@ pub struct UpdateClientKey {
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
pub provider_kind: ProviderKind,
pub group_ids: Vec<AccountGroupId>,
pub limits: RateLimits,
}
+15 -1
View File
@@ -2,9 +2,11 @@
use std::num::{NonZeroU16, NonZeroU64};
pub mod account_groups;
pub mod accounts;
pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod observability;
pub mod provider_credentials;
@@ -20,11 +22,12 @@ pub enum AdminErrorKind {
Conflict,
RateLimited,
BadGateway,
UpstreamResultUnknown,
Unavailable,
Internal,
}
/// 不携带基础设施细节的管理用例错误。
/// 不携带基础设施细节、可安全返回给管理员的管理用例错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct AdminError {
@@ -46,6 +49,12 @@ impl AdminError {
self.kind
}
/// 返回已脱敏、可公开给管理员的文案。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
#[must_use]
pub fn invalid(message: impl Into<String>) -> Self {
Self::new(AdminErrorKind::Invalid, message)
@@ -71,6 +80,11 @@ impl AdminError {
Self::new(AdminErrorKind::BadGateway, message)
}
#[must_use]
pub fn upstream_result_unknown(message: impl Into<String>) -> Self {
Self::new(AdminErrorKind::UpstreamResultUnknown, message)
}
#[must_use]
pub fn unavailable(message: impl Into<String>) -> Self {
Self::new(AdminErrorKind::Unavailable, message)
@@ -1,6 +1,6 @@
//! 用量、成本、健康与错误诊断的 UTC 语义事实。
use std::{num::NonZeroU32, str::FromStr};
use std::str::FromStr;
use chrono::{DateTime, TimeDelta, Timelike as _, Utc};
@@ -38,29 +38,6 @@ impl TimeRange {
}
}
/// 从一开始的观测页码。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PageNumber(NonZeroU32);
impl PageNumber {
#[must_use]
pub const fn new(value: NonZeroU32) -> Self {
Self(value)
}
#[must_use]
pub const fn get(self) -> u32 {
self.0.get()
}
}
/// 观测记录的稳定键集游标。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObservabilityCursor {
pub observed_at: DateTime<Utc>,
pub stable_id: String,
}
/// 请求结果状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequestOutcome {
@@ -146,8 +123,7 @@ pub struct UsageFilter {
pub struct UsageQuery {
pub range: TimeRange,
pub filter: UsageFilter,
pub cursor: Option<ObservabilityCursor>,
pub page: PageNumber,
pub current_page: u32,
pub page_size: PageSize,
}
@@ -164,7 +140,6 @@ pub struct OpsErrorFilter {
pub attempt_index: Option<u32>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub failure_kind: Option<String>,
pub status_code: Option<u16>,
pub search: Option<String>,
}
@@ -174,8 +149,7 @@ pub struct OpsErrorFilter {
pub struct OpsErrorQuery {
pub range: TimeRange,
pub filter: OpsErrorFilter,
pub cursor: Option<ObservabilityCursor>,
pub page: PageNumber,
pub current_page: u32,
pub page_size: PageSize,
}
@@ -213,11 +187,11 @@ impl DecimalAmount {
Self::from_decimal(self.to_decimal()?.checked_div_u64(divisor)?)
}
fn to_decimal(&self) -> Option<gateway_core::accounting::Decimal> {
fn to_decimal(&self) -> Option<gateway_core::metering::Decimal> {
self.0.parse().ok()
}
fn from_decimal(value: gateway_core::accounting::Decimal) -> Option<Self> {
fn from_decimal(value: gateway_core::metering::Decimal) -> Option<Self> {
Some(Self(value.canonical()))
}
}
@@ -402,6 +376,16 @@ pub struct RequestMetrics {
pub max_latency_ms: Option<u64>,
pub latency_percentiles: LatencyPercentiles,
pub first_token_latency_percentiles: LatencyPercentiles,
pub admission_decision_count: u64,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_count: u64,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_sample_count: u64,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
pub cache_eligible_request_count: u64,
pub cache_hit_request_count: u64,
}
@@ -439,25 +423,15 @@ pub struct RequestMetricPoint {
pub costs: Vec<CurrencyCost>,
}
/// 账号池容量统计。
///
/// 首页概览的 `unavailable` 表示不可参与调度,包含配额耗尽及过期、禁用和封禁账号。
///
/// `quota_exhausted`(402 确认耗尽)与 `rate_limited`(429 限流中)单列,
/// 以便状态详情展示和诊断。
/// 账号池的统一五态统计。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AccountPoolMetrics {
pub total: u64,
pub enabled: u64,
pub unavailable: u64,
pub active: u64,
pub rate_limited: u64,
pub expired: u64,
pub invalid: u64,
pub normal: u64,
pub quota_exhausted: u64,
pub refreshing: Option<u64>,
pub rate_limited: u64,
pub disabled: u64,
pub banned: u64,
pub error: u64,
}
/// Dashboard 中一个账号的模型级用量事实。
@@ -488,7 +462,7 @@ pub struct DashboardAccountRequestBucket {
pub request_count: u64,
}
/// Dashboard 中一个账号的完整用量与公共状态事实。
/// Dashboard 中一个账号的完整用量事实。
#[derive(Debug, Clone, PartialEq)]
pub struct DashboardAccountUsage {
pub account_id: String,
@@ -497,8 +471,6 @@ pub struct DashboardAccountUsage {
pub name: String,
pub email: Option<String>,
pub plan_type: Option<String>,
pub enabled: bool,
pub availability: String,
pub request_count: u64,
pub success_count: u64,
pub input_tokens: Option<u64>,
@@ -525,22 +497,82 @@ pub struct DashboardAccountUsage {
/// Dashboard 当前账号池的可重建运行时槽位事实。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DashboardRuntimeSlots {
/// 与持久账号池指标相同谓词下可参与调度的账号数。
pub active_accounts: u64,
/// 继承运行参数默认并发上限的可调度账号数。
pub inherited_accounts: u64,
/// 已配置账号级并发上限的可调度账号槽位总数。
pub overridden_slots: u64,
/// Redis 可用时的实时 in-flight 槽位总数;不可用时为 `None`。
pub used_slots: Option<u64>,
}
/// 仪表盘所需的公共观测事实。
/// 仪表盘卡片脚注展示的全历史累计。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DashboardTotals {
pub request_count: u64,
pub input_tokens: u64,
pub cached_tokens: u64,
pub total_tokens: u64,
pub billing_usd: Option<DecimalAmount>,
}
/// 仪表盘所需的当前区间事实及全历史累计。
#[derive(Debug, Clone, PartialEq)]
pub struct DashboardObservation {
pub range: TimeRange,
pub requests: RequestMetrics,
pub attempts: AttemptMetrics,
pub totals: DashboardTotals,
pub provider_accounts: AccountPoolMetrics,
pub trend: Vec<RequestMetricPoint>,
pub account_usage: Vec<DashboardAccountUsage>,
pub recent_requests: Vec<UsageRecord>,
pub recent_requests: Vec<UsageListRecord>,
}
/// 使用记录表格的窄读模型。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageListRecord {
pub id: String,
pub endpoint: String,
pub client_transport: String,
pub requested_model_id: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub service_tier: Option<String>,
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 total_tokens: Option<u64>,
pub cost_source: String,
pub cost_amount: Option<DecimalAmount>,
pub cost_currency: Option<String>,
pub billing: Option<UsageBilling>,
pub transport_decision_wait_ms: Option<u64>,
pub connect_ms: Option<u64>,
pub headers_ms: Option<u64>,
pub first_event_ms: Option<u64>,
pub first_reasoning_ms: Option<u64>,
pub first_text_ms: Option<u64>,
pub first_token_ms: Option<u64>,
pub provider_processing_ms: Option<u64>,
pub latency_ms: Option<u64>,
pub admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
pub capacity_used_slots: Option<u64>,
pub capacity_total_slots: Option<u64>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
pub reasoning_preset: Option<String>,
pub subagent_kind: Option<String>,
pub compact: bool,
pub started_at: DateTime<Utc>,
}
/// 一次完整模型请求的公共观测记录。
@@ -549,11 +581,14 @@ pub struct UsageRecord {
pub id: String,
pub client_api_key_ref: String,
pub config_revision: u64,
pub routing_scope: String,
pub routing_group_refs: Vec<String>,
pub routing_group_names_snapshot: Vec<String>,
pub protocol: String,
pub operation: String,
pub endpoint: String,
pub client_transport: String,
pub requested_model_id: String,
pub requested_model_id: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
@@ -600,6 +635,10 @@ pub struct UsageRecord {
pub first_token_ms: Option<u64>,
pub provider_processing_ms: Option<u64>,
pub latency_ms: Option<u64>,
pub admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
pub capacity_used_slots: Option<u64>,
pub capacity_total_slots: Option<u64>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
@@ -617,9 +656,10 @@ pub struct UsageRecord {
/// 用量分页结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsagePage {
pub items: Vec<UsageRecord>,
pub items: Vec<UsageListRecord>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
pub next_cursor: Option<ObservabilityCursor>,
}
/// 请求中的一次上游尝试或运维事件。
@@ -704,6 +744,9 @@ pub struct DiagnosticObservation {
pub total_tokens: u64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub retry_count: u64,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCost>,
}
@@ -718,6 +761,10 @@ pub struct OpsError {
pub client_api_key_ref: Option<String>,
pub component: String,
pub operation: String,
pub protocol: Option<String>,
pub client_transport: Option<String>,
pub requested_model_id: Option<String>,
pub service_tier: Option<String>,
pub endpoint: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
@@ -727,6 +774,7 @@ pub struct OpsError {
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub failure_kind: String,
pub upstream_send_state: Option<String>,
pub client_status_code: Option<u16>,
pub upstream_status_code: Option<u16>,
pub provider_error_code: Option<String>,
@@ -734,7 +782,27 @@ pub struct OpsError {
pub upstream_request_id: Option<String>,
pub latency_ms: Option<u64>,
pub message: String,
pub raw_upstream_error: Option<String>,
pub occurrence_count: u32,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
pub reasoning_preset: Option<String>,
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: Option<bool>,
pub continuation_affinity_hash: Option<String>,
pub continuation_previous_response_id_hash: Option<String>,
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection_id: Option<String>,
pub upstream_connection_exit_reason: Option<String>,
pub upstream_connection_age_ms: Option<u64>,
pub upstream_connection_idle_ms: Option<u64>,
pub recovery_request_id: Option<String>,
pub recovered_at: Option<DateTime<Utc>>,
pub recovery_attempt_count: u32,
pub recovery_retry_delay_ms: Option<u64>,
pub recovery_total_latency_ms: Option<u64>,
pub occurred_at: DateTime<Utc>,
pub stable_sort_id: String,
}
@@ -743,8 +811,9 @@ pub struct OpsError {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsErrorPage {
pub items: Vec<OpsError>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
pub next_cursor: Option<ObservabilityCursor>,
}
/// 仪表盘趋势指标。
@@ -783,6 +852,9 @@ pub struct TrendSummary {
pub average_latency_ms: Option<u64>,
pub max_latency_ms: Option<u64>,
pub min_latency_ms: Option<u64>,
pub peak_first_token_p95_ms: Option<f64>,
pub peak_latency_p95_ms: Option<f64>,
pub minimum_output_throughput_p50: Option<u64>,
pub success_rate: Option<f64>,
pub cache_hit_request_rate: Option<f64>,
pub costs: Vec<CurrencyCost>,
@@ -931,6 +1003,7 @@ pub struct DashboardResult {
/// 洞察健康时间点的语义结果。
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsHealthPoint {
pub total_requests: u64,
pub bucket_start: DateTime<Utc>,
pub success_requests: u64,
pub failed_requests: u64,
@@ -950,6 +1023,7 @@ pub struct UsageInsightsHealth {
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub success_rate: f64,
pub completion_rate: f64,
pub points: Vec<UsageInsightsHealthPoint>,
}
@@ -959,6 +1033,13 @@ pub struct UsageInsightsPerformancePoint {
pub bucket_start: DateTime<Utc>,
pub latency_percentiles: LatencyPercentiles,
pub first_token_latency_percentiles: LatencyPercentiles,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
}
/// 洞察性能汇总与可观测覆盖率。
@@ -968,6 +1049,16 @@ pub struct UsageInsightsPerformance {
pub first_token_latency_percentiles: LatencyPercentiles,
pub latency_coverage: f64,
pub first_token_coverage: f64,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub admission_decision_coverage: f64,
pub account_selection_wait_coverage: f64,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
pub capacity_coverage: f64,
pub points: Vec<UsageInsightsPerformancePoint>,
}
@@ -981,6 +1072,8 @@ pub struct UsageInsightsCostPoint {
pub total_tokens: u64,
pub estimated_cost: Option<DecimalAmount>,
pub standard_cost: Option<DecimalAmount>,
pub no_cache_cost: Option<DecimalAmount>,
pub cache_savings: Option<DecimalAmount>,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
}
@@ -990,7 +1083,11 @@ pub struct UsageInsightsCostPoint {
pub struct UsageInsightsCost {
pub estimated_cost: Option<DecimalAmount>,
pub standard_cost: Option<DecimalAmount>,
pub no_cache_cost: Option<DecimalAmount>,
pub cache_savings: Option<DecimalAmount>,
pub tier_premium: Option<DecimalAmount>,
pub cost_per_request: Option<DecimalAmount>,
pub cost_per_successful_request: Option<DecimalAmount>,
pub tokens_per_request: f64,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
@@ -1026,6 +1123,12 @@ pub struct DiagnosticsItem {
pub request_share: f64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub non_completion_rate: f64,
pub retry_count: u64,
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<DecimalAmount>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -1,20 +1,20 @@
//! Provider 管理能力交换的中立 Command 与 Result。
use std::fmt;
use std::{fmt, pin::Pin};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use bytes::Bytes;
use chrono::{DateTime, NaiveDate, Utc};
use futures::Stream;
use gateway_core::{
engine::credential::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
account::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
routing::{ProviderKind, UpstreamModelId},
};
use uuid::Uuid;
use super::{
AdminError, MutationActor, MutationContext, PageSize, Revision,
accounts::{
AccountAvailability, AccountErrorReason, AccountRecord, AccountStatus, AccountSummary,
AccountUsage,
},
accounts::{AccountRecord, AccountSummary, AccountUsage, CredentialState},
};
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
@@ -55,24 +55,24 @@ pub struct CredentialCursor {
/// Provider credential 列表查询。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialListQuery {
pub availability: Option<CredentialAvailabilityFilter>,
pub credential_state: Option<CredentialStateFilter>,
pub enabled: Option<bool>,
pub window: CredentialListWindow,
}
/// Credential 可用性筛选;支持一个 wire 值对应多个持久状态。
/// Credential 状态筛选。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialAvailabilityFilter {
Exact(AccountAvailability),
AnyOf(Vec<AccountAvailability>),
pub enum CredentialStateFilter {
Exact(CredentialState),
AnyOf(Vec<CredentialState>),
}
impl CredentialAvailabilityFilter {
impl CredentialStateFilter {
#[must_use]
pub fn matches(&self, availability: AccountAvailability) -> bool {
pub fn matches(&self, credential_state: CredentialState) -> bool {
match self {
Self::Exact(expected) => *expected == availability,
Self::AnyOf(expected) => expected.contains(&availability),
Self::Exact(expected) => *expected == credential_state,
Self::AnyOf(expected) => expected.contains(&credential_state),
}
}
}
@@ -146,7 +146,7 @@ pub struct PreparedCredentialCreate {
pub provider_kind: ProviderKind,
pub name: String,
pub email: Option<String>,
pub upstream_user_id: String,
pub upstream_user_id: Option<String>,
pub upstream_account_id: Option<String>,
pub plan_type: Option<String>,
pub authentication_kind: String,
@@ -155,8 +155,8 @@ pub struct PreparedCredentialCreate {
pub access_token_expires_at: Option<DateTime<Utc>>,
pub next_refresh_at: Option<DateTime<Utc>>,
pub enabled: bool,
pub availability: AccountAvailability,
pub availability_observed_at: DateTime<Utc>,
pub credential_state: CredentialState,
pub credential_observed_at: DateTime<Utc>,
}
/// Provider 对一份导入文档的完整验证结果。
@@ -564,13 +564,44 @@ impl fmt::Debug for RotateCredential {
}
}
/// 通用账号用量能否可靠归属到一个 Provider quota 窗口。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuotaLocalUsageAttribution {
/// 窗口覆盖账号的全部请求,可按账号与时间范围聚合。
AccountWide,
/// 窗口需要 Provider / 模型级归属,通用账号聚合不可用。
Unavailable,
}
/// Provider quota bucket 中窗口的官方位置语义。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderQuotaWindowRole {
Primary,
Secondary,
Monthly,
}
impl ProviderQuotaWindowRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Primary => "primary",
Self::Secondary => "secondary",
Self::Monthly => "monthly",
}
}
}
/// 一个 Provider quota 窗口的公共投影。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuotaWindow {
pub key: String,
pub group: String,
pub label: String,
pub source: Option<String>,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<ProviderQuotaWindowRole>,
pub local_usage_attribution: QuotaLocalUsageAttribution,
pub window_seconds: Option<u64>,
pub used_percent: Option<f64>,
pub reset_at: Option<DateTime<Utc>>,
@@ -593,47 +624,234 @@ pub struct ProviderQuota {
pub observed_at: Option<DateTime<Utc>>,
pub refresh_token_expires_at: Option<DateTime<Utc>>,
pub windows: Vec<ProviderQuotaWindow>,
/// 快照级限流事实(顶层或任一窗口触顶);Admin 状态派生用,前端不再遍历窗口猜测。
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生。
pub limit_reached: bool,
pub provider_data: Option<ProviderDocument>,
}
/// Provider 官方个人资料中的累计摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileStatisticsSummary {
pub total_text_tokens: Option<u64>,
pub peak_tokens: Option<u64>,
pub longest_task_duration_ms: Option<u64>,
pub current_streak_days: Option<u64>,
pub longest_streak_days: Option<u64>,
}
/// Provider 官方个人资料中的单日 Token bucket。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileDailyUsage {
pub date: NaiveDate,
pub tokens: u64,
}
/// Provider 官方个人资料中的插件或 Skill 调用排行。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileInvocation {
pub invocation_type: String,
pub plugin_id: Option<String>,
pub plugin_name: Option<String>,
pub skill_id: Option<String>,
pub skill_name: Option<String>,
pub usage_count: Option<u64>,
}
/// Provider 官方个人资料中的活动洞察。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderProfileActivityInsights {
pub fast_mode_percent: Option<f64>,
pub reasoning_effort: Option<String>,
pub reasoning_effort_percent: Option<f64>,
pub skills_explored: Option<u64>,
pub total_skills_used: Option<u64>,
pub total_threads: Option<u64>,
pub invocations: Option<Vec<ProviderProfileInvocation>>,
}
/// Provider 已解释的官方个人资料统计。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderProfileStatistics {
pub display_name: Option<String>,
pub username: Option<String>,
pub image_url: Option<String>,
pub has_stats_error: bool,
pub summary: ProviderProfileStatisticsSummary,
pub daily_usage: Option<Vec<ProviderProfileDailyUsage>>,
pub activity_insights: ProviderProfileActivityInsights,
}
/// Provider 头像正文的中立字节流;公共层不解释 MIME,也不限制总字节数。
pub type ProviderProfileAvatarStream =
Pin<Box<dyn Stream<Item = Result<Bytes, ProviderProfileAvatarStreamError>> + Send + 'static>>;
/// Provider 头像流在响应开始后的通用失败,不携带上游 URL 或正文。
#[derive(Debug, thiserror::Error)]
#[error("provider profile avatar stream failed")]
pub struct ProviderProfileAvatarStreamError;
/// Provider 已校验并打开的官方头像响应。
pub struct ProviderProfileAvatar {
pub content_type: Option<String>,
pub content_length: Option<u64>,
pub etag: Option<String>,
pub body: ProviderProfileAvatarStream,
}
impl fmt::Debug for ProviderProfileAvatar {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ProviderProfileAvatar")
.field("content_type", &self.content_type)
.field("content_length", &self.content_length)
.field("etag", &self.etag)
.field("body", &"<stream>")
.finish()
}
}
/// Provider 返回的一张安全主动额度重置卡。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCredit {
pub id: String,
pub status: Option<String>,
pub title: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub reset_type: Option<String>,
}
/// Provider 主动额度重置卡列表。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCredits {
pub available_count: u64,
pub credits: Vec<ProviderResetCredit>,
}
/// 一次主动额度重置卡消费命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsumeProviderResetCredit {
pub account_id: ProviderAccountId,
pub credit_id: Option<String>,
pub redeem_request_id: Uuid,
}
/// Provider 返回的主动额度重置卡消费结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCreditResult {
pub code: String,
pub credit: Option<ProviderResetCredit>,
}
impl ProviderQuota {
/// 返回 Dashboard 使用的代表性额度比例。
/// 返回账号展开面板使用的当前额度窗口用量。
///
/// 优先使用短周期窗口,再依次使用周、月和其它窗口;同一优先级取较高的已用比例。
/// 这里只解释跨 Provider 共享的窗口语义,绝不读取 Provider 私有 JSON。
/// Provider 已提供账号级本地用量时,固定窗口和无固定重置点的滚动窗口
/// 都可作为代表用量;优先级与 Dashboard 代表性额度窗口一致,
/// 同一优先级保持 Provider 投影顺序。
#[must_use]
pub fn representative_used_percent(&self) -> Option<f64> {
pub fn representative_window_usage(&self) -> Option<&AccountUsage> {
self.windows
.iter()
.enumerate()
.filter(|(_, window)| {
window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
&& window.window_seconds.is_some()
&& window.local_usage.is_some()
})
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
.and_then(|(_, window)| window.local_usage.as_ref())
}
/// 返回 Dashboard 使用的代表性额度比例。
///
/// 优先使用覆盖账号全部请求的窗口;同一归属范围内再依次使用短周期、周、月
/// 和其它窗口,同一优先级取较高的已用比例。这里只解释跨 Provider 共享的窗口
/// 语义,绝不读取 Provider 私有 JSON。
#[must_use]
pub fn representative_used_percent(&self) -> Option<f64> {
if self.limit_reached {
return Some(100.0);
}
self.representative_used_window()
.map(|(_, used_percent)| used_percent)
}
/// 将已确认的账号级额度耗尽事实投影到展示窗口。
///
/// Provider 已指出具体触顶窗口时只归一化这些窗口;否则归一化 Dashboard
/// 同样会选择的代表窗口,避免把多个独立额度窗口全部伪造成已用尽。
pub fn apply_limit_reached_display(&mut self) {
if !self.limit_reached {
return;
}
let reached_windows = self
.windows
.iter()
.enumerate()
.filter_map(|(index, window)| window.limit_reached.then_some(index))
.collect::<Vec<_>>();
if !reached_windows.is_empty() {
for index in reached_windows {
self.windows[index].used_percent = Some(100.0);
}
return;
}
let representative = self
.representative_used_window()
.map(|(index, _)| index)
.or_else(|| {
self.windows
.iter()
.enumerate()
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
.map(|(index, _)| index)
});
if let Some(index) = representative {
self.windows[index].used_percent = Some(100.0);
self.windows[index].limit_reached = true;
}
}
fn representative_used_window(&self) -> Option<(usize, f64)> {
self.windows
.iter()
.enumerate()
.filter_map(|window| {
let (index, window) = 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))
Some((index, 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) =>
{
.fold(
None::<(usize, (u8, u8), f64)>,
|selected, candidate| match selected {
Some(current)
}
_ => Some(candidate),
})
.map(|(_, used_percent)| used_percent)
if current.1 < candidate.1
|| (current.1 == candidate.1 && current.2 >= candidate.2) =>
{
Some(current)
}
_ => Some(candidate),
},
)
.map(|(index, _, used_percent)| (index, used_percent))
}
}
fn quota_usage_priority(window: &ProviderQuotaWindow) -> u8 {
match window.group.as_str() {
fn quota_usage_priority(window: &ProviderQuotaWindow) -> (u8, u8) {
let attribution = match window.local_usage_attribution {
QuotaLocalUsageAttribution::AccountWide => 0,
QuotaLocalUsageAttribution::Unavailable => 1,
};
let duration = match window.group.as_str() {
"shortTerm" if window.window_seconds.is_some_and(is_week_window) => 1,
"shortTerm" => 0,
"monthly" => 2,
_ => 3,
}
};
(attribution, duration)
}
fn is_week_window(seconds: u64) -> bool {
@@ -690,9 +908,7 @@ impl fmt::Debug for ProviderExport {
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryItem {
pub account: AccountRecord,
pub status: AccountStatus,
/// `status == Error` 时的具体原因;其余状态为 `None`。
pub error_reason: Option<AccountErrorReason>,
pub projection: gateway_core::account::AccountStatusProjection,
pub usage: Option<super::accounts::AccountUsage>,
pub quota: ProviderQuota,
}
@@ -12,7 +12,7 @@ use super::Revision;
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射。
pub use gateway_core::engine::credential::RotationStrategy;
pub use gateway_core::account::RotationStrategy;
/// 完整运行设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -24,6 +24,8 @@ pub struct RuntimeSettings {
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
@@ -39,6 +41,8 @@ pub struct ReplaceRuntimeSettings {
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
@@ -145,7 +145,7 @@ pub trait DatabaseDumpPort: Send + Sync {
#[derive(Debug, Clone)]
pub struct DumpRequest {
pub backup_id: String,
pub cancellation: gateway_core::engine::CancellationToken,
pub cancellation: gateway_core::lifecycle::CancellationToken,
}
/// 已完成并落盘的归档事实。
@@ -212,7 +212,7 @@ pub struct UploadObjectRequest {
pub object_key: String,
pub source: PathBuf,
pub metadata: BackupObjectMetadata,
pub cancellation: gateway_core::engine::CancellationToken,
pub cancellation: gateway_core::lifecycle::CancellationToken,
}
/// 备份控制面所需的全部 Store 能力集合。
@@ -0,0 +1,10 @@
//! 客户端安装包解析的外部能力端口。
use async_trait::async_trait;
use crate::model::client_distribution::CodexDesktopWindowsDownloads;
#[async_trait]
pub trait ClientDistributionResolver: Send + Sync {
async fn resolve_codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads;
}
@@ -1,6 +1,7 @@
//! 管理用例依赖的外部能力端口。
pub mod backup;
pub mod client_distribution;
pub mod provider;
pub mod store;
pub mod system;
@@ -4,7 +4,7 @@ use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
use gateway_core::{
engine::credential::ProviderAccountId,
account::ProviderAccountId,
operation::Operation,
routing::{ProviderKind, UpstreamModelId},
};
@@ -13,11 +13,12 @@ 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,
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderResetCreditResult, ProviderResetCredits,
};
/// Provider 管理失败的稳定分类。
@@ -27,27 +28,59 @@ pub enum ProviderAdminErrorKind {
Unsupported,
NotFound,
Conflict,
/// Provider 已发起不可逆操作,但无法确认最终执行结果。
Ambiguous,
Unavailable,
CredentialRefreshRequired,
BadGateway,
Internal,
}
/// 不泄漏 OAuth material 与 Provider 响应体的管理错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
/// 不携带 OAuth 请求材料的管理错误。
///
/// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案。
/// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在。
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
#[error("provider admin operation failed: {kind:?}")]
pub struct ProviderAdminError {
kind: ProviderAdminErrorKind,
message: Option<String>,
}
impl std::fmt::Debug for ProviderAdminError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProviderAdminError")
.field("kind", &self.kind)
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
.finish()
}
}
impl ProviderAdminError {
#[must_use]
pub const fn new(kind: ProviderAdminErrorKind) -> Self {
Self { kind }
Self {
kind,
message: None,
}
}
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
#[must_use]
pub const fn kind(&self) -> ProviderAdminErrorKind {
self.kind
}
#[must_use]
pub fn message(&self) -> Option<&str> {
self.message.as_deref()
}
}
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力。
@@ -116,6 +149,38 @@ pub trait ProviderAdmin: Send + Sync {
request: ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError>;
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
async fn profile_statistics(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 打开 Provider 官方头像字节流;不支持该能力的 Provider 使用默认拒绝。
async fn profile_avatar(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileAvatar, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
async fn reset_credits(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderResetCredits, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 消费 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
async fn consume_reset_credit(
&self,
_command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
async fn models(
&self,
account_id: &ProviderAccountId,
+126 -14
View File
@@ -10,9 +10,14 @@ use chrono::{DateTime, Utc};
use super::backup::BackupStorePorts;
use crate::model::{
MutationContext, Revision,
account_groups::{
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage,
DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
},
accounts::{
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
AccountListQuery, AccountPage, AccountPageItem, AccountRuntimeSnapshot,
AccountUpdateResult, AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult,
AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
},
auth::{AdminAuditEvent, AdminSession},
client_keys::{
@@ -81,9 +86,17 @@ 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 list_accounts(
&self,
query: AccountListQuery,
runtime: AccountRuntimeSnapshot,
) -> AdminStoreResult<AccountPage>;
async fn load_account(&self, account_id: &str) -> AdminStoreResult<Option<AccountRecord>>;
async fn load_account(
&self,
account_id: &str,
runtime: AccountRuntimeSnapshot,
) -> AdminStoreResult<Option<AccountPageItem>>;
async fn load_account_usage(
&self,
@@ -105,13 +118,13 @@ pub trait AccountStore: Send + Sync {
async fn credential_details(
&self,
provider_kind: &gateway_core::routing::ProviderKind,
account_id: &gateway_core::engine::credential::ProviderAccountId,
account_id: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<CredentialDetails>>;
async fn load_credentials_for_export(
&self,
provider_kind: &gateway_core::routing::ProviderKind,
account_ids: &[gateway_core::engine::credential::ProviderAccountId],
account_ids: &[gateway_core::account::ProviderAccountId],
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>>;
async fn commit_credential_import(
@@ -138,11 +151,23 @@ pub trait AccountStore: Send + Sync {
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult>;
async fn set_account_enabled(
async fn update_account(
&self,
command: SetAccountEnabled,
command: UpdateAccount,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
) -> AdminStoreResult<AccountUpdateResult>;
async fn recover_account(
&self,
account_id: &gateway_core::account::ProviderAccountId,
context: &MutationContext,
) -> AdminStoreResult<AccountUpdateResult>;
async fn batch_update_accounts(
&self,
command: BatchUpdateAccounts,
context: &MutationContext,
) -> AdminStoreResult<AccountsUpdateResult>;
async fn delete_accounts(
&self,
@@ -152,11 +177,22 @@ pub trait AccountStore: Send + Sync {
async fn record_credential_export(
&self,
account_ids: &[gateway_core::engine::credential::ProviderAccountId],
account_ids: &[gateway_core::account::ProviderAccountId],
context: &MutationContext,
) -> AdminStoreResult<()>;
}
/// 可丢失账号运行态的管理读端口;跨存储编排由 Admin application service 拥有。
#[async_trait]
pub trait AccountRuntimeStore: Send + Sync {
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot>;
async fn account_runtime(
&self,
account_ids: &[String],
) -> AdminStoreResult<AccountRuntimeSnapshot>;
}
/// 管理员密码、会话和安全审计。
#[async_trait]
pub trait AuthStore: Send + Sync {
@@ -215,10 +251,53 @@ pub trait ClientKeyStore: Send + Sync {
) -> AdminStoreResult<Revision>;
}
/// Provider-neutral account group management transactions.
#[async_trait]
pub trait AccountGroupStore: Send + Sync {
async fn list_account_groups(
&self,
query: AccountGroupListQuery,
) -> AdminStoreResult<AccountGroupPage>;
async fn load_account_group_members(
&self,
group_ids: &[gateway_core::routing::AccountGroupId],
) -> AdminStoreResult<Vec<AccountGroupMemberFact>>;
async fn create_account_group(
&self,
command: NewAccountGroup,
context: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation>;
async fn update_account_group(
&self,
command: UpdateAccountGroup,
context: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation>;
async fn set_account_group_enabled(
&self,
command: SetAccountGroupEnabled,
context: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation>;
async fn delete_account_group(
&self,
command: DeleteAccountGroup,
context: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation>;
}
/// 用量、趋势、诊断与运维错误的只读能力。
#[async_trait]
pub trait ObservabilityStore: Send + Sync {
async fn dashboard_summary(&self, range: TimeRange) -> AdminStoreResult<DashboardObservation>;
/// 返回历史统计区间和指定观测时刻下的实时账号状态。
async fn dashboard_summary(
&self,
range: TimeRange,
observed_at: DateTime<Utc>,
) -> AdminStoreResult<DashboardObservation>;
/// 返回 Dashboard 可选的实时槽位事实。
///
@@ -291,12 +370,35 @@ pub trait SettingsStore: Send + Sync {
) -> AdminStoreResult<AdminApiKeyMutation>;
}
/// 账号目录、运行态与分组所需的 Store 能力集合。
#[derive(Clone)]
pub struct AdminAccountStorePorts {
accounts: Arc<dyn AccountStore>,
runtime: Arc<dyn AccountRuntimeStore>,
groups: Arc<dyn AccountGroupStore>,
}
impl AdminAccountStorePorts {
#[must_use]
pub fn new(
accounts: Arc<dyn AccountStore>,
runtime: Arc<dyn AccountRuntimeStore>,
groups: Arc<dyn AccountGroupStore>,
) -> Self {
Self {
accounts,
runtime,
groups,
}
}
}
/// 管理用例所需能力的封闭集合。
///
/// 字段保持私有,每个 getter 只交出一种明确能力。该类型不提供通用拆包入口。
#[derive(Clone)]
pub struct AdminStorePorts {
accounts: Arc<dyn AccountStore>,
accounts: AdminAccountStorePorts,
auth: Arc<dyn AuthStore>,
client_keys: Arc<dyn ClientKeyStore>,
observability: Arc<dyn ObservabilityStore>,
@@ -307,7 +409,7 @@ pub struct AdminStorePorts {
impl AdminStorePorts {
#[must_use]
pub fn new(
accounts: Arc<dyn AccountStore>,
accounts: AdminAccountStorePorts,
auth: Arc<dyn AuthStore>,
client_keys: Arc<dyn ClientKeyStore>,
observability: Arc<dyn ObservabilityStore>,
@@ -326,7 +428,17 @@ impl AdminStorePorts {
#[must_use]
pub fn accounts(&self) -> Arc<dyn AccountStore> {
self.accounts.clone()
self.accounts.accounts.clone()
}
#[must_use]
pub fn account_runtime(&self) -> Arc<dyn AccountRuntimeStore> {
self.accounts.runtime.clone()
}
#[must_use]
pub fn account_groups(&self) -> Arc<dyn AccountGroupStore> {
self.accounts.groups.clone()
}
#[must_use]
@@ -0,0 +1,242 @@
//! Account group management use cases.
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
time::SystemTime,
};
use async_trait::async_trait;
use gateway_core::{
account::{AccountStatus, resolve_account_status},
routing::AccountGroupId,
runtime::SnapshotControl,
};
use uuid::Uuid;
use crate::{
model::{
AdminError, MutationContext,
account_groups::{
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupListQuery,
AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage, AccountGroupRecord,
CreateAccountGroup, DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled,
UpdateAccountGroup,
},
},
ports::store::{AccountGroupStore, AccountRuntimeStore},
};
use super::{map_store_error, publish_committed};
/// API-facing account group management service.
#[async_trait]
pub trait AccountGroupService: Send + Sync {
async fn list(&self, query: AccountGroupListQuery) -> Result<AccountGroupPage, AdminError>;
async fn create(
&self,
context: &MutationContext,
command: CreateAccountGroup,
) -> Result<AccountGroupMutation, AdminError>;
async fn update(
&self,
context: &MutationContext,
command: UpdateAccountGroup,
) -> Result<AccountGroupMutation, AdminError>;
async fn set_enabled(
&self,
context: &MutationContext,
command: SetAccountGroupEnabled,
) -> Result<AccountGroupMutation, AdminError>;
async fn delete(
&self,
context: &MutationContext,
command: DeleteAccountGroup,
) -> Result<AccountGroupMutation, AdminError>;
}
pub(crate) struct DefaultAccountGroupService {
store: Arc<dyn AccountGroupStore>,
runtime: Arc<dyn AccountRuntimeStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultAccountGroupService {
#[must_use]
pub(crate) fn new(
store: Arc<dyn AccountGroupStore>,
runtime: Arc<dyn AccountRuntimeStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
store,
runtime,
snapshot,
}
}
async fn enrich_records(&self, records: &mut [AccountGroupRecord]) -> Result<(), AdminError> {
if records.is_empty() {
return Ok(());
}
let group_ids = records
.iter()
.map(|record| record.id.clone())
.collect::<Vec<_>>();
let members = self
.store
.load_account_group_members(&group_ids)
.await
.map_err(|error| map_store_error(error, "account group members"))?;
let account_ids = members
.iter()
.map(|member| member.account_id.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let runtime = match self.runtime.account_runtime(&account_ids).await {
Ok(runtime) => runtime,
Err(error) => {
tracing::warn!(error = %error, "account group runtime projection is unavailable");
Default::default()
}
};
project_group_runtime(records, &members, &runtime);
Ok(())
}
async fn publish(
&self,
result: Result<AccountGroupMutation, crate::ports::store::AdminStoreError>,
) -> Result<AccountGroupMutation, AdminError> {
let mut result = result.map_err(|error| map_store_error(error, "account group"))?;
if let Some(record) = result.record.as_mut() {
self.enrich_records(std::slice::from_mut(record)).await?;
}
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
}
#[async_trait]
impl AccountGroupService for DefaultAccountGroupService {
async fn list(&self, query: AccountGroupListQuery) -> Result<AccountGroupPage, AdminError> {
let mut page = self
.store
.list_account_groups(query)
.await
.map_err(|error| map_store_error(error, "account group"))?;
self.enrich_records(&mut page.items).await?;
Ok(page)
}
async fn create(
&self,
context: &MutationContext,
command: CreateAccountGroup,
) -> Result<AccountGroupMutation, AdminError> {
let id = AccountGroupId::new(format!("grp_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建账号组 ID 失败"))?;
self.publish(
self.store
.create_account_group(
NewAccountGroup {
id,
name: command.name,
description: command.description,
color: command.color,
},
context,
)
.await,
)
.await
}
async fn update(
&self,
context: &MutationContext,
command: UpdateAccountGroup,
) -> Result<AccountGroupMutation, AdminError> {
self.publish(self.store.update_account_group(command, context).await)
.await
}
async fn set_enabled(
&self,
context: &MutationContext,
command: SetAccountGroupEnabled,
) -> Result<AccountGroupMutation, AdminError> {
self.publish(self.store.set_account_group_enabled(command, context).await)
.await
}
async fn delete(
&self,
context: &MutationContext,
command: DeleteAccountGroup,
) -> Result<AccountGroupMutation, AdminError> {
self.publish(self.store.delete_account_group(command, context).await)
.await
}
}
fn project_group_runtime(
records: &mut [AccountGroupRecord],
members: &[AccountGroupMemberFact],
runtime: &crate::model::accounts::AccountRuntimeSnapshot,
) {
let now = SystemTime::now();
let mut status_by_account = BTreeMap::new();
let mut slots_by_account = BTreeMap::new();
let mut accounts_by_group = BTreeMap::<&str, Vec<&str>>::new();
for member in members {
let mut status = member.status.clone();
status.rate_limited_until = runtime
.rate_limited_until
.get(&member.account_id)
.copied()
.map(Into::into);
status_by_account
.entry(member.account_id.as_str())
.or_insert_with(|| resolve_account_status(&status, now).status);
slots_by_account
.entry(member.account_id.as_str())
.or_insert(member.total_slots);
accounts_by_group
.entry(member.group_id.as_str())
.or_default()
.push(member.account_id.as_str());
}
for record in records {
let account_ids = accounts_by_group
.get(record.id.as_str())
.map_or(&[][..], Vec::as_slice);
let available_ids = account_ids
.iter()
.copied()
.filter(|account_id| status_by_account.get(account_id) == Some(&AccountStatus::Normal))
.collect::<Vec<_>>();
let total = u64::try_from(account_ids.len()).unwrap_or(u64::MAX);
let available = u64::try_from(available_ids.len()).unwrap_or(u64::MAX);
record.account_summary = AccountGroupAccountSummary {
available,
limited: total.saturating_sub(available),
total,
};
record.capacity = AccountGroupCapacity {
used_slots: if available_ids.is_empty() {
Some(0)
} else {
runtime.in_flight.as_ref().map(|in_flight| {
available_ids.iter().fold(0_u64, |sum, account_id| {
sum.saturating_add(in_flight.get(*account_id).copied().unwrap_or(0))
})
})
},
total_slots: available_ids.iter().fold(0_u64, |sum, account_id| {
sum.saturating_add(slots_by_account.get(*account_id).copied().unwrap_or(0))
}),
};
}
}
@@ -6,11 +6,10 @@ 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},
account::ProviderAccountId,
engine::probe::{AccountProbe, AccountProbeRequest},
routing::{ProviderKind, UpstreamModelId},
runtime::SnapshotControl,
};
use crate::{
@@ -18,19 +17,21 @@ use crate::{
AdminError, MutationContext,
accounts::{
AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery,
AccountRecord, AccountStatus, AccountStatusSignals, AccountUsageWindowQuery,
derive_error_reason,
AccountPageItem, AccountUpdateResult, AccountUsageWindowQuery, AccountsUpdateResult,
BatchUpdateAccounts, UpdateAccount,
},
observability::TimeRange,
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
PrepareCredentialRefresh, ProviderModels, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
QuotaLocalUsageAttribution,
},
},
ports::{
provider::ProviderAdminRegistry,
store::{AccountStore, SettingsStore},
store::{AccountRuntimeStore, AccountStore},
},
};
@@ -58,12 +59,60 @@ pub trait AccountsService: Send + Sync {
account_id: ProviderAccountId,
) -> Result<AccountRefreshResult, AdminError>;
async fn recover(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<AccountRefreshResult, AdminError>;
async fn update(
&self,
context: &MutationContext,
command: UpdateAccount,
) -> Result<AccountUpdateResult, AdminError>;
async fn batch_update(
&self,
context: &MutationContext,
command: BatchUpdateAccounts,
) -> Result<AccountsUpdateResult, AdminError>;
async fn quota(
&self,
account_id: &ProviderAccountId,
refresh: bool,
) -> Result<AccountDirectoryItem, AdminError>;
async fn profile_statistics(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持账号统计"))
}
async fn profile_avatar(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileAvatar, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持账号头像"))
}
async fn reset_credits(
&self,
_context: &MutationContext,
_account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持重置额度"))
}
async fn consume_reset_credit(
&self,
_context: &MutationContext,
_command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持重置额度"))
}
async fn models(
&self,
account_id: &ProviderAccountId,
@@ -79,39 +128,59 @@ pub trait AccountsService: Send + Sync {
pub(crate) struct DefaultAccountsService {
accounts: Arc<dyn AccountStore>,
settings: Arc<dyn SettingsStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
reset_credit_locks:
Arc<futures::lock::Mutex<BTreeMap<ProviderAccountId, Arc<futures::lock::Mutex<()>>>>>,
}
impl DefaultAccountsService {
#[must_use]
pub(crate) const fn new(
pub(crate) fn new(
accounts: Arc<dyn AccountStore>,
settings: Arc<dyn SettingsStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
) -> Self {
Self {
accounts,
settings,
account_runtime,
providers,
snapshot,
probe,
reset_credit_locks: Arc::new(futures::lock::Mutex::new(BTreeMap::new())),
}
}
async fn reset_credit_lock(
&self,
account_id: &ProviderAccountId,
) -> Arc<futures::lock::Mutex<()>> {
let mut locks = self.reset_credit_locks.lock().await;
Arc::clone(
locks
.entry(account_id.clone())
.or_insert_with(|| Arc::new(futures::lock::Mutex::new(()))),
)
}
async fn load_account(
&self,
account_id: &ProviderAccountId,
) -> Result<AccountRecord, AdminError> {
) -> Result<AccountPageItem, AdminError> {
let runtime = self
.account_runtime
.account_runtime(&[account_id.as_str().to_owned()])
.await
.map_err(|error| map_store_error(error, "account runtime"))?;
self.accounts
.load_account(account_id.as_str())
.load_account(account_id.as_str(), runtime)
.await
.map_err(|error| map_store_error(error, "provider account"))?
.ok_or_else(|| AdminError::not_found("Provider account was not found"))
.ok_or_else(|| AdminError::not_found("Provider 账号不存在"))
}
async fn provider_for_account(
@@ -119,32 +188,32 @@ impl DefaultAccountsService {
account_id: &ProviderAccountId,
) -> Result<
(
AccountRecord,
AccountPageItem,
Arc<dyn crate::ports::provider::ProviderAdmin>,
),
AdminError,
> {
let account = self.load_account(account_id).await?;
let item = self.load_account(account_id).await?;
let provider = self
.providers
.require(&account.provider_kind)
.require(&item.account.provider_kind)
.map_err(|error| map_provider_error(error, "provider account"))?;
Ok((account, provider))
Ok((item, provider))
}
async fn attach_quota_local_usage(
&self,
accounts: &[AccountRecord],
accounts: &[AccountPageItem],
quotas: &mut [ProviderQuota],
) -> Result<(), AdminError> {
let windows = accounts
.iter()
.zip(quotas.iter())
.flat_map(|(account, quota)| {
.flat_map(|(item, quota)| {
quota
.windows
.iter()
.filter_map(|window| quota_usage_window(&account.id, window))
.filter_map(|window| quota_usage_window(&item.account.id, window))
})
.collect::<Vec<_>>();
if windows.is_empty() {
@@ -158,10 +227,12 @@ impl DefaultAccountsService {
.into_iter()
.map(|result| ((result.account_id, result.key), result.usage))
.collect::<BTreeMap<_, _>>();
for (account, quota) in accounts.iter().zip(quotas) {
for (item, 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 window.local_usage.is_none()
&& window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
{
let key = (item.account.id.clone(), window.key.clone());
if let Some(usage) = usage_by_window.get(&key) {
window.local_usage = Some(usage.clone());
}
@@ -176,33 +247,19 @@ impl DefaultAccountsService {
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 (stored, provider) = self.provider_for_account(account_id).await?;
let account = &stored.account;
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 = 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 {
@@ -213,16 +270,15 @@ impl DefaultAccountsService {
.await
.map_err(|error| map_provider_error(error, "provider quota"))?;
self.attach_quota_local_usage(
std::slice::from_ref(&account),
std::slice::from_ref(&stored),
std::slice::from_mut(&mut quota),
)
.await?;
let signals = account_signals(&account, quota.limit_reached, now);
let usage = quota.representative_window_usage().cloned();
Ok(AccountDirectoryItem {
status: signals.derive(),
error_reason: derive_error_reason(signals),
usage: usage.into_iter().next(),
account,
projection: stored.projection,
usage,
account: stored.account,
quota,
})
}
@@ -231,13 +287,17 @@ impl DefaultAccountsService {
#[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 runtime = self
.account_runtime
.active_rate_limits()
.await
.map_err(|error| map_store_error(error, "account runtime"))?;
let page = self
.accounts
.list_accounts(query, runtime)
.await
.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,
@@ -245,24 +305,21 @@ impl AccountsService for DefaultAccountsService {
let ids = page
.items
.iter()
.map(|account| account.id.clone())
.map(|item| item.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 = 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()
.map(|usage| (usage.account_id.clone(), usage))
.collect::<BTreeMap<_, _>>();
let mut quotas = futures::future::join_all(page.items.iter().map(|account| async {
let mut quotas = futures::future::join_all(page.items.iter().map(|item| async {
let account = &item.account;
let account_id = ProviderAccountId::new(account.id.clone())
.map_err(|_| AdminError::invalid("Invalid provider account ID"))?;
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
// 单个账号的 quota 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页:
// 该账号降级为空额度投影,其余账号与页面状态照常返回。
let provider = match self.providers.require(&account.provider_kind) {
@@ -304,13 +361,12 @@ impl AccountsService for DefaultAccountsService {
.items
.into_iter()
.zip(quotas)
.map(|(account, quota)| {
let signals = account_signals(&account, quota.limit_reached, now);
.map(|(item, quota)| {
let usage = quota.representative_window_usage().cloned();
AccountDirectoryItem {
status: signals.derive(),
error_reason: derive_error_reason(signals),
usage: usage.get(&account.id).cloned(),
account,
usage,
account: item.account,
projection: item.projection,
quota,
}
})
@@ -329,16 +385,14 @@ impl AccountsService for DefaultAccountsService {
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",
));
return Err(AdminError::invalid("账号导出数量必须在 1 到 200 之间"));
}
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)
.entry(account.account.provider_kind)
.or_default()
.push(account_id);
}
@@ -346,7 +400,7 @@ impl AccountsService for DefaultAccountsService {
let unique = ids.iter().collect::<std::collections::BTreeSet<_>>();
unique.len() != ids.len()
}) {
return Err(AdminError::invalid("Account export contains duplicate IDs"));
return Err(AdminError::invalid("账号导出列表包含重复 ID"));
}
let mut documents = Vec::with_capacity(grouped.len());
for (provider_kind, ids) in grouped {
@@ -381,7 +435,8 @@ impl AccountsService for DefaultAccountsService {
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<AccountRefreshResult, AdminError> {
let (account, provider) = self.provider_for_account(&account_id).await?;
let (stored, provider) = self.provider_for_account(&account_id).await?;
let account = stored.account;
let prepared = provider
.prepare_refresh(PrepareCredentialRefresh {
account: account.clone(),
@@ -407,6 +462,98 @@ impl AccountsService for DefaultAccountsService {
})
}
async fn recover(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<AccountRefreshResult, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
let result = self
.accounts
.recover_account(&account_id, context)
.await
.map_err(|error| map_store_error(error, "provider account recovery"))?;
provider
.account_facts_changed(std::slice::from_ref(&account_id))
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
let account = self.load_directory_item(&account_id, false).await?;
Ok(AccountRefreshResult {
config_revision: result.config_revision,
account,
})
}
async fn update(
&self,
context: &MutationContext,
command: UpdateAccount,
) -> Result<AccountUpdateResult, AdminError> {
let account_id = ProviderAccountId::new(command.account_id.clone())
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
let (_, provider) = self.provider_for_account(&account_id).await?;
let enabled = command.enabled;
let result = self
.accounts
.update_account(command, context)
.await
.map_err(|error| map_store_error(error, "provider account"))?;
if !enabled {
provider.account_unavailable(&account_id).await;
}
provider
.account_facts_changed(std::slice::from_ref(&account_id))
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn batch_update(
&self,
context: &MutationContext,
command: BatchUpdateAccounts,
) -> Result<AccountsUpdateResult, AdminError> {
let account_ids = command
.account_ids
.iter()
.map(|id| {
ProviderAccountId::new(id.clone())
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))
})
.collect::<Result<Vec<_>, _>>()?;
let mut providers = BTreeMap::<
ProviderKind,
(
Arc<dyn crate::ports::provider::ProviderAdmin>,
Vec<ProviderAccountId>,
),
>::new();
for account_id in &account_ids {
let (item, provider) = self.provider_for_account(account_id).await?;
providers
.entry(item.account.provider_kind)
.or_insert_with(|| (provider, Vec::new()))
.1
.push(account_id.clone());
}
let enabled = command.enabled;
let result = self
.accounts
.batch_update_accounts(command, context)
.await
.map_err(|error| map_store_error(error, "provider accounts"))?;
for (provider, provider_ids) in providers.values() {
if !enabled {
for account_id in provider_ids {
provider.account_unavailable(account_id).await;
}
}
provider.account_facts_changed(provider_ids).await;
}
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn quota(
&self,
account_id: &ProviderAccountId,
@@ -415,6 +562,79 @@ impl AccountsService for DefaultAccountsService {
self.load_directory_item(account_id, refresh).await
}
async fn profile_statistics(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.profile_statistics(account_id)
.await
.map_err(|error| map_provider_error(error, "provider profile statistics"))
}
async fn profile_avatar(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderProfileAvatar, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.profile_avatar(account_id)
.await
.map_err(|error| map_provider_error(error, "provider profile avatar"))
}
async fn reset_credits(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.reset_credits(&account_id).await {
Ok(credits) => Ok(credits),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.reset_credits(&account_id)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset credits")),
}
}
async fn consume_reset_credit(
&self,
context: &MutationContext,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
let account_id = command.account_id.clone();
// 覆盖 credential refresh + 同键重试的完整账号级临界区,避免 401 两次调用
// 之间插入另一笔不可逆消费。
let lock = self.reset_credit_lock(&account_id).await;
let _guard = lock.lock().await;
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.consume_reset_credit(command.clone()).await {
Ok(result) => Ok(result),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.consume_reset_credit(command)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset-credit consume")),
}
}
async fn models(
&self,
account_id: &ProviderAccountId,
@@ -432,8 +652,8 @@ impl AccountsService for DefaultAccountsService {
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, false, Utc::now());
let (stored, provider) = self.provider_for_account(&account_id).await?;
let account = stored.account;
let model = upstream_model.as_str().to_owned();
let operation = provider
.connection_test_operation(&upstream_model, CONNECTION_TEST_INPUT)
@@ -450,8 +670,6 @@ impl AccountsService for DefaultAccountsService {
},
];
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 {
@@ -461,22 +679,12 @@ impl AccountsService for DefaultAccountsService {
operation,
})
.await;
let status = accounts
.load_account(&stored_account_id)
.await
.ok()
.flatten()
.map_or(initial_status, |account| {
account_status(&account, false, Utc::now())
});
match result {
Ok(result) => result
.text
.into_iter()
.map(|text| AccountConnectionTestEvent::Content { text })
.chain(std::iter::once(AccountConnectionTestEvent::Completed {
account_status: status,
}))
.chain(std::iter::once(AccountConnectionTestEvent::Completed))
.collect(),
Err(error) => {
let upstream_status = error
@@ -490,19 +698,17 @@ impl AccountsService for DefaultAccountsService {
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();
let message = error.client_message().to_owned();
vec![AccountConnectionTestEvent::Failed {
source: error.source(),
gateway_error_code: error.kind(),
send_state: error.send_state(),
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,
}]
}
}
@@ -512,6 +718,15 @@ impl AccountsService for DefaultAccountsService {
}
}
fn map_reset_credits_error_after_refresh(
error: crate::ports::provider::ProviderAdminError,
) -> AdminError {
if error.kind() == crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired {
return AdminError::bad_gateway("上游服务拒绝了刷新后的凭据");
}
map_provider_error(error, "provider reset credits")
}
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
fn empty_quota() -> ProviderQuota {
ProviderQuota {
@@ -523,44 +738,13 @@ fn empty_quota() -> ProviderQuota {
}
}
/// 构造状态派生所需的信号快照。
fn account_signals(
account: &AccountRecord,
quota_limit_reached: bool,
now: chrono::DateTime<Utc>,
) -> AccountStatusSignals {
AccountStatusSignals {
enabled: account.enabled,
availability: account.availability,
access_token_expired: account
.access_token_expires_at
.is_some_and(|expires_at| expires_at <= now),
quota_limit_reached,
}
}
/// 账号运营分类(互斥展示状态)。
fn account_status(
account: &AccountRecord,
quota_limit_reached: bool,
now: chrono::DateTime<Utc>,
) -> AccountStatus {
account_signals(account, quota_limit_reached, now).derive()
}
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() {
if window.local_usage.is_some()
|| window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide
{
return None;
}
let reset_at = window.reset_at?;
@@ -2,7 +2,7 @@
use std::sync::Arc;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{Duration, Utc};
@@ -195,16 +195,15 @@ impl AuthService for DefaultAuthService {
}
fn hash_admin_password(password: &str) -> Result<String, AdminError> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.hash_password(password.as_bytes())
.map(|hash| hash.to_string())
.map_err(|_| AdminError::internal("Failed to hash administrator password"))
.map_err(|_| AdminError::internal("管理员密码哈希失败"))
}
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"))?;
.map_err(|_| AdminError::internal("已保存的管理员密码哈希不合法"))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &hash)
.is_ok())
@@ -5,7 +5,7 @@ use std::time::Duration;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use gateway_core::routing::snapshot::SnapshotControl;
use gateway_core::runtime::SnapshotControl;
use secrecy::ExposeSecret as _;
use uuid::Uuid;
@@ -429,9 +429,7 @@ fn map_backup_error(error: BackupError) -> AdminError {
code::ACTIVE_TASK_CONFLICT | code::STATE_CONFLICT | code::STORAGE_IDENTITY_LOCKED => {
AdminError::conflict(error.message().to_owned())
}
code::STORE_UNAVAILABLE => {
AdminError::new(AdminErrorKind::Unavailable, error.message().to_owned())
}
_ => AdminError::bad_gateway(error.message().to_owned()),
code::STORE_UNAVAILABLE => AdminError::new(AdminErrorKind::Unavailable, "备份服务暂不可用"),
_ => AdminError::bad_gateway("备份服务请求失败"),
}
}
@@ -0,0 +1,33 @@
//! Codex 客户端下载信息用例。
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
model::client_distribution::CodexDesktopWindowsDownloads,
ports::client_distribution::ClientDistributionResolver,
};
#[async_trait]
pub trait ClientDistributionService: Send + Sync {
async fn codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads;
}
pub(crate) struct DefaultClientDistributionService {
resolver: Arc<dyn ClientDistributionResolver>,
}
impl DefaultClientDistributionService {
#[must_use]
pub(crate) const fn new(resolver: Arc<dyn ClientDistributionResolver>) -> Self {
Self { resolver }
}
}
#[async_trait]
impl ClientDistributionService for DefaultClientDistributionService {
async fn codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads {
self.resolver.resolve_codex_desktop_windows(refresh).await
}
}
@@ -5,7 +5,7 @@ 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 gateway_core::runtime::SnapshotControl;
use rand_core::{OsRng, RngCore as _};
use uuid::Uuid;
@@ -77,7 +77,7 @@ impl ClientKeyService for DefaultClientKeyService {
.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"))
.ok_or_else(|| AdminError::not_found("Client API Key 不存在"))
}
async fn create(
@@ -86,7 +86,7 @@ impl ClientKeyService for DefaultClientKeyService {
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"))?;
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes));
@@ -97,7 +97,7 @@ impl ClientKeyService for DefaultClientKeyService {
id,
name: command.name,
label: command.label,
provider_kind: command.provider_kind,
group_ids: command.group_ids,
limits: command.limits,
plaintext: plaintext.clone(),
},
@@ -176,7 +176,7 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
};
if cursor.sort != query.sort {
return Err(AdminError::invalid(
"Client API key cursor sort does not match the query",
"Client API Key 游标排序与查询条件不一致",
));
}
let matches = matches!(
@@ -198,6 +198,6 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
if matches {
Ok(())
} else {
Err(AdminError::invalid("Invalid Client API key cursor"))
Err(AdminError::invalid("Client API Key 游标不合法"))
}
}
@@ -1,8 +1,10 @@
//! 管理控制面的用例实现。
pub mod account_groups;
pub mod accounts;
pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod observability;
pub mod openai;
@@ -13,11 +15,11 @@ pub mod xai;
use crate::{
model::{
AdminError, AdminErrorKind, MutationContext,
accounts::{DeleteAccounts, SetAccountEnabled},
accounts::DeleteAccounts,
provider_credentials::{
AuthorizationMutationTarget, CredentialDeletion, CredentialDeletionResult,
CredentialDetails, CredentialMutation, CredentialMutationResult,
CredentialRotationCommit, PendingAuthorizationMutation, PreparedAuthorizationCommit,
CredentialDetails, CredentialMutationResult, CredentialRotationCommit,
PendingAuthorizationMutation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
StartAuthorization,
},
@@ -28,8 +30,9 @@ use crate::{
},
};
use gateway_core::{
engine::credential::ProviderAccountId,
routing::{ConfigRevision, ProviderKind, snapshot::SnapshotControl},
account::ProviderAccountId,
routing::{ConfigRevision, ProviderKind},
runtime::SnapshotControl,
};
fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError {
@@ -41,7 +44,15 @@ fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError
}
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
};
AdminError::new(kind, format!("{resource} operation failed"))
tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed");
let message = match kind {
AdminErrorKind::Invalid => "请求参数不合法",
AdminErrorKind::NotFound => "请求的资源不存在",
AdminErrorKind::Conflict => "当前资源状态冲突,请刷新后重试",
AdminErrorKind::Unavailable => "依赖服务暂不可用",
_ => "服务内部错误",
};
AdminError::new(kind, message)
}
fn map_provider_error(
@@ -55,10 +66,24 @@ fn map_provider_error(
ProviderAdminErrorKind::Unsupported => AdminErrorKind::Invalid,
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::CredentialRefreshRequired => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, format!("{resource} operation failed"))
tracing::warn!(resource, error_kind = ?error.kind(), "admin provider operation failed");
let message = match kind {
AdminErrorKind::Invalid => "Provider 请求不合法",
AdminErrorKind::NotFound => "Provider 资源不存在",
AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试",
AdminErrorKind::BadGateway => "上游服务请求失败",
AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试",
AdminErrorKind::Unavailable => "Provider 服务暂不可用",
AdminErrorKind::Internal => "服务内部错误",
_ => "Provider 操作失败",
};
AdminError::new(kind, message)
}
async fn publish_committed(
@@ -66,7 +91,7 @@ async fn publish_committed(
revision: crate::model::Revision,
) -> Result<(), AdminError> {
let revision = ConfigRevision::new(revision.get())
.map_err(|_| AdminError::internal("Committed configuration revision is invalid"))?;
.map_err(|_| AdminError::internal("已提交的配置版本不合法"))?;
snapshot.publish_committed(revision).await;
Ok(())
}
@@ -81,7 +106,7 @@ async fn required_credential(
.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")))
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
async fn pending_authorization(
@@ -113,7 +138,7 @@ async fn pending_authorization(
fn validate_prepared_import(
provider_kind: &ProviderKind,
prepared: &PreparedCredentialImport,
resource: &'static str,
_resource: &'static str,
) -> Result<(), AdminError> {
if prepared.provider_kind != *provider_kind
|| prepared
@@ -121,9 +146,7 @@ fn validate_prepared_import(
.iter()
.any(|credential| credential.provider_kind != *provider_kind)
{
return Err(AdminError::conflict(format!(
"{resource} prepared facts do not match the requested Provider scope"
)));
return Err(AdminError::conflict("Provider 准备结果与请求范围不一致"));
}
Ok(())
}
@@ -131,15 +154,13 @@ fn validate_prepared_import(
fn validate_prepared_rotation(
account: &crate::model::accounts::AccountRecord,
prepared: &PreparedCredentialRotation,
resource: &'static str,
_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"
)));
return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致"));
}
Ok(())
}
@@ -153,9 +174,7 @@ async fn validate_authorization_commit(
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"
));
let validation_error = AdminError::conflict("OAuth 授权绑定已失效,请重新发起授权");
if let Err(error) = prepared.abort().await {
tracing::warn!(
resource,
@@ -181,9 +200,7 @@ async fn validate_authorization_commit(
_ => false,
};
if !matches_target {
let validation_error = AdminError::conflict(format!(
"{resource} prepared credential does not match its pending target"
));
let validation_error = AdminError::conflict("OAuth 凭据与待提交目标不一致");
if let Err(error) = prepared.abort().await {
tracing::warn!(
resource,
@@ -280,44 +297,6 @@ async fn commit_credential_refresh(
}
}
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,
@@ -1,13 +1,15 @@
//! 观测查询、趋势、费用覆盖与健康阈值规则。
use std::{
collections::{BTreeMap, btree_map::Entry},
collections::{BTreeMap, HashMap, btree_map::Entry},
str::FromStr,
sync::Arc,
time::{Duration as StdDuration, Instant},
};
use async_trait::async_trait;
use chrono::{DateTime, Duration, Timelike as _, Utc};
use gateway_core::{engine::credential::ProviderAccountId, routing::ProviderKind};
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
use crate::{
model::{
@@ -38,6 +40,58 @@ 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;
const DASHBOARD_QUERY_BUCKET_SECONDS: i64 = 2;
const DASHBOARD_QUERY_CACHE_RETENTION: StdDuration = StdDuration::from_secs(2);
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct DashboardQueryKey {
start: DateTime<Utc>,
end_bucket: i64,
}
impl DashboardQueryKey {
fn new(range: TimeRange) -> Self {
Self {
start: range.start,
end_bucket: range
.end
.timestamp()
.div_euclid(DASHBOARD_QUERY_BUCKET_SECONDS),
}
}
}
struct DashboardQueryEntry {
created_at: Instant,
result: Arc<tokio::sync::OnceCell<Result<DashboardResult, AdminError>>>,
}
#[derive(Default)]
struct DashboardQueryCache {
entries: tokio::sync::Mutex<HashMap<DashboardQueryKey, DashboardQueryEntry>>,
}
impl DashboardQueryCache {
async fn result_cell(
&self,
key: DashboardQueryKey,
) -> Arc<tokio::sync::OnceCell<Result<DashboardResult, AdminError>>> {
let now = Instant::now();
let mut entries = self.entries.lock().await;
entries.retain(|_, entry| {
now.duration_since(entry.created_at) < DASHBOARD_QUERY_CACHE_RETENTION
});
Arc::clone(
&entries
.entry(key)
.or_insert_with(|| DashboardQueryEntry {
created_at: now,
result: Arc::new(tokio::sync::OnceCell::new()),
})
.result,
)
}
}
/// API 消费的观测控制面服务。
#[async_trait]
@@ -74,6 +128,7 @@ pub(crate) struct DefaultObservabilityService {
store: Arc<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
providers: ProviderAdminRegistry,
dashboard_queries: DashboardQueryCache,
}
impl DefaultObservabilityService {
@@ -87,24 +142,22 @@ impl DefaultObservabilityService {
store,
settings,
providers,
dashboard_queries: DashboardQueryCache::default(),
}
}
}
#[async_trait]
impl ObservabilityService for DefaultObservabilityService {
async fn dashboard_summary(
async fn load_dashboard_summary(
&self,
range: TimeRange,
kind: TrendKind,
) -> Result<DashboardResult, AdminError> {
let observed_at = Utc::now();
let (mut observation, settings, runtime_slots) = futures::try_join!(
self.store.dashboard_summary(range),
self.store.dashboard_summary(range, observed_at),
self.settings.load_runtime_settings(),
self.store.dashboard_runtime_slots(range.end),
self.store.dashboard_runtime_slots(observed_at),
)
.map_err(|error| map_store_error(error, "dashboard"))?;
self.enrich_billing(&mut observation.recent_requests);
self.enrich_list_billing(&mut observation.recent_requests);
self.enrich_dashboard_quotas(&mut observation.account_usage)
.await;
let today_start = china_day_start(observation.range.end);
@@ -112,30 +165,40 @@ impl ObservabilityService for DefaultObservabilityService {
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_billing_usd = observation.totals.billing_usd.clone();
let total_cached_token_rate = rate_or_zero(
observation.requests.cached_tokens,
observation.requests.input_tokens,
observation.totals.cached_tokens,
observation.totals.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 (first_token_latency_sum_ms, first_token_latency_count) = observation
.trend
.iter()
.fold((0_u64, 0_u64), |(sum, count), point| {
(
sum.saturating_add(point.metrics.first_token_latency_sum_ms),
count.saturating_add(point.metrics.first_token_latency_count),
)
});
let average_first_token_latency_ms =
average(first_token_latency_sum_ms, first_token_latency_count);
let trend = trend(TrendKind::Usage, observation.trend.clone())?;
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
let wire_profiles = self.providers.dashboard_wire_profiles();
let 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 total_slots = runtime_slots.as_ref().map_or_else(
|| {
observation
.provider_accounts
.normal
.saturating_mul(max_concurrent_per_account)
},
|slots| {
slots
.inherited_accounts
.saturating_mul(max_concurrent_per_account)
.saturating_add(slots.overridden_slots)
},
);
let used_slots = runtime_slots.and_then(|slots| slots.used_slots);
Ok(DashboardResult {
capacity: DashboardCapacity {
@@ -156,6 +219,26 @@ impl ObservabilityService for DefaultObservabilityService {
wire_profiles,
})
}
}
#[async_trait]
impl ObservabilityService for DefaultObservabilityService {
async fn dashboard_summary(
&self,
range: TimeRange,
kind: TrendKind,
) -> Result<DashboardResult, AdminError> {
let cell = self
.dashboard_queries
.result_cell(DashboardQueryKey::new(range))
.await;
let mut result = cell
.get_or_init(|| self.load_dashboard_summary(range))
.await
.clone()?;
result.trend = trend(kind, result.observation.trend.clone())?;
Ok(result)
}
async fn dashboard_trend(
&self,
@@ -176,20 +259,20 @@ impl ObservabilityService for DefaultObservabilityService {
.list_usage_records(query)
.await
.map_err(|error| map_store_error(error, "usage records"))?;
self.enrich_billing(&mut page.items);
self.enrich_list_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"));
return Err(AdminError::invalid("用量记录 ID 不能为空"));
}
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));
self.enrich_detail_billing(std::slice::from_mut(&mut detail.request));
Ok(detail)
}
@@ -242,26 +325,50 @@ impl ObservabilityService for DefaultObservabilityService {
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 {
let mut items = items
.into_iter()
.map(|item| {
let error_rate = rate_or_zero(item.failure_count, item.request_count);
let non_completion_rate =
rate_or_zero(item.non_completion_count, item.request_count);
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
let impact_score = diagnostic_impact_score(
item.request_count,
total_requests,
error_rate,
non_completion_rate,
retry_rate,
item.first_token_p95_ms,
);
DiagnosticsItem {
key: item.key,
name: item.name,
request_count: item.request_count,
success_count: item.request_count.saturating_sub(item.failure_count),
success_count: item.success_count,
error_count: item.failure_count,
error_rate: rate_or_zero(item.failure_count, item.request_count),
error_rate,
request_share: rate_or_zero(item.request_count, total_requests),
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
first_token_p95_ms: item.first_token_p95_ms,
non_completion_count: item.non_completion_count,
non_completion_rate,
retry_count: item.retry_count,
retry_rate,
impact_score,
estimated_cost: usd_cost(&item.costs),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
})
.collect(),
})
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.impact_score
.total_cmp(&left.impact_score)
.then_with(|| right.request_count.cmp(&left.request_count))
});
Ok(DiagnosticsResult { dimension, items })
}
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
@@ -275,35 +382,40 @@ impl ObservabilityService for DefaultObservabilityService {
fn build_usage_insights(
overview: UsageOverview,
trend: Vec<RequestMetricPoint>,
standard_costs: UsageStandardCosts,
standard_costs: UsageCostScenarios,
) -> 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",
));
return Err(AdminError::internal("用量洞察趋势包含不一致的时间粒度"));
}
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,
total_requests: requests.request_count,
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),
completion_rate: rate_or_zero(
requests
.success_count
.saturating_add(requests.failure_count),
requests.request_count,
),
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 {
total_requests: point.metrics.request_count,
bucket_start: point.bucket_start,
success_requests: point.metrics.success_count,
failed_requests,
@@ -323,27 +435,74 @@ fn build_usage_insights(
requests.first_token_latency_count,
requests.request_count,
),
admission_decision_percentiles: requests.admission_decision_percentiles,
account_selection_wait_percentiles: requests.account_selection_wait_percentiles,
admission_decision_coverage: rate_or_zero(
requests.admission_decision_count,
requests.request_count,
),
account_selection_wait_coverage: rate_or_zero(
requests.account_selection_wait_count,
requests.request_count,
),
output_throughput_p10: requests.output_throughput_p10,
output_throughput_p50: requests.output_throughput_p50,
output_throughput_p90: requests.output_throughput_p90,
capacity_utilization_avg_basis_points: requests.capacity_utilization_avg_basis_points,
capacity_utilization_p95_basis_points: requests.capacity_utilization_p95_basis_points,
capacity_coverage: rate_or_zero(requests.capacity_sample_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,
admission_decision_percentiles: point.metrics.admission_decision_percentiles,
account_selection_wait_percentiles: point
.metrics
.account_selection_wait_percentiles,
output_throughput_p10: point.metrics.output_throughput_p10,
output_throughput_p50: point.metrics.output_throughput_p50,
output_throughput_p90: point.metrics.output_throughput_p90,
capacity_utilization_avg_basis_points: point
.metrics
.capacity_utilization_avg_basis_points,
capacity_utilization_p95_basis_points: point
.metrics
.capacity_utilization_p95_basis_points,
})
.collect(),
};
let estimated_cost = usd_cost(&overview.attempts.costs);
let UsageStandardCosts {
total: standard_cost,
by_bucket: standard_costs_by_bucket,
let UsageCostScenarios {
standard:
ScenarioCosts {
total: standard_cost,
by_bucket: standard_costs_by_bucket,
},
no_cache:
ScenarioCosts {
total: no_cache_cost,
by_bucket: no_cache_costs_by_bucket,
},
} = standard_costs;
let cache_savings = amount_difference(no_cache_cost.as_ref(), estimated_cost.as_ref());
let tier_premium = amount_difference(estimated_cost.as_ref(), standard_cost.as_ref());
let cost_per_request = estimated_cost
.as_ref()
.and_then(|cost| cost.checked_div_u64(requests.request_count));
let cost_per_successful_request = estimated_cost
.as_ref()
.and_then(|cost| cost.checked_div_u64(requests.success_count));
let cost = UsageInsightsCost {
cost_per_request: estimated_cost
.as_ref()
.and_then(|cost| cost.checked_div_u64(requests.request_count)),
cost_per_request,
estimated_cost,
standard_cost,
no_cache_cost,
cache_savings,
tier_premium,
tokens_per_request: rate_or_zero(requests.total_tokens, requests.request_count),
cost_per_successful_request,
cached_token_rate: rate_or_zero(requests.cached_tokens, requests.input_tokens),
cache_hit_request_rate: ratio(
requests.cache_hit_request_count,
@@ -363,6 +522,11 @@ fn build_usage_insights(
total_tokens: point.metrics.total_tokens,
estimated_cost: usd_cost(&point.costs),
standard_cost: standard_costs_by_bucket.get(&point.bucket_start).cloned(),
no_cache_cost: no_cache_costs_by_bucket.get(&point.bucket_start).cloned(),
cache_savings: amount_difference(
no_cache_costs_by_bucket.get(&point.bucket_start),
usd_cost(&point.costs).as_ref(),
),
cached_token_rate: rate_or_zero(
point.metrics.cached_tokens,
point.metrics.input_tokens,
@@ -387,12 +551,12 @@ fn build_usage_insights(
}
#[derive(Debug, Default)]
struct UsageStandardCosts {
struct ScenarioCosts {
total: Option<DecimalAmount>,
by_bucket: BTreeMap<DateTime<Utc>, DecimalAmount>,
}
impl UsageStandardCosts {
impl ScenarioCosts {
fn add(
&mut self,
bucket_start: DateTime<Utc>,
@@ -401,14 +565,15 @@ impl UsageStandardCosts {
self.total = Some(match self.total.take() {
Some(current) => current
.checked_add(&amount)
.ok_or_else(|| AdminError::internal("Usage standard cost exceeds decimal range"))?,
.ok_or_else(|| AdminError::internal("用量标准成本超出数值范围"))?,
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")
})?;
let sum = entry
.get()
.checked_add(&amount)
.ok_or_else(|| AdminError::internal("用量标准成本超出数值范围"))?;
entry.insert(sum);
}
Entry::Vacant(entry) => {
@@ -419,23 +584,29 @@ impl UsageStandardCosts {
}
}
#[derive(Debug, Default)]
struct UsageCostScenarios {
standard: ScenarioCosts,
no_cache: ScenarioCosts,
}
fn recover_standard_costs(
providers: &ProviderAdminRegistry,
facts: Vec<UsageCalculatedBillingFact>,
) -> Result<UsageStandardCosts, AdminError> {
let mut standard_costs = UsageStandardCosts::default();
) -> Result<UsageCostScenarios, AdminError> {
let mut scenarios = UsageCostScenarios::default();
for fact in facts {
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind) else {
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind.clone()) else {
continue;
};
let input = ProviderBillingInput {
upstream_model_id: fact.upstream_model_id,
service_tier: fact.service_tier,
upstream_model_id: fact.upstream_model_id.clone(),
service_tier: fact.service_tier.clone(),
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,
total: fact.total.clone(),
};
let breakdown = match providers.calculated_billing(&provider_kind, &input) {
Ok(breakdown) => breakdown,
@@ -452,9 +623,80 @@ fn recover_standard_costs(
{
continue;
}
standard_costs.add(fact.bucket_start, breakdown.standard_amount.amount)?;
scenarios
.standard
.add(fact.bucket_start, breakdown.standard_amount.amount.clone())?;
if let Some(no_cache_cost) = no_cache_cost(&fact, &breakdown) {
scenarios.no_cache.add(fact.bucket_start, no_cache_cost)?;
}
}
Ok(standard_costs)
Ok(scenarios)
}
fn no_cache_cost(
fact: &UsageCalculatedBillingFact,
breakdown: &crate::model::observability::CalculatedBillingBreakdown,
) -> Option<DecimalAmount> {
let input_tokens = fact.input_tokens?;
let cached_tokens = fact.cached_tokens.unwrap_or_default().min(input_tokens);
let cache_write_tokens = fact
.cache_write_tokens
.unwrap_or_default()
.min(input_tokens.saturating_sub(cached_tokens));
let input_rate = decimal(&breakdown.input_price_per_million.amount)?;
let replaced_input_amount = input_rate
.scaled()
.checked_mul(u128::from(cached_tokens.saturating_add(cache_write_tokens)))?
.checked_div(1_000_000)?;
let total = decimal(&breakdown.total_amount.amount)?
.scaled()
.checked_sub(decimal(&breakdown.cache_read_amount.amount)?.scaled())?
.checked_sub(decimal(&breakdown.cache_write_amount.amount)?.scaled())?
.checked_add(replaced_input_amount)?;
DecimalAmount::from_str(
&gateway_core::metering::Decimal::from_scaled(total)
.ok()?
.canonical(),
)
.ok()
}
fn amount_difference(
larger: Option<&DecimalAmount>,
smaller: Option<&DecimalAmount>,
) -> Option<DecimalAmount> {
let difference = decimal(larger?)?
.scaled()
.checked_sub(decimal(smaller?)?.scaled())?;
DecimalAmount::from_str(
&gateway_core::metering::Decimal::from_scaled(difference)
.ok()?
.canonical(),
)
.ok()
}
fn decimal(value: &DecimalAmount) -> Option<gateway_core::metering::Decimal> {
value.as_str().parse().ok()
}
fn diagnostic_impact_score(
request_count: u64,
total_requests: u64,
error_rate: f64,
non_completion_rate: f64,
retry_rate: f64,
first_token_p95_ms: Option<u64>,
) -> f64 {
let request_share = rate_or_zero(request_count, total_requests);
let slow_score = first_token_p95_ms
.map_or(0.0, |value| value as f64 / 30_000.0)
.min(1.0);
error_rate * 0.35
+ non_completion_rate * 0.25
+ retry_rate.min(1.0) * 0.20
+ request_share * 0.10
+ slow_score * 0.10
}
fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
@@ -489,14 +731,45 @@ impl DefaultObservabilityService {
});
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));
account.quota_used_percent = used_percent;
}
}
/// 逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回。
fn enrich_billing(&self, records: &mut [crate::model::observability::UsageRecord]) {
fn enrich_list_billing(&self, records: &mut [crate::model::observability::UsageListRecord]) {
for record in records {
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
continue;
};
if !matches!(source.as_str(), "calculated" | "provider_reported") {
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)));
}
}
}
fn enrich_detail_billing(&self, records: &mut [crate::model::observability::UsageRecord]) {
for record in records {
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
continue;
@@ -609,9 +882,9 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
.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")
})?;
*amount = amount
.checked_add(&cost.amount)
.ok_or_else(|| AdminError::internal("成本聚合结果超出数值范围"))?;
} else {
costs.insert(cost.currency.clone(), cost.amount.clone());
}
@@ -627,6 +900,20 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
metrics.cache_hit_request_count,
metrics.cache_eligible_request_count,
);
let peak_first_token_p95_ms = points
.iter()
.filter_map(|point| point.metrics.first_token_latency_percentiles.p95_ms)
.map(|value| value.as_f64())
.reduce(f64::max);
let peak_latency_p95_ms = points
.iter()
.filter_map(|point| point.metrics.latency_percentiles.p95_ms)
.map(|value| value.as_f64())
.reduce(f64::max);
let minimum_output_throughput_p50 = points
.iter()
.filter_map(|point| point.metrics.output_throughput_p50)
.min();
Ok(Trend {
kind,
points: points.into_iter().map(trend_point).collect(),
@@ -640,6 +927,9 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
average_latency_ms,
max_latency_ms: metrics.max_latency_ms,
min_latency_ms: metrics.min_latency_ms,
peak_first_token_p95_ms,
peak_latency_p95_ms,
minimum_output_throughput_p50,
success_rate,
cache_hit_request_rate,
costs: costs
@@ -3,7 +3,7 @@
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::{engine::credential::ProviderAccountId, routing::snapshot::SnapshotControl};
use gateway_core::{account::ProviderAccountId, runtime::SnapshotControl};
use crate::{
model::{
@@ -11,9 +11,9 @@ use crate::{
provider_credentials::{
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
CredentialDeletionResult, CredentialDetails, CredentialImportCommit,
CredentialImportResult, CredentialListQuery, CredentialMutation,
CredentialMutationResult, CredentialPage, ImportCredentials, PrepareCredentialImport,
PrepareCredentialRotation, ProviderQuotaRequest, RotateCredential, StartAuthorization,
CredentialImportResult, CredentialListQuery, CredentialMutationResult, CredentialPage,
ImportCredentials, PrepareCredentialImport, PrepareCredentialRotation,
ProviderQuotaRequest, RotateCredential, StartAuthorization,
},
},
ports::{provider::ProviderAdmin, store::AccountStore},
@@ -22,8 +22,7 @@ use crate::{
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,
validate_authorization_commit, validate_prepared_import, validate_prepared_rotation,
};
/// OpenAI 固定管理路由消费的服务。
@@ -50,14 +49,6 @@ pub trait OpenAiService: Send + Sync {
&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,
@@ -84,25 +75,29 @@ impl DefaultOpenAiService {
}
}
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"
);
fn observe_initial_quotas(&self, account_ids: &[ProviderAccountId], request_id: &str) {
let provider = Arc::clone(&self.provider);
let account_ids = account_ids.to_vec();
let request_id = request_id.to_owned();
tokio::spawn(async move {
for account_id in account_ids {
if let Err(error) = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await
{
tracing::warn!(
request_id,
account_id = %account_id.as_str(),
quota_error = ?error.kind(),
"OpenAI initial quota observation failed"
);
}
}
}
});
}
}
@@ -155,8 +150,7 @@ impl OpenAiService for DefaultOpenAiService {
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
// 导入完成后立即做一次观察;失败不回滚已经提交的 credential。
self.observe_initial_quotas(&result.credential_ids, &context.request_id)
.await;
self.observe_initial_quotas(&result.credential_ids, &context.request_id);
Ok(result)
}
@@ -208,8 +202,7 @@ impl OpenAiService for DefaultOpenAiService {
self.observe_initial_quotas(
std::slice::from_ref(&result.account_id),
&context.request_id,
)
.await;
);
Ok(result)
}
@@ -250,38 +243,6 @@ impl OpenAiService for DefaultOpenAiService {
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,
@@ -3,7 +3,8 @@
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::routing::snapshot::SnapshotControl;
use gateway_core::policy::CodexClientVersion;
use gateway_core::runtime::SnapshotControl;
use rand_core::{OsRng, RngCore as _};
use crate::{
@@ -119,10 +120,16 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
&& command.usage_retention_days >= 31
&& command.ops_event_retention_days > 0
&& command.audit_retention_days > 0
&& valid_client_version(command.min_codex_desktop_version.as_deref())
&& valid_client_version(command.min_codex_cli_version.as_deref())
&& i64::try_from(command.request_interval_ms).is_ok();
if valid {
Ok(())
} else {
Err(AdminError::invalid("Runtime settings violate constraints"))
Err(AdminError::invalid("运行时设置不满足约束"))
}
}
fn valid_client_version(value: Option<&str>) -> bool {
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
}
@@ -94,5 +94,12 @@ fn map_system_error(error: SystemOperationError) -> AdminError {
SystemOperationErrorKind::Upstream => AdminErrorKind::BadGateway,
SystemOperationErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, error.message())
let message = match kind {
AdminErrorKind::Invalid => "系统操作请求不合法",
AdminErrorKind::Conflict => "系统当前状态不允许执行该操作",
AdminErrorKind::BadGateway => "系统更新服务请求失败",
AdminErrorKind::Internal => "系统操作失败",
_ => "系统操作失败",
};
AdminError::new(kind, message)
}
@@ -3,7 +3,7 @@
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::routing::snapshot::SnapshotControl;
use gateway_core::runtime::SnapshotControl;
use crate::{
model::{
@@ -11,8 +11,8 @@ use crate::{
provider_credentials::{
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
CredentialListQuery, CredentialMutation, CredentialMutationResult, CredentialPage,
ImportCredentials, PrepareCredentialImport, ProviderQuotaRequest, StartAuthorization,
CredentialListQuery, CredentialMutationResult, CredentialPage, ImportCredentials,
PrepareCredentialImport, ProviderQuotaRequest, StartAuthorization,
},
},
ports::{provider::ProviderAdmin, store::AccountStore},
@@ -20,8 +20,8 @@ use crate::{
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,
pending_authorization, publish_committed, validate_authorization_commit,
validate_prepared_import,
};
/// xAI 固定管理路由消费的服务。
@@ -40,14 +40,6 @@ pub trait XaiService: Send + Sync {
&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,
@@ -166,38 +158,6 @@ impl XaiService for DefaultXaiService {
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,
@@ -13,9 +13,10 @@ use gateway_admin::{
MutationActor, MutationContext, Revision,
auth::AdminAuditEvent,
backup::{
BackupObjectMetadata, BackupRecord, BackupRecordListQuery, BackupRecordPage,
BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig, BackupTriggerKind,
ConnectionTestResult, UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery,
BackupRecordPage, BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig,
BackupTriggerKind, ConnectionTestResult, UpdateBackupScheduleCommand,
UpdateBackupStorageCommand,
},
},
ports::{
@@ -497,15 +498,23 @@ impl DatabaseDumpPort for FakeDumpPort {
/// 内存版对象存储:把上传内容按 key 保存并支持 Head/Delete/预签名。
pub(crate) struct FakeObjectStore {
pub(crate) objects: Mutex<std::collections::HashMap<String, Vec<u8>>>,
upload_error: Option<BackupError>,
}
impl FakeObjectStore {
pub(crate) fn new() -> Self {
Self {
objects: Mutex::new(std::collections::HashMap::new()),
upload_error: None,
}
}
/// 让上传返回指定的脱敏 S3 错误。
pub(crate) fn fail_upload(mut self, code: &'static str, message: impl Into<String>) -> Self {
self.upload_error = Some(BackupError::new(code, message.into()));
self
}
pub(crate) fn object(&self, key: &str) -> Option<Vec<u8>> {
self.objects.lock().expect("objects").get(key).cloned()
}
@@ -530,6 +539,9 @@ impl BackupObjectStorePort for FakeObjectStore {
_config: &BackupStorageConfig,
request: UploadObjectRequest,
) -> Result<(), gateway_admin::model::backup::BackupError> {
if let Some(error) = &self.upload_error {
return Err(error.clone());
}
let content = std::fs::read(&request.source).map_err(|_| {
gateway_admin::model::backup::BackupError::new(
gateway_admin::model::backup::code::S3_UPLOAD_FAILED,
@@ -6,11 +6,11 @@ use chrono::{Duration, Utc};
use gateway_admin::{
backup::task::BackupTask,
model::backup::{
BackupRecordSeed, BackupStatus, BackupTriggerKind, UpdateBackupScheduleCommand,
BackupRecordSeed, BackupStatus, BackupTriggerKind, UpdateBackupScheduleCommand, code,
},
ports::backup::BackupRepository,
ports::backup::{BackupRepository, DatabaseDumpPort, DumpRequest},
};
use gateway_core::engine::CancellationToken;
use gateway_core::lifecycle::CancellationToken;
use super::support::{
DUMP_CONTENT, FakeBackupRepository, FakeDumpPort, FakeObjectStore, backup_id,
@@ -89,6 +89,78 @@ async fn backup_task_marks_failed_when_dump_port_fails() {
assert!(object_store.object(&seed.object_key).is_none());
}
#[tokio::test]
async fn backup_task_marks_failed_with_the_upload_error() {
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
let dump = Arc::new(FakeDumpPort::new());
let object_store =
Arc::new(FakeObjectStore::new().fail_upload(code::S3_UPLOAD_FAILED, "对象存储拒绝上传"));
let seed = BackupRecordSeed {
id: backup_id("upload-failure"),
trigger_kind: BackupTriggerKind::Manual,
scheduled_at: None,
object_key: "codex/2026/08/01/upload-failure.dump".to_owned(),
expires_at: None,
};
repository
.insert_backup_record(seed.clone())
.await
.expect("insert queued");
let task = BackupTask::new(repository.clone(), dump, object_store);
task.run_cycle(&CancellationToken::new())
.await
.expect("run cycle");
let records = repository.all_records();
let failed = &records[0];
assert_eq!(failed.status, BackupStatus::Failed);
assert_eq!(failed.error_code.as_deref(), Some(code::S3_UPLOAD_FAILED));
assert_eq!(
failed.error_message.as_deref(),
Some("上传到对象存储失败:对象存储拒绝上传")
);
}
#[tokio::test]
async fn backup_task_recovery_marks_failed_with_the_upload_error() {
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
let dump = Arc::new(FakeDumpPort::new());
let object_store =
Arc::new(FakeObjectStore::new().fail_upload(code::S3_UPLOAD_FAILED, "对象存储拒绝上传"));
let seed = BackupRecordSeed {
id: backup_id("recovered-upload-failure"),
trigger_kind: BackupTriggerKind::Manual,
scheduled_at: None,
object_key: "codex/2026/08/01/recovered-upload-failure.dump".to_owned(),
expires_at: None,
};
repository
.insert_backup_record(seed.clone())
.await
.expect("insert queued");
let cancellation = CancellationToken::new();
dump.dump(DumpRequest {
backup_id: seed.id.clone(),
cancellation: cancellation.clone(),
})
.await
.expect("stage dump");
repository.force_status(&seed.id, BackupStatus::Dumping);
let task = BackupTask::new(repository.clone(), dump, object_store);
task.run_cycle(&cancellation).await.expect("run cycle");
let records = repository.all_records();
let failed = &records[0];
assert_eq!(failed.status, BackupStatus::Failed);
assert_eq!(failed.error_code.as_deref(), Some(code::S3_UPLOAD_FAILED));
assert_eq!(
failed.error_message.as_deref(),
Some("上传到对象存储失败:对象存储拒绝上传")
);
}
#[tokio::test]
async fn retention_cleans_expired_scheduled_backups() {
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
+116 -2
View File
@@ -1,16 +1,18 @@
use std::str::FromStr as _;
use chrono::{TimeDelta, Utc};
use gateway_core::engine::credential::OpaqueProviderData;
use gateway_core::account::OpaqueProviderData;
use gateway_admin::{
model::{
PageSize, Revision,
accounts::AccountUsage,
auth::LoginCommand,
client_keys::ClientKeyPageSize,
observability::{DecimalAmount, RequestOutcome, TimeRange},
provider_credentials::{
CredentialCommitGuard, ProviderDocument, ProviderQuota, ProviderQuotaWindow,
QuotaLocalUsageAttribution,
},
settings::AdminApiKey,
},
@@ -134,6 +136,115 @@ fn representative_quota_should_prefer_short_window_and_highest_usage() {
assert_eq!(quota.representative_used_percent(), Some(45.0));
}
#[test]
fn representative_quota_should_prefer_account_wide_window_over_model_specific_window() {
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let quota = ProviderQuota {
observed_at: None,
refresh_token_expires_at: None,
windows: vec![
model_specific,
quota_window("shortTerm", Some(604_800), Some(5.0)),
],
limit_reached: false,
provider_data: None,
};
assert_eq!(quota.representative_used_percent(), Some(5.0));
}
#[test]
fn representative_window_usage_should_accept_account_wide_rolling_window_without_reset() {
let usage = AccountUsage {
account_id: "acct_xai".to_owned(),
request_count: 5,
success_count: 5,
input_tokens: Some(100),
output_tokens: Some(20),
cached_tokens: Some(80),
cache_write_tokens: Some(0),
reasoning_tokens: Some(0),
image_input_tokens: Some(0),
image_output_tokens: Some(0),
image_request_count: 0,
image_request_failed_count: 0,
total_tokens: Some(120),
cost_coverage: Default::default(),
costs: Vec::new(),
last_used_at: Some(Utc::now()),
request_buckets: Vec::new(),
models: Vec::new(),
};
let quota = ProviderQuota {
observed_at: None,
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
key: "free-rolling-24h".to_owned(),
group: "shortTerm".to_owned(),
label: "日限额".to_owned(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(86_400),
used_percent: None,
reset_at: None,
limit_reached: false,
local_usage: Some(usage.clone()),
provider_data: None,
}],
limit_reached: false,
provider_data: None,
};
assert_eq!(quota.representative_window_usage(), Some(&usage));
}
#[test]
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
let mut 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(95.0)),
],
limit_reached: true,
provider_data: None,
};
assert_eq!(quota.representative_used_percent(), Some(100.0));
quota.apply_limit_reached_display();
assert_eq!(quota.windows[0].used_percent, Some(99.0));
assert_eq!(quota.windows[1].used_percent, Some(80.0));
assert_eq!(quota.windows[2].used_percent, Some(30.0));
assert_eq!(quota.windows[3].used_percent, Some(100.0));
assert!(quota.windows[3].limit_reached);
}
#[test]
fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
reached.limit_reached = true;
let mut quota = ProviderQuota {
observed_at: None,
refresh_token_expires_at: None,
windows: vec![reached, quota_window("shortTerm", Some(18_000), Some(95.0))],
limit_reached: true,
provider_data: None,
};
quota.apply_limit_reached_display();
assert_eq!(quota.windows[0].used_percent, Some(100.0));
assert_eq!(quota.windows[1].used_percent, Some(95.0));
assert!(!quota.windows[1].limit_reached);
}
fn quota_window(
group: &str,
window_seconds: Option<u64>,
@@ -143,7 +254,10 @@ fn quota_window(
key: group.to_owned(),
group: group.to_owned(),
label: group.to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds,
used_percent,
reset_at: None,
@@ -1,4 +1,4 @@
//! 备份领域模型(model/backup.rs)的纯逻辑测试。
//! 备份领域模型(model/backup)的纯逻辑测试。
use chrono::{TimeZone as _, Utc};
use secrecy::SecretString;
@@ -0,0 +1,236 @@
use std::{
collections::BTreeMap,
str::FromStr as _,
sync::{Arc, Mutex},
};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use gateway_admin::{
model::{
MutationContext, PageSize, Revision,
account_groups::{
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupColor,
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage,
AccountGroupRecord, AccountGroupUsage, DeleteAccountGroup, NewAccountGroup,
SetAccountGroupEnabled, UpdateAccountGroup,
},
accounts::AccountRuntimeSnapshot,
observability::DecimalAmount,
},
ports::store::{
AccountGroupStore, AccountRuntimeStore, AdminStoreError, AdminStoreErrorKind,
AdminStoreResult,
},
};
use gateway_core::{
account::{AccountStatusFacts, CredentialState, QuotaState},
routing::AccountGroupId,
};
use super::AdminHarness;
const GROUP_ID: &str = "grp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
#[tokio::test]
async fn group_query_service_enriches_only_current_page_members_with_runtime_facts() {
let groups = Arc::new(FakeGroupStore::default());
let runtime = Arc::new(FakeRuntimeStore::default());
let service = AdminHarness::new()
.account_groups(groups.clone())
.account_runtime(runtime.clone())
.build()
.await;
let page = service
.account_groups()
.list(AccountGroupListQuery {
page: 1,
page_size: PageSize::new(20).expect("page size"),
search: None,
enabled: None,
})
.await
.expect("list projected account groups");
assert_eq!(
groups
.requested_groups
.lock()
.expect("requested groups")
.as_slice(),
[GROUP_ID]
);
assert_eq!(
runtime
.requested_accounts
.lock()
.expect("requested accounts")
.as_slice(),
["acct_available", "acct_limited"]
);
let group = &page.items[0];
assert_eq!(
group.account_summary,
AccountGroupAccountSummary {
available: 1,
limited: 1,
total: 2,
}
);
assert_eq!(
group.capacity,
AccountGroupCapacity {
used_slots: Some(2),
total_slots: 4,
}
);
}
#[derive(Default)]
struct FakeGroupStore {
requested_groups: Mutex<Vec<String>>,
}
#[async_trait]
impl AccountGroupStore for FakeGroupStore {
async fn list_account_groups(
&self,
query: AccountGroupListQuery,
) -> AdminStoreResult<AccountGroupPage> {
Ok(AccountGroupPage {
config_revision: Revision::new(1).expect("revision"),
items: vec![group_record()],
total: 1,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn load_account_group_members(
&self,
group_ids: &[AccountGroupId],
) -> AdminStoreResult<Vec<AccountGroupMemberFact>> {
*self.requested_groups.lock().expect("requested groups") = group_ids
.iter()
.map(|group_id| group_id.as_str().to_owned())
.collect();
Ok(vec![member("acct_available", 4), member("acct_limited", 3)])
}
async fn create_account_group(
&self,
_: NewAccountGroup,
_: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation> {
Err(unused())
}
async fn update_account_group(
&self,
_: UpdateAccountGroup,
_: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation> {
Err(unused())
}
async fn set_account_group_enabled(
&self,
_: SetAccountGroupEnabled,
_: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation> {
Err(unused())
}
async fn delete_account_group(
&self,
_: DeleteAccountGroup,
_: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation> {
Err(unused())
}
}
#[derive(Default)]
struct FakeRuntimeStore {
requested_accounts: Mutex<Vec<String>>,
}
#[async_trait]
impl AccountRuntimeStore for FakeRuntimeStore {
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
async fn account_runtime(
&self,
account_ids: &[String],
) -> AdminStoreResult<AccountRuntimeSnapshot> {
*self.requested_accounts.lock().expect("requested accounts") = account_ids.to_vec();
Ok(AccountRuntimeSnapshot {
rate_limited_until: BTreeMap::from([(
"acct_limited".to_owned(),
Utc::now() + Duration::minutes(5),
)]),
in_flight: Some(BTreeMap::from([("acct_available".to_owned(), 2)])),
})
}
}
fn group_record() -> AccountGroupRecord {
let now = Utc::now();
AccountGroupRecord {
id: group_id(),
name: "Primary".to_owned(),
description: None,
color: AccountGroupColor::parse("#2563EBFF").expect("color"),
enabled: true,
member_count: 2,
provider_counts: BTreeMap::from([("openai".to_owned(), 2)]),
client_key_count: 1,
account_summary: AccountGroupAccountSummary {
available: 0,
limited: 0,
total: 0,
},
capacity: AccountGroupCapacity {
used_slots: None,
total_slots: 0,
},
usage: AccountGroupUsage {
today_usd: DecimalAmount::from_str("1").expect("today usage"),
retained_total_usd: DecimalAmount::from_str("3").expect("retained usage"),
},
created_at: now,
updated_at: now,
}
}
fn member(account_id: &str, total_slots: u64) -> AccountGroupMemberFact {
AccountGroupMemberFact {
group_id: group_id(),
account_id: account_id.to_owned(),
status: AccountStatusFacts {
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::default(),
rate_limited_until: None,
last_error_reason: None,
last_error_message: None,
},
total_slots,
}
}
fn group_id() -> AccountGroupId {
AccountGroupId::new(GROUP_ID).expect("group ID")
}
fn unused() -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
"account group",
"unused test operation",
)
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
mod account_groups;
mod accounts;
mod auth;
mod backup;
@@ -14,16 +15,23 @@ use std::{
};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::future::BoxFuture;
use gateway_admin::{
AdminConfig, AdminServices, InitialAdminPassword,
model::{
MutationContext, Revision,
account_groups::{
AccountGroupListQuery, AccountGroupMemberFact, AccountGroupMutation, AccountGroupPage,
DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
},
accounts::{
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
AccountListQuery, AccountPage, AccountRuntimeSnapshot, AccountUpdateResult,
AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult, AccountsUpdateResult,
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
},
auth::{AdminAuditEvent, AdminSession},
client_distribution::CodexDesktopWindowsDownloads,
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
@@ -48,10 +56,12 @@ use gateway_admin::{
},
ports::{
backup::BackupStorePorts,
client_distribution::ClientDistributionResolver,
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
store::{
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStorePorts, AdminStoreResult,
AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore,
AccountGroupStore, AccountRuntimeStore, AccountStore, AdminAccountStorePorts,
AdminStoreError, AdminStoreErrorKind, AdminStorePorts, AdminStoreResult, AuthStore,
ClientKeyStore, ObservabilityStore, SettingsStore,
},
system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations,
@@ -60,19 +70,20 @@ use gateway_admin::{
},
};
use gateway_core::{
engine::{
credential::ProviderAccountId,
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
},
account::ProviderAccountId,
engine::probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
error::{GatewayError, GatewayErrorKind},
policy::ClientApiKeyId,
routing::{ConfigRevision, ProviderKind, snapshot::SnapshotControl},
routing::{ConfigRevision, ProviderKind},
runtime::SnapshotControl,
};
pub(super) struct AdminHarness {
default_password: String,
session_ttl_minutes: u64,
accounts: Arc<dyn AccountStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
account_groups: Arc<dyn AccountGroupStore>,
auth: Arc<dyn AuthStore>,
client_keys: Arc<dyn ClientKeyStore>,
observability: Arc<dyn ObservabilityStore>,
@@ -90,6 +101,8 @@ impl AdminHarness {
default_password: "strong-test-password".to_owned(),
session_ttl_minutes: 60,
accounts: unavailable.clone(),
account_runtime: unavailable.clone(),
account_groups: Arc::new(UnavailableAccountGroupStore),
auth: Arc::new(BootstrapAuthStore::default()),
client_keys: unavailable.clone(),
observability: unavailable.clone(),
@@ -119,6 +132,16 @@ impl AdminHarness {
self
}
pub(super) fn account_runtime(mut self, store: Arc<dyn AccountRuntimeStore>) -> Self {
self.account_runtime = store;
self
}
pub(super) fn account_groups(mut self, store: Arc<dyn AccountGroupStore>) -> Self {
self.account_groups = store;
self
}
pub(super) fn auth(mut self, store: Arc<dyn AuthStore>) -> Self {
self.auth = store;
self
@@ -169,7 +192,11 @@ impl AdminHarness {
default_password: InitialAdminPassword::new(self.default_password),
},
AdminStorePorts::new(
self.accounts,
AdminAccountStorePorts::new(
self.accounts,
self.account_runtime,
self.account_groups,
),
self.auth,
self.client_keys,
self.observability,
@@ -179,6 +206,7 @@ impl AdminHarness {
self.providers,
Arc::new(NoopSnapshot),
self.probe,
Arc::new(NoopClientDistribution),
self.system,
)
.await
@@ -187,6 +215,20 @@ impl AdminHarness {
}
}
struct NoopClientDistribution;
#[async_trait]
impl ClientDistributionResolver for NoopClientDistribution {
async fn resolve_codex_desktop_windows(&self, _: bool) -> CodexDesktopWindowsDownloads {
CodexDesktopWindowsDownloads {
resolved_at: Utc::now(),
cached: false,
warning: None,
packages: Vec::new(),
}
}
}
#[derive(Default)]
struct BootstrapAuthStore {
password_hash: Mutex<Option<String>>,
@@ -234,13 +276,72 @@ impl AuthStore for BootstrapAuthStore {
struct UnavailableStore;
struct UnavailableAccountGroupStore;
#[async_trait]
impl AccountGroupStore for UnavailableAccountGroupStore {
async fn list_account_groups(
&self,
_: AccountGroupListQuery,
) -> AdminStoreResult<AccountGroupPage> {
Err(unavailable("account groups"))
}
async fn load_account_group_members(
&self,
_: &[gateway_core::routing::AccountGroupId],
) -> AdminStoreResult<Vec<AccountGroupMemberFact>> {
Err(unavailable("account group members"))
}
async fn create_account_group(
&self,
_: NewAccountGroup,
_: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation> {
Err(unavailable("account group create"))
}
async fn update_account_group(
&self,
_: UpdateAccountGroup,
_: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation> {
Err(unavailable("account group update"))
}
async fn set_account_group_enabled(
&self,
_: SetAccountGroupEnabled,
_: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation> {
Err(unavailable("account group state"))
}
async fn delete_account_group(
&self,
_: DeleteAccountGroup,
_: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation> {
Err(unavailable("account group delete"))
}
}
#[async_trait]
impl AccountStore for UnavailableStore {
async fn list_accounts(&self, _: AccountListQuery) -> AdminStoreResult<AccountPage> {
async fn list_accounts(
&self,
_: AccountListQuery,
_: AccountRuntimeSnapshot,
) -> AdminStoreResult<AccountPage> {
Err(unavailable("accounts"))
}
async fn load_account(&self, _: &str) -> AdminStoreResult<Option<AccountRecord>> {
async fn load_account(
&self,
_: &str,
_: AccountRuntimeSnapshot,
) -> AdminStoreResult<Option<gateway_admin::model::accounts::AccountPageItem>> {
Err(unavailable("account"))
}
@@ -315,14 +416,30 @@ impl AccountStore for UnavailableStore {
Err(unavailable("credential refresh"))
}
async fn set_account_enabled(
async fn update_account(
&self,
_: SetAccountEnabled,
_: UpdateAccount,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
) -> AdminStoreResult<AccountUpdateResult> {
Err(unavailable("account enabled"))
}
async fn recover_account(
&self,
_: &ProviderAccountId,
_: &MutationContext,
) -> AdminStoreResult<AccountUpdateResult> {
Err(unavailable("account recovery"))
}
async fn batch_update_accounts(
&self,
_: BatchUpdateAccounts,
_: &MutationContext,
) -> AdminStoreResult<AccountsUpdateResult> {
Err(unavailable("account batch update"))
}
async fn delete_accounts(
&self,
_: DeleteAccounts,
@@ -340,6 +457,17 @@ impl AccountStore for UnavailableStore {
}
}
#[async_trait]
impl AccountRuntimeStore for UnavailableStore {
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
async fn account_runtime(&self, _: &[String]) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
}
#[async_trait]
impl ClientKeyStore for UnavailableStore {
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
@@ -388,7 +516,11 @@ impl ClientKeyStore for UnavailableStore {
#[async_trait]
impl ObservabilityStore for UnavailableStore {
async fn dashboard_summary(&self, _: TimeRange) -> AdminStoreResult<DashboardObservation> {
async fn dashboard_summary(
&self,
_: TimeRange,
_: DateTime<Utc>,
) -> AdminStoreResult<DashboardObservation> {
Err(unavailable("dashboard"))
}
@@ -1,7 +1,10 @@
use std::{
num::NonZeroU32,
str::FromStr as _,
sync::{Arc, Mutex},
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
time::Duration as StdDuration,
};
use async_trait::async_trait;
@@ -14,10 +17,10 @@ use gateway_admin::{
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,
HealthStatus, LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds,
RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, UsageBilling,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord, UsageOverview,
UsagePage, UsageQuery, china_day_start,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -105,6 +108,40 @@ async fn health_timeline_should_keep_exactly_china_day_quarter_hour_slots() {
}));
}
#[tokio::test]
async fn dashboard_summary_should_derive_first_token_average_from_trend() {
let now = Utc::now();
let current_slot = quarter_hour_start(now);
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
store.replace_trend(vec![
health_metric_point(
current_slot - Duration::minutes(15),
RequestMetrics {
first_token_latency_sum_ms: 120,
first_token_latency_count: 2,
..RequestMetrics::default()
},
),
health_metric_point(
current_slot,
RequestMetrics {
first_token_latency_sum_ms: 180,
first_token_latency_count: 1,
..RequestMetrics::default()
},
),
]);
let services = observability_services(store).await;
let summary = services
.observability()
.dashboard_summary(observation_range(now), TrendKind::Usage)
.await
.expect("dashboard summary");
assert_eq!(summary.average_first_token_latency_ms, Some(100));
}
#[tokio::test]
async fn health_timeline_should_match_legacy_status_precedence_and_thresholds() {
let now = Utc::now();
@@ -160,11 +197,15 @@ async fn health_timeline_should_match_legacy_status_precedence_and_thresholds()
),
];
for (metrics, expected_status, expected_reliability) in fixtures {
for (index, (metrics, expected_status, expected_reliability)) in
fixtures.into_iter().enumerate()
{
store.replace_trend(vec![health_metric_point(current_slot, metrics)]);
let range_end =
now + Duration::seconds(i64::try_from(index).expect("fixture index fits i64") * 2);
let timeline = services
.observability()
.dashboard_summary(observation_range(now), TrendKind::Usage)
.dashboard_summary(observation_range(range_end), TrendKind::Usage)
.await
.expect("dashboard summary")
.health_timeline;
@@ -183,7 +224,8 @@ 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,
inherited_accounts: 2,
overridden_slots: 5,
used_slots: Some(2),
}));
let services = observability_services(store).await;
@@ -195,9 +237,75 @@ async fn dashboard_summary_should_project_rebuildable_runtime_slots() {
.expect("dashboard summary")
.capacity;
assert_eq!(capacity.total_slots, 3);
assert_eq!(capacity.total_slots, 7);
assert_eq!(capacity.used_slots, Some(2));
assert_eq!(capacity.available_slots, Some(1));
assert_eq!(capacity.available_slots, Some(5));
}
#[tokio::test]
async fn dashboard_summary_should_share_one_current_observation_time_across_runtime_facts() {
let historical_end = Utc::now() - Duration::hours(1);
let store = Arc::new(FixtureObservabilityStore::new(observation_range(
historical_end,
)));
let services = observability_services(store.clone()).await;
services
.observability()
.dashboard_summary(observation_range(historical_end), TrendKind::Usage)
.await
.expect("dashboard summary");
let (summary_observed_at, slots_observed_at) = store.observed_times();
assert_eq!(summary_observed_at, slots_observed_at);
assert!(summary_observed_at.is_some_and(|value| value > historical_end));
}
#[tokio::test]
async fn dashboard_summary_should_coalesce_concurrent_requests_in_one_short_window() {
let end_bucket = Utc::now().timestamp().div_euclid(2) * 2;
let first_end = DateTime::from_timestamp(end_bucket, 100_000_000).expect("first end");
let second_end = DateTime::from_timestamp(end_bucket, 900_000_000).expect("second end");
let first_range = TimeRange::new(china_day_start(first_end), first_end).expect("first range");
let second_range =
TimeRange::new(china_day_start(second_end), second_end).expect("second range");
let store = Arc::new(FixtureObservabilityStore::new(first_range));
store.set_dashboard_delay(StdDuration::from_millis(20));
let services = observability_services(store.clone()).await;
let observability = services.observability();
let (first, second) = tokio::join!(
observability.dashboard_summary(first_range, TrendKind::Usage),
observability.dashboard_summary(second_range, TrendKind::Errors),
);
first.expect("first dashboard summary");
second.expect("second dashboard summary");
assert_eq!(store.dashboard_summary_calls(), 1);
}
#[tokio::test]
async fn dashboard_summary_should_reload_after_the_short_window_changes() {
let end_bucket = Utc::now().timestamp().div_euclid(2) * 2;
let first_end = DateTime::from_timestamp(end_bucket, 100_000_000).expect("first end");
let second_end = first_end + Duration::seconds(2);
let first_range = TimeRange::new(china_day_start(first_end), first_end).expect("first range");
let second_range =
TimeRange::new(china_day_start(second_end), second_end).expect("second range");
let store = Arc::new(FixtureObservabilityStore::new(first_range));
let services = observability_services(store.clone()).await;
let observability = services.observability();
observability
.dashboard_summary(first_range, TrendKind::Usage)
.await
.expect("first dashboard summary");
observability
.dashboard_summary(second_range, TrendKind::Usage)
.await
.expect("second dashboard summary");
assert_eq!(store.dashboard_summary_calls(), 2);
}
#[tokio::test]
@@ -214,6 +322,24 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
output_tokens: 200,
total_tokens: 1_000,
latency_count: 5,
admission_decision_count: 10,
admission_decision_percentiles: LatencyPercentiles {
p50_ms: Some(PercentileMilliseconds::new(2.0).expect("admission P50")),
p95_ms: Some(PercentileMilliseconds::new(5.0).expect("admission P95")),
p99_ms: Some(PercentileMilliseconds::new(8.0).expect("admission P99")),
},
account_selection_wait_count: 8,
account_selection_wait_percentiles: LatencyPercentiles {
p50_ms: Some(PercentileMilliseconds::new(10.0).expect("selection P50")),
p95_ms: Some(PercentileMilliseconds::new(40.0).expect("selection P95")),
p99_ms: Some(PercentileMilliseconds::new(70.0).expect("selection P99")),
},
output_throughput_p10: Some(40),
output_throughput_p50: Some(50),
output_throughput_p90: Some(60),
capacity_sample_count: 8,
capacity_utilization_avg_basis_points: Some(6_500),
capacity_utilization_p95_basis_points: Some(8_000),
..RequestMetrics::default()
};
store.replace_overview(UsageOverview {
@@ -274,10 +400,16 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
.await
.expect("usage diagnostics");
assert_eq!(insights.health.total_requests, 8);
assert_eq!(insights.health.total_requests, 10);
assert_eq!(insights.health.failed_requests, 2);
assert_eq!(insights.health.success_rate, 0.75);
assert_eq!(insights.health.completion_rate, 1.0);
assert_eq!(insights.granularity, Granularity::FifteenMinutes);
assert_eq!(insights.performance.latency_coverage, 0.5);
assert_eq!(insights.performance.admission_decision_coverage, 1.0);
assert_eq!(insights.performance.account_selection_wait_coverage, 0.8);
assert_eq!(insights.performance.capacity_coverage, 0.8);
assert_eq!(insights.performance.output_throughput_p50, Some(50));
assert_eq!(insights.cost.tokens_per_request, 100.0);
assert_eq!(
insights
@@ -317,8 +449,25 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("1")
);
assert_eq!(
insights
.cost
.no_cache_cost
.as_ref()
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("1.25")
);
assert_eq!(
insights
.cost
.tier_premium
.as_ref()
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("0.25")
);
assert_eq!(diagnostics.items[0].request_share, 0.75);
assert_eq!(diagnostics.items[1].request_share, 0.25);
assert!((diagnostics.items[0].impact_score - 0.075).abs() < 1e-9);
}
#[tokio::test]
@@ -394,7 +543,11 @@ struct FixtureObservabilityStore {
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
diagnostics: Mutex<Vec<DiagnosticObservation>>,
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
usage_records: Mutex<Vec<UsageRecord>>,
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
slots_observed_at: Mutex<Option<DateTime<Utc>>>,
usage_records: Mutex<Vec<UsageListRecord>>,
dashboard_delay: Mutex<StdDuration>,
dashboard_summary_calls: AtomicUsize,
}
impl FixtureObservabilityStore {
@@ -410,10 +563,22 @@ impl FixtureObservabilityStore {
calculated_billing_facts: Mutex::new(Vec::new()),
diagnostics: Mutex::new(Vec::new()),
runtime_slots: Mutex::new(None),
summary_observed_at: Mutex::new(None),
slots_observed_at: Mutex::new(None),
usage_records: Mutex::new(Vec::new()),
dashboard_delay: Mutex::new(StdDuration::ZERO),
dashboard_summary_calls: AtomicUsize::new(0),
}
}
fn set_dashboard_delay(&self, delay: StdDuration) {
*self.dashboard_delay.lock().expect("dashboard delay") = delay;
}
fn dashboard_summary_calls(&self) -> usize {
self.dashboard_summary_calls.load(Ordering::Relaxed)
}
fn replace_trend(&self, trend: Vec<RequestMetricPoint>) {
*self.trend.lock().expect("trend") = trend;
}
@@ -437,18 +602,40 @@ impl FixtureObservabilityStore {
*self.runtime_slots.lock().expect("runtime slots") = runtime_slots;
}
fn replace_usage_records(&self, records: Vec<UsageRecord>) {
fn observed_times(&self) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
(
*self
.summary_observed_at
.lock()
.expect("summary observed at"),
*self.slots_observed_at.lock().expect("slots observed at"),
)
}
fn replace_usage_records(&self, records: Vec<UsageListRecord>) {
*self.usage_records.lock().expect("usage records") = records;
}
}
#[async_trait]
impl ObservabilityStore for FixtureObservabilityStore {
async fn dashboard_summary(&self, range: TimeRange) -> AdminStoreResult<DashboardObservation> {
async fn dashboard_summary(
&self,
range: TimeRange,
observed_at: DateTime<Utc>,
) -> AdminStoreResult<DashboardObservation> {
self.dashboard_summary_calls.fetch_add(1, Ordering::Relaxed);
*self
.summary_observed_at
.lock()
.expect("summary observed at") = Some(observed_at);
let dashboard_delay = *self.dashboard_delay.lock().expect("dashboard delay");
if !dashboard_delay.is_zero() {
tokio::time::sleep(dashboard_delay).await;
}
Ok(DashboardObservation {
range,
requests: RequestMetrics::default(),
attempts: AttemptMetrics::default(),
totals: Default::default(),
provider_accounts: AccountPoolMetrics::default(),
trend: self.trend.lock().expect("trend").clone(),
account_usage: Vec::new(),
@@ -458,8 +645,9 @@ impl ObservabilityStore for FixtureObservabilityStore {
async fn dashboard_runtime_slots(
&self,
_: DateTime<Utc>,
observed_at: DateTime<Utc>,
) -> AdminStoreResult<Option<DashboardRuntimeSlots>> {
*self.slots_observed_at.lock().expect("slots observed at") = Some(observed_at);
Ok(*self.runtime_slots.lock().expect("runtime slots"))
}
@@ -487,13 +675,14 @@ impl ObservabilityStore for FixtureObservabilityStore {
.clone())
}
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
async fn list_usage_records(&self, query: 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,
current_page: query.current_page,
page_size: query.page_size.get(),
total,
next_cursor: None,
})
}
@@ -532,6 +721,8 @@ impl SettingsStore for FixtureSettingsStore {
max_concurrent_per_account: 1,
request_interval_ms: 0,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
@@ -595,8 +786,7 @@ 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")),
current_page: 1,
page_size: PageSize::new(50).expect("page size"),
}
}
@@ -606,16 +796,12 @@ fn total_record(
provider_kind: Option<&str>,
source: &str,
now: DateTime<Utc>,
) -> UsageRecord {
UsageRecord {
) -> UsageListRecord {
UsageListRecord {
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(),
requested_model_id: Some("gpt-5.5".to_owned()),
provider_kind: provider_kind.map(str::to_owned),
provider_account_ref: None,
provider_account_name: None,
@@ -623,23 +809,7 @@ fn total_record(
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),
@@ -668,18 +838,17 @@ fn total_record(
first_token_ms: None,
provider_processing_ms: None,
latency_ms: Some(100),
admission_decision_ms: None,
account_selection_wait_ms: None,
capacity_used_slots: None,
capacity_total_slots: None,
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),
}
}
@@ -695,6 +864,9 @@ fn health_metric_point(bucket_start: DateTime<Utc>, metrics: RequestMetrics) ->
fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
DiagnosticObservation {
first_token_p95_ms: None,
non_completion_count: 0,
retry_count: 0,
key: name.to_owned(),
name: name.to_owned(),
request_count,
@@ -1,12 +1,12 @@
use std::sync::Arc;
use gateway_core::engine::credential::ProviderAccountId;
use gateway_core::account::ProviderAccountId;
use gateway_admin::{
AdminServices,
model::provider_credentials::{
AuthorizationMutationTarget, CompleteAuthorization, CredentialDeletion, CredentialMutation,
ImportCredentials, ProviderQuotaRequest, StartAuthorization,
AuthorizationMutationTarget, CompleteAuthorization, CredentialDeletion, ImportCredentials,
ProviderQuotaRequest, StartAuthorization,
},
ports::provider::ProviderAdminErrorKind,
};
@@ -15,55 +15,6 @@ 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();
@@ -89,26 +40,6 @@ async fn openai_delete_should_commit_then_release_provider_resources() {
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();
@@ -125,6 +56,8 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
.await
.expect("import credential");
await_quota_requests(&provider, 1).await;
assert_eq!(
recorded(&events),
[
@@ -162,6 +95,8 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
.await
.expect("import credentials");
await_quota_requests(&provider, 2).await;
assert_eq!(
result.credential_ids,
[
@@ -203,6 +138,8 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
.await
.expect("committed import remains successful");
await_quota_requests(&provider, 1).await;
assert_eq!(
result.credential_ids,
[ProviderAccountId::new("acct_prepared").expect("account ID")]
@@ -236,6 +173,8 @@ async fn openai_authorization_create_should_observe_initial_quota() {
.await
.expect("complete authorization");
await_quota_requests(&provider, 1).await;
assert_eq!(
recorded(&events),
[
@@ -284,6 +223,8 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
.await
.expect("quota observation must not fail authorization");
await_quota_requests(&provider, 1).await;
assert_eq!(result.account_id.as_str(), "acct_prepared");
assert_eq!(provider.quota_requests().len(), 1);
}
@@ -322,6 +263,8 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
.await
.expect("released claim must permit retry");
await_quota_requests(&provider, 1).await;
assert_eq!(
recorded(&events),
[
@@ -400,6 +343,8 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
.await
.expect("complete reauthorization");
await_quota_requests(&provider, 1).await;
assert_eq!(result.credential_revision, Some(revision(3)));
assert_eq!(
@@ -426,11 +371,14 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
.await
}
fn mutation(account_id: &str) -> CredentialMutation {
CredentialMutation {
context: context("request-openai"),
account_id: ProviderAccountId::new(account_id).expect("account ID"),
async fn await_quota_requests(provider: &FakeProviderAdmin, expected: usize) {
for _ in 0..100 {
if provider.quota_requests().len() == expected {
return;
}
tokio::task::yield_now().await;
}
assert_eq!(provider.quota_requests().len(), expected);
}
fn deletion(account_id: &str) -> CredentialDeletion {
@@ -67,6 +67,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
max_concurrent_per_account: 1,
request_interval_ms: 0,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
@@ -1,40 +1,15 @@
use std::sync::Arc;
use gateway_core::engine::credential::ProviderAccountId;
use gateway_core::account::ProviderAccountId;
use gateway_admin::{
AdminServices,
model::provider_credentials::{CredentialMutation, ImportCredentials, ProviderQuotaRequest},
model::provider_credentials::{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();
@@ -132,10 +107,3 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
.build()
.await
}
fn mutation(account_id: &str) -> CredentialMutation {
CredentialMutation {
context: context("request-xai"),
account_id: ProviderAccountId::new(account_id).expect("account ID"),
}
}
+2 -2
View File
@@ -24,7 +24,7 @@ secrecy = "0.10"
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"] }
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "sync", "time"] }
tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeout", "trace"] }
tracing = "0.1.44"
url = "2.5.8"
@@ -33,7 +33,7 @@ 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 = { version = "1.52.3", default-features = false, features = ["macros", "net", "rt-multi-thread", "test-util"] }
tokio-tungstenite = "0.29.0"
tower = { version = "0.5.3", features = ["util"] }
@@ -0,0 +1,416 @@
//! Account group HTTP wire and fixed routes.
use std::collections::BTreeMap;
use axum::{
Router,
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use chrono::{DateTime, Utc};
use gateway_admin::model::{
PageSize,
account_groups::{
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupColor, AccountGroupListQuery,
AccountGroupMutation, AccountGroupPage, AccountGroupRecord, AccountGroupUsage,
CreateAccountGroup, DeleteAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
},
};
use gateway_core::routing::AccountGroupId;
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta, WireValidationError, wire::map_admin_service_error,
};
const DEFAULT_PAGE_SIZE: u32 = 50;
const MAX_PAGE_SIZE: u32 = 200;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ListAccountGroupsQuery {
page: Option<u32>,
page_size: Option<u32>,
search: Option<String>,
enabled: Option<bool>,
}
impl ListAccountGroupsQuery {
fn into_command(self) -> Result<AccountGroupListQuery, WireValidationError> {
let page = self.page.unwrap_or(1);
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
if page == 0 || page_size == 0 || page_size > MAX_PAGE_SIZE {
return Err(WireValidationError::new("page"));
}
let search = self
.search
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty());
if search
.as_deref()
.is_some_and(|value| value.len() > 256 || value.chars().any(char::is_control))
{
return Err(WireValidationError::new("search"));
}
Ok(AccountGroupListQuery {
page,
page_size: PageSize::new(
u16::try_from(page_size).map_err(|_| WireValidationError::new("pageSize"))?,
)
.map_err(|_| WireValidationError::new("pageSize"))?,
search,
enabled: self.enabled,
})
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateAccountGroupRequest {
name: String,
description: Option<String>,
color: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct UpdateAccountGroupRequest {
id: String,
name: String,
description: Option<String>,
color: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AccountGroupIdRequest {
id: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountGroupView {
id: String,
name: String,
description: Option<String>,
color: String,
enabled: bool,
member_count: u64,
provider_counts: BTreeMap<String, u64>,
client_key_count: u64,
account_summary: AccountGroupAccountSummaryView,
capacity: AccountGroupCapacityView,
usage: AccountGroupUsageView,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountGroupAccountSummaryView {
available: u64,
limited: u64,
total: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountGroupCapacityView {
used_slots: Option<u64>,
total_slots: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountGroupUsageView {
today_usd: String,
retained_total_usd: String,
}
impl From<AccountGroupRecord> for AccountGroupView {
fn from(record: AccountGroupRecord) -> Self {
Self {
id: record.id.to_string(),
name: record.name,
description: record.description,
color: record.color.as_str().to_owned(),
enabled: record.enabled,
member_count: record.member_count,
provider_counts: record.provider_counts,
client_key_count: record.client_key_count,
account_summary: account_summary_view(record.account_summary),
capacity: capacity_view(record.capacity),
usage: usage_view(record.usage),
created_at: record.created_at,
updated_at: record.updated_at,
}
}
}
fn account_summary_view(summary: AccountGroupAccountSummary) -> AccountGroupAccountSummaryView {
AccountGroupAccountSummaryView {
available: summary.available,
limited: summary.limited,
total: summary.total,
}
}
fn capacity_view(capacity: AccountGroupCapacity) -> AccountGroupCapacityView {
AccountGroupCapacityView {
used_slots: capacity.used_slots,
total_slots: capacity.total_slots,
}
}
fn usage_view(usage: AccountGroupUsage) -> AccountGroupUsageView {
AccountGroupUsageView {
today_usd: usage.today_usd.to_string(),
retained_total_usd: usage.retained_total_usd.to_string(),
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountGroupPageData {
items: Vec<AccountGroupView>,
page: PageMeta,
config_revision: u64,
}
impl From<AccountGroupPage> for AccountGroupPageData {
fn from(page: AccountGroupPage) -> Self {
let total_pages = if page.total == 0 {
0
} else {
page.total.div_ceil(u64::from(page.page_size))
};
Self {
items: page.items.into_iter().map(Into::into).collect(),
page: PageMeta::new(
page.page,
u32::from(page.page_size),
page.total,
u32::try_from(total_pages).unwrap_or(u32::MAX),
),
config_revision: page.config_revision.get(),
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountGroupMutationData {
id: String,
record: Option<AccountGroupView>,
config_revision: u64,
}
impl From<AccountGroupMutation> for AccountGroupMutationData {
fn from(mutation: AccountGroupMutation) -> Self {
Self {
id: mutation.id.to_string(),
record: mutation.record.map(Into::into),
config_revision: mutation.config_revision.get(),
}
}
}
/// Construct all fixed account-group management routes.
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/account-groups", get(list::<S>))
.route("/api/admin/account-groups/create", post(create::<S>))
.route("/api/admin/account-groups/update", post(update::<S>))
.route("/api/admin/account-groups/enable", post(enable::<S>))
.route("/api/admin/account-groups/disable", post(disable::<S>))
.route("/api/admin/account-groups/delete", post(delete::<S>))
}
async fn list<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<ListAccountGroupsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.account_groups()
.list(query.into_command().map_err(map_wire_error)?)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountGroupPageData::from(result)),
))
}
async fn create<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<CreateAccountGroupRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
validate_group_fields(&request.name, request.description.as_deref())?;
mutation_response(
StatusCode::CREATED,
state
.admin_services()
.account_groups()
.create(
&auth.context().mutation_context(),
CreateAccountGroup {
name: request.name,
description: request.description,
color: group_color(&request.color)?,
},
)
.await,
)
}
async fn update<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<UpdateAccountGroupRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
validate_group_fields(&request.name, request.description.as_deref())?;
mutation_response(
StatusCode::OK,
state
.admin_services()
.account_groups()
.update(
&auth.context().mutation_context(),
UpdateAccountGroup {
id: group_id(request.id)?,
name: request.name,
description: request.description,
color: group_color(&request.color)?,
},
)
.await,
)
}
async fn enable<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
set_enabled(auth, state, request, true).await
}
async fn disable<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
set_enabled(auth, state, request, false).await
}
async fn set_enabled<S>(
auth: AdminAuth,
state: S,
request: AccountGroupIdRequest,
enabled: bool,
) -> Result<AdminResponse<AdminEnvelope<AccountGroupMutationData>>, AdminError>
where
S: AdminSessionState + Send + Sync,
{
mutation_response(
StatusCode::OK,
state
.admin_services()
.account_groups()
.set_enabled(
&auth.context().mutation_context(),
SetAccountGroupEnabled {
id: group_id(request.id)?,
enabled,
},
)
.await,
)
}
async fn delete<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
mutation_response(
StatusCode::OK,
state
.admin_services()
.account_groups()
.delete(
&auth.context().mutation_context(),
DeleteAccountGroup {
id: group_id(request.id)?,
},
)
.await,
)
}
fn mutation_response(
status: StatusCode,
result: Result<AccountGroupMutation, gateway_admin::model::AdminError>,
) -> Result<AdminResponse<AdminEnvelope<AccountGroupMutationData>>, AdminError> {
let result = result.map_err(map_service_error)?;
Ok(AdminResponse::new(
status,
AdminEnvelope::ok(AccountGroupMutationData::from(result)),
))
}
fn group_id(value: String) -> Result<AccountGroupId, AdminError> {
AccountGroupId::new(value).map_err(|_| AdminError::bad_request("账号组 ID 不合法"))
}
fn group_color(value: &str) -> Result<AccountGroupColor, AdminError> {
AccountGroupColor::parse(value).ok_or_else(|| AdminError::bad_request("账号组颜色不合法"))
}
fn validate_group_fields(name: &str, description: Option<&str>) -> Result<(), AdminError> {
if name.trim() != name
|| name.is_empty()
|| name.chars().count() > 100
|| name.chars().any(char::is_control)
|| description
.is_some_and(|value| value.len() > 4096 || value.chars().any(char::is_control))
{
return Err(AdminError::bad_request("账号组请求不合法"));
}
Ok(())
}
fn map_wire_error(_: WireValidationError) -> AdminError {
AdminError::bad_request("账号组查询参数不合法")
}
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,488 @@
//! Provider-owned 凭据请求、命令转换与敏感材料校验。
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum AccountProvider {
OpenAi,
Xai,
}
impl AccountProvider {
fn parse(value: &str) -> Result<Self, WireValidationError> {
match value.trim() {
"openai" => Ok(Self::OpenAi),
"xai" => Ok(Self::Xai),
_ => Err(WireValidationError::new("provider")),
}
}
}
/// Provider-owned 账号导入请求;公共 API 不解释 `data` 内部字段。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportRequest {
pub provider: String,
pub data: Value,
}
impl AccountImportRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
AccountProvider::parse(&self.provider)?;
if !self.data.is_object()
|| serde_json::to_vec(&self.data)
.map_or(true, |encoded| encoded.len() > MAX_IMPORT_DATA_BYTES)
{
return Err(WireValidationError::new("data"));
}
Ok(())
}
pub(super) fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, ImportCredentials), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
Ok((
provider,
ImportCredentials {
context,
document: provider_document(self.data, "data")?,
},
))
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StartAccountAuthorizationRequest {
pub provider: String,
pub name: String,
pub account_id: Option<String>,
}
impl StartAccountAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
AccountProvider::parse(&self.provider)?;
require_text(&self.name, MAX_NAME_BYTES, "name")?;
if let Some(account_id) = self.account_id.as_deref() {
require_account_id(account_id, "accountId")?;
}
Ok(())
}
pub(super) fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, StartAuthorization), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
let reauthorization = self
.account_id
.map(ProviderAccountId::new)
.transpose()
.map_err(|_| WireValidationError::new("accountId"))?;
Ok((
provider,
StartAuthorization {
context,
name: self.name,
reauthorization,
},
))
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CompleteAccountAuthorizationRequest {
pub provider: String,
pub flow_id: String,
pub callback_url: String,
}
impl CompleteAccountAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
let provider = AccountProvider::parse(&self.provider)?;
match provider {
AccountProvider::OpenAi => {
if !URL_SAFE_NO_PAD
.decode(&self.flow_id)
.is_ok_and(|decoded| decoded.len() == 32)
{
return Err(WireValidationError::new("flowId"));
}
}
AccountProvider::Xai => require_wire_id(&self.flow_id, "flowId")?,
}
require_text(&self.callback_url, MAX_CALLBACK_URL_BYTES, "callbackUrl")
}
pub(super) fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, CompleteAuthorization), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
Ok((
provider,
CompleteAuthorization {
context,
flow_id: self.flow_id,
callback_url: self.callback_url,
},
))
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateAccountRequest {
pub account_id: String,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
pub weight: u64,
pub group_ids: Vec<String>,
}
impl UpdateAccountRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
validate_wire_group_ids(&self.group_ids)?;
Ok(())
}
pub(super) fn into_command(self) -> Result<UpdateAccount, WireValidationError> {
self.validate()?;
Ok(UpdateAccount {
account_id: self.account_id,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
group_ids: validate_wire_group_ids(&self.group_ids)?,
})
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdatedAccountData {
pub account_id: String,
pub config_revision: u64,
}
impl From<AccountUpdateResult> for UpdatedAccountData {
fn from(result: AccountUpdateResult) -> Self {
Self {
account_id: result.account_id.to_string(),
config_revision: result.config_revision.get(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountDeletionRequest {
pub provider: String,
pub account_ids: Vec<String>,
}
impl AccountDeletionRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
AccountProvider::parse(&self.provider)?;
if self.account_ids.is_empty() || self.account_ids.len() > MAX_ACCOUNT_DELETE_BATCH {
return Err(WireValidationError::new("accountIds"));
}
let mut unique = BTreeSet::new();
for account_id in &self.account_ids {
require_account_id(account_id, "accountIds")?;
if !unique.insert(account_id.as_str()) {
return Err(WireValidationError::new("accountIds"));
}
}
Ok(())
}
pub(super) fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, CredentialDeletion), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
Ok((
provider,
CredentialDeletion {
context,
account_ids: self
.account_ids
.into_iter()
.map(ProviderAccountId::new)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| WireValidationError::new("accountIds"))?,
},
))
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RotateAccountRequest {
pub provider: String,
pub account_id: String,
pub access_token: String,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
}
impl RotateAccountRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if AccountProvider::parse(&self.provider)? != AccountProvider::OpenAi {
return Err(WireValidationError::new("provider"));
}
require_account_id(&self.account_id, "accountId")?;
validate_oauth_material(
&self.access_token,
self.refresh_token.as_deref(),
self.id_token.as_deref(),
)
}
pub(super) fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<RotateCredential, WireValidationError> {
self.validate()?;
let mut material = Map::new();
material.insert("access_token".to_owned(), Value::String(self.access_token));
material.insert(
"refresh_token".to_owned(),
self.refresh_token.map_or(Value::Null, Value::String),
);
material.insert(
"id_token".to_owned(),
self.id_token.map_or(Value::Null, Value::String),
);
Ok(RotateCredential {
mutation: CredentialMutation {
context,
account_id: ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
},
provider_material: ProviderDocument::new(OpaqueProviderData::new(material)),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountImportData {
pub imported_count: usize,
pub account_ids: Vec<String>,
}
impl AccountImportData {
pub fn from_result(result: CredentialImportResult) -> Self {
let account_ids = result
.credential_ids
.into_iter()
.map(|account_id| account_id.to_string())
.collect::<Vec<_>>();
Self {
imported_count: account_ids.len(),
account_ids,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountAuthorizationData {
pub flow_id: String,
pub authorization_url: String,
pub expires_at: DateTime<Utc>,
}
impl From<AuthorizationStarted> for AccountAuthorizationData {
fn from(started: AuthorizationStarted) -> Self {
Self {
flow_id: started.flow_id,
authorization_url: started.authorization_url,
expires_at: started.expires_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountMutationData {
pub account_id: String,
}
impl From<CredentialMutationResult> for AccountMutationData {
fn from(result: CredentialMutationResult) -> Self {
Self {
account_id: result.account_id.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountDeletionData {
pub deleted_count: usize,
pub account_ids: Vec<String>,
}
impl From<CredentialDeletionResult> for AccountDeletionData {
fn from(result: CredentialDeletionResult) -> Self {
let account_ids = result
.account_ids
.into_iter()
.map(|account_id| account_id.to_string())
.collect::<Vec<_>>();
Self {
deleted_count: account_ids.len(),
account_ids,
}
}
}
pub(super) fn require_account_id(
value: &str,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.trim().is_empty()
|| value.len() > 128
|| value.chars().any(char::is_control)
|| !value.starts_with("acct_")
{
return Err(WireValidationError::new(field));
}
Ok(())
}
pub(super) fn parse_concurrency_limit(
value: Option<u64>,
) -> Result<Option<AccountConcurrencyLimit>, WireValidationError> {
value
.map(|value| {
u32::try_from(value)
.ok()
.and_then(AccountConcurrencyLimit::new)
.ok_or_else(|| WireValidationError::new("concurrencyLimit"))
})
.transpose()
}
pub(super) fn deserialize_required_nullable<'de, D>(
deserializer: D,
) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
Option::<u64>::deserialize(deserializer)
}
pub(super) fn parse_account_weight(value: u64) -> Result<AccountWeight, WireValidationError> {
u16::try_from(value)
.ok()
.and_then(AccountWeight::new)
.ok_or_else(|| WireValidationError::new("weight"))
}
pub(super) fn validate_wire_group_ids(
values: &[String],
) -> Result<Vec<AccountGroupId>, WireValidationError> {
if values.len() > MAX_ACCOUNT_GROUP_BATCH
|| values.iter().collect::<BTreeSet<_>>().len() != values.len()
{
return Err(WireValidationError::new("groupIds"));
}
values
.iter()
.cloned()
.map(|value| AccountGroupId::new(value).map_err(|_| WireValidationError::new("groupIds")))
.collect()
}
fn provider_document(
value: Value,
field: &'static str,
) -> Result<ProviderDocument, WireValidationError> {
match value {
Value::Object(document) => Ok(ProviderDocument::new(OpaqueProviderData::new(document))),
_ => Err(WireValidationError::new(field)),
}
}
fn require_wire_id(value: &str, field: &'static str) -> Result<(), WireValidationError> {
require_text(value, MAX_ID_BYTES, field)?;
if value.starts_with("__")
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'))
{
return Err(WireValidationError::new(field));
}
Ok(())
}
fn require_text(
value: &str,
max_bytes: usize,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.trim().is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn validate_oauth_material(
access_token: &str,
refresh_token: Option<&str>,
id_token: Option<&str>,
) -> Result<(), WireValidationError> {
if access_token.len() > MAX_ACCESS_TOKEN_BYTES
|| !valid_visible_ascii(access_token)
|| !valid_compact_jwt_shape(access_token)
{
return Err(WireValidationError::new("accessToken"));
}
if refresh_token.is_some_and(|token| {
token.len() > MAX_REFRESH_TOKEN_BYTES
|| !valid_visible_ascii(token)
|| token == access_token
}) {
return Err(WireValidationError::new("refreshToken"));
}
if id_token.is_some_and(|token| {
token.len() > MAX_ID_TOKEN_BYTES
|| !valid_visible_ascii(token)
|| !valid_compact_jwt_shape(token)
}) {
return Err(WireValidationError::new("idToken"));
}
Ok(())
}
fn valid_compact_jwt_shape(value: &str) -> bool {
let mut segments = value.split('.');
matches!(
(segments.next(), segments.next(), segments.next(), segments.next()),
(Some(header), Some(payload), Some(signature), None)
if !header.is_empty() && !payload.is_empty() && !signature.is_empty()
)
}
fn valid_visible_ascii(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
}
pub(super) fn provider_document_value(document: ProviderDocument) -> Value {
Value::Object(document.into_provider_data().into_inner())
}
@@ -0,0 +1,573 @@
//! 账号管理路由与 HTTP handler 编排。
use super::*;
/// 构造统一账号管理路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/accounts", get(list_accounts::<S>))
.route("/api/admin/accounts/detail", get(account_detail::<S>))
.route("/api/admin/accounts/export", get(export_accounts::<S>))
.route("/api/admin/accounts/import", post(import_accounts::<S>))
.route("/api/admin/accounts/refresh", post(refresh_account::<S>))
.route("/api/admin/accounts/recover", post(recover_account::<S>))
.route("/api/admin/accounts/rotate", post(rotate_account::<S>))
.route("/api/admin/accounts/update", post(update_account::<S>))
.route("/api/admin/accounts/delete", post(delete_accounts::<S>))
.route(
"/api/admin/accounts/batch-update",
post(batch_update_accounts::<S>),
)
.route("/api/admin/accounts/quota", get(account_quota::<S>))
.route(
"/api/admin/accounts/profile-statistics",
get(account_profile_statistics::<S>),
)
.route(
"/api/admin/accounts/profile-avatar",
get(account_profile_avatar::<S>),
)
.route(
"/api/admin/accounts/reset-credits",
get(account_reset_credits::<S>).post(consume_account_reset_credit::<S>),
)
.route(
"/api/admin/accounts/quota/refresh",
post(refresh_account_quota::<S>),
)
.route("/api/admin/accounts/models", get(account_models::<S>))
.route(
"/api/admin/accounts/models/refresh",
post(refresh_account_models::<S>),
)
.route(
"/api/admin/accounts/connection-test",
get(test_account_connection::<S>),
)
.route(
"/api/admin/accounts/oauth/start",
post(start_account_authorization::<S>),
)
.route(
"/api/admin/accounts/oauth/complete",
post(complete_account_authorization::<S>),
)
}
async fn batch_update_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<BatchUpdateAccountsRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.batch_update(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(BatchUpdatedAccountsData::from(result)),
))
}
async fn list_accounts<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<ListQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = query.validate().map_err(map_wire_error)?;
let page = command.page;
let page_size = command.page_size.get();
let result = state
.admin_services()
.accounts()
.list(command)
.await
.map_err(map_service_error)?;
let data = account_page_data(result, page, page_size, Utc::now());
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_detail<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.quota(&account_id, false)
.await
.map_err(map_service_error)?;
let data = AccountQuotaData {
account: account_view(result, Utc::now()),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn export_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountExportQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let ids = query.into_ids().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.export(&auth.context().mutation_context(), ids)
.await
.map_err(map_service_error)?;
let data = AccountExportData::from_result(result);
let mut response = AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)).into_response();
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(response)
}
async fn import_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountImportRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match provider {
AccountProvider::OpenAi => {
state
.admin_services()
.openai()
.import_document(command)
.await
}
AccountProvider::Xai => state.admin_services().xai().import_document(command).await,
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(AccountImportData::from_result(result)),
))
}
async fn start_account_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<StartAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match provider {
AccountProvider::OpenAi => {
state
.admin_services()
.openai()
.start_authorization(command)
.await
}
AccountProvider::Xai => {
state
.admin_services()
.xai()
.start_authorization(command)
.await
}
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(AccountAuthorizationData::from(result)),
))
}
async fn complete_account_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<CompleteAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match provider {
AccountProvider::OpenAi => {
state
.admin_services()
.openai()
.complete_authorization(command)
.await
}
AccountProvider::Xai => {
state
.admin_services()
.xai()
.complete_authorization(command)
.await
}
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(AccountMutationData::from(result)),
))
}
async fn rotate_account<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<RotateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = state
.admin_services()
.openai()
.rotate(command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountMutationData::from(result)),
))
}
async fn update_account<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<UpdateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.update(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(UpdatedAccountData::from(result)),
))
}
async fn delete_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountDeletionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match provider {
AccountProvider::OpenAi => state.admin_services().openai().delete(command).await,
AccountProvider::Xai => state.admin_services().xai().delete(command).await,
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountDeletionData::from(result)),
))
}
async fn refresh_account<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountRefreshRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.refresh(&auth.context().mutation_context(), account_id)
.await
.map_err(map_service_error)?;
let data = account_refresh_data(result, Utc::now());
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn recover_account<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.recover(&auth.context().mutation_context(), account_id)
.await
.map_err(map_service_error)?;
let data = account_refresh_data(result, Utc::now());
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_quota<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.quota(&account_id, false)
.await
.map_err(map_service_error)?;
let data = AccountQuotaData {
account: account_view(result, Utc::now()),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_profile_statistics<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.profile_statistics(&account_id)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountProfileStatisticsData::from(result)),
))
}
async fn account_profile_avatar<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountProfileAvatarQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let avatar = state
.admin_services()
.accounts()
.profile_avatar(&account_id)
.await
.map_err(map_service_error)?;
Ok(profile_avatar_response(avatar))
}
/// 将 Provider 头像流投影为受保护的同源 HTTP 响应。
#[must_use]
pub fn profile_avatar_response(avatar: ProviderProfileAvatar) -> Response {
let ProviderProfileAvatar {
content_type,
content_length,
etag,
body,
} = avatar;
let mut response = Response::new(Body::from_stream(body));
*response.status_mut() = StatusCode::OK;
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
content_type
.and_then(|value| HeaderValue::from_str(&value).ok())
.unwrap_or_else(|| HeaderValue::from_static("application/octet-stream")),
);
if let Some(value) =
content_length.and_then(|length| HeaderValue::from_str(&length.to_string()).ok())
{
headers.insert(header::CONTENT_LENGTH, value);
}
if let Some(value) = etag.and_then(|value| HeaderValue::from_str(&value).ok()) {
headers.insert(header::ETAG, value);
}
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, max-age=3600"),
);
headers.insert(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
);
headers.insert(
HeaderName::from_static("cross-origin-resource-policy"),
HeaderValue::from_static("same-origin"),
);
headers.insert(
HeaderName::from_static("content-security-policy"),
HeaderValue::from_static("sandbox; default-src 'none'"),
);
response
}
async fn refresh_account_quota<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.quota(&account_id, true)
.await
.map_err(map_service_error)?;
let data = AccountQuotaData {
account: account_view(result, Utc::now()),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_reset_credits<S>(
auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.reset_credits(&auth.context().mutation_context(), account_id)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditsData::from(result)),
))
}
async fn consume_account_reset_credit<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountResetCreditConsumeRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.consume_reset_credit(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditResultData::from(result)),
))
}
async fn account_models<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.models(&account_id, false)
.await
.map_err(map_service_error)?;
let data = account_models_data(result);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn refresh_account_models<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.models(&account_id, true)
.await
.map_err(map_service_error)?;
let data = account_models_data(result);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn test_account_connection<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountTestQuery>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (account_id, upstream_model) = query.into_command().map_err(map_wire_error)?;
let stream = state
.admin_services()
.accounts()
.test_connection(account_id, upstream_model)
.await
.map_err(map_service_error)?
.map(|event| {
let event = AccountConnectionTestEvent::from(event);
let data = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".to_owned());
Ok(Event::default().data(data))
});
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
@@ -0,0 +1,80 @@
//! 管理端账号目录的 HTTP 合同、凭据动作、路由处理与安全响应投影。
use std::{collections::BTreeSet, convert::Infallible, fmt};
use axum::{
Router,
body::Body,
extract::State,
http::{HeaderName, HeaderValue, StatusCode, header},
response::{
IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post},
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, FixedOffset, Utc};
use futures::{Stream, StreamExt as _};
use gateway_admin::model::{
AdminError as AdminServiceError, PageSize,
accounts::{
AccountConcurrencyLimit, AccountConnectionTestEvent as DomainConnectionTestEvent,
AccountCost, AccountGroupFilter, AccountListQuery, AccountModelUsage, AccountSort,
AccountSortField, AccountStatus as DomainAccountStatus, AccountUpdateResult, AccountUsage,
AccountWeight, AccountsUpdateResult, BatchUpdateAccounts, SortDirection, UpdateAccount,
},
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileDailyUsage,
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
ProviderResetCredits, RotateCredential, StartAuthorization,
},
};
use gateway_core::{
account::{OpaqueProviderData, ProviderAccountId},
routing::{AccountGroupId, ProviderKind, UpstreamModelId},
};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use uuid::{Uuid, Version};
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta, WireValidationError,
};
const DEFAULT_PAGE_SIZE: u32 = 50;
const MAX_PAGE_SIZE: u32 = 200;
const MAX_SEARCH_BYTES: usize = 256;
const MAX_ID_BYTES: usize = 256;
const MAX_NAME_BYTES: usize = 512;
const MAX_IMPORT_DATA_BYTES: usize = 64 * 1024 * 1024;
const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1024;
const MAX_REFRESH_TOKEN_BYTES: usize = 64 * 1024;
const MAX_ID_TOKEN_BYTES: usize = 16 * 1024;
const MAX_CALLBACK_URL_BYTES: usize = 64 * 1024;
const MAX_ACCOUNT_DELETE_BATCH: usize = 200;
const MAX_ACCOUNT_GROUP_BATCH: usize = 1000;
const MAX_AVATAR_VERSION_BYTES: usize = 32;
mod credentials;
mod handlers;
mod presenter;
mod wire;
pub use credentials::*;
pub use handlers::{profile_avatar_response, router};
pub use wire::*;
use credentials::{
AccountProvider, deserialize_required_nullable, parse_account_weight, parse_concurrency_limit,
provider_document_value, require_account_id, validate_wire_group_ids,
};
use presenter::*;
use wire::BatchUpdatedAccountsData;
@@ -0,0 +1,413 @@
//! Admin 领域结果到安全 HTTP wire 的展示投影。
use super::*;
pub(super) fn account_page_data(
result: AccountDirectoryPage,
page: u32,
page_size: u16,
now: DateTime<Utc>,
) -> AccountPageData {
let total_pages = if result.total == 0 {
0
} else {
u32::try_from(result.total.div_ceil(u64::from(page_size))).unwrap_or(u32::MAX)
};
AccountPageData {
items: result
.items
.into_iter()
.map(|item| account_view(item, now))
.collect(),
page: PageMeta::new(page, u32::from(page_size), result.total, total_pages),
summary: AccountSummaryView {
total: result.summary.total,
normal: result.summary.normal,
quota_exhausted: result.summary.quota_exhausted,
rate_limited: result.summary.rate_limited,
disabled: result.summary.disabled,
error: result.summary.error,
},
}
}
pub(super) fn account_refresh_data(
result: AccountRefreshResult,
now: DateTime<Utc>,
) -> AccountRefreshData {
AccountRefreshData {
account: account_view(result.account, now),
}
}
pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData {
AccountModelsData {
models: result
.models
.into_iter()
.map(|model| {
let id = model.id.to_string();
AccountModelView {
label: id.clone(),
id,
}
})
.collect(),
}
}
pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
let AccountDirectoryItem {
account,
projection,
usage,
quota,
} = item;
let status = projection.status.as_str().to_owned();
let rate_limited_until = projection.rate_limited_until.map(DateTime::<Utc>::from);
let expires_at = account.access_token_expires_at.as_ref().map(china_rfc3339);
let added_at = china_rfc3339(&account.created_at);
let updated_at = china_rfc3339(&account.updated_at);
let (quota, refresh_token_expires_at) = account_quota_view(quota, rate_limited_until, now);
AccountView {
id: account.id.clone(),
name: account.name,
provider: account.provider_kind.to_string(),
groups: account
.groups
.into_iter()
.map(|group| AccountGroupRefView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.collect(),
resource_ref: account.id,
email: account.email,
account_id: account.upstream_account_id,
user_id: account.upstream_user_id,
label: None,
plan_type: account.plan_type,
authentication_kind: account.authentication_kind,
has_refresh_token: account.has_refresh_token,
status,
error_reason: projection
.error_reason
.map(|reason| reason.as_str().to_owned()),
error_message: projection.error_message,
enabled: account.enabled,
concurrency_limit: account.concurrency_limit.map(|limit| limit.get()),
weight: account.weight.get(),
access_token_expires_at: expires_at,
access_token_expires_at_display: account
.access_token_expires_at
.as_ref()
.map(china_datetime),
refresh_token_expires_at,
next_refresh_at: account.next_refresh_at.map(|value| china_rfc3339(&value)),
added_at,
added_at_display: china_datetime(&account.created_at),
updated_at,
updated_at_display: china_datetime(&account.updated_at),
quota,
usage: account_usage_view(usage, now),
}
}
pub(super) fn account_quota_view(
mut quota: ProviderQuota,
rate_limited_until: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> (AccountQuotaView, Option<String>) {
quota.apply_limit_reached_display();
let refresh_token_expires_at = quota
.refresh_token_expires_at
.map(|value| china_rfc3339(&value));
let refreshed_at_display = quota
.observed_at
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now));
let windows = quota.windows.into_iter().map(quota_window_view).collect();
let rate_limited_until = rate_limited_until.map(|until| china_datetime(&until));
(
AccountQuotaView {
refreshed_at_display,
limit_reached: quota.limit_reached,
rate_limited_until,
windows,
},
refresh_token_expires_at,
)
}
pub(super) fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
let ProviderQuotaWindow {
key,
group,
label,
limit_id,
limit_name,
role,
local_usage_attribution: _,
window_seconds,
used_percent,
reset_at,
limit_reached,
local_usage,
provider_data: _,
} = window;
let label_display = limit_name
.as_ref()
.map_or_else(|| label.clone(), |name| format!("{name} · {label}"));
AccountQuotaWindowView {
label_display,
window_label_display: label,
key,
group,
limit_id,
limit_name,
role: role.map(|value| value.as_str().to_owned()),
window_seconds,
used_percent,
used_percent_display: used_percent
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
limit_reached,
local_usage: local_usage.as_ref().map(quota_local_usage),
reset_at_display: reset_at.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
}
}
pub(super) fn quota_local_usage(usage: &AccountUsage) -> Value {
let total_tokens = usage.total_tokens.unwrap_or_default();
serde_json::json!({
"requestCount": usage.request_count,
"requestCountDisplay": format_number(usage.request_count),
"inputTokens": usage.input_tokens.unwrap_or_default(),
"inputTokensDisplay": display_optional_tokens(usage.input_tokens),
"outputTokens": usage.output_tokens.unwrap_or_default(),
"outputTokensDisplay": display_optional_tokens(usage.output_tokens),
"cachedTokens": usage.cached_tokens.unwrap_or_default(),
"cachedTokensDisplay": display_optional_tokens(usage.cached_tokens),
"imageInputTokens": usage.image_input_tokens.unwrap_or_default(),
"imageInputTokensDisplay": display_optional_tokens(usage.image_input_tokens),
"imageOutputTokens": usage.image_output_tokens.unwrap_or_default(),
"imageOutputTokensDisplay": display_optional_tokens(usage.image_output_tokens),
"imageRequestCount": usage.image_request_count,
"imageRequestFailedCount": usage.image_request_failed_count,
"totalTokens": total_tokens,
"totalTokensDisplay": format_compact_number(total_tokens),
"requestBuckets": usage.request_buckets.iter().map(|bucket| serde_json::json!({
"bucketStart": bucket.bucket_start,
"requestCount": bucket.request_count,
})).collect::<Vec<_>>(),
})
}
pub(super) fn account_usage_view(
usage: Option<AccountUsage>,
now: DateTime<Utc>,
) -> AccountUsageView {
let Some(usage) = usage else {
return empty_account_usage();
};
let known_count = usage
.cost_coverage
.provider_reported_count
.saturating_add(usage.cost_coverage.calculated_count);
let cost_estimate_status = if known_count == 0 {
"unknown"
} else if usage.cost_coverage.unavailable_count > 0 {
"partial"
} else {
"known"
};
AccountUsageView {
request_count: Some(usage.request_count),
request_count_display: format_number(usage.request_count),
input_tokens: usage.input_tokens,
input_tokens_display: display_optional_tokens(usage.input_tokens),
output_tokens: usage.output_tokens,
output_tokens_display: display_optional_tokens(usage.output_tokens),
cached_tokens: usage.cached_tokens,
cached_tokens_display: display_optional_tokens(usage.cached_tokens),
image_input_tokens: usage.image_input_tokens,
image_input_tokens_display: display_optional_tokens(usage.image_input_tokens),
image_output_tokens: usage.image_output_tokens,
image_output_tokens_display: display_optional_tokens(usage.image_output_tokens),
image_request_count: Some(usage.image_request_count),
image_request_count_display: format_number(usage.image_request_count),
image_request_failed_count: Some(usage.image_request_failed_count),
image_request_failed_count_display: format_number(usage.image_request_failed_count),
total_tokens: usage.total_tokens,
total_tokens_display: display_optional_tokens(usage.total_tokens),
created_tokens: usage.cache_write_tokens,
created_tokens_display: display_optional_tokens(usage.cache_write_tokens),
read_tokens: usage.cached_tokens,
read_tokens_display: display_optional_tokens(usage.cached_tokens),
last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)),
last_used_at_display: usage
.last_used_at
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now)),
cost_estimate_status: cost_estimate_status.to_owned(),
known_cost_count: Some(known_count),
partial_cost_count: Some(u64::from(cost_estimate_status == "partial")),
unknown_cost_count: Some(usage.cost_coverage.unavailable_count),
costs: usage.costs.iter().map(account_currency_cost_view).collect(),
models: usage
.models
.into_iter()
.map(|model| account_model_usage_view(model, now))
.collect(),
}
}
pub(super) fn account_model_usage_view(
usage: AccountModelUsage,
now: DateTime<Utc>,
) -> ModelUsageView {
let known_count = usage
.cost_coverage
.provider_reported_count
.saturating_add(usage.cost_coverage.calculated_count);
let cost_estimate_status = if known_count == 0 {
"unknown"
} else if usage.cost_coverage.unavailable_count > 0 {
"partial"
} else {
"known"
};
let usd = usage
.costs
.iter()
.find(|cost| cost.currency.eq_ignore_ascii_case("USD"));
ModelUsageView {
model: usage.model,
request_count: usage.request_count,
request_count_display: format_number(usage.request_count),
success_rate: (usage.request_count > 0)
.then(|| usage.success_count as f64 * 100.0 / usage.request_count as f64),
success_rate_display: if usage.request_count == 0 {
"—".to_owned()
} else {
format!(
"{:.1}%",
usage.success_count as f64 * 100.0 / usage.request_count as f64
)
},
input_tokens: usage.input_tokens,
input_tokens_display: display_optional_tokens(usage.input_tokens),
output_tokens: usage.output_tokens,
output_tokens_display: display_optional_tokens(usage.output_tokens),
cached_tokens: usage.cached_tokens,
cached_tokens_display: display_optional_tokens(usage.cached_tokens),
image_input_tokens: usage.image_input_tokens,
image_input_tokens_display: display_optional_tokens(usage.image_input_tokens),
image_output_tokens: usage.image_output_tokens,
image_output_tokens_display: display_optional_tokens(usage.image_output_tokens),
image_request_count: usage.image_request_count,
image_request_count_display: format_number(usage.image_request_count),
image_request_failed_count: usage.image_request_failed_count,
image_request_failed_count_display: format_number(usage.image_request_failed_count),
total_tokens: usage.total_tokens,
total_tokens_display: display_optional_tokens(usage.total_tokens),
billing_amount_usd: usd.map(|cost| cost.amount.as_str().to_owned()),
billing_amount_usd_display: usd.map_or_else(
|| "—".to_owned(),
|cost| format_decimal_currency(cost.amount.as_str(), "USD"),
),
cost_estimate_status: cost_estimate_status.to_owned(),
known_cost_count: known_count,
partial_cost_count: u64::from(cost_estimate_status == "partial"),
unknown_cost_count: usage.cost_coverage.unavailable_count,
costs: usage.costs.iter().map(account_currency_cost_view).collect(),
last_used_at: china_rfc3339(&usage.last_used_at),
last_used_at_display: relative_time(usage.last_used_at, now),
}
}
pub(super) fn account_currency_cost_view(cost: &AccountCost) -> CurrencyCostView {
CurrencyCostView {
currency: cost.currency.clone(),
estimated_amount: cost.amount.as_str().to_owned(),
estimated_amount_display: format_decimal_currency(cost.amount.as_str(), &cost.currency),
}
}
pub(super) fn display_optional_tokens(value: Option<u64>) -> String {
value.map_or_else(|| "—".to_owned(), format_compact_number)
}
pub(super) fn empty_account_usage() -> AccountUsageView {
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: "unavailable".to_owned(),
known_cost_count: None,
partial_cost_count: None,
unknown_cost_count: None,
costs: Vec::new(),
models: Vec::new(),
}
}
pub(super) fn relative_time(value: DateTime<Utc>, now: DateTime<Utc>) -> String {
let elapsed = now.signed_duration_since(value);
if elapsed.num_seconds() < 0 {
return china_datetime(&value);
}
if elapsed.num_seconds() < 60 {
return "刚刚".to_owned();
}
if elapsed.num_minutes() < 60 {
return format!("{} 分钟前", elapsed.num_minutes());
}
if elapsed.num_hours() < 24 {
return format!("{} 小时前", elapsed.num_hours());
}
format!("{} 天前", elapsed.num_days())
}
pub(super) fn china_offset() -> FixedOffset {
FixedOffset::east_opt(8 * 60 * 60).expect("UTC+8 is a valid fixed offset")
}
pub(super) fn china_rfc3339(value: &DateTime<Utc>) -> String {
value.with_timezone(&china_offset()).to_rfc3339()
}
pub(super) fn china_datetime(value: &DateTime<Utc>) -> String {
value
.with_timezone(&china_offset())
.format("%Y-%m-%d %H:%M:%S")
.to_string()
}
pub(super) fn map_wire_error(error: WireValidationError) -> AdminError {
AdminError::bad_request(format!("{} 字段不合法", error.field()))
}
pub(super) fn map_service_error(error: AdminServiceError) -> AdminError {
super::super::wire::map_admin_service_error(error)
}
@@ -0,0 +1,838 @@
//! 账号管理请求、响应与查询 wire contract。
use super::*;
/// 账号列表查询参数。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ListQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
pub provider: Option<String>,
pub group_id: Option<String>,
pub search: Option<String>,
pub status: Option<String>,
pub sort_by: Option<String>,
pub sort_direction: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BatchUpdateAccountsRequest {
pub account_ids: Vec<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
pub weight: u64,
pub group_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct BatchUpdatedAccountsData {
account_ids: Vec<String>,
config_revision: u64,
}
impl From<AccountsUpdateResult> for BatchUpdatedAccountsData {
fn from(result: AccountsUpdateResult) -> Self {
Self {
account_ids: result
.account_ids
.into_iter()
.map(|id| id.to_string())
.collect(),
config_revision: result.config_revision.get(),
}
}
}
impl BatchUpdateAccountsRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if self.account_ids.is_empty()
|| self.account_ids.len() > MAX_ACCOUNT_GROUP_BATCH
|| self
.account_ids
.iter()
.any(|id| require_account_id(id, "accountIds").is_err())
|| self.account_ids.iter().collect::<BTreeSet<_>>().len() != self.account_ids.len()
{
return Err(WireValidationError::new("accountIds"));
}
validate_wire_group_ids(&self.group_ids)?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
Ok(())
}
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
self.validate()?;
Ok(BatchUpdateAccounts {
account_ids: self.account_ids,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
group_ids: validate_wire_group_ids(&self.group_ids)?,
})
}
}
impl ListQuery {
/// 解析并校验全部 wire 字段,生成 Admin 查询命令。
pub fn validate(self) -> Result<AccountListQuery, WireValidationError> {
let page = self.page.unwrap_or(1);
if page == 0 {
return Err(WireValidationError::new("page"));
}
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
if page_size == 0 || page_size > MAX_PAGE_SIZE {
return Err(WireValidationError::new("pageSize"));
}
let provider_kind = parse_provider(self.provider.as_deref().unwrap_or("all"))?;
let group_filter = match self.group_id.as_deref().map(str::trim) {
None | Some("") => None,
Some("ungrouped") => Some(AccountGroupFilter::Ungrouped),
Some(value) => Some(AccountGroupFilter::Group(
AccountGroupId::new(value.to_owned())
.map_err(|_| WireValidationError::new("groupId"))?,
)),
};
let search = self
.search
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty());
if search.as_deref().is_some_and(|value| {
value.len() > MAX_SEARCH_BYTES || value.chars().any(char::is_control)
}) {
return Err(WireValidationError::new("search"));
}
let status = match self.status.as_deref().map(str::trim) {
None | Some("") => None,
Some(value) => Some(
DomainAccountStatus::parse(&value.to_ascii_lowercase())
.ok_or_else(|| WireValidationError::new("status"))?,
),
};
let sort = match (self.sort_by.as_deref(), self.sort_direction.as_deref()) {
(None, None) => None,
(Some(field), Some(direction)) => Some(AccountSort {
field: parse_sort_field(field).ok_or_else(|| WireValidationError::new("sortBy"))?,
direction: parse_sort_direction(direction)
.ok_or_else(|| WireValidationError::new("sortDirection"))?,
}),
_ => return Err(WireValidationError::new("sort")),
};
Ok(AccountListQuery {
page,
page_size: PageSize::new(
u16::try_from(page_size).map_err(|_| WireValidationError::new("pageSize"))?,
)
.map_err(|_| WireValidationError::new("pageSize"))?,
provider_kind,
group_filter,
search,
status,
sort,
})
}
}
fn parse_provider(value: &str) -> Result<Option<ProviderKind>, WireValidationError> {
let value = value.trim();
if value.is_empty() || value.eq_ignore_ascii_case("all") {
return Ok(None);
}
ProviderKind::new(value.to_owned())
.map(Some)
.map_err(|_| WireValidationError::new("provider"))
}
fn parse_sort_field(value: &str) -> Option<AccountSortField> {
match value.trim() {
"email" => Some(AccountSortField::Email),
"status" => Some(AccountSortField::Status),
"planType" => Some(AccountSortField::PlanType),
"usage" => Some(AccountSortField::Usage),
"lastUsedAt" => Some(AccountSortField::LastUsedAt),
"expiresAt" => Some(AccountSortField::ExpiresAt),
_ => None,
}
}
fn parse_sort_direction(value: &str) -> Option<SortDirection> {
match value.trim().to_ascii_lowercase().as_str() {
"asc" => Some(SortDirection::Asc),
"desc" => Some(SortDirection::Desc),
_ => None,
}
}
/// 账号列表响应数据。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountPageData {
pub items: Vec<AccountView>,
pub page: PageMeta,
pub summary: AccountSummaryView,
}
/// 账号概览计数。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountSummaryView {
pub total: u64,
pub normal: u64,
pub quota_exhausted: u64,
pub rate_limited: u64,
pub disabled: u64,
pub error: u64,
}
/// 一条安全账号视图。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountView {
pub id: String,
pub name: String,
pub provider: String,
pub groups: Vec<AccountGroupRefView>,
pub resource_ref: String,
pub email: Option<String>,
pub account_id: Option<String>,
pub user_id: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
pub authentication_kind: String,
pub has_refresh_token: bool,
pub status: String,
/// `status == "error"` 时的具体原因;其余状态为 `null`。
pub error_reason: Option<String>,
/// 最近一次失败的上游错误描述;仅错误状态存在。
pub error_message: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<u32>,
pub weight: u16,
pub access_token_expires_at: Option<String>,
pub access_token_expires_at_display: Option<String>,
pub refresh_token_expires_at: Option<String>,
pub next_refresh_at: Option<String>,
pub added_at: String,
pub added_at_display: String,
pub updated_at: String,
pub updated_at_display: String,
pub quota: AccountQuotaView,
pub usage: AccountUsageView,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountGroupRefView {
pub id: String,
pub name: String,
pub color: String,
pub enabled: bool,
}
/// Provider quota 安全视图。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountQuotaView {
pub refreshed_at_display: String,
pub limit_reached: bool,
/// 429 临时限流(Redis 冷却)到期时间展示;非限流中为 `null`。
pub rate_limited_until: Option<String>,
pub windows: Vec<AccountQuotaWindowView>,
}
/// 一个 quota 时间窗口。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountQuotaWindowView {
pub key: String,
pub group: String,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<String>,
pub window_seconds: Option<u64>,
pub label_display: String,
pub window_label_display: String,
pub used_percent: Option<f64>,
pub used_percent_display: String,
pub limit_reached: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub local_usage: Option<serde_json::Value>,
pub reset_at_display: String,
}
/// 账号观测用量。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageView {
pub request_count: Option<u64>,
pub request_count_display: String,
pub input_tokens: Option<u64>,
pub input_tokens_display: String,
pub output_tokens: Option<u64>,
pub output_tokens_display: String,
pub cached_tokens: Option<u64>,
pub cached_tokens_display: String,
pub image_input_tokens: Option<u64>,
pub image_input_tokens_display: String,
pub image_output_tokens: Option<u64>,
pub image_output_tokens_display: String,
pub image_request_count: Option<u64>,
pub image_request_count_display: String,
pub image_request_failed_count: Option<u64>,
pub image_request_failed_count_display: String,
pub total_tokens: Option<u64>,
pub total_tokens_display: String,
pub created_tokens: Option<u64>,
pub created_tokens_display: String,
pub read_tokens: Option<u64>,
pub read_tokens_display: String,
pub last_used_at: Option<String>,
pub last_used_at_display: String,
pub cost_estimate_status: String,
pub known_cost_count: Option<u64>,
pub partial_cost_count: Option<u64>,
pub unknown_cost_count: Option<u64>,
pub costs: Vec<CurrencyCostView>,
pub models: Vec<ModelUsageView>,
}
/// 凭据在单个上游模型上的观测用量。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelUsageView {
pub model: String,
pub request_count: u64,
pub request_count_display: String,
pub success_rate: Option<f64>,
pub success_rate_display: String,
pub input_tokens: Option<u64>,
pub input_tokens_display: String,
pub output_tokens: Option<u64>,
pub output_tokens_display: String,
pub cached_tokens: Option<u64>,
pub cached_tokens_display: String,
pub image_input_tokens: Option<u64>,
pub image_input_tokens_display: String,
pub image_output_tokens: Option<u64>,
pub image_output_tokens_display: String,
pub image_request_count: u64,
pub image_request_count_display: String,
pub image_request_failed_count: u64,
pub image_request_failed_count_display: String,
pub total_tokens: Option<u64>,
pub total_tokens_display: String,
pub billing_amount_usd: Option<String>,
pub billing_amount_usd_display: String,
pub cost_estimate_status: String,
pub known_cost_count: u64,
pub partial_cost_count: u64,
pub unknown_cost_count: u64,
pub costs: Vec<CurrencyCostView>,
pub last_used_at: String,
pub last_used_at_display: String,
}
/// 单一货币的可查询成本。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrencyCostView {
pub currency: String,
pub estimated_amount: String,
pub estimated_amount_display: String,
}
/// 账号详情类 GET 的固定 ID query。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountIdQuery {
pub account_id: String,
}
impl AccountIdQuery {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")
}
pub(super) fn into_id(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
}
}
/// 头像 GET 的固定 query;`version` 只参与浏览器缓存键,不进入上游请求。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountProfileAvatarQuery {
pub account_id: String,
pub version: Option<String>,
}
impl AccountProfileAvatarQuery {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
if self.version.as_deref().is_some_and(|version| {
version.is_empty()
|| version.len() > MAX_AVATAR_VERSION_BYTES
|| !version.bytes().all(|byte| byte.is_ascii_alphanumeric())
}) {
return Err(WireValidationError::new("version"));
}
Ok(())
}
pub(super) fn into_id(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
}
}
/// 敏感导出的固定 query;IDs 使用逗号分隔,禁止隐式导出全部账号。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountExportQuery {
pub account_ids: String,
pub confirm: String,
}
impl AccountExportQuery {
pub fn into_ids(self) -> Result<Vec<ProviderAccountId>, WireValidationError> {
if self.confirm != "export_sensitive_accounts" {
return Err(WireValidationError::new("confirm"));
}
let ids = self
.account_ids
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
if ids.is_empty()
|| ids.len() > 200
|| ids
.iter()
.any(|id| require_account_id(id, "accountIds").is_err())
{
return Err(WireValidationError::new("accountIds"));
}
let unique = ids.iter().collect::<BTreeSet<_>>();
if unique.len() != ids.len() {
return Err(WireValidationError::new("accountIds"));
}
ids.into_iter()
.map(|id| {
ProviderAccountId::new(id).map_err(|_| WireValidationError::new("accountIds"))
})
.collect()
}
}
/// 账号运行期动作。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountActionRequest {
pub account_id: String,
}
impl AccountActionRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")
}
pub(super) fn into_id(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
}
}
/// 主动额度重置卡消费请求。幂等键由 UI 生成并在不确定重试时复用,与官方一致。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountResetCreditConsumeRequest {
pub account_id: String,
#[serde(default)]
pub credit_id: Option<String>,
pub redeem_request_id: String,
}
impl AccountResetCreditConsumeRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
if self.credit_id.as_deref().is_some_and(|value| {
let value = value.trim();
value.is_empty() || value.len() > 512 || value.chars().any(char::is_control)
}) {
return Err(WireValidationError::new("creditId"));
}
let value = self.redeem_request_id.trim();
let redeem_request_id =
Uuid::parse_str(value).map_err(|_| WireValidationError::new("redeemRequestId"))?;
if redeem_request_id.get_version() != Some(Version::Random)
|| redeem_request_id.hyphenated().to_string() != value
{
return Err(WireValidationError::new("redeemRequestId"));
}
Ok(())
}
pub(super) fn into_command(self) -> Result<ConsumeProviderResetCredit, WireValidationError> {
self.validate()?;
Ok(ConsumeProviderResetCredit {
account_id: ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
credit_id: self.credit_id.map(|value| value.trim().to_owned()),
redeem_request_id: Uuid::parse_str(&self.redeem_request_id)
.map_err(|_| WireValidationError::new("redeemRequestId"))?,
})
}
}
/// 手工 OAuth 刷新会变更持久 credential。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountRefreshRequest {
pub account_id: String,
}
impl AccountRefreshRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")
}
pub(super) fn into_command(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
}
}
/// 连接测试 query;测试仍经唯一 Core/Provider 模型请求路径执行。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountTestQuery {
pub account_id: String,
pub model_id: String,
}
impl AccountTestQuery {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
if self.model_id.trim().is_empty() || self.model_id.chars().any(char::is_control) {
return Err(WireValidationError::new("modelId"));
}
Ok(())
}
pub(super) fn into_command(
self,
) -> Result<(ProviderAccountId, UpstreamModelId), WireValidationError> {
self.validate()?;
Ok((
ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
UpstreamModelId::new(self.model_id).map_err(|_| WireValidationError::new("modelId"))?,
))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AccountModelView {
pub id: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct AccountModelsData {
pub models: Vec<AccountModelView>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountRefreshData {
pub account: AccountView,
}
#[derive(Debug, Clone, Serialize)]
pub struct AccountQuotaData {
pub account: AccountView,
}
/// Provider 官方个人资料统计响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileStatisticsData {
pub display_name: Option<String>,
pub username: Option<String>,
pub image_url: Option<String>,
pub has_stats_error: bool,
pub summary: AccountProfileStatisticsSummaryView,
pub daily_usage: Option<Vec<AccountProfileDailyUsageView>>,
pub activity_insights: AccountProfileActivityInsightsView,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileStatisticsSummaryView {
pub total_text_tokens: Option<u64>,
pub peak_tokens: Option<u64>,
pub longest_task_duration_ms: Option<u64>,
pub current_streak_days: Option<u64>,
pub longest_streak_days: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileDailyUsageView {
pub date: String,
pub tokens: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileActivityInsightsView {
pub fast_mode_percent: Option<f64>,
pub reasoning_effort: Option<String>,
pub reasoning_effort_percent: Option<f64>,
pub skills_explored: Option<u64>,
pub total_skills_used: Option<u64>,
pub total_threads: Option<u64>,
pub invocations: Option<Vec<AccountProfileInvocationView>>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileInvocationView {
#[serde(rename = "type")]
pub invocation_type: String,
pub plugin_id: Option<String>,
pub plugin_name: Option<String>,
pub skill_id: Option<String>,
pub skill_name: Option<String>,
pub usage_count: Option<u64>,
}
impl From<ProviderProfileStatistics> for AccountProfileStatisticsData {
fn from(statistics: ProviderProfileStatistics) -> Self {
Self {
display_name: statistics.display_name,
username: statistics.username,
image_url: statistics.image_url,
has_stats_error: statistics.has_stats_error,
summary: profile_statistics_summary_view(statistics.summary),
daily_usage: statistics
.daily_usage
.map(|daily| daily.into_iter().map(profile_daily_usage_view).collect()),
activity_insights: profile_activity_insights_view(statistics.activity_insights),
}
}
}
const fn profile_statistics_summary_view(
summary: ProviderProfileStatisticsSummary,
) -> AccountProfileStatisticsSummaryView {
AccountProfileStatisticsSummaryView {
total_text_tokens: summary.total_text_tokens,
peak_tokens: summary.peak_tokens,
longest_task_duration_ms: summary.longest_task_duration_ms,
current_streak_days: summary.current_streak_days,
longest_streak_days: summary.longest_streak_days,
}
}
fn profile_daily_usage_view(daily: ProviderProfileDailyUsage) -> AccountProfileDailyUsageView {
AccountProfileDailyUsageView {
date: daily.date.to_string(),
tokens: daily.tokens,
}
}
fn profile_activity_insights_view(
insights: ProviderProfileActivityInsights,
) -> AccountProfileActivityInsightsView {
AccountProfileActivityInsightsView {
fast_mode_percent: insights.fast_mode_percent,
reasoning_effort: insights.reasoning_effort,
reasoning_effort_percent: insights.reasoning_effort_percent,
skills_explored: insights.skills_explored,
total_skills_used: insights.total_skills_used,
total_threads: insights.total_threads,
invocations: insights.invocations.map(|invocations| {
invocations
.into_iter()
.map(profile_invocation_view)
.collect()
}),
}
}
fn profile_invocation_view(invocation: ProviderProfileInvocation) -> AccountProfileInvocationView {
AccountProfileInvocationView {
invocation_type: invocation.invocation_type,
plugin_id: invocation.plugin_id,
plugin_name: invocation.plugin_name,
skill_id: invocation.skill_id,
skill_name: invocation.skill_name,
usage_count: invocation.usage_count,
}
}
/// 主动额度重置卡列表响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResetCreditsData {
pub available_count: u64,
pub credits: Vec<AccountResetCreditView>,
}
/// 一张安全主动额度重置卡视图。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResetCreditView {
pub id: String,
pub status: Option<String>,
pub title: Option<String>,
pub expires_at: Option<String>,
pub reset_type: Option<String>,
}
/// 主动额度重置卡消费响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResetCreditResultData {
pub code: String,
pub credit: Option<AccountResetCreditView>,
}
impl From<ProviderResetCredit> for AccountResetCreditView {
fn from(credit: ProviderResetCredit) -> Self {
Self {
id: credit.id,
status: credit.status,
title: credit.title,
expires_at: credit.expires_at.map(|value| value.to_rfc3339()),
reset_type: credit.reset_type,
}
}
}
impl From<ProviderResetCredits> for AccountResetCreditsData {
fn from(credits: ProviderResetCredits) -> Self {
Self {
available_count: credits.available_count,
credits: credits
.credits
.into_iter()
.map(AccountResetCreditView::from)
.collect(),
}
}
}
impl From<ProviderResetCreditResult> for AccountResetCreditResultData {
fn from(result: ProviderResetCreditResult) -> Self {
Self {
code: result.code,
credit: result.credit.map(AccountResetCreditView::from),
}
}
}
/// Provider-owned 明文导出文档;Debug 永远不输出内部 JSON。
#[derive(Serialize)]
#[serde(transparent)]
pub struct AccountExportData(Value);
impl AccountExportData {
#[must_use]
pub const fn new(value: Value) -> Self {
Self(value)
}
pub(super) fn from_result(bundle: AccountExportBundle) -> Self {
let documents = bundle
.documents
.into_iter()
.map(|document| {
serde_json::json!({
"provider": document.provider_kind.to_string(),
"document": provider_document_value(document.document),
})
})
.collect::<Vec<_>>();
Self::new(serde_json::json!({
"exportedAt": bundle.exported_at.to_rfc3339(),
"documents": documents,
}))
}
}
impl fmt::Debug for AccountExportData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("AccountExportData(<redacted>)")
}
}
pub struct AccountConnectionTestEvent {
pub data: Value,
}
impl From<DomainConnectionTestEvent> for AccountConnectionTestEvent {
fn from(event: DomainConnectionTestEvent) -> Self {
let data = match event {
DomainConnectionTestEvent::Started { model } => serde_json::json!({
"type": "test_start",
"model": model,
"text": "正在连接上游 Responses"
}),
DomainConnectionTestEvent::Request {
model,
input_text,
stream,
store,
} => serde_json::json!({
"type": "request",
"payload": {
"model": model,
"input": [{
"role": "user",
"content": [{ "type": "input_text", "text": input_text }]
}],
"stream": stream,
"store": store
}
}),
DomainConnectionTestEvent::Content { text } => {
serde_json::json!({ "type": "content", "text": text })
}
DomainConnectionTestEvent::Completed => serde_json::json!({
"type": "test_complete",
"success": true
}),
DomainConnectionTestEvent::Failed {
source,
gateway_error_code,
send_state,
message,
provider_error_code,
provider_error_type,
upstream_status,
upstream_content_type,
upstream_body,
} => serde_json::json!({
"type": "error",
"source": source.as_str(),
"gatewayErrorCode": gateway_error_code.as_str(),
"sendState": send_state.map(gateway_core::upstream::UpstreamSendState::as_str),
"error": message,
"providerErrorCode": provider_error_code,
"providerErrorType": provider_error_type,
"upstreamStatus": upstream_status,
"upstreamContentType": upstream_content_type,
"upstreamBody": upstream_body
}),
};
Self { data }
}
}
+10 -18
View File
@@ -3,7 +3,7 @@
use std::fmt;
use axum::{
Json, Router,
Router,
extract::{FromRequestParts, State},
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, request::Parts},
response::{IntoResponse, Response},
@@ -16,7 +16,7 @@ use gateway_admin::{
use serde::{Deserialize, Serialize};
use tower_http::request_id::RequestId;
use super::{AdminEnvelope, AdminError, AdminResponse};
use super::{AdminEnvelope, AdminError, AdminJson, AdminResponse};
const REQUEST_ID_HEADER: &str = "x-request-id";
const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session";
@@ -37,11 +37,6 @@ impl AdminAuth {
pub const fn context(&self) -> &AdminRequestContext {
&self.context
}
#[must_use]
pub fn into_context(self) -> AdminRequestContext {
self.context
}
}
impl<S> FromRequestParts<S> for AdminAuth
@@ -52,8 +47,7 @@ where
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"))?;
let request_id = admin_request_id(parts).ok_or_else(AdminError::internal)?;
Ok(Self {
context: AdminRequestContext {
principal,
@@ -90,7 +84,7 @@ where
{
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")),
Err(_) => Err(AdminError::internal()),
}
}
@@ -107,7 +101,7 @@ where
{
Ok(true) => Ok(AdminPrincipal::ApiKey),
Ok(false) => Err(AdminError::invalid_admin_api_key()),
Err(_) => Err(AdminError::internal("Failed to validate admin API key")),
Err(_) => Err(AdminError::internal()),
};
}
@@ -209,7 +203,7 @@ where
async fn login<S>(
State(state): State<S>,
Json(payload): Json<AdminLoginRequest>,
AdminJson(payload): AdminJson<AdminLoginRequest>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -232,8 +226,7 @@ where
);
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie)
.map_err(|_| AdminError::internal("Failed to create admin session cookie"))?,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
);
Ok(response)
}
@@ -250,7 +243,7 @@ where
.auth()
.validate_session(admin_session_cookie(&headers).as_deref())
.await
.map_err(|_| AdminError::internal("Failed to validate admin session"))?;
.map_err(|_| AdminError::internal())?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminSessionStatusData::new(authenticated)),
@@ -270,8 +263,7 @@ where
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"))?,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
);
Ok(response)
}
@@ -287,6 +279,6 @@ fn admin_session_cookie(headers: &HeaderMap) -> Option<String> {
fn map_login_error(error: LoginError) -> AdminError {
match error {
LoginError::InvalidCredentials => AdminError::invalid_admin_credentials(),
LoginError::Unavailable => AdminError::internal("Failed to create admin session"),
LoginError::Unavailable => AdminError::internal(),
}
}
+15 -15
View File
@@ -3,8 +3,8 @@
//! 路径统一位于 `/api/admin/settings/backups/*`,内部由独立 `BackupService` 承担业务。
use axum::{
Json, Router,
extract::{Query, State},
Router,
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
@@ -18,8 +18,8 @@ use secrecy::{ExposeSecret as _, SecretString};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminPageData, AdminResponse, AdminSessionState,
PageMeta, wire::map_admin_service_error,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminPageData, AdminQuery, AdminResponse,
AdminSessionState, PageMeta, wire::map_admin_service_error,
};
/// 备份设置视图;`secretAccessKey` 返回已保存的明文凭据,由前端掩码显示。
@@ -256,7 +256,7 @@ where
async fn update_backup_storage<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateBackupStorageRequest>,
AdminJson(request): AdminJson<UpdateBackupStorageRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -304,7 +304,7 @@ where
async fn update_backup_schedule<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateBackupScheduleRequest>,
AdminJson(request): AdminJson<UpdateBackupScheduleRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -331,28 +331,28 @@ where
async fn backup_records<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<BackupRecordsQuery>,
AdminQuery(query): AdminQuery<BackupRecordsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let page_size = gateway_admin::model::PageSize::new(query.page_size.unwrap_or(20))
.map_err(|_| AdminError::bad_request("Invalid pageSize"))?;
.map_err(|_| AdminError::bad_request("pageSize 不合法"))?;
let page = query.page.unwrap_or(1);
if page == 0 {
return Err(AdminError::bad_request("Invalid page"));
return Err(AdminError::bad_request("page 不合法"));
}
let status = match query.status.as_deref() {
None => None,
Some(value) => BackupStatus::parse(value)
.map(Some)
.ok_or_else(|| AdminError::bad_request("Invalid status"))?,
.ok_or_else(|| AdminError::bad_request("status 不合法"))?,
};
let trigger = match query.trigger.as_deref() {
None => None,
Some(value) => BackupTriggerKind::parse(value)
.map(Some)
.ok_or_else(|| AdminError::bad_request("Invalid trigger"))?,
.ok_or_else(|| AdminError::bad_request("trigger 不合法"))?,
};
let domain_query = gateway_admin::model::backup::BackupRecordListQuery {
page,
@@ -386,7 +386,7 @@ where
async fn create_backup<S>(
auth: AdminAuth,
State(state): State<S>,
body: Option<Json<CreateBackupRequest>>,
body: Option<AdminJson<CreateBackupRequest>>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -417,7 +417,7 @@ pub struct CreateBackupRequest {
async fn download_backup_url<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<BackupIdRequest>,
AdminJson(request): AdminJson<BackupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -437,7 +437,7 @@ where
async fn delete_backup<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<BackupIdRequest>,
AdminJson(request): AdminJson<BackupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -455,5 +455,5 @@ where
}
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "Backup service unavailable")
map_admin_service_error(error)
}
@@ -12,21 +12,21 @@ use gateway_admin::model::client_keys::{
};
use gateway_core::{
policy::{ClientApiKeyId, RateLimits},
routing::ProviderKind,
routing::AccountGroupId,
};
use serde::{Deserialize, Serialize};
use axum::{
Json, Router,
extract::{Query, State},
Router,
extract::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,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
WireValidationError, wire::map_admin_service_error,
};
const MAX_CURSOR_BYTES: usize = 512;
@@ -137,7 +137,7 @@ impl ClientKeySort {
pub struct CreateClientKeyRequest {
name: String,
label: Option<String>,
provider_kind: String,
group_ids: Vec<String>,
max_concurrency: u64,
requests_per_minute: u64,
}
@@ -147,14 +147,13 @@ impl CreateClientKeyRequest {
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)?;
let group_ids = validate_group_ids(self.group_ids)?;
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"))?,
group_ids,
limits: RateLimits {
max_concurrency: self.max_concurrency,
requests_per_minute: self.requests_per_minute,
@@ -170,7 +169,7 @@ pub struct UpdateClientKeyRequest {
id: String,
name: String,
label: Option<String>,
provider_kind: String,
group_ids: Vec<String>,
max_concurrency: u64,
requests_per_minute: u64,
}
@@ -181,15 +180,14 @@ impl UpdateClientKeyRequest {
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)?;
let group_ids = validate_group_ids(self.group_ids)?;
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"))?,
group_ids,
limits: RateLimits {
max_concurrency: self.max_concurrency,
requests_per_minute: self.requests_per_minute,
@@ -242,7 +240,9 @@ pub struct ClientKeyView {
id: String,
name: String,
label: Option<String>,
provider_kind: String,
routing_scope: &'static str,
groups: Vec<ClientKeyGroupView>,
provider_kinds: Vec<String>,
prefix: String,
enabled: bool,
max_concurrency: u64,
@@ -252,13 +252,42 @@ pub struct ClientKeyView {
last_used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientKeyGroupView {
id: String,
name: String,
color: String,
enabled: bool,
}
impl From<ClientKeyRecord> for ClientKeyView {
fn from(record: ClientKeyRecord) -> Self {
let routing_scope = if record.groups.is_empty() {
"all"
} else {
"groups"
};
Self {
id: record.id.to_string(),
name: record.name,
label: record.label,
provider_kind: record.provider_kind.to_string(),
routing_scope,
groups: record
.groups
.into_iter()
.map(|group| ClientKeyGroupView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.collect(),
provider_kinds: record
.provider_kinds
.into_iter()
.map(|provider| provider.to_string())
.collect(),
prefix: record.prefix,
enabled: record.enabled,
max_concurrency: record.limits.max_concurrency,
@@ -572,21 +601,20 @@ fn validate_required_text(value: &str, field: &'static str) -> Result<(), WireVa
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_group_ids(values: Vec<String>) -> Result<Vec<AccountGroupId>, WireValidationError> {
if values.len() > 1000
|| values
.iter()
.collect::<std::collections::BTreeSet<_>>()
.len()
!= values.len()
{
return Err(WireValidationError::new("groupIds"));
}
values
.into_iter()
.map(|value| AccountGroupId::new(value).map_err(|_| WireValidationError::new("groupIds")))
.collect()
}
fn validate_optional_text(
@@ -641,7 +669,7 @@ where
async fn list_client_keys<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ListClientKeysQuery>,
AdminQuery(query): AdminQuery<ListClientKeysQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -652,15 +680,14 @@ where
.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"))?;
let data = ClientKeyListData::try_from(result).map_err(|_| AdminError::internal())?;
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn create_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<CreateClientKeyRequest>,
AdminJson(payload): AdminJson<CreateClientKeyRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -681,7 +708,7 @@ where
async fn reveal_client_key<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ClientKeyIdQuery>,
AdminQuery(query): AdminQuery<ClientKeyIdQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -707,7 +734,7 @@ where
async fn update_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<UpdateClientKeyRequest>,
AdminJson(payload): AdminJson<UpdateClientKeyRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -725,7 +752,7 @@ where
async fn disable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyMutationRequest>,
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -746,7 +773,7 @@ where
async fn enable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyMutationRequest>,
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -767,7 +794,7 @@ where
async fn delete_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyMutationRequest>,
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -794,13 +821,14 @@ fn mutation_response(
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"),
"cursor" => AdminError::bad_request("Client API Key 游标不合法"),
"clientKeyRevealNotFound" | "clientKeyMutationNotFound" => {
AdminError::not_found("Client API Key 不存在")
}
_ => AdminError::bad_request("Client API Key 请求不合法"),
}
}
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "Configuration repository unavailable")
map_admin_service_error(error)
}
@@ -0,0 +1,87 @@
//! 管理端 JSON 与 Query extractor 的稳定 rejection 映射。
use axum::{
Json,
extract::{
FromRequest, FromRequestParts, OptionalFromRequest, Query, Request,
rejection::{JsonRejection, QueryRejection},
},
http::{StatusCode, request::Parts},
};
use serde::de::DeserializeOwned;
use super::AdminError;
/// 将 Axum JSON rejection 收口为管理端错误信封。
pub struct AdminJson<T>(pub T);
impl<T, S> FromRequest<S> for AdminJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = AdminError;
async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
<Json<T> as FromRequest<S>>::from_request(request, state)
.await
.map(|Json(value)| Self(value))
.map_err(map_json_rejection)
}
}
impl<T, S> OptionalFromRequest<S> for AdminJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = AdminError;
async fn from_request(request: Request, state: &S) -> Result<Option<Self>, Self::Rejection> {
<Json<T> as OptionalFromRequest<S>>::from_request(request, state)
.await
.map(|value| value.map(|Json(value)| Self(value)))
.map_err(map_json_rejection)
}
}
/// 将 Axum Query rejection 收口为管理端错误信封。
pub struct AdminQuery<T>(pub T);
impl<T, S> FromRequestParts<S> for AdminQuery<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = AdminError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
Query::<T>::from_request_parts(parts, state)
.await
.map(|Query(value)| Self(value))
.map_err(map_query_rejection)
}
}
fn map_json_rejection(rejection: JsonRejection) -> AdminError {
let status = rejection.status();
match rejection {
JsonRejection::JsonSyntaxError(_) => AdminError::malformed_json(),
JsonRejection::JsonDataError(_) => {
AdminError::invalid_request(StatusCode::UNPROCESSABLE_ENTITY, "请求字段不合法")
}
JsonRejection::MissingJsonContentType(_) => AdminError::invalid_request(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"请求必须使用 application/json",
),
JsonRejection::BytesRejection(_) if status == StatusCode::PAYLOAD_TOO_LARGE => {
AdminError::invalid_request(status, "请求体过大")
}
JsonRejection::BytesRejection(_) => AdminError::internal(),
_ => AdminError::invalid_request(status, "请求体不合法"),
}
}
fn map_query_rejection(_: QueryRejection) -> AdminError {
AdminError::bad_request("请求参数不合法")
}
+18 -1
View File
@@ -5,12 +5,15 @@ use axum::{
http::{HeaderValue, header},
middleware,
response::Response,
routing::any,
};
pub mod account_groups;
pub mod accounts;
pub mod auth;
pub mod backups;
pub mod client_keys;
mod extract;
pub mod observability;
pub mod presenter;
pub mod settings;
@@ -18,6 +21,7 @@ pub mod system;
pub mod wire;
pub use auth::{AdminAuth, AdminSessionState};
pub use extract::{AdminJson, AdminQuery};
pub use wire::{
ADMIN_OK_CODE, ADMIN_OK_MESSAGE, AdminEnvelope, AdminError, AdminErrorBody, AdminErrorCode,
AdminPageData, AdminResponse, PageMeta, WireValidationError,
@@ -29,6 +33,7 @@ where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.merge(account_groups::router::<S>())
.merge(accounts::router::<S>())
.merge(auth::router::<S>())
.merge(backups::router::<S>())
@@ -36,12 +41,24 @@ where
.merge(observability::router::<S>())
.merge(settings::router::<S>())
.merge(system::router::<S>())
.method_not_allowed_fallback(method_not_allowed)
.route("/api/admin", any(admin_not_found))
.route("/api/admin/{*path}", any(admin_not_found))
.layer(middleware::map_response(no_store))
}
async fn method_not_allowed() -> AdminError {
AdminError::method_not_allowed()
}
async fn admin_not_found() -> AdminError {
AdminError::admin_route_not_found()
}
async fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
.entry(header::CACHE_CONTROL)
.or_insert(HeaderValue::from_static("no-store"));
response
}
@@ -1,23 +1,16 @@
//! Dashboard、用量与诊断查询的 wire 映射和固定路由。
use std::{collections::BTreeMap, num::NonZeroU32};
use std::collections::BTreeMap;
use axum::{
Router,
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
use chrono::{DateTime, Duration, Utc};
use gateway_admin::model::{PageSize as DomainPageSize, observability as domain};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::presenter::{format_compact_number, format_number};
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta,
AdminAuth, AdminEnvelope, AdminError, AdminQuery, AdminResponse, AdminSessionState,
WireValidationError, wire::map_admin_service_error,
};
@@ -17,6 +17,21 @@ pub(crate) fn display_duration(value: Option<u64>) -> String {
}
}
fn display_duration_f64(value: Option<f64>) -> String {
let Some(value) = value.filter(|value| value.is_finite() && *value >= 0.0) else {
return "—".to_owned();
};
if value < 1_000.0 {
format!("{value:.0} ms")
} else if value < 60_000.0 {
format!("{:.2} s", value / 1_000.0)
} else if value < 3_600_000.0 {
format!("{:.1} min", value / 60_000.0)
} else {
format!("{:.1} h", value / 3_600_000.0)
}
}
pub(crate) fn display_rate(value: f64) -> String {
if value.is_finite() {
format!("{:.1}%", value * 100.0)
@@ -25,6 +40,10 @@ pub(crate) fn display_rate(value: f64) -> String {
}
}
fn basis_points_rate(value: u64) -> f64 {
value as f64 / 10_000.0
}
pub(crate) fn china_datetime(value: &DateTime<Utc>) -> String {
(*value + Duration::hours(8))
.format("%Y-%m-%d %H:%M:%S")
@@ -127,17 +146,40 @@ pub(crate) fn token_details(record: &domain::UsageRecord) -> TokenDetailsView {
}
}
pub(crate) fn format_decimal_currency(amount: &str, currency: &str) -> String {
if currency == "USD" {
let value = amount.parse::<f64>().map_or(0.0, |value| value);
let precision = if value != 0.0 && value.abs() < 0.01 {
4
} else {
2
};
format!("${value:.precision$}")
} else {
format!("{currency} {amount}")
pub(crate) fn usage_list_token_details(record: &domain::UsageListRecord) -> TokenDetailsView {
TokenDetailsView {
input_tokens: record.input_tokens,
output_tokens: record.output_tokens,
cached_tokens: record.cached_tokens,
cache_write_tokens: record.cache_write_tokens,
reasoning_tokens: record.reasoning_tokens,
image_input_tokens: record.image_input_tokens,
image_output_tokens: record.image_output_tokens,
total_tokens: record.total_tokens,
input_tokens_display: record
.input_tokens
.map_or_else(|| "-".to_owned(), format_number),
output_tokens_display: record
.output_tokens
.map_or_else(|| "-".to_owned(), format_number),
cached_tokens_display: record
.cached_tokens
.map_or_else(|| "-".to_owned(), format_compact_number),
cache_write_tokens_display: record
.cache_write_tokens
.map_or_else(|| "-".to_owned(), format_compact_number),
reasoning_tokens_display: record
.reasoning_tokens
.map_or_else(|| "-".to_owned(), format_number),
image_input_tokens_display: record
.image_input_tokens
.map_or_else(|| "-".to_owned(), format_number),
image_output_tokens_display: record
.image_output_tokens
.map_or_else(|| "-".to_owned(), format_number),
total_tokens_display: record
.total_tokens
.map_or_else(|| "-".to_owned(), format_number),
}
}
@@ -156,11 +198,19 @@ pub(crate) fn format_service_tier(service_tier: Option<&str>) -> String {
match service_tier {
Some("priority" | "fast") => "Fast".to_owned(),
Some("flex") => "Flex".to_owned(),
Some("default") | None => "Default".to_owned(),
Some(other) => other.to_owned(),
Some("default" | "standard") | None => "Standard".to_owned(),
Some(other) => capitalize_first(other),
}
}
fn capitalize_first(value: &str) -> String {
let mut chars = value.chars();
chars
.next()
.map(|first| first.to_uppercase().chain(chars).collect())
.unwrap_or_default()
}
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
match billing? {
domain::UsageBilling::Total { total, .. } => Some(BillingView {
@@ -194,6 +244,57 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<Bil
}
}
pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageListRecordView {
let token_details = usage_list_token_details(&record);
let billing = billing_view(record.billing.as_ref());
let created_at_display = china_datetime(&record.started_at);
let model = record
.upstream_model_id
.clone()
.or_else(|| record.requested_model_id.clone());
UsageListRecordView {
id: record.id,
provider: record.provider_kind,
authentication_kind: record.provider_account_authentication_kind,
account_id: record.provider_account_ref,
account_email: record.provider_account_email,
account_name: record.provider_account_name,
route: record.endpoint,
model,
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
service_tier: record.service_tier,
client_transport: record.client_transport,
upstream_transport: record.upstream_transport,
reasoning_effort: record.reasoning_effort,
reasoning_preset: record.reasoning_preset,
subagent_kind: record.subagent_kind,
compact: record.compact,
token_details,
billing,
latency_details: UsageLatencyDetailsView {
admission_decision_ms: record.admission_decision_ms,
account_selection_wait_ms: record.account_selection_wait_ms,
capacity_used_slots: record.capacity_used_slots,
capacity_total_slots: record.capacity_total_slots,
transport_decision_wait_ms: record.transport_decision_wait_ms,
ws_connect_ms: record.connect_ms,
upstream_headers_ms: record.headers_ms,
first_event_ms: record.first_event_ms,
first_reasoning_ms: record.first_reasoning_ms,
first_text_ms: record.first_text_ms,
first_token_ms: record.first_token_ms,
openai_processing_ms: record.provider_processing_ms,
},
first_token_latency_ms: record.first_token_ms,
latency_ms: record.latency_ms,
created_at: record.started_at,
created_at_display,
client_ip: record.client_ip,
user_agent: record.user_agent,
}
}
pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView {
let tokens = token_details(&record);
let billing = billing_view(record.billing.as_ref());
@@ -236,16 +337,15 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
let model = record
.upstream_model_id
.clone()
.unwrap_or_else(|| record.requested_model_id.clone());
let transport = record
.upstream_transport
.clone()
.or_else(|| Some(record.client_transport.clone()));
.or_else(|| record.requested_model_id.clone());
let metadata = provider_metadata_fields(record.provider_metadata_json.as_deref());
UsageRecordView {
id: record.id.clone(),
request_id: record.id,
client_api_key_id: Some(record.client_api_key_ref),
routing_scope: record.routing_scope,
routing_group_refs: record.routing_group_refs,
routing_group_names_snapshot: record.routing_group_names_snapshot,
kind: record.operation,
provider: record.provider_kind,
authentication_kind: record.provider_account_authentication_kind,
@@ -254,11 +354,12 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
account_name: record.provider_account_name,
route: record.endpoint,
model,
requested_model: Some(record.requested_model_id),
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
service_tier: record.service_tier,
status_code,
transport,
client_transport: record.client_transport,
upstream_transport: record.upstream_transport,
protocol: record.protocol.clone(),
http_version: record.http_version.clone(),
client_status_code: record.client_status_code.map(i64::from),
@@ -270,6 +371,10 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
image_generation_requested: record.image_generation_requested,
image_generation_succeeded: record.image_generation_succeeded,
latency_details: UsageLatencyDetailsView {
admission_decision_ms: record.admission_decision_ms,
account_selection_wait_ms: record.account_selection_wait_ms,
capacity_used_slots: record.capacity_used_slots,
capacity_total_slots: record.capacity_total_slots,
transport_decision_wait_ms: record.transport_decision_wait_ms,
ws_connect_ms: record.connect_ms,
upstream_headers_ms: record.headers_ms,
@@ -329,6 +434,8 @@ pub(crate) fn provider_metadata_fields(value: Option<&str>) -> BTreeMap<String,
"requestKind",
"subagentKind",
"transport",
"clientTransport",
"upstreamTransport",
"httpVersion",
"clientStatusCode",
"upstreamStatusCode",
@@ -362,9 +469,7 @@ pub(crate) fn usage_attempt_view(attempt: domain::UsageAttempt) -> UsageAttemptV
provider: attempt
.provider_kind
.unwrap_or_else(|| "unknown".to_owned()),
model: attempt
.upstream_model_id
.unwrap_or_else(|| "unknown".to_owned()),
model: attempt.upstream_model_id,
transport: attempt
.upstream_transport
.unwrap_or_else(|| "unknown".to_owned()),
@@ -410,31 +515,13 @@ pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetai
}
}
pub(crate) fn page_meta(page: u32, page_size: u16, total: u64) -> PageMeta {
let page_size_u64 = u64::from(page_size);
let total_pages = total.saturating_add(page_size_u64 - 1) / page_size_u64;
PageMeta::new(
page,
u32::from(page_size),
total,
u32::try_from(total_pages).unwrap_or(u32::MAX),
)
}
pub(crate) fn usage_page_view(
page: domain::UsagePage,
page_number: u32,
page_size: u16,
) -> Result<PageData<UsageRecordView>, WireValidationError> {
Ok(PageData {
items: page.items.into_iter().map(usage_record_view).collect(),
page: page_meta(page_number, page_size, page.total),
next_cursor: page
.next_cursor
.as_ref()
.map(encode_observability_cursor)
.transpose()?,
})
pub(crate) fn usage_page_view(page: domain::UsagePage) -> PageData<UsageListRecordView> {
PageData {
items: page.items.into_iter().map(usage_list_record_view).collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
}
}
pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
@@ -444,50 +531,78 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
.or(error.provider_account_name.as_ref())
.or(error.provider_account_ref.as_ref())
.cloned();
let model = error
.upstream_model_id
.clone()
.or_else(|| error.requested_model_id.clone());
OpsErrorView {
id: error.event_id,
request_id: error.request_id,
client_api_key_id: error.client_api_key_ref,
kind: error.component.clone(),
operation: error.operation,
protocol: error.protocol,
client_transport: error.client_transport,
provider: error.provider_kind,
authentication_kind: error.provider_account_authentication_kind,
account_id: error.provider_account_ref,
account_name: error.provider_account_name,
account_email: error.provider_account_email,
route: error.endpoint.unwrap_or_else(|| "—".to_owned()),
model: error.upstream_model_id,
model,
requested_model: error.requested_model_id,
upstream_model: error.upstream_model_id,
service_tier: error.service_tier,
client_status_code: error.client_status_code.map(i64::from),
upstream_status_code: error.upstream_status_code.map(i64::from),
transport: error.upstream_transport,
attempt_index: error.attempt_index,
failure_class: error.failure_kind,
upstream_send_state: error.upstream_send_state,
provider_error_code: error.provider_error_code,
occurrence_count: error.occurrence_count,
response_id: error.client_response_id,
upstream_request_id: error.upstream_request_id,
latency_ms: error.latency_ms,
client_ip: error.client_ip,
user_agent: error.user_agent,
reasoning_effort: error.reasoning_effort,
reasoning_preset: error.reasoning_preset,
request_kind: error.request_kind,
subagent_kind: error.subagent_kind,
compact: error.compact,
message: error.message,
raw_upstream_error: error.raw_upstream_error,
metadata: OpsErrorMetadataView {
source: error.source,
component: error.component,
attempt_id: None,
account_label,
continuation_affinity_hash: error.continuation_affinity_hash,
continuation_previous_response_id_hash: error.continuation_previous_response_id_hash,
continuation_unavailable_reason: error.continuation_unavailable_reason,
upstream_connection_id: error.upstream_connection_id,
upstream_connection_exit_reason: error.upstream_connection_exit_reason,
upstream_connection_age_ms: error.upstream_connection_age_ms,
upstream_connection_idle_ms: error.upstream_connection_idle_ms,
recovery_request_id: error.recovery_request_id,
recovered_at: error.recovered_at,
recovery_attempt_count: error.recovery_attempt_count,
recovery_retry_delay_ms: error.recovery_retry_delay_ms,
recovery_total_latency_ms: error.recovery_total_latency_ms,
},
created_at: error.occurred_at,
created_at_display: china_datetime(&error.occurred_at),
}
}
pub(crate) fn ops_page_view(
page: domain::OpsErrorPage,
page_number: u32,
page_size: u16,
) -> Result<PageData<OpsErrorView>, WireValidationError> {
Ok(PageData {
pub(crate) fn ops_page_view(page: domain::OpsErrorPage) -> PageData<OpsErrorView> {
PageData {
items: page.items.into_iter().map(ops_error_view).collect(),
page: page_meta(page_number, page_size, page.total),
next_cursor: page
.next_cursor
.as_ref()
.map(encode_observability_cursor)
.transpose()?,
})
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
}
}
pub(crate) fn trend_point_view(point: domain::TrendPoint) -> TrendPointView {
@@ -519,6 +634,36 @@ pub(crate) fn trend_point_view(point: domain::TrendPoint) -> TrendPointView {
success_rate: success_rate_value
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
success_rate_value,
first_token_p50_ms: point
.metrics
.first_token_latency_percentiles
.p50_ms
.map(|value| value.as_f64()),
first_token_p95_ms: point
.metrics
.first_token_latency_percentiles
.p95_ms
.map(|value| value.as_f64()),
latency_p95_ms: point
.metrics
.latency_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p50: point.metrics.output_throughput_p50,
admission_decision_p95_ms: point
.metrics
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: point
.metrics
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
capacity_utilization: point
.metrics
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
}
}
@@ -546,18 +691,20 @@ pub(crate) fn trend_summary_view(
],
TrendKind::Latency => vec![
TrendSummaryView {
label: "平均".to_owned(),
value: display_duration(summary.average_latency_ms),
label: "首字 P95".to_owned(),
value: display_duration_f64(summary.peak_first_token_p95_ms),
ratio: None,
},
TrendSummaryView {
label: "最高".to_owned(),
value: display_duration(summary.max_latency_ms),
label: "总耗时 P95".to_owned(),
value: display_duration_f64(summary.peak_latency_p95_ms),
ratio: None,
},
TrendSummaryView {
label: "最低".to_owned(),
value: display_duration(summary.min_latency_ms),
label: "吞吐 P50".to_owned(),
value: summary
.minimum_output_throughput_p50
.map_or_else(|| "—".to_owned(), |value| format!("{value} tok/s")),
ratio: None,
},
],
@@ -709,7 +856,7 @@ pub(crate) fn dashboard_view(
} = result;
let domain::DashboardObservation {
range,
requests,
totals,
provider_accounts,
account_usage,
recent_requests,
@@ -742,27 +889,31 @@ pub(crate) fn dashboard_view(
last_used: relative_time(credential.last_used_at, range.end),
});
}
let unavailable_accounts = provider_accounts
.total
.saturating_sub(provider_accounts.normal);
DashboardDataView {
cards: DashboardCardsView {
credentials: DashboardCredentialsCardView {
total: format_compact_number(provider_accounts.total),
total: provider_accounts.total.to_string(),
total_value: provider_accounts.total,
available: format_compact_number(provider_accounts.active),
available_value: provider_accounts.active,
unavailable: format_compact_number(provider_accounts.unavailable),
unavailable_value: provider_accounts.unavailable,
available: provider_accounts.normal.to_string(),
available_value: provider_accounts.normal,
unavailable: unavailable_accounts.to_string(),
unavailable_value: unavailable_accounts,
},
traffic: DashboardTrafficCardView {
today_requests: format_compact_number(today.request_count),
today_requests_value: today.request_count,
yesterday_requests_value: yesterday.request_count,
total_requests: format_compact_number(requests.request_count),
total_requests: format_compact_number(totals.request_count),
},
tokens: DashboardTokensCardView {
today_tokens: format_compact_number(today.total_tokens),
today_tokens_value: today.total_tokens,
yesterday_tokens_value: yesterday.total_tokens,
total_tokens: format_compact_number(requests.total_tokens),
total_tokens: format_compact_number(totals.total_tokens),
total_billing_amount_usd: total_billing_usd
.as_ref()
.map_or_else(|| "—".to_owned(), |amount| format!("${}", amount.as_str())),
@@ -772,7 +923,7 @@ pub(crate) fn dashboard_view(
today_hit_rate_value: today.observed_cached_token_rate,
yesterday_hit_rate_value: yesterday.observed_cached_token_rate,
total_hit_rate: display_rate(total_cached_token_rate),
total_cached_tokens: format_compact_number(requests.cached_tokens),
total_cached_tokens: format_compact_number(totals.cached_tokens),
average_first_token_latency_ms: display_duration(average_first_token_latency_ms),
},
},
@@ -780,17 +931,17 @@ pub(crate) fn dashboard_view(
health_timeline: health_timeline_view(health_timeline),
wire_profiles: wire_profiles.into_iter().map(wire_profile_view).collect(),
account_usage: account_usage_views,
usage_records: recent_requests.into_iter().map(usage_record_view).collect(),
usage_records: recent_requests
.into_iter()
.map(usage_list_record_view)
.collect(),
pool_summary: DashboardPoolSummaryView {
total: provider_accounts.total,
active: provider_accounts.active,
rate_limited: provider_accounts.rate_limited,
expired: provider_accounts.expired,
invalid: provider_accounts.invalid,
normal: provider_accounts.normal,
quota_exhausted: provider_accounts.quota_exhausted,
refreshing: provider_accounts.refreshing,
rate_limited: provider_accounts.rate_limited,
disabled: provider_accounts.disabled,
banned: provider_accounts.banned,
error: provider_accounts.error,
},
capacity_info: DashboardCapacityInfoView {
max_concurrent_per_account: capacity.max_concurrent_per_account,
@@ -825,6 +976,7 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.map(|point| OverviewHealthPointView {
bucket: point.bucket_start,
label: china_label(point.bucket_start, "%m-%d %H:%M"),
total_requests: point.total_requests,
success_requests: point.success_requests,
failed_requests: point.failed_requests,
cancelled_requests: point.cancelled_requests,
@@ -855,6 +1007,31 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.first_token_latency_percentiles
.p99_ms
.map(|value| value.as_f64()),
admission_decision_p50_ms: point
.admission_decision_percentiles
.p50_ms
.map(|value| value.as_f64()),
admission_decision_p95_ms: point
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p50_ms: point
.account_selection_wait_percentiles
.p50_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: point
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p10: point.output_throughput_p10,
output_throughput_p50: point.output_throughput_p50,
output_throughput_p90: point.output_throughput_p90,
capacity_utilization: point
.capacity_utilization_avg_basis_points
.map(basis_points_rate),
capacity_utilization_p95: point
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
})
.collect();
let cost_points = insights
@@ -870,6 +1047,8 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
total_tokens: point.total_tokens,
estimated_cost: point.estimated_cost.as_ref().map(ToString::to_string),
standard_cost: point.standard_cost.as_ref().map(ToString::to_string),
no_cache_cost: point.no_cache_cost.as_ref().map(ToString::to_string),
cache_savings: point.cache_savings.as_ref().map(ToString::to_string),
cached_token_rate: point.cached_token_rate,
cache_hit_request_rate: point.cache_hit_request_rate,
})
@@ -884,6 +1063,7 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
incomplete_requests: insights.health.incomplete_requests,
caller_error_requests: insights.health.caller_error_requests,
success_rate: insights.health.success_rate,
completion_rate: insights.health.completion_rate,
request_change_rate: None,
success_rate_change: None,
points: health_points,
@@ -919,8 +1099,42 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.first_token_latency_percentiles
.p99_ms
.map(|value| value.as_f64()),
admission_decision_p50_ms: insights
.performance
.admission_decision_percentiles
.p50_ms
.map(|value| value.as_f64()),
admission_decision_p95_ms: insights
.performance
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p50_ms: insights
.performance
.account_selection_wait_percentiles
.p50_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: insights
.performance
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p10: insights.performance.output_throughput_p10,
output_throughput_p50: insights.performance.output_throughput_p50,
output_throughput_p90: insights.performance.output_throughput_p90,
capacity_utilization: insights
.performance
.capacity_utilization_avg_basis_points
.map(basis_points_rate),
capacity_utilization_p95: insights
.performance
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
latency_coverage: insights.performance.latency_coverage,
first_token_coverage: insights.performance.first_token_coverage,
admission_decision_coverage: insights.performance.admission_decision_coverage,
account_selection_wait_coverage: insights.performance.account_selection_wait_coverage,
capacity_coverage: insights.performance.capacity_coverage,
points: performance_points,
},
cost: OverviewCostView {
@@ -934,11 +1148,27 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.standard_cost
.as_ref()
.map(ToString::to_string),
no_cache_cost: insights
.cost
.no_cache_cost
.as_ref()
.map(ToString::to_string),
cache_savings: insights
.cost
.cache_savings
.as_ref()
.map(ToString::to_string),
tier_premium: insights.cost.tier_premium.as_ref().map(ToString::to_string),
cost_per_request: insights
.cost
.cost_per_request
.as_ref()
.map(ToString::to_string),
cost_per_successful_request: insights
.cost
.cost_per_successful_request
.as_ref()
.map(ToString::to_string),
tokens_per_request: insights.cost.tokens_per_request,
cached_token_rate: insights.cost.cached_token_rate,
cache_hit_request_rate: insights.cost.cache_hit_request_rate,
@@ -996,6 +1226,12 @@ pub(crate) fn diagnostics_view(
request_share: item.request_share,
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
first_token_p95_ms: item.first_token_p95_ms,
non_completion_count: item.non_completion_count,
non_completion_rate: item.non_completion_rate,
retry_count: item.retry_count,
retry_rate: item.retry_rate,
impact_score: item.impact_score,
estimated_cost: item.estimated_cost.as_ref().map(ToString::to_string),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
@@ -1005,21 +1241,21 @@ pub(crate) fn diagnostics_view(
}
pub(crate) fn map_wire_error(error: WireValidationError) -> AdminError {
if error.field() == "timeRange" {
return AdminError::invalid_time_range();
}
let message = match error.field() {
"timeRange" => "Invalid time range",
"statusCode" => "Status code must be between 100 and 599",
"attemptIndex" => "Attempt index is out of range",
"kind" => "Invalid dashboard trend kind",
"dimension" => "Invalid diagnostics dimension",
"outcome" => "Invalid Observability query",
"id" => "Usage record ID is required",
"page" | "pageSize" => "Invalid Observability query",
"cursor" => "Invalid observability cursor",
_ => "Invalid observability query",
"statusCode" => "statusCode 必须在 100 到 599 之间",
"attemptIndex" => "attemptIndex 超出有效范围",
"kind" => "Dashboard 趋势类型不合法",
"dimension" => "诊断维度不合法",
"id" => "用量记录 ID 不能为空",
"cursor" => "观测数据游标不合法",
_ => "观测查询参数不合法",
};
AdminError::bad_request(message)
}
pub(crate) fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "Observability repository unavailable")
map_admin_service_error(error)
}
@@ -2,14 +2,10 @@
use super::*;
use base64::Engine as _;
/// 观测列表默认页大小。
pub const DEFAULT_PAGE_SIZE: u16 = 50;
/// 观测列表允许的最大页大小。
pub const MAX_PAGE_SIZE: u16 = 100;
/// 游标编码在 HTTP 层解码前允许的最大长度。
pub const MAX_CURSOR_BYTES: usize = 1024;
/// Dashboard 查询参数。
#[derive(Clone, Default, Deserialize)]
@@ -31,9 +27,8 @@ impl DashboardQuery {
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UsageQuery {
pub page: Option<u32>,
pub current_page: Option<u32>,
pub page_size: Option<u16>,
pub cursor: Option<String>,
pub kind: Option<String>,
pub outcome: Option<String>,
pub client_api_key_id: Option<String>,
@@ -53,22 +48,17 @@ pub struct UsageQuery {
}
impl UsageQuery {
/// 校验分页字段,不改变仓储层的分页 owner。
pub fn validate_page(&self) -> Result<(u32, u16), WireValidationError> {
let page = self.page.unwrap_or(1);
if page == 0 {
return Err(WireValidationError::new("page"));
/// 校验 Element Plus 风格的页码分页字段。
pub fn validate_pagination(&self) -> Result<(u32, u16), WireValidationError> {
let current_page = self.current_page.unwrap_or(1);
if current_page == 0 {
return Err(WireValidationError::new("currentPage"));
}
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
if page_size == 0 || page_size > MAX_PAGE_SIZE {
return Err(WireValidationError::new("pageSize"));
}
Ok((page, page_size))
}
/// 校验游标的 wire 边界;编码和排序语义由应用层负责。
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
validate_cursor(self.cursor.as_deref())
Ok((current_page, page_size))
}
}
@@ -110,22 +100,19 @@ impl DiagnosticsQuery {
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct OpsQuery {
pub page: Option<u32>,
pub current_page: Option<u32>,
pub page_size: Option<u16>,
pub cursor: Option<String>,
pub kind: Option<String>,
pub client_api_key_id: Option<String>,
pub provider: Option<String>,
pub request_id: Option<String>,
pub account_id: Option<String>,
pub route: Option<String>,
pub model: Option<String>,
pub status_code: Option<i64>,
pub client_status_code: Option<i64>,
pub upstream_status_code: Option<i64>,
pub transport: Option<String>,
pub attempt_index: Option<i64>,
pub failure_class: Option<String>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub search: Option<String>,
@@ -134,22 +121,17 @@ pub struct OpsQuery {
}
impl OpsQuery {
/// 校验分页字段。
pub fn validate_page(&self) -> Result<(u32, u16), WireValidationError> {
let page = self.page.unwrap_or(1);
if page == 0 {
return Err(WireValidationError::new("page"));
/// 校验 Element Plus 风格的页码分页字段。
pub fn validate_pagination(&self) -> Result<(u32, u16), WireValidationError> {
let current_page = self.current_page.unwrap_or(1);
if current_page == 0 {
return Err(WireValidationError::new("currentPage"));
}
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
if page_size == 0 || page_size > MAX_PAGE_SIZE {
return Err(WireValidationError::new("pageSize"));
}
Ok((page, page_size))
}
/// 校验游标的 wire 边界。
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
validate_cursor(self.cursor.as_deref())
Ok((current_page, page_size))
}
}
@@ -258,13 +240,6 @@ pub(crate) fn require_text(value: &str, field: &'static str) -> Result<(), WireV
}
}
pub(crate) fn validate_cursor(value: Option<&str>) -> Result<(), WireValidationError> {
if value.is_some_and(|value| value.is_empty() || value.len() > MAX_CURSOR_BYTES) {
return Err(WireValidationError::new("cursor"));
}
Ok(())
}
pub(crate) fn trimmed(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
@@ -310,16 +285,6 @@ pub(crate) fn usage_range(
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
}
pub(crate) fn dashboard_range(
start: Option<&str>,
end: Option<&str>,
) -> Result<domain::TimeRange, WireValidationError> {
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
let start =
parse_datetime(start)?.unwrap_or_else(|| domain::china_day_start(end) - Duration::days(1));
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
}
pub(crate) fn dashboard_today_range(
start: Option<&str>,
end: Option<&str>,
@@ -355,37 +320,20 @@ pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, Wi
})
}
pub(crate) fn usage_command(
query: &UsageQuery,
) -> Result<(domain::UsageQuery, u32, u16), WireValidationError> {
let (page, page_size) = query.validate_page()?;
query.validate_cursor()?;
let page_number = NonZeroU32::new(page)
.map(domain::PageNumber::new)
.ok_or_else(|| WireValidationError::new("page"))?;
pub(crate) fn usage_command(query: &UsageQuery) -> Result<domain::UsageQuery, WireValidationError> {
let (current_page, page_size) = query.validate_pagination()?;
let page_size_value =
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
Ok((
domain::UsageQuery {
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
filter: usage_filter(query)?,
cursor: decode_observability_cursor(query.cursor.as_deref())?,
page: page_number,
page_size: page_size_value,
},
page,
page_size,
))
Ok(domain::UsageQuery {
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
filter: usage_filter(query)?,
current_page,
page_size: page_size_value,
})
}
pub(crate) fn ops_command(
query: &OpsQuery,
) -> Result<(domain::OpsErrorQuery, u32, u16), WireValidationError> {
let (page, page_size) = query.validate_page()?;
query.validate_cursor()?;
let page_number = NonZeroU32::new(page)
.map(domain::PageNumber::new)
.ok_or_else(|| WireValidationError::new("page"))?;
pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, WireValidationError> {
let (current_page, page_size) = query.validate_pagination()?;
let page_size_value =
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
let status_code = parse_status(
@@ -394,66 +342,25 @@ pub(crate) fn ops_command(
.or(query.client_status_code)
.or(query.status_code),
)?;
Ok((
domain::OpsErrorQuery {
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
filter: domain::OpsErrorFilter {
client_api_key_ref: non_empty(query.client_api_key_id.clone()),
request_id: non_empty(query.request_id.clone()),
provider_kind: non_empty(query.provider.clone()),
provider_account_ref: non_empty(query.account_id.clone()),
operation: non_empty(query.route.clone()).or_else(|| non_empty(query.kind.clone())),
model: non_empty(query.model.clone()),
transport: non_empty(query.transport.clone()),
attempt_index: parse_attempt_index(query.attempt_index)?,
response_id: non_empty(query.response_id.clone()),
upstream_request_id: non_empty(query.upstream_request_id.clone()),
failure_kind: non_empty(query.failure_class.clone()),
status_code,
search: non_empty(query.search.clone()),
},
cursor: decode_observability_cursor(query.cursor.as_deref())?,
page: page_number,
page_size: page_size_value,
Ok(domain::OpsErrorQuery {
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
filter: domain::OpsErrorFilter {
client_api_key_ref: non_empty(query.client_api_key_id.clone()),
request_id: non_empty(query.request_id.clone()),
provider_kind: non_empty(query.provider.clone()),
provider_account_ref: non_empty(query.account_id.clone()),
operation: non_empty(query.kind.clone()),
model: non_empty(query.model.clone()),
transport: non_empty(query.transport.clone()),
attempt_index: parse_attempt_index(query.attempt_index)?,
response_id: non_empty(query.response_id.clone()),
upstream_request_id: non_empty(query.upstream_request_id.clone()),
status_code,
search: non_empty(query.search.clone()),
},
page,
page_size,
))
}
pub(crate) fn decode_observability_cursor(
value: Option<&str>,
) -> Result<Option<domain::ObservabilityCursor>, WireValidationError> {
value
.map(|encoded| {
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 wire: CursorWire =
serde_json::from_slice(&bytes).map_err(|_| WireValidationError::new("cursor"))?;
if wire.stable_id.trim().is_empty() {
return Err(WireValidationError::new("cursor"));
}
Ok(domain::ObservabilityCursor {
observed_at: wire.observed_at,
stable_id: wire.stable_id,
})
})
.transpose()
}
pub(crate) fn encode_observability_cursor(
cursor: &domain::ObservabilityCursor,
) -> Result<String, WireValidationError> {
let bytes = serde_json::to_vec(&CursorWire {
observed_at: cursor.observed_at,
stable_id: cursor.stable_id.clone(),
current_page,
page_size: page_size_value,
})
.map_err(|_| WireValidationError::new("cursor"))?;
Ok(URL_SAFE_NO_PAD.encode(bytes))
}
pub(crate) fn non_empty(value: Option<String>) -> Option<String> {
@@ -32,13 +32,14 @@ where
pub(crate) async fn dashboard_summary<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DashboardQuery>,
AdminQuery(query): AdminQuery<DashboardQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let kind = query.trend_kind().map_err(map_wire_error)?;
let range = dashboard_range(query.start_time.as_deref(), query.end_time.as_deref())
// 概览默认按中国时区当日统计,与单独趋势接口保持同一口径。
let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
let result = state
.admin_services()
@@ -55,7 +56,7 @@ where
pub(crate) async fn dashboard_trend<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DashboardQuery>,
AdminQuery(query): AdminQuery<DashboardQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -78,27 +79,26 @@ where
pub(crate) async fn usage_records<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UsageQuery>,
AdminQuery(query): AdminQuery<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (command, page, page_size) = usage_command(&query).map_err(map_wire_error)?;
let command = usage_command(&query).map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
.usage_records(command)
.await
.map_err(map_service_error)?;
let data = usage_page_view(result, page, page_size)
.map_err(|_| AdminError::internal("Failed to encode observability cursor"))?;
let data = usage_page_view(result);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
pub(crate) async fn usage_record_detail<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DetailQuery>,
AdminQuery(query): AdminQuery<DetailQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -119,7 +119,7 @@ where
pub(crate) async fn usage_records_summary<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UsageQuery>,
AdminQuery(query): AdminQuery<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -142,7 +142,7 @@ where
pub(crate) async fn usage_insights_overview<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UsageQuery>,
AdminQuery(query): AdminQuery<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -165,7 +165,7 @@ where
pub(crate) async fn usage_insights_diagnostics<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DiagnosticsQuery>,
AdminQuery(query): AdminQuery<DiagnosticsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -195,19 +195,18 @@ where
pub(crate) async fn ops_errors<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<OpsQuery>,
AdminQuery(query): AdminQuery<OpsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (command, page, page_size) = ops_command(&query).map_err(map_wire_error)?;
let command = ops_command(&query).map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
.ops_errors(command)
.await
.map_err(map_service_error)?;
let data = ops_page_view(result, page, page_size)
.map_err(|_| AdminError::internal("Failed to encode observability cursor"))?;
let data = ops_page_view(result);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -2,21 +2,14 @@
use super::*;
/// 观测列表游标的稳定 wire 形状。
#[derive(Clone, Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CursorWire {
pub observed_at: DateTime<Utc>,
pub stable_id: String,
}
/// 观测列表响应数据。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageData<T> {
pub items: Vec<T>,
pub page: PageMeta,
pub next_cursor: Option<String>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
}
/// Token 详情展示。
@@ -77,13 +70,48 @@ pub struct BillingView {
pub multiplier_display: String,
}
/// 单条逻辑请求展示。
/// 使用记录表格的窄展示。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageListRecordView {
pub id: String,
pub provider: Option<String>,
pub authentication_kind: Option<String>,
pub account_id: Option<String>,
pub account_email: Option<String>,
pub account_name: Option<String>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub service_tier: Option<String>,
pub client_transport: String,
pub upstream_transport: Option<String>,
pub reasoning_effort: Option<String>,
pub reasoning_preset: Option<String>,
pub subagent_kind: Option<String>,
pub compact: bool,
pub token_details: TokenDetailsView,
pub billing: Option<BillingView>,
pub latency_details: UsageLatencyDetailsView,
pub first_token_latency_ms: Option<u64>,
pub latency_ms: Option<u64>,
pub created_at: DateTime<Utc>,
pub created_at_display: String,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
}
/// 单条逻辑请求详情展示。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageRecordView {
pub id: String,
pub request_id: String,
pub client_api_key_id: Option<String>,
pub routing_scope: String,
pub routing_group_refs: Vec<String>,
pub routing_group_names_snapshot: Vec<String>,
pub kind: String,
pub provider: Option<String>,
pub authentication_kind: Option<String>,
@@ -91,12 +119,13 @@ pub struct UsageRecordView {
pub account_email: Option<String>,
pub account_name: Option<String>,
pub route: String,
pub model: String,
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub service_tier: Option<String>,
pub status_code: Option<i64>,
pub transport: Option<String>,
pub client_transport: String,
pub upstream_transport: Option<String>,
pub protocol: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_version: Option<String>,
@@ -154,6 +183,14 @@ pub struct WebSocketPoolMetadataView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageLatencyDetailsView {
#[serde(skip_serializing_if = "Option::is_none")]
pub admission_decision_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub account_selection_wait_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capacity_used_slots: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capacity_total_slots: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transport_decision_wait_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -180,7 +217,7 @@ pub struct UsageAttemptView {
pub attempt_index: u32,
pub trigger: String,
pub provider: String,
pub model: String,
pub model: Option<String>,
pub transport: String,
pub send_state: String,
pub outcome: String,
@@ -253,6 +290,13 @@ pub struct TrendPointView {
pub min_latency_value: Option<u64>,
pub success_rate: String,
pub success_rate_value: Option<f64>,
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub latency_p95_ms: Option<f64>,
pub output_throughput_p50: Option<u64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub capacity_utilization: Option<f64>,
}
/// Dashboard 趋势摘要。
@@ -347,14 +391,11 @@ pub struct DashboardAccountRequestBucketView {
#[serde(rename_all = "camelCase")]
pub struct DashboardPoolSummaryView {
pub total: u64,
pub active: u64,
pub rate_limited: u64,
pub expired: u64,
pub invalid: u64,
pub normal: u64,
pub quota_exhausted: u64,
pub refreshing: Option<u64>,
pub rate_limited: u64,
pub disabled: u64,
pub banned: u64,
pub error: u64,
}
/// 同 target 账号调度容量;Redis 未提供聚合事实时运行中槽位保持空值。
@@ -510,7 +551,7 @@ pub struct DashboardDataView {
pub health_timeline: HealthTimelineView,
pub wire_profiles: Vec<DashboardWireProfileView>,
pub account_usage: Vec<DashboardAccountUsageView>,
pub usage_records: Vec<UsageRecordView>,
pub usage_records: Vec<UsageListRecordView>,
pub pool_summary: DashboardPoolSummaryView,
pub capacity_info: DashboardCapacityInfoView,
pub rotation_strategy: String,
@@ -537,6 +578,7 @@ pub struct UsageSummaryView {
pub struct OverviewHealthPointView {
pub bucket: DateTime<Utc>,
pub label: String,
pub total_requests: u64,
pub success_requests: u64,
pub failed_requests: u64,
pub cancelled_requests: u64,
@@ -556,6 +598,7 @@ pub struct OverviewHealthView {
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub success_rate: f64,
pub completion_rate: f64,
pub request_change_rate: Option<f64>,
pub success_rate_change: Option<f64>,
pub points: Vec<OverviewHealthPointView>,
@@ -573,6 +616,15 @@ pub struct OverviewPerformancePointView {
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub first_token_p99_ms: Option<f64>,
pub admission_decision_p50_ms: Option<f64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p50_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization: Option<f64>,
pub capacity_utilization_p95: Option<f64>,
}
/// 洞察性能摘要。
@@ -585,8 +637,20 @@ pub struct OverviewPerformanceView {
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub first_token_p99_ms: Option<f64>,
pub admission_decision_p50_ms: Option<f64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p50_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization: Option<f64>,
pub capacity_utilization_p95: Option<f64>,
pub latency_coverage: f64,
pub first_token_coverage: f64,
pub admission_decision_coverage: f64,
pub account_selection_wait_coverage: f64,
pub capacity_coverage: f64,
pub points: Vec<OverviewPerformancePointView>,
}
@@ -602,6 +666,8 @@ pub struct OverviewCostPointView {
pub total_tokens: u64,
pub estimated_cost: Option<String>,
pub standard_cost: Option<String>,
pub no_cache_cost: Option<String>,
pub cache_savings: Option<String>,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
}
@@ -612,7 +678,11 @@ pub struct OverviewCostPointView {
pub struct OverviewCostView {
pub estimated_cost: Option<String>,
pub standard_cost: Option<String>,
pub no_cache_cost: Option<String>,
pub cache_savings: Option<String>,
pub tier_premium: Option<String>,
pub cost_per_request: Option<String>,
pub cost_per_successful_request: Option<String>,
pub tokens_per_request: f64,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
@@ -661,6 +731,12 @@ pub struct DiagnosticItemView {
pub request_share: f64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub non_completion_rate: f64,
pub retry_count: u64,
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<String>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -682,20 +758,40 @@ pub struct OpsErrorView {
pub request_id: Option<String>,
pub client_api_key_id: Option<String>,
pub kind: String,
pub operation: String,
pub protocol: Option<String>,
pub client_transport: Option<String>,
pub provider: Option<String>,
pub authentication_kind: Option<String>,
pub account_id: Option<String>,
pub account_name: Option<String>,
pub account_email: Option<String>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub service_tier: Option<String>,
pub client_status_code: Option<i64>,
pub upstream_status_code: Option<i64>,
pub transport: Option<String>,
pub attempt_index: Option<u32>,
pub failure_class: String,
pub upstream_send_state: Option<String>,
pub provider_error_code: Option<String>,
pub occurrence_count: u32,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub latency_ms: Option<u64>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
pub reasoning_preset: Option<String>,
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: Option<bool>,
pub message: String,
/// 上游错误正文或 WebSocket close/error frame 原文;不做脱敏。
pub raw_upstream_error: Option<String>,
pub metadata: OpsErrorMetadataView,
pub created_at: DateTime<Utc>,
pub created_at_display: String,
@@ -709,4 +805,16 @@ pub struct OpsErrorMetadataView {
pub component: String,
pub attempt_id: Option<String>,
pub account_label: Option<String>,
pub continuation_affinity_hash: Option<String>,
pub continuation_previous_response_id_hash: Option<String>,
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection_id: Option<String>,
pub upstream_connection_exit_reason: Option<String>,
pub upstream_connection_age_ms: Option<u64>,
pub upstream_connection_idle_ms: Option<u64>,
pub recovery_request_id: Option<String>,
pub recovered_at: Option<DateTime<Utc>>,
pub recovery_attempt_count: u32,
pub recovery_retry_delay_ms: Option<u64>,
pub recovery_total_latency_ms: Option<u64>,
}
@@ -1,7 +1,9 @@
//! Admin API 的展示层辅助:数值格式化。
//!
//! 按 docs/project-redundancy-boundary-audit.md BE-02 的目标,display 字段最终
//! 迁移到前端;过渡期统一由本模块提供,避免 accounts/observability 各自维护。
//! 当前 wire 仍保留 display 字段;统一由本模块提供,避免 accounts/observability
//! 各自维护格式化规则。
use gateway_core::metering::Decimal;
/// 千分位格式化。
#[must_use]
@@ -37,3 +39,33 @@ pub fn format_compact_number(value: u64) -> String {
}
format_number(value)
}
/// 货币展示格式化。USD 常规金额保留两位,小于 1 美元时最多保留四位。
#[must_use]
pub fn format_decimal_currency(amount: &str, currency: &str) -> String {
if currency != "USD" {
return format!("{currency} {amount}");
}
const DECIMAL_SCALE: u128 = 10_000_000_000;
let scaled = amount.parse::<Decimal>().unwrap_or_default().scaled();
let precision = if scaled != 0 && scaled < DECIMAL_SCALE {
4_u32
} else {
2_u32
};
let rounding_unit = 10_u128.pow(10 - precision);
let rounded = (scaled + rounding_unit / 2) / rounding_unit;
let display_scale = 10_u128.pow(precision);
let whole = rounded / display_scale;
let fraction = rounded % display_scale;
let mut display = format!("{whole}.{fraction:0width$}", width = precision as usize);
while display.ends_with('0')
&& display
.split_once('.')
.is_some_and(|(_, fraction)| fraction.len() > 2)
{
display.pop();
}
format!("${display}")
}
+118 -11
View File
@@ -3,22 +3,26 @@
use std::{collections::BTreeMap, fmt};
use axum::{
Json, Router,
Router,
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use chrono::{DateTime, Utc};
use gateway_admin::model::client_distribution::{
ClientDownloadPackage, CodexDesktopWindowsDownloads,
};
use gateway_admin::model::settings::{
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings,
};
use gateway_core::policy::CodexClientVersion;
use gateway_core::routing::{PublicModelId, UpstreamModelId};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError,
wire::map_admin_service_error,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
WireValidationError, wire::map_admin_service_error,
};
/// 客户端模型到上游模型的全局精确映射。
@@ -34,6 +38,8 @@ pub struct RuntimeSettingsView {
pub max_concurrent_per_account: u64,
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
@@ -50,6 +56,8 @@ pub struct UpdateRuntimeSettingsRequest {
pub max_concurrent_per_account: u64,
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
@@ -75,6 +83,14 @@ impl UpdateRuntimeSettingsRequest {
if RotationStrategy::parse(&self.rotation_strategy).is_none() {
return Err(WireValidationError::new("rotationStrategy"));
}
validate_optional_client_version(
self.min_codex_desktop_version.as_deref(),
"minCodexDesktopVersion",
)?;
validate_optional_client_version(
self.min_codex_cli_version.as_deref(),
"minCodexCliVersion",
)?;
Ok(())
}
@@ -90,6 +106,8 @@ impl UpdateRuntimeSettingsRequest {
request_interval_ms: self.request_interval_ms,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
min_codex_desktop_version: self.min_codex_desktop_version,
min_codex_cli_version: self.min_codex_cli_version,
usage_retention_days: u32::try_from(self.usage_retention_days)
.map_err(|_| WireValidationError::new("settingsUsageRetentionOverflow"))?,
ops_event_retention_days: u32::try_from(self.ops_event_retention_days)
@@ -109,6 +127,8 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
request_interval_ms: settings.request_interval_ms,
rotation_strategy: settings.rotation_strategy.as_str().to_owned(),
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: u64::from(settings.usage_retention_days),
ops_event_retention_days: u64::from(settings.ops_event_retention_days),
audit_retention_days: u64::from(settings.audit_retention_days),
@@ -145,6 +165,58 @@ pub struct DeletedAdminApiKey {
pub message: &'static str,
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct ClientDownloadsQuery {
refresh: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ClientDownloadPackageView {
architecture: String,
source: String,
version: Option<String>,
file_name: String,
size_bytes: Option<u64>,
download_url: String,
expires_at: Option<DateTime<Utc>>,
}
impl From<ClientDownloadPackage> for ClientDownloadPackageView {
fn from(package: ClientDownloadPackage) -> Self {
Self {
architecture: package.architecture.as_str().to_owned(),
source: package.source.as_str().to_owned(),
version: package.version,
file_name: package.file_name,
size_bytes: package.size_bytes,
download_url: package.download_url,
expires_at: package.expires_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexDesktopWindowsDownloadsView {
resolved_at: DateTime<Utc>,
cached: bool,
warning: Option<String>,
packages: Vec<ClientDownloadPackageView>,
}
impl From<CodexDesktopWindowsDownloads> for CodexDesktopWindowsDownloadsView {
fn from(downloads: CodexDesktopWindowsDownloads) -> Self {
Self {
resolved_at: downloads.resolved_at,
cached: downloads.cached,
warning: downloads.warning,
packages: downloads.packages.into_iter().map(Into::into).collect(),
}
}
}
impl Default for DeletedAdminApiKey {
fn default() -> Self {
Self {
@@ -161,6 +233,10 @@ where
Router::new()
.route("/api/admin/settings", get(settings::<S>))
.route("/api/admin/settings/update", post(update_settings::<S>))
.route(
"/api/admin/settings/client-downloads/codex-desktop/windows",
get(codex_desktop_windows_downloads::<S>),
)
.route(
"/api/admin/settings/admin-api-key",
get(admin_api_key_status::<S>),
@@ -175,6 +251,25 @@ where
)
}
async fn codex_desktop_windows_downloads<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<ClientDownloadsQuery>,
) -> impl IntoResponse
where
S: AdminSessionState + Send + Sync,
{
let downloads = state
.admin_services()
.client_distribution()
.codex_desktop_windows(query.refresh)
.await;
AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(CodexDesktopWindowsDownloadsView::from(downloads)),
)
}
async fn settings<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -197,7 +292,7 @@ where
async fn update_settings<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateRuntimeSettingsRequest>,
AdminJson(request): AdminJson<UpdateRuntimeSettingsRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -322,18 +417,30 @@ fn valid_model_name(value: &str, max_len: usize) -> bool {
&& !value.bytes().any(|byte| byte.is_ascii_control())
}
fn validate_optional_client_version(
value: Option<&str>,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.is_some_and(|value| CodexClientVersion::parse(value).is_err()) {
return Err(WireValidationError::new(field));
}
Ok(())
}
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}"),
"settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(),
"settingsMaxConcurrencyOverflow" => "maxConcurrentPerAccount 不合法".to_owned(),
"settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(),
"settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(),
"settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(),
"minCodexDesktopVersion" => "Codex Desktop 最低版本格式不合法".to_owned(),
"minCodexCliVersion" => "Codex CLI 最低版本格式不合法".to_owned(),
field => format!("{field} 字段不合法"),
};
AdminError::bad_request(message)
}
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "Settings repository unavailable")
map_admin_service_error(error)
}
+10 -10
View File
@@ -5,8 +5,8 @@
use std::convert::Infallible;
use axum::{
Json, Router,
extract::{Query, State},
Router,
extract::State,
http::StatusCode,
response::{
IntoResponse,
@@ -23,7 +23,7 @@ use gateway_admin::model::system::{
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
wire::map_admin_service_error,
};
@@ -251,7 +251,7 @@ where
async fn update_detail<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UpdateDetailQuery>,
AdminQuery(query): AdminQuery<UpdateDetailQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -290,7 +290,7 @@ where
async fn perform_update<S>(
_auth: AdminAuth,
State(state): State<S>,
payload: Option<Json<UpdateRequest>>,
payload: Option<AdminJson<UpdateRequest>>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -298,7 +298,7 @@ where
let result = state
.admin_services()
.system()
.perform_update(payload.map(|Json(value)| value.into_target_version()))
.perform_update(payload.map(|AdminJson(value)| value.into_target_version()))
.await
.map_err(map_system_error)?;
let SystemOperationAccepted::Update {
@@ -309,7 +309,7 @@ where
target_version,
} = result
else {
return Err(AdminError::internal("Invalid system update result"));
return Err(AdminError::internal());
};
Ok(AdminResponse::new(
StatusCode::OK,
@@ -361,7 +361,7 @@ where
need_restart,
} = result
else {
return Err(AdminError::internal("Invalid system rollback result"));
return Err(AdminError::internal());
};
Ok(AdminResponse::new(
StatusCode::OK,
@@ -391,7 +391,7 @@ where
message,
} = result
else {
return Err(AdminError::internal("Invalid system restart result"));
return Err(AdminError::internal());
};
Ok(AdminResponse::new(
StatusCode::OK,
@@ -477,5 +477,5 @@ const fn update_event_level_name(level: SystemUpdateEventLevel) -> &'static str
}
fn map_system_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "System operation unavailable")
map_admin_service_error(error)
}
+132 -53
View File
@@ -116,6 +116,8 @@ impl AdminErrorCode {
pub const USAGE_RECORD_ACCOUNTS: Self = Self(50002);
/// 上游网关失败。
pub const BAD_GATEWAY: Self = Self(50201);
/// 不可逆上游操作的执行结果未知。
pub const UPSTREAM_RESULT_UNKNOWN: Self = Self(50202);
/// 依赖的服务暂不可用。
pub const SERVICE_UNAVAILABLE: Self = Self(50301);
@@ -165,6 +167,89 @@ pub struct AdminError {
body: AdminErrorBody,
}
#[derive(Debug, Clone, Copy)]
struct AdminErrorSpec {
status: StatusCode,
code: AdminErrorCode,
message: &'static str,
}
impl AdminErrorSpec {
const fn new(status: StatusCode, code: AdminErrorCode, message: &'static str) -> Self {
Self {
status,
code,
message,
}
}
}
const MALFORMED_JSON: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::BAD_REQUEST,
AdminErrorCode::MALFORMED_JSON,
"请求体不是合法 JSON",
);
const BAD_REQUEST: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::BAD_REQUEST,
AdminErrorCode::BAD_REQUEST,
"请求参数不合法",
);
const INVALID_TIME_RANGE: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::BAD_REQUEST,
AdminErrorCode::INVALID_TIME_RANGE,
"时间范围不合法",
);
const SESSION_REQUIRED: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::SESSION_REQUIRED,
"需要管理员登录",
);
const INVALID_CREDENTIALS: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::INVALID_CREDENTIALS,
"管理员用户名或密码错误",
);
const INVALID_API_KEY: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::INVALID_API_KEY,
"管理 API Key 无效",
);
const ADMIN_ROUTE_NOT_FOUND: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::NOT_FOUND,
AdminErrorCode::NOT_FOUND,
"管理接口不存在",
);
const METHOD_NOT_ALLOWED: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::METHOD_NOT_ALLOWED,
AdminErrorCode::BAD_REQUEST,
"请求方法不受支持",
);
const TOO_MANY_LOGIN_ATTEMPTS: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::TOO_MANY_REQUESTS,
AdminErrorCode::TOO_MANY_LOGIN_ATTEMPTS,
"登录尝试过多,请稍后重试",
);
const INTERNAL: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminErrorCode::INTERNAL,
"服务内部错误",
);
const BAD_GATEWAY: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::BAD_GATEWAY,
AdminErrorCode::BAD_GATEWAY,
"上游服务请求失败",
);
const UPSTREAM_RESULT_UNKNOWN: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::BAD_GATEWAY,
AdminErrorCode::UPSTREAM_RESULT_UNKNOWN,
"上游执行结果未知,请刷新状态后再决定是否重试",
);
const SERVICE_UNAVAILABLE: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::SERVICE_UNAVAILABLE,
AdminErrorCode::SERVICE_UNAVAILABLE,
"依赖服务暂不可用",
);
impl AdminError {
fn new(status: StatusCode, code: AdminErrorCode, message: impl Into<String>) -> Self {
Self {
@@ -173,44 +258,48 @@ impl AdminError {
}
}
fn from_spec(spec: AdminErrorSpec) -> Self {
Self::new(spec.status, spec.code, spec.message)
}
pub fn malformed_json() -> Self {
Self::from_spec(MALFORMED_JSON)
}
pub fn invalid_request(status: StatusCode, message: &'static str) -> Self {
Self::new(status, BAD_REQUEST.code, message)
}
pub fn invalid_time_range() -> Self {
Self::from_spec(INVALID_TIME_RANGE)
}
pub fn admin_route_not_found() -> Self {
Self::from_spec(ADMIN_ROUTE_NOT_FOUND)
}
pub fn method_not_allowed() -> Self {
Self::from_spec(METHOD_NOT_ALLOWED)
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(
StatusCode::BAD_REQUEST,
AdminErrorCode::BAD_REQUEST,
message,
)
Self::new(BAD_REQUEST.status, BAD_REQUEST.code, message)
}
pub fn admin_session_required() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::SESSION_REQUIRED,
"Admin session required",
)
Self::from_spec(SESSION_REQUIRED)
}
pub fn invalid_admin_credentials() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::INVALID_CREDENTIALS,
"Invalid admin credentials",
)
Self::from_spec(INVALID_CREDENTIALS)
}
pub fn invalid_admin_api_key() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::INVALID_API_KEY,
"Invalid admin API key",
)
Self::from_spec(INVALID_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",
)
Self::from_spec(TOO_MANY_LOGIN_ATTEMPTS)
}
pub fn conflict(message: impl Into<String>) -> Self {
@@ -221,47 +310,37 @@ impl AdminError {
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 internal() -> Self {
Self::from_spec(INTERNAL)
}
pub fn bad_gateway(message: impl Into<String>) -> Self {
Self::new(
StatusCode::BAD_GATEWAY,
AdminErrorCode::BAD_GATEWAY,
message,
)
pub fn bad_gateway() -> Self {
Self::from_spec(BAD_GATEWAY)
}
pub fn service_unavailable(message: impl Into<String>) -> Self {
Self::new(
StatusCode::SERVICE_UNAVAILABLE,
AdminErrorCode::SERVICE_UNAVAILABLE,
message,
)
pub fn upstream_result_unknown() -> Self {
Self::from_spec(UPSTREAM_RESULT_UNKNOWN)
}
pub fn service_unavailable() -> Self {
Self::from_spec(SERVICE_UNAVAILABLE)
}
}
/// 把管理用例的稳定错误分类映射到既有 HTTP 错误 contract。
pub(crate) fn map_admin_service_error(
error: gateway_admin::model::AdminError,
unavailable_message: &'static str,
) -> AdminError {
pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -> AdminError {
use gateway_admin::model::AdminErrorKind;
match error.kind() {
AdminErrorKind::Invalid => AdminError::bad_request(error.to_string()),
AdminErrorKind::Invalid => AdminError::bad_request(error.message()),
AdminErrorKind::Unauthorized => AdminError::admin_session_required(),
AdminErrorKind::NotFound => AdminError::not_found(error.to_string()),
AdminErrorKind::Conflict => AdminError::conflict(error.to_string()),
AdminErrorKind::NotFound => AdminError::not_found(error.message()),
AdminErrorKind::Conflict => AdminError::conflict(error.message()),
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()),
AdminErrorKind::BadGateway => AdminError::bad_gateway(),
AdminErrorKind::UpstreamResultUnknown => AdminError::upstream_result_unknown(),
AdminErrorKind::Unavailable => AdminError::service_unavailable(),
AdminErrorKind::Internal => AdminError::internal(),
}
}
+21 -3
View File
@@ -1,4 +1,4 @@
//! `/healthz` 对 Core、Store 与 Host worker 健康事实的聚合。
//! `/healthz` 对 Core、Store 与 Host 关键 worker 健康事实的聚合。
use std::sync::Arc;
use std::time::Duration;
@@ -6,8 +6,10 @@ use std::time::Duration;
use axum::{extract::State, http::StatusCode};
use futures::future::join_all;
use gateway_core::health::{
HealthProbe, HealthState, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState,
HealthProbe, HealthState, WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource,
WorkerRuntimeState,
};
use gateway_core::task::WorkerKind;
use crate::ApiState;
@@ -32,7 +34,13 @@ impl HealthStatus {
}
pub(crate) async fn healthy(&self) -> bool {
if !self.workers.snapshot().iter().all(worker_is_healthy) {
if !self
.workers
.snapshot()
.iter()
.filter(|worker| worker_affects_healthz(worker))
.all(worker_is_healthy)
{
return false;
}
let checks = self.probes.iter().map(|probe| probe.check());
@@ -54,6 +62,16 @@ pub(crate) async fn healthz(State(state): State<ApiState>) -> StatusCode {
}
}
fn worker_affects_healthz(worker: &WorkerHealthSnapshot) -> bool {
let WorkerHealthKey::Task(id) = &worker.key else {
return false;
};
!matches!(
id.kind(),
WorkerKind::OAuthRefresh | WorkerKind::QuotaCatalogHealth
)
}
fn worker_is_healthy(worker: &WorkerHealthSnapshot) -> bool {
match worker.state {
WorkerRuntimeState::Disabled | WorkerRuntimeState::Standby => true,
+153 -9
View File
@@ -5,9 +5,13 @@ use axum::{
response::{IntoResponse, Response},
};
use gateway_core::engine::execution::AuthenticatedClient;
use gateway_core::policy::{ClientVersionRejection, CodexClientKind, CodexClientVersion};
use super::{
error::{missing_client_api_key_response, runtime_unavailable_response},
error::{
client_version_rejection_response, missing_client_api_key_response,
runtime_unavailable_response,
},
service::OpenAiService,
};
@@ -26,6 +30,44 @@ pub enum ClientApiKeyAuthError {
RuntimeUnavailable,
}
/// Client API Key 或最低版本准入失败。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientAccessError {
Authentication(ClientApiKeyAuthError),
Version(ClientVersionRejection),
}
impl From<ClientApiKeyAuthError> for ClientAccessError {
fn from(error: ClientApiKeyAuthError) -> Self {
Self::Authentication(error)
}
}
impl From<ClientVersionRejection> for ClientAccessError {
fn from(error: ClientVersionRejection) -> Self {
Self::Version(error)
}
}
/// 从有界请求头中识别出的客户端及其可选合法版本。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentifiedCodexClient {
kind: CodexClientKind,
version: Option<CodexClientVersion>,
}
impl IdentifiedCodexClient {
#[must_use]
pub const fn kind(&self) -> CodexClientKind {
self.kind
}
#[must_use]
pub const fn version(&self) -> Option<&CodexClientVersion> {
self.version.as_ref()
}
}
impl ClientApiKeyAuthError {
/// 返回可用于日志和指标的稳定失败原因。
#[must_use]
@@ -67,19 +109,121 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu
pub(crate) fn authenticate_client(
service: &OpenAiService,
headers: &HeaderMap,
) -> Result<AuthenticatedClient, ClientApiKeyAuthError> {
bearer_client_api_key(headers).and_then(|key| service.authenticate(key))
) -> Result<AuthenticatedClient, ClientAccessError> {
let key = bearer_client_api_key(headers)?;
let client = service.authenticate(key)?;
if let Some(identified) = identify_codex_client(headers) {
client
.snapshot()
.min_codex_client_versions()
.enforce(identified.kind(), identified.version())?;
}
Ok(client)
}
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()
pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response {
match error {
ClientAccessError::Authentication(error) => {
log_client_api_key_auth_failure(error);
if error == ClientApiKeyAuthError::RuntimeUnavailable {
runtime_unavailable_response().into_response()
} else {
missing_client_api_key_response().into_response()
}
}
ClientAccessError::Version(error) => {
tracing::info!(
client = error.kind().as_str(),
current_version = error.current().map(ToString::to_string),
min_version = %error.min(),
"Codex client version requirement rejected request"
);
client_version_rejection_response(&error).into_response()
}
}
}
/// Desktop 优先于其内嵌的 CLI/Core 标记;未知客户端保持兼容并返回 `None`。
#[must_use]
pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClient> {
const MAXIMUM_HEADER_LENGTH: usize = 4096;
let originator = bounded_ascii_header(headers, "originator", MAXIMUM_HEADER_LENGTH);
let user_agent = bounded_ascii_header(headers, "user-agent", MAXIMUM_HEADER_LENGTH);
let is_desktop = originator.is_some_and(|value| value.eq_ignore_ascii_case("Codex Desktop"))
|| user_agent.is_some_and(|value| contains_ascii_case_insensitive(value, "Codex Desktop"));
if is_desktop {
let explicit_version = bounded_ascii_header(headers, "version", MAXIMUM_HEADER_LENGTH);
let version = match explicit_version {
Some(value) => CodexClientVersion::parse(value).ok(),
None => user_agent
.and_then(desktop_version_from_user_agent)
.and_then(|value| CodexClientVersion::parse(value).ok()),
};
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Desktop,
version,
});
}
let user_agent = user_agent?;
for product in user_agent.split(|character: char| {
character.is_ascii_whitespace() || matches!(character, '(' | ')' | ';' | ',' | '[' | ']')
}) {
for prefix in ["codex_cli_rs", "codex-cli"] {
if product.eq_ignore_ascii_case(prefix) {
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Cli,
version: None,
});
}
let Some((name, version)) = product.split_once('/') else {
continue;
};
if name.eq_ignore_ascii_case(prefix) {
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Cli,
version: CodexClientVersion::parse(version).ok(),
});
}
}
}
None
}
fn bounded_ascii_header<'a>(
headers: &'a HeaderMap,
name: &'static str,
maximum_length: usize,
) -> Option<&'a str> {
let value = headers.get(name)?.to_str().ok()?;
if !value.is_ascii() {
return None;
}
Some(&value[..value.len().min(maximum_length)])
}
fn desktop_version_from_user_agent(user_agent: &str) -> Option<&str> {
let marker = "Codex Desktop;";
let start = find_ascii_case_insensitive(user_agent, marker)? + marker.len();
let version = user_agent[start..]
.trim_start()
.split([')', ' ', ';', ','])
.next()?;
(!version.is_empty()).then_some(version)
}
fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
find_ascii_case_insensitive(haystack, needle).is_some()
}
fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
haystack
.as_bytes()
.windows(needle.len())
.position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
}
fn log_client_api_key_auth_failure(error: ClientApiKeyAuthError) {
match error {
ClientApiKeyAuthError::RuntimeUnavailable => {
@@ -9,6 +9,7 @@ use axum::{
use gateway_core::{
engine::EngineError,
error::{GatewayError, GatewayErrorKind},
policy::{ClientVersionRejection, CodexClientKind},
};
use serde_json::{Value, json};
@@ -58,6 +59,47 @@ pub fn runtime_unavailable_response() -> (StatusCode, Json<Value>) {
)
}
/// 已识别 Codex 客户端不满足最低版本要求。
pub fn client_version_rejection_response(
rejection: &ClientVersionRejection,
) -> (StatusCode, Json<Value>) {
let client_label = match rejection.kind() {
CodexClientKind::Desktop => "Codex Desktop",
CodexClientKind::Cli => "Codex CLI",
};
let (message, code, current_version) = match rejection.current() {
Some(current) => (
format!(
"{client_label} {current} is below the minimum required version {}. Upgrade {client_label} and retry.",
rejection.min()
),
"client_version_too_old",
Some(current.to_string()),
),
None => (
format!(
"A valid {client_label} version is required. Upgrade to version {} or newer and retry.",
rejection.min()
),
"client_version_unavailable",
None,
),
};
(
StatusCode::UPGRADE_REQUIRED,
Json(json!({
"error": {
"message": message,
"type": "invalid_request_error",
"code": code,
"client": rejection.kind().as_str(),
"current_version": current_version,
"min_version": rejection.min().to_string()
}
})),
)
}
/// 对外模型不存在。
pub fn model_not_found_response() -> (StatusCode, Json<Value>) {
openai_error_response(
@@ -89,6 +131,7 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
| EngineError::ProviderMetadataMismatch
| EngineError::ContinuationPinMismatch
| EngineError::RequiredAccountMismatch
| EngineError::AccountOutsideClientScope
| EngineError::DownstreamCommitRequired
| EngineError::NoActiveAttempt
| EngineError::InvalidDeliveryState => {
@@ -0,0 +1,119 @@
//! Codex Images 非流式 HTTP adapter。
use std::net::SocketAddr;
use axum::{
body::Bytes,
extract::{Extension, State, connect_info::ConnectInfo},
http::HeaderMap,
response::Response,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::operation::{ImageRequest, ImageRequestKind, Operation, RawJsonPayload};
use serde_json::{Map, Value};
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, client_access_error_response},
error::gateway_error_response,
provider_endpoint::collect_raw_json_response,
responses::request_client_context,
};
const OPENAI_PROTOCOL: &str = "openai";
const IMAGE_TURN_ID_CONTEXT_KEY: &str = "image_turn_id";
/// `POST /v1/images/generations`。
pub(crate) async fn image_generations(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_image_request(
state,
connect_info,
headers,
body,
ImageRequestKind::Generation,
"/v1/images/generations",
)
.await
}
/// `POST /v1/images/edits`。
pub(crate) async fn image_edits(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_image_request(
state,
connect_info,
headers,
body,
ImageRequestKind::Edit,
"/v1/images/edits",
)
.await
}
async fn handle_image_request(
state: ApiState,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
kind: ImageRequestKind,
endpoint: &'static str,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
let (client_ip, user_agent) = request_client_context(
&headers,
connect_info.map(|Extension(ConnectInfo(address))| address),
);
let operation = match image_operation(body, &headers, kind) {
Ok(operation) => operation,
Err(error) => return gateway_error_response(&error),
};
let started = match service
.start_provider_endpoint(client, operation, client_ip, user_agent, endpoint)
.await
{
Ok(started) => started,
Err(error) => return gateway_error_response(&error),
};
collect_raw_json_response(started).await
}
fn image_operation(
body: Bytes,
headers: &HeaderMap,
kind: ImageRequestKind,
) -> Result<Operation, GatewayError> {
let mut context = Map::new();
if let Some(turn_id) = headers
.get("x-codex-image-turn-id")
.and_then(|value| value.to_str().ok())
{
context.insert(
IMAGE_TURN_ID_CONTEXT_KEY.to_owned(),
Value::String(turn_id.to_owned()),
);
}
let payload = RawJsonPayload::new(OPENAI_PROTOCOL, body)
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::Internal,
"OpenAI protocol identifier is invalid",
)
})?
.with_context(context);
Ok(Operation::GenerateImage(ImageRequest::from_raw_json(
kind, payload,
)))
}
@@ -0,0 +1,5 @@
//! OpenAI/Codex Images JSON 透明 adapter。
mod http;
pub(crate) use http::{image_edits, image_generations};
@@ -2,7 +2,10 @@
pub mod auth;
pub mod error;
pub mod images;
pub mod models;
mod provider_endpoint;
pub mod responses;
pub mod router;
pub mod search;
pub(crate) mod service;
@@ -13,7 +13,7 @@ use serde_json::{Value, json};
use crate::ApiState;
use super::{
auth::{authenticate_client, authentication_error_response},
auth::{authenticate_client, client_access_error_response},
error::model_not_found_response,
};
@@ -34,7 +34,7 @@ pub(crate) async fn models(
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
if query
@@ -81,7 +81,7 @@ pub(crate) async fn model_detail(
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
let Ok(public_model) = PublicModelId::new(model_id) else {
return model_not_found_response().into_response();
@@ -97,50 +97,6 @@ pub(crate) async fn model_detail(
.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,
@@ -150,22 +106,6 @@ fn openai_model_json(id: &str) -> Value {
})
}
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();
@@ -0,0 +1,124 @@
//! OpenAI Provider 原生非流式 JSON 端点的公共交付边界。
use axum::{
body::{Body, Bytes},
http::{HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use gateway_core::engine::execution::StartedExecution;
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
use super::{
error::{engine_error_response, protocol_error_response},
responses::{PendingExecution, ProtocolError, ProtocolErrorBody},
};
/// 收集一个 Provider 原生 JSON 响应,并在 Core commit 后按原始 bytes 交付。
pub(super) async fn collect_raw_json_response(started: StartedExecution) -> Response {
let mut execution = PendingExecution::new(started.session);
let Some(session) = execution.session_mut() else {
return invalid_upstream_response();
};
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 body = match raw_json_body(events) {
Some(body) => body,
None => {
let response = invalid_upstream_response();
let response = execution.record_response_status(response).await;
execution.cancel_and_finalize().await;
return response;
}
};
let status = session
.response_status_code()
.and_then(|status| StatusCode::from_u16(status).ok())
.filter(StatusCode::is_success)
.unwrap_or(StatusCode::OK);
let response_headers = session.response_headers().to_vec();
let response = json_body_response(body, status, &response_headers);
let Some(session) = execution.session_mut() else {
return invalid_upstream_response();
};
if let Err(error) = session.commit_downstream(Some(status.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 invalid_upstream_response();
}
execution.disarm();
response
}
fn raw_json_body(events: Vec<ProviderEvent>) -> Option<Bytes> {
let mut body = None;
for event in events {
let (_, wire) = event.into_parts();
let Some(wire) = wire.filter(|wire| wire.protocol() == "openai") else {
continue;
};
let Some(raw) = wire.into_raw_json_body() else {
continue;
};
if body.replace(raw).is_some() {
return None;
}
}
body
}
fn json_body_response(
body: Bytes,
status: StatusCode,
response_headers: &[ProviderResponseHeader],
) -> Response {
let mut response = Response::new(Body::from(body));
*response.status_mut() = status;
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::responses::response_connection_options(response_headers);
for header in response_headers {
if !super::responses::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
}
fn invalid_upstream_response() -> Response {
protocol_error_response(
StatusCode::BAD_GATEWAY,
ProtocolErrorBody {
error: ProtocolError {
kind: "server_error",
code: "invalid_upstream_response",
message: "The gateway could not forward the upstream JSON response.".to_owned(),
param: None,
},
},
)
.into_response()
}
@@ -23,7 +23,7 @@ use gateway_protocol::openai::sse::{DONE_SSE_FRAME, response_failed_sse_event_wi
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, authentication_error_response},
auth::{authenticate_client, client_access_error_response},
error::{
engine_error_response, gateway_error_contract, gateway_error_from_engine,
gateway_error_response, protocol_error_response, runtime_unavailable_response,
@@ -32,7 +32,7 @@ use crate::openai::{
use super::{
OpenAiResponsesEncoder, ProtocolErrorBody, ResponseEncodeError,
request::{decode_request_with_headers, decode_review_request_with_headers},
request::decode_request_with_headers,
};
/// `POST /v1/responses`。
@@ -41,37 +41,13 @@ pub(crate) async fn responses(
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),
Err(error) => return client_access_error_response(error),
};
let decoded = match if review {
decode_review_request_with_headers(&body, &headers)
} else {
decode_request_with_headers(&body, &headers)
} {
let decoded = match decode_request_with_headers(&body, &headers) {
Ok(decoded) => decoded,
Err(error) => {
return protocol_error_response(StatusCode::BAD_REQUEST, error.protocol_body());
@@ -100,11 +76,7 @@ async fn handle_responses(
} else {
ClientTransport::HttpJson
},
if review {
"/v1/responses/review"
} else {
"/v1/responses"
},
"/v1/responses",
)
.await
{
@@ -123,7 +95,7 @@ async fn handle_responses(
}
/// 从 socket 与标准转发头提取旧 Usage 页面使用的诊断事实。
pub(super) fn request_client_context(
pub(in crate::openai) fn request_client_context(
headers: &HeaderMap,
peer_address: Option<SocketAddr>,
) -> (Option<IpAddr>, Option<String>) {
@@ -367,22 +339,24 @@ fn internal_gateway_response(message: &'static str) -> Response {
gateway_error_response(&GatewayError::new(GatewayErrorKind::Internal, message))
}
pub(super) struct PendingExecution {
pub(in crate::openai) struct PendingExecution {
session: Option<Box<dyn ExecutionSession>>,
}
impl PendingExecution {
pub(super) fn new(session: Box<dyn ExecutionSession>) -> Self {
pub(in crate::openai) fn new(session: Box<dyn ExecutionSession>) -> Self {
Self {
session: Some(session),
}
}
pub(super) fn session_mut(&mut self) -> Option<&mut (dyn ExecutionSession + 'static)> {
pub(in crate::openai) fn session_mut(
&mut self,
) -> Option<&mut (dyn ExecutionSession + 'static)> {
self.session.as_deref_mut()
}
async fn cancel_and_finalize(&mut self) {
pub(in crate::openai) async fn cancel_and_finalize(&mut self) {
if let Some(session) = self.session.as_mut() {
session.cancel();
if !session.is_finalized() {
@@ -391,7 +365,10 @@ impl PendingExecution {
}
}
async fn record_response_status(&mut self, response: Response) -> Response {
pub(in crate::openai) 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())
@@ -400,7 +377,7 @@ impl PendingExecution {
response
}
pub(super) fn disarm(&mut self) {
pub(in crate::openai) fn disarm(&mut self) {
self.session = None;
}
@@ -4,11 +4,12 @@ mod error;
mod http;
mod request;
mod response;
mod websocket;
pub mod websocket;
pub use error::{ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
pub(crate) use http::responses;
pub(super) use http::{PendingExecution, request_client_context};
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,
@@ -73,7 +74,6 @@ pub(super) fn response_header_is_forwardable(name: &str, connection_options: &[S
| "x-openai-organization"
| "x-openai-project"
| "x-codex-installation-id"
| "x-codex-turn-state"
| "x-codex-turn-metadata"
)
}
@@ -13,10 +13,19 @@ 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 CODEX_TURN_METADATA_KEY: &str = "x-codex-turn-metadata";
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
/// Responses 请求进入共享解码内核时的下游传输来源。
#[derive(Clone, Copy)]
pub(super) enum RequestDecodeSource {
/// 单次 HTTP 请求,请求头与正文属于同一请求。
Http,
/// 复用连接上的 WebSocket `response.create` 帧。
WebSocketFrame,
}
#[derive(Clone, Default)]
pub struct OpenAiRequestHeaders {
turn_state: Option<String>,
@@ -65,16 +74,24 @@ impl OpenAiRequestHeaders {
}
}
fn apply_subagent(&self, body: &mut Map<String, Value>, forced_subagent: Option<&str>) {
if let Some(subagent) = forced_subagent.or(self.subagent.as_deref()) {
fn apply_subagent(&self, body: &mut Map<String, Value>) {
if let Some(subagent) = self.subagent.as_deref() {
inject_subagent_metadata(body, subagent);
}
}
fn protocol_context(&self, use_websocket: Option<bool>) -> Map<String, Value> {
fn protocol_context(
&self,
use_websocket: Option<bool>,
frame_turn_metadata: Option<&String>,
) -> 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,
"turn_metadata",
frame_turn_metadata.or(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(
@@ -136,12 +153,6 @@ pub enum ContinuationIntent {
}
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> {
@@ -165,7 +176,6 @@ impl fmt::Debug for ContinuationIntent {
#[derive(Clone, PartialEq, Eq)]
pub struct ResponsesRequestMetadata {
requested_model: String,
public_model: String,
stream: bool,
store: bool,
continuation: ContinuationIntent,
@@ -180,12 +190,6 @@ impl ResponsesRequestMetadata {
&self.requested_model
}
/// 返回协议层公开模型候选。
#[must_use]
pub fn public_model(&self) -> &str {
&self.public_model
}
/// 返回客户端是否请求 SSE。
#[must_use]
pub const fn stream(&self) -> bool {
@@ -222,7 +226,6 @@ impl fmt::Debug for ResponsesRequestMetadata {
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)
@@ -297,20 +300,11 @@ 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))
decode_request_inner(body, &OpenAiRequestHeaders::from_headers(headers))
}
pub(super) fn decode_request_inner(
body: &[u8],
review: bool,
request_headers: &OpenAiRequestHeaders,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
let value =
@@ -318,14 +312,14 @@ pub(super) fn decode_request_inner(
let Value::Object(object) = value else {
return Err(RequestDecodeError::ExpectedObject);
};
decode_request_object(object, review, request_headers)
decode_request_object(object, request_headers, RequestDecodeSource::Http)
}
/// 解码已解析的顶层 object;WebSocket 帧解码复用此内核,避免序列化往返。
/// 解码已解析的顶层 object;按下游传输来源恢复连接级协议上下文。
pub(super) fn decode_request_object(
mut object: Map<String, Value>,
review: bool,
request_headers: &OpenAiRequestHeaders,
source: RequestDecodeSource,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
// 仅消费已识别的本地 transport 开关;未知同名值保留给未来上游协议。
let use_websocket = object.get("use_websocket").and_then(Value::as_bool);
@@ -354,8 +348,12 @@ pub(super) fn decode_request_object(
.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);
request_headers.apply_subagent(&mut object);
let frame_turn_metadata = match source {
RequestDecodeSource::Http => None,
RequestDecodeSource::WebSocketFrame => frame_turn_metadata(&object),
};
let protocol_context = request_headers.protocol_context(use_websocket, frame_turn_metadata);
let payload = ProtocolPayload::json_object(OPENAI_PROTOCOL, object)
.map_err(|_| RequestDecodeError::CanonicalContract {
@@ -366,8 +364,7 @@ pub(super) fn decode_request_object(
Ok(DecodedResponsesRequest {
operation,
metadata: ResponsesRequestMetadata {
requested_model: model.clone(),
public_model: model,
requested_model: model,
stream,
store,
continuation,
@@ -377,6 +374,16 @@ pub(super) fn decode_request_object(
})
}
fn frame_turn_metadata(body: &Map<String, Value>) -> Option<&String> {
body.get("client_metadata")
.and_then(Value::as_object)
.and_then(|metadata| metadata.get(CODEX_TURN_METADATA_KEY))
.and_then(|value| match value {
Value::String(value) if !value.trim().is_empty() => Some(value),
_ => None,
})
}
fn inject_subagent_metadata(body: &mut Map<String, Value>, subagent: &str) {
let metadata = body
.entry("client_metadata".to_owned())
@@ -470,10 +477,11 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
| "x-openai-project"
// installation ID 始终由当前 lease 重建。
| "x-codex-installation-id"
// 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent
// 不能透传覆盖,避免不同下游客户端暴露不一致的设备指纹。
// 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent/version
// 不能作为不透明头透传覆盖,避免不同下游客户端暴露不一致的设备指纹。
| "originator"
| "user-agent"
| "version"
// 设备 attestation/integrity 头只能由官方客户端或网关自身生成,
// 客户端注入的 x-oai-attestation / X-OAI-IS 不得透传上游。
| "x-oai-attestation"
@@ -1,699 +0,0 @@
//! 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;
}
@@ -0,0 +1,569 @@
//! 下游客户端 WebSocket 的单 owner pump 与有界收发边界。
use std::{
fmt,
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
},
time::Duration,
};
use axum::extract::ws::{CloseFrame, Message, WebSocket, close_code};
use futures::{Sink, SinkExt, Stream, StreamExt};
use gateway_core::lifecycle::CancellationToken;
use thiserror::Error;
use tokio::{
sync::{mpsc, oneshot},
task::JoinHandle,
time::{Instant, timeout},
};
const OUTBOUND_COMMAND_BUFFER: usize = 32;
const INBOUND_EVENT_BUFFER: usize = 32;
const DOWNSTREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
const CONNECTION_MAX_AGE: Duration = Duration::from_secs(60 * 60);
/// WebSocket pump 的写入与生命周期预算。
#[derive(Clone, Copy)]
pub struct ConnectionConfig {
write_timeout: Duration,
max_age: Duration,
}
impl ConnectionConfig {
/// 生产环境固定预算。
pub const PRODUCTION: Self = Self {
write_timeout: DOWNSTREAM_WRITE_TIMEOUT,
max_age: CONNECTION_MAX_AGE,
};
}
/// 业务层可观察的客户端输入;Ping/Pong 始终由 pump 消费。
pub enum ConnectionEvent {
Text(String),
Binary,
Expired,
Exited(PumpExitReason),
}
/// 下游写入阶段;名称刻意使用 write,而不是暗示客户端已消费的 delivery。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FramePhase {
Metadata,
First,
Data,
Terminal,
FirstAndTerminal,
Error,
ConnectionLimit,
Close,
}
impl FramePhase {
const fn as_str(self) -> &'static str {
match self {
Self::Metadata => "metadata",
Self::First => "first",
Self::Data => "data",
Self::Terminal => "terminal",
Self::FirstAndTerminal => "first_and_terminal",
Self::Error => "error",
Self::ConnectionLimit => "connection_limit",
Self::Close => "close",
}
}
const fn is_milestone(self) -> bool {
matches!(
self,
Self::First
| Self::Terminal
| Self::FirstAndTerminal
| Self::Error
| Self::ConnectionLimit
| Self::Close
)
}
}
/// 一次下游写入的请求归属与协议阶段。
#[derive(Clone)]
pub struct WriteContext {
request_id: Option<Arc<str>>,
phase: FramePhase,
}
impl WriteContext {
/// 创建归属于某个请求的写入上下文。
#[must_use]
pub fn request(request_id: &Arc<str>, phase: FramePhase) -> Self {
Self {
request_id: Some(Arc::clone(request_id)),
phase,
}
}
/// 创建连接级写入上下文。
#[must_use]
pub const fn connection(phase: FramePhase) -> Self {
Self {
request_id: None,
phase,
}
}
}
/// WebSocket pump 停止的稳定原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PumpExitReason {
ClientClose,
PeerEof,
ReadError,
WriteError,
WriteTimeout,
LifecycleShutdown,
ConnectionMaxAge,
CoordinatorDropped,
InboundOverload,
ServerClose,
PumpStopped,
}
impl PumpExitReason {
pub(super) const fn as_str(self) -> &'static str {
match self {
Self::ClientClose => "client_close",
Self::PeerEof => "peer_eof",
Self::ReadError => "read_error",
Self::WriteError => "write_error",
Self::WriteTimeout => "write_timeout",
Self::LifecycleShutdown => "lifecycle_shutdown",
Self::ConnectionMaxAge => "connection_max_age",
Self::CoordinatorDropped => "coordinator_dropped",
Self::InboundOverload => "inbound_overload",
Self::ServerClose => "server_close",
Self::PumpStopped => "pump_stopped",
}
}
}
/// 下游 WebSocket 写入失败。
#[derive(Debug, Error)]
pub enum ConnectionWriteError {
#[error("downstream WebSocket pump is closed")]
Closed,
#[error("downstream WebSocket write timed out after {timeout:?}")]
Timeout { timeout: Duration },
#[error("downstream WebSocket transport write failed: {message}")]
Transport { message: String },
}
struct ConnectionCommand {
message: Message,
context: WriteContext,
acknowledged: oneshot::Sender<Result<(), ConnectionWriteError>>,
}
#[derive(Default)]
struct ConnectionStats {
command_queue_high_water: AtomicUsize,
command_backpressure_count: AtomicU64,
ping_received_count: AtomicU64,
pong_written_count: AtomicU64,
}
impl ConnectionStats {
fn observe_command_queue(&self, sender: &mpsc::Sender<ConnectionCommand>) {
let queued = OUTBOUND_COMMAND_BUFFER
.saturating_sub(sender.capacity())
.saturating_add(1)
.min(OUTBOUND_COMMAND_BUFFER);
self.command_queue_high_water
.fetch_max(queued, Ordering::Relaxed);
if sender.capacity() == 0 {
self.command_backpressure_count
.fetch_add(1, Ordering::Relaxed);
}
}
}
/// 协调层持有的单 owner WebSocket 连接句柄。
pub struct ResponsesWebSocketConnection {
connection_id: Arc<str>,
opened_at: Instant,
expired: Arc<AtomicBool>,
commands: Option<mpsc::Sender<ConnectionCommand>>,
incoming: mpsc::Receiver<ConnectionEvent>,
pump_task: Option<JoinHandle<()>>,
stats: Arc<ConnectionStats>,
config: ConnectionConfig,
exit_reason: Option<PumpExitReason>,
}
impl ResponsesWebSocketConnection {
pub(super) fn new(
socket: WebSocket,
connection_id: String,
cancellation: CancellationToken,
) -> Self {
spawn_connection(
socket,
Arc::<str>::from(connection_id),
cancellation,
ConnectionConfig::PRODUCTION,
)
}
pub(super) fn id(&self) -> &str {
&self.connection_id
}
/// 返回连接是否已经达到生命周期上限。
#[must_use]
pub fn is_expired(&self) -> bool {
self.expired.load(Ordering::Acquire)
}
pub(super) fn age(&self) -> Duration {
self.opened_at.elapsed()
}
/// 等待下一个需要业务层处理的客户端事件。
pub async fn next_event(&mut self) -> Option<ConnectionEvent> {
let event = self.incoming.recv().await;
if let Some(ConnectionEvent::Exited(reason)) = event.as_ref() {
self.exit_reason.get_or_insert(*reason);
} else if event.is_none() {
self.exit_reason.get_or_insert(PumpExitReason::PumpStopped);
}
event
}
/// 串行写入文本帧,并等待 pump 确认 transport 写入结果。
///
/// # Errors
///
/// pump 已关闭、写入超时或 transport 失败时返回稳定错误。
pub async fn send_text(
&mut self,
payload: String,
context: WriteContext,
) -> Result<(), ConnectionWriteError> {
self.send(Message::Text(payload.into()), context).await
}
pub(super) async fn close_policy(
&mut self,
reason: &'static str,
request_id: Option<&Arc<str>>,
) {
let context = request_id.map_or_else(
|| WriteContext::connection(FramePhase::Close),
|request_id| WriteContext::request(request_id, FramePhase::Close),
);
self.close(
CloseFrame {
code: close_code::POLICY,
reason: reason.into(),
},
context,
PumpExitReason::ServerClose,
)
.await;
}
pub(super) async fn close_for_connection_limit(&mut self, reason: &'static str) {
self.close(
CloseFrame {
code: close_code::NORMAL,
reason: reason.into(),
},
WriteContext::connection(FramePhase::Close),
PumpExitReason::ConnectionMaxAge,
)
.await;
}
async fn close(
&mut self,
frame: CloseFrame,
context: WriteContext,
exit_reason: PumpExitReason,
) {
if self
.send(Message::Close(Some(frame)), context)
.await
.is_ok()
{
self.exit_reason.get_or_insert(exit_reason);
}
}
async fn send(
&mut self,
message: Message,
context: WriteContext,
) -> Result<(), ConnectionWriteError> {
let Some(commands) = self.commands.as_ref().cloned() else {
return Err(ConnectionWriteError::Closed);
};
self.stats.observe_command_queue(&commands);
let (acknowledged, acknowledgement) = oneshot::channel();
let started_at = Instant::now();
let request_id = context.request_id.clone();
let phase = context.phase;
let command = ConnectionCommand {
message,
context,
acknowledged,
};
let write = async move {
commands
.send(command)
.await
.map_err(|_| ConnectionWriteError::Closed)?;
acknowledgement
.await
.map_err(|_| ConnectionWriteError::Closed)?
};
let result = match timeout(self.config.write_timeout, write).await {
Ok(result) => result,
Err(_) => Err(ConnectionWriteError::Timeout {
timeout: self.config.write_timeout,
}),
};
let duration = started_at.elapsed();
match &result {
Ok(()) if phase.is_milestone() => tracing::info!(
websocket_connection_id = %self.connection_id,
request_id = request_id.as_deref().unwrap_or(""),
frame_phase = phase.as_str(),
write_duration_ms = duration.as_millis(),
"Responses WebSocket frame write succeeded"
),
Ok(()) => tracing::debug!(
websocket_connection_id = %self.connection_id,
request_id = request_id.as_deref().unwrap_or(""),
frame_phase = phase.as_str(),
write_duration_ms = duration.as_millis(),
"Responses WebSocket frame write succeeded"
),
Err(error) => tracing::info!(
websocket_connection_id = %self.connection_id,
request_id = request_id.as_deref().unwrap_or(""),
frame_phase = phase.as_str(),
write_duration_ms = duration.as_millis(),
error = %error,
"Responses WebSocket frame write failed"
),
}
if let Err(error) = &result {
let reason = match error {
ConnectionWriteError::Timeout { .. } => PumpExitReason::WriteTimeout,
ConnectionWriteError::Transport { .. } => PumpExitReason::WriteError,
ConnectionWriteError::Closed => PumpExitReason::PumpStopped,
};
self.terminate(reason);
}
result
}
fn terminate(&mut self, reason: PumpExitReason) {
self.exit_reason.get_or_insert(reason);
self.commands.take();
if let Some(task) = self.pump_task.take() {
task.abort();
}
}
pub(super) fn log_summary(&self, request_count: u64) {
tracing::info!(
websocket_connection_id = %self.connection_id,
request_count,
connection_age_ms = self.age().as_millis(),
pump_exit_reason = self
.exit_reason
.map_or("unknown", PumpExitReason::as_str),
command_queue_high_water = self
.stats
.command_queue_high_water
.load(Ordering::Relaxed),
command_backpressure_count = self
.stats
.command_backpressure_count
.load(Ordering::Relaxed),
ping_received_count = self.stats.ping_received_count.load(Ordering::Relaxed),
pong_written_count = self.stats.pong_written_count.load(Ordering::Relaxed),
"Responses WebSocket disconnected"
);
}
}
impl Drop for ResponsesWebSocketConnection {
fn drop(&mut self) {
self.commands.take();
if let Some(task) = self.pump_task.take() {
task.abort();
}
}
}
/// 为一个 socket 启动单 owner pump,并返回协调层句柄。
pub fn spawn_connection<S, E>(
socket: S,
connection_id: Arc<str>,
cancellation: CancellationToken,
config: ConnectionConfig,
) -> ResponsesWebSocketConnection
where
S: Stream<Item = Result<Message, E>> + Sink<Message, Error = E> + Unpin + Send + 'static,
E: fmt::Display + Send + 'static,
{
let opened_at = Instant::now();
let expired = Arc::new(AtomicBool::new(false));
let stats = Arc::new(ConnectionStats::default());
let (command_tx, command_rx) = mpsc::channel(OUTBOUND_COMMAND_BUFFER);
let (incoming_tx, incoming_rx) = mpsc::channel(INBOUND_EVENT_BUFFER);
let pump_task = tokio::spawn(run_pump(
socket,
command_rx,
incoming_tx,
Arc::clone(&connection_id),
cancellation,
opened_at,
Arc::clone(&expired),
Arc::clone(&stats),
config,
));
ResponsesWebSocketConnection {
connection_id,
opened_at,
expired,
commands: Some(command_tx),
incoming: incoming_rx,
pump_task: Some(pump_task),
stats,
config,
exit_reason: None,
}
}
#[expect(clippy::too_many_arguments)]
async fn run_pump<S, E>(
mut socket: S,
mut commands: mpsc::Receiver<ConnectionCommand>,
incoming: mpsc::Sender<ConnectionEvent>,
connection_id: Arc<str>,
cancellation: CancellationToken,
opened_at: Instant,
expired: Arc<AtomicBool>,
stats: Arc<ConnectionStats>,
config: ConnectionConfig,
) where
S: Stream<Item = Result<Message, E>> + Sink<Message, Error = E> + Unpin,
E: fmt::Display,
{
let deadline = tokio::time::sleep_until(opened_at + config.max_age);
tokio::pin!(deadline);
let mut deadline_elapsed = false;
let reason = loop {
tokio::select! {
biased;
() = cancellation.cancelled() => break PumpExitReason::LifecycleShutdown,
() = &mut deadline, if !deadline_elapsed => {
deadline_elapsed = true;
expired.store(true, Ordering::Release);
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Expired) {
break reason;
}
}
command = commands.recv() => {
let Some(command) = command else {
break PumpExitReason::CoordinatorDropped;
};
let ConnectionCommand {
message,
context,
acknowledged,
} = command;
let closing = matches!(message, Message::Close(_));
let result = socket.send(message).await.map_err(|error| {
let message = error.to_string();
tracing::debug!(
websocket_connection_id = %connection_id,
request_id = context.request_id.as_deref().unwrap_or(""),
frame_phase = context.phase.as_str(),
error = %message,
"Responses WebSocket pump transport write failed"
);
ConnectionWriteError::Transport { message }
});
let failed = result.is_err();
let _ = acknowledged.send(result);
if failed {
break PumpExitReason::WriteError;
}
if closing {
break PumpExitReason::ServerClose;
}
}
message = socket.next() => match message {
Some(Ok(Message::Text(payload))) => {
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Text(payload.to_string())) {
break reason;
}
}
Some(Ok(Message::Binary(_))) => {
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Binary) {
break reason;
}
}
Some(Ok(Message::Ping(payload))) => {
stats.ping_received_count.fetch_add(1, Ordering::Relaxed);
match timeout(config.write_timeout, socket.send(Message::Pong(payload))).await {
Ok(Ok(())) => {
stats.pong_written_count.fetch_add(1, Ordering::Relaxed);
}
Ok(Err(error)) => {
tracing::info!(
websocket_connection_id = %connection_id,
error = %error,
"Responses WebSocket Pong write failed"
);
break PumpExitReason::WriteError;
}
Err(_) => break PumpExitReason::WriteTimeout,
}
}
Some(Ok(Message::Pong(_))) => {}
Some(Ok(Message::Close(_))) => break PumpExitReason::ClientClose,
Some(Err(error)) => {
tracing::info!(
websocket_connection_id = %connection_id,
error = %error,
"Responses WebSocket receive failed"
);
break PumpExitReason::ReadError;
}
None => break PumpExitReason::PeerEof,
},
}
};
let _ = incoming.try_send(ConnectionEvent::Exited(reason));
tracing::debug!(
websocket_connection_id = %connection_id,
pump_exit_reason = reason.as_str(),
"Responses WebSocket pump exited"
);
}
fn emit_incoming(
incoming: &mpsc::Sender<ConnectionEvent>,
event: ConnectionEvent,
) -> Result<(), PumpExitReason> {
incoming.try_send(event).map_err(|error| match error {
mpsc::error::TrySendError::Full(_) => PumpExitReason::InboundOverload,
mpsc::error::TrySendError::Closed(_) => PumpExitReason::CoordinatorDropped,
})
}
@@ -0,0 +1,482 @@
//! 已启动 Responses execution 到客户端 WebSocket wire event 的串行转发。
use std::sync::Arc;
use axum::http::StatusCode;
use gateway_core::engine::execution::StartedExecution;
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::operation::ProviderSessionState;
use tokio::time::Instant;
use crate::openai::error::{gateway_error_contract, gateway_error_from_engine};
use super::{
super::{DecodedResponsesRequest, OpenAiResponsesEncoder, PendingExecution, ProtocolErrorBody},
connection::{ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext},
protocol::{error_event, response_metadata_event},
};
const ACTIVE_RESPONSE_VIOLATION: &str = "Only one response.create may be active per connection";
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum ForwardOutcome {
Continue,
Disconnect,
}
#[derive(Default)]
pub(super) struct ConnectionReplaySnapshot {
last_response_id: Option<String>,
provider_state: Option<ProviderSessionState>,
}
impl ConnectionReplaySnapshot {
pub(super) 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;
}
}
pub(super) async fn forward_execution(
connection: &mut ResponsesWebSocketConnection,
started: StartedExecution,
replay: &mut ConnectionReplaySnapshot,
) -> ForwardOutcome {
let request_id = Arc::<str>::from(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(connection, &error, &request_id).await;
}
let first = loop {
match next_active_input(connection, &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(connection, &error, &request_id).await;
}
ActiveInput::Event(Err(error)) => {
let error = gateway_error_from_engine(&error);
return send_gateway_error(connection, &error, &request_id).await;
}
ActiveInput::Control => continue,
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
ActiveInput::ProtocolViolation => {
connection
.close_policy(ACTIVE_RESPONSE_VIOLATION, Some(&request_id))
.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(connection, &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(connection, &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(connection, &error, &request_id).await;
}
let mut first_frame_written = false;
if encoder.is_completed() {
if let Err(outcome) =
confirm_completed_execution(connection, &mut execution, &request_id).await
{
return outcome;
}
let provider_terminal_at = Instant::now();
commit_connection_replay(replay, &encoder, provider_state.take());
let writes_succeeded = send_metadata(
connection,
&request_id,
response_metadata_event(&request_id, &response_headers),
)
.await
&& send_messages(
connection,
first_messages,
&request_id,
&mut first_frame_written,
true,
)
.await;
if !writes_succeeded {
log_terminal_write_failure(connection, &request_id, provider_terminal_at.elapsed());
return ForwardOutcome::Disconnect;
}
log_terminal_write_success(connection, &request_id, provider_terminal_at.elapsed());
execution.disarm();
return ForwardOutcome::Continue;
}
if !send_metadata(
connection,
&request_id,
response_metadata_event(&request_id, &response_headers),
)
.await
|| !send_messages(
connection,
first_messages,
&request_id,
&mut first_frame_written,
encoder.has_wire_failure(),
)
.await
{
return ForwardOutcome::Disconnect;
}
loop {
match next_active_input(connection, &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(connection, &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(connection, &mut execution, &request_id).await
{
return outcome;
}
let provider_terminal_at = Instant::now();
commit_connection_replay(replay, &encoder, provider_state.take());
if !send_messages(
connection,
messages,
&request_id,
&mut first_frame_written,
true,
)
.await
{
log_terminal_write_failure(
connection,
&request_id,
provider_terminal_at.elapsed(),
);
return ForwardOutcome::Disconnect;
}
log_terminal_write_success(
connection,
&request_id,
provider_terminal_at.elapsed(),
);
execution.disarm();
return ForwardOutcome::Continue;
}
let terminal_batch = encoder.has_wire_failure();
if !send_messages(
connection,
messages,
&request_id,
&mut first_frame_written,
terminal_batch,
)
.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(connection, &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(connection, &error, &request_id).await;
}
ActiveInput::Control => {}
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
ActiveInput::ProtocolViolation => {
connection
.close_policy(ACTIVE_RESPONSE_VIOLATION, Some(&request_id))
.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(
connection: &mut ResponsesWebSocketConnection,
execution: &mut PendingExecution,
request_id: &Arc<str>,
) -> Result<(), ForwardOutcome> {
loop {
match next_active_input(connection, 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(connection, &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(connection, &error, request_id).await);
}
ActiveInput::Event(Err(error)) => {
let error = gateway_error_from_engine(&error);
return Err(send_gateway_error(connection, &error, request_id).await);
}
ActiveInput::Control => {}
ActiveInput::Disconnect => return Err(ForwardOutcome::Disconnect),
ActiveInput::ProtocolViolation => {
connection
.close_policy(ACTIVE_RESPONSE_VIOLATION, Some(request_id))
.await;
return Err(ForwardOutcome::Disconnect);
}
}
}
}
enum ActiveInput {
Event(Result<Option<CoordinatedEvent>, EngineError>),
Control,
Disconnect,
ProtocolViolation,
}
async fn next_active_input(
connection: &mut ResponsesWebSocketConnection,
execution: &mut PendingExecution,
) -> ActiveInput {
let Some(session) = execution.session_mut() else {
return ActiveInput::Disconnect;
};
tokio::select! {
event = session.next_event() => ActiveInput::Event(event),
message = connection.next_event() => match message {
Some(ConnectionEvent::Expired) => ActiveInput::Control,
Some(ConnectionEvent::Text(_) | ConnectionEvent::Binary) => {
ActiveInput::ProtocolViolation
}
Some(ConnectionEvent::Exited(_)) | None => ActiveInput::Disconnect,
},
}
}
pub(super) async fn send_gateway_error(
connection: &mut ResponsesWebSocketConnection,
error: &GatewayError,
request_id: &Arc<str>,
) -> ForwardOutcome {
let (status, default_type, default_code) = gateway_error_contract(error.kind());
send_error(
connection,
status,
error.client_error_type().unwrap_or(default_type),
error.client_error_code().unwrap_or(default_code),
error.client_message(),
None,
request_id,
)
.await
}
pub(super) async fn send_protocol_error(
connection: &mut ResponsesWebSocketConnection,
status: StatusCode,
body: ProtocolErrorBody,
request_id: &Arc<str>,
) -> ForwardOutcome {
let error = body.error;
send_error(
connection,
status,
error.kind,
error.code,
&error.message,
error.param.as_deref(),
request_id,
)
.await
}
async fn send_error(
connection: &mut ResponsesWebSocketConnection,
status: StatusCode,
error_type: &str,
code: &str,
message: &str,
param: Option<&str>,
request_id: &Arc<str>,
) -> ForwardOutcome {
if connection
.send_text(
error_event(status, error_type, code, message, param, Some(request_id)),
WriteContext::request(request_id, FramePhase::Error),
)
.await
.is_ok()
{
ForwardOutcome::Continue
} else {
ForwardOutcome::Disconnect
}
}
async fn send_metadata(
connection: &mut ResponsesWebSocketConnection,
request_id: &Arc<str>,
message: String,
) -> bool {
connection
.send_text(
message,
WriteContext::request(request_id, FramePhase::Metadata),
)
.await
.is_ok()
}
async fn send_messages(
connection: &mut ResponsesWebSocketConnection,
messages: Vec<String>,
request_id: &Arc<str>,
first_frame_written: &mut bool,
terminal_batch: bool,
) -> bool {
let message_count = messages.len();
for (index, message) in messages.into_iter().enumerate() {
let is_terminal = terminal_batch && index + 1 == message_count;
let phase = match (*first_frame_written, is_terminal) {
(false, true) => FramePhase::FirstAndTerminal,
(false, false) => FramePhase::First,
(true, true) => FramePhase::Terminal,
(true, false) => FramePhase::Data,
};
if connection
.send_text(message, WriteContext::request(request_id, phase))
.await
.is_err()
{
return false;
}
*first_frame_written = true;
}
true
}
fn log_terminal_write_success(
connection: &ResponsesWebSocketConnection,
request_id: &Arc<str>,
provider_terminal_to_write: std::time::Duration,
) {
tracing::info!(
websocket_connection_id = connection.id(),
request_id = %request_id,
provider_terminal_to_terminal_write_ms = provider_terminal_to_write.as_millis(),
terminal_frame_written = true,
"Provider execution terminal was written to the WebSocket transport"
);
}
fn log_terminal_write_failure(
connection: &ResponsesWebSocketConnection,
request_id: &Arc<str>,
provider_terminal_to_write: std::time::Duration,
) {
tracing::info!(
websocket_connection_id = connection.id(),
request_id = %request_id,
provider_terminal_to_terminal_write_ms = provider_terminal_to_write.as_millis(),
provider_succeeded_but_terminal_write_failed = true,
"Provider execution succeeded before the terminal WebSocket frame write failed"
);
}
@@ -0,0 +1,248 @@
//! OpenAI Responses WebSocket 的升级、连接与串行 session 编排。
pub mod connection;
mod forward;
mod protocol;
use std::{
net::{IpAddr, SocketAddr},
sync::Arc,
};
use axum::{
extract::{
Extension, State,
connect_info::ConnectInfo,
ws::{WebSocket, WebSocketUpgrade},
},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use gateway_core::{
engine::execution::{AuthenticatedClient, ClientTransport},
lifecycle::{ConnectionGuard, ConnectionLifecycle},
};
use crate::{
ApiState,
openai::{
auth::{authenticate_client, client_access_error_response},
error::runtime_unavailable_response,
service::OpenAiService,
},
};
use super::{http::request_client_context, request::OpenAiRequestHeaders};
use connection::{ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext};
use forward::{
ConnectionReplaySnapshot, ForwardOutcome, forward_execution, send_gateway_error,
send_protocol_error,
};
use protocol::connection_limit_event;
pub use protocol::{ResponseCreateFrameError, decode_response_create_with_context};
const TEXT_FRAMES_ONLY: &str = "Responses WebSocket accepts text frames only";
const CONNECTION_LIMIT_CLOSE_REASON: &str = "Responses websocket connection limit reached";
/// 将已认证的 `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 client_access_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,
};
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(socket: WebSocket, session: ResponsesWebSocketSession) {
let ResponsesWebSocketSession {
service,
client,
connection_id,
client_ip,
user_agent,
request_headers,
lifecycle,
connection_guard,
} = session;
tracing::info!(
websocket_connection_id = %connection_id,
"Responses WebSocket connected"
);
let _connection_guard = connection_guard;
let cancellation = lifecycle.cancellation();
let mut connection = ResponsesWebSocketConnection::new(socket, connection_id, cancellation);
let mut request_count = 0_u64;
let mut replay = ConnectionReplaySnapshot::default();
loop {
let Some(event) = connection.next_event().await else {
break;
};
let payload = match event {
ConnectionEvent::Text(payload) => payload,
ConnectionEvent::Binary => {
connection.close_policy(TEXT_FRAMES_ONLY, None).await;
break;
}
ConnectionEvent::Expired => {
expire_connection(&mut connection).await;
break;
}
ConnectionEvent::Exited(_) => break,
};
if connection.is_expired() {
expire_connection(&mut connection).await;
break;
}
request_count = request_count.saturating_add(1);
let correlation_id = Arc::<str>::from(service.next_request_id());
let decoded = match decode_response_create_with_context(&payload, &request_headers) {
Ok(decoded) => decoded.with_client_context(client_ip, user_agent.clone()),
Err(error) => {
tracing::info!(
websocket_connection_id = connection.id(),
request_id = %correlation_id,
error = %error,
"Responses WebSocket request rejected"
);
if send_protocol_error(
&mut connection,
StatusCode::BAD_REQUEST,
error.protocol_body(),
&correlation_id,
)
.await
== ForwardOutcome::Disconnect
{
break;
}
continue;
}
};
let decoded = replay.prepare(decoded);
// deadline 与 Text 可能同时就绪;在任何上游执行开始前再次封住该竞争窗口。
if connection.is_expired() {
expire_connection(&mut connection).await;
break;
}
let started = match service
.start_response(
client.clone(),
decoded,
ClientTransport::WebSocket,
"/v1/responses",
)
.await
{
Ok(started) => started,
Err(error) => {
if send_gateway_error(&mut connection, &error, &correlation_id).await
== ForwardOutcome::Disconnect
{
break;
}
continue;
}
};
if forward_execution(&mut connection, started, &mut replay).await
== ForwardOutcome::Disconnect
{
break;
}
if connection.is_expired() {
expire_connection(&mut connection).await;
break;
}
}
connection.log_summary(request_count);
}
async fn expire_connection(connection: &mut ResponsesWebSocketConnection) {
if connection
.send_text(
connection_limit_event(),
WriteContext::connection(FramePhase::ConnectionLimit),
)
.await
.is_ok()
{
connection
.close_for_connection_limit(CONNECTION_LIMIT_CLOSE_REASON)
.await;
}
}
@@ -0,0 +1,156 @@
//! Responses WebSocket 入站请求与下行事件的纯协议映射。
use axum::http::{HeaderName, StatusCode};
use gateway_core::event::ProviderResponseHeader;
use serde_json::{Map, Value, json};
use thiserror::Error;
use super::super::{
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError,
request::{OpenAiRequestHeaders, RequestDecodeSource},
};
/// 使用连接级请求头和 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::super::request::decode_request_object(
body,
request_headers,
RequestDecodeSource::WebSocketFrame,
)
.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 {
pub(super) 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(),
}
}
}
pub(super) fn response_metadata_event(
request_id: &str,
response_headers: &[ProviderResponseHeader],
) -> String {
let connection_options = super::super::response_connection_options(response_headers);
let mut headers = Map::new();
for header in response_headers {
if !super::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()
}
pub(super) fn error_event(
status: StatusCode,
error_type: &str,
code: &str,
message: &str,
param: Option<&str>,
request_id: Option<&str>,
) -> String {
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()));
}
let mut event = Map::new();
event.insert("type".to_owned(), Value::String("error".to_owned()));
event.insert("status".to_owned(), Value::Number(status.as_u16().into()));
event.insert("error".to_owned(), Value::Object(error));
if let Some(request_id) = request_id {
event.insert(
"request_id".to_owned(),
Value::String(request_id.to_owned()),
);
}
Value::Object(event).to_string()
}
pub(super) fn connection_limit_event() -> String {
error_event(
StatusCode::BAD_REQUEST,
"invalid_request_error",
"websocket_connection_limit_reached",
"Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue.",
None,
None,
)
}
@@ -7,8 +7,10 @@ use axum::{
};
use super::{
models::{model_catalog, model_detail, model_info, models},
responses::{responses, responses_websocket, review_responses},
images::{image_edits, image_generations},
models::{model_detail, models},
responses::{responses, responses_websocket},
search::standalone_search,
};
use crate::ApiState;
@@ -16,14 +18,14 @@ use crate::ApiState;
/// 构造 OpenAI 客户端协议路由。
pub(crate) fn router() -> Router<ApiState> {
Router::new()
.route("/v1/images/generations", post(image_generations))
.route("/v1/images/edits", post(image_edits))
.route("/v1/alpha/search", post(standalone_search))
.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}/info", get(model_info))
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
.route("/v1/models/{model_id}", get(model_detail))
// Responses 正文属于 OpenAI/Codex 协议;代理不能用私有大小上限提前拒绝
// OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝
// 上游本可接受的未来 payload。
.layer(DefaultBodyLimit::disable())
}
@@ -0,0 +1,78 @@
//! Codex standalone search 非流式 HTTP adapter。
use std::net::SocketAddr;
use axum::{
body::Bytes,
extract::{Extension, State, connect_info::ConnectInfo},
http::HeaderMap,
response::Response,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::operation::{Operation, RawJsonPayload, StandaloneSearchRequest};
use serde_json::{Map, Value};
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, client_access_error_response},
error::gateway_error_response,
provider_endpoint::collect_raw_json_response,
responses::request_client_context,
};
const OPENAI_PROTOCOL: &str = "openai";
const TURN_METADATA_CONTEXT_KEY: &str = "turn_metadata";
/// `POST /v1/alpha/search`。
pub(crate) async fn standalone_search(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
let (client_ip, user_agent) = request_client_context(
&headers,
connect_info.map(|Extension(ConnectInfo(address))| address),
);
let operation = match search_operation(body, &headers) {
Ok(operation) => operation,
Err(error) => return gateway_error_response(&error),
};
let started = match service
.start_provider_endpoint(client, operation, client_ip, user_agent, "/v1/alpha/search")
.await
{
Ok(started) => started,
Err(error) => return gateway_error_response(&error),
};
collect_raw_json_response(started).await
}
fn search_operation(body: Bytes, headers: &HeaderMap) -> Result<Operation, GatewayError> {
let mut context = Map::new();
if let Some(turn_metadata) = headers
.get("x-codex-turn-metadata")
.and_then(|value| value.to_str().ok())
{
context.insert(
TURN_METADATA_CONTEXT_KEY.to_owned(),
Value::String(turn_metadata.to_owned()),
);
}
let payload = RawJsonPayload::new(OPENAI_PROTOCOL, body)
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::Internal,
"OpenAI protocol identifier is invalid",
)
})?
.with_context(context);
Ok(Operation::Search(StandaloneSearchRequest::from_raw_json(
payload,
)))
}
@@ -0,0 +1,5 @@
//! Codex standalone search HTTP adapter。
mod http;
pub(crate) use http::standalone_search;
@@ -1,11 +1,12 @@
//! OpenAI wire adapter 到 Core 执行用例的唯一映射。
use std::net::IpAddr;
use std::sync::Arc;
use gateway_core::engine::continuation::PreviousResponseId;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionRequestMetadata,
ExecutionService, StartExecution, StartedExecution,
ExecutionService, StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
@@ -74,13 +75,13 @@ impl OpenAiService {
endpoint: &'static str,
) -> Result<StartedExecution, GatewayError> {
let (operation, metadata) = request.into_parts();
let public_model = PublicModelId::from_client_wire(metadata.public_model().to_owned())
let public_model = PublicModelId::from_client_wire(metadata.requested_model().to_owned())
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::ModelNotFound,
"requested model was not found",
)
})?;
GatewayError::new(
GatewayErrorKind::ModelNotFound,
"requested model was not found",
)
})?;
let previous_response_id = match metadata.continuation() {
ContinuationIntent::None => None,
ContinuationIntent::PreviousResponseId(value) => {
@@ -105,6 +106,38 @@ impl OpenAiService {
.await
}
pub(crate) async fn start_provider_endpoint(
&self,
client: AuthenticatedClient,
operation: gateway_core::operation::Operation,
client_ip: Option<IpAddr>,
user_agent: Option<String>,
endpoint: &'static str,
) -> Result<StartedExecution, GatewayError> {
let provider = gateway_core::routing::ProviderKind::new("openai").map_err(|_| {
GatewayError::new(
GatewayErrorKind::Internal,
"OpenAI provider identifier is invalid",
)
})?;
self.execution
.start_provider_endpoint(StartProviderExecution {
client,
provider,
operation,
metadata: ExecutionRequestMetadata {
protocol: "openai".to_owned(),
endpoint: endpoint.to_owned(),
transport: ClientTransport::HttpJson,
stream: false,
client_ip,
user_agent,
previous_response_id: None,
},
})
.await
}
pub(crate) fn try_register_connection(
&self,
) -> Result<Box<dyn ConnectionGuard>, ConnectionDraining> {
@@ -0,0 +1,274 @@
use axum::{
Router,
body::{Body, to_bytes},
http::{Method, Request, StatusCode, header},
};
use gateway_api::admin::account_groups;
use serde_json::{Value, json};
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState, PRIMARY_GROUP_ID};
const SESSION_COOKIE: &str = "cpr_admin_session=valid-session";
#[tokio::test]
async fn list_route_should_keep_camel_case_group_and_page_wire() {
let fixture = authenticated_fixture().await;
let response = request(
router(&fixture),
Method::GET,
"/api/admin/account-groups?page=1&pageSize=1&search=alpha&enabled=true",
None,
true,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let value = response_json(response).await;
assert_eq!(value["data"]["page"]["page"], 1);
assert_eq!(value["data"]["page"]["pageSize"], 1);
assert_eq!(value["data"]["page"]["total"], 1);
assert_eq!(value["data"]["page"]["totalPages"], 1);
assert_eq!(value["data"]["configRevision"], 7);
assert_eq!(value["data"]["items"][0]["id"], PRIMARY_GROUP_ID);
assert_eq!(value["data"]["items"][0]["memberCount"], 2);
assert_eq!(value["data"]["items"][0]["providerCounts"]["openai"], 1);
assert_eq!(value["data"]["items"][0]["clientKeyCount"], 2);
assert_eq!(value["data"]["items"][0]["accountSummary"]["available"], 1);
assert_eq!(value["data"]["items"][0]["accountSummary"]["limited"], 1);
assert_eq!(value["data"]["items"][0]["accountSummary"]["total"], 2);
assert_eq!(value["data"]["items"][0]["capacity"]["usedSlots"], 0);
assert_eq!(value["data"]["items"][0]["capacity"]["totalSlots"], 1);
assert_eq!(value["data"]["items"][0]["usage"]["todayUsd"], "1.25");
assert_eq!(
value["data"]["items"][0]["usage"]["retainedTotalUsd"],
"5.5"
);
assert!(value["data"]["items"][0].get("member_count").is_none());
}
#[tokio::test]
async fn create_route_should_create_an_empty_group() {
let fixture = authenticated_fixture().await;
let response = request(
router(&fixture),
Method::POST,
"/api/admin/account-groups/create",
Some(json!({
"name": "Gamma routing",
"description": "New traffic pool",
"color": "#a855f780"
})),
true,
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
let value = response_json(response).await;
assert!(
value["data"]["id"]
.as_str()
.is_some_and(|id| id.starts_with("grp_") && id.len() == 36)
);
assert_eq!(value["data"]["record"]["name"], "Gamma routing");
assert_eq!(value["data"]["record"]["memberCount"], 0);
assert_eq!(value["data"]["record"]["color"], "#A855F780");
assert_eq!(value["data"]["configRevision"], 8);
}
#[tokio::test]
async fn update_route_should_replace_group_fields() {
let fixture = authenticated_fixture().await;
let response = request(
router(&fixture),
Method::POST,
"/api/admin/account-groups/update",
Some(json!({
"id": PRIMARY_GROUP_ID,
"name": "Renamed routing",
"description": null,
"color": "#F43F5ECC"
})),
true,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let value = response_json(response).await;
assert_eq!(value["data"]["record"]["name"], "Renamed routing");
assert!(value["data"]["record"]["description"].is_null());
assert_eq!(value["data"]["record"]["color"], "#F43F5ECC");
}
#[tokio::test]
async fn enable_and_disable_routes_should_set_explicit_group_state() {
let fixture = authenticated_fixture().await;
let disabled = request(
router(&fixture),
Method::POST,
"/api/admin/account-groups/disable",
Some(json!({ "id": PRIMARY_GROUP_ID })),
true,
)
.await;
let enabled = request(
router(&fixture),
Method::POST,
"/api/admin/account-groups/enable",
Some(json!({ "id": PRIMARY_GROUP_ID })),
true,
)
.await;
assert_eq!(disabled.status(), StatusCode::OK);
assert!(
!response_json(disabled).await["data"]["record"]["enabled"]
.as_bool()
.expect("disabled flag")
);
assert_eq!(enabled.status(), StatusCode::OK);
assert!(
response_json(enabled).await["data"]["record"]["enabled"]
.as_bool()
.expect("enabled flag")
);
}
#[tokio::test]
async fn delete_route_should_return_deleted_id_without_a_live_record() {
let fixture = authenticated_fixture().await;
let response = request(
router(&fixture),
Method::POST,
"/api/admin/account-groups/delete",
Some(json!({ "id": PRIMARY_GROUP_ID })),
true,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let value = response_json(response).await;
assert_eq!(value["data"]["id"], PRIMARY_GROUP_ID);
assert!(value["data"]["record"].is_null());
}
#[tokio::test]
async fn list_route_should_reject_unknown_and_invalid_pagination_fields() {
let fixture = authenticated_fixture().await;
for uri in [
"/api/admin/account-groups?other=true",
"/api/admin/account-groups?page=0",
"/api/admin/account-groups?pageSize=0",
"/api/admin/account-groups?pageSize=201",
] {
let response = request(router(&fixture), Method::GET, uri, None, true).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{uri}");
}
}
#[tokio::test]
async fn mutation_route_should_reject_unknown_json_fields() {
let fixture = authenticated_fixture().await;
let response = request(
router(&fixture),
Method::POST,
"/api/admin/account-groups/create",
Some(json!({
"name": "Unknown field",
"description": null,
"color": "#2563EBFF",
"providerKind": "openai"
})),
true,
)
.await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn create_route_should_reject_six_digit_color_without_alpha() {
let fixture = authenticated_fixture().await;
let response = request(
router(&fixture),
Method::POST,
"/api/admin/account-groups/create",
Some(json!({
"name": "Opaque legacy color",
"description": null,
"color": "#2563EB"
})),
true,
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn account_group_routes_should_require_admin_authentication() {
let fixture = AdminTestFixture::new().await;
let get_response = request(
router(&fixture),
Method::GET,
"/api/admin/account-groups",
None,
false,
)
.await;
let post_response = request(
router(&fixture),
Method::POST,
"/api/admin/account-groups/create",
Some(json!({ "name": "Unauthorized", "description": null, "color": "#2563EBFF" })),
false,
)
.await;
assert_eq!(get_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(post_response.status(), StatusCode::UNAUTHORIZED);
}
async fn authenticated_fixture() -> AdminTestFixture {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
fixture
}
fn router(fixture: &AdminTestFixture) -> Router {
account_groups::router::<AdminTestState>().with_state(fixture.state())
}
async fn request(
router: Router,
method: Method,
uri: &str,
body: Option<Value>,
authenticated: bool,
) -> axum::response::Response {
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header("x-request-id", "req_account_groups");
if authenticated {
builder = builder.header(header::COOKIE, SESSION_COOKIE);
}
let body = match body {
Some(value) => {
builder = builder.header(header::CONTENT_TYPE, "application/json");
Body::from(serde_json::to_vec(&value).expect("serialize request body"))
}
None => Body::empty(),
};
router
.oneshot(builder.body(body).expect("account group request"))
.await
.expect("account group response")
}
async fn response_json(response: axum::response::Response) -> Value {
let body = to_bytes(response.into_body(), 64 * 1024)
.await
.expect("account group response body");
serde_json::from_slice(&body).expect("account group response JSON")
}
@@ -70,8 +70,272 @@ mod query {
}
}
mod profile_statistics {
use chrono::NaiveDate;
use gateway_admin::model::provider_credentials::{
ProviderProfileActivityInsights, ProviderProfileDailyUsage, ProviderProfileInvocation,
ProviderProfileStatistics, ProviderProfileStatisticsSummary,
};
use gateway_api::admin::accounts::{AccountProfileAvatarQuery, AccountProfileStatisticsData};
use serde_json::json;
#[test]
fn profile_statistics_response_preserves_nullable_official_fields() {
let response = AccountProfileStatisticsData::from(ProviderProfileStatistics {
display_name: Some("Ada".to_owned()),
username: Some("ada".to_owned()),
image_url: Some("https://example.test/avatar.png".to_owned()),
has_stats_error: false,
summary: ProviderProfileStatisticsSummary {
total_text_tokens: Some(409_500_000),
peak_tokens: Some(267_000_000),
longest_task_duration_ms: Some(51_900_000),
current_streak_days: Some(16),
longest_streak_days: Some(32),
},
daily_usage: Some(vec![ProviderProfileDailyUsage {
date: NaiveDate::from_ymd_opt(2026, 8, 25).expect("usage date"),
tokens: 42,
}]),
activity_insights: ProviderProfileActivityInsights {
fast_mode_percent: Some(0.0),
reasoning_effort: Some("high".to_owned()),
reasoning_effort_percent: Some(48.0),
skills_explored: Some(8),
total_skills_used: Some(61),
total_threads: Some(2_391),
invocations: Some(vec![ProviderProfileInvocation {
invocation_type: "plugin".to_owned(),
plugin_id: Some("plugin_1".to_owned()),
plugin_name: Some("example".to_owned()),
skill_id: None,
skill_name: None,
usage_count: Some(29),
}]),
},
});
let value = serde_json::to_value(response).expect("serialize profile statistics");
assert_eq!(value["displayName"], "Ada");
assert_eq!(value["summary"]["totalTextTokens"], 409_500_000);
assert_eq!(
value["dailyUsage"][0],
json!({"date": "2026-08-25", "tokens": 42})
);
assert_eq!(
value["activityInsights"]["invocations"][0]["type"],
"plugin"
);
assert_eq!(
value["activityInsights"]["invocations"][0]["usageCount"],
29
);
assert!(value.get("cycle").is_none());
assert!(value.get("models").is_none());
assert!(value.get("estimatedCost").is_none());
}
#[test]
fn profile_avatar_query_accepts_only_bounded_cache_versions() {
let valid: AccountProfileAvatarQuery = serde_json::from_value(json!({
"accountId": "acct_openai",
"version": "k9m2z1"
}))
.expect("decode avatar query");
valid.validate().expect("validate avatar query");
for version in ["", "contains-dash", "x23456789012345678901234567890123"] {
let query: AccountProfileAvatarQuery = serde_json::from_value(json!({
"accountId": "acct_openai",
"version": version
}))
.expect("decode invalid avatar query");
assert_eq!(
query.validate().expect_err("reject version").field(),
"version"
);
}
assert!(
serde_json::from_value::<AccountProfileAvatarQuery>(json!({
"accountId": "acct_openai",
"sourceUrl": "https://example.test/avatar"
}))
.is_err()
);
}
}
mod profile_avatar {
use axum::body::to_bytes;
use bytes::Bytes;
use futures::stream;
use gateway_admin::model::provider_credentials::ProviderProfileAvatar;
use gateway_api::admin::accounts::profile_avatar_response;
#[tokio::test]
async fn profile_avatar_response_preserves_stream_metadata_and_private_cache() {
let response = profile_avatar_response(ProviderProfileAvatar {
content_type: Some("image/svg+xml".to_owned()),
content_length: Some(6),
etag: Some("\"v1\"".to_owned()),
body: Box::pin(stream::iter([Ok(Bytes::from_static(b"avatar"))])),
});
assert_eq!(response.status(), 200);
assert_eq!(response.headers()["content-type"], "image/svg+xml");
assert_eq!(response.headers()["content-length"], "6");
assert_eq!(response.headers()["etag"], "\"v1\"");
assert_eq!(response.headers()["cache-control"], "private, max-age=3600");
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
assert_eq!(
response.headers()["cross-origin-resource-policy"],
"same-origin"
);
assert_eq!(
response.headers()["content-security-policy"],
"sandbox; default-src 'none'"
);
assert_eq!(
to_bytes(response.into_body(), 16)
.await
.expect("avatar response body"),
Bytes::from_static(b"avatar")
);
}
}
mod batch_update {
use gateway_api::admin::accounts::{BatchUpdateAccountsRequest, UpdateAccountRequest};
use serde_json::json;
#[test]
fn batch_update_should_accept_complete_atomic_payload() {
let request: BatchUpdateAccountsRequest = serde_json::from_value(json!({
"accountIds": ["acct_openai", "acct_xai"],
"enabled": false,
"concurrencyLimit": null,
"weight": 1,
"groupIds": ["grp_00000000000000000000000000000001"]
}))
.expect("deserialize batch update");
request.validate().expect("validate batch update");
}
#[test]
fn batch_update_should_reject_duplicate_or_invalid_account_ids() {
for account_ids in [
json!(["acct_same", "acct_same"]),
json!(["not-an-account"]),
json!([]),
] {
let request: BatchUpdateAccountsRequest = serde_json::from_value(json!({
"accountIds": account_ids,
"enabled": true,
"concurrencyLimit": 8,
"weight": 100,
"groupIds": []
}))
.expect("deserialize invalid batch update");
assert_eq!(
request.validate().expect_err("reject account IDs").field(),
"accountIds"
);
}
}
#[test]
fn batch_update_should_require_enabled_and_reject_unknown_fields() {
assert!(
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
"accountIds": ["acct_test"],
"concurrencyLimit": null,
"weight": 1,
"groupIds": []
}))
.is_err()
);
assert!(
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
"accountIds": ["acct_test"],
"enabled": true,
"concurrencyLimit": null,
"weight": 1,
"groupIds": [],
"legacy": true
}))
.is_err()
);
}
#[test]
fn batch_update_should_reject_invalid_scheduling_bounds_and_missing_fields() {
for (concurrency_limit, weight, field) in [
(json!(0), json!(1), "concurrencyLimit"),
(json!(4294967296_u64), json!(1), "concurrencyLimit"),
(json!(null), json!(0), "weight"),
(json!(null), json!(101), "weight"),
] {
let request: BatchUpdateAccountsRequest = serde_json::from_value(json!({
"accountIds": ["acct_test"],
"enabled": true,
"concurrencyLimit": concurrency_limit,
"weight": weight,
"groupIds": []
}))
.expect("deserialize invalid scheduling");
assert_eq!(
request.validate().expect_err("reject scheduling").field(),
field
);
}
assert!(
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
"accountIds": ["acct_test"],
"enabled": true,
"weight": 1,
"groupIds": []
}))
.is_err()
);
assert!(
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
"accountIds": ["acct_test"],
"enabled": true,
"concurrencyLimit": null,
"groupIds": []
}))
.is_err()
);
}
#[test]
fn single_update_should_require_the_same_complete_scheduling_contract() {
let request: UpdateAccountRequest = serde_json::from_value(json!({
"accountId": "acct_test",
"enabled": true,
"concurrencyLimit": 4294967295_u64,
"weight": 100,
"groupIds": []
}))
.expect("deserialize single update");
request.validate().expect("validate single update");
assert!(
serde_json::from_value::<UpdateAccountRequest>(json!({
"accountId": "acct_test",
"enabled": true,
"weight": 1,
"groupIds": []
}))
.is_err()
);
}
}
mod response {
use gateway_api::admin::accounts::AccountUsageView;
use gateway_api::admin::presenter::format_decimal_currency;
#[test]
fn account_usage_view_should_keep_unobserved_numbers_null() {
@@ -112,12 +376,22 @@ mod response {
assert!(value["totalTokens"].is_null());
assert_eq!(value["createdTokensDisplay"], "-");
}
#[test]
fn account_usage_currency_should_limit_usd_to_four_fraction_digits() {
assert_eq!(format_decimal_currency("0.1204956", "USD"), "$0.1205");
assert_eq!(format_decimal_currency("0.99996", "USD"), "$1.00");
assert_eq!(format_decimal_currency("0.0300", "USD"), "$0.03");
assert_eq!(format_decimal_currency("12.3456", "USD"), "$12.35");
assert_eq!(format_decimal_currency("0.1204956", "CNY"), "CNY 0.1204956");
}
}
mod actions {
use base64::Engine as _;
use gateway_admin::model::{
Revision,
accounts::{AccountConnectionTestEvent as DomainConnectionTestEvent, AccountStatus},
accounts::AccountConnectionTestEvent as DomainConnectionTestEvent,
provider_credentials::{
CredentialDeletionResult, CredentialImportResult, CredentialMutationResult,
},
@@ -126,9 +400,13 @@ mod actions {
AccountActionRequest, AccountConnectionTestEvent, AccountDeletionData,
AccountDeletionRequest, AccountExportData, AccountExportQuery, AccountIdQuery,
AccountImportData, AccountImportRequest, AccountMutationData, AccountRefreshRequest,
AccountTestQuery, RotateAccountRequest, StartAccountAuthorizationRequest,
AccountResetCreditConsumeRequest, AccountTestQuery, CompleteAccountAuthorizationRequest,
RotateAccountRequest, StartAccountAuthorizationRequest,
};
use gateway_core::{
account::ProviderAccountId, engine::probe::AccountProbeErrorSource,
error::GatewayErrorKind, upstream::UpstreamSendState,
};
use gateway_core::engine::credential::ProviderAccountId;
use serde_json::json;
#[test]
@@ -184,6 +462,44 @@ mod actions {
);
}
#[test]
fn reset_credit_consume_should_require_canonical_v4_idempotency_key() {
let valid: AccountResetCreditConsumeRequest = serde_json::from_value(json!({
"accountId": "acct_1",
"creditId": "credit_1",
"redeemRequestId": "8fbf302d-11df-4bd5-82e4-08e4b3df7874"
}))
.expect("decode reset-credit consume");
valid.validate().expect("validate reset-credit consume");
for (redeem_request_id, field) in [
("019c0000-0000-7000-8000-000000000000", "redeemRequestId"),
("8FBF302D-11DF-4BD5-82E4-08E4B3DF7874", "redeemRequestId"),
("invalid", "redeemRequestId"),
] {
let request: AccountResetCreditConsumeRequest = serde_json::from_value(json!({
"accountId": "acct_1",
"redeemRequestId": redeem_request_id
}))
.expect("decode invalid reset-credit consume");
assert_eq!(request.validate().expect_err("reject UUID").field(), field);
}
let invalid_credit: AccountResetCreditConsumeRequest = serde_json::from_value(json!({
"accountId": "acct_1",
"creditId": " ",
"redeemRequestId": "8fbf302d-11df-4bd5-82e4-08e4b3df7874"
}))
.expect("decode invalid credit");
assert_eq!(
invalid_credit
.validate()
.expect_err("reject credit")
.field(),
"creditId"
);
}
#[test]
fn credential_recovery_requests_should_not_accept_client_revision_fences() {
let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({
@@ -207,7 +523,8 @@ mod actions {
"provider": "openai",
"accountId": "acct_1",
"accessToken": "header.payload.signature",
"refreshToken": "refresh-token"
"refreshToken": "refresh-token",
"idToken": "id-header.id-payload.id-signature"
}))
.expect("decode rotation");
assert!(rotation.validate().is_ok());
@@ -269,6 +586,35 @@ mod actions {
}))
.is_err()
);
assert!(
serde_json::from_value::<AccountImportRequest>(json!({
"provider": "openai",
"data": {},
"groupIds": []
}))
.is_err()
);
}
#[test]
fn oauth_complete_should_not_accept_group_assignment() {
let flow_id = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7_u8; 32]);
let request: CompleteAccountAuthorizationRequest = serde_json::from_value(json!({
"provider": "openai",
"flowId": flow_id,
"callbackUrl": "http://localhost/callback?code=ok"
}))
.expect("decode OAuth completion");
assert!(request.validate().is_ok());
assert!(
serde_json::from_value::<CompleteAccountAuthorizationRequest>(json!({
"provider": "xai",
"flowId": "flow_test",
"callbackUrl": "http://localhost/callback?code=ok",
"groupIds": []
}))
.is_err()
);
}
#[test]
@@ -305,7 +651,7 @@ mod actions {
#[test]
fn account_import_response_should_emit_account_ids() {
let response = AccountImportData::from(CredentialImportResult {
let response = AccountImportData::from_result(CredentialImportResult {
config_revision: Revision::new(8).expect("revision"),
credential_ids: vec![ProviderAccountId::new("acct_imported").expect("account ID")],
});
@@ -343,17 +689,17 @@ mod actions {
DomainConnectionTestEvent::Content {
text: "OK".to_owned(),
},
DomainConnectionTestEvent::Completed {
account_status: AccountStatus::Normal,
},
DomainConnectionTestEvent::Completed {},
DomainConnectionTestEvent::Failed {
source: AccountProbeErrorSource::Upstream,
gateway_error_code: GatewayErrorKind::RateLimited,
send_state: Some(UpstreamSendState::Sent),
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::QuotaExhausted,
},
]
.map(|event| AccountConnectionTestEvent::from(event).data);
@@ -378,16 +724,18 @@ mod actions {
}
}),
json!({ "type": "content", "text": "OK" }),
json!({ "type": "test_complete", "success": true, "accountStatus": "normal" }),
json!({ "type": "test_complete", "success": true }),
json!({
"type": "error",
"source": "upstream",
"gatewayErrorCode": "rate_limited",
"sendState": "sent",
"error": "upstream unavailable",
"providerErrorCode": "usage_exhausted",
"providerErrorType": "invalid_request_error",
"upstreamStatus": 429,
"upstreamContentType": "application/json",
"upstreamBody": r#"{"error":{"type":"usage_limit_reached"}}"#,
"accountStatus": "quota_exhausted"
"upstreamBody": r#"{"error":{"type":"usage_limit_reached"}}"#
}),
]
);
@@ -105,7 +105,7 @@ 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",
"groupIds": [],
"maxConcurrency": 2,
"requestsPerMinute": 60
}))
@@ -118,7 +118,7 @@ fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
(
json!({
"name": " ",
"providerKind": "openai",
"groupIds": [],
"maxConcurrency": 0,
"requestsPerMinute": 0
}),
@@ -127,7 +127,7 @@ fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
(
json!({
"name": "key",
"providerKind": "openai",
"groupIds": [],
"maxConcurrency": u64::MAX,
"requestsPerMinute": 0
}),
@@ -150,7 +150,7 @@ fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
"id": "key_1",
"expectedConfigRevision": 1,
"name": "key",
"providerKind": "openai",
"groupIds": [],
"maxConcurrency": 0,
"requestsPerMinute": 0,
"tokensPerMinute": 0
@@ -161,7 +161,7 @@ fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
serde_json::from_value::<CreateClientKeyRequest>(json!({
"expectedConfigRevision": 7,
"name": "terminal key",
"providerKind": "openai",
"groupIds": [],
"maxConcurrency": 2,
"requestsPerMinute": 60
}))
@@ -210,7 +210,10 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
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"),
groups: Vec::new(),
provider_kinds: vec![
gateway_core::routing::ProviderKind::new("openai").expect("Provider kind"),
],
prefix: "sk_visible12".to_owned(),
enabled: true,
limits: gateway_core::policy::RateLimits {
@@ -226,7 +229,11 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
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]["providerKinds"],
serde_json::json!(["openai"])
);
assert_eq!(list["items"][0]["routingScope"], "all");
assert_eq!(list["items"][0]["maxConcurrency"], 2);
assert_eq!(list["items"][0]["requestsPerMinute"], 60);
assert!(list["items"][0].get("tokensPerMinute").is_none());

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