Compare commits

...
38 Commits
Author SHA1 Message Date
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
354 changed files with 24699 additions and 15277 deletions
+1 -1
View File
@@ -79,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@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: trivy-results.sarif
category: runtime-container
+2
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
paths-ignore:
- 'release/version.yaml'
pull_request:
branches:
- main
+10 -1
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
@@ -497,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:
+303 -32
View File
@@ -49,13 +49,13 @@ checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "argon2"
version = "0.5.3"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"cpufeatures 0.3.0",
"password-hash",
]
@@ -116,7 +116,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -206,9 +206,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.142.0"
version = "1.144.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9e15a5c55e05f4b0b7e483160b3c85cccdf77cff02c95504f3e71d460855cd2"
checksum = "30dc8bf6baaf7d46336a0ca2c69f223d9b90d7a801fb3e28f7ea17b00dc6b1de"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -331,9 +331,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c1c8a04cb31ba74d0115af5a890bb8c0d48fba64b52812fa13929a6ef0cc83c"
checksum = "ebfd138fac0337cee7516c352757ea73b9f2266e57d0bcb5bc70e9547e45aef1"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -375,9 +375,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.13.1"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "483b858ff67522011c4786310c5cd8fd88d0be7ea3d5f1a48328446300c4269e"
checksum = "b82e438d30e02a825d363bd639a9efaed68a8089d86101054b0081e7e0d3e606"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -599,11 +599,11 @@ dependencies = [
[[package]]
name = "blake2"
version = "0.10.6"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
dependencies = [
"digest 0.10.7",
"digest 0.11.3",
]
[[package]]
@@ -740,7 +740,7 @@ dependencies = [
"provider-xai",
"serde",
"serde_json",
"syn 3.0.3",
"syn 3.0.4",
"tempfile",
"thiserror",
"tokio",
@@ -954,6 +954,29 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "ctutils"
version = "0.4.2"
@@ -1005,6 +1028,27 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.119",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1013,7 +1057,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
@@ -1054,6 +1097,21 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dtoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "dunce"
version = "1.0.5"
@@ -1075,6 +1133,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ego-tree"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3"
[[package]]
name = "either"
version = "1.16.0"
@@ -1318,7 +1382,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -1363,6 +1427,7 @@ dependencies = [
"argon2",
"async-trait",
"base64 0.23.1",
"bytes",
"chrono",
"chrono-tz",
"cron",
@@ -1416,6 +1481,7 @@ dependencies = [
"bytes",
"futures",
"futures-timer",
"semver",
"serde",
"serde_json",
"thiserror",
@@ -1438,6 +1504,7 @@ dependencies = [
"gateway-core",
"hex",
"reqwest",
"scraper",
"semver",
"serde",
"serde_json",
@@ -1501,6 +1568,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1586,6 +1662,11 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashlink"
@@ -1656,6 +1737,16 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "html5ever"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever",
]
[[package]]
name = "http"
version = "0.2.12"
@@ -2046,11 +2137,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru"
version = "0.16.4"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
dependencies = [
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
@@ -2059,6 +2150,17 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2127,6 +2229,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2232,13 +2340,12 @@ dependencies = [
[[package]]
name = "password-hash"
version = "0.5.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
"getrandom 0.4.3",
"phc",
]
[[package]]
@@ -2262,13 +2369,24 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phc"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
dependencies = [
"base64ct",
"ctutils",
"getrandom 0.4.3",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
@@ -2281,6 +2399,27 @@ dependencies = [
"phf_shared 0.12.1",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
@@ -2291,19 +2430,42 @@ dependencies = [
"rand 0.8.7",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "phf_macros"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
@@ -2322,6 +2484,15 @@ dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2374,6 +2545,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "primefield"
version = "0.14.0"
@@ -2902,6 +3079,21 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [
"cssparser",
"ego-tree",
"getopts",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "sec1"
version = "0.8.1"
@@ -2949,6 +3141,25 @@ dependencies = [
"libc",
]
[[package]]
name = "selectors"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
"smallvec",
]
[[package]]
name = "semver"
version = "1.0.28"
@@ -2982,7 +3193,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -3032,6 +3243,15 @@ dependencies = [
"serde",
]
[[package]]
name = "servo_arc"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.7"
@@ -3371,6 +3591,30 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
]
[[package]]
name = "stringprep"
version = "0.1.5"
@@ -3407,9 +3651,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.3"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
@@ -3460,6 +3704,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "thiserror"
version = "2.0.20"
@@ -3477,7 +3730,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -3875,6 +4128,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.7.1"
@@ -3913,9 +4172,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.24.1"
version = "1.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -4058,6 +4317,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
dependencies = [
"phf 0.13.1",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
+1
View File
@@ -79,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()
+2 -2
View File
@@ -144,7 +144,7 @@ fn config_loader_should_reject_removed_fingerprint_section() {
#[test]
fn config_loader_should_reject_invalid_codex_cli_version() {
assert_rejected(valid_config().replace(
"codex_version: '0.147.0-alpha.6.6'",
"codex_version: '0.151.0-alpha.7.2'",
"codex_version: 'latest'",
));
}
@@ -165,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: '6415'", "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(),
);
}
}
}
}
+2 -1
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" }
@@ -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};
+16 -4
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,
@@ -22,19 +23,22 @@ mod use_case;
pub use use_case::{
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
backup::BackupService, client_keys::ClientKeyService, observability::ObservabilityService,
openai::OpenAiService, settings::SettingsService, system::SystemService, xai::XaiService,
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::{
account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService,
auth::DefaultAuthService, backup::DefaultBackupService, client_keys::DefaultClientKeyService,
auth::DefaultAuthService, backup::DefaultBackupService,
client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService,
observability::DefaultObservabilityService, openai::DefaultOpenAiService,
settings::DefaultSettingsService, system::DefaultSystemService, xai::DefaultXaiService,
};
@@ -149,6 +153,7 @@ pub struct AdminServices {
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>,
@@ -178,6 +183,11 @@ impl AdminServices {
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()
@@ -238,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
@@ -290,6 +301,7 @@ pub async fn initialize(
store.client_keys(),
snapshot.clone(),
)),
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
observability: Arc::new(DefaultObservabilityService::new(
store.observability(),
store.settings(),
@@ -3,7 +3,7 @@
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use gateway_core::{engine::credential::AccountStatusFacts, routing::AccountGroupId};
use gateway_core::{account::AccountStatusFacts, routing::AccountGroupId};
use super::{PageSize, Revision, observability::DecimalAmount};
@@ -6,14 +6,13 @@ use chrono::{DateTime, Utc};
use futures::Stream;
use gateway_core::{
engine::{UpstreamSendState, probe::AccountProbeErrorSource},
error::GatewayErrorKind,
routing::ProviderKind,
engine::probe::AccountProbeErrorSource, error::GatewayErrorKind, routing::ProviderKind,
upstream::UpstreamSendState,
};
use super::{PageSize, Revision, account_groups::AccountGroupRef, observability::TimeRange};
pub use gateway_core::engine::credential::{
pub use gateway_core::account::{
AccountConcurrencyLimit, AccountErrorReason, AccountStatus, AccountStatusFacts,
AccountStatusProjection, AccountWeight, CredentialState, QuotaAccessState, QuotaEvidence,
QuotaState, resolve_account_status,
@@ -218,7 +217,7 @@ pub struct UpdateAccount {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountUpdateResult {
pub config_revision: Revision,
pub account_id: gateway_core::engine::credential::ProviderAccountId,
pub account_id: gateway_core::account::ProviderAccountId,
}
/// 一批账号可编辑事实的一次性替换命令。
@@ -235,7 +234,7 @@ pub struct BatchUpdateAccounts {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountsUpdateResult {
pub config_revision: Revision,
pub account_ids: Vec<gateway_core::engine::credential::ProviderAccountId>,
pub account_ids: Vec<gateway_core::account::ProviderAccountId>,
}
/// 账号批量删除命令。
@@ -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";
@@ -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,6 +6,7 @@ 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;
@@ -38,13 +38,6 @@ impl TimeRange {
}
}
/// 观测记录的稳定键集游标。
#[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 {
@@ -130,9 +123,8 @@ pub struct UsageFilter {
pub struct UsageQuery {
pub range: TimeRange,
pub filter: UsageFilter,
pub cursor: Option<ObservabilityCursor>,
pub current_page: u32,
pub page_size: PageSize,
pub include_total: bool,
}
/// 运维错误过滤条件。
@@ -148,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>,
}
@@ -158,9 +149,8 @@ pub struct OpsErrorFilter {
pub struct OpsErrorQuery {
pub range: TimeRange,
pub filter: OpsErrorFilter,
pub cursor: Option<ObservabilityCursor>,
pub current_page: u32,
pub page_size: PageSize,
pub include_total: bool,
}
/// 用量诊断维度。
@@ -197,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()))
}
}
@@ -667,8 +657,9 @@ pub struct UsageRecord {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsagePage {
pub items: Vec<UsageListRecord>,
pub total: Option<u64>,
pub next_cursor: Option<ObservabilityCursor>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
}
/// 请求中的一次上游尝试或运维事件。
@@ -770,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>,
@@ -779,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>,
@@ -786,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,
}
@@ -795,8 +811,9 @@ pub struct OpsError {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsErrorPage {
pub items: Vec<OpsError>,
pub total: Option<u64>,
pub next_cursor: Option<ObservabilityCursor>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
}
/// 仪表盘趋势指标。
@@ -1,11 +1,13 @@
//! Provider 管理能力交换的中立 Command 与 Result。
use std::fmt;
use std::{fmt, pin::Pin};
use async_trait::async_trait;
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;
@@ -679,6 +681,35 @@ pub struct ProviderProfileStatistics {
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 {
@@ -877,7 +908,7 @@ impl fmt::Debug for ProviderExport {
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryItem {
pub account: AccountRecord,
pub projection: gateway_core::engine::credential::AccountStatusProjection,
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},
};
@@ -17,8 +17,8 @@ use crate::model::provider_credentials::{
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult,
ProviderResetCredits,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderResetCreditResult, ProviderResetCredits,
};
/// Provider 管理失败的稳定分类。
@@ -157,6 +157,14 @@ pub trait ProviderAdmin: Send + Sync {
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,
@@ -118,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(
@@ -159,7 +159,7 @@ pub trait AccountStore: Send + Sync {
async fn recover_account(
&self,
account_id: &gateway_core::engine::credential::ProviderAccountId,
account_id: &gateway_core::account::ProviderAccountId,
context: &MutationContext,
) -> AdminStoreResult<AccountUpdateResult>;
@@ -177,7 +177,7 @@ 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<()>;
}
@@ -8,8 +8,9 @@ use std::{
use async_trait::async_trait;
use gateway_core::{
engine::credential::{AccountStatus, resolve_account_status},
routing::{AccountGroupId, snapshot::SnapshotControl},
account::{AccountStatus, resolve_account_status},
routing::AccountGroupId,
runtime::SnapshotControl,
};
use uuid::Uuid;
@@ -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::{
@@ -25,8 +24,9 @@ use crate::{
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
QuotaLocalUsageAttribution,
},
},
ports::{
@@ -90,6 +90,13 @@ pub trait AccountsService: Send + Sync {
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,
@@ -566,6 +573,17 @@ impl AccountsService for DefaultAccountsService {
.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,
@@ -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,9 +195,8 @@ 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("管理员密码哈希失败"))
}
@@ -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;
@@ -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;
@@ -4,6 +4,7 @@ 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;
@@ -29,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 {
@@ -9,7 +9,7 @@ use std::{
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::{
@@ -654,7 +654,7 @@ fn no_cache_cost(
.checked_sub(decimal(&breakdown.cache_write_amount.amount)?.scaled())?
.checked_add(replaced_input_amount)?;
DecimalAmount::from_str(
&gateway_core::accounting::Decimal::from_scaled(total)
&gateway_core::metering::Decimal::from_scaled(total)
.ok()?
.canonical(),
)
@@ -669,14 +669,14 @@ fn amount_difference(
.scaled()
.checked_sub(decimal(smaller?)?.scaled())?;
DecimalAmount::from_str(
&gateway_core::accounting::Decimal::from_scaled(difference)
&gateway_core::metering::Decimal::from_scaled(difference)
.ok()?
.canonical(),
)
.ok()
}
fn decimal(value: &DecimalAmount) -> Option<gateway_core::accounting::Decimal> {
fn decimal(value: &DecimalAmount) -> Option<gateway_core::metering::Decimal> {
value.as_str().parse().ok()
}
@@ -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::{
@@ -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,6 +120,8 @@ 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(())
@@ -126,3 +129,7 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
Err(AdminError::invalid("运行时设置不满足约束"))
}
}
fn valid_client_version(value: Option<&str>) -> bool {
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
}
@@ -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::{
@@ -10,7 +10,7 @@ use gateway_admin::{
},
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,
+1 -1
View File
@@ -1,7 +1,7 @@
use std::str::FromStr as _;
use chrono::{TimeDelta, Utc};
use gateway_core::engine::credential::OpaqueProviderData;
use gateway_core::account::OpaqueProviderData;
use gateway_admin::{
model::{
@@ -1,4 +1,4 @@
//! 备份领域模型(model/backup.rs)的纯逻辑测试。
//! 备份领域模型(model/backup)的纯逻辑测试。
use chrono::{TimeZone as _, Utc};
use secrecy::SecretString;
@@ -24,7 +24,7 @@ use gateway_admin::{
},
};
use gateway_core::{
engine::credential::{AccountStatusFacts, CredentialState, QuotaState},
account::{AccountStatusFacts, CredentialState, QuotaState},
routing::AccountGroupId,
};
@@ -4,20 +4,18 @@ use async_trait::async_trait;
use chrono::{TimeDelta, Utc};
use futures::{StreamExt as _, future::BoxFuture};
use gateway_core::{
engine::{
UpstreamSendState,
credential::{
AccountStatusFacts, CredentialState, OpaqueProviderData, ProviderAccountId,
QuotaEvidence, QuotaState, resolve_account_status,
},
probe::{
AccountProbe, AccountProbeError, AccountProbeErrorSource, AccountProbeRequest,
AccountProbeResult,
},
account::{
AccountStatusFacts, CredentialState, OpaqueProviderData, ProviderAccountId, QuotaEvidence,
QuotaState, resolve_account_status,
},
engine::probe::{
AccountProbe, AccountProbeError, AccountProbeErrorSource, AccountProbeRequest,
AccountProbeResult,
},
error::{ClientVisibleUpstreamError, GatewayError, GatewayErrorKind},
operation::{GenerateRequest, Operation, ProtocolPayload},
routing::ProviderKind,
upstream::UpstreamSendState,
};
use gateway_admin::{
@@ -798,6 +796,8 @@ impl SettingsStore for StaticSettingsStore {
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: 30,
ops_event_retention_days: 30,
audit_retention_days: 30,
@@ -1110,8 +1110,7 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
std::time::SystemTime::now(),
None,
);
account.last_error_reason =
Some(gateway_core::engine::credential::AccountErrorReason::CredentialInvalid);
account.last_error_reason = Some(gateway_core::account::AccountErrorReason::CredentialInvalid);
account.last_error_message = Some("invalid credential".to_owned());
let store = FakeAccountStore::with_account(account, events.clone());
let services = accounts_service(provider, store.clone()).await;
@@ -1133,7 +1132,7 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
);
assert_eq!(
result.account.account.quota.access(),
gateway_core::engine::credential::QuotaAccessState::Allowed
gateway_core::account::QuotaAccessState::Allowed
);
assert_eq!(
result.account.projection.status,
@@ -1166,7 +1165,7 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
account_id: "acct_test".to_owned(),
enabled: false,
concurrency_limit: None,
weight: gateway_core::engine::credential::AccountWeight::DEFAULT,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: Vec::new(),
},
)
@@ -1202,7 +1201,7 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
account_id: "acct_test".to_owned(),
enabled: false,
concurrency_limit: None,
weight: gateway_core::engine::credential::AccountWeight::DEFAULT,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: Vec::new(),
},
)
@@ -1243,7 +1242,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
enabled: false,
concurrency_limit: None,
weight: gateway_core::engine::credential::AccountWeight::DEFAULT,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: Vec::new(),
},
)
@@ -1896,7 +1895,7 @@ pub(super) fn account_record(kind: &str) -> AccountRecord {
next_refresh_at: Some(now + TimeDelta::minutes(30)),
enabled: true,
concurrency_limit: None,
weight: gateway_core::engine::credential::AccountWeight::DEFAULT,
weight: gateway_core::account::AccountWeight::DEFAULT,
credential_state: CredentialState::Ready,
credential_observed_at: now,
quota: QuotaState::allowed(now.into()),
@@ -31,6 +31,7 @@ use gateway_admin::{
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
},
auth::{AdminAuditEvent, AdminSession},
client_distribution::CodexDesktopWindowsDownloads,
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
@@ -55,6 +56,7 @@ use gateway_admin::{
},
ports::{
backup::BackupStorePorts,
client_distribution::ClientDistributionResolver,
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
store::{
AccountGroupStore, AccountRuntimeStore, AccountStore, AdminAccountStorePorts,
@@ -68,13 +70,12 @@ 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 {
@@ -205,6 +206,7 @@ impl AdminHarness {
self.providers,
Arc::new(NoopSnapshot),
self.probe,
Arc::new(NoopClientDistribution),
self.system,
)
.await
@@ -213,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>>,
@@ -675,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,
total: Some(total),
next_cursor: None,
current_page: query.current_page,
page_size: query.page_size.get(),
total,
})
}
@@ -720,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,
@@ -783,9 +786,8 @@ fn usage_query(now: DateTime<Utc>) -> UsageQuery {
UsageQuery {
range: observation_range(now),
filter: UsageFilter::default(),
cursor: None,
current_page: 1,
page_size: PageSize::new(50).expect("page size"),
include_total: true,
}
}
@@ -1,6 +1,6 @@
use std::sync::Arc;
use gateway_core::engine::credential::ProviderAccountId;
use gateway_core::account::ProviderAccountId;
use gateway_admin::{
AdminServices,
@@ -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,6 +1,6 @@
use std::sync::Arc;
use gateway_core::engine::credential::ProviderAccountId;
use gateway_core::account::ProviderAccountId;
use gateway_admin::{
AdminServices,
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 }
}
}
+2 -1
View File
@@ -58,6 +58,7 @@ async fn admin_not_found() -> AdminError {
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
}
@@ -3,7 +3,6 @@
use std::collections::BTreeMap;
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Duration, Utc};
use gateway_admin::model::{PageSize as DomainPageSize, observability as domain};
use serde::{Deserialize, Serialize};
@@ -515,18 +515,13 @@ pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetai
}
}
pub(crate) fn usage_page_view(
page: domain::UsagePage,
) -> Result<PageData<UsageListRecordView>, WireValidationError> {
Ok(PageData {
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,
next_cursor: page
.next_cursor
.as_ref()
.map(encode_observability_cursor)
.transpose()?,
})
}
}
pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
@@ -536,48 +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,
) -> 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(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
next_cursor: page
.next_cursor
.as_ref()
.map(encode_observability_cursor)
.transpose()?,
})
}
}
pub(crate) fn trend_point_view(point: domain::TrendPoint) -> TrendPointView {
@@ -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,8 +27,8 @@ impl DashboardQuery {
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UsageQuery {
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>,
@@ -52,18 +48,17 @@ pub struct UsageQuery {
}
impl UsageQuery {
/// 校验分页字段,不改变仓储层的分页 owner。
pub fn validate_page_size(&self) -> Result<u16, WireValidationError> {
/// 校验 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_size)
}
/// 校验游标的 wire 边界;编码和排序语义由应用层负责。
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
validate_cursor(self.cursor.as_deref())
Ok((current_page, page_size))
}
}
@@ -105,21 +100,19 @@ impl DiagnosticsQuery {
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct OpsQuery {
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>,
@@ -128,18 +121,17 @@ pub struct OpsQuery {
}
impl OpsQuery {
/// 校验分页字段。
pub fn validate_page_size(&self) -> Result<u16, WireValidationError> {
/// 校验 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_size)
}
/// 校验游标的 wire 边界。
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
validate_cursor(self.cursor.as_deref())
Ok((current_page, page_size))
}
}
@@ -248,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())
}
@@ -336,22 +321,19 @@ pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, Wi
}
pub(crate) fn usage_command(query: &UsageQuery) -> Result<domain::UsageQuery, WireValidationError> {
let page_size = query.validate_page_size()?;
query.validate_cursor()?;
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())?,
current_page,
page_size: page_size_value,
include_total: query.cursor.is_none(),
})
}
pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, WireValidationError> {
let page_size = query.validate_page_size()?;
query.validate_cursor()?;
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(
@@ -367,57 +349,20 @@ pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, Wir
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())),
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()),
failure_kind: non_empty(query.failure_class.clone()),
status_code,
search: non_empty(query.search.clone()),
},
cursor: decode_observability_cursor(query.cursor.as_deref())?,
current_page,
page_size: page_size_value,
include_total: query.cursor.is_none(),
})
}
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(),
})
.map_err(|_| WireValidationError::new("cursor"))?;
Ok(URL_SAFE_NO_PAD.encode(bytes))
}
pub(crate) fn non_empty(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_owned())
@@ -91,7 +91,7 @@ where
.usage_records(command)
.await
.map_err(map_service_error)?;
let data = usage_page_view(result).map_err(|_| AdminError::internal())?;
let data = usage_page_view(result);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -207,6 +207,6 @@ where
.ops_errors(command)
.await
.map_err(map_service_error)?;
let data = ops_page_view(result).map_err(|_| AdminError::internal())?;
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 total: Option<u64>,
pub next_cursor: Option<String>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
}
/// Token 详情展示。
@@ -765,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,
@@ -792,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>,
}
@@ -3,7 +3,7 @@
//! 当前 wire 仍保留 display 字段;统一由本模块提供,避免 accounts/observability
//! 各自维护格式化规则。
use gateway_core::accounting::Decimal;
use gateway_core::metering::Decimal;
/// 千分位格式化。
#[must_use]
@@ -10,14 +10,18 @@ use axum::{
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, AdminJson, AdminResponse, AdminSessionState,
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>,
@@ -322,6 +417,16 @@ 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" => "refreshConcurrency 不合法".to_owned(),
@@ -329,6 +434,8 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
"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)
+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(
@@ -16,7 +16,7 @@ use serde_json::{Map, Value};
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_response, protocol_error_response},
responses::{PendingExecution, ProtocolError, ProtocolErrorBody, request_client_context},
};
@@ -71,7 +71,7 @@ async fn handle_image_request(
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 (client_ip, user_agent) = request_client_context(
&headers,
@@ -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();
@@ -105,7 +105,7 @@ pub(crate) async fn model_catalog(State(state): State<ApiState>, headers: Header
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 models = service
.public_models(&client)
@@ -125,7 +125,7 @@ pub(crate) async fn model_info(
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();
@@ -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,
@@ -65,7 +65,7 @@ async fn handle_responses(
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)
@@ -4,7 +4,7 @@ mod error;
mod http;
mod request;
mod response;
mod websocket;
pub mod websocket;
pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
pub(super) use http::{PendingExecution, request_client_context};
@@ -15,8 +15,18 @@ 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>,
@@ -71,10 +81,18 @@ impl OpenAiRequestHeaders {
}
}
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(
@@ -304,14 +322,15 @@ 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, review, 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);
@@ -341,7 +360,11 @@ pub(super) fn decode_request_object(
.unwrap_or(ContinuationIntent::None);
request_headers.apply_subagent(&mut object, review.then_some(REVIEW_SUBAGENT));
let protocol_context = request_headers.protocol_context(use_websocket);
let 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 {
@@ -362,6 +385,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())
@@ -455,10 +488,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"
@@ -11,7 +11,7 @@ use std::{
use axum::extract::ws::{CloseFrame, Message, WebSocket, close_code};
use futures::{Sink, SinkExt, Stream, StreamExt};
use gateway_core::engine::CancellationToken;
use gateway_core::lifecycle::CancellationToken;
use thiserror::Error;
use tokio::{
sync::{mpsc, oneshot},
@@ -24,21 +24,23 @@ 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(super) struct ConnectionConfig {
pub(super) write_timeout: Duration,
pub(super) max_age: Duration,
pub struct ConnectionConfig {
write_timeout: Duration,
max_age: Duration,
}
impl ConnectionConfig {
pub(super) const PRODUCTION: Self = Self {
/// 生产环境固定预算。
pub const PRODUCTION: Self = Self {
write_timeout: DOWNSTREAM_WRITE_TIMEOUT,
max_age: CONNECTION_MAX_AGE,
};
}
/// 业务层可观察的客户端输入;Ping/Pong 始终由 pump 消费。
pub(super) enum ConnectionEvent {
pub enum ConnectionEvent {
Text(String),
Binary,
Expired,
@@ -47,7 +49,7 @@ pub(super) enum ConnectionEvent {
/// 下游写入阶段;名称刻意使用 write,而不是暗示客户端已消费的 delivery。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FramePhase {
pub enum FramePhase {
Metadata,
First,
Data,
@@ -85,21 +87,26 @@ impl FramePhase {
}
}
/// 一次下游写入的请求归属与协议阶段。
#[derive(Clone)]
pub(super) struct WriteContext {
pub struct WriteContext {
request_id: Option<Arc<str>>,
phase: FramePhase,
}
impl WriteContext {
pub(super) fn request(request_id: &Arc<str>, phase: FramePhase) -> Self {
/// 创建归属于某个请求的写入上下文。
#[must_use]
pub fn request(request_id: &Arc<str>, phase: FramePhase) -> Self {
Self {
request_id: Some(Arc::clone(request_id)),
phase,
}
}
pub(super) const fn connection(phase: FramePhase) -> Self {
/// 创建连接级写入上下文。
#[must_use]
pub const fn connection(phase: FramePhase) -> Self {
Self {
request_id: None,
phase,
@@ -107,8 +114,9 @@ impl WriteContext {
}
}
/// WebSocket pump 停止的稳定原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PumpExitReason {
pub enum PumpExitReason {
ClientClose,
PeerEof,
ReadError,
@@ -140,8 +148,9 @@ impl PumpExitReason {
}
}
/// 下游 WebSocket 写入失败。
#[derive(Debug, Error)]
pub(super) enum ConnectionWriteError {
pub enum ConnectionWriteError {
#[error("downstream WebSocket pump is closed")]
Closed,
#[error("downstream WebSocket write timed out after {timeout:?}")]
@@ -179,7 +188,8 @@ impl ConnectionStats {
}
}
pub(super) struct ResponsesWebSocketConnection {
/// 协调层持有的单 owner WebSocket 连接句柄。
pub struct ResponsesWebSocketConnection {
connection_id: Arc<str>,
opened_at: Instant,
expired: Arc<AtomicBool>,
@@ -209,7 +219,9 @@ impl ResponsesWebSocketConnection {
&self.connection_id
}
pub(super) fn is_expired(&self) -> bool {
/// 返回连接是否已经达到生命周期上限。
#[must_use]
pub fn is_expired(&self) -> bool {
self.expired.load(Ordering::Acquire)
}
@@ -217,7 +229,8 @@ impl ResponsesWebSocketConnection {
self.opened_at.elapsed()
}
pub(super) async fn next_event(&mut self) -> Option<ConnectionEvent> {
/// 等待下一个需要业务层处理的客户端事件。
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);
@@ -227,7 +240,12 @@ impl ResponsesWebSocketConnection {
event
}
pub(super) async fn send_text(
/// 串行写入文本帧,并等待 pump 确认 transport 写入结果。
///
/// # Errors
///
/// pump 已关闭、写入超时或 transport 失败时返回稳定错误。
pub async fn send_text(
&mut self,
payload: String,
context: WriteContext,
@@ -391,7 +409,8 @@ impl Drop for ResponsesWebSocketConnection {
}
}
pub(super) fn spawn_connection<S, E>(
/// 为一个 socket 启动单 owner pump,并返回协调层句柄。
pub fn spawn_connection<S, E>(
socket: S,
connection_id: Arc<str>,
cancellation: CancellationToken,
@@ -1,6 +1,6 @@
//! OpenAI Responses WebSocket 的升级、连接与串行 session 编排。
mod connection;
pub mod connection;
mod forward;
mod protocol;
@@ -26,7 +26,7 @@ use gateway_core::{
use crate::{
ApiState,
openai::{
auth::{authenticate_client, authentication_error_response},
auth::{authenticate_client, client_access_error_response},
error::runtime_unavailable_response,
service::OpenAiService,
},
@@ -54,7 +54,7 @@ pub(crate) async fn responses_websocket(
let service = state.openai().clone();
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 (client_ip, user_agent) = request_client_context(
&headers,
@@ -6,7 +6,8 @@ use serde_json::{Map, Value, json};
use thiserror::Error;
use super::super::{
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError, request::OpenAiRequestHeaders,
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError,
request::{OpenAiRequestHeaders, RequestDecodeSource},
};
/// 使用连接级请求头和 Provider 上下文解码官方 `response.create` 文本帧。
@@ -40,8 +41,13 @@ fn decode_response_create_inner(
if matches!(body.get("stream"), Some(value) if value.as_bool() != Some(true)) {
return Err(ResponseCreateFrameError::StreamingRequired);
}
super::super::request::decode_request_object(body, false, request_headers)
.map_err(ResponseCreateFrameError::Request)
super::super::request::decode_request_object(
body,
false,
request_headers,
RequestDecodeSource::WebSocketFrame,
)
.map_err(ResponseCreateFrameError::Request)
}
/// `response.create` 帧的稳定安全错误。
@@ -76,7 +76,7 @@ mod profile_statistics {
ProviderProfileActivityInsights, ProviderProfileDailyUsage, ProviderProfileInvocation,
ProviderProfileStatistics, ProviderProfileStatisticsSummary,
};
use gateway_api::admin::accounts::AccountProfileStatisticsData;
use gateway_api::admin::accounts::{AccountProfileAvatarQuery, AccountProfileStatisticsData};
use serde_json::json;
#[test]
@@ -134,6 +134,74 @@ mod profile_statistics {
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 {
@@ -336,10 +404,8 @@ mod actions {
RotateAccountRequest, StartAccountAuthorizationRequest,
};
use gateway_core::{
engine::{
UpstreamSendState, credential::ProviderAccountId, probe::AccountProbeErrorSource,
},
error::GatewayErrorKind,
account::ProviderAccountId, engine::probe::AccountProbeErrorSource,
error::GatewayErrorKind, upstream::UpstreamSendState,
};
use serde_json::json;
+44 -11
View File
@@ -25,6 +25,10 @@ use gateway_admin::{
AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
},
auth::{AdminAuditEvent, AdminSession},
client_distribution::{
ClientArchitecture, ClientDownloadPackage, ClientDownloadSource,
CodexDesktopWindowsDownloads,
},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
@@ -50,6 +54,7 @@ use gateway_admin::{
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
},
ports::{
client_distribution::ClientDistributionResolver,
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
store::{
AccountGroupStore, AccountRuntimeStore, AccountStore, AdminAccountStorePorts,
@@ -64,14 +69,11 @@ use gateway_admin::{
};
use gateway_api::admin::AdminSessionState;
use gateway_core::{
engine::{
credential::{AccountStatusFacts, CredentialState, ProviderAccountId, QuotaState},
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
},
account::{AccountStatusFacts, CredentialState, ProviderAccountId, QuotaState},
engine::probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
policy::{ClientApiKeyId, RateLimits},
routing::{
ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId, snapshot::SnapshotControl,
},
routing::{ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId},
runtime::SnapshotControl,
};
mod account_groups;
@@ -143,6 +145,7 @@ impl AdminTestFixture {
providers,
Arc::new(NoopSnapshot),
Arc::new(NoopProbe),
Arc::new(StaticClientDistribution),
system,
)
.await
@@ -165,6 +168,30 @@ impl AdminTestFixture {
}
}
struct StaticClientDistribution;
#[async_trait]
impl ClientDistributionResolver for StaticClientDistribution {
async fn resolve_codex_desktop_windows(&self, _: bool) -> CodexDesktopWindowsDownloads {
CodexDesktopWindowsDownloads {
resolved_at: Utc::now(),
cached: false,
warning: None,
packages: vec![ClientDownloadPackage {
architecture: ClientArchitecture::X64,
source: ClientDownloadSource::MicrosoftStore,
version: Some("26.825.6671.0".to_owned()),
file_name: "OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.msix".to_owned(),
size_bytes: Some(744_250_000),
download_url:
"https://dl.delivery.mp.microsoft.com/filestreamingservice/files/test"
.to_owned(),
expires_at: Some(Utc::now() + Duration::hours(1)),
}],
}
}
}
#[derive(Clone)]
pub(super) struct AdminTestState(AdminServices);
@@ -319,6 +346,8 @@ impl SettingsStore for MemorySettingsStore {
max_concurrent_per_account: command.max_concurrent_per_account,
request_interval_ms: command.request_interval_ms,
rotation_strategy: command.rotation_strategy,
min_codex_desktop_version: command.min_codex_desktop_version,
min_codex_cli_version: command.min_codex_cli_version,
usage_retention_days: command.usage_retention_days,
ops_event_retention_days: command.ops_event_retention_days,
audit_retention_days: command.audit_retention_days,
@@ -858,9 +887,10 @@ impl ObservabilityStore for UnusedStore {
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
let items = self.usage_records.lock().expect("usage records").clone();
Ok(UsagePage {
total: query.include_total.then_some(items.len() as u64),
current_page: query.current_page,
page_size: query.page_size.get(),
total: items.len() as u64,
items,
next_cursor: None,
})
}
@@ -888,9 +918,10 @@ impl ObservabilityStore for UnusedStore {
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
let items = self.ops_errors.lock().expect("ops errors").clone();
Ok(OpsErrorPage {
total: query.include_total.then_some(items.len() as u64),
current_page: query.current_page,
page_size: query.page_size.get(),
total: items.len() as u64,
items,
next_cursor: None,
})
}
}
@@ -1071,6 +1102,8 @@ fn test_runtime_settings() -> RuntimeSettings {
max_concurrent_per_account: 3,
request_interval_ms: 50,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
@@ -19,14 +19,13 @@ fn dashboard_query_should_reject_unknown_trend_kind() {
}
#[test]
fn usage_query_should_bound_page_size_and_cursor() {
fn usage_query_should_accept_element_plus_pagination() {
let query: UsageQuery = serde_json::from_value(json!({
"pageSize": 100,
"cursor": "opaque"
"currentPage": 129,
"pageSize": 10
}))
.unwrap();
assert_eq!(query.validate_page_size().unwrap(), 100);
assert!(query.validate_cursor().is_ok());
assert_eq!(query.validate_pagination().unwrap(), (129, 10));
}
#[test]
@@ -35,14 +34,25 @@ fn usage_query_should_reject_removed_total_toggle() {
}
#[test]
fn usage_query_should_reject_removed_offset_page_contract() {
fn usage_query_should_reject_removed_cursor_contract() {
assert!(serde_json::from_value::<UsageQuery>(json!({"cursor": "opaque"})).is_err());
}
#[test]
fn usage_query_should_reject_nonstandard_page_name() {
assert!(serde_json::from_value::<UsageQuery>(json!({"page": 1})).is_err());
}
#[test]
fn ops_query_should_reject_page_size_above_terminal_limit() {
let query: OpsQuery = serde_json::from_value(json!({"pageSize": 101})).unwrap();
assert_eq!(query.validate_page_size().unwrap_err().field(), "pageSize");
assert_eq!(query.validate_pagination().unwrap_err().field(), "pageSize");
}
#[test]
fn ops_query_should_reject_removed_filters() {
assert!(serde_json::from_value::<OpsQuery>(json!({"route": "/v1/responses"})).is_err());
assert!(serde_json::from_value::<OpsQuery>(json!({"failureClass": "rate_limited"})).is_err());
}
#[test]
@@ -3,10 +3,10 @@
use chrono::{TimeZone, Utc};
use gateway_admin::model::observability::DesktopReleaseStatus;
use gateway_api::admin::observability::{
BillingView, CostCoverageView, CursorWire, DashboardAccountRequestBucketView,
DashboardAccountUsageView, DashboardDesktopReleaseStatusView, DashboardWireAttributeView,
DashboardWireProfileView, DashboardWireTargetView, PageData, TokenDetailsView, TrendData,
TrendKind, TrendPointView, TrendSummaryView,
BillingView, CostCoverageView, DashboardAccountRequestBucketView, DashboardAccountUsageView,
DashboardDesktopReleaseStatusView, DashboardWireAttributeView, DashboardWireProfileView,
DashboardWireTargetView, PageData, TokenDetailsView, TrendData, TrendKind, TrendPointView,
TrendSummaryView,
};
use serde_json::json;
@@ -14,12 +14,15 @@ use serde_json::json;
fn usage_page_should_keep_terminal_camel_case_shape() {
let data = PageData {
items: vec![json!({"id": "request_1"})],
total: Some(1),
next_cursor: Some("cursor".to_owned()),
current_page: 129,
page_size: 10,
total: 1287,
};
let value = serde_json::to_value(data).unwrap();
assert_eq!(value["total"], 1);
assert_eq!(value["nextCursor"], "cursor");
assert_eq!(value["currentPage"], 129);
assert_eq!(value["pageSize"], 10);
assert_eq!(value["total"], 1287);
assert!(value.get("nextCursor").is_none());
}
#[test]
@@ -154,11 +157,7 @@ fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() {
image_output_tokens_display: "-".to_owned(),
total_tokens_display: "3".to_owned(),
};
let cursor = CursorWire {
observed_at: Utc.timestamp_opt(0, 0).single().unwrap(),
stable_id: "request_1".to_owned(),
};
let value = serde_json::to_value((&coverage, &token_details, &cursor)).unwrap();
let value = serde_json::to_value((&coverage, &token_details)).unwrap();
assert!(value.to_string().contains("known"));
assert!(!value.to_string().contains("secret"));
}
@@ -540,6 +539,10 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
client_api_key_ref: Some("key_err".to_owned()),
component: "model_request".to_owned(),
operation: "responses".to_owned(),
protocol: Some("openai".to_owned()),
client_transport: Some("http_sse".to_owned()),
requested_model_id: Some("gpt-5.4".to_owned()),
service_tier: Some("priority".to_owned()),
endpoint: Some("/v1/responses".to_owned()),
provider_kind: Some("openai".to_owned()),
provider_account_ref: Some("acct_err".to_owned()),
@@ -549,6 +552,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
upstream_model_id: Some("upstream-err".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
failure_kind: "upstream_error".to_owned(),
upstream_send_state: Some("sent".to_owned()),
client_status_code: Some(502),
upstream_status_code: Some(502),
provider_error_code: Some("upstream".to_owned()),
@@ -556,7 +560,29 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
upstream_request_id: None,
latency_ms: Some(90),
message: "snapshot error".to_owned(),
raw_upstream_error: Some(
r#"{"error":{"code":"upstream","message":"raw upstream marker"}}"#.to_owned(),
),
occurrence_count: 1,
client_ip: Some("203.0.113.8".to_owned()),
user_agent: Some("codex-cli/0.144.0".to_owned()),
reasoning_effort: Some("medium".to_owned()),
reasoning_preset: None,
request_kind: Some("root".to_owned()),
subagent_kind: None,
compact: Some(false),
continuation_affinity_hash: Some("affinity-hash".to_owned()),
continuation_previous_response_id_hash: Some("response-hash".to_owned()),
continuation_unavailable_reason: Some("reused_connection_lost".to_owned()),
upstream_connection_id: Some("connection-id".to_owned()),
upstream_connection_exit_reason: Some("tcp_reset".to_owned()),
upstream_connection_age_ms: Some(12_000),
upstream_connection_idle_ms: Some(4_000),
recovery_request_id: None,
recovered_at: None,
recovery_attempt_count: 0,
recovery_retry_delay_ms: None,
recovery_total_latency_ms: None,
occurred_at: Utc::now(),
stable_sort_id: "model_request:req_err".to_owned(),
});
@@ -586,6 +612,16 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"accountLabel": value["data"]["items"][0]["metadata"]["accountLabel"],
"clientStatusCode": value["data"]["items"][0]["clientStatusCode"],
"route": value["data"]["items"][0]["route"],
"requestedModel": value["data"]["items"][0]["requestedModel"],
"clientTransport": value["data"]["items"][0]["clientTransport"],
"clientIp": value["data"]["items"][0]["clientIp"],
"userAgent": value["data"]["items"][0]["userAgent"],
"providerErrorCode": value["data"]["items"][0]["providerErrorCode"],
"upstreamSendState": value["data"]["items"][0]["upstreamSendState"],
"rawUpstreamError": value["data"]["items"][0]["rawUpstreamError"],
"continuationUnavailableReason": value["data"]["items"][0]["metadata"]["continuationUnavailableReason"],
"upstreamConnectionExitReason": value["data"]["items"][0]["metadata"]["upstreamConnectionExitReason"],
"upstreamConnectionAgeMs": value["data"]["items"][0]["metadata"]["upstreamConnectionAgeMs"],
}),
serde_json::json!({
"provider": "openai",
@@ -595,6 +631,16 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"accountLabel": "err@example.invalid",
"clientStatusCode": 502,
"route": "/v1/responses",
"requestedModel": "gpt-5.4",
"clientTransport": "http_sse",
"clientIp": "203.0.113.8",
"userAgent": "codex-cli/0.144.0",
"providerErrorCode": "upstream",
"upstreamSendState": "sent",
"rawUpstreamError": "{\"error\":{\"code\":\"upstream\",\"message\":\"raw upstream marker\"}}",
"continuationUnavailableReason": "reused_connection_lost",
"upstreamConnectionExitReason": "tcp_reset",
"upstreamConnectionAgeMs": 12000,
})
);
}
@@ -46,6 +46,8 @@ fn update_body() -> Value {
"maxConcurrentPerAccount": 5,
"requestIntervalMs": 25,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
"usageRetentionDays": 32,
"opsEventRetentionDays": 31,
"auditRetentionDays": 91
@@ -62,6 +64,19 @@ fn settings_request_should_reject_unknown_rotation_strategy() {
assert_eq!(request.validate().unwrap_err().field(), "rotationStrategy");
}
#[test]
fn settings_request_should_reject_non_semver_client_min() {
let mut body = update_body();
body["minCodexCliVersion"] = json!("v0.40.0");
let request: UpdateRuntimeSettingsRequest =
serde_json::from_value(body).expect("decode settings");
assert_eq!(
request.validate().unwrap_err().field(),
"minCodexCliVersion"
);
}
#[test]
fn settings_response_should_cover_the_full_runtime_settings_contract() {
use std::collections::BTreeMap;
@@ -70,7 +85,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
use gateway_admin::model::Revision;
use gateway_admin::model::settings::RuntimeSettings;
use gateway_api::admin::settings::RuntimeSettingsView;
use gateway_core::engine::credential::RotationStrategy;
use gateway_core::account::RotationStrategy;
use gateway_core::routing::{PublicModelId, UpstreamModelId};
let settings = RuntimeSettings {
@@ -90,6 +105,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
max_concurrent_per_account: 5,
request_interval_ms: 25,
rotation_strategy: RotationStrategy::RoundRobin,
min_codex_desktop_version: Some("26.825.6671".to_owned()),
min_codex_cli_version: Some("0.40.0".to_owned()),
usage_retention_days: 32,
ops_event_retention_days: 31,
audit_retention_days: 91,
@@ -112,6 +129,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"maxConcurrentPerAccount": 5,
"requestIntervalMs": 25,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
"usageRetentionDays": 32,
"opsEventRetentionDays": 31,
"auditRetentionDays": 91,
@@ -127,7 +146,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
use gateway_admin::model::Revision;
use gateway_admin::model::settings::RuntimeSettings;
use gateway_api::admin::settings::RuntimeSettingsView;
use gateway_core::engine::credential::RotationStrategy;
use gateway_core::account::RotationStrategy;
use gateway_core::routing::{PublicModelId, UpstreamModelId};
let request: UpdateRuntimeSettingsRequest =
@@ -159,6 +178,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
request_interval_ms: request.request_interval_ms,
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
.expect("fixture rotation strategy"),
min_codex_desktop_version: request.min_codex_desktop_version,
min_codex_cli_version: request.min_codex_cli_version,
usage_retention_days: u32::try_from(request.usage_retention_days).expect("u32"),
ops_event_retention_days: u32::try_from(request.ops_event_retention_days).expect("u32"),
audit_retention_days: u32::try_from(request.audit_retention_days).expect("u32"),
@@ -234,6 +255,31 @@ async fn settings_post_should_replace_global_model_mappings() {
assert_eq!(data["modelMappings"]["grok-latest"], "grok-4.5");
}
#[tokio::test]
async fn client_downloads_should_return_validated_direct_links() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let response = app(fixture.state())
.oneshot(request(
Method::GET,
"/api/admin/settings/client-downloads/codex-desktop/windows?refresh=true",
None,
))
.await
.expect("client downloads response");
assert_eq!(response.status(), StatusCode::OK);
let data = response_json(response).await["data"].clone();
assert_eq!(data["packages"][0]["architecture"], "x64");
assert_eq!(data["packages"][0]["source"], "microsoft_store");
assert_eq!(data["packages"][0]["version"], "26.825.6671.0");
assert!(
data["packages"][0]["downloadUrl"]
.as_str()
.is_some_and(|url| url.starts_with("https://dl.delivery.mp.microsoft.com/"))
);
}
#[test]
fn settings_request_should_reject_invalid_model_mapping_name() {
let mut body = update_body();
@@ -34,7 +34,11 @@ fn source_tree_should_match_frozen_machine_manifest() {
actual.sort();
let mut expected = vec![
"src/admin/account_groups.rs",
"src/admin/accounts.rs",
"src/admin/accounts/credentials.rs",
"src/admin/accounts/handlers.rs",
"src/admin/accounts/mod.rs",
"src/admin/accounts/presenter.rs",
"src/admin/accounts/wire.rs",
"src/admin/auth.rs",
"src/admin/backups.rs",
"src/admin/client_keys.rs",
@@ -81,7 +85,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
actual.sort();
let mut expected = vec![
"tests/admin/account_groups.rs",
"tests/admin/accounts.rs",
"tests/admin/accounts/mod.rs",
"tests/admin/auth.rs",
"tests/admin/client_keys.rs",
"tests/admin/errors.rs",
@@ -97,7 +101,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/main.rs",
"tests/openai/auth.rs",
"tests/openai/error.rs",
"tests/openai/images.rs",
"tests/openai/images/mod.rs",
"tests/openai/mod.rs",
"tests/openai/models.rs",
"tests/openai/responses/http.rs",
@@ -1,6 +1,9 @@
use axum::http::{HeaderMap, HeaderValue, header::AUTHORIZATION};
use gateway_api::openai::auth::{ClientApiKeyAuthError, bearer_client_api_key};
use gateway_api::openai::auth::{
ClientApiKeyAuthError, bearer_client_api_key, identify_codex_client,
};
use gateway_core::policy::CodexClientKind;
#[test]
fn bearer_client_api_key_should_reject_missing_authorization() {
@@ -88,3 +91,48 @@ fn client_auth_failure_reasons_should_be_stable_and_secret_free() {
]
);
}
#[test]
fn desktop_headers_should_take_precedence_over_embedded_cli_marker() {
let mut headers = HeaderMap::new();
headers.insert("originator", HeaderValue::from_static("Codex Desktop"));
headers.insert("version", HeaderValue::from_static("26.825.6671"));
headers.insert(
"user-agent",
HeaderValue::from_static("codex_cli_rs/0.39.0 terminal (Codex Desktop; 26.1.0)"),
);
let identified = identify_codex_client(&headers).expect("recognized desktop");
assert_eq!(identified.kind(), CodexClientKind::Desktop);
assert_eq!(
identified.version().map(ToString::to_string).as_deref(),
Some("26.825.6671")
);
}
#[test]
fn cli_user_agent_should_expose_semver_or_recognized_missing_version() {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_static("codex_cli_rs/0.39.0 (Linux; x86_64)"),
);
let identified = identify_codex_client(&headers).expect("recognized CLI");
assert_eq!(identified.kind(), CodexClientKind::Cli);
assert_eq!(
identified.version().map(ToString::to_string).as_deref(),
Some("0.39.0")
);
headers.insert(
"user-agent",
HeaderValue::from_static("codex-cli/not-a-version"),
);
assert!(
identify_codex_client(&headers)
.expect("recognized invalid CLI")
.version()
.is_none()
);
}
@@ -1,9 +1,10 @@
use axum::{body::to_bytes, http::StatusCode};
use gateway_core::engine::{EngineError, UpstreamSendState};
use gateway_core::engine::EngineError;
use gateway_core::error::{
ClientVisibleUpstreamError, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind,
StoreError, StoreErrorKind,
};
use gateway_core::upstream::UpstreamSendState;
use gateway_api::openai::error::{
gateway_error_contract, gateway_error_from_engine, gateway_error_response,
@@ -397,3 +398,38 @@ async fn gateway_error_response_should_expose_only_structured_client_visible_ups
})
);
}
#[tokio::test]
async fn continuation_recovery_should_preserve_the_official_retry_signal() {
let error = GatewayError::from_provider(
&ProviderError::new(
ProviderErrorKind::ContinuationRecoveryRequired,
UpstreamSendState::NotSent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"Previous response was not found. Retrying the full request.",
Some("previous_response_not_found".to_owned()),
Some("invalid_request_error".to_owned()),
)),
);
let response = gateway_error_response(&error);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read continuation recovery response");
let body: serde_json::Value =
serde_json::from_slice(&body).expect("continuation recovery JSON");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
body,
serde_json::json!({
"error": {
"message": "Previous response was not found. Retrying the full request.",
"type": "invalid_request_error",
"code": "previous_response_not_found"
}
})
);
}
@@ -10,7 +10,6 @@ use axum::{
};
use bytes::Bytes;
use futures::future::BoxFuture;
use gateway_core::engine::UpstreamSendState;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionService,
ExecutionSession, StartExecution, StartProviderExecution, StartedExecution,
@@ -22,6 +21,7 @@ use gateway_core::error::{
use gateway_core::event::{ProtocolWireEvent, ProviderEvent, ProviderResponseHeader};
use gateway_core::operation::{ImageRequestKind, Operation};
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::UpstreamSendState;
use serde_json::{Value, json};
use tower::ServiceExt;
+34 -8
View File
@@ -12,6 +12,7 @@ use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use futures::future::BoxFuture;
use gateway_core::account::{AccountSelectionPolicy, ProviderAccountId, RotationStrategy};
use gateway_core::engine::admission::{
ClientAdmissionDecision, ClientAdmissionError, ClientAdmissionPort, ClientAdmissionRecovery,
ClientAdmissionRequest, ClientAdmissionRestoreResult,
@@ -19,9 +20,6 @@ use gateway_core::engine::admission::{
use gateway_core::engine::continuation::{
NativeContinuationPin, NativeContinuationPort, NativeContinuationStoreError, PreviousResponseId,
};
use gateway_core::engine::credential::{
AccountSelectionPolicy, ProviderAccountId, RotationStrategy,
};
use gateway_core::engine::execution::{
AuthenticatedClient, ClientApiKeyUsageSink, DefaultExecutionService, ExecutionService,
ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
@@ -29,17 +27,21 @@ use gateway_core::engine::execution::{
use gateway_core::engine::provider::ProviderRegistry;
use gateway_core::engine::{
AttemptRecord, ExecutionStore, IntermediateFailure, ModelRequestFinalization, ModelRequestId,
NewModelRequest, RecoveryReport, UpstreamSendState,
NewModelRequest, RecoveryReport,
};
use gateway_core::error::StoreError;
use gateway_core::health::{WorkerHealthSnapshot, WorkerHealthSource};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
use gateway_core::routing::snapshot::RuntimeSnapshotHandle;
use gateway_core::policy::{
ClientApiKeyId, ClientPolicy, CodexClientMinVersions, CodexClientVersion,
PlaintextClientApiKey, RateLimits,
};
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
ProviderModel, RuntimeAccount, RuntimeAccountDirectory, RuntimeSnapshot, UpstreamModelId,
};
use gateway_core::runtime::RuntimeSnapshotHandle;
use gateway_core::upstream::UpstreamSendState;
pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Router {
api_router_with_origins(execution, Vec::new()).await
@@ -109,6 +111,30 @@ pub(super) fn authenticated_client_for_provider(
.expect("authenticated client")
}
pub(super) fn authenticated_client_with_min_versions(
plaintext: &str,
desktop: Option<&str>,
cli: Option<&str>,
) -> AuthenticatedClient {
let snapshot =
snapshot(plaintext, "openai").with_min_codex_client_versions(CodexClientMinVersions::new(
desktop.map(|version| CodexClientVersion::parse(version).expect("Desktop min version")),
cli.map(|version| CodexClientVersion::parse(version).expect("CLI min version")),
));
let source = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(UnusedExecutionStore),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(IgnoredClientApiKeyUsage),
);
source
.authenticate(plaintext)
.expect("authenticated client")
}
struct IgnoredClientApiKeyUsage;
impl ClientApiKeyUsageSink for IgnoredClientApiKeyUsage {
@@ -160,7 +186,7 @@ fn snapshot(plaintext: &str, provider_name: &str) -> RuntimeSnapshot {
#[derive(Default)]
struct TestLifecycle {
cancellation: gateway_core::engine::CancellationToken,
cancellation: gateway_core::lifecycle::CancellationToken,
}
struct TestConnectionGuard;
@@ -172,7 +198,7 @@ impl ConnectionLifecycle for TestLifecycle {
Ok(Box::new(TestConnectionGuard))
}
fn cancellation(&self) -> gateway_core::engine::CancellationToken {
fn cancellation(&self) -> gateway_core::lifecycle::CancellationToken {
self.cancellation.clone()
}
@@ -15,7 +15,7 @@ use gateway_core::routing::{
};
use tower::ServiceExt;
use super::{api_router, authenticated_client};
use super::{api_router, authenticated_client, authenticated_client_with_min_versions};
pub(super) struct ModelsExecution {
client: AuthenticatedClient,
@@ -36,6 +36,24 @@ impl ModelsExecution {
profiles: true,
})
}
fn with_cli_min() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client_with_min_versions("sk_models_test", None, Some("0.40.0")),
profiles: false,
})
}
fn with_desktop_min() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client_with_min_versions(
"sk_models_test",
Some("26.825.51511"),
None,
),
profiles: false,
})
}
}
impl ExecutionService for ModelsExecution {
@@ -144,6 +162,111 @@ async fn models_should_encode_the_service_visible_catalog() {
);
}
#[tokio::test]
async fn models_should_reject_recognized_cli_below_configured_min() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("user-agent", "codex_cli_rs/0.39.0 (Linux; x86_64)")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read version rejection body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON");
assert_eq!(value["error"]["code"], "client_version_too_old");
assert_eq!(value["error"]["client"], "codex_cli");
assert_eq!(value["error"]["current_version"], "0.39.0");
assert_eq!(value["error"]["min_version"], "0.40.0");
}
#[tokio::test]
async fn models_should_reject_recognized_cli_with_invalid_version() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("user-agent", "codex-cli/not-a-version")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read version rejection body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON");
assert_eq!(value["error"]["code"], "client_version_unavailable");
assert!(value["error"]["current_version"].is_null());
assert_eq!(value["error"]["min_version"], "0.40.0");
}
#[tokio::test]
async fn models_should_reject_desktop_below_its_configured_min() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("originator", "Codex Desktop")
.header("version", "26.825.50000")
.header("user-agent", "codex_cli_rs/99.0.0 (Codex Desktop)")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_desktop_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read version rejection body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON");
assert_eq!(value["error"]["code"], "client_version_too_old");
assert_eq!(value["error"]["client"], "codex_desktop");
assert_eq!(value["error"]["current_version"], "26.825.50000");
assert_eq!(value["error"]["min_version"], "26.825.51511");
}
#[tokio::test]
async fn models_should_allow_cli_at_its_configured_min() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("user-agent", "codex_cli_rs/0.40.0 (Linux; x86_64)")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn models_should_leave_unknown_clients_unrestricted() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("user-agent", "curl/8.14.1")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn models_should_encode_provider_profiles_for_current_codex_clients() {
let response = api_router(ModelsExecution::with_profiles())
@@ -301,11 +424,12 @@ async fn model_info_should_hide_unknown_models() {
}
#[tokio::test]
async fn models_should_require_a_valid_bearer_client_key() {
let response = api_router(ModelsExecution::new())
async fn models_should_authenticate_before_applying_the_version_gate() {
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(
Request::get("/v1/models")
.header("user-agent", "codex_cli_rs/0.1.0 (Linux; x86_64)")
.body(Body::empty())
.expect("build unauthenticated request"),
)
@@ -19,7 +19,7 @@ use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError};
use gateway_core::error::{
ClientVisibleUpstreamError, ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind,
ProviderError, ProviderErrorKind,
@@ -30,6 +30,7 @@ use gateway_core::event::{
};
use gateway_core::operation::{Operation, OperationKind};
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::UpstreamSendState;
use serde_json::{Value, json};
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
@@ -196,7 +196,13 @@ fn decoder_should_preserve_connection_metadata_outside_the_openai_wire_body() {
HeaderValue::from_static("{\"kind\":\"review\"}"),
);
headers.insert("conversation-id", HeaderValue::from_static("conversation"));
let body = json!({"model": "smart-code", "input": "hello"});
let body = json!({
"model": "smart-code",
"input": "hello",
"client_metadata": {
"x-codex-turn-metadata": "{\"kind\":\"turn\"}"
}
});
let decoded = decode_request_with_headers(body.to_string().as_bytes(), &headers)
.expect("request should decode");
@@ -320,8 +326,8 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
vec![b"future-ascii".to_vec(), b"\x80\xff".to_vec()]
);
assert_eq!(
values("version"),
vec![b"future-v1".to_vec(), b"future-\x80".to_vec()]
openai_protocol_context(&decoded).get("version"),
Some(&json!("future-v1"))
);
assert_eq!(
values("openai-beta"),
@@ -349,6 +355,7 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
// 上游指纹由运行时画像统一生成,客户端不得覆盖。
"user-agent",
"originator",
"version",
"x-oai-attestation",
"x-oai-is",
"x-oai-is-update",
@@ -16,7 +16,7 @@ use axum::{
use futures::{Sink, SinkExt, Stream, StreamExt, future::BoxFuture};
use gateway_core::{
engine::{
CancellationToken, CommitRequirement, CoordinatedEvent, EngineError,
CommitRequirement, CoordinatedEvent, EngineError,
execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
StartExecution, StartProviderExecution, StartedExecution,
@@ -24,6 +24,7 @@ use gateway_core::{
},
error::GatewayError,
event::{GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ResponseMeta},
lifecycle::CancellationToken,
routing::PublicModelId,
};
use serde_json::{Value, json};
@@ -190,16 +191,7 @@ fn crossing_limit_terminal_batch() -> CoordinatedEvent {
.expect("terminal WebSocket batch")
}
// 连接 runtime 没有正式公共 API;镜像测试直接编译该私有模块,以测试 socket pump,
// 不把测试钩子暴露给 gateway-api 的消费者。
#[expect(
dead_code,
reason = "the mirrored test only exercises pump-facing seams"
)]
#[path = "../../../../src/openai/responses/websocket/connection.rs"]
mod connection_under_test;
use connection_under_test::{
use gateway_api::openai::responses::websocket::connection::{
ConnectionConfig, ConnectionEvent, ConnectionWriteError, FramePhase,
ResponsesWebSocketConnection, WriteContext, spawn_connection,
};
@@ -9,22 +9,27 @@ use std::time::{Duration, SystemTime};
use axum::{
body::Body,
http::{Request, StatusCode, header::AUTHORIZATION},
http::{HeaderMap, HeaderValue, Request, StatusCode, header::AUTHORIZATION},
};
use bytes::Bytes;
use futures::{SinkExt, StreamExt, future::BoxFuture};
use gateway_api::openai::responses::ResponseCreateFrameError;
use gateway_api::openai::responses::{
DecodedResponsesRequest, OpenAiRequestHeaders, ResponseCreateFrameError,
decode_response_create_with_context,
};
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError};
use gateway_core::error::{GatewayError, ProviderError, ProviderErrorKind};
use gateway_core::event::{
GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ResponseMeta,
};
use gateway_core::operation::Operation;
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::UpstreamSendState;
use gateway_protocol::openai::codex_responses_request_semantics;
use serde_json::{Value, json};
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message as ClientMessage;
@@ -34,6 +39,22 @@ use tower::ServiceExt;
use super::decode_response_create;
use crate::openai::{api_router, authenticated_client, models::ModelsExecution};
fn decode_response_create_with_turn_header(
payload: Value,
opening_turn_metadata: &'static str,
) -> DecodedResponsesRequest {
let mut headers = HeaderMap::new();
headers.insert(
"x-codex-turn-metadata",
HeaderValue::from_static(opening_turn_metadata),
);
decode_response_create_with_context(
&payload.to_string(),
&OpenAiRequestHeaders::from_headers(&headers),
)
.expect("response.create should decode")
}
#[test]
fn response_create_should_default_to_the_websocket_streaming_contract() {
let decoded = decode_response_create(
@@ -118,6 +139,87 @@ fn response_create_should_preserve_compaction_trigger_for_openai() {
);
}
#[test]
fn response_create_should_prefer_frame_turn_metadata_over_opening_headers() {
let decoded = decode_response_create_with_turn_header(
json!({
"type": "response.create",
"model": "smart-code",
"input": "hello",
"client_metadata": {
"x-codex-turn-metadata": r#"{"request_kind":"turn"}"#
}
}),
r#"{"request_kind":"compaction"}"#,
);
let Operation::Generate(request) = decoded.operation() else {
panic!("Responses must map to Generate");
};
let semantics = codex_responses_request_semantics(
request.protocol_payload().body(),
request.protocol_payload().context(),
);
assert_eq!(
(semantics.request_kind.as_deref(), semantics.compact),
(Some("turn"), false)
);
}
#[test]
fn response_create_should_accept_frame_compaction_after_a_turn_opening_header() {
let decoded = decode_response_create_with_turn_header(
json!({
"type": "response.create",
"model": "smart-code",
"input": "hello",
"client_metadata": {
"x-codex-turn-metadata": r#"{"request_kind":"compaction"}"#
}
}),
r#"{"request_kind":"turn"}"#,
);
let Operation::Generate(request) = decoded.operation() else {
panic!("Responses must map to Generate");
};
let semantics = codex_responses_request_semantics(
request.protocol_payload().body(),
request.protocol_payload().context(),
);
assert_eq!(
(semantics.request_kind.as_deref(), semantics.compact),
(Some("compaction"), true)
);
}
#[test]
fn response_create_should_fall_back_to_opening_turn_metadata_when_the_frame_omits_it() {
let decoded = decode_response_create_with_turn_header(
json!({
"type": "response.create",
"model": "smart-code",
"input": "hello"
}),
r#"{"request_kind":"compaction"}"#,
);
let Operation::Generate(request) = decoded.operation() else {
panic!("Responses must map to Generate");
};
let semantics = codex_responses_request_semantics(
request.protocol_payload().body(),
request.protocol_payload().context(),
);
assert_eq!(
(semantics.request_kind.as_deref(), semantics.compact),
(Some("compaction"), true)
);
}
#[test]
fn response_create_should_reject_explicit_non_streaming_requests() {
let error = decode_response_create(
+1
View File
@@ -18,6 +18,7 @@ futures = "0.3.32"
futures-timer = "3.0.3"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
semver = "1.0.27"
thiserror = "2.0.18"
tracing = "0.1.44"
uuid = { version = "1.23.3", features = ["v7"] }
@@ -0,0 +1,16 @@
//! Provider credential 值对象构造错误。
use thiserror::Error;
/// Credential 值对象构造错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum CredentialError {
#[error("credential revision must be greater than zero")]
InvalidRevision,
#[error("credential revision overflow")]
RevisionOverflow,
#[error("credential CAS profile belongs to a different account")]
ProfileAccountMismatch,
#[error("credential refresh schedule requires a refresh token")]
InvalidRefreshSchedule,
}
@@ -0,0 +1,11 @@
//! Provider 账号领域、持久化端口与同一 target 内的账号选择。
mod error;
mod model;
mod selection;
mod store;
pub use error::CredentialError;
pub use model::*;
pub use selection::*;
pub use store::ProviderAccountStore;
@@ -1,20 +1,16 @@
//! Provider 账号、明文 credential port 与同一 target 内的账号选择。
//! Provider 账号、明文 credential 与持久状态值对象。
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap};
use std::fmt;
use std::num::{NonZeroU16, NonZeroU32, NonZeroU64};
use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use serde_json::{Map, Value};
use thiserror::Error;
use crate::error::{IdentifierError, StoreError, validate_text};
use crate::error::{IdentifierError, validate_text};
use crate::routing::ProviderKind;
use super::CredentialError;
/// `provider_accounts.id` 的核心值对象。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ProviderAccountId(String);
@@ -1257,806 +1253,3 @@ pub struct AccountStateChange {
/// 刷新成功或其他无失败事实的 Ready 写入必须清空。
pub message: Option<String>,
}
/// `provider_accounts` 的数据库中立端口。
#[async_trait]
pub trait ProviderAccountStore: Send + Sync {
async fn create_account(&self, account: NewProviderAccount) -> Result<(), StoreError>;
async fn get_account(
&self,
account: &ProviderAccountId,
) -> Result<Option<ProviderAccount>, StoreError>;
async fn list_accounts(&self) -> Result<Vec<ProviderAccount>, StoreError>;
async fn list_for_provider(
&self,
provider: &ProviderKind,
) -> Result<Vec<ProviderAccount>, StoreError>;
/// 一次有界查询返回账号事实和 revision-fenced 明文 credential。
async fn list_refresh_candidates(
&self,
query: ProviderRefreshQuery,
) -> Result<Vec<LoadedCredential>, StoreError>;
async fn load_credential(
&self,
account: &ProviderAccountId,
expected_revision: CredentialRevision,
) -> Result<LoadedCredential, StoreError>;
/// 读取账号当前 credential 及其 revision,不做任何版本比对。
///
/// 管理写入必须在临近 CAS 时用它取 fence,而不是携带调用方持有的旧 revision:
/// 后台刷新随时会推进 revision,用陈旧快照会把正常的恢复操作误判为冲突。
async fn load_current_credential(
&self,
account: &ProviderAccountId,
) -> Result<LoadedCredential, StoreError>;
async fn compare_and_swap_credential(
&self,
update: CredentialCasUpdate,
) -> Result<CredentialCasOutcome, StoreError>;
async fn get_quotas(
&self,
accounts: &[ProviderAccountId],
) -> Result<Vec<QuotaObservation>, StoreError>;
async fn compare_and_swap_quota(
&self,
observation: QuotaObservation,
) -> Result<QuotaWriteOutcome, StoreError>;
async fn touch_quota_observation(
&self,
touch: QuotaObservationTouch,
) -> Result<QuotaWriteOutcome, StoreError>;
async fn apply_quota_access(
&self,
change: QuotaAccessChange,
) -> Result<QuotaWriteOutcome, StoreError>;
async fn apply_state_change(&self, change: AccountStateChange) -> Result<(), StoreError>;
async fn update_account(&self, update: ProviderAccountUpdate) -> Result<(), StoreError>;
async fn set_enabled(
&self,
account: &ProviderAccountId,
enabled: bool,
) -> Result<(), StoreError>;
async fn delete_account(&self, account: &ProviderAccountId) -> Result<(), StoreError>;
}
/// `runtime_settings.rotation_strategy` 的稳定值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RotationStrategy {
Smart,
QuotaResetPriority,
RoundRobin,
Sticky,
}
impl RotationStrategy {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Smart => "smart",
Self::QuotaResetPriority => "quota_reset_priority",
Self::RoundRobin => "round_robin",
Self::Sticky => "sticky",
}
}
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value {
"smart" => Some(Self::Smart),
"quota_reset_priority" => Some(Self::QuotaResetPriority),
"round_robin" => Some(Self::RoundRobin),
"sticky" => Some(Self::Sticky),
_ => None,
}
}
}
/// 从 `runtime_settings` 冻结到一次请求计划的账号调度策略。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountSelectionPolicy {
strategy: RotationStrategy,
max_concurrent_per_account: NonZeroU32,
request_interval: Duration,
}
impl AccountSelectionPolicy {
#[must_use]
pub const fn new(
strategy: RotationStrategy,
max_concurrent_per_account: NonZeroU32,
request_interval: Duration,
) -> Self {
Self {
strategy,
max_concurrent_per_account,
request_interval,
}
}
#[must_use]
pub const fn strategy(self) -> RotationStrategy {
self.strategy
}
#[must_use]
pub const fn max_concurrent_per_account(self) -> NonZeroU32 {
self.max_concurrent_per_account
}
#[must_use]
pub const fn request_interval(self) -> Duration {
self.request_interval
}
}
/// Store 提供并发事实,Provider 叠加自己解释的额度事实;全部信号均可重建。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRuntimeSignals {
pub in_flight: u32,
pub last_started_at: Option<SystemTime>,
pub quota_reset_at: Option<SystemTime>,
pub quota_remaining_rank: Option<u64>,
pub rate_limited_until: Option<SystemTime>,
pub failure_rate_basis_points: Option<u16>,
pub first_output_latency_ms: Option<u64>,
}
const ACCOUNT_FEEDBACK_EWMA_ALPHA: f64 = 0.2;
const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits();
/// 一次真实上游 attempt 对账号级 Smart 调度产生的中立反馈。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountAttemptFeedback {
Succeeded { first_output_ms: Option<u64> },
Failed { first_output_ms: Option<u64> },
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct AccountFeedbackKey {
provider_kind: ProviderKind,
account_id: ProviderAccountId,
}
#[derive(Debug)]
struct AccountFeedback {
failure_rate: AtomicU64,
first_output_ms: AtomicU64,
}
impl Default for AccountFeedback {
fn default() -> Self {
Self {
// 新账号从健康基线开始,首个失败样本按 EWMA 平滑,而不是直接判死。
failure_rate: AtomicU64::new(0.0_f64.to_bits()),
first_output_ms: AtomicU64::new(EMPTY_FEEDBACK_SAMPLE),
}
}
}
impl AccountFeedback {
fn report(&self, feedback: AccountAttemptFeedback) {
let (failure, first_output_ms) = match feedback {
AccountAttemptFeedback::Succeeded { first_output_ms } => (0.0, first_output_ms),
AccountAttemptFeedback::Failed { first_output_ms } => (1.0, first_output_ms),
};
update_feedback_ewma(&self.failure_rate, failure);
if let Some(first_output_ms) = first_output_ms.filter(|value| *value > 0) {
update_feedback_ewma(&self.first_output_ms, first_output_ms as f64);
}
}
fn scheduling_signals(&self) -> (Option<u16>, Option<u64>) {
let failure_rate = load_feedback_ewma(&self.failure_rate).map(|value| {
let basis_points = (value.clamp(0.0, 1.0) * 10_000.0).round();
basis_points as u16
});
let first_output_ms = load_feedback_ewma(&self.first_output_ms)
.filter(|value| value.is_finite() && *value > 0.0)
.map(|value| value.round() as u64);
(failure_rate, first_output_ms)
}
}
/// 所有 Provider 共享、按 Provider 和账号隔离的进程内 Smart 健康反馈。
#[derive(Debug, Default)]
pub struct AccountFeedbackStats {
accounts: RwLock<HashMap<AccountFeedbackKey, AccountFeedback>>,
}
impl AccountFeedbackStats {
/// 读取账号当前的错误率与首个有效输出延迟 EWMA。
#[must_use]
pub fn scheduling_signals(
&self,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
) -> (Option<u16>, Option<u64>) {
let key = AccountFeedbackKey {
provider_kind: provider_kind.clone(),
account_id: account_id.clone(),
};
self.accounts
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&key)
.map(AccountFeedback::scheduling_signals)
.unwrap_or_default()
}
/// 回灌一次已经真实发送的上游 attempt。
pub fn report(
&self,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
feedback: AccountAttemptFeedback,
) {
let key = AccountFeedbackKey {
provider_kind: provider_kind.clone(),
account_id: account_id.clone(),
};
if let Some(account) = self
.accounts
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&key)
{
account.report(feedback);
return;
}
self.accounts
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.entry(key)
.or_default()
.report(feedback);
}
}
fn load_feedback_ewma(value: &AtomicU64) -> Option<f64> {
let value = f64::from_bits(value.load(Ordering::Relaxed));
(!value.is_nan()).then_some(value)
}
fn update_feedback_ewma(target: &AtomicU64, sample: f64) {
let mut current = target.load(Ordering::Relaxed);
loop {
let previous = f64::from_bits(current);
let next = if previous.is_nan() {
sample
} else {
ACCOUNT_FEEDBACK_EWMA_ALPHA * sample + (1.0 - ACCOUNT_FEEDBACK_EWMA_ALPHA) * previous
};
match target.compare_exchange_weak(
current,
next.to_bits(),
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return,
Err(observed) => current = observed,
}
}
}
impl AccountRuntimeSignals {
#[must_use]
pub fn with_provider_quota(mut self, quota: Option<AccountQuotaSignals>) -> Self {
if let Some(quota) = quota {
self.quota_reset_at = quota.reset_at;
self.quota_remaining_rank = quota.remaining_rank;
}
self
}
#[must_use]
pub const fn with_rate_limit(mut self, rate_limited_until: Option<SystemTime>) -> Self {
self.rate_limited_until = rate_limited_until;
self
}
#[must_use]
pub const fn with_runtime_health(
mut self,
failure_rate_basis_points: Option<u16>,
first_output_latency_ms: Option<u64>,
) -> Self {
self.failure_rate_basis_points = failure_rate_basis_points;
self.first_output_latency_ms = first_output_latency_ms;
self
}
}
/// Provider 从私有 quota JSON 投影出的中立调度事实。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountQuotaSignals {
reset_at: Option<SystemTime>,
remaining_rank: Option<u64>,
}
impl AccountQuotaSignals {
#[must_use]
pub const fn new(reset_at: Option<SystemTime>, remaining_rank: Option<u64>) -> Self {
Self {
reset_at,
remaining_rank,
}
}
#[must_use]
pub const fn reset_at(self) -> Option<SystemTime> {
self.reset_at
}
#[must_use]
pub const fn remaining_rank(self) -> Option<u64> {
self.remaining_rank
}
}
/// 账号持久事实与可重建运行信号的请求级组合。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountCandidate {
pub account: ProviderAccount,
pub signals: AccountRuntimeSignals,
}
/// 一次账号选择看到的可调度并发槽快照。
///
/// `used_slots` 包含刚刚获取成功的当前请求;快照只覆盖本次请求范围内、
/// 模型可用且未被显式排除的账号池。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AccountCapacitySnapshot {
used_slots: u64,
total_slots: u64,
}
impl AccountCapacitySnapshot {
#[must_use]
pub const fn used_slots(self) -> u64 {
self.used_slots
}
#[must_use]
pub const fn total_slots(self) -> u64 {
self.total_slots
}
#[must_use]
pub fn with_acquired_request(self) -> Self {
Self {
used_slots: self.used_slots.saturating_add(1).min(self.total_slots),
total_slots: self.total_slots,
}
}
}
/// 一次账号选择使用的全局策略快照。
#[derive(Debug, Clone)]
pub struct AccountSelectionContext {
pub policy: AccountSelectionPolicy,
pub now: SystemTime,
pub excluded_accounts: BTreeSet<ProviderAccountId>,
pub preferred_account: Option<ProviderAccountId>,
pub round_robin_cursor: u64,
pub eligibility: AccountEligibilityPolicy,
pub account_scope: Option<std::sync::Arc<crate::routing::FrozenAccountScope>>,
}
/// 选择账号时是否执行本地调度资格投影。
///
/// 管理端对指定账号执行诊断时,会直接向上游确认实际状态;该模式跳过
/// `enabled`、可用性、冷却和 token 到期等本地投影,仍保留租约、并发和
/// 请求间隔约束,且只能与固定账号约束组合使用。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AccountEligibilityPolicy {
#[default]
Enforce,
BypassForDiagnostic,
}
impl AccountEligibilityPolicy {
#[must_use]
pub const fn bypasses_local_eligibility(self) -> bool {
matches!(self, Self::BypassForDiagnostic)
}
}
/// 候选账号未进入调度池的约束。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountSchedulingBlocker {
OutsideClientScope,
LocalAvailability,
Excluded,
ConcurrencyLimit,
RequestInterval,
LowerWeight,
}
/// 优先账号在本次选择中的处理结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreferredAccountSelection {
NotRequested,
Hit,
Missing,
Blocked(AccountSchedulingBlocker),
}
/// 一次完整账号选择;同时保留优先账号决策,供 Provider 记录调度遥测。
#[derive(Debug, Clone, Copy)]
pub struct AccountSelection<'a> {
candidate: &'a AccountCandidate,
preferred: PreferredAccountSelection,
}
impl<'a> AccountSelection<'a> {
#[must_use]
pub const fn candidate(self) -> &'a AccountCandidate {
self.candidate
}
#[must_use]
pub const fn preferred(self) -> PreferredAccountSelection {
self.preferred
}
}
/// 同一 target 内唯一的账号排序器。
#[derive(Debug, Default, Clone, Copy)]
pub struct AccountSelector;
impl AccountSelector {
/// 汇总与本次调度约束一致的并发容量,供请求级观测使用。
#[must_use]
pub fn capacity_snapshot(
&self,
candidates: &[AccountCandidate],
context: &AccountSelectionContext,
) -> Option<AccountCapacitySnapshot> {
let (used_slots, total_slots) = candidates
.iter()
.filter(|candidate| {
!matches!(
self.scheduling_blocker(candidate, context),
Some(
AccountSchedulingBlocker::OutsideClientScope
| AccountSchedulingBlocker::LocalAvailability
| AccountSchedulingBlocker::Excluded
)
)
})
.fold((0_u64, 0_u64), |(used, total), candidate| {
let capacity = u64::from(
candidate
.account
.effective_concurrency(context.policy.max_concurrent_per_account())
.get(),
);
(
used.saturating_add(u64::from(candidate.signals.in_flight)),
total.saturating_add(capacity),
)
});
(total_slots > 0).then_some(AccountCapacitySnapshot {
used_slots: used_slots.min(total_slots),
total_slots,
})
}
/// 从可调度账号中确定一个候选;这里只消费 Provider 已解析的额度投影。
#[must_use]
pub fn select<'a>(
&self,
candidates: &'a [AccountCandidate],
context: &AccountSelectionContext,
) -> Option<AccountSelection<'a>> {
let mut eligible = candidates
.iter()
.filter(|candidate| self.scheduling_blocker(candidate, context).is_none())
.collect::<Vec<_>>();
if eligible.is_empty() {
return None;
}
let highest_weight = eligible
.iter()
.map(|candidate| candidate.account.weight())
.max()?;
let preferred = if let Some(preferred) = context.preferred_account.as_ref() {
match candidates
.iter()
.find(|candidate| candidate.account.id() == preferred)
{
Some(candidate) => match self.scheduling_blocker(candidate, context) {
Some(blocker) => PreferredAccountSelection::Blocked(blocker),
None if candidate.account.weight() < highest_weight => {
PreferredAccountSelection::Blocked(AccountSchedulingBlocker::LowerWeight)
}
None => {
return Some(AccountSelection {
candidate,
preferred: PreferredAccountSelection::Hit,
});
}
},
None => PreferredAccountSelection::Missing,
}
} else {
PreferredAccountSelection::NotRequested
};
eligible.retain(|candidate| candidate.account.weight() == highest_weight);
let candidate = match context.policy.strategy() {
RotationStrategy::QuotaResetPriority => {
let default_concurrency = context.policy.max_concurrent_per_account();
eligible.sort_by(|left, right| {
(
left.signals.quota_reset_at.is_none(),
left.signals.quota_reset_at,
)
.cmp(&(
right.signals.quota_reset_at.is_none(),
right.signals.quota_reset_at,
))
.then_with(|| {
capacity_utilization(left, default_concurrency)
.total_cmp(&capacity_utilization(right, default_concurrency))
})
.then_with(|| {
left.signals
.last_started_at
.cmp(&right.signals.last_started_at)
})
.then_with(|| left.account.id().cmp(right.account.id()))
});
eligible.first().copied()?
}
RotationStrategy::RoundRobin => {
eligible.sort_by_key(|candidate| candidate.account.id().clone());
let index = context.round_robin_cursor as usize % eligible.len();
eligible.get(index).copied()?
}
RotationStrategy::Smart => select_smart_candidate(
&eligible,
context.policy.max_concurrent_per_account(),
context.round_robin_cursor,
)?,
RotationStrategy::Sticky => {
eligible.sort_by_key(|candidate| {
(
Reverse(candidate.signals.last_started_at),
candidate.account.id().clone(),
)
});
eligible.first().copied()?
}
};
Some(AccountSelection {
candidate,
preferred,
})
}
fn scheduling_blocker(
&self,
candidate: &AccountCandidate,
context: &AccountSelectionContext,
) -> Option<AccountSchedulingBlocker> {
if context
.account_scope
.as_ref()
.is_some_and(|scope| !scope.allows(candidate.account.id()))
{
return Some(AccountSchedulingBlocker::OutsideClientScope);
}
if !context.eligibility.bypasses_local_eligibility() {
let status = candidate
.account
.status_projection(context.now, candidate.signals.rate_limited_until)
.status;
if status != AccountStatus::Normal {
return Some(AccountSchedulingBlocker::LocalAvailability);
}
}
if context.excluded_accounts.contains(candidate.account.id()) {
return Some(AccountSchedulingBlocker::Excluded);
}
if candidate.signals.in_flight
>= candidate
.account
.effective_concurrency(context.policy.max_concurrent_per_account())
.get()
{
return Some(AccountSchedulingBlocker::ConcurrencyLimit);
}
if candidate
.signals
.last_started_at
.is_some_and(|last_started| {
!context
.now
.duration_since(last_started)
.is_ok_and(|elapsed| elapsed >= context.policy.request_interval())
})
{
return Some(AccountSchedulingBlocker::RequestInterval);
}
None
}
}
const SMART_LOAD_WEIGHT: f64 = 1.0;
const SMART_QUOTA_WEIGHT: f64 = 0.8;
const SMART_FAILURE_WEIGHT: f64 = 1.0;
const SMART_LATENCY_WEIGHT: f64 = 0.5;
fn capacity_utilization(candidate: &AccountCandidate, default_concurrency: NonZeroU32) -> f64 {
f64::from(candidate.signals.in_flight)
/ f64::from(
candidate
.account
.effective_concurrency(default_concurrency)
.get(),
)
}
struct SmartNormalization {
max_capacity_utilization: f64,
min_quota: Option<u64>,
max_quota: Option<u64>,
min_latency_ms: Option<u64>,
max_latency_ms: Option<u64>,
}
impl SmartNormalization {
fn from_candidates(candidates: &[&AccountCandidate], default_concurrency: NonZeroU32) -> Self {
let max_capacity_utilization = candidates
.iter()
.map(|candidate| capacity_utilization(candidate, default_concurrency))
.reduce(f64::max)
.unwrap_or_default();
let min_quota = candidates
.iter()
.filter_map(|candidate| candidate.signals.quota_remaining_rank)
.min();
let max_quota = candidates
.iter()
.filter_map(|candidate| candidate.signals.quota_remaining_rank)
.max();
let min_latency_ms = candidates
.iter()
.filter_map(|candidate| candidate.signals.first_output_latency_ms)
.filter(|latency| *latency > 0)
.min();
let max_latency_ms = candidates
.iter()
.filter_map(|candidate| candidate.signals.first_output_latency_ms)
.filter(|latency| *latency > 0)
.max();
Self {
max_capacity_utilization,
min_quota,
max_quota,
min_latency_ms,
max_latency_ms,
}
}
}
fn select_smart_candidate<'a>(
candidates: &[&'a AccountCandidate],
default_concurrency: NonZeroU32,
cursor: u64,
) -> Option<&'a AccountCandidate> {
let normalization = SmartNormalization::from_candidates(candidates, default_concurrency);
let mut ranked = candidates
.iter()
.map(|candidate| {
(
*candidate,
smart_score(candidate, default_concurrency, &normalization),
)
})
.collect::<Vec<_>>();
ranked.sort_by(|(left, left_score), (right, right_score)| {
right_score
.total_cmp(left_score)
.then_with(|| left.account.id().cmp(right.account.id()))
});
let best_score = ranked.first()?.1.to_bits();
let tied = ranked
.iter()
.take_while(|(_, score)| score.to_bits() == best_score)
.count();
let index = cursor as usize % tied;
Some(ranked[index].0)
}
fn smart_score(
candidate: &AccountCandidate,
default_concurrency: NonZeroU32,
normalization: &SmartNormalization,
) -> f64 {
let load = lower_ratio_is_better(
capacity_utilization(candidate, default_concurrency),
normalization.max_capacity_utilization,
);
let quota = candidate.signals.quota_remaining_rank.map_or(0.5, |quota| {
higher_is_better(
quota,
normalization.min_quota.unwrap_or(quota),
normalization.max_quota.unwrap_or(quota),
)
});
let failure = 1.0
- f64::from(
candidate
.signals
.failure_rate_basis_points
.unwrap_or_default()
.min(10_000),
) / 10_000.0;
let latency = candidate
.signals
.first_output_latency_ms
.filter(|latency| *latency > 0)
.map_or(1.0, |latency| {
lower_is_better(
latency,
normalization.min_latency_ms.unwrap_or(latency),
normalization.max_latency_ms.unwrap_or(latency),
)
});
SMART_LOAD_WEIGHT * load
+ SMART_QUOTA_WEIGHT * quota
+ SMART_FAILURE_WEIGHT * failure
+ SMART_LATENCY_WEIGHT * latency
}
fn lower_ratio_is_better(value: f64, maximum: f64) -> f64 {
if maximum <= 0.0 {
return 1.0;
}
1.0 - (value / maximum).clamp(0.0, 1.0)
}
fn lower_is_better(value: u64, minimum: u64, maximum: u64) -> f64 {
if maximum <= minimum {
return 1.0;
}
1.0 - (value.saturating_sub(minimum) as f64 / (maximum - minimum) as f64).clamp(0.0, 1.0)
}
fn higher_is_better(value: u64, minimum: u64, maximum: u64) -> f64 {
if maximum <= minimum {
return 1.0;
}
(value.saturating_sub(minimum) as f64 / (maximum - minimum) as f64).clamp(0.0, 1.0)
}
/// Credential 值对象构造错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum CredentialError {
#[error("credential revision must be greater than zero")]
InvalidRevision,
#[error("credential revision overflow")]
RevisionOverflow,
#[error("credential CAS profile belongs to a different account")]
ProfileAccountMismatch,
#[error("credential refresh schedule requires a refresh token")]
InvalidRefreshSchedule,
}
@@ -0,0 +1,783 @@
//! 请求级账号资格投影、反馈统计与选择算法。
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap};
use std::num::NonZeroU32;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, RwLock};
use std::time::{Duration, Instant, SystemTime};
use crate::routing::ProviderKind;
use super::{AccountStatus, ProviderAccount, ProviderAccountId};
/// `runtime_settings.rotation_strategy` 的稳定值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RotationStrategy {
Smart,
QuotaResetPriority,
RoundRobin,
Sticky,
}
impl RotationStrategy {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Smart => "smart",
Self::QuotaResetPriority => "quota_reset_priority",
Self::RoundRobin => "round_robin",
Self::Sticky => "sticky",
}
}
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value {
"smart" => Some(Self::Smart),
"quota_reset_priority" => Some(Self::QuotaResetPriority),
"round_robin" => Some(Self::RoundRobin),
"sticky" => Some(Self::Sticky),
_ => None,
}
}
}
/// 从 `runtime_settings` 冻结到一次请求计划的账号调度策略。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountSelectionPolicy {
strategy: RotationStrategy,
max_concurrent_per_account: NonZeroU32,
request_interval: Duration,
}
impl AccountSelectionPolicy {
#[must_use]
pub const fn new(
strategy: RotationStrategy,
max_concurrent_per_account: NonZeroU32,
request_interval: Duration,
) -> Self {
Self {
strategy,
max_concurrent_per_account,
request_interval,
}
}
#[must_use]
pub const fn strategy(self) -> RotationStrategy {
self.strategy
}
#[must_use]
pub const fn max_concurrent_per_account(self) -> NonZeroU32 {
self.max_concurrent_per_account
}
#[must_use]
pub const fn request_interval(self) -> Duration {
self.request_interval
}
}
/// Store 提供并发事实,Provider 叠加自己解释的额度事实;全部信号均可重建。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRuntimeSignals {
pub in_flight: u32,
pub last_started_at: Option<SystemTime>,
pub quota_reset_at: Option<SystemTime>,
pub quota_remaining_rank: Option<u64>,
pub rate_limited_until: Option<SystemTime>,
pub failure_rate_basis_points: Option<u16>,
pub first_output_latency_ms: Option<u64>,
}
const ACCOUNT_FEEDBACK_EWMA_ALPHA: f64 = 0.2;
const ACCOUNT_FAILURE_RATE_HALF_LIFE: Duration = Duration::from_secs(15 * 60);
const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits();
/// 一次真实上游 attempt 对账号级 Smart 调度产生的中立反馈。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountAttemptFeedback {
Succeeded { first_output_ms: Option<u64> },
Failed { first_output_ms: Option<u64> },
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct AccountFeedbackKey {
provider_kind: ProviderKind,
account_id: ProviderAccountId,
}
#[derive(Debug)]
struct AccountFeedback {
failure_rate: Mutex<DecayingAccountFailureRate>,
first_output_ms: AtomicU64,
}
#[derive(Debug, Default)]
struct DecayingAccountFailureRate {
value: f64,
updated_at: Option<Instant>,
}
impl DecayingAccountFailureRate {
fn value_at(&self, now: Instant) -> f64 {
let Some(updated_at) = self.updated_at else {
return self.value;
};
let elapsed = now.checked_duration_since(updated_at).unwrap_or_default();
self.value
* 0.5_f64.powf(elapsed.as_secs_f64() / ACCOUNT_FAILURE_RATE_HALF_LIFE.as_secs_f64())
}
fn report_at(&mut self, sample: f64, now: Instant) {
let now = self
.updated_at
.map_or(now, |updated_at| updated_at.max(now));
let decayed = self.value_at(now);
self.value =
ACCOUNT_FEEDBACK_EWMA_ALPHA * sample + (1.0 - ACCOUNT_FEEDBACK_EWMA_ALPHA) * decayed;
self.updated_at = Some(now);
}
}
impl Default for AccountFeedback {
fn default() -> Self {
Self {
// 新账号从健康基线开始,首个失败样本按 EWMA 平滑,而不是直接判死。
failure_rate: Mutex::new(DecayingAccountFailureRate::default()),
first_output_ms: AtomicU64::new(EMPTY_FEEDBACK_SAMPLE),
}
}
}
impl AccountFeedback {
fn report_at(&self, feedback: AccountAttemptFeedback, now: Instant) {
let (failure, first_output_ms) = match feedback {
AccountAttemptFeedback::Succeeded { first_output_ms } => (0.0, first_output_ms),
AccountAttemptFeedback::Failed { first_output_ms } => (1.0, first_output_ms),
};
self.failure_rate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.report_at(failure, now);
if let Some(first_output_ms) = first_output_ms.filter(|value| *value > 0) {
update_feedback_ewma(&self.first_output_ms, first_output_ms as f64);
}
}
fn scheduling_signals_at(&self, now: Instant) -> (Option<u16>, Option<u64>) {
let failure_rate = self
.failure_rate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.value_at(now);
let failure_rate = Some((failure_rate.clamp(0.0, 1.0) * 10_000.0).round() as u16);
let first_output_ms = load_feedback_ewma(&self.first_output_ms)
.filter(|value| value.is_finite() && *value > 0.0)
.map(|value| value.round() as u64);
(failure_rate, first_output_ms)
}
}
/// 所有 Provider 共享、按 Provider 和账号隔离的进程内 Smart 健康反馈。
#[derive(Debug, Default)]
pub struct AccountFeedbackStats {
accounts: RwLock<HashMap<AccountFeedbackKey, AccountFeedback>>,
}
impl AccountFeedbackStats {
/// 读取账号当前的错误率与首个有效输出延迟 EWMA。
#[must_use]
pub fn scheduling_signals(
&self,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
) -> (Option<u16>, Option<u64>) {
self.scheduling_signals_at(provider_kind, account_id, Instant::now())
}
/// 读取账号在指定单调时刻的错误率与首个有效输出延迟 EWMA。
#[doc(hidden)]
#[must_use]
pub fn scheduling_signals_at(
&self,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
now: Instant,
) -> (Option<u16>, Option<u64>) {
let key = AccountFeedbackKey {
provider_kind: provider_kind.clone(),
account_id: account_id.clone(),
};
self.accounts
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&key)
.map(|account| account.scheduling_signals_at(now))
.unwrap_or_default()
}
/// 回灌一次已经真实发送的上游 attempt。
pub fn report(
&self,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
feedback: AccountAttemptFeedback,
) {
self.report_at(provider_kind, account_id, feedback, Instant::now());
}
/// 回灌一次在指定单调时刻真实发送的上游 attempt。
#[doc(hidden)]
pub fn report_at(
&self,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
feedback: AccountAttemptFeedback,
observed_at: Instant,
) {
let key = AccountFeedbackKey {
provider_kind: provider_kind.clone(),
account_id: account_id.clone(),
};
if let Some(account) = self
.accounts
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&key)
{
account.report_at(feedback, observed_at);
return;
}
self.accounts
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.entry(key)
.or_default()
.report_at(feedback, observed_at);
}
}
fn load_feedback_ewma(value: &AtomicU64) -> Option<f64> {
let value = f64::from_bits(value.load(Ordering::Relaxed));
(!value.is_nan()).then_some(value)
}
fn update_feedback_ewma(target: &AtomicU64, sample: f64) {
let mut current = target.load(Ordering::Relaxed);
loop {
let previous = f64::from_bits(current);
let next = if previous.is_nan() {
sample
} else {
ACCOUNT_FEEDBACK_EWMA_ALPHA * sample + (1.0 - ACCOUNT_FEEDBACK_EWMA_ALPHA) * previous
};
match target.compare_exchange_weak(
current,
next.to_bits(),
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return,
Err(observed) => current = observed,
}
}
}
impl AccountRuntimeSignals {
#[must_use]
pub fn with_provider_quota(mut self, quota: Option<AccountQuotaSignals>) -> Self {
if let Some(quota) = quota {
self.quota_reset_at = quota.reset_at;
self.quota_remaining_rank = quota.remaining_rank;
}
self
}
#[must_use]
pub const fn with_rate_limit(mut self, rate_limited_until: Option<SystemTime>) -> Self {
self.rate_limited_until = rate_limited_until;
self
}
#[must_use]
pub const fn with_runtime_health(
mut self,
failure_rate_basis_points: Option<u16>,
first_output_latency_ms: Option<u64>,
) -> Self {
self.failure_rate_basis_points = failure_rate_basis_points;
self.first_output_latency_ms = first_output_latency_ms;
self
}
}
/// Provider 从私有 quota JSON 投影出的中立调度事实。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountQuotaSignals {
reset_at: Option<SystemTime>,
remaining_rank: Option<u64>,
}
impl AccountQuotaSignals {
#[must_use]
pub const fn new(reset_at: Option<SystemTime>, remaining_rank: Option<u64>) -> Self {
Self {
reset_at,
remaining_rank,
}
}
#[must_use]
pub const fn reset_at(self) -> Option<SystemTime> {
self.reset_at
}
#[must_use]
pub const fn remaining_rank(self) -> Option<u64> {
self.remaining_rank
}
}
/// 账号持久事实与可重建运行信号的请求级组合。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountCandidate {
pub account: ProviderAccount,
pub signals: AccountRuntimeSignals,
}
/// 一次账号选择看到的可调度并发槽快照。
///
/// `used_slots` 包含刚刚获取成功的当前请求;快照只覆盖本次请求范围内、
/// 模型可用且未被显式排除的账号池。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AccountCapacitySnapshot {
used_slots: u64,
total_slots: u64,
}
impl AccountCapacitySnapshot {
#[must_use]
pub const fn used_slots(self) -> u64 {
self.used_slots
}
#[must_use]
pub const fn total_slots(self) -> u64 {
self.total_slots
}
#[must_use]
pub fn with_acquired_request(self) -> Self {
Self {
used_slots: self.used_slots.saturating_add(1).min(self.total_slots),
total_slots: self.total_slots,
}
}
}
/// 一次账号选择使用的全局策略快照。
#[derive(Debug, Clone)]
pub struct AccountSelectionContext {
pub policy: AccountSelectionPolicy,
pub now: SystemTime,
pub excluded_accounts: BTreeSet<ProviderAccountId>,
pub preferred_account: Option<ProviderAccountId>,
pub round_robin_cursor: u64,
pub eligibility: AccountEligibilityPolicy,
pub account_scope: Option<std::sync::Arc<crate::routing::FrozenAccountScope>>,
}
/// 选择账号时是否执行本地调度资格投影。
///
/// 管理端对指定账号执行诊断时,会直接向上游确认实际状态;该模式跳过
/// `enabled`、可用性、冷却和 token 到期等本地投影,仍保留租约、并发和
/// 请求间隔约束,且只能与固定账号约束组合使用。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AccountEligibilityPolicy {
#[default]
Enforce,
BypassForDiagnostic,
}
impl AccountEligibilityPolicy {
#[must_use]
pub const fn bypasses_local_eligibility(self) -> bool {
matches!(self, Self::BypassForDiagnostic)
}
}
/// 候选账号未进入调度池的约束。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountSchedulingBlocker {
OutsideClientScope,
LocalAvailability,
Excluded,
ConcurrencyLimit,
RequestInterval,
LowerWeight,
}
/// 优先账号在本次选择中的处理结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreferredAccountSelection {
NotRequested,
Hit,
Missing,
Blocked(AccountSchedulingBlocker),
}
/// 一次完整账号选择;同时保留优先账号决策,供 Provider 记录调度遥测。
#[derive(Debug, Clone, Copy)]
pub struct AccountSelection<'a> {
candidate: &'a AccountCandidate,
preferred: PreferredAccountSelection,
}
impl<'a> AccountSelection<'a> {
#[must_use]
pub const fn candidate(self) -> &'a AccountCandidate {
self.candidate
}
#[must_use]
pub const fn preferred(self) -> PreferredAccountSelection {
self.preferred
}
}
/// 同一 target 内唯一的账号排序器。
#[derive(Debug, Default, Clone, Copy)]
pub struct AccountSelector;
impl AccountSelector {
/// 汇总与本次调度约束一致的并发容量,供请求级观测使用。
#[must_use]
pub fn capacity_snapshot(
&self,
candidates: &[AccountCandidate],
context: &AccountSelectionContext,
) -> Option<AccountCapacitySnapshot> {
let (used_slots, total_slots) = candidates
.iter()
.filter(|candidate| {
!matches!(
self.scheduling_blocker(candidate, context),
Some(
AccountSchedulingBlocker::OutsideClientScope
| AccountSchedulingBlocker::LocalAvailability
| AccountSchedulingBlocker::Excluded
)
)
})
.fold((0_u64, 0_u64), |(used, total), candidate| {
let capacity = u64::from(
candidate
.account
.effective_concurrency(context.policy.max_concurrent_per_account())
.get(),
);
(
used.saturating_add(u64::from(candidate.signals.in_flight)),
total.saturating_add(capacity),
)
});
(total_slots > 0).then_some(AccountCapacitySnapshot {
used_slots: used_slots.min(total_slots),
total_slots,
})
}
/// 从可调度账号中确定一个候选;这里只消费 Provider 已解析的额度投影。
#[must_use]
pub fn select<'a>(
&self,
candidates: &'a [AccountCandidate],
context: &AccountSelectionContext,
) -> Option<AccountSelection<'a>> {
let mut eligible = candidates
.iter()
.filter(|candidate| self.scheduling_blocker(candidate, context).is_none())
.collect::<Vec<_>>();
if eligible.is_empty() {
return None;
}
let highest_weight = eligible
.iter()
.map(|candidate| candidate.account.weight())
.max()?;
let preferred = if let Some(preferred) = context.preferred_account.as_ref() {
match candidates
.iter()
.find(|candidate| candidate.account.id() == preferred)
{
Some(candidate) => match self.scheduling_blocker(candidate, context) {
Some(blocker) => PreferredAccountSelection::Blocked(blocker),
None if candidate.account.weight() < highest_weight => {
PreferredAccountSelection::Blocked(AccountSchedulingBlocker::LowerWeight)
}
None => {
return Some(AccountSelection {
candidate,
preferred: PreferredAccountSelection::Hit,
});
}
},
None => PreferredAccountSelection::Missing,
}
} else {
PreferredAccountSelection::NotRequested
};
eligible.retain(|candidate| candidate.account.weight() == highest_weight);
let candidate = match context.policy.strategy() {
RotationStrategy::QuotaResetPriority => {
let default_concurrency = context.policy.max_concurrent_per_account();
eligible.sort_by(|left, right| {
(
left.signals.quota_reset_at.is_none(),
left.signals.quota_reset_at,
)
.cmp(&(
right.signals.quota_reset_at.is_none(),
right.signals.quota_reset_at,
))
.then_with(|| {
capacity_utilization(left, default_concurrency)
.total_cmp(&capacity_utilization(right, default_concurrency))
})
.then_with(|| {
left.signals
.last_started_at
.cmp(&right.signals.last_started_at)
})
.then_with(|| left.account.id().cmp(right.account.id()))
});
eligible.first().copied()?
}
RotationStrategy::RoundRobin => {
eligible.sort_by_key(|candidate| candidate.account.id().clone());
let index = context.round_robin_cursor as usize % eligible.len();
eligible.get(index).copied()?
}
RotationStrategy::Smart => select_smart_candidate(
&eligible,
context.policy.max_concurrent_per_account(),
context.round_robin_cursor,
)?,
RotationStrategy::Sticky => {
eligible.sort_by_key(|candidate| {
(
Reverse(candidate.signals.last_started_at),
candidate.account.id().clone(),
)
});
eligible.first().copied()?
}
};
Some(AccountSelection {
candidate,
preferred,
})
}
fn scheduling_blocker(
&self,
candidate: &AccountCandidate,
context: &AccountSelectionContext,
) -> Option<AccountSchedulingBlocker> {
if context
.account_scope
.as_ref()
.is_some_and(|scope| !scope.allows(candidate.account.id()))
{
return Some(AccountSchedulingBlocker::OutsideClientScope);
}
if !context.eligibility.bypasses_local_eligibility() {
let status = candidate
.account
.status_projection(context.now, candidate.signals.rate_limited_until)
.status;
if status != AccountStatus::Normal {
return Some(AccountSchedulingBlocker::LocalAvailability);
}
}
if context.excluded_accounts.contains(candidate.account.id()) {
return Some(AccountSchedulingBlocker::Excluded);
}
if candidate.signals.in_flight
>= candidate
.account
.effective_concurrency(context.policy.max_concurrent_per_account())
.get()
{
return Some(AccountSchedulingBlocker::ConcurrencyLimit);
}
if candidate
.signals
.last_started_at
.is_some_and(|last_started| {
!context
.now
.duration_since(last_started)
.is_ok_and(|elapsed| elapsed >= context.policy.request_interval())
})
{
return Some(AccountSchedulingBlocker::RequestInterval);
}
None
}
}
const SMART_LOAD_WEIGHT: f64 = 1.0;
const SMART_QUOTA_WEIGHT: f64 = 0.8;
const SMART_FAILURE_WEIGHT: f64 = 1.0;
const SMART_LATENCY_WEIGHT: f64 = 0.5;
fn capacity_utilization(candidate: &AccountCandidate, default_concurrency: NonZeroU32) -> f64 {
f64::from(candidate.signals.in_flight)
/ f64::from(
candidate
.account
.effective_concurrency(default_concurrency)
.get(),
)
}
struct SmartNormalization {
max_capacity_utilization: f64,
min_quota: Option<u64>,
max_quota: Option<u64>,
min_latency_ms: Option<u64>,
max_latency_ms: Option<u64>,
}
impl SmartNormalization {
fn from_candidates(candidates: &[&AccountCandidate], default_concurrency: NonZeroU32) -> Self {
let max_capacity_utilization = candidates
.iter()
.map(|candidate| capacity_utilization(candidate, default_concurrency))
.reduce(f64::max)
.unwrap_or_default();
let min_quota = candidates
.iter()
.filter_map(|candidate| candidate.signals.quota_remaining_rank)
.min();
let max_quota = candidates
.iter()
.filter_map(|candidate| candidate.signals.quota_remaining_rank)
.max();
let min_latency_ms = candidates
.iter()
.filter_map(|candidate| candidate.signals.first_output_latency_ms)
.filter(|latency| *latency > 0)
.min();
let max_latency_ms = candidates
.iter()
.filter_map(|candidate| candidate.signals.first_output_latency_ms)
.filter(|latency| *latency > 0)
.max();
Self {
max_capacity_utilization,
min_quota,
max_quota,
min_latency_ms,
max_latency_ms,
}
}
}
fn select_smart_candidate<'a>(
candidates: &[&'a AccountCandidate],
default_concurrency: NonZeroU32,
cursor: u64,
) -> Option<&'a AccountCandidate> {
let normalization = SmartNormalization::from_candidates(candidates, default_concurrency);
let mut ranked = candidates
.iter()
.map(|candidate| {
(
*candidate,
smart_score(candidate, default_concurrency, &normalization),
)
})
.collect::<Vec<_>>();
ranked.sort_by(|(left, left_score), (right, right_score)| {
right_score
.total_cmp(left_score)
.then_with(|| left.account.id().cmp(right.account.id()))
});
let best_score = ranked.first()?.1.to_bits();
let tied = ranked
.iter()
.take_while(|(_, score)| score.to_bits() == best_score)
.count();
let index = cursor as usize % tied;
Some(ranked[index].0)
}
fn smart_score(
candidate: &AccountCandidate,
default_concurrency: NonZeroU32,
normalization: &SmartNormalization,
) -> f64 {
let load = lower_ratio_is_better(
capacity_utilization(candidate, default_concurrency),
normalization.max_capacity_utilization,
);
let quota = candidate.signals.quota_remaining_rank.map_or(0.5, |quota| {
higher_is_better(
quota,
normalization.min_quota.unwrap_or(quota),
normalization.max_quota.unwrap_or(quota),
)
});
let failure = 1.0
- f64::from(
candidate
.signals
.failure_rate_basis_points
.unwrap_or_default()
.min(10_000),
) / 10_000.0;
let latency = candidate
.signals
.first_output_latency_ms
.filter(|latency| *latency > 0)
.map_or(1.0, |latency| {
lower_is_better(
latency,
normalization.min_latency_ms.unwrap_or(latency),
normalization.max_latency_ms.unwrap_or(latency),
)
});
SMART_LOAD_WEIGHT * load
+ SMART_QUOTA_WEIGHT * quota
+ SMART_FAILURE_WEIGHT * failure
+ SMART_LATENCY_WEIGHT * latency
}
fn lower_ratio_is_better(value: f64, maximum: f64) -> f64 {
if maximum <= 0.0 {
return 1.0;
}
1.0 - (value / maximum).clamp(0.0, 1.0)
}
fn lower_is_better(value: u64, minimum: u64, maximum: u64) -> f64 {
if maximum <= minimum {
return 1.0;
}
1.0 - (value.saturating_sub(minimum) as f64 / (maximum - minimum) as f64).clamp(0.0, 1.0)
}
fn higher_is_better(value: u64, minimum: u64, maximum: u64) -> f64 {
if maximum <= minimum {
return 1.0;
}
(value.saturating_sub(minimum) as f64 / (maximum - minimum) as f64).clamp(0.0, 1.0)
}
@@ -0,0 +1,89 @@
//! Provider 账号持久化端口。
use async_trait::async_trait;
use crate::error::StoreError;
use crate::routing::ProviderKind;
use super::{
AccountStateChange, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision,
LoadedCredential, NewProviderAccount, ProviderAccount, ProviderAccountId,
ProviderAccountUpdate, ProviderRefreshQuery, QuotaAccessChange, QuotaObservation,
QuotaObservationTouch, QuotaWriteOutcome,
};
/// `provider_accounts` 的数据库中立端口。
#[async_trait]
pub trait ProviderAccountStore: Send + Sync {
async fn create_account(&self, account: NewProviderAccount) -> Result<(), StoreError>;
async fn get_account(
&self,
account: &ProviderAccountId,
) -> Result<Option<ProviderAccount>, StoreError>;
async fn list_accounts(&self) -> Result<Vec<ProviderAccount>, StoreError>;
async fn list_for_provider(
&self,
provider: &ProviderKind,
) -> Result<Vec<ProviderAccount>, StoreError>;
/// 一次有界查询返回账号事实和 revision-fenced 明文 credential。
async fn list_refresh_candidates(
&self,
query: ProviderRefreshQuery,
) -> Result<Vec<LoadedCredential>, StoreError>;
async fn load_credential(
&self,
account: &ProviderAccountId,
expected_revision: CredentialRevision,
) -> Result<LoadedCredential, StoreError>;
/// 读取账号当前 credential 及其 revision,不做任何版本比对。
///
/// 管理写入必须在临近 CAS 时用它取 fence,而不是携带调用方持有的旧 revision:
/// 后台刷新随时会推进 revision,用陈旧快照会把正常的恢复操作误判为冲突。
async fn load_current_credential(
&self,
account: &ProviderAccountId,
) -> Result<LoadedCredential, StoreError>;
async fn compare_and_swap_credential(
&self,
update: CredentialCasUpdate,
) -> Result<CredentialCasOutcome, StoreError>;
async fn get_quotas(
&self,
accounts: &[ProviderAccountId],
) -> Result<Vec<QuotaObservation>, StoreError>;
async fn compare_and_swap_quota(
&self,
observation: QuotaObservation,
) -> Result<QuotaWriteOutcome, StoreError>;
async fn touch_quota_observation(
&self,
touch: QuotaObservationTouch,
) -> Result<QuotaWriteOutcome, StoreError>;
async fn apply_quota_access(
&self,
change: QuotaAccessChange,
) -> Result<QuotaWriteOutcome, StoreError>;
async fn apply_state_change(&self, change: AccountStateChange) -> Result<(), StoreError>;
async fn update_account(&self, update: ProviderAccountUpdate) -> Result<(), StoreError>;
async fn set_enabled(
&self,
account: &ProviderAccountId,
enabled: bool,
) -> Result<(), StoreError>;
async fn delete_account(&self, account: &ProviderAccountId) -> Result<(), StoreError>;
}
@@ -7,7 +7,7 @@ use std::fmt;
use futures::future::BoxFuture;
use crate::engine::credential::ProviderAccountId;
use crate::account::ProviderAccountId;
use crate::operation::ProviderSessionState;
use crate::policy::ClientApiKeyId;
use crate::routing::ProviderKind;
@@ -6,22 +6,26 @@ use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use crate::accounting::{CostEstimate, CostSource, Usage};
use crate::engine::continuation::{
ContinuationBinding, NativeContinuationPin, NativeContinuationScope, PreviousResponseId,
};
use crate::engine::provider::{Provider, ProviderCallMetadata, ProviderRequest, ProviderStream};
use crate::engine::{
AccountAttemptContext, AttemptContext, AttemptRecord, AttemptTrigger, CancellationToken,
AccountAttemptContext, AttemptContext, AttemptRecord, AttemptTransport, AttemptTrigger,
CommitRequirement, ContinuationAttempt, CoordinatedEvent, EngineError, ExecutionOutcome,
ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFinalization, ModelRequestId,
ModelRequestTimings, NewModelRequest, ProviderAccountStateOwner, ProviderAttemptOutcome,
RequestAttemptContext, UpstreamSendState,
ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFailureObservation,
ModelRequestFinalization, ModelRequestId, ModelRequestTimings, NewModelRequest,
ProviderAccountStateOwner, ProviderAttemptOutcome, RequestAttemptContext, UpstreamSendState,
};
use crate::error::{
ContinuationRecoveryDisposition, GatewayError, GatewayErrorKind, ProviderError,
ProviderErrorKind, StoreError,
};
use crate::error::{GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind, StoreError};
use crate::event::{
GatewayEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseObservation,
};
use crate::lifecycle::CancellationToken;
use crate::metering::{CostEstimate, CostSource, Usage};
use crate::operation::{Operation, ProviderSessionState};
use crate::routing::RoutingPlan;
use futures::future::Fuse;
@@ -35,12 +39,12 @@ pub struct AttemptCoordinator<S: ?Sized> {
#[derive(Debug, Clone)]
enum AccountSelection {
Scheduled(Option<crate::engine::credential::ProviderAccountId>),
Diagnostic(crate::engine::credential::ProviderAccountId),
Scheduled(Option<crate::account::ProviderAccountId>),
Diagnostic(crate::account::ProviderAccountId),
}
impl AccountSelection {
fn required_account(&self) -> Option<&crate::engine::credential::ProviderAccountId> {
fn required_account(&self) -> Option<&crate::account::ProviderAccountId> {
match self {
Self::Scheduled(account) => account.as_ref(),
Self::Diagnostic(account) => Some(account),
@@ -73,7 +77,7 @@ where
request: NewModelRequest,
operation: Operation,
plan: RoutingPlan,
required_account: Option<crate::engine::credential::ProviderAccountId>,
required_account: Option<crate::account::ProviderAccountId>,
continuation: Option<ContinuationBinding>,
cancellation: CancellationToken,
) -> Result<ResponseExecutionSession<S>, EngineError> {
@@ -96,7 +100,7 @@ where
request: NewModelRequest,
operation: Operation,
plan: RoutingPlan,
required_account: crate::engine::credential::ProviderAccountId,
required_account: crate::account::ProviderAccountId,
continuation: Option<ContinuationBinding>,
cancellation: CancellationToken,
) -> Result<ResponseExecutionSession<S>, EngineError> {
@@ -162,10 +166,12 @@ where
account_state_owner,
cancellation,
attempts: 0,
routing_attempts: 0,
candidate_index,
excluded_accounts: BTreeSet::new(),
credential_recovery_attempted_accounts: BTreeSet::new(),
recovery_account: None,
transport_recovery: None,
current: None,
send_state_watermark: UpstreamSendState::NotSent,
downstream_committed_at: None,
@@ -213,7 +219,16 @@ struct FailureFinalization {
error: GatewayError,
upstream_status_code: Option<u16>,
provider_error_code: Option<String>,
raw_upstream_error: Option<String>,
retry_after_ms: Option<u64>,
observation: ModelRequestFailureObservation,
}
#[derive(Debug, Clone)]
struct PendingTransportRecovery {
account: crate::account::ProviderAccountId,
transport: AttemptTransport,
delay: Duration,
}
/// API 可逐事件消费的 Core 执行会话。
@@ -237,14 +252,20 @@ pub struct ResponseExecutionSession<S: ?Sized> {
continuation_attempt: ContinuationAttempt,
account_state_owner: Option<ProviderAccountStateOwner>,
cancellation: CancellationToken,
/// 所有实际上游 attempt 数;包含同账号传输重试,作为持久化序号。
attempts: u32,
/// 路由预算只统计正常选号/账号恢复,不被 Provider-owned 传输预算消耗。
routing_attempts: u32,
candidate_index: usize,
excluded_accounts: BTreeSet<crate::engine::credential::ProviderAccountId>,
credential_recovery_attempted_accounts: BTreeSet<crate::engine::credential::ProviderAccountId>,
excluded_accounts: BTreeSet<crate::account::ProviderAccountId>,
credential_recovery_attempted_accounts: BTreeSet<crate::account::ProviderAccountId>,
/// 凭据恢复后的一次性同账号钉选;只约束紧随其后的 replay attempt,
/// attempt 建立时即被消费,后续可重试错误仍可换号消耗剩余重试预算。
/// 与 `required_account`(外部指定、贯穿整个请求)语义不同,不可合并。
recovery_account: Option<crate::engine::credential::ProviderAccountId>,
recovery_account: Option<crate::account::ProviderAccountId>,
/// 上游传输在提交边界前失败后的一次性同账号重试或备用传输钉选。
/// 与凭据恢复分开保存,避免把 transport recovery 误记为 OAuth 恢复。
transport_recovery: Option<PendingTransportRecovery>,
current: Option<CurrentAttempt>,
/// 请求级发送状态水位;跨 attempt 单调不降,终态写回不得低于此档。
send_state_watermark: UpstreamSendState,
@@ -586,9 +607,25 @@ where
}
async fn prepare_attempt(&mut self) -> Result<Option<PullOutcome>, EngineError> {
if self.attempts >= self.plan.max_attempts().get() {
let transport_recovery = self.transport_recovery.take();
if transport_recovery.is_none() && self.routing_attempts >= self.plan.max_attempts().get() {
return Err(EngineError::EmptyRoutingPlan);
}
if let Some(recovery) = transport_recovery.as_ref()
&& !recovery.delay.is_zero()
{
match poll_retry_delay(recovery.delay, self.cancellation.clone(), self.deadline).await {
RetryDelayBoundary::Elapsed => {}
RetryDelayBoundary::Cancelled => {
self.finish_interruption(EngineError::Cancelled).await?;
return Err(EngineError::Cancelled);
}
RetryDelayBoundary::Deadline => {
self.finish_interruption(EngineError::Deadline).await?;
return Err(EngineError::Deadline);
}
}
}
let Some(candidate) = self.plan.candidates().get(self.candidate_index).cloned() else {
let error = GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
@@ -600,7 +637,9 @@ where
error,
upstream_status_code: None,
provider_error_code: None,
raw_upstream_error: None,
retry_after_ms: None,
observation: ModelRequestFailureObservation::default(),
})
.await?;
return Err(EngineError::EmptyRoutingPlan);
@@ -610,14 +649,23 @@ where
.checked_add(1)
.and_then(NonZeroU32::new)
.ok_or(EngineError::EmptyRoutingPlan)?;
// recovery 钉选在此被一次性消费,只绑定本次 replay attempt;
// 请求局部恢复钉选在此被一次性消费,只绑定本次 replay attempt;
// 外部 required_account 每次 attempt 都重新生效。
let pinned_account = match &self.account_selection {
AccountSelection::Diagnostic(account) => Some(account.clone()),
AccountSelection::Scheduled(_) => self
.recovery_account
.take()
.or_else(|| self.account_selection.required_account().cloned()),
let is_transport_recovery = transport_recovery.is_some();
let (pinned_account, attempt_transport) = if let Some(recovery) = transport_recovery {
(Some(recovery.account), recovery.transport)
} else {
match &self.account_selection {
AccountSelection::Diagnostic(account) => {
(Some(account.clone()), AttemptTransport::Default)
}
AccountSelection::Scheduled(_) => (
self.recovery_account
.take()
.or_else(|| self.account_selection.required_account().cloned()),
AttemptTransport::Default,
),
}
};
let account_context = match &self.account_selection {
AccountSelection::Diagnostic(account) => AccountAttemptContext::diagnostic(
@@ -646,7 +694,8 @@ where
self.continuation.clone(),
self.cancellation.clone(),
)
.with_continuation_attempt(self.continuation_attempt);
.with_continuation_attempt(self.continuation_attempt)
.with_transport(attempt_transport);
let trigger = if self.attempts == 0 {
AttemptTrigger::Initial
} else {
@@ -746,7 +795,9 @@ where
error,
upstream_status_code: None,
provider_error_code: None,
raw_upstream_error: None,
retry_after_ms: None,
observation: ModelRequestFailureObservation::default(),
})
.await?;
return Err(EngineError::ProviderMetadataMismatch);
@@ -770,7 +821,9 @@ where
error,
upstream_status_code: None,
provider_error_code: None,
raw_upstream_error: None,
retry_after_ms: None,
observation: ModelRequestFailureObservation::default(),
})
.await?;
return Err(EngineError::AccountOutsideClientScope);
@@ -789,7 +842,9 @@ where
error,
upstream_status_code: None,
provider_error_code: None,
raw_upstream_error: None,
retry_after_ms: None,
observation: ModelRequestFailureObservation::default(),
})
.await?;
return Err(EngineError::RequiredAccountMismatch);
@@ -811,7 +866,9 @@ where
error,
upstream_status_code: None,
provider_error_code: None,
raw_upstream_error: None,
retry_after_ms: None,
observation: ModelRequestFailureObservation::default(),
})
.await?;
return Err(EngineError::ContinuationPinMismatch);
@@ -864,6 +921,9 @@ where
}
}
self.attempts = next_attempt.get();
if !is_transport_recovery {
self.routing_attempts = self.routing_attempts.saturating_add(1);
}
self.current = Some(CurrentAttempt {
stream,
metadata,
@@ -1017,30 +1077,56 @@ where
attempt_send_state,
provider_proved_replay_safe,
);
let pre_delivery_retry = self.account_selection.required_account().is_none()
let account_rotation_retry = self.account_selection.required_account().is_none()
&& self.continuation_attempt == ContinuationAttempt::None
&& self.downstream_committed_at.is_none()
&& !self.delivery_pending
&& error.allows_pre_delivery_retry()
&& self.attempts < self.plan.max_attempts().get();
&& attempt_send_state != UpstreamSendState::Ambiguous
&& matches!(
error.pre_delivery_retry(),
Some(crate::error::PreDeliveryRetry::AccountRotation)
)
&& self.routing_attempts < self.plan.max_attempts().get();
let transport_recovery = match error.pre_delivery_retry() {
Some(crate::error::PreDeliveryRetry::SameAccountTransportRetry {
retry_index,
delay,
}) if self.downstream_committed_at.is_none()
&& !self.delivery_pending
&& attempt_send_state != UpstreamSendState::Ambiguous =>
{
Some((AttemptTransport::Retry(retry_index), delay))
}
Some(crate::error::PreDeliveryRetry::SameAccountTransportFallback)
if self.downstream_committed_at.is_none()
&& !self.delivery_pending
&& attempt_send_state != UpstreamSendState::Ambiguous =>
{
Some((AttemptTransport::Fallback, Duration::ZERO))
}
_ => None,
};
let ordinary_retry = self.account_selection.required_account().is_none()
&& self.continuation_attempt == ContinuationAttempt::None
&& self.downstream_committed_at.is_none()
&& !self.delivery_pending
&& attempt_send_state != UpstreamSendState::Ambiguous
&& provider_proved_replay_safe
&& self.attempts < self.plan.max_attempts().get();
&& self.routing_attempts < self.plan.max_attempts().get();
let same_account_retry = error.retries_same_account()
&& provider_proved_replay_safe
&& self.downstream_committed_at.is_none()
&& !self.delivery_pending
&& attempt_send_state != UpstreamSendState::Ambiguous
&& self.attempts < self.plan.max_attempts().get()
&& self.routing_attempts < self.plan.max_attempts().get()
&& !self
.credential_recovery_attempted_accounts
.contains(current.metadata.provider_account_id());
let retryable =
continuation_retry || same_account_retry || ordinary_retry || pre_delivery_retry;
let retryable = continuation_retry
|| same_account_retry
|| ordinary_retry
|| account_rotation_retry
|| transport_recovery.is_some();
if retryable {
// 普通 clone 只保留稳定事实;原始 wire/HTTP response 由 request-local
@@ -1053,6 +1139,12 @@ where
// 只钉住紧随其后的 replay attempt;replay 再遇可重试错误时,
// ordinary/continuation 重试门不受影响,仍可换号。
self.recovery_account = Some(account);
} else if let Some((transport, delay)) = transport_recovery {
self.transport_recovery = Some(PendingTransportRecovery {
account: current.metadata.provider_account_id().clone(),
transport,
delay,
});
} else if !continuation_retry {
self.excluded_accounts
.insert(current.metadata.provider_account_id().clone());
@@ -1130,7 +1222,7 @@ where
|| self.delivery_pending
|| send_state == UpstreamSendState::Ambiguous
|| !provider_proved_replay_safe
|| self.attempts >= self.plan.max_attempts().get()
|| self.routing_attempts >= self.plan.max_attempts().get()
|| self
.operation
.provider_session_state(current.metadata.provider().as_str())
@@ -1140,10 +1232,27 @@ where
}
match self.continuation_attempt {
ContinuationAttempt::Native if error.continuation_failure().is_some() => {
self.continuation_attempt = ContinuationAttempt::ReplayOwner;
ContinuationAttempt::Native => match error.continuation_recovery_disposition() {
Some(ContinuationRecoveryDisposition::RetryExactConnection) => {
self.recovery_account = Some(current.metadata.provider_account_id().clone());
}
Some(ContinuationRecoveryDisposition::ProviderReplayAllowed) => {
self.continuation_attempt = ContinuationAttempt::ReplayOwner;
}
Some(ContinuationRecoveryDisposition::ClientReplayRequired) | None => return false,
},
ContinuationAttempt::ReplayOwner | ContinuationAttempt::ReplayAny
if matches!(
error.continuation_recovery_disposition(),
Some(
ContinuationRecoveryDisposition::RetryExactConnection
| ContinuationRecoveryDisposition::ClientReplayRequired
)
) =>
{
return false;
}
ContinuationAttempt::Native | ContinuationAttempt::ReplayOwner => {
ContinuationAttempt::ReplayOwner => {
self.continuation_attempt = ContinuationAttempt::ReplayAny;
self.excluded_accounts
.insert(current.metadata.provider_account_id().clone());
@@ -1264,6 +1373,8 @@ where
provider_metadata_json,
error: None,
provider_error_code: None,
raw_upstream_error: None,
failure_observation: ModelRequestFailureObservation::default(),
retry_after_ms: None,
usage: self.usage.clone(),
image_generation_succeeded: self.image_generation_succeeded(),
@@ -1302,7 +1413,16 @@ where
error: GatewayError::from_provider(error),
upstream_status_code: error.upstream_status(),
provider_error_code: error.upstream_code().map(|code| code.as_str().to_owned()),
raw_upstream_error: error
.raw_upstream_error()
.map(|raw| raw.as_str().to_owned()),
retry_after_ms: error.retry_after().map(duration_ms),
observation: ModelRequestFailureObservation {
continuation_unavailable_reason: error
.continuation_unavailable_reason()
.map(str::to_owned),
upstream_connection: error.connection_observation().cloned(),
},
})
.await
}
@@ -1355,7 +1475,9 @@ where
error: gateway_error,
upstream_status_code: None,
provider_error_code: None,
raw_upstream_error: None,
retry_after_ms: None,
observation: ModelRequestFailureObservation::default(),
})
.await
}
@@ -1423,6 +1545,8 @@ where
provider_metadata_json,
error: Some(finalization.error),
provider_error_code: finalization.provider_error_code,
raw_upstream_error: finalization.raw_upstream_error,
failure_observation: finalization.observation,
retry_after_ms: finalization.retry_after_ms,
usage: self.usage.clone(),
image_generation_succeeded: self.image_generation_succeeded(),
@@ -1611,6 +1735,31 @@ enum ProviderBoundary {
Deadline,
}
enum RetryDelayBoundary {
Elapsed,
Cancelled,
Deadline,
}
async fn poll_retry_delay(
delay: Duration,
cancellation: CancellationToken,
deadline: SystemTime,
) -> RetryDelayBoundary {
let Ok(remaining) = deadline.duration_since(SystemTime::now()) else {
return RetryDelayBoundary::Deadline;
};
let retry_delay = Delay::new(delay).fuse();
let cancelled = cancellation.cancelled().fuse();
let timeout = Delay::new(remaining).fuse();
pin_mut!(retry_delay, cancelled, timeout);
select_biased! {
_ = cancelled => RetryDelayBoundary::Cancelled,
_ = timeout => RetryDelayBoundary::Deadline,
_ = retry_delay => RetryDelayBoundary::Elapsed,
}
}
async fn poll_provider(
provider: Arc<dyn Provider>,
request: ProviderRequest,
@@ -25,20 +25,20 @@ use crate::engine::probe::{
};
use crate::engine::provider::ProviderRegistry;
use crate::engine::{
AttemptCoordinator, AttemptRecord, CancellationToken, CoordinatedEvent, EngineError,
ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFinalization, ModelRequestId,
NewModelRequest, ProbeFailure, ProviderAccountId, ProviderAttemptOutcome, RecoveryReport,
UpstreamSendState,
AttemptCoordinator, AttemptRecord, CoordinatedEvent, EngineError, ExecutionStore,
GatewayEngine, IntermediateFailure, ModelRequestFinalization, ModelRequestId, NewModelRequest,
ProbeFailure, ProviderAccountId, ProviderAttemptOutcome, RecoveryReport, UpstreamSendState,
};
use crate::error::{GatewayError, GatewayErrorKind, ProviderErrorKind, StoreError};
use crate::event::{GatewayEvent, ProviderEvent, ProviderResponseHeader};
use crate::lifecycle::CancellationToken;
use crate::operation::{Operation, ProviderSessionState};
use crate::policy::{ClientApiKeyId, ClientPolicy};
use crate::routing::snapshot::RuntimeSnapshotHandle;
use crate::routing::{
ProviderKind, PublicModelId, PublicModelProfile, RoutingContext, RuntimeSnapshot,
UpstreamModelId,
};
use crate::runtime::RuntimeSnapshotHandle;
const MODEL_REQUEST_DEADLINE: Duration = Duration::from_secs(10 * 60);
const COORDINATION_TIMEOUT: Duration = Duration::from_millis(100);
@@ -505,8 +505,11 @@ impl DefaultExecutionService {
.candidates()
.first()
.map_or_else(Default::default, |candidate| {
self.providers
.request_observation(candidate.provider(), &operation)
self.providers.request_observation(
candidate.provider(),
&operation,
client.policy.key_id(),
)
});
let new_request = NewModelRequest {
id: request_id.clone(),
@@ -526,6 +529,7 @@ impl DefaultExecutionService {
request_kind: observation.request_kind,
subagent_kind: observation.subagent_kind,
compact: observation.compact,
continuation: observation.continuation,
image_generation_requested: operation.image_generation_requested(),
admission_decision_ms: Some(admission_decision_ms),
started_at,
@@ -668,6 +672,7 @@ impl DefaultExecutionService {
request_kind: Some("account_connection_test".to_owned()),
subagent_kind: None,
compact: false,
continuation: Default::default(),
image_generation_requested: false,
admission_decision_ms: None,
started_at,
+60 -102
View File
@@ -3,7 +3,6 @@
pub mod admission;
pub mod continuation;
pub mod coordinator;
pub mod credential;
pub mod execution;
pub mod probe;
pub mod provider;
@@ -14,25 +13,26 @@ use std::collections::BTreeSet;
use std::fmt;
use std::net::IpAddr;
use std::num::NonZeroU32;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use async_trait::async_trait;
use futures::channel::oneshot;
use thiserror::Error;
use crate::accounting::{CostEstimate, Usage};
use crate::account::{AccountSelectionPolicy, ProviderAccountId};
use crate::engine::continuation::{ContinuationBinding, NativeContinuationPin};
use crate::engine::credential::{AccountSelectionPolicy, ProviderAccountId};
use crate::error::{
GatewayError, IdentifierError, ProviderError, ProviderErrorKind, StoreError, validate_text,
GatewayError, IdentifierError, ProviderConnectionObservation, ProviderError, ProviderErrorKind,
StoreError, validate_text,
};
use crate::event::ProviderEvent;
use crate::lifecycle::CancellationToken;
use crate::metering::{CostEstimate, Usage};
use crate::operation::OperationKind;
use crate::operation::ProviderSessionState;
use crate::policy::ClientApiKeyId;
use crate::routing::{ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId};
use crate::upstream::UpstreamSendState;
/// `model_requests.id`。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -57,25 +57,6 @@ impl fmt::Display for ModelRequestId {
}
}
/// 上游是否可能已经收到业务 payload。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UpstreamSendState {
NotSent,
Sent,
Ambiguous,
}
impl UpstreamSendState {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotSent => "not_sent",
Self::Sent => "sent",
Self::Ambiguous => "ambiguous",
}
}
}
/// `model_requests.outcome`。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExecutionOutcome {
@@ -106,6 +87,18 @@ pub enum AttemptTrigger {
AccountRetry,
}
/// Provider 可为当前 attempt 选择的请求局部传输档位。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum AttemptTransport {
/// 使用 Provider 的默认传输策略。
#[default]
Default,
/// 固定账号重试 Provider 的首选传输;序号由 Provider 的独立预算驱动。
Retry(NonZeroU32),
/// 使用 Provider 定义的备用传输。
Fallback,
}
impl AttemptTrigger {
#[must_use]
pub const fn as_str(self) -> &'static str {
@@ -149,83 +142,10 @@ impl ProviderAttemptOutcome {
}
}
struct CancellationState {
cancelled: AtomicBool,
waiters: Mutex<Vec<oneshot::Sender<()>>>,
}
/// 可克隆的请求取消信号。
#[derive(Clone)]
pub struct CancellationToken(Arc<CancellationState>);
impl fmt::Debug for CancellationToken {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CancellationToken")
.field("cancelled", &self.is_cancelled())
.finish()
}
}
impl Default for CancellationToken {
fn default() -> Self {
Self::new()
}
}
impl CancellationToken {
#[must_use]
pub fn new() -> Self {
Self(Arc::new(CancellationState {
cancelled: AtomicBool::new(false),
waiters: Mutex::new(Vec::new()),
}))
}
pub fn cancel(&self) {
if self.0.cancelled.swap(true, Ordering::AcqRel) {
return;
}
let waiters = {
let mut guard = lock_unpoisoned(&self.0.waiters);
std::mem::take(&mut *guard)
};
for waiter in waiters {
let _ = waiter.send(());
}
}
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.0.cancelled.load(Ordering::Acquire)
}
pub async fn cancelled(&self) {
if self.is_cancelled() {
return;
}
let (sender, receiver) = oneshot::channel();
{
let mut waiters = lock_unpoisoned(&self.0.waiters);
if self.is_cancelled() {
return;
}
waiters.push(sender);
}
let _ = receiver.await;
}
}
fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// 单次 attempt 的账号选择与账号绑定状态事实。
///
/// `required_account` 只用于管理端 connection test 等必须命中唯一账号的内部请求;
/// 一旦设置,Core 与 Provider 都不得换号或切换 target。
/// `required_account` 用于管理端 connection test,或请求局部的同账号恢复 attempt;
/// 一旦设置,Provider 在本 attempt 内不得换号或切换 target。
#[derive(Debug, Clone, Default)]
pub struct AccountAttemptContext {
excluded_accounts: BTreeSet<ProviderAccountId>,
@@ -373,6 +293,18 @@ pub enum ContinuationAttempt {
ReplayAny,
}
impl ContinuationAttempt {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Native => "native",
Self::ReplayOwner => "replay_owner",
Self::ReplayAny => "replay_any",
}
}
}
/// Provider 每次执行可见的 request-local context。
#[derive(Debug, Clone)]
pub struct RequestAttemptContext {
@@ -425,6 +357,7 @@ pub struct AttemptContext {
account: AccountAttemptContext,
continuation: Option<ContinuationBinding>,
continuation_attempt: ContinuationAttempt,
transport: AttemptTransport,
cancellation: CancellationToken,
}
@@ -452,6 +385,7 @@ impl AttemptContext {
account,
continuation,
continuation_attempt,
transport: AttemptTransport::Default,
cancellation,
}
}
@@ -466,6 +400,13 @@ impl AttemptContext {
self
}
/// 覆盖本次 attempt 的 Provider 传输档位。
#[must_use]
pub const fn with_transport(mut self, transport: AttemptTransport) -> Self {
self.transport = transport;
self
}
#[must_use]
pub const fn request_id(&self) -> &ModelRequestId {
self.request.request_id()
@@ -503,7 +444,7 @@ impl AttemptContext {
self.account.excluded_accounts()
}
/// 管理端 connection test 等内部请求强制使用的唯一账号。
/// 管理端诊断或请求局部恢复 attempt 强制使用的唯一账号。
#[must_use]
pub const fn required_account(&self) -> Option<&ProviderAccountId> {
self.account.required_account()
@@ -542,6 +483,12 @@ impl AttemptContext {
self.continuation_attempt
}
/// 返回本次 attempt 的 Provider 传输档位。
#[must_use]
pub const fn transport(&self) -> AttemptTransport {
self.transport
}
#[must_use]
pub const fn cancellation(&self) -> &CancellationToken {
&self.cancellation
@@ -568,6 +515,7 @@ pub struct NewModelRequest {
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: bool,
pub continuation: provider::ContinuationRequestObservation,
pub image_generation_requested: bool,
/// Client Key 准入判定的完整耗时;内部探测不经过该阶段。
pub admission_decision_ms: Option<u64>,
@@ -633,6 +581,13 @@ pub struct ModelRequestTimings {
pub latency_ms: Option<u64>,
}
/// 终态失败的 Provider 结构化连接与 continuation 观测。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelRequestFailureObservation {
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection: Option<ProviderConnectionObservation>,
}
/// 单行模型请求的终态写回。
#[derive(Debug)]
pub struct ModelRequestFinalization {
@@ -654,6 +609,9 @@ pub struct ModelRequestFinalization {
pub provider_metadata_json: Option<String>,
pub error: Option<GatewayError>,
pub provider_error_code: Option<String>,
/// Provider 返回的原始错误正文或 WebSocket close/error frame。
pub raw_upstream_error: Option<String>,
pub failure_observation: ModelRequestFailureObservation,
pub retry_after_ms: Option<u64>,
pub usage: Usage,
pub image_generation_succeeded: Option<bool>,
@@ -7,10 +7,7 @@ use futures::future::BoxFuture;
use crate::error::{ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind};
use crate::routing::{ProviderKind, UpstreamModelId};
use crate::{
engine::{UpstreamSendState, credential::ProviderAccountId},
operation::Operation,
};
use crate::{account::ProviderAccountId, operation::Operation, upstream::UpstreamSendState};
#[derive(Debug, Clone, PartialEq)]
pub struct AccountProbeRequest {
@@ -11,46 +11,23 @@ use async_trait::async_trait;
use futures::Stream;
use thiserror::Error;
use crate::engine::credential::{
use crate::account::{
AccountAttemptFeedback, AccountCapacitySnapshot, AccountFeedbackStats, ProviderAccountId,
};
use crate::engine::{AttemptContext, UpstreamSendState};
use crate::error::{
IdentifierError, OpaqueUpstreamValue, ProviderError, ProviderErrorKind, validate_text,
};
use crate::engine::AttemptContext;
use crate::error::{OpaqueUpstreamValue, PreDeliveryRetry, ProviderError, ProviderErrorKind};
use crate::event::{EventSequenceValidator, ProviderEvent};
use crate::operation::Operation;
use crate::policy::ClientApiKeyId;
use crate::routing::{
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderKind, UpstreamModelId,
};
use crate::upstream::{UpstreamSendState, UpstreamTransport};
/// Box 只出现在 Provider Registry 的统一 event envelope 边界。
pub type EventStream =
Pin<Box<dyn Stream<Item = Result<ProviderEvent, ProviderError>> + Send + 'static>>;
/// 上游 transport 注册名称。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct UpstreamTransport(String);
impl UpstreamTransport {
/// 校验 transport 名称。
///
/// # Errors
///
/// 名称无效时返回错误。
pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
let value = value.into();
validate_text(&value, 64, true, None)?;
Ok(Self(value))
}
/// 返回 transport 名称。
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Provider 选定单个 credential 后返回的事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderCallMetadata {
@@ -214,11 +191,16 @@ struct ProviderStreamAccountFeedback {
stats: Arc<AccountFeedbackStats>,
provider_kind: ProviderKind,
account_id: ProviderAccountId,
failure_filter: fn(&ProviderError) -> bool,
started_at: Option<Instant>,
first_output_ms: Option<u64>,
reported: bool,
}
fn score_all_confirmed_failures(_: &ProviderError) -> bool {
true
}
impl ProviderStreamAccountFeedback {
fn start(&mut self) {
self.started_at.get_or_insert_with(Instant::now);
@@ -267,6 +249,15 @@ impl ProviderStreamAccountFeedback {
error.kind(),
ProviderErrorKind::Cancelled | ProviderErrorKind::ProcessTerminated
)
|| matches!(
error.pre_delivery_retry(),
Some(
PreDeliveryRetry::SameAccountTransportRetry { .. }
| PreDeliveryRetry::SameAccountTransportFallback
)
)
|| error.retries_same_account()
|| !(self.failure_filter)(error)
{
return;
}
@@ -302,15 +293,35 @@ impl ProviderStream {
/// 让公共 stream 边界统一回灌账号成功率与首个有效输出延迟。
#[must_use]
pub fn with_account_feedback(mut self, stats: Arc<AccountFeedbackStats>) -> Self {
self.set_account_feedback(stats, score_all_confirmed_failures);
self
}
/// 使用 Provider 定义的闭集判断回灌账号成功率与首个有效输出延迟。
#[must_use]
pub fn with_filtered_account_feedback(
mut self,
stats: Arc<AccountFeedbackStats>,
failure_filter: fn(&ProviderError) -> bool,
) -> Self {
self.set_account_feedback(stats, failure_filter);
self
}
fn set_account_feedback(
&mut self,
stats: Arc<AccountFeedbackStats>,
failure_filter: fn(&ProviderError) -> bool,
) {
self.account_feedback = Some(ProviderStreamAccountFeedback {
stats,
provider_kind: self.metadata.provider().clone(),
account_id: self.metadata.provider_account_id().clone(),
failure_filter,
started_at: None,
first_output_ms: None,
reported: false,
});
self
}
/// 返回调用事实。
@@ -411,6 +422,15 @@ pub struct ProviderRequestObservation {
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: bool,
pub continuation: ContinuationRequestObservation,
}
/// 仅用于恢复事件关联的客户端作用域不透明请求事实。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ContinuationRequestObservation {
pub affinity_hash: Option<String>,
pub previous_response_id_hash: Option<String>,
pub requested: bool,
}
/// Provider 实时目录编译后的单模型能力。
@@ -519,7 +539,11 @@ pub trait Provider: Send + Sync {
fn catalog_generation(&self) -> ProviderCatalogGeneration;
/// 解释 Provider 差异化观测字段;不参与路由和传输。
fn request_observation(&self, _operation: &Operation) -> ProviderRequestObservation {
fn request_observation(
&self,
_operation: &Operation,
_client_api_key_id: &ClientApiKeyId,
) -> ProviderRequestObservation {
ProviderRequestObservation::default()
}
@@ -647,11 +671,12 @@ impl ProviderRegistry {
&self,
provider: &ProviderKind,
operation: &Operation,
client_api_key_id: &ClientApiKeyId,
) -> ProviderRequestObservation {
self.providers
.get(provider)
.map_or_else(ProviderRequestObservation::default, |registered| {
registered.request_observation(operation)
registered.request_observation(operation, client_api_key_id)
})
}
+323 -6
View File
@@ -1,13 +1,14 @@
//! 网关核心使用的稳定错误分类。
use std::fmt;
use std::num::NonZeroU32;
use std::time::Duration;
use bytes::Bytes;
use thiserror::Error;
use crate::engine::UpstreamSendState;
use crate::event::{ProviderEvent, ProviderResponseHeader};
use crate::upstream::UpstreamSendState;
/// 应用层标识不满足约束。
#[derive(Debug, Clone, PartialEq, Eq, Error)]
@@ -119,7 +120,7 @@ pub enum PolicyError {
/// 用量或价格估算不满足事实约束。
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum AccountingError {
pub enum MeteringError {
/// 十进制定点值格式无效或超过 `numeric(20, 10)`。
#[error("decimal value must fit unsigned numeric(20, 10)")]
InvalidDecimal,
@@ -134,6 +135,8 @@ pub enum AccountingError {
pub enum ProviderErrorKind {
/// 请求语义错误。
InvalidRequest,
/// previous-response 续接状态已不可用,需要客户端重建请求链。
ContinuationRecoveryRequired,
/// Provider 不支持所需能力。
Unsupported,
/// Credential 认证失败。
@@ -170,6 +173,7 @@ impl ProviderErrorKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::InvalidRequest => "invalid_request",
Self::ContinuationRecoveryRequired => "continuation_recovery_required",
Self::Unsupported => "unsupported",
Self::Unauthorized => "unauthorized",
Self::PermissionDenied => "permission_denied",
@@ -197,6 +201,36 @@ pub enum ContinuationFailure {
HistoryUnavailable,
}
/// Provider 对 previous-response 失败给出的显式恢复边界。
///
/// Core 不根据失败分类猜测 continuation 是否可迁移;只有 Provider 明确允许时,
/// 才能放宽 owner 范围。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ContinuationRecoveryDisposition {
/// 只能再次取得同账号、同一精确连接,不能改变 continuation scope。
RetryExactConnection,
/// 代理停止内部恢复,由客户端携带完整输入创建新链。
ClientReplayRequired,
/// Provider 已证明可以进入 owner 或其他合法目标的恢复流程。
ProviderReplayAllowed,
}
/// Provider 在下游尚未提交事件时请求的隐藏恢复方式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PreDeliveryRetry {
/// 排除本次账号,按普通调度策略重新选号。
AccountRotation,
/// 固定本次账号,并按 Provider 给出的序号重试当前传输。
SameAccountTransportRetry {
/// Provider-owned 传输重试序号,从 1 开始。
retry_index: NonZeroU32,
/// 发起下一次传输尝试前的退避时长。
delay: Duration,
},
/// 固定本次账号,并要求 Provider 使用备用传输。
SameAccountTransportFallback,
}
/// Adapter 已明确分类为非 bearer 的不透明上游值。
///
/// Core 不解释或校验内容,只通过自定义 [`Debug`] 避免它意外进入日志。协议 adapter
@@ -275,6 +309,106 @@ impl fmt::Debug for ClientVisibleUpstreamError {
}
}
/// Provider 已分类为可安全持久化和展示的诊断摘要。
///
/// Adapter 只能放入不含凭据、cookie、原始请求正文和 opaque response ID 的有界摘要;
/// 原始上游错误返回由 [`RawUpstreamError`] 单独承载。
#[derive(Clone, PartialEq, Eq)]
pub struct ProviderDiagnostic(String);
impl ProviderDiagnostic {
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for ProviderDiagnostic {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ProviderDiagnostic(<redacted-from-Debug>)")
}
}
/// 运维错误详情中按原样展示的上游错误返回。
///
/// 该值只接收响应方向的错误正文或 WebSocket close/error frame;不得放入请求
/// Authorization、Cookie 或客户端输入。它会被持久化并由管理端显式返回,但普通
/// `Debug`/`Display` 不输出正文,避免在非运维日志中重复扩散。
#[derive(Clone, PartialEq, Eq)]
pub struct RawUpstreamError(String);
impl RawUpstreamError {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for RawUpstreamError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("RawUpstreamError(<present>)")
}
}
/// Provider 上游物理连接结束时的低基数观测。
///
/// 连接标识必须是随机或单向派生值,不得携带凭据、原始会话 ID
/// 或请求正文。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderConnectionObservation {
connection_id: String,
exit_reason: String,
age_ms: u64,
idle_ms: u64,
}
impl ProviderConnectionObservation {
#[must_use]
pub fn new(
connection_id: impl Into<String>,
exit_reason: impl Into<String>,
age_ms: u64,
idle_ms: u64,
) -> Self {
Self {
connection_id: connection_id.into(),
exit_reason: exit_reason.into(),
age_ms,
idle_ms,
}
}
#[must_use]
pub fn connection_id(&self) -> &str {
&self.connection_id
}
#[must_use]
pub fn exit_reason(&self) -> &str {
&self.exit_reason
}
#[must_use]
pub const fn age_ms(&self) -> u64 {
self.age_ms
}
#[must_use]
pub const fn idle_ms(&self) -> u64 {
self.idle_ms
}
}
/// 只供当前客户端请求使用的原始上游 HTTP 失败响应。
///
/// 该值不属于稳定诊断事实,不能进入日志或持久化。它刻意不实现 [`Clone`];
@@ -359,11 +493,15 @@ pub struct ProviderError {
upstream_values: Option<Box<ProviderErrorUpstreamValues>>,
retry_after: Option<Duration>,
continuation_failure: Option<ContinuationFailure>,
continuation_recovery_disposition: Option<ContinuationRecoveryDisposition>,
failure_observation: Option<Box<ProviderErrorFailureObservation>>,
replay_safe: bool,
pre_delivery_retry: bool,
pre_delivery_retry: Option<PreDeliveryRetry>,
credential_recovery_required: bool,
retry_same_account: bool,
sensitive_context_redacted: bool,
diagnostic: Option<Box<ProviderDiagnostic>>,
raw_upstream_error: Option<Box<RawUpstreamError>>,
client_visible_upstream_error: Option<Box<ClientVisibleUpstreamError>>,
client_visible_upstream_response: Option<Box<ClientVisibleUpstreamResponse>>,
atomic_client_events: Option<Box<AtomicClientEvents>>,
@@ -375,6 +513,12 @@ struct ProviderErrorUpstreamValues {
request_id: Option<OpaqueUpstreamValue>,
}
#[derive(Clone, Default)]
struct ProviderErrorFailureObservation {
continuation_unavailable_reason: Option<&'static str>,
connection: Option<ProviderConnectionObservation>,
}
#[derive(Clone)]
struct AtomicClientEvents(Vec<ProviderEvent>);
@@ -389,11 +533,15 @@ impl ProviderError {
upstream_values: None,
retry_after: None,
continuation_failure: None,
continuation_recovery_disposition: None,
failure_observation: None,
replay_safe: false,
pre_delivery_retry: false,
pre_delivery_retry: None,
credential_recovery_required: false,
retry_same_account: false,
sensitive_context_redacted: false,
diagnostic: None,
raw_upstream_error: None,
client_visible_upstream_error: None,
client_visible_upstream_response: None,
atomic_client_events: None,
@@ -442,6 +590,39 @@ impl ProviderError {
self
}
/// 附加 Provider 明确选择的 previous-response 恢复边界。
#[must_use]
pub const fn with_continuation_recovery_disposition(
mut self,
disposition: ContinuationRecoveryDisposition,
) -> Self {
self.continuation_recovery_disposition = Some(disposition);
self
}
/// 附加 Provider 给出的低基数 continuation 不可用原因。
#[must_use]
pub fn with_continuation_unavailable_reason(mut self, reason: &'static str) -> Self {
self.failure_observation_mut()
.continuation_unavailable_reason = Some(reason);
self
}
/// 附加上游物理连接结束观测。
#[must_use]
pub fn with_connection_observation(
mut self,
observation: ProviderConnectionObservation,
) -> Self {
self.failure_observation_mut().connection = Some(observation);
self
}
fn failure_observation_mut(&mut self) -> &mut ProviderErrorFailureObservation {
self.failure_observation
.get_or_insert_with(|| Box::new(ProviderErrorFailureObservation::default()))
}
/// 标记 Provider 已证明本次拒绝没有执行生成,可在下游提交前重放。
#[must_use]
pub const fn with_replay_safe(mut self) -> Self {
@@ -456,10 +637,49 @@ impl ProviderError {
/// 指定账号或下游已经进入提交状态时必须忽略它。
#[must_use]
pub const fn with_pre_delivery_retry(mut self) -> Self {
self.pre_delivery_retry = true;
self.pre_delivery_retry = Some(PreDeliveryRetry::AccountRotation);
self
}
/// 允许 Core 在首个客户端事件前固定原账号并改用 Provider 备用传输。
///
/// 与普通换号恢复相同,Core 仍会拒绝 continuation、指定账号、预算耗尽或
/// 下游已提交后的隐藏重放。
#[must_use]
pub const fn with_pre_delivery_transport_fallback(mut self) -> Self {
self.set_pre_delivery_transport_fallback();
self
}
/// 原地设置同账号备用传输恢复,保留当前错误携带的原客户端响应。
pub const fn set_pre_delivery_transport_fallback(&mut self) {
self.pre_delivery_retry = Some(PreDeliveryRetry::SameAccountTransportFallback);
}
/// 允许 Core 在首个客户端事件前固定原账号并重试当前 Provider 传输。
///
/// 重试预算和退避策略由 Provider 所属协议定义;Core 只负责同账号钉选、
/// deadline/取消边界以及下游 commit barrier。
#[must_use]
pub const fn with_pre_delivery_transport_retry(
mut self,
retry_index: NonZeroU32,
delay: Duration,
) -> Self {
self.set_pre_delivery_transport_retry(retry_index, delay);
self
}
/// 原地设置同账号当前传输重试,保留当前错误携带的原客户端响应。
pub const fn set_pre_delivery_transport_retry(
&mut self,
retry_index: NonZeroU32,
delay: Duration,
) {
self.pre_delivery_retry =
Some(PreDeliveryRetry::SameAccountTransportRetry { retry_index, delay });
}
/// 要求 Core 在账号凭据已恢复后仅对原账号重放一次。
#[must_use]
pub const fn with_same_account_retry(mut self) -> Self {
@@ -509,6 +729,20 @@ impl ProviderError {
self
}
/// 附加 Adapter 已确认可安全持久化和展示的诊断摘要。
#[must_use]
pub fn with_diagnostic(mut self, diagnostic: ProviderDiagnostic) -> Self {
self.diagnostic = Some(Box::new(diagnostic));
self
}
/// 附加将由运维错误详情原样展示的上游错误返回。
#[must_use]
pub fn with_raw_upstream_error(mut self, error: RawUpstreamError) -> Self {
self.raw_upstream_error = Some(Box::new(error));
self
}
/// 返回稳定错误分类。
#[must_use]
pub const fn kind(&self) -> ProviderErrorKind {
@@ -555,15 +789,44 @@ impl ProviderError {
self.continuation_failure
}
/// 返回 Provider 明确选择的 previous-response 恢复边界。
#[must_use]
pub const fn continuation_recovery_disposition(
&self,
) -> Option<ContinuationRecoveryDisposition> {
self.continuation_recovery_disposition
}
#[must_use]
pub fn continuation_unavailable_reason(&self) -> Option<&'static str> {
match self.failure_observation.as_deref() {
Some(observation) => observation.continuation_unavailable_reason,
None => None,
}
}
#[must_use]
pub fn connection_observation(&self) -> Option<&ProviderConnectionObservation> {
self.failure_observation
.as_deref()
.and_then(|observation| observation.connection.as_ref())
}
/// 返回 Provider 是否已证明本次失败可安全重放。
#[must_use]
pub const fn replay_is_safe(&self) -> bool {
self.replay_safe
}
/// 返回 Provider 是否允许在首个客户端事件前换号恢复传输失败。
/// 返回 Provider 是否允许在首个客户端事件前隐藏恢复传输失败。
#[must_use]
pub const fn allows_pre_delivery_retry(&self) -> bool {
self.pre_delivery_retry.is_some()
}
/// 返回下游提交前由 Provider 选择的隐藏恢复方式。
#[must_use]
pub const fn pre_delivery_retry(&self) -> Option<PreDeliveryRetry> {
self.pre_delivery_retry
}
@@ -584,6 +847,18 @@ impl ProviderError {
self.sensitive_context_redacted
}
/// 返回可安全持久化和展示的 Provider 诊断摘要。
#[must_use]
pub fn diagnostic(&self) -> Option<&ProviderDiagnostic> {
self.diagnostic.as_deref()
}
/// 返回将由运维错误详情原样展示的上游错误返回。
#[must_use]
pub fn raw_upstream_error(&self) -> Option<&RawUpstreamError> {
self.raw_upstream_error.as_deref()
}
/// 返回仅供原客户端协议展示的结构化上游错误。
#[must_use]
pub fn client_visible_upstream_error(&self) -> Option<&ClientVisibleUpstreamError> {
@@ -623,11 +898,15 @@ impl Clone for ProviderError {
upstream_values: self.upstream_values.clone(),
retry_after: self.retry_after,
continuation_failure: self.continuation_failure,
continuation_recovery_disposition: self.continuation_recovery_disposition,
failure_observation: self.failure_observation.clone(),
replay_safe: self.replay_safe,
pre_delivery_retry: self.pre_delivery_retry,
credential_recovery_required: self.credential_recovery_required,
retry_same_account: self.retry_same_account,
sensitive_context_redacted: self.sensitive_context_redacted,
diagnostic: self.diagnostic.clone(),
raw_upstream_error: self.raw_upstream_error.clone(),
client_visible_upstream_error: self.client_visible_upstream_error.clone(),
client_visible_upstream_response: None,
atomic_client_events: None,
@@ -652,6 +931,15 @@ impl fmt::Debug for ProviderError {
)
.field("retry_after", &self.retry_after)
.field("continuation_failure", &self.continuation_failure)
.field(
"continuation_recovery_disposition",
&self.continuation_recovery_disposition,
)
.field(
"continuation_unavailable_reason",
&self.continuation_unavailable_reason(),
)
.field("connection_observation", &self.connection_observation())
.field("replay_safe", &self.replay_safe)
.field("pre_delivery_retry", &self.pre_delivery_retry)
.field(
@@ -663,6 +951,11 @@ impl fmt::Debug for ProviderError {
"sensitive_context",
&self.sensitive_context_redacted.then_some("<redacted>"),
)
.field("diagnostic", &self.diagnostic.as_ref().map(|_| "<present>"))
.field(
"raw_upstream_error",
&self.raw_upstream_error.as_ref().map(|_| "<present>"),
)
.field(
"client_visible_upstream_error",
&self
@@ -756,6 +1049,7 @@ impl GatewayErrorKind {
pub struct GatewayError {
kind: GatewayErrorKind,
message: &'static str,
diagnostic: Option<ProviderDiagnostic>,
client_visible_upstream_error: Option<ClientVisibleUpstreamError>,
}
@@ -766,6 +1060,7 @@ impl GatewayError {
Self {
kind,
message,
diagnostic: None,
client_visible_upstream_error: None,
}
}
@@ -777,6 +1072,10 @@ impl GatewayError {
ProviderErrorKind::InvalidRequest => {
Self::new(GatewayErrorKind::InvalidRequest, "invalid upstream request")
}
ProviderErrorKind::ContinuationRecoveryRequired => Self::new(
GatewayErrorKind::InvalidRequest,
"conversation continuation must be rebuilt",
),
ProviderErrorKind::Unsupported => Self::new(
GatewayErrorKind::Unsupported,
"requested capability is unsupported",
@@ -815,12 +1114,23 @@ impl GatewayError {
"upstream service is unavailable",
),
};
let gateway = match error.diagnostic() {
Some(diagnostic) => gateway.with_diagnostic(diagnostic.clone()),
None => gateway,
};
match error.client_visible_upstream_error() {
Some(upstream) => gateway.with_client_visible_upstream_error(upstream.clone()),
None => gateway,
}
}
/// 附加只供运维观测使用的脱敏 Provider 摘要。
#[must_use]
pub fn with_diagnostic(mut self, diagnostic: ProviderDiagnostic) -> Self {
self.diagnostic = Some(diagnostic);
self
}
/// 附加只供请求方协议展示的结构化上游错误。
#[must_use]
pub fn with_client_visible_upstream_error(mut self, error: ClientVisibleUpstreamError) -> Self {
@@ -840,6 +1150,12 @@ impl GatewayError {
self.message
}
/// 返回可安全持久化和展示的 Provider 诊断摘要。
#[must_use]
pub fn diagnostic(&self) -> Option<&ProviderDiagnostic> {
self.diagnostic.as_ref()
}
/// 返回优先用于原客户端协议响应的 message;持久化和日志必须继续使用
/// [`Self::safe_message`]。
#[must_use]
@@ -872,6 +1188,7 @@ impl fmt::Debug for GatewayError {
.debug_struct("GatewayError")
.field("kind", &self.kind)
.field("message", &self.message)
.field("diagnostic", &self.diagnostic.as_ref().map(|_| "<present>"))
.field(
"client_visible_upstream_error",
&self
+2 -2
View File
@@ -7,10 +7,10 @@ use bytes::Bytes;
use serde_json::Value;
use thiserror::Error;
use crate::accounting::{CalculatedCost, ProviderReportedCost, Usage};
use crate::engine::provider::UpstreamTransport;
use crate::error::{IdentifierError, OpaqueUpstreamValue, validate_text};
use crate::metering::{CalculatedCost, ProviderReportedCost, Usage};
use crate::operation::ProviderSessionState;
use crate::upstream::UpstreamTransport;
/// 一次响应的稳定元数据。
#[derive(Debug, Clone, PartialEq, Eq)]

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